123 lines
5.7 KiB
TypeScript
123 lines
5.7 KiB
TypeScript
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 };
|