import { z } from "zod"; export const SECOND = 1_000_000; const time = z .number() .int() .min(0) .max(600 * SECOND); const id = z.string().min(1).max(180); const url = z .string() .url() .max(4096) .refine((value) => /^https?:\/\//i.test(value)); export const subtitleSchema = z .object({ start: time, end: time, text: z.string().max(500) }) .refine((x) => x.end > x.start); export const assetInputSchema = z.object({ id, url, name: z.string().max(200), subtitles: z.array(subtitleSchema).max(500).default([]), }); export const editAssetSchema = assetInputSchema.extend({ duration: time, width: z.number().int().positive().max(8192), height: z.number().int().positive().max(8192), hasAudio: z.boolean(), hash: z.string().regex(/^[a-f0-9]{64}$/), }); export const candidateSchema = z .object({ id, assetId: id, start: time, end: time, text: z.string().max(1500).default("") }) .refine((x) => x.end - x.start >= 200000); export const editClipSchema = z .object({ id, assetId: id, sourceIn: time, sourceOut: time, speed: z.number().min(0.5).max(2).default(1), gain: z.number().min(0).max(1).default(1), label: z.string().max(300).default(""), }) .refine((x) => x.sourceOut - x.sourceIn >= 200000); export const editPlanSchema = z.object({ version: z.literal(1), title: z.string().max(200), assets: z.array(editAssetSchema).min(1).max(6), clips: z.array(editClipSchema).min(1).max(12), ratio: z.enum(["16:9", "9:16", "1:1"]), resolution: z.enum(["720p", "1080p"]).default("720p"), transition: z.number().min(0).max(0.5).default(0), subtitles: z.boolean().default(true), music: z .object({ url, gain: z.number().min(0).max(1).default(0.2), start: time.default(0) }) .optional(), warnings: z.array(z.string().max(500)).max(30).default([]), }); export type EditPlan = z.infer; export type EditAsset = z.infer; export type EditCandidate = z.infer; export const smartEditRequestSchema = z.discriminatedUnion("operation", [ z.object({ operation: z.literal("plan"), assets: z.array(assetInputSchema).min(1).max(6), mode: z.enum(["montage", "talk", "explain", "ads"]).default("montage"), prompt: z.string().max(3000).default(""), targetDuration: z.number().int().min(3).max(90).default(20), ratio: z.enum(["16:9", "9:16", "1:1"]).default("16:9"), resolution: z.enum(["720p", "1080p"]).default("720p"), removeSilence: z.boolean().default(false), transcribe: z.boolean().default(false), semantic: z.boolean().default(true), preferredModelRef: z.string().max(180).optional(), music: z .object({ url, gain: z.number().min(0).max(1).default(0.2), start: time.default(0) }) .optional(), beatSync: z.boolean().default(false), beatsPerCut: z.enum(["2", "4"]).default("4"), variants: z.number().int().min(1).max(3).default(1), }), z.object({ operation: z.literal("render"), plan: editPlanSchema, sourceNodeId: z.string().max(180).optional(), }), ]); export type SmartEditRequest = z.infer; export function planTimeline(input: EditPlan) { const plan = editPlanSchema.parse(input), ids = new Set(); let cursor = 0; const clips = plan.clips.map((clip, index) => { if (ids.has(clip.id)) throw Error("片段 ID 重复"); ids.add(clip.id); const asset = plan.assets.find((a) => a.id === clip.assetId); if (!asset || clip.sourceOut > asset.duration) throw Error("片段超出原始素材范围"); const duration = Math.round( (Math.round(((clip.sourceOut - clip.sourceIn) / clip.speed / SECOND) * 30) * SECOND) / 30, ); const previous = index ? plan.clips[index - 1] : null; const overlap = previous ? Math.min( Math.round(plan.transition * SECOND), Math.floor(duration / 3), Math.floor((previous.sourceOut - previous.sourceIn) / previous.speed / 3), ) : 0; cursor -= overlap; const start = cursor; cursor += duration; return { ...clip, start, end: cursor, duration, overlap }; }); if (new Set(plan.assets.map((a) => a.id)).size !== plan.assets.length) throw Error("素材 ID 重复"); if (cursor > 90 * SECOND || cursor < 200000) throw Error("成片时长需在 0.2–90 秒内"); return { clips, duration: cursor }; } export function compiledSubtitles(plan: EditPlan) { if (!plan.subtitles) return []; return planTimeline(plan).clips.flatMap((clip) => plan.assets .find((a) => a.id === clip.assetId)! .subtitles.flatMap((s) => { const a = Math.max(s.start, clip.sourceIn), b = Math.min(s.end, clip.sourceOut); if (b <= a) return []; // A crossfade's incoming subtitle starts when the outgoing subtitle ends. const start = Math.max( clip.start + clip.overlap, clip.start + Math.round((a - clip.sourceIn) / clip.speed), ); const end = Math.min(clip.end, clip.start + Math.round((b - clip.sourceIn) / clip.speed)); return end > start ? [{ start, end, text: s.text }] : []; }), ); } export function subtitlesToSrt(subtitles: z.infer[]) { const stamp = (v: number) => { const ms = Math.round(v / 1000); return `${String(Math.floor(ms / 3600000)).padStart(2, "0")}:${String(Math.floor(ms / 60000) % 60).padStart(2, "0")}:${String(Math.floor(ms / 1000) % 60).padStart(2, "0")},${String(ms % 1000).padStart(3, "0")}`; }; return subtitles .map( (s, i) => `${i + 1}\n${stamp(s.start)} --> ${stamp(s.end)}\n${s.text.replace(/\r/g, "").replace(/\n\s*\n/g, "\n")}`, ) .join("\n\n"); } export function parseSrt(text: string) { if (text.length > 200000) throw Error("字幕文件过大"); const stamp = (v: string) => { const [h, m, s] = v.replace(",", ".").split(":").map(Number); return Math.round((h * 3600 + m * 60 + s) * SECOND); }; const rows = text .replace(/^\uFEFF/, "") .replace(/\r/g, "") .trim() .split(/\n\s*\n/) .filter(Boolean) .map((block) => { const lines = block.split("\n"), i = lines.findIndex((l) => l.includes("-->")); const match = lines[i]?.match( /(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})/, ); if (!match) throw Error("SRT 时间格式无效"); return subtitleSchema.parse({ start: stamp(match[1]), end: stamp(match[2]), text: lines.slice(i + 1).join("\n"), }); }); if (rows.length > 500) throw Error("字幕最多 500 条"); return rows; } export function buildEditPlans( params: Extract, assets: EditAsset[], candidates: EditCandidate[], order: string[], beats: number[], warnings: string[], ): EditPlan[] { if (!candidates.length) throw Error("没有可剪辑片段,请关闭去静音或更换素材"); const valid = candidates.map((c) => candidateSchema.parse(c)); const seen = new Set(); const ordered = order.map((id) => { const c = valid.find((c) => c.id === id); if (!c || seen.has(id)) throw Error("选片模型返回了无效或重复片段 ID"); seen.add(id); return c; }); if (!ordered.length) throw Error("没有选中可用片段"); return Array.from({ length: params.mode === "ads" ? params.variants : 1 }, (_, variant) => { const list = variant ? [...ordered.slice(variant % ordered.length), ...ordered.slice(0, variant % ordered.length)] : ordered; const clips: EditPlan["clips"] = []; let duration = 0; for (const candidate of list) { if (duration >= params.targetDuration * SECOND || clips.length >= 12) break; const available = candidate.end - candidate.start, remaining = params.targetDuration * SECOND - duration; const speech = params.mode === "talk" || params.mode === "explain"; // Speech selections keep their sentence boundary even if it slightly misses requested length. let length = speech ? available : Math.min(available, remaining, 5 * SECOND); if (speech && duration + length > 90 * SECOND) continue; if (params.beatSync && !speech && beats.length) { const stride = Number(params.beatsPerCut); const desired = Math.min( 5 * SECOND, available, remaining / Math.max(1, Math.min(12 - clips.length, list.length - clips.length)), ); const next = beats .filter((_, i) => i % stride === 0) .map((t) => t - params.music!.start) .filter( (t) => t - duration >= 0.5 * SECOND && t - duration <= Math.min(available, remaining), ) .sort((a, b) => Math.abs(a - duration - desired) - Math.abs(b - duration - desired))[0]; if (next !== undefined) length = next - duration; } if (length < 200000) continue; clips.push({ id: `clip-${variant}-${clips.length}`, assetId: candidate.assetId, sourceIn: candidate.start, sourceOut: candidate.start + length, speed: 1, gain: 1, label: candidate.text.slice(0, 300) || `镜头 ${clips.length + 1}`, }); duration += length; } const plan: EditPlan = { version: 1, title: `${{ montage: "素材混剪", talk: "口播视频", explain: "讲解视频", ads: "批量广告" }[params.mode]}${variant ? ` ${variant + 1}` : ""}`, assets, clips, ratio: params.ratio, resolution: params.resolution, transition: 0, subtitles: true, music: params.music, warnings: [...warnings], }; const actual = planTimeline(plan).duration / SECOND; if (Math.abs(actual - params.targetDuration) > 0.5) plan.warnings.push( `目标 ${params.targetDuration} 秒,实际 ${actual.toFixed(2)} 秒;保留完整语句或受素材长度限制。`, ); if (params.mode === "ads" && params.variants > ordered.length) plan.warnings.push("候选镜头不足,部分广告版本可能相同。"); return plan; }); }