feat: add Seedance 2.0 models

This commit is contained in:
saturn
2026-04-02 19:16:00 +08:00
parent 9703714b69
commit 71ef6ff818
21 changed files with 811 additions and 38 deletions

View File

@@ -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<string>([
encodeModelKey('ark', 'doubao-seedance-2-0-260128'),
])
const PRESET_COMING_SOON_MODEL_KEYS = new Set<string>([])
export function isPresetComingSoonModel(provider: string, modelId: string): boolean {
return PRESET_COMING_SOON_MODEL_KEYS.has(encodeModelKey(provider, modelId))

View File

@@ -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, unknown>): 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', {

View File

@@ -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`)

View File

@@ -30,6 +30,7 @@ export interface PollResult {
resultUrl?: string
imageUrl?: string
videoUrl?: string
actualVideoTokens?: number
downloadHeaders?: Record<string, string>
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
}
}

View File

@@ -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' }

View File

@@ -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<Seedance2AspectRatio, { width: number; height: number }>
> = {
'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<number, Record<Seedance2Resolution, number>> = {
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<string, unknown> | 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<string, CapabilityValue>,
metadata?: Record<string, unknown>,
): 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<string, unknown>,
): 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<string, unknown>,
): 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({

View File

@@ -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<string, unknown>).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<string, unknown>).actualQuantity

View File

@@ -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 {

View File

@@ -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' }
]

View File

@@ -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<string, ArkSeedanceModelSpec> = {
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'])

View File

@@ -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<string, string> }> {
): Promise<{ url: string; actualVideoTokens?: number; downloadHeaders?: Record<string, string> }> {
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 } : {}),
}
}

View File

@@ -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<TaskJobData>) {
@@ -190,7 +196,7 @@ async function handleVideoPanelTask(job: Job<TaskJobData>) {
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<TaskJobData>) {
return {
panelId: panel.id,
videoUrl: cosKey,
...(typeof actualVideoTokens === 'number' ? { actualVideoTokens } : {}),
}
}