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-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-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-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' },
|
{ modelId: 'doubao-seedance-1-0-pro-250528', name: 'Seedance 1.0 Pro', type: 'video', provider: 'ark' },
|
||||||
// Google Veo
|
// Google Veo
|
||||||
{ modelId: 'veo-3.1-generate-preview', name: 'Veo 3.1', type: 'video', provider: 'google' },
|
{ 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' },
|
{ modelId: 'vidu2.0', name: 'Vidu 2.0', type: 'video', provider: 'vidu' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const PRESET_COMING_SOON_MODEL_KEYS = new Set<string>([
|
const PRESET_COMING_SOON_MODEL_KEYS = new Set<string>([])
|
||||||
encodeModelKey('ark', 'doubao-seedance-2-0-260128'),
|
|
||||||
])
|
|
||||||
|
|
||||||
export function isPresetComingSoonModel(provider: string, modelId: string): boolean {
|
export function isPresetComingSoonModel(provider: string, modelId: string): boolean {
|
||||||
return PRESET_COMING_SOON_MODEL_KEYS.has(encodeModelKey(provider, modelId))
|
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'
|
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 {
|
function resolveVideoModelKeyFromPayload(payload: Record<string, unknown>): string | null {
|
||||||
const firstLast = isRecord(payload.firstLastFrame) ? payload.firstLastFrame : null
|
const firstLast = isRecord(payload.firstLastFrame) ? payload.firstLastFrame : null
|
||||||
if (firstLast && typeof firstLast.flModel === 'string' && parseModelKeyStrict(firstLast.flModel)) {
|
if (firstLast && typeof firstLast.flModel === 'string' && parseModelKeyStrict(firstLast.flModel)) {
|
||||||
@@ -126,7 +136,10 @@ async function validateVideoCapabilityCombination(input: {
|
|||||||
const resolution = resolveBuiltinPricing({
|
const resolution = resolveBuiltinPricing({
|
||||||
apiType: 'video',
|
apiType: 'video',
|
||||||
model: modelKey,
|
model: modelKey,
|
||||||
selections: resolvedOptions,
|
selections: {
|
||||||
|
...resolvedOptions,
|
||||||
|
...(isSeedance2Model(modelKey) ? { containsVideoInput: false } : {}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (resolution.status === 'missing_capability_match') {
|
if (resolution.status === 'missing_capability_match') {
|
||||||
throw new ApiError('INVALID_PARAMS', {
|
throw new ApiError('INVALID_PARAMS', {
|
||||||
|
|||||||
@@ -70,10 +70,12 @@ interface ArkImageGenerationResponse {
|
|||||||
interface ArkVideoTaskRequest {
|
interface ArkVideoTaskRequest {
|
||||||
model: string
|
model: string
|
||||||
content: Array<{
|
content: Array<{
|
||||||
type: 'image_url' | 'text' | 'draft_task'
|
type: 'image_url' | 'video_url' | 'audio_url' | 'text' | 'draft_task'
|
||||||
image_url?: { url: string }
|
image_url?: { url: string }
|
||||||
|
video_url?: { url: string }
|
||||||
|
audio_url?: { url: string }
|
||||||
text?: 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 }
|
draft_task?: { id: string }
|
||||||
}>
|
}>
|
||||||
resolution?: '480p' | '720p' | '1080p'
|
resolution?: '480p' | '720p' | '1080p'
|
||||||
@@ -88,16 +90,32 @@ interface ArkVideoTaskRequest {
|
|||||||
execution_expires_after?: number
|
execution_expires_after?: number
|
||||||
generate_audio?: boolean
|
generate_audio?: boolean
|
||||||
draft?: boolean
|
draft?: boolean
|
||||||
|
tools?: Array<{
|
||||||
|
type: 'web_search'
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ArkVideoTaskResponse {
|
interface ArkVideoTaskResponse {
|
||||||
id: string
|
id: string
|
||||||
model: string
|
model: string
|
||||||
status: 'processing' | 'succeeded' | 'failed'
|
status: 'processing' | 'queued' | 'running' | 'succeeded' | 'failed'
|
||||||
content?: Array<{
|
content?: {
|
||||||
type: 'video_url'
|
video_url?: string
|
||||||
video_url: { 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?: {
|
error?: {
|
||||||
code: string
|
code: string
|
||||||
message: string
|
message: string
|
||||||
@@ -132,6 +150,7 @@ function validateArkVideoTaskRequest(request: ArkVideoTaskRequest) {
|
|||||||
'execution_expires_after',
|
'execution_expires_after',
|
||||||
'generate_audio',
|
'generate_audio',
|
||||||
'draft',
|
'draft',
|
||||||
|
'tools',
|
||||||
])
|
])
|
||||||
for (const key of Object.keys(request)) {
|
for (const key of Object.keys(request)) {
|
||||||
if (!allowedTopLevelKeys.has(key)) {
|
if (!allowedTopLevelKeys.has(key)) {
|
||||||
@@ -159,7 +178,7 @@ function validateArkVideoTaskRequest(request: ArkVideoTaskRequest) {
|
|||||||
if (!isInteger(request.duration)) {
|
if (!isInteger(request.duration)) {
|
||||||
throw new Error('ARK_VIDEO_REQUEST_INVALID: duration must be integer')
|
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}`)
|
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) {
|
for (let index = 0; index < request.content.length; index += 1) {
|
||||||
const item = request.content[index]
|
const item = request.content[index]
|
||||||
const path = `content[${index}]`
|
const path = `content[${index}]`
|
||||||
@@ -240,6 +271,32 @@ function validateArkVideoTaskRequest(request: ArkVideoTaskRequest) {
|
|||||||
continue
|
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 (item.type === 'draft_task') {
|
||||||
if (!isRecord(item.draft_task) || !isNonEmptyString(item.draft_task.id)) {
|
if (!isRecord(item.draft_task) || !isNonEmptyString(item.draft_task.id)) {
|
||||||
throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path}.draft_task.id is required`)
|
throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path}.draft_task.id is required`)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface PollResult {
|
|||||||
resultUrl?: string
|
resultUrl?: string
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
videoUrl?: string
|
videoUrl?: string
|
||||||
|
actualVideoTokens?: number
|
||||||
downloadHeaders?: Record<string, string>
|
downloadHeaders?: Record<string, string>
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
@@ -513,6 +514,7 @@ async function pollArkTask(
|
|||||||
status: result.status,
|
status: result.status,
|
||||||
videoUrl: result.videoUrl,
|
videoUrl: result.videoUrl,
|
||||||
resultUrl: result.videoUrl,
|
resultUrl: result.videoUrl,
|
||||||
|
...(typeof result.actualVideoTokens === 'number' ? { actualVideoTokens: result.actualVideoTokens } : {}),
|
||||||
error: result.error
|
error: result.error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface TaskStatus {
|
|||||||
status: 'pending' | 'completed' | 'failed'
|
status: 'pending' | 'completed' | 'failed'
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
videoUrl?: string
|
videoUrl?: string
|
||||||
|
actualVideoTokens?: number
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +22,23 @@ function asRecord(value: unknown): UnknownRecord | null {
|
|||||||
return value && typeof value === 'object' ? (value as 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 {
|
function getErrorMessage(error: unknown): string {
|
||||||
if (error instanceof Error) return error.message
|
if (error instanceof Error) return error.message
|
||||||
const record = asRecord(error)
|
const record = asRecord(error)
|
||||||
@@ -306,12 +324,19 @@ export async function querySeedanceVideoStatus(taskId: string, apiKey: string):
|
|||||||
|
|
||||||
const queryData = await queryResponse.json()
|
const queryData = await queryResponse.json()
|
||||||
const status = queryData.status
|
const status = queryData.status
|
||||||
|
const actualVideoTokens = typeof queryData?.usage?.total_tokens === 'number'
|
||||||
|
? queryData.usage.total_tokens
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (status === 'succeeded') {
|
if (status === 'succeeded') {
|
||||||
const videoUrl = queryData.content?.video_url
|
const videoUrl = readArkVideoUrl(queryData.content)
|
||||||
|
|
||||||
if (videoUrl) {
|
if (videoUrl) {
|
||||||
return { status: 'completed', videoUrl }
|
return {
|
||||||
|
status: 'completed',
|
||||||
|
videoUrl,
|
||||||
|
...(typeof actualVideoTokens === 'number' ? { actualVideoTokens } : {}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: 'failed', error: 'No video URL in response' }
|
return { status: 'failed', error: 'No video URL in response' }
|
||||||
|
|||||||
@@ -273,6 +273,150 @@ function applyVideoDurationScaling(input: {
|
|||||||
return input.amount * (selectedDuration / baseDuration)
|
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(
|
export function calcText(
|
||||||
model: string,
|
model: string,
|
||||||
inputTokens: number,
|
inputTokens: number,
|
||||||
@@ -409,6 +553,13 @@ export function calcVideo(
|
|||||||
customPricing?: ModelCustomPricing | null,
|
customPricing?: ModelCustomPricing | null,
|
||||||
): number {
|
): number {
|
||||||
const selections = normalizeCapabilitySelections(metadata)
|
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 (
|
if (
|
||||||
typeof selections.resolution !== 'string'
|
typeof selections.resolution !== 'string'
|
||||||
&& videoCapabilitySupportsField(model, 'resolution')
|
&& videoCapabilitySupportsField(model, 'resolution')
|
||||||
@@ -427,12 +578,34 @@ export function calcVideo(
|
|||||||
selections.generateAudio = defaultGenerateAudio
|
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({
|
const resolutionResult = resolveBuiltinPricing({
|
||||||
apiType: 'video',
|
apiType: 'video',
|
||||||
model,
|
model,
|
||||||
selections,
|
selections: capabilitySelections,
|
||||||
})
|
})
|
||||||
if (resolutionResult.status === 'ambiguous_model') {
|
if (resolutionResult.status === 'ambiguous_model') {
|
||||||
throw new BillingOperationError(
|
throw new BillingOperationError(
|
||||||
@@ -524,6 +697,24 @@ export function calcVideo(
|
|||||||
return unitPrice * quantity * getMarkup('video')
|
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 {
|
export function calcVoice(durationSeconds: number): number {
|
||||||
const seconds = Math.max(0, Number(durationSeconds) || 0)
|
const seconds = Math.max(0, Number(durationSeconds) || 0)
|
||||||
const unitPrice = resolveModelPriceStrict({
|
const unitPrice = resolveModelPriceStrict({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
calcLipSync,
|
calcLipSync,
|
||||||
calcText,
|
calcText,
|
||||||
calcVideo,
|
calcVideo,
|
||||||
|
calcVideoByTokens,
|
||||||
calcVoice,
|
calcVoice,
|
||||||
calcVoiceDesign,
|
calcVoiceDesign,
|
||||||
type ModelCustomPricing,
|
type ModelCustomPricing,
|
||||||
@@ -306,6 +307,18 @@ function resolveTaskActual(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = options?.result && typeof options.result === 'object' ? options.result : null
|
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
|
const actualQuantity = payload
|
||||||
? asNumber(
|
? asNumber(
|
||||||
(payload as Record<string, unknown>).actualQuantity
|
(payload as Record<string, unknown>).actualQuantity
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ function buildVideoTaskInfo(taskType: TaskType, payload: AnyPayload): TaskBillin
|
|||||||
const generationOptions = toRecord(payload?.generationOptions)
|
const generationOptions = toRecord(payload?.generationOptions)
|
||||||
const resolution = readString(generationOptions.resolution) || readString(payload?.resolution)
|
const resolution = readString(generationOptions.resolution) || readString(payload?.resolution)
|
||||||
const duration = readNumber(generationOptions.duration) ?? readNumber(payload?.duration)
|
const duration = readNumber(generationOptions.duration) ?? readNumber(payload?.duration)
|
||||||
|
const aspectRatio = readString(generationOptions.aspectRatio) || readString(payload?.aspectRatio)
|
||||||
const generateAudio = typeof generationOptions.generateAudio === 'boolean'
|
const generateAudio = typeof generationOptions.generateAudio === 'boolean'
|
||||||
? generationOptions.generateAudio
|
? generationOptions.generateAudio
|
||||||
: undefined
|
: undefined
|
||||||
@@ -167,8 +168,10 @@ function buildVideoTaskInfo(taskType: TaskType, payload: AnyPayload): TaskBillin
|
|||||||
const metadata = {
|
const metadata = {
|
||||||
...(resolution ? { resolution } : {}),
|
...(resolution ? { resolution } : {}),
|
||||||
...(typeof duration === 'number' ? { duration } : {}),
|
...(typeof duration === 'number' ? { duration } : {}),
|
||||||
|
...(aspectRatio ? { aspectRatio } : {}),
|
||||||
generationMode,
|
generationMode,
|
||||||
...(typeof generateAudio === 'boolean' ? { generateAudio } : {}),
|
...(typeof generateAudio === 'boolean' ? { generateAudio } : {}),
|
||||||
|
containsVideoInput: false,
|
||||||
}
|
}
|
||||||
let maxFrozenCost = 0
|
let maxFrozenCost = 0
|
||||||
try {
|
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 BANANA_MODELS = ['banana', 'banana-2', 'gemini-3-pro-image-preview', 'gemini-3-pro-image-preview-batch']
|
||||||
|
|
||||||
export const VIDEO_MODELS = [
|
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', 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-pro-fast-251015-batch', label: 'Seedance 1.0 Pro Fast (批量) 省50%' },
|
||||||
{ value: 'doubao-seedance-1-0-lite-i2v-250428', label: 'Seedance 1.0 Lite' },
|
{ 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',
|
'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;此常量仅作静态兜底展示)
|
// 首尾帧视频模型(能力权威来源是 standards/capabilities;此常量仅作静态兜底展示)
|
||||||
export const FIRST_LAST_FRAME_MODELS = [
|
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', label: 'Seedance 1.5 Pro (首尾帧)' },
|
||||||
{ value: 'doubao-seedance-1-5-pro-251215-batch', label: 'Seedance 1.5 Pro (首尾帧/批量) 省50%' },
|
{ 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 (首尾帧)' },
|
{ 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 = [
|
export const VIDEO_RESOLUTIONS = [
|
||||||
|
{ value: '480p', label: '480p' },
|
||||||
{ value: '720p', label: '720p' },
|
{ value: '720p', label: '720p' },
|
||||||
{ value: '1080p', label: '1080p' }
|
{ 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 Pro (doubao-seedance-1-0-pro-250528)
|
||||||
* - Seedance 1.0 Lite (doubao-seedance-1-0-lite-i2v-250428)
|
* - Seedance 1.0 Lite (doubao-seedance-1-0-lite-i2v-250428)
|
||||||
* - Seedance 1.5 Pro (doubao-seedance-1-5-pro-251215)
|
* - Seedance 1.5 Pro (doubao-seedance-1-5-pro-251215)
|
||||||
|
* - Seedance 2.0 / 2.0 Fast
|
||||||
* - 支持批量模式 (-batch 后缀)
|
* - 支持批量模式 (-batch 后缀)
|
||||||
* - 支持首尾帧模式
|
* - 支持首尾帧模式
|
||||||
* - 支持音频生成 (Seedance 1.5 Pro)
|
* - 支持音频生成
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -56,7 +57,21 @@ interface ArkVideoOptions {
|
|||||||
|
|
||||||
type ArkVideoContentItem =
|
type ArkVideoContentItem =
|
||||||
| { type: 'text'; text: string }
|
| { 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 {
|
interface ArkSeedanceModelSpec {
|
||||||
durationMin: number
|
durationMin: number
|
||||||
@@ -105,6 +120,24 @@ const ARK_SEEDANCE_MODEL_SPECS: Record<string, ArkSeedanceModelSpec> = {
|
|||||||
supportsFrames: false,
|
supportsFrames: false,
|
||||||
resolutionOptions: ['480p', '720p', '1080p'],
|
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'])
|
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,
|
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') {
|
if (status.status === 'failed') {
|
||||||
@@ -419,7 +424,7 @@ export async function resolveVideoSourceFromGeneration(
|
|||||||
}
|
}
|
||||||
pollProgress?: { start?: number; end?: number }
|
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 logger = scopedWorkerUtilLogger(job, 'worker.video.generate_source')
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
||||||
@@ -441,6 +446,7 @@ export async function resolveVideoSourceFromGeneration(
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
url: polled.url,
|
url: polled.url,
|
||||||
|
...(typeof polled.actualVideoTokens === 'number' ? { actualVideoTokens: polled.actualVideoTokens } : {}),
|
||||||
...(polled.downloadHeaders ? { downloadHeaders: polled.downloadHeaders } : {}),
|
...(polled.downloadHeaders ? { downloadHeaders: polled.downloadHeaders } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,6 +528,7 @@ export async function resolveVideoSourceFromGeneration(
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
url: polled.url,
|
url: polled.url,
|
||||||
|
...(typeof polled.actualVideoTokens === 'number' ? { actualVideoTokens: polled.actualVideoTokens } : {}),
|
||||||
...(polled.downloadHeaders ? { downloadHeaders: polled.downloadHeaders } : {}),
|
...(polled.downloadHeaders ? { downloadHeaders: polled.downloadHeaders } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ async function generateVideoForPanel(
|
|||||||
modelId: string,
|
modelId: string,
|
||||||
projectVideoRatio: string | null | undefined,
|
projectVideoRatio: string | null | undefined,
|
||||||
generationOptions: VideoOptionMap,
|
generationOptions: VideoOptionMap,
|
||||||
): Promise<{ cosKey: string; generationMode: VideoGenerationMode }> {
|
): Promise<{ cosKey: string; generationMode: VideoGenerationMode; actualVideoTokens?: number }> {
|
||||||
if (!panel.imageUrl) {
|
if (!panel.imageUrl) {
|
||||||
throw new Error(`Panel ${panel.id} has no 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)
|
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>) {
|
async function handleVideoPanelTask(job: Job<TaskJobData>) {
|
||||||
@@ -190,7 +196,7 @@ async function handleVideoPanelTask(job: Job<TaskJobData>) {
|
|||||||
panelId: panel.id,
|
panelId: panel.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { cosKey, generationMode } = await generateVideoForPanel(
|
const { cosKey, generationMode, actualVideoTokens } = await generateVideoForPanel(
|
||||||
job,
|
job,
|
||||||
panel,
|
panel,
|
||||||
payload,
|
payload,
|
||||||
@@ -211,6 +217,7 @@ async function handleVideoPanelTask(job: Job<TaskJobData>) {
|
|||||||
return {
|
return {
|
||||||
panelId: panel.id,
|
panelId: panel.id,
|
||||||
videoUrl: cosKey,
|
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",
|
"modelType": "video",
|
||||||
"provider": "ark",
|
"provider": "ark",
|
||||||
|
|||||||
@@ -512,6 +512,50 @@
|
|||||||
"flatAmount": 0.144
|
"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",
|
"apiType": "video",
|
||||||
"provider": "ark",
|
"provider": "ark",
|
||||||
|
|||||||
@@ -393,11 +393,32 @@ const DIRECT_CASES: ReadonlyArray<DirectRouteCase> = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
routeFile: 'src/app/api/novel-promotion/[projectId]/generate-video/route.ts',
|
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' },
|
params: { projectId: 'project-1' },
|
||||||
expectedTaskType: TASK_TYPE.VIDEO_PANEL,
|
expectedTaskType: TASK_TYPE.VIDEO_PANEL,
|
||||||
expectedTargetType: 'NovelPromotionPanel',
|
expectedTargetType: 'NovelPromotionPanel',
|
||||||
expectedProjectId: 'project-1',
|
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',
|
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')
|
expect(model?.name).toBe('Nano Banana 2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('registers Seedance 2.0 as a coming-soon preset model', () => {
|
it('registers Seedance 2.0 and Seedance 2.0 Fast as preset video models', () => {
|
||||||
const model = PRESET_MODELS.find(
|
const modelIds = PRESET_MODELS
|
||||||
(entry) => entry.provider === 'ark' && entry.modelId === 'doubao-seedance-2-0-260128',
|
.filter((entry) => entry.provider === 'ark' && entry.type === 'video')
|
||||||
)
|
.map((entry) => entry.modelId)
|
||||||
expect(model).toBeDefined()
|
|
||||||
expect(model?.name).toContain('待上线')
|
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')
|
const modelKey = encodeModelKey('ark', 'doubao-seedance-2-0-260128')
|
||||||
expect(isPresetComingSoonModel('ark', 'doubao-seedance-2-0-260128')).toBe(true)
|
expect(isPresetComingSoonModel('ark', 'doubao-seedance-2-0-260128')).toBe(false)
|
||||||
expect(isPresetComingSoonModelKey(modelKey)).toBe(true)
|
expect(isPresetComingSoonModelKey(modelKey)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not mark normal preset models as coming soon', () => {
|
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')
|
const modelKey = encodeModelKey('ark', 'doubao-seedance-1-5-pro-251215')
|
||||||
expect(isPresetComingSoonModel('ark', 'doubao-seedance-1-5-pro-251215')).toBe(false)
|
expect(isPresetComingSoonModel('ark', 'doubao-seedance-1-5-pro-251215')).toBe(false)
|
||||||
expect(isPresetComingSoonModelKey(modelKey)).toBe(false)
|
expect(isPresetComingSoonModelKey(modelKey)).toBe(false)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
calcLipSync,
|
calcLipSync,
|
||||||
calcText,
|
calcText,
|
||||||
calcVideo,
|
calcVideo,
|
||||||
|
calcVideoByTokens,
|
||||||
calcVoice,
|
calcVoice,
|
||||||
calcVoiceDesign,
|
calcVoiceDesign,
|
||||||
} from '@/lib/billing/cost'
|
} from '@/lib/billing/cost'
|
||||||
@@ -87,6 +88,37 @@ describe('billing/cost', () => {
|
|||||||
})).toThrow('Unsupported video capability pricing')
|
})).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', () => {
|
it('supports minimax capability-aware video pricing', () => {
|
||||||
const hailuoNormal = calcVideo('minimax-hailuo-2.3', '768p', 1, {
|
const hailuoNormal = calcVideo('minimax-hailuo-2.3', '768p', 1, {
|
||||||
generationMode: 'normal',
|
generationMode: 'normal',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
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'
|
import type { TaskBillingInfo } from '@/lib/task/types'
|
||||||
|
|
||||||
const ledgerMock = vi.hoisted(() => ({
|
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 () => {
|
it('prepareTaskBilling handles OFF/SHADOW/ENFORCE paths', async () => {
|
||||||
modeMock.getBillingMode.mockResolvedValueOnce('OFF')
|
modeMock.getBillingMode.mockResolvedValueOnce('OFF')
|
||||||
const off = await prepareTaskBilling({
|
const off = await prepareTaskBilling({
|
||||||
@@ -468,6 +496,25 @@ describe('billing/service', () => {
|
|||||||
expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(calcVoice(50), 8)
|
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 () => {
|
it('settleTaskBilling keeps quoted charge when text usage has no token counts', async () => {
|
||||||
const quoted = calcText('anthropic/claude-sonnet-4', 500, 500)
|
const quoted = calcText('anthropic/claude-sonnet-4', 500, 500)
|
||||||
const textBillingInfo: Extract<TaskBillingInfo, { billable: true }> = {
|
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),
|
assertTaskActive: vi.fn(async () => undefined),
|
||||||
getProjectModels: vi.fn(async () => ({ videoRatio: '16:9' })),
|
getProjectModels: vi.fn(async () => ({ videoRatio: '16:9' })),
|
||||||
resolveLipSyncVideoSource: vi.fn(async () => 'https://provider.example/lipsync.mp4'),
|
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)),
|
toSignedUrlIfCos: vi.fn((url: string | null) => (url ? `https://signed.example/${url}` : null)),
|
||||||
uploadVideoSourceToCos: vi.fn(async () => 'cos/lip-sync/video.mp4'),
|
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 () => {
|
it('LIP_SYNC: 缺少 panel 时显式失败', async () => {
|
||||||
const processor = workerState.processor
|
const processor = workerState.processor
|
||||||
expect(processor).toBeTruthy()
|
expect(processor).toBeTruthy()
|
||||||
|
|||||||
Reference in New Issue
Block a user