163 lines
6.2 KiB
TypeScript
163 lines
6.2 KiB
TypeScript
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;
|
||
}
|