返回 AiToEarn
1 /**
2 * AI 批量生成 - 模型配置常量
3 * 积分与时长等动态数据已由 pricing API 驱动,此处仅保留 API 不提供的静态配置
4 */
5
6 import type { CreditsScope } from '@/api/_shared/credits.types'
7 import type { ImageModelInfo, VideoModelInfo, VideoModelInputConstraint, VideoModelPricing } from '@/api/ai/ai.types'
8
9 /** 根据比例字符串(如 "9:16")计算预览方块的 w/h(缩放到合适的 UI 尺寸) */
10 export function ratioToPreviewSize(label: string): { w: number, h: number } {
11 const [a, b] = label.split(':').map(Number)
12 if (!a || !b)
13 return { w: 14, h: 14 }
14 const maxDim = 18
15 const scale = maxDim / Math.max(a, b)
16 return { w: Math.round(a * scale), h: Math.round(b * scale) }
17 }
18
19 export function getVideoModelCreditsScope(model?: VideoModelInfo): CreditsScope {
20 if (model?.creditsScope === 'seedance')
21 return 'seedance'
22 return 'general'
23 }
24
25 export function isSeedanceCreditsModel(model?: VideoModelInfo): boolean {
26 return getVideoModelCreditsScope(model) === 'seedance'
27 }
28
29 // ==================== 视频模型配置 ====================
30
31 /** 视频模型静态配置 */
32 export interface VideoModelStaticConfig {
33 supportedRatios: Set<string>
34 maxImages: number
35 maxVideos: number
36 maxAudios: number
37 maxVideoDuration: number
38 maxAudioDuration: number
39 imageInputConstraints?: VideoModelInputConstraint
40 videoInputConstraints?: VideoModelInputConstraint
41 audioInputConstraints?: VideoModelInputConstraint
42 }
43
44 /** 允许上传图片的 modes */
45 const IMAGE_UPLOAD_MODES = new Set(['image2video', 'multi-image2video', 'flf2video', 'lf2video', 'multi-ref'])
46 const VIDEO_UPLOAD_MODE = 'video2video'
47 const MIXED_REFERENCE_MODE = 'multi-ref'
48 const DEFAULT_VIDEO_ASPECT_RATIOS = ['1:1', '16:9', '9:16', '4:3', '3:4']
49 const DEFAULT_VIDEO_DURATION_LIMITS = { min: 4, max: 15 }
50
51 function normalizeOptionValue(value: string) {
52 return value.trim()
53 }
54
55 function getOptionCompareKey(value: string) {
56 return normalizeOptionValue(value).replace(/\s+/g, '').toLowerCase()
57 }
58
59 function getUniqueStrings(values: string[]) {
60 const normalizedValues = new Map<string, string>()
61 values.forEach((value) => {
62 const normalizedValue = normalizeOptionValue(value)
63 if (!normalizedValue)
64 return
65 const normalizedKey = getOptionCompareKey(normalizedValue)
66 if (!normalizedValues.has(normalizedKey))
67 normalizedValues.set(normalizedKey, normalizedValue)
68 })
69 return [...normalizedValues.values()]
70 }
71
72 function getCommonStrings(groups: string[][]) {
73 const nonEmptyGroups = groups
74 .map(group => getUniqueStrings(group))
75 .filter(group => group.length > 0)
76 if (nonEmptyGroups.length === 0)
77 return []
78
79 const [firstGroup, ...restGroups] = nonEmptyGroups
80 if (!firstGroup)
81 return []
82
83 return firstGroup.filter(item => restGroups.every(group =>
84 group.some(value => getOptionCompareKey(value) === getOptionCompareKey(item))))
85 }
86
87 export function getVideoModelAspectRatios(model?: VideoModelInfo): string[] {
88 const ratios = getUniqueStrings(model?.aspectRatios?.filter(Boolean) ?? [])
89 return ratios.length > 0 ? ratios : DEFAULT_VIDEO_ASPECT_RATIOS
90 }
91
92 /** 从 API 返回的 VideoModelInfo 动态生成静态配置 */
93 export function getVideoModelConfigFromApi(model: VideoModelInfo): VideoModelStaticConfig {
94 const canUploadMixedReference = model.modes.includes(MIXED_REFERENCE_MODE)
95 const canUploadVideo = model.modes.includes(VIDEO_UPLOAD_MODE) || canUploadMixedReference
96 const videoInputConstraints = model.inputConstraints?.videos
97 const audioInputConstraints = model.inputConstraints?.audios
98 const imageInputConstraints = model.inputConstraints?.images
99 const maxModelDuration = model.durations.length > 0 ? Math.max(...model.durations) : 8
100 return {
101 supportedRatios: new Set(getVideoModelAspectRatios(model)),
102 maxImages: model.modes.some(m => IMAGE_UPLOAD_MODES.has(m)) ? imageInputConstraints?.maxCount ?? model.maxInputImages : 0,
103 maxVideos: canUploadVideo ? videoInputConstraints?.maxCount ?? 1 : 0,
104 maxAudios: canUploadMixedReference ? audioInputConstraints?.maxCount ?? 1 : 0,
105 maxVideoDuration: canUploadVideo
106 ? videoInputConstraints?.maxTotalDuration ?? videoInputConstraints?.maxDuration ?? maxModelDuration
107 : maxModelDuration,
108 maxAudioDuration: canUploadMixedReference
109 ? audioInputConstraints?.maxTotalDuration ?? audioInputConstraints?.maxDuration ?? maxModelDuration
110 : maxModelDuration,
111 imageInputConstraints,
112 videoInputConstraints: canUploadVideo ? videoInputConstraints : undefined,
113 audioInputConstraints: canUploadMixedReference ? audioInputConstraints : undefined,
114 }
115 }
116
117 /** 默认静态配置,API 数据不可用时兜底 */
118 const DEFAULT_STATIC_CONFIG: VideoModelStaticConfig = {
119 supportedRatios: new Set(DEFAULT_VIDEO_ASPECT_RATIOS),
120 maxImages: 1,
121 maxVideos: 0,
122 maxAudios: 0,
123 maxVideoDuration: 8,
124 maxAudioDuration: 8,
125 }
126
127 /** 获取视频模型的静态配置:优先从 API 数据动态生成,兜底用默认值 */
128 export function getVideoModelStaticConfig(modelName: string, videoModels?: VideoModelInfo[]): VideoModelStaticConfig {
129 if (videoModels) {
130 const model = videoModels.find(m => m.name === modelName)
131 if (model)
132 return getVideoModelConfigFromApi(model)
133 }
134 return DEFAULT_STATIC_CONFIG
135 }
136
137 /** 获取视频模型可选分辨率,优先使用模型字段,缺失时从 pricing 兜底 */
138 export function getVideoModelResolutions(model?: VideoModelInfo): string[] {
139 const resolutions = getUniqueStrings(model?.resolutions?.filter(Boolean) ?? [])
140 if (resolutions.length > 0)
141 return resolutions
142
143 return getUniqueStrings(model?.pricing?.map(item => item.resolution).filter((item): item is string => !!item) ?? [])
144 }
145
146 export function getVideoModelsCommonResolutions(models: VideoModelInfo[]): string[] {
147 return getCommonStrings(models.map(model => getVideoModelResolutions(model)))
148 }
149
150 export function getVideoModelsCommonAspectRatios(models: VideoModelInfo[]): string[] {
151 return getCommonStrings(models.map(model => getVideoModelAspectRatios(model)))
152 }
153
154 export function getVideoModelsCommonStaticConfig(models: VideoModelInfo[]): VideoModelStaticConfig {
155 if (models.length === 0)
156 return DEFAULT_STATIC_CONFIG
157
158 const configs = models.map(model => getVideoModelConfigFromApi(model))
159 return {
160 supportedRatios: new Set(getVideoModelsCommonAspectRatios(models)),
161 maxImages: Math.min(...configs.map(config => config.maxImages)),
162 maxVideos: Math.min(...configs.map(config => config.maxVideos)),
163 maxAudios: Math.min(...configs.map(config => config.maxAudios)),
164 maxVideoDuration: Math.min(...configs.map(config => config.maxVideoDuration)),
165 maxAudioDuration: Math.min(...configs.map(config => config.maxAudioDuration)),
166 imageInputConstraints: mergeInputConstraints(configs.map(config => config.imageInputConstraints)),
167 videoInputConstraints: mergeInputConstraints(configs.map(config => config.videoInputConstraints)),
168 audioInputConstraints: mergeInputConstraints(configs.map(config => config.audioInputConstraints)),
169 }
170 }
171
172 function mergeInputConstraints(constraints: Array<VideoModelInputConstraint | undefined>) {
173 const items = constraints.filter((item): item is VideoModelInputConstraint => Boolean(item))
174 if (items.length === 0)
175 return undefined
176 return {
177 maxCount: minOptional(items.map(item => item.maxCount)),
178 formats: intersectFormats(items.map(item => item.formats)),
179 minDuration: maxOptional(items.map(item => item.minDuration)),
180 maxDuration: minOptional(items.map(item => item.maxDuration)),
181 maxTotalDuration: minOptional(items.map(item => item.maxTotalDuration)),
182 maxSizeMb: minOptional(items.map(item => item.maxSizeMb)),
183 minAspectRatio: maxOptional(items.map(item => item.minAspectRatio)),
184 maxAspectRatio: minOptional(items.map(item => item.maxAspectRatio)),
185 minWidth: maxOptional(items.map(item => item.minWidth)),
186 maxWidth: minOptional(items.map(item => item.maxWidth)),
187 minPixels: maxOptional(items.map(item => item.minPixels)),
188 maxPixels: minOptional(items.map(item => item.maxPixels)),
189 minFps: maxOptional(items.map(item => item.minFps)),
190 maxFps: minOptional(items.map(item => item.maxFps)),
191 }
192 }
193
194 function minOptional(values: Array<number | undefined>) {
195 const items = values.filter((value): value is number => value !== undefined)
196 return items.length > 0 ? Math.min(...items) : undefined
197 }
198
199 function maxOptional(values: Array<number | undefined>) {
200 const items = values.filter((value): value is number => value !== undefined)
201 return items.length > 0 ? Math.max(...items) : undefined
202 }
203
204 function intersectFormats(formatGroups: Array<string[] | undefined>) {
205 const groups = formatGroups
206 .filter((formats): formats is string[] => Boolean(formats?.length))
207 .map(formats => formats.map(format => format.trim().toLowerCase()).filter(Boolean))
208 if (groups.length === 0)
209 return undefined
210 const [firstGroup, ...restGroups] = groups
211 return firstGroup?.filter(format => restGroups.every(group => group.includes(format)))
212 }
213
214 /** 获取视频模型默认分辨率 */
215 export function getVideoModelDefaultResolution(model?: VideoModelInfo): string {
216 return model?.defaults?.resolution ?? getVideoModelResolutions(model)[0] ?? ''
217 }
218
219 export function getVideoModelDurationLimits(
220 model: VideoModelInfo | undefined,
221 resolution: string,
222 isVideoEditMode: boolean,
223 ): { min: number, max: number } {
224 if (!model)
225 return DEFAULT_VIDEO_DURATION_LIMITS
226
227 const pricing = filterVideoPricingByResolution(model.pricing, resolution, isVideoEditMode)
228 const durations = pricing.length > 0 ? pricing.map(item => item.duration) : model.durations
229 if (durations.length === 0)
230 return DEFAULT_VIDEO_DURATION_LIMITS
231
232 return {
233 min: Math.min(...durations),
234 max: Math.max(...durations),
235 }
236 }
237
238 /** 按模式与分辨率过滤视频定价;模型无分辨率定价时复用通用价格 */
239 export function filterVideoPricingByResolution(
240 pricing: VideoModelPricing[] | undefined,
241 resolution: string,
242 isVideoEditMode: boolean,
243 ): VideoModelPricing[] {
244 const pricingItems = pricing ?? []
245 const modePricing = isVideoEditMode
246 ? pricingItems.filter(item => item.mode === 'video2video')
247 : pricingItems.filter(item => !item.mode)
248 const effectiveModePricing = modePricing.length > 0 ? modePricing : pricingItems.filter(item => !item.mode)
249
250 if (resolution) {
251 const resolutionPricing = effectiveModePricing.filter(item => item.resolution?.toLowerCase() === resolution.toLowerCase())
252 if (resolutionPricing.length > 0)
253 return resolutionPricing
254 }
255
256 const genericPricing = effectiveModePricing.filter(item => !item.resolution)
257 return genericPricing.length > 0 ? genericPricing : effectiveModePricing
258 }
259
260 /** 按目标时长获取最接近的视频定价项 */
261 export function getNearestVideoPricing(pricing: VideoModelPricing[], duration: number) {
262 const exactMatch = pricing.find(item => item.duration === duration)
263 if (exactMatch)
264 return exactMatch
265 return [...pricing].sort(
266 (a, b) => Math.abs(a.duration - duration) - Math.abs(b.duration - duration),
267 )[0]
268 }
269
270 /** 获取视频模型指定分辨率的每秒积分价格 */
271 export function getVideoModelPricePerSecond(
272 model: VideoModelInfo | undefined,
273 resolution: string,
274 duration: number,
275 isVideoEditMode: boolean,
276 ) {
277 if (!model)
278 return null
279
280 const pricing = filterVideoPricingByResolution(model.pricing, resolution, isVideoEditMode)
281 const nearestPricing = getNearestVideoPricing(pricing, duration)
282 if (!nearestPricing || nearestPricing.duration <= 0)
283 return null
284
285 return nearestPricing.price / nearestPricing.duration
286 }
287
288 // ==================== 视频比例匹配 ====================
289
290 /** 将视频宽高匹配到最近的支持比例 */
291 export function matchClosestRatio(width: number, height: number, supportedRatios: Set<string>): string | null {
292 const videoRatio = width / height
293 let closest: string | null = null
294 let minDiff = Infinity
295 for (const label of supportedRatios) {
296 const [w, h] = label.split(':').map(Number)
297 const ratio = w! / h!
298 const diff = Math.abs(videoRatio - ratio)
299 if (diff < minDiff) {
300 minDiff = diff
301 closest = label
302 }
303 }
304 return closest
305 }
306
307 // ==================== 图文模型常量 ====================
308
309 export const DEFAULT_MAX_INPUT_IMAGES = 14
310
311 export const IMAGE_TEXT_ASPECT_RATIOS = [
312 { label: '1:1', w: 14, h: 14 },
313 { label: '3:4', w: 12, h: 16 },
314 { label: '4:3', w: 16, h: 12 },
315 { label: '9:16', w: 10, h: 18 },
316 { label: '16:9', w: 18, h: 10 },
317 ]
318
319 /** 获取图片模型支持的比例,优先使用 pricing API 返回值,缺失时使用旧兜底 */
320 export function getImageModelAspectRatios(model?: ImageModelInfo): string[] {
321 const supported = getUniqueStrings(model?.supportedAspectRatios?.filter(Boolean) ?? [])
322 if (supported?.length)
323 return supported
324 return IMAGE_TEXT_ASPECT_RATIOS.map(ratio => ratio.label)
325 }
326
327 export function getImageModelsCommonAspectRatios(models: ImageModelInfo[]): string[] {
328 return getCommonStrings(models.map(model => getImageModelAspectRatios(model)))
329 }
330
331 export function getImageModelsCommonResolutions(models: ImageModelInfo[]): string[] {
332 return getCommonStrings(models.map(model => model.pricing.map(item => item.resolution).filter(Boolean)))
333 }
334
335 export function getImageModelsMaxInputImages(models: ImageModelInfo[]): number {
336 if (models.length === 0)
337 return DEFAULT_MAX_INPUT_IMAGES
338
339 return Math.min(...models.map(model => model.maxInputImages ?? DEFAULT_MAX_INPUT_IMAGES))
340 }
341
342 export const IMAGE_COUNT_LIMITS = { min: 1, max: 9 }
343
343 lines TYPESCRIPT