Files
NianAIGC/lib/seedance/client.ts
2026-08-21 13:44:56 +08:00

163 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>;
};
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<string, unknown>,
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<SeedanceQueryResult> {
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<string, unknown>
};
}
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<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
}