Files
NianAIGC/lib/seedance/client.ts
T
2026-10-02 19:56:32 +08:00

175 lines
6.7 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;
inputMode?: "frames" | "reference";
};
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)) validateSeedance20Materials(input.materials);
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 个音频素材。");
}
}
function validateSeedance20Materials(materials: PromptMaterial[]) {
const counts = materials.reduce((result, material) => {
result[material.type] += 1;
return result;
}, { image: 0, video: 0, audio: 0 });
if (counts.image > 9 || counts.video > 3 || counts.audio > 3) {
throw new Error("Seedance 2.0 最多支持 9 张图片、3 个视频、3 个音频。");
}
if (counts.audio > 0 && counts.image + counts.video === 0) {
throw new Error("Seedance 2.0 音频不能单独使用,请添加图片或视频素材。");
}
}
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;
}