返回 AiToEarn
1 import type { ImageModelInfo, VideoModelInfo, VideoModelInputConstraint } from '@/api/ai/ai.types'
2 import type { IUploadedMedia } from '@/components/Chat/MediaUpload'
3 import type { IPersistedMedia, VideoModelParams } from '@/store/draft-box/draftBoxConfigStore'
4 import {
5 filterVideoPricingByResolution,
6 getImageModelsCommonResolutions,
7 getNearestVideoPricing,
8 getVideoModelAspectRatios,
9 getVideoModelCreditsScope,
10 getVideoModelDefaultResolution,
11 getVideoModelDurationLimits,
12 getVideoModelResolutions,
13 } from './constants'
14
15 export const PROMPT_MAX_LENGTH = 2000
16 const MEDIA_DURATION_LIMIT_TOLERANCE_SECONDS = 0.1
17 export const MEDIA_MENTION_MAX_IMAGES = 9
18 export const MEDIA_MENTION_MAX_VIDEOS = 3
19 export const MEDIA_MENTION_MAX_AUDIOS = 3
20 const MEDIA_MENTION_TOKEN_PATTERN = /@(Image[1-9]|Video[1-3]|Audio[1-3])/g
21
22 function getMediaMentionLimit(type: IUploadedMedia['type']) {
23 if (type === 'image')
24 return { prefix: 'Image', max: MEDIA_MENTION_MAX_IMAGES }
25 if (type === 'video')
26 return { prefix: 'Video', max: MEDIA_MENTION_MAX_VIDEOS }
27 if (type === 'audio')
28 return { prefix: 'Audio', max: MEDIA_MENTION_MAX_AUDIOS }
29 return null
30 }
31
32 function getMediaIdentity(media: IUploadedMedia) {
33 if (media.id)
34 return media.id
35 if (media.url)
36 return media.url
37 return media.file ? `${media.file.name}${media.file.size}` : ''
38 }
39
40 function getMediaMentionTokenForMedia(media: IUploadedMedia | undefined, medias: IUploadedMedia[]) {
41 if (!media || !media.url || media.progress !== undefined)
42 return ''
43
44 const limit = getMediaMentionLimit(media.type)
45 if (!limit)
46 return ''
47
48 const mediaIdentity = getMediaIdentity(media)
49 const sameTypeMedias = medias.filter(
50 item => item.type === media.type && item.url && item.progress === undefined,
51 )
52 const mediaIndex = sameTypeMedias.findIndex((item) => {
53 if (item === media)
54 return true
55 const itemIdentity = getMediaIdentity(item)
56 return Boolean(mediaIdentity && itemIdentity && mediaIdentity === itemIdentity)
57 })
58
59 if (mediaIndex < 0 || mediaIndex >= limit.max)
60 return ''
61
62 return `@${limit.prefix}${mediaIndex + 1}`
63 }
64
65 function getMediaMentionOrdinalForMedia(media: IUploadedMedia | undefined, medias: IUploadedMedia[]) {
66 if (!media || !media.url || media.progress !== undefined)
67 return 0
68
69 const limit = getMediaMentionLimit(media.type)
70 if (!limit)
71 return 0
72
73 const mediaIdentity = getMediaIdentity(media)
74 const sameTypeMedias = medias.filter(
75 item => item.type === media.type && item.url && item.progress === undefined,
76 )
77 const mediaIndex = sameTypeMedias.findIndex((item) => {
78 if (item === media)
79 return true
80 const itemIdentity = getMediaIdentity(item)
81 return Boolean(mediaIdentity && itemIdentity && mediaIdentity === itemIdentity)
82 })
83
84 if (mediaIndex < 0 || mediaIndex >= limit.max)
85 return 0
86
87 return mediaIndex + 1
88 }
89
90 export function removeAndReindexMediaMentionTokensAfterRemove(value: string, media: IUploadedMedia | undefined, medias: IUploadedMedia[]) {
91 const limit = media ? getMediaMentionLimit(media.type) : null
92 const removedOrdinal = getMediaMentionOrdinalForMedia(media, medias)
93 if (!limit || removedOrdinal <= 0)
94 return value
95
96 const removedToken = `@${limit.prefix}${removedOrdinal}`
97 const nextValue = value
98 .split(/\r?\n/)
99 .map((line) => {
100 const nextLine = line.replace(MEDIA_MENTION_TOKEN_PATTERN, (token, mentionValue) => {
101 if (!mentionValue.startsWith(limit.prefix))
102 return token
103
104 const ordinal = Number(mentionValue.slice(limit.prefix.length))
105 if (ordinal === removedOrdinal)
106 return ''
107 if (ordinal > removedOrdinal && ordinal <= limit.max)
108 return `@${limit.prefix}${ordinal - 1}`
109 return token
110 })
111
112 if (line.includes(removedToken) && nextLine.trim() === '')
113 return null
114
115 return nextLine
116 })
117 .filter((line): line is string => line !== null)
118 .join('\n')
119
120 return normalizeMediaMentionBlankLines(nextValue)
121 }
122
123 function isMediaMentionOnlyLine(line: string) {
124 return /^\s*(?:@(?:Image[1-9]|Video[1-3]|Audio[1-3])\s*)+$/.test(line)
125 }
126
127 function normalizeMediaMentionBlankLines(value: string) {
128 const lines = value.split(/\r?\n/)
129 return lines
130 .filter((line, index) => {
131 if (line.trim())
132 return true
133
134 const previousLine = lines.slice(0, index).reverse().find(item => item.trim())
135 const nextLine = lines.slice(index + 1).find(item => item.trim())
136
137 if (!previousLine && nextLine && isMediaMentionOnlyLine(nextLine))
138 return false
139 if (previousLine && !nextLine && isMediaMentionOnlyLine(previousLine))
140 return false
141 if (previousLine && nextLine && isMediaMentionOnlyLine(previousLine) && isMediaMentionOnlyLine(nextLine))
142 return false
143
144 return true
145 })
146 .join('\n')
147 }
148
149 export function removeUnavailableMediaMentionTokens(value: string, availableValues: Set<string>) {
150 return value.replace(MEDIA_MENTION_TOKEN_PATTERN, (token, mentionValue) => {
151 return availableValues.has(mentionValue) ? token : ''
152 })
153 }
154
155 export function isDurationBelowLimit(duration: number, min: number) {
156 return min - duration > MEDIA_DURATION_LIMIT_TOLERANCE_SECONDS
157 }
158
159 export function isDurationAboveLimit(duration: number, max: number) {
160 return duration - max > MEDIA_DURATION_LIMIT_TOLERANCE_SECONDS
161 }
162
163 export function buildPersistedMediaSignature(
164 medias: Array<Pick<IPersistedMedia, 'id' | 'url' | 'type' | 'name' | 'duration'>>,
165 ) {
166 return JSON.stringify(
167 medias.map(media => [
168 media.id,
169 media.url,
170 media.type,
171 media.name ?? '',
172 media.duration ?? null,
173 ]),
174 )
175 }
176
177 export function normalizeSelectedValues(
178 selectedValues: string[],
179 availableValues: string[],
180 fallbackValue?: string,
181 ) {
182 const normalized = selectedValues.filter(
183 (value, index, array) => availableValues.includes(value) && array.indexOf(value) === index,
184 )
185 if (normalized.length > 0)
186 return normalized
187 if (fallbackValue && availableValues.includes(fallbackValue))
188 return [fallbackValue]
189 return availableValues[0] ? [availableValues[0]] : []
190 }
191
192 function getOptionCompareKey(value?: string) {
193 return (value ?? '').trim().replace(/\s+/g, '').toLowerCase()
194 }
195
196 export function includesOption(values: string[], value?: string) {
197 const compareKey = getOptionCompareKey(value)
198 return compareKey.length > 0 && values.some(item => getOptionCompareKey(item) === compareKey)
199 }
200
201 export function includesExactStringOption(values: string[], value?: string): value is string {
202 return value !== undefined && values.includes(value)
203 }
204
205 function includesExactNumberOption(values: number[], value?: number): value is number {
206 return value !== undefined && values.includes(value)
207 }
208
209 export function getVideoModelFallbackResolution(model?: VideoModelInfo) {
210 const resolutions = getVideoModelResolutions(model)
211 const defaultResolution = model?.defaults?.resolution
212 if (includesExactStringOption(resolutions, defaultResolution))
213 return defaultResolution!
214 return resolutions[0] ?? defaultResolution ?? ''
215 }
216
217 function getResolvedVideoModelResolution(model: VideoModelInfo, requestedResolution?: string) {
218 const resolutions = getVideoModelResolutions(model)
219 if (includesExactStringOption(resolutions, requestedResolution))
220 return requestedResolution!
221 return getVideoModelFallbackResolution(model)
222 }
223
224 function getVideoModelDurationOptions(
225 model: VideoModelInfo | undefined,
226 resolution: string,
227 isVideoEditMode: boolean,
228 ) {
229 if (!model)
230 return []
231 const pricing = filterVideoPricingByResolution(model.pricing, resolution, isVideoEditMode)
232 const durations = pricing.length > 0 ? pricing.map(item => item.duration) : model.durations
233 return Array.from(new Set(durations))
234 }
235
236 function getVideoModelFallbackDuration(
237 model: VideoModelInfo | undefined,
238 resolution: string,
239 fallbackDuration: number,
240 isVideoEditMode: boolean,
241 ) {
242 const durations = getVideoModelDurationOptions(model, resolution, isVideoEditMode)
243 const defaultDuration = model?.defaults?.duration
244 if (includesExactNumberOption(durations, defaultDuration))
245 return defaultDuration!
246 return durations[0] ?? defaultDuration ?? fallbackDuration
247 }
248
249 function getResolvedVideoModelDuration(
250 model: VideoModelInfo,
251 resolution: string,
252 requestedDuration: number,
253 fallbackDuration: number,
254 isVideoEditMode: boolean,
255 ) {
256 const durations = getVideoModelDurationOptions(model, resolution, isVideoEditMode)
257 if (includesExactNumberOption(durations, requestedDuration))
258 return requestedDuration
259 const duration = getVideoModelFallbackDuration(
260 model,
261 resolution,
262 fallbackDuration,
263 isVideoEditMode,
264 )
265 const durationLimits = getVideoModelDurationLimits(model, resolution, isVideoEditMode)
266 return clampDuration(duration, durationLimits.min, durationLimits.max)
267 }
268
269 export function getVideoDurationLimits(
270 models: VideoModelInfo[],
271 resolutions: Record<string, string>,
272 isVideoEditMode: boolean,
273 ) {
274 const limits = models.map((model) => {
275 const resolution = resolutions[model.name] ?? getVideoModelFallbackResolution(model)
276 return getVideoModelDurationLimits(model, resolution, isVideoEditMode)
277 })
278
279 if (limits.length === 0)
280 return { min: 4, max: 15 }
281
282 const min = Math.max(...limits.map(item => item.min))
283 const max = Math.min(...limits.map(item => item.max))
284 return min <= max ? { min, max } : { min: 4, max: 15 }
285 }
286
287 function getVideoModelCredits(
288 model: VideoModelInfo,
289 params: Record<string, VideoModelParams>,
290 isVideoEditMode: boolean,
291 ) {
292 const modelParams = params[model.name] ?? {}
293 const resolution = modelParams.resolution ?? getVideoModelDefaultResolution(model)
294 const pricing = filterVideoPricingByResolution(model.pricing, resolution, isVideoEditMode)
295 return (
296 getNearestVideoPricing(pricing, modelParams.duration ?? model.defaults?.duration ?? 8)?.price
297 ?? 0
298 )
299 }
300
301 export function getVideoModelsCredits(
302 models: VideoModelInfo[],
303 params: Record<string, VideoModelParams>,
304 isVideoEditMode: boolean,
305 quantity: number,
306 ) {
307 const total = models.reduce((sum, model) => {
308 return sum + getVideoModelCredits(model, params, isVideoEditMode)
309 }, 0)
310 return Math.ceil(total * quantity * 100) / 100
311 }
312
313 export function getVideoCreditsByScope(
314 models: VideoModelInfo[],
315 params: Record<string, VideoModelParams>,
316 isVideoEditMode: boolean,
317 quantity: number,
318 ) {
319 const totals = models.reduce(
320 (result, model) => {
321 const credits = getVideoModelCredits(model, params, isVideoEditMode)
322 if (getVideoModelCreditsScope(model) === 'seedance') {
323 result.seedance += credits
324 }
325 else {
326 result.general += credits
327 }
328 return result
329 },
330 { general: 0, seedance: 0 },
331 )
332
333 return {
334 general: Math.ceil(totals.general * quantity * 100) / 100,
335 seedance: Math.ceil(totals.seedance * quantity * 100) / 100,
336 }
337 }
338
339 export function getVideoModelResolutionMap(models: VideoModelInfo[], stored: Record<string, string>) {
340 const map: Record<string, string> = {}
341 models.forEach((model) => {
342 const resolutions = getVideoModelResolutions(model)
343 const storedResolution = stored[model.name]
344 map[model.name] = includesExactStringOption(resolutions, storedResolution)
345 ? storedResolution!
346 : getVideoModelFallbackResolution(model)
347 })
348 return map
349 }
350
351 export function clampDuration(value: number, min: number, max: number) {
352 return Math.min(Math.max(value, min), max)
353 }
354
355 export function getDefaultVideoAspectRatio(model: VideoModelInfo, fallbackAspectRatio: string) {
356 const ratios = getVideoModelAspectRatios(model)
357 const defaultAspectRatio = model.defaults?.aspectRatio
358 if (includesExactStringOption(ratios, defaultAspectRatio))
359 return defaultAspectRatio
360 return ratios[0] ?? defaultAspectRatio ?? fallbackAspectRatio
361 }
362
363 function getResolvedVideoModelAspectRatio(
364 model: VideoModelInfo,
365 requestedAspectRatio: string,
366 fallbackAspectRatio: string,
367 ) {
368 const ratios = getVideoModelAspectRatios(model)
369 if (includesExactStringOption(ratios, requestedAspectRatio))
370 return requestedAspectRatio
371 return getDefaultVideoAspectRatio(model, fallbackAspectRatio)
372 }
373
374 function getResolvedVideoModelParams(
375 model: VideoModelInfo,
376 storedParams: VideoModelParams | undefined,
377 legacyResolution: string | undefined,
378 fallback: Required<VideoModelParams>,
379 isVideoEditMode: boolean,
380 ): VideoModelParams {
381 const requestedResolution = storedParams?.resolution ?? legacyResolution ?? fallback.resolution
382 const resolution = getResolvedVideoModelResolution(model, requestedResolution)
383 const requestedAspectRatio = isVideoEditMode
384 ? fallback.aspectRatio
385 : (storedParams?.aspectRatio ?? fallback.aspectRatio)
386 const aspectRatio = getResolvedVideoModelAspectRatio(
387 model,
388 requestedAspectRatio,
389 fallback.aspectRatio,
390 )
391 const requestedDuration = isVideoEditMode
392 ? fallback.duration
393 : (storedParams?.duration ?? fallback.duration)
394 const duration = getResolvedVideoModelDuration(
395 model,
396 resolution,
397 requestedDuration,
398 fallback.duration,
399 isVideoEditMode,
400 )
401
402 return {
403 resolution,
404 duration,
405 aspectRatio,
406 }
407 }
408
409 export function getVideoModelParamsMap(
410 models: VideoModelInfo[],
411 storedParams: Record<string, VideoModelParams>,
412 legacyResolutions: Record<string, string>,
413 fallback: Required<VideoModelParams>,
414 isVideoEditMode: boolean,
415 ) {
416 const params: Record<string, VideoModelParams> = {}
417 models.forEach((model) => {
418 params[model.name] = getResolvedVideoModelParams(
419 model,
420 storedParams[model.name],
421 legacyResolutions[model.name],
422 fallback,
423 isVideoEditMode,
424 )
425 })
426 return params
427 }
428
429 export function getVideoModelParamsResolutionMap(params: Record<string, VideoModelParams>) {
430 const resolutions: Record<string, string> = {}
431 Object.entries(params).forEach(([model, modelParams]) => {
432 if (modelParams.resolution) {
433 resolutions[model] = modelParams.resolution
434 }
435 })
436 return resolutions
437 }
438
439 export function getSeededVideoModelParamsMap(
440 models: VideoModelInfo[],
441 storedParams: Record<string, VideoModelParams>,
442 seedParams: Required<VideoModelParams>,
443 ) {
444 const params: Record<string, VideoModelParams> = {}
445 models.forEach((model) => {
446 const stored = storedParams[model.name]
447 params[model.name] = {
448 resolution: stored?.resolution ?? seedParams.resolution,
449 duration: stored?.duration ?? seedParams.duration,
450 aspectRatio: stored?.aspectRatio ?? seedParams.aspectRatio,
451 }
452 })
453 return params
454 }
455
456 export function isFileSizeAllowed(file: File, maxSizeMb?: number) {
457 return maxSizeMb === undefined || file.size <= maxSizeMb * 1024 * 1024
458 }
459
460 export function getMediaDurationLimit(
461 constraint: VideoModelInputConstraint | undefined,
462 fallback: number,
463 ) {
464 return constraint?.maxTotalDuration ?? constraint?.maxDuration ?? fallback
465 }
466
467 function supportsVideoEditMode(model: Pick<VideoModelInfo, 'modes'>) {
468 return model.modes.includes('video2video')
469 }
470
471 export function shouldUseVideoEditMode(models: VideoModelInfo[]) {
472 return models.length > 0 && models.every(supportsVideoEditMode)
473 }
474
475 function getMediaCacheKey(media: Pick<IUploadedMedia, 'file'>) {
476 return media.file ? `${media.file.name}${media.file.size}` : undefined
477 }
478
479 export function getMediaDuration(media: IUploadedMedia, durationMap: Map<string, number>) {
480 if (media.id) {
481 const idDuration = durationMap.get(media.id)
482 if (idDuration !== undefined)
483 return idDuration
484 }
485 const cacheKey = getMediaCacheKey(media)
486 return cacheKey ? durationMap.get(cacheKey) : undefined
487 }
488
489 export function getMediaDurationKeys(media: IUploadedMedia) {
490 return [media.id, getMediaCacheKey(media)].filter((key): key is string => Boolean(key))
491 }
492
493 function isPersistedMediaType(type: IUploadedMedia['type']): type is IPersistedMedia['type'] {
494 return type === 'image' || type === 'video' || type === 'audio'
495 }
496
497 function getMediaFileNameFromUrl(url: string) {
498 const pathname = url.split('?')[0] ?? ''
499 const filename = pathname.split('/').filter(Boolean).pop() ?? ''
500 if (!filename)
501 return ''
502 try {
503 return decodeURIComponent(filename)
504 }
505 catch {
506 return filename
507 }
508 }
509
510 export function getMediaDisplayName(media: IUploadedMedia, fallback: string) {
511 return media.name || media.file?.name || getMediaFileNameFromUrl(media.url) || fallback
512 }
513
514 export function toPersistedLocalMedia(media: IUploadedMedia): IPersistedMedia | null {
515 if (!media.id || !media.url || media.progress !== undefined || !isPersistedMediaType(media.type))
516 return null
517 return {
518 id: media.id,
519 url: media.url,
520 type: media.type,
521 name: media.name,
522 duration: undefined,
523 }
524 }
525
526 export function buildAggregateImagePricing(models: ImageModelInfo[]) {
527 const commonResolutions = getImageModelsCommonResolutions(models)
528 return commonResolutions.map(resolution => ({
529 resolution,
530 pricePerImage:
531 Math.ceil(
532 models.reduce((sum, model) => {
533 return (
534 sum + (model.pricing.find(item => item.resolution === resolution)?.pricePerImage ?? 0)
535 )
536 }, 0) * 100,
537 ) / 100,
538 }))
539 }
540
541 /** youmind.com URL 语言路径映射:英语等无前缀,日语/韩语使用不同代码 */
542 export const PROMPTS_EXPLORE_LNG_MAP: Record<string, string> = {
543 'zh-CN': 'zh-CN',
544 'ja': 'ja-JP',
545 'ko': 'ko-KR',
546 }
547
548 const DEFAULT_PROMPTS_EXPLORE_SLUG = 'grok-imagine-prompts'
549 const SEEDANCE_PROMPTS_EXPLORE_SLUG = 'seedance-2-0-prompts'
550
551 export function getPromptsExploreSlugByCreditsScope(creditsScope?: VideoModelInfo['creditsScope']) {
552 if (creditsScope === 'seedance')
553 return SEEDANCE_PROMPTS_EXPLORE_SLUG
554 return DEFAULT_PROMPTS_EXPLORE_SLUG
555 }
556
556 lines TYPESCRIPT