259 lines
9.9 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 { 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<typeof editPlanSchema>;
export type EditAsset = z.infer<typeof editAssetSchema>;
export type EditCandidate = z.infer<typeof candidateSchema>;
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<typeof smartEditRequestSchema>;
export function planTimeline(input: EditPlan) {
const plan = editPlanSchema.parse(input),
ids = new Set<string>();
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<typeof subtitleSchema>[]) {
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<SmartEditRequest, { operation: "plan" }>,
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<string>();
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;
});
}