import { type EditPlan, editPlanSchema, planTimeline, SECOND } from "./contract"; import { materialize, probeMedia } from "./local"; import { renderEditPlan } from "./render"; // Shared by the local compatibility path and the isolated render service. export async function renderJob( value: EditPlan, userId: string, dir: string, progress: (stage: string) => Promise, ) { const plan = editPlanSchema.parse(value); planTimeline(plan); if (plan.assets.reduce((n, a) => n + a.duration, 0) > 1800 * SECOND) throw Error("素材与分离音轨总时长不能超过 30 分钟"); const files: Record = {}; for (const asset of plan.assets) { await progress("读取原始素材"); const media = await materialize(asset.url, userId), actual = await probeMedia(media.file); if ( media.hash !== asset.hash || Math.abs(actual.duration - asset.duration) > 100000 || actual.hasAudio !== asset.hasAudio || actual.width !== asset.width || actual.height !== asset.height ) throw Error("素材内容或参数已变化,请重新生成剪辑方案"); files[asset.id] = media.file; } let musicFile: string | undefined; if (plan.music) { const media = await materialize(plan.music.url, userId), meta = await probeMedia(media.file); if (!meta.hasAudio || plan.music.start >= meta.duration) throw Error("背景音乐或起始时间无效"); musicFile = media.file; } const rendered = await renderEditPlan(plan, files, musicFile, dir, progress); const actual = await probeMedia(rendered.file, 300.2); if (Math.abs(actual.duration / SECOND - rendered.duration) > 0.15) throw Error("导出时长与剪辑方案不一致,未发布结果"); return { ...rendered, duration: actual.duration / SECOND }; }