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 } : {}),
}
}

View File

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

View File

@@ -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",

View File

@@ -393,11 +393,32 @@ const DIRECT_CASES: ReadonlyArray<DirectRouteCase> = [
},
{
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',

View File

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

View File

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

View File

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

View File

@@ -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<TaskBillingInfo, { billable: true }>> = {},
): Extract<TaskBillingInfo, { billable: true }> {
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<TaskBillingInfo, { billable: true }>).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<TaskBillingInfo, { billable: true }>).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<TaskBillingInfo, { billable: true }> = {

View File

@@ -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,
})
})
})

View File

@@ -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<string, string> }>>(async () => ({ url: 'https://provider.example/video.mp4' })),
resolveVideoSourceFromGeneration: vi.fn<(...args: unknown[]) => Promise<{ url: string; actualVideoTokens?: number; downloadHeaders?: Record<string, string> }>>(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()