import { materialContentForProvider, type PromptMaterial } from "@/lib/prompt/assembler"; import { SEEDANCE_20_MODEL, VIDEO_DURATION_DEFAULT, VIDEO_RATIO_DEFAULT, VIDEO_RESOLUTION_DEFAULT, clampVideoDuration, isSeedance25Model, isSupportedSeedanceModel, normalizeSeedanceModel, normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution } from "@/lib/video-settings"; export type SeedanceSettings = { ratio?: string; duration?: number; resolution?: string; }; export type SeedanceCreateInput = { model?: string; prompt: string; settings: SeedanceSettings; materials: PromptMaterial[]; origin: string; }; export type SeedanceQueryResult = { status: "queued" | "running" | "succeeded" | "failed" | "cancelled" | "expired"; resultUrl?: string; errorMessage?: string; usage?: SeedanceUsage; raw: Record; }; export type SeedanceUsage = { completionTokens: number; }; export function getSeedanceConfig() { const model = normalizeSeedanceModel(process.env.SEEDANCE_MODEL, SEEDANCE_20_MODEL); return { apiKey: process.env.SEEDANCE_API_KEY, baseUrl: process.env.SEEDANCE_BASE_URL || "https://ark.cn-beijing.volces.com/api/v3", model, ratio: normalizeVideoRatio(process.env.SEEDANCE_RATIO, VIDEO_RATIO_DEFAULT), duration: clampVideoDuration(process.env.SEEDANCE_DURATION, VIDEO_DURATION_DEFAULT, { model }), resolution: normalizeVideoResolution(process.env.SEEDANCE_RESOLUTION, model, VIDEO_RESOLUTION_DEFAULT) }; } export async function createSeedanceTask(input: SeedanceCreateInput) { const config = getSeedanceConfig(); if (!config.apiKey) throw new Error("缺少 SEEDANCE_API_KEY。请在 .env.local 配置火山方舟 API Key。"); if (input.model !== undefined && !isSupportedSeedanceModel(input.model)) { throw new Error("不支持的 Seedance 视频模型。"); } const model = normalizeSeedanceModel(input.model, config.model); const materialContent = materialContentForProvider(input.materials, input.origin); if (!isSeedance25Model(model) && materialContent.length > 4) { throw new Error("Seedance 2.0 最多支持 4 个素材(加上文本后 content 最多 5 项)。"); } if (isSeedance25Model(model)) validateSeedance25Materials(input.materials); const payload = { model, content: [ { type: "text", text: input.prompt }, ...materialContent ], generate_audio: true, ...(isSeedance25Model(model) && materialContent.length > 0 ? { omni_reference_task_type: "auto" } : {}), ratio: normalizeVideoRatio(input.settings.ratio, config.ratio), duration: normalizeVideoDuration(input.settings.duration, model) ?? config.duration, resolution: normalizeVideoResolution(input.settings.resolution, model, config.resolution), watermark: false }; const response = await fetch(`${config.baseUrl.replace(/\/$/, "")}/contents/generations/tasks`, { method: "POST", headers: { Authorization: `Bearer ${config.apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify(payload) }); const json = await response.json().catch(() => ({})); if (!response.ok) throw new Error(`Seedance 创建任务失败:${response.status} ${JSON.stringify(json)}`); const providerTaskId = json.id || json.task_id || json.data?.id || json.data?.task_id; if (!providerTaskId) throw new Error(`Seedance 响应中缺少任务 ID:${JSON.stringify(json)}`); return { providerTaskId: String(providerTaskId), raw: json as Record, payload }; } function validateSeedance25Materials(materials: PromptMaterial[]) { const counts = materials.reduce((result, material) => { result[material.type] += 1; return result; }, { image: 0, video: 0, audio: 0 }); if (counts.image > 30 || counts.video > 10 || counts.audio > 10 || materials.length > 50) { throw new Error("Seedance 2.5 最多支持 30 张图片、10 个视频和 10 个音频素材。"); } } export async function querySeedanceTask(providerTaskId: string): Promise { const config = getSeedanceConfig(); if (!config.apiKey) throw new Error("缺少 SEEDANCE_API_KEY,无法查询真实生成任务。"); const response = await fetch(`${config.baseUrl.replace(/\/$/, "")}/contents/generations/tasks/${providerTaskId}`, { headers: { Authorization: `Bearer ${config.apiKey}`, "Content-Type": "application/json" } }); const json = await response.json().catch(() => ({})); if (!response.ok) throw new Error(`Seedance 查询任务失败:${response.status}`); return { status: normalizeSeedanceStatus(json.status || json.data?.status), resultUrl: json.content?.video_url || json.content?.file_url || json.video_url || json.url || json.output || json.data?.content?.video_url || json.data?.content?.file_url || json.data?.video_url || json.data?.url || json.data?.output, errorMessage: json.error?.message || json.data?.error?.message, usage: extractSeedanceUsage(json), raw: json as Record }; } export function extractSeedanceUsage(value: unknown): SeedanceUsage | undefined { const root = recordValue(value); const data = recordValue(root?.data); const usage = recordValue(root?.usage) || recordValue(data?.usage); if (!usage) return undefined; const completionTokens = Number(usage.completion_tokens ?? usage.completionTokens); if (!Number.isFinite(completionTokens) || completionTokens <= 0) return undefined; return { completionTokens: Math.floor(completionTokens) }; } function normalizeSeedanceStatus(status: unknown): SeedanceQueryResult["status"] { const value = String(status || "").toLowerCase(); if (["succeeded", "success", "completed"].includes(value)) return "succeeded"; if (["expired", "timeout"].includes(value)) return "expired"; if (["failed", "error"].includes(value)) return "failed"; if (["cancelled", "canceled"].includes(value)) return "cancelled"; if (["running", "processing", "generating"].includes(value)) return "running"; return "queued"; } function recordValue(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : undefined; }