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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user