From 71ef6ff818498ebc218d02020939748d56c4d065 Mon Sep 17 00:00:00 2001 From: saturn Date: Thu, 2 Apr 2026 19:16:00 +0800 Subject: [PATCH] feat: add Seedance 2.0 models --- .../profile/components/api-config/types.ts | 7 +- .../[projectId]/generate-video/route.ts | 15 +- src/lib/ark-api.ts | 71 ++++++- src/lib/async-poll.ts | 2 + src/lib/async-task-utils.ts | 29 ++- src/lib/billing/cost.ts | 195 +++++++++++++++++- src/lib/billing/service.ts | 13 ++ src/lib/billing/task-policy.ts | 3 + src/lib/constants.ts | 14 +- src/lib/generators/ark.ts | 37 +++- src/lib/workers/utils.ts | 11 +- src/lib/workers/video.worker.ts | 13 +- .../capabilities/image-video.catalog.json | 76 ++++++- standards/pricing/image-video.pricing.json | 44 ++++ .../api/contract/direct-submit-routes.test.ts | 23 ++- .../provider/ark-provider.contract.test.ts | 105 ++++++++++ .../api-config/preset-coming-soon.test.ts | 27 ++- tests/unit/billing/cost.test.ts | 32 +++ tests/unit/billing/service.test.ts | 49 ++++- tests/unit/task/async-poll-ark.test.ts | 53 +++++ tests/unit/worker/video-worker.test.ts | 30 ++- 21 files changed, 811 insertions(+), 38 deletions(-) create mode 100644 tests/integration/provider/ark-provider.contract.test.ts create mode 100644 tests/unit/task/async-poll-ark.test.ts diff --git a/src/app/[locale]/profile/components/api-config/types.ts b/src/app/[locale]/profile/components/api-config/types.ts index 0e84635..cef4ace 100644 --- a/src/app/[locale]/profile/components/api-config/types.ts +++ b/src/app/[locale]/profile/components/api-config/types.ts @@ -136,7 +136,8 @@ export const PRESET_MODELS: PresetModel[] = [ { modelId: 'doubao-seedance-1-0-pro-fast-251015', name: 'Seedance 1.0 Pro Fast', type: 'video', provider: 'ark' }, { modelId: 'doubao-seedance-1-0-lite-i2v-250428', name: 'Seedance 1.0 Lite', type: 'video', provider: 'ark' }, { modelId: 'doubao-seedance-1-5-pro-251215', name: 'Seedance 1.5 Pro', type: 'video', provider: 'ark' }, - { modelId: 'doubao-seedance-2-0-260128', name: 'Seedance 2.0(待上线)', type: 'video', provider: 'ark' }, + { modelId: 'doubao-seedance-2-0-260128', name: 'Seedance 2.0', type: 'video', provider: 'ark' }, + { modelId: 'doubao-seedance-2-0-fast-260128', name: 'Seedance 2.0 Fast', type: 'video', provider: 'ark' }, { modelId: 'doubao-seedance-1-0-pro-250528', name: 'Seedance 1.0 Pro', type: 'video', provider: 'ark' }, // Google Veo { modelId: 'veo-3.1-generate-preview', name: 'Veo 3.1', type: 'video', provider: 'google' }, @@ -184,9 +185,7 @@ export const PRESET_MODELS: PresetModel[] = [ { modelId: 'vidu2.0', name: 'Vidu 2.0', type: 'video', provider: 'vidu' }, ] -const PRESET_COMING_SOON_MODEL_KEYS = new Set([ - encodeModelKey('ark', 'doubao-seedance-2-0-260128'), -]) +const PRESET_COMING_SOON_MODEL_KEYS = new Set([]) export function isPresetComingSoonModel(provider: string, modelId: string): boolean { return PRESET_COMING_SOON_MODEL_KEYS.has(encodeModelKey(provider, modelId)) diff --git a/src/app/api/novel-promotion/[projectId]/generate-video/route.ts b/src/app/api/novel-promotion/[projectId]/generate-video/route.ts index 5cca00d..d06443e 100644 --- a/src/app/api/novel-promotion/[projectId]/generate-video/route.ts +++ b/src/app/api/novel-promotion/[projectId]/generate-video/route.ts @@ -37,6 +37,16 @@ function resolveVideoGenerationMode(payload: unknown): 'normal' | 'firstlastfram return isRecord(payload.firstLastFrame) ? 'firstlastframe' : 'normal' } +function isSeedance2Model(modelKey: string): boolean { + const parsed = parseModelKeyStrict(modelKey) + if (!parsed) return false + return parsed.provider === 'ark' + && ( + parsed.modelId === 'doubao-seedance-2-0-260128' + || parsed.modelId === 'doubao-seedance-2-0-fast-260128' + ) +} + function resolveVideoModelKeyFromPayload(payload: Record): string | null { const firstLast = isRecord(payload.firstLastFrame) ? payload.firstLastFrame : null if (firstLast && typeof firstLast.flModel === 'string' && parseModelKeyStrict(firstLast.flModel)) { @@ -126,7 +136,10 @@ async function validateVideoCapabilityCombination(input: { const resolution = resolveBuiltinPricing({ apiType: 'video', model: modelKey, - selections: resolvedOptions, + selections: { + ...resolvedOptions, + ...(isSeedance2Model(modelKey) ? { containsVideoInput: false } : {}), + }, }) if (resolution.status === 'missing_capability_match') { throw new ApiError('INVALID_PARAMS', { diff --git a/src/lib/ark-api.ts b/src/lib/ark-api.ts index 20da128..68c5c73 100644 --- a/src/lib/ark-api.ts +++ b/src/lib/ark-api.ts @@ -70,10 +70,12 @@ interface ArkImageGenerationResponse { interface ArkVideoTaskRequest { model: string content: Array<{ - type: 'image_url' | 'text' | 'draft_task' + type: 'image_url' | 'video_url' | 'audio_url' | 'text' | 'draft_task' image_url?: { url: string } + video_url?: { url: string } + audio_url?: { url: string } text?: string - role?: 'first_frame' | 'last_frame' | 'reference_image' + role?: 'first_frame' | 'last_frame' | 'reference_image' | 'reference_video' | 'reference_audio' draft_task?: { id: string } }> resolution?: '480p' | '720p' | '1080p' @@ -88,16 +90,32 @@ interface ArkVideoTaskRequest { execution_expires_after?: number generate_audio?: boolean draft?: boolean + tools?: Array<{ + type: 'web_search' + }> } interface ArkVideoTaskResponse { id: string model: string - status: 'processing' | 'succeeded' | 'failed' - content?: Array<{ - type: 'video_url' - video_url: { url: string } + status: 'processing' | 'queued' | 'running' | 'succeeded' | 'failed' + content?: { + video_url?: string + image_url?: string + audio_url?: string + } | Array<{ + type?: 'video_url' | 'image_url' | 'audio_url' + video_url?: { url?: string } + image_url?: { url?: string } + audio_url?: { url?: string } }> + usage?: { + completion_tokens?: number + total_tokens?: number + tool_usage?: { + web_search?: number + } + } error?: { code: string message: string @@ -132,6 +150,7 @@ function validateArkVideoTaskRequest(request: ArkVideoTaskRequest) { 'execution_expires_after', 'generate_audio', 'draft', + 'tools', ]) for (const key of Object.keys(request)) { if (!allowedTopLevelKeys.has(key)) { @@ -159,7 +178,7 @@ function validateArkVideoTaskRequest(request: ArkVideoTaskRequest) { if (!isInteger(request.duration)) { throw new Error('ARK_VIDEO_REQUEST_INVALID: duration must be integer') } - if (request.duration !== -1 && (request.duration < 2 || request.duration > 12)) { + if (request.duration !== -1 && (request.duration < 2 || request.duration > 15)) { throw new Error(`ARK_VIDEO_REQUEST_INVALID: duration=${request.duration}`) } } @@ -211,6 +230,18 @@ function validateArkVideoTaskRequest(request: ArkVideoTaskRequest) { } } + if (request.tools !== undefined) { + if (!Array.isArray(request.tools)) { + throw new Error('ARK_VIDEO_REQUEST_INVALID: tools must be array') + } + for (let index = 0; index < request.tools.length; index += 1) { + const tool = request.tools[index] + if (!isRecord(tool) || tool.type !== 'web_search') { + throw new Error(`ARK_VIDEO_REQUEST_INVALID: tools[${index}].type=${String((tool as { type?: unknown })?.type)}`) + } + } + } + for (let index = 0; index < request.content.length; index += 1) { const item = request.content[index] const path = `content[${index}]` @@ -240,6 +271,32 @@ function validateArkVideoTaskRequest(request: ArkVideoTaskRequest) { continue } + if (item.type === 'video_url') { + if (!isRecord(item.video_url) || !isNonEmptyString(item.video_url.url)) { + throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path}.video_url.url is required`) + } + if ( + item.role !== undefined + && item.role !== 'reference_video' + ) { + throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path}.role=${String(item.role)}`) + } + continue + } + + if (item.type === 'audio_url') { + if (!isRecord(item.audio_url) || !isNonEmptyString(item.audio_url.url)) { + throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path}.audio_url.url is required`) + } + if ( + item.role !== undefined + && item.role !== 'reference_audio' + ) { + throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path}.role=${String(item.role)}`) + } + continue + } + if (item.type === 'draft_task') { if (!isRecord(item.draft_task) || !isNonEmptyString(item.draft_task.id)) { throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path}.draft_task.id is required`) diff --git a/src/lib/async-poll.ts b/src/lib/async-poll.ts index f642909..af7b877 100644 --- a/src/lib/async-poll.ts +++ b/src/lib/async-poll.ts @@ -30,6 +30,7 @@ export interface PollResult { resultUrl?: string imageUrl?: string videoUrl?: string + actualVideoTokens?: number downloadHeaders?: Record error?: string } @@ -513,6 +514,7 @@ async function pollArkTask( status: result.status, videoUrl: result.videoUrl, resultUrl: result.videoUrl, + ...(typeof result.actualVideoTokens === 'number' ? { actualVideoTokens: result.actualVideoTokens } : {}), error: result.error } } diff --git a/src/lib/async-task-utils.ts b/src/lib/async-task-utils.ts index a4df10e..7a11d06 100644 --- a/src/lib/async-task-utils.ts +++ b/src/lib/async-task-utils.ts @@ -12,6 +12,7 @@ export interface TaskStatus { status: 'pending' | 'completed' | 'failed' imageUrl?: string videoUrl?: string + actualVideoTokens?: number error?: string } @@ -21,6 +22,23 @@ function asRecord(value: unknown): UnknownRecord | null { return value && typeof value === 'object' ? (value as UnknownRecord) : null } +function readArkVideoUrl(content: unknown): string | undefined { + const contentRecord = asRecord(content) + if (contentRecord && typeof contentRecord.video_url === 'string' && contentRecord.video_url.trim()) { + return contentRecord.video_url.trim() + } + + if (!Array.isArray(content)) return undefined + for (const item of content) { + const itemRecord = asRecord(item) + const videoUrl = asRecord(itemRecord?.video_url) + if (videoUrl && typeof videoUrl.url === 'string' && videoUrl.url.trim()) { + return videoUrl.url.trim() + } + } + return undefined +} + function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message const record = asRecord(error) @@ -306,12 +324,19 @@ export async function querySeedanceVideoStatus(taskId: string, apiKey: string): const queryData = await queryResponse.json() const status = queryData.status + const actualVideoTokens = typeof queryData?.usage?.total_tokens === 'number' + ? queryData.usage.total_tokens + : undefined if (status === 'succeeded') { - const videoUrl = queryData.content?.video_url + const videoUrl = readArkVideoUrl(queryData.content) if (videoUrl) { - return { status: 'completed', videoUrl } + return { + status: 'completed', + videoUrl, + ...(typeof actualVideoTokens === 'number' ? { actualVideoTokens } : {}), + } } return { status: 'failed', error: 'No video URL in response' } diff --git a/src/lib/billing/cost.ts b/src/lib/billing/cost.ts index e70573e..c7e4575 100644 --- a/src/lib/billing/cost.ts +++ b/src/lib/billing/cost.ts @@ -273,6 +273,150 @@ function applyVideoDurationScaling(input: { return input.amount * (selectedDuration / baseDuration) } +type Seedance2Resolution = '480p' | '720p' +type Seedance2AspectRatio = '16:9' | '4:3' | '1:1' | '3:4' | '9:16' | '21:9' + +const SEEDANCE_2_TOKEN_PRICED_MODEL_IDS = new Set([ + 'doubao-seedance-2-0-260128', + 'doubao-seedance-2-0-fast-260128', +]) + +const SEEDANCE_2_OUTPUT_DIMENSIONS: Record< + Seedance2Resolution, + Record +> = { + '480p': { + '16:9': { width: 864, height: 496 }, + '4:3': { width: 752, height: 560 }, + '1:1': { width: 640, height: 640 }, + '3:4': { width: 560, height: 752 }, + '9:16': { width: 496, height: 864 }, + '21:9': { width: 992, height: 432 }, + }, + '720p': { + '16:9': { width: 1280, height: 720 }, + '4:3': { width: 1112, height: 834 }, + '1:1': { width: 960, height: 960 }, + '3:4': { width: 834, height: 1112 }, + '9:16': { width: 720, height: 1280 }, + '21:9': { width: 1470, height: 630 }, + }, +} + +const SEEDANCE_2_VIDEO_INPUT_MIN_TOKEN_FLOOR: Record> = { + 4: { '480p': 70308, '720p': 151200 }, + 5: { '480p': 90396, '720p': 194400 }, + 6: { '480p': 100440, '720p': 216000 }, + 7: { '480p': 120528, '720p': 259200 }, + 8: { '480p': 140616, '720p': 302400 }, + 9: { '480p': 150660, '720p': 324000 }, + 10: { '480p': 170748, '720p': 367200 }, + 11: { '480p': 190836, '720p': 410400 }, + 12: { '480p': 200880, '720p': 432000 }, + 13: { '480p': 220968, '720p': 475200 }, + 14: { '480p': 241056, '720p': 518400 }, + 15: { '480p': 251100, '720p': 540000 }, +} + +const SEEDANCE_2_DEFAULT_OUTPUT_DURATION_SECONDS = 5 +const SEEDANCE_2_MIN_INPUT_VIDEO_SECONDS = 2 +const SEEDANCE_2_DEFAULT_ASPECT_RATIO: Seedance2AspectRatio = '16:9' +const SEEDANCE_2_FPS = 24 + +function isSeedance2TokenPricedModel(model: string): boolean { + return SEEDANCE_2_TOKEN_PRICED_MODEL_IDS.has(parseModelId(model)) +} + +function readMetadataNumber(metadata: Record | undefined, field: string): number | null { + if (!metadata) return null + const value = metadata[field] + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function resolveSeedance2Resolution(value: CapabilityValue | undefined): Seedance2Resolution { + if (value === '480p' || value === '720p') return value + throw new BillingOperationError( + 'BILLING_UNKNOWN_VIDEO_RESOLUTION', + `Unsupported video resolution pricing: ${String(value)}`, + { + apiType: 'video', + resolution: value, + }, + ) +} + +function resolveSeedance2AspectRatio(value: CapabilityValue | undefined): Seedance2AspectRatio { + if ( + value === '16:9' + || value === '4:3' + || value === '1:1' + || value === '3:4' + || value === '9:16' + || value === '21:9' + ) { + return value + } + if (value === undefined) return SEEDANCE_2_DEFAULT_ASPECT_RATIO + throw new BillingOperationError( + 'BILLING_UNKNOWN_VIDEO_CAPABILITY_COMBINATION', + `Unsupported video capability pricing: aspectRatio=${String(value)}`, + { + apiType: 'video', + aspectRatio: value, + }, + ) +} + +function resolveSeedance2TokenUnitPrice( + model: string, + containsVideoInput: boolean, +): number { + return resolveModelPriceStrict({ + apiType: 'video', + model, + selections: { containsVideoInput }, + }) +} + +function estimateSeedance2VideoTokens( + selections: Record, + metadata?: Record, +): number { + const resolution = resolveSeedance2Resolution(selections.resolution) + const aspectRatio = resolveSeedance2AspectRatio(selections.aspectRatio) + const outputDurationSeconds = typeof selections.duration === 'number' + ? selections.duration + : SEEDANCE_2_DEFAULT_OUTPUT_DURATION_SECONDS + const containsVideoInput = selections.containsVideoInput === true + const inputVideoSeconds = containsVideoInput + ? (readMetadataNumber(metadata, 'inputVideoSeconds') ?? SEEDANCE_2_MIN_INPUT_VIDEO_SECONDS) + : 0 + const outputSize = SEEDANCE_2_OUTPUT_DIMENSIONS[resolution][aspectRatio] + const estimatedTokens = Math.ceil( + ((inputVideoSeconds + outputDurationSeconds) * outputSize.width * outputSize.height * SEEDANCE_2_FPS) / 1024, + ) + + if (!containsVideoInput || aspectRatio !== '16:9') { + return estimatedTokens + } + + const durationFloor = SEEDANCE_2_VIDEO_INPUT_MIN_TOKEN_FLOOR[outputDurationSeconds]?.[resolution] + return typeof durationFloor === 'number' + ? Math.max(estimatedTokens, durationFloor) + : estimatedTokens +} + +function calcSeedance2VideoCostFromTokens( + model: string, + totalTokens: number, + metadata?: Record, +): number { + const containsVideoInput = metadata?.containsVideoInput === true + const unitPrice = resolveSeedance2TokenUnitPrice(model, containsVideoInput) + const normalizedTokens = Math.max(0, Number(totalTokens) || 0) + return (normalizedTokens / 1_000_000) * unitPrice * getMarkup('video') +} + export function calcText( model: string, inputTokens: number, @@ -409,6 +553,13 @@ export function calcVideo( customPricing?: ModelCustomPricing | null, ): number { const selections = normalizeCapabilitySelections(metadata) + if (typeof selections.containsVideoInput !== 'boolean' && isSeedance2TokenPricedModel(model)) { + selections.containsVideoInput = false + } + const capabilitySelections = { ...selections } + delete capabilitySelections.aspectRatio + delete capabilitySelections.containsVideoInput + delete capabilitySelections.inputVideoSeconds if ( typeof selections.resolution !== 'string' && videoCapabilitySupportsField(model, 'resolution') @@ -427,12 +578,34 @@ export function calcVideo( selections.generateAudio = defaultGenerateAudio } } - validateVideoSelectionsAgainstCapabilitiesOrThrow(model, selections) + if ( + typeof capabilitySelections.resolution !== 'string' + && videoCapabilitySupportsField(model, 'resolution') + ) { + capabilitySelections.resolution = selections.resolution + } + if ( + typeof capabilitySelections.generationMode !== 'string' + && videoCapabilitySupportsField(model, 'generationMode') + ) { + capabilitySelections.generationMode = selections.generationMode + } + if (typeof capabilitySelections.generateAudio !== 'boolean' && typeof selections.generateAudio === 'boolean') { + capabilitySelections.generateAudio = selections.generateAudio + } + validateVideoSelectionsAgainstCapabilitiesOrThrow(model, capabilitySelections) + + if (isSeedance2TokenPricedModel(model)) { + const estimatedTokens = estimateSeedance2VideoTokens(selections, metadata) + const unitCost = calcSeedance2VideoCostFromTokens(model, estimatedTokens, metadata) + const quantity = Math.max(0, Number(count) || 0) + return unitCost * quantity + } const resolutionResult = resolveBuiltinPricing({ apiType: 'video', model, - selections, + selections: capabilitySelections, }) if (resolutionResult.status === 'ambiguous_model') { throw new BillingOperationError( @@ -524,6 +697,24 @@ export function calcVideo( return unitPrice * quantity * getMarkup('video') } +export function calcVideoByTokens( + model: string, + totalTokens: number, + metadata?: Record, +): number { + if (!isSeedance2TokenPricedModel(model)) { + throw new BillingOperationError( + 'BILLING_UNKNOWN_VIDEO_CAPABILITY_COMBINATION', + `Video token settlement is not supported for ${model}`, + { + apiType: 'video', + model, + }, + ) + } + return calcSeedance2VideoCostFromTokens(model, totalTokens, metadata) +} + export function calcVoice(durationSeconds: number): number { const seconds = Math.max(0, Number(durationSeconds) || 0) const unitPrice = resolveModelPriceStrict({ diff --git a/src/lib/billing/service.ts b/src/lib/billing/service.ts index 8c993af..1045368 100644 --- a/src/lib/billing/service.ts +++ b/src/lib/billing/service.ts @@ -9,6 +9,7 @@ import { calcLipSync, calcText, calcVideo, + calcVideoByTokens, calcVoice, calcVoiceDesign, type ModelCustomPricing, @@ -306,6 +307,18 @@ function resolveTaskActual( } const payload = options?.result && typeof options.result === 'object' ? options.result : null + const actualVideoTokens = payload + ? asNumber((payload as Record).actualVideoTokens) + : null + if (info.apiType === 'video' && actualVideoTokens !== null && actualVideoTokens >= 0) { + return { + actualCost: calcVideoByTokens(info.model, actualVideoTokens, info.metadata), + actualQuantity: actualVideoTokens, + metadata: { + actualVideoTokens, + }, + } + } const actualQuantity = payload ? asNumber( (payload as Record).actualQuantity diff --git a/src/lib/billing/task-policy.ts b/src/lib/billing/task-policy.ts index 8468764..f1d784e 100644 --- a/src/lib/billing/task-policy.ts +++ b/src/lib/billing/task-policy.ts @@ -160,6 +160,7 @@ function buildVideoTaskInfo(taskType: TaskType, payload: AnyPayload): TaskBillin const generationOptions = toRecord(payload?.generationOptions) const resolution = readString(generationOptions.resolution) || readString(payload?.resolution) const duration = readNumber(generationOptions.duration) ?? readNumber(payload?.duration) + const aspectRatio = readString(generationOptions.aspectRatio) || readString(payload?.aspectRatio) const generateAudio = typeof generationOptions.generateAudio === 'boolean' ? generationOptions.generateAudio : undefined @@ -167,8 +168,10 @@ function buildVideoTaskInfo(taskType: TaskType, payload: AnyPayload): TaskBillin const metadata = { ...(resolution ? { resolution } : {}), ...(typeof duration === 'number' ? { duration } : {}), + ...(aspectRatio ? { aspectRatio } : {}), generationMode, ...(typeof generateAudio === 'boolean' ? { generateAudio } : {}), + containsVideoInput: false, } let maxFrozenCost = 0 try { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e149149..454f273 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -66,6 +66,8 @@ export const BANANA_RESOLUTION_OPTIONS = [ export const BANANA_MODELS = ['banana', 'banana-2', 'gemini-3-pro-image-preview', 'gemini-3-pro-image-preview-batch'] export const VIDEO_MODELS = [ + { value: 'doubao-seedance-2-0-260128', label: 'Seedance 2.0' }, + { value: 'doubao-seedance-2-0-fast-260128', label: 'Seedance 2.0 Fast' }, { value: 'doubao-seedance-1-0-pro-fast-251015', label: 'Seedance 1.0 Pro Fast' }, { value: 'doubao-seedance-1-0-pro-fast-251015-batch', label: 'Seedance 1.0 Pro Fast (批量) 省50%' }, { value: 'doubao-seedance-1-0-lite-i2v-250428', label: 'Seedance 1.0 Lite' }, @@ -90,11 +92,18 @@ export const SEEDANCE_BATCH_MODELS = [ 'doubao-seedance-1-0-lite-i2v-250428-batch', ] -// 支持生成音频的模型(仅 Seedance 1.5 Pro 支持,包含批量版本) -export const AUDIO_SUPPORTED_MODELS = ['doubao-seedance-1-5-pro-251215', 'doubao-seedance-1-5-pro-251215-batch'] +// 支持生成音频的模型 +export const AUDIO_SUPPORTED_MODELS = [ + 'doubao-seedance-2-0-260128', + 'doubao-seedance-2-0-fast-260128', + 'doubao-seedance-1-5-pro-251215', + 'doubao-seedance-1-5-pro-251215-batch', +] // 首尾帧视频模型(能力权威来源是 standards/capabilities;此常量仅作静态兜底展示) export const FIRST_LAST_FRAME_MODELS = [ + { value: 'doubao-seedance-2-0-260128', label: 'Seedance 2.0 (首尾帧)' }, + { value: 'doubao-seedance-2-0-fast-260128', label: 'Seedance 2.0 Fast (首尾帧)' }, { value: 'doubao-seedance-1-5-pro-251215', label: 'Seedance 1.5 Pro (首尾帧)' }, { value: 'doubao-seedance-1-5-pro-251215-batch', label: 'Seedance 1.5 Pro (首尾帧/批量) 省50%' }, { value: 'doubao-seedance-1-0-pro-250528', label: 'Seedance 1.0 Pro (首尾帧)' }, @@ -106,6 +115,7 @@ export const FIRST_LAST_FRAME_MODELS = [ ] export const VIDEO_RESOLUTIONS = [ + { value: '480p', label: '480p' }, { value: '720p', label: '720p' }, { value: '1080p', label: '1080p' } ] diff --git a/src/lib/generators/ark.ts b/src/lib/generators/ark.ts index c7357a7..b1897bf 100644 --- a/src/lib/generators/ark.ts +++ b/src/lib/generators/ark.ts @@ -10,9 +10,10 @@ import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core * - Seedance 1.0 Pro (doubao-seedance-1-0-pro-250528) * - Seedance 1.0 Lite (doubao-seedance-1-0-lite-i2v-250428) * - Seedance 1.5 Pro (doubao-seedance-1-5-pro-251215) + * - Seedance 2.0 / 2.0 Fast * - 支持批量模式 (-batch 后缀) * - 支持首尾帧模式 - * - 支持音频生成 (Seedance 1.5 Pro) + * - 支持音频生成 */ import { @@ -56,7 +57,21 @@ interface ArkVideoOptions { type ArkVideoContentItem = | { type: 'text'; text: string } - | { type: 'image_url'; image_url: { url: string }; role?: 'first_frame' | 'last_frame' | 'reference_image' } + | { + type: 'image_url' + image_url: { url: string } + role?: 'first_frame' | 'last_frame' | 'reference_image' + } + | { + type: 'video_url' + video_url: { url: string } + role: 'reference_video' + } + | { + type: 'audio_url' + audio_url: { url: string } + role: 'reference_audio' + } interface ArkSeedanceModelSpec { durationMin: number @@ -105,6 +120,24 @@ const ARK_SEEDANCE_MODEL_SPECS: Record = { supportsFrames: false, resolutionOptions: ['480p', '720p', '1080p'], }, + 'doubao-seedance-2-0-260128': { + durationMin: 4, + durationMax: 15, + supportsFirstLastFrame: true, + supportsGenerateAudio: true, + supportsDraft: false, + supportsFrames: false, + resolutionOptions: ['480p', '720p'], + }, + 'doubao-seedance-2-0-fast-260128': { + durationMin: 4, + durationMax: 15, + supportsFirstLastFrame: true, + supportsGenerateAudio: true, + supportsDraft: false, + supportsFrames: false, + resolutionOptions: ['480p', '720p'], + }, } const ARK_VIDEO_ALLOWED_RATIOS = new Set(['16:9', '4:3', '1:1', '3:4', '9:16', '21:9', 'adaptive']) diff --git a/src/lib/workers/utils.ts b/src/lib/workers/utils.ts index dbed1ef..973743a 100644 --- a/src/lib/workers/utils.ts +++ b/src/lib/workers/utils.ts @@ -123,7 +123,12 @@ export async function waitExternalResult( externalId, }, }) - return { url, status, ...(status.downloadHeaders ? { downloadHeaders: status.downloadHeaders } : {}) } + return { + url, + status, + ...(typeof status.actualVideoTokens === 'number' ? { actualVideoTokens: status.actualVideoTokens } : {}), + ...(status.downloadHeaders ? { downloadHeaders: status.downloadHeaders } : {}), + } } if (status.status === 'failed') { @@ -419,7 +424,7 @@ export async function resolveVideoSourceFromGeneration( } pollProgress?: { start?: number; end?: number } }, -): Promise<{ url: string; downloadHeaders?: Record }> { +): Promise<{ url: string; actualVideoTokens?: number; downloadHeaders?: Record }> { const logger = scopedWorkerUtilLogger(job, 'worker.video.generate_source') const startedAt = Date.now() @@ -441,6 +446,7 @@ export async function resolveVideoSourceFromGeneration( }) return { url: polled.url, + ...(typeof polled.actualVideoTokens === 'number' ? { actualVideoTokens: polled.actualVideoTokens } : {}), ...(polled.downloadHeaders ? { downloadHeaders: polled.downloadHeaders } : {}), } } @@ -522,6 +528,7 @@ export async function resolveVideoSourceFromGeneration( }) return { url: polled.url, + ...(typeof polled.actualVideoTokens === 'number' ? { actualVideoTokens: polled.actualVideoTokens } : {}), ...(polled.downloadHeaders ? { downloadHeaders: polled.downloadHeaders } : {}), } } diff --git a/src/lib/workers/video.worker.ts b/src/lib/workers/video.worker.ts index ab71ec9..9a8c653 100644 --- a/src/lib/workers/video.worker.ts +++ b/src/lib/workers/video.worker.ts @@ -84,7 +84,7 @@ async function generateVideoForPanel( modelId: string, projectVideoRatio: string | null | undefined, generationOptions: VideoOptionMap, -): Promise<{ cosKey: string; generationMode: VideoGenerationMode }> { +): Promise<{ cosKey: string; generationMode: VideoGenerationMode; actualVideoTokens?: number }> { if (!panel.imageUrl) { throw new Error(`Panel ${panel.id} has no imageUrl`) } @@ -171,7 +171,13 @@ async function generateVideoForPanel( } const cosKey = await uploadVideoSourceToCos(videoSource, 'panel-video', panel.id, downloadHeaders) - return { cosKey, generationMode } + return { + cosKey, + generationMode, + ...(typeof generatedVideo.actualVideoTokens === 'number' + ? { actualVideoTokens: generatedVideo.actualVideoTokens } + : {}), + } } async function handleVideoPanelTask(job: Job) { @@ -190,7 +196,7 @@ async function handleVideoPanelTask(job: Job) { panelId: panel.id, }) - const { cosKey, generationMode } = await generateVideoForPanel( + const { cosKey, generationMode, actualVideoTokens } = await generateVideoForPanel( job, panel, payload, @@ -211,6 +217,7 @@ async function handleVideoPanelTask(job: Job) { return { panelId: panel.id, videoUrl: cosKey, + ...(typeof actualVideoTokens === 'number' ? { actualVideoTokens } : {}), } } diff --git a/standards/capabilities/image-video.catalog.json b/standards/capabilities/image-video.catalog.json index 22af589..d0654e6 100644 --- a/standards/capabilities/image-video.catalog.json +++ b/standards/capabilities/image-video.catalog.json @@ -13,6 +13,80 @@ } } }, + { + "modelType": "video", + "provider": "ark", + "modelId": "doubao-seedance-2-0-260128", + "capabilities": { + "video": { + "generationModeOptions": [ + "normal", + "firstlastframe" + ], + "generateAudioOptions": [ + true, + false + ], + "durationOptions": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "resolutionOptions": [ + "480p", + "720p" + ], + "firstlastframe": true, + "supportGenerateAudio": true + } + } + }, + { + "modelType": "video", + "provider": "ark", + "modelId": "doubao-seedance-2-0-fast-260128", + "capabilities": { + "video": { + "generationModeOptions": [ + "normal", + "firstlastframe" + ], + "generateAudioOptions": [ + true, + false + ], + "durationOptions": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "resolutionOptions": [ + "480p", + "720p" + ], + "firstlastframe": true, + "supportGenerateAudio": true + } + } + }, { "modelType": "video", "provider": "ark", @@ -1048,4 +1122,4 @@ } } } -] \ No newline at end of file +] diff --git a/standards/pricing/image-video.pricing.json b/standards/pricing/image-video.pricing.json index e2c06ff..dc1d384 100644 --- a/standards/pricing/image-video.pricing.json +++ b/standards/pricing/image-video.pricing.json @@ -512,6 +512,50 @@ "flatAmount": 0.144 } }, + { + "apiType": "video", + "provider": "ark", + "modelId": "doubao-seedance-2-0-260128", + "pricing": { + "mode": "capability", + "tiers": [ + { + "when": { + "containsVideoInput": false + }, + "amount": 46 + }, + { + "when": { + "containsVideoInput": true + }, + "amount": 28 + } + ] + } + }, + { + "apiType": "video", + "provider": "ark", + "modelId": "doubao-seedance-2-0-fast-260128", + "pricing": { + "mode": "capability", + "tiers": [ + { + "when": { + "containsVideoInput": false + }, + "amount": 37 + }, + { + "when": { + "containsVideoInput": true + }, + "amount": 22 + } + ] + } + }, { "apiType": "video", "provider": "ark", diff --git a/tests/integration/api/contract/direct-submit-routes.test.ts b/tests/integration/api/contract/direct-submit-routes.test.ts index aa87197..200dd2a 100644 --- a/tests/integration/api/contract/direct-submit-routes.test.ts +++ b/tests/integration/api/contract/direct-submit-routes.test.ts @@ -393,11 +393,32 @@ const DIRECT_CASES: ReadonlyArray = [ }, { routeFile: 'src/app/api/novel-promotion/[projectId]/generate-video/route.ts', - body: { videoModel: 'fal::video-model', storyboardId: 'storyboard-1', panelIndex: 0 }, + body: { + videoModel: 'ark::doubao-seedance-2-0-260128', + storyboardId: 'storyboard-1', + panelIndex: 0, + generationOptions: { + resolution: '720p', + duration: 5, + }, + firstLastFrame: { + flModel: 'ark::doubao-seedance-2-0-260128', + }, + }, params: { projectId: 'project-1' }, expectedTaskType: TASK_TYPE.VIDEO_PANEL, expectedTargetType: 'NovelPromotionPanel', expectedProjectId: 'project-1', + expectedPayloadSubset: { + videoModel: 'ark::doubao-seedance-2-0-260128', + generationOptions: { + resolution: '720p', + duration: 5, + }, + firstLastFrame: { + flModel: 'ark::doubao-seedance-2-0-260128', + }, + }, }, { routeFile: 'src/app/api/novel-promotion/[projectId]/insert-panel/route.ts', diff --git a/tests/integration/provider/ark-provider.contract.test.ts b/tests/integration/provider/ark-provider.contract.test.ts new file mode 100644 index 0000000..c981815 --- /dev/null +++ b/tests/integration/provider/ark-provider.contract.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { arkCreateVideoTask } from '@/lib/ark-api' +import { querySeedanceVideoStatus } from '@/lib/async-task-utils' + +describe('provider contract - ark seedance', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('submits Seedance 2.0 multimodal create payload with official request fields', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ id: 'cgt-task-1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch) + + const result = await arkCreateVideoTask({ + model: 'doubao-seedance-2-0-260128', + content: [ + { type: 'text', text: 'reference 视频1 的运镜,参考音频1 的节奏' }, + { type: 'image_url', image_url: { url: 'https://example.com/first.png' }, role: 'reference_image' }, + { type: 'video_url', video_url: { url: 'https://example.com/ref.mp4' }, role: 'reference_video' }, + { type: 'audio_url', audio_url: { url: 'https://example.com/ref.mp3' }, role: 'reference_audio' }, + ], + resolution: '720p', + ratio: '16:9', + duration: 15, + generate_audio: true, + watermark: true, + tools: [{ type: 'web_search' }], + }, { + apiKey: 'ark-key', + maxRetries: 1, + timeoutMs: 1000, + logPrefix: '[Ark Test]', + }) + + expect(result.id).toBe('cgt-task-1') + expect(fetchMock).toHaveBeenCalledTimes(1) + const firstCall = fetchMock.mock.calls[0] + expect(firstCall).toBeTruthy() + const [url, init] = firstCall as unknown as [string, RequestInit] + expect(url).toBe('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks') + expect(init.method).toBe('POST') + expect(init.headers).toEqual({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ark-key', + }) + expect(JSON.parse(String(init.body))).toEqual({ + model: 'doubao-seedance-2-0-260128', + content: [ + { type: 'text', text: 'reference 视频1 的运镜,参考音频1 的节奏' }, + { type: 'image_url', image_url: { url: 'https://example.com/first.png' }, role: 'reference_image' }, + { type: 'video_url', video_url: { url: 'https://example.com/ref.mp4' }, role: 'reference_video' }, + { type: 'audio_url', audio_url: { url: 'https://example.com/ref.mp3' }, role: 'reference_audio' }, + ], + resolution: '720p', + ratio: '16:9', + duration: 15, + generate_audio: true, + watermark: true, + tools: [{ type: 'web_search' }], + }) + }) + + it('reads Ark task usage.total_tokens from status query', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ + status: 'succeeded', + content: { + video_url: 'https://example.com/result.mp4', + }, + usage: { + total_tokens: 108000, + completion_tokens: 108000, + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch) + + const result = await querySeedanceVideoStatus('cgt-task-2', 'ark-key') + + expect(result).toEqual({ + status: 'completed', + videoUrl: 'https://example.com/result.mp4', + actualVideoTokens: 108000, + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/cgt-task-2', + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ark-key', + }, + cache: 'no-store', + }, + ) + }) +}) diff --git a/tests/unit/api-config/preset-coming-soon.test.ts b/tests/unit/api-config/preset-coming-soon.test.ts index 01bda99..ba4e47d 100644 --- a/tests/unit/api-config/preset-coming-soon.test.ts +++ b/tests/unit/api-config/preset-coming-soon.test.ts @@ -15,21 +15,30 @@ describe('api-config preset coming soon', () => { expect(model?.name).toBe('Nano Banana 2') }) - it('registers Seedance 2.0 as a coming-soon preset model', () => { - const model = PRESET_MODELS.find( - (entry) => entry.provider === 'ark' && entry.modelId === 'doubao-seedance-2-0-260128', - ) - expect(model).toBeDefined() - expect(model?.name).toContain('待上线') + it('registers Seedance 2.0 and Seedance 2.0 Fast as preset video models', () => { + const modelIds = PRESET_MODELS + .filter((entry) => entry.provider === 'ark' && entry.type === 'video') + .map((entry) => entry.modelId) + + expect(modelIds).toEqual(expect.arrayContaining([ + 'doubao-seedance-2-0-260128', + 'doubao-seedance-2-0-fast-260128', + ])) }) - it('recognizes coming-soon model by provider/modelId and modelKey', () => { + it('does not mark live preset models as coming soon', () => { const modelKey = encodeModelKey('ark', 'doubao-seedance-2-0-260128') - expect(isPresetComingSoonModel('ark', 'doubao-seedance-2-0-260128')).toBe(true) - expect(isPresetComingSoonModelKey(modelKey)).toBe(true) + expect(isPresetComingSoonModel('ark', 'doubao-seedance-2-0-260128')).toBe(false) + expect(isPresetComingSoonModelKey(modelKey)).toBe(false) }) it('does not mark normal preset models as coming soon', () => { + const modelKey = encodeModelKey('ark', 'doubao-seedance-2-0-fast-260128') + expect(isPresetComingSoonModel('ark', 'doubao-seedance-2-0-fast-260128')).toBe(false) + expect(isPresetComingSoonModelKey(modelKey)).toBe(false) + }) + + it('keeps existing live preset models non-coming-soon', () => { const modelKey = encodeModelKey('ark', 'doubao-seedance-1-5-pro-251215') expect(isPresetComingSoonModel('ark', 'doubao-seedance-1-5-pro-251215')).toBe(false) expect(isPresetComingSoonModelKey(modelKey)).toBe(false) diff --git a/tests/unit/billing/cost.test.ts b/tests/unit/billing/cost.test.ts index 40f0cb7..dc5c4d5 100644 --- a/tests/unit/billing/cost.test.ts +++ b/tests/unit/billing/cost.test.ts @@ -5,6 +5,7 @@ import { calcLipSync, calcText, calcVideo, + calcVideoByTokens, calcVoice, calcVoiceDesign, } from '@/lib/billing/cost' @@ -87,6 +88,37 @@ describe('billing/cost', () => { })).toThrow('Unsupported video capability pricing') }) + it('estimates Seedance 2.0 video pricing from official token formula', () => { + const cost = calcVideo('doubao-seedance-2-0-260128', '720p', 1, { + resolution: '720p', + duration: 5, + aspectRatio: '16:9', + containsVideoInput: false, + }) + + expect(cost).toBeCloseTo(4.968, 8) + }) + + it('applies Seedance 2.0 video-input token floor for quoted pricing', () => { + const cost = calcVideo('doubao-seedance-2-0-fast-260128', '720p', 1, { + resolution: '720p', + duration: 5, + aspectRatio: '16:9', + containsVideoInput: true, + inputVideoSeconds: 2, + }) + + expect(cost).toBeCloseTo(4.2768, 8) + }) + + it('settles Seedance 2.0 videos from exact usage tokens', () => { + const cost = calcVideoByTokens('doubao-seedance-2-0-260128', 120_000, { + containsVideoInput: false, + }) + + expect(cost).toBeCloseTo(5.52, 8) + }) + it('supports minimax capability-aware video pricing', () => { const hailuoNormal = calcVideo('minimax-hailuo-2.3', '768p', 1, { generationMode: 'normal', diff --git a/tests/unit/billing/service.test.ts b/tests/unit/billing/service.test.ts index 7702eee..c4574b3 100644 --- a/tests/unit/billing/service.test.ts +++ b/tests/unit/billing/service.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { calcText, calcVoice } from '@/lib/billing/cost' +import { calcText, calcVideo, calcVoice } from '@/lib/billing/cost' import type { TaskBillingInfo } from '@/lib/task/types' const ledgerMock = vi.hoisted(() => ({ @@ -230,6 +230,34 @@ describe('billing/service', () => { } } + function buildSeedance2VideoTaskInfo( + overrides: Partial> = {}, + ): Extract { + return { + billable: true, + source: 'task', + taskType: 'video_panel', + apiType: 'video', + model: 'doubao-seedance-2-0-260128', + quantity: 1, + unit: 'video', + maxFrozenCost: calcVideo('doubao-seedance-2-0-260128', '720p', 1, { + resolution: '720p', + duration: 5, + aspectRatio: '16:9', + containsVideoInput: false, + }), + action: 'video_panel_generate', + metadata: { + resolution: '720p', + duration: 5, + aspectRatio: '16:9', + containsVideoInput: false, + }, + ...overrides, + } + } + it('prepareTaskBilling handles OFF/SHADOW/ENFORCE paths', async () => { modeMock.getBillingMode.mockResolvedValueOnce('OFF') const off = await prepareTaskBilling({ @@ -468,6 +496,25 @@ describe('billing/service', () => { expect((settled as Extract).chargedCost).toBeCloseTo(calcVoice(50), 8) }) + it('settleTaskBilling charges Seedance 2.0 videos from exact usage tokens', async () => { + ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true) + + const settled = await settleTaskBilling({ + id: 'task_seedance2_actual_tokens', + userId: 'u1', + projectId: 'p1', + billingInfo: buildSeedance2VideoTaskInfo({ + modeSnapshot: 'ENFORCE', + freezeId: 'freeze_seedance2_actual_tokens', + }), + }, { + result: { actualVideoTokens: 120_000 }, + }) + + expect(ledgerMock.increasePendingFreezeAmount).toHaveBeenCalledTimes(1) + expect((settled as Extract).chargedCost).toBeCloseTo(5.52, 8) + }) + it('settleTaskBilling keeps quoted charge when text usage has no token counts', async () => { const quoted = calcText('anthropic/claude-sonnet-4', 500, 500) const textBillingInfo: Extract = { diff --git a/tests/unit/task/async-poll-ark.test.ts b/tests/unit/task/async-poll-ark.test.ts new file mode 100644 index 0000000..5908a4b --- /dev/null +++ b/tests/unit/task/async-poll-ark.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getProviderConfigMock = vi.hoisted(() => + vi.fn(async () => ({ + id: 'ark', + apiKey: 'ark-key', + })), +) + +const asyncTaskUtilsMock = vi.hoisted(() => ({ + queryGeminiBatchStatus: vi.fn(), + queryGoogleVideoStatus: vi.fn(), + querySeedanceVideoStatus: vi.fn(), +})) + +vi.mock('@/lib/api-config', () => ({ + getProviderConfig: getProviderConfigMock, + getUserModels: vi.fn(), +})) + +vi.mock('@/lib/async-submit', () => ({ + queryFalStatus: vi.fn(), +})) + +vi.mock('@/lib/async-task-utils', () => asyncTaskUtilsMock) + +import { pollAsyncTask } from '@/lib/async-poll' + +describe('async poll ark task', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('passes through actual video token usage from Ark polling', async () => { + asyncTaskUtilsMock.querySeedanceVideoStatus.mockResolvedValueOnce({ + status: 'completed', + videoUrl: 'https://ark.example/result.mp4', + actualVideoTokens: 108000, + }) + + const result = await pollAsyncTask('ARK:VIDEO:task-1', 'user-1') + + expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'ark') + expect(asyncTaskUtilsMock.querySeedanceVideoStatus).toHaveBeenCalledWith('task-1', 'ark-key') + expect(result).toEqual({ + status: 'completed', + resultUrl: 'https://ark.example/result.mp4', + videoUrl: 'https://ark.example/result.mp4', + actualVideoTokens: 108000, + error: undefined, + }) + }) +}) diff --git a/tests/unit/worker/video-worker.test.ts b/tests/unit/worker/video-worker.test.ts index 1da8ffd..9029778 100644 --- a/tests/unit/worker/video-worker.test.ts +++ b/tests/unit/worker/video-worker.test.ts @@ -27,7 +27,7 @@ const utilsMock = vi.hoisted(() => ({ assertTaskActive: vi.fn(async () => undefined), getProjectModels: vi.fn(async () => ({ videoRatio: '16:9' })), resolveLipSyncVideoSource: vi.fn(async () => 'https://provider.example/lipsync.mp4'), - resolveVideoSourceFromGeneration: vi.fn<(...args: unknown[]) => Promise<{ url: string; downloadHeaders?: Record }>>(async () => ({ url: 'https://provider.example/video.mp4' })), + resolveVideoSourceFromGeneration: vi.fn<(...args: unknown[]) => Promise<{ url: string; actualVideoTokens?: number; downloadHeaders?: Record }>>(async () => ({ url: 'https://provider.example/video.mp4' })), toSignedUrlIfCos: vi.fn((url: string | null) => (url ? `https://signed.example/${url}` : null)), uploadVideoSourceToCos: vi.fn(async () => 'cos/lip-sync/video.mp4'), })) @@ -196,6 +196,34 @@ describe('worker video processor behavior', () => { ) }) + it('VIDEO_PANEL: 将 Ark 返回的实际视频 token 用量透传到任务结果', async () => { + const processor = workerState.processor + expect(processor).toBeTruthy() + + utilsMock.resolveVideoSourceFromGeneration.mockResolvedValueOnce({ + url: 'https://provider.example/video.mp4', + actualVideoTokens: 108000, + }) + + const job = buildJob({ + type: TASK_TYPE.VIDEO_PANEL, + payload: { + videoModel: 'ark::doubao-seedance-2-0-260128', + generationOptions: { + duration: 5, + resolution: '720p', + }, + }, + }) + + const result = await processor!(job) as { panelId: string; videoUrl: string; actualVideoTokens: number } + expect(result).toEqual({ + panelId: 'panel-1', + videoUrl: 'cos/lip-sync/video.mp4', + actualVideoTokens: 108000, + }) + }) + it('LIP_SYNC: 缺少 panel 时显式失败', async () => { const processor = workerState.processor expect(processor).toBeTruthy()