ai-chatbot/lib/video-canvas/aliyun-video-edit.ts

123 lines
5.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 "server-only";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service";
import { archiveCanvasVideo } from "./archive-video";
import { preserveGeneratedVideo } from "./archive-result";
import { ProviderTaskFailedError, ProviderTaskUncertainError } from "./video-task-lifecycle";
import { videoEditRequestSchema, type VideoEditRequest } from "./video-edit-contract";
import { buildAliyunVideoEditRequest } from "./video-edit-request";
import { createVideoEditMask } from "./video-edit-mask";
type VideoEditCheckpoint = {
operation: "video-edit";
action: VideoEditRequest["operation"];
sourceUrl: string;
taskId: string;
maskUrl?: string;
};
async function resolveProvider(modelId: string) {
const models = await getEnabledAiModelsWithProvider().catch(() => []);
const officialProvider = (item: (typeof models)[number]) =>
["alibaba", "qianwen-video"].includes(item.provider.type) &&
/^https:\/\/(?:dashscope(?:-intl)?\.aliyuncs\.com|[-a-z0-9]+\.[-a-z0-9]+\.maas\.aliyuncs\.com)(?:\/|$)/i.test(item.provider.baseUrl || "");
const matching = models.find((item) => item.model.modelId === modelId && officialProvider(item)) ||
models.find(officialProvider);
const apiKey = matching?.provider.apiKey || process.env.DASHSCOPE_API_KEY || "";
const baseUrl = matching?.provider.baseUrl || "https://dashscope.aliyuncs.com";
if (!apiKey) throw new Error("阿里云视频编辑 Key 未配置");
const url = new URL(baseUrl);
if (url.protocol !== "https:" || !(
url.hostname === "dashscope.aliyuncs.com" ||
url.hostname === "dashscope-intl.aliyuncs.com" ||
/^[-a-z0-9]+\.[-a-z0-9]+\.maas\.aliyuncs\.com$/.test(url.hostname)
)) throw new Error("视频编辑只允许调用阿里云百炼官方地址");
url.pathname = "/api/v1";
url.search = "";
return { apiKey, endpoint: url.toString().replace(/\/$/, "") };
}
export async function executeAliyunVideoEdit(
raw: Record<string, unknown>,
lifecycle: {
resume?: VideoEditCheckpoint | null;
active: () => Promise<void>;
save: (checkpoint: VideoEditCheckpoint) => Promise<void>;
},
runtime: { fetch: typeof fetch; sleep: (ms: number) => Promise<void> } = {
fetch: globalThis.fetch,
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
},
) {
const input = videoEditRequestSchema.parse(raw);
const modelId = input.operation === "repaint" ? "wan2.7-videoedit" : "wanx2.1-vace-plus";
const { apiKey, endpoint } = await resolveProvider(modelId);
const headers = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
};
const deadline = Date.now() + 650_000;
const checkpoint = lifecycle.resume;
if (checkpoint && (checkpoint.action !== input.operation || checkpoint.sourceUrl !== input.sourceUrl)) {
throw new ProviderTaskUncertainError("视频编辑源素材已变化,不能自动重提任务");
}
let taskId = checkpoint?.taskId || "";
const request = async (path: string, init: RequestInit = {}) => {
const response = await runtime.fetch(`${endpoint}${path}`, {
...init,
headers,
signal: AbortSignal.timeout(30_000),
});
const body = await response.json();
if (!response.ok) throw new Error(`阿里云视频编辑失败(${response.status}):${body.message || body.code || "未知错误"}`);
return body;
};
await lifecycle.active();
if (!taskId) {
let maskUrl: string | undefined;
if (input.operation === "erase" || input.operation === "subtitle") {
const regions = input.operation === "subtitle" && !input.regions?.length
? [{ x: 0.04, y: 0.78, width: 0.92, height: 0.2 }]
: input.regions || [];
const mask = await createVideoEditMask(regions, input.width, input.height);
const bytes = Uint8Array.from(mask);
const uploaded = await uploadFile(`video-edit-mask-${crypto.randomUUID()}.png`, bytes.buffer);
maskUrl = uploaded.url;
}
const submitted = await request("/services/aigc/video-generation/video-synthesis", {
method: "POST",
body: JSON.stringify(buildAliyunVideoEditRequest(input, maskUrl)),
});
taskId = String(submitted.output?.task_id || "");
if (!taskId) throw new Error("阿里云未返回视频编辑任务 ID");
await lifecycle.save({ operation: "video-edit", action: input.operation, sourceUrl: input.sourceUrl, taskId, maskUrl });
}
while (Date.now() < deadline) {
await runtime.sleep(5000);
await lifecycle.active();
let result;
try { result = await request(`/tasks/${encodeURIComponent(taskId)}`); }
catch (error) { throw new ProviderTaskUncertainError(`上游任务 ${taskId} 查询失败,将按原任务编号恢复:${error instanceof Error ? error.message : "网络错误"}`); }
const output = result.output || {};
if (output.task_status === "SUCCEEDED") {
const url = String(output.video_url || "");
if (!/^https?:\/\//i.test(url)) throw new Error("视频编辑成功但未返回视频地址");
const stored = await preserveGeneratedVideo(url, {
provider: "aliyun",
model: modelId,
taskId,
operation: input.operation,
durationSeconds: input.operation === "intro" ? 5 : Math.min(input.durationSeconds, input.operation === "repaint" ? 10 : 5),
}, archiveCanvasVideo);
return stored;
}
if (["FAILED", "CANCELED", "UNKNOWN"].includes(output.task_status)) {
throw new ProviderTaskFailedError(`阿里云视频编辑失败:${output.message || output.code || output.task_status},任务 ${taskId}`);
}
}
throw new ProviderTaskUncertainError(`上游任务 ${taskId} 仍在处理,将按原任务编号继续查询`);
}
export type { VideoEditCheckpoint };