feat: add Seedance 2.0 models
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
105
tests/integration/provider/ark-provider.contract.test.ts
Normal file
105
tests/integration/provider/ark-provider.contract.test.ts
Normal 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',
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }> = {
|
||||
|
||||
53
tests/unit/task/async-poll-ark.test.ts
Normal file
53
tests/unit/task/async-poll-ark.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user