379 lines
15 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;
export const MAX_EDIT_SECONDS = 300;
export const editRatios = ["21:9", "16:9", "9:16", "1:1", "4:3", "3:4"] as const;
export const editResolutions = ["480p", "720p", "1080p"] as const;
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 captionStyleSchema = z.object({
fontSize: z.number().min(8).max(160).default(28),
color: z
.string()
.regex(/^#[0-9a-f]{6}$/i)
.default("#ffffff"),
outlineColor: z
.string()
.regex(/^#[0-9a-f]{6}$/i)
.default("#000000"),
outlineWidth: z.number().min(0).max(10).default(0),
opacity: z.number().min(0).max(1).default(1),
spacing: z.number().min(-5).max(30).default(0),
});
export const timelineCaptionSchema = z
.object({
id,
start: time,
end: time,
text: z.string().max(500),
style: captionStyleSchema.optional(),
})
.refine((x) => x.end > x.start);
export type TimelineCaption = z.infer<typeof timelineCaptionSchema>;
export type CaptionStyle = z.infer<typeof captionStyleSchema>;
export const defaultCaptionStyle: CaptionStyle = captionStyleSchema.parse({});
const transitionSchema = z.object({
duration: z.number().min(0).max(0.5),
type: z.enum(["fade", "fadeblack", "fadewhite"]),
});
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(""),
// The transition belongs to the incoming clip, so it follows that clip when reordered.
transition: transitionSchema.optional(),
})
.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(12),
clips: z.array(editClipSchema).min(1).max(12),
ratio: z.enum(editRatios),
resolution: z.enum(editResolutions).default("720p"),
transition: z.number().min(0).max(0.5).default(0),
subtitles: z.boolean().default(true),
captions: z.array(timelineCaptionSchema).max(500).optional(),
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(12),
mode: z.enum(["sequence", "auto", "montage", "talk", "explain", "ads"]).default("montage"),
prompt: z.string().max(3000).default(""),
targetDuration: z.number().int().min(0).max(MAX_EDIT_SECONDS).default(20),
ratio: z.enum(editRatios).default("16:9"),
resolution: z.enum(editResolutions).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 editOutputSize(plan: Pick<EditPlan, "ratio" | "resolution">) {
const size = Number.parseInt(plan.resolution, 10),
[w, h] = plan.ratio.split(":").map(Number);
const even = (v: number) => Math.round(v / 2) * 2;
return w >= h
? { width: even((size * w) / h), height: size }
: { width: size, height: even((size * h) / w) };
}
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((clip.transition?.duration ?? 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,
transitionType: clip.transition?.type || "fade",
};
});
if (new Set(plan.assets.map((a) => a.id)).size !== plan.assets.length)
throw Error("素材 ID 重复");
if (plan.captions && new Set(plan.captions.map((s) => s.id)).size !== plan.captions.length)
throw Error("字幕 ID 重复");
if (cursor > MAX_EDIT_SECONDS * SECOND || cursor < 200000)
throw Error("成片时长需在 0.2–300 秒内");
return { clips, duration: cursor };
}
export function compiledSubtitles(plan: EditPlan) {
if (!plan.subtitles) return [];
if (plan.captions) {
const duration = planTimeline(plan).duration;
return plan.captions.flatMap((s) => {
const end = Math.min(s.end, duration);
return end > s.start ? [{ ...s, end }] : [];
});
}
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 editableCaptions(plan: EditPlan): TimelineCaption[] {
if (plan.captions) return plan.captions;
return compiledSubtitles({ ...plan, subtitles: true }).map((s, i) => ({
...s,
id: `caption-${i}`,
}));
}
export function subtitlesToAss(plan: EditPlan, width: number, height: number) {
const stamp = (v: number) => {
const cs = Math.round(v / 10000);
return `${Math.floor(cs / 360000)}:${String(Math.floor(cs / 6000) % 60).padStart(2, "0")}:${String(Math.floor(cs / 100) % 60).padStart(2, "0")}.${String(cs % 100).padStart(2, "0")}`;
};
const color = (hex: string, opacity: number) =>
`&H${Math.round((1 - opacity) * 255)
.toString(16)
.padStart(2, "0")}${hex.slice(5, 7)}${hex.slice(3, 5)}${hex.slice(1, 3)}`;
// Style units use a 720px-high composition in both the browser and libass.
const rows = compiledSubtitles(plan) as Array<{
start: number;
end: number;
text: string;
style?: CaptionStyle;
}>;
const scale = height / 720;
const styles = rows.map((s, i) => {
const style = s.style || defaultCaptionStyle;
return `Style: c${i},Noto Sans CJK SC,${style.fontSize * scale},${color(style.color, style.opacity)},&H00FFFFFF,${color(style.outlineColor, style.opacity)},&HFF000000,0,0,0,0,100,100,${style.spacing * scale},0,1,${style.outlineWidth * scale},0,2,20,20,${Math.round(height * 0.05)},1`;
});
const events = rows.map((s, i) => {
const text = s.text
.replace(/\\/g, "\")
.replace(/\{/g, "{")
.replace(/\}/g, "}")
.replace(/\r/g, "")
.replace(/\n/g, "\\N");
return `Dialogue: 0,${stamp(s.start)},${stamp(s.end)},c${i},,0,0,0,,${text}`;
});
return `[Script Info]\nScriptType: v4.00+\nPlayResX: ${width}\nPlayResY: ${height}\nWrapStyle: 0\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n${styles.join("\n")}\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n${events.join("\n")}\n`;
}
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;
const target =
params.mode === "sequence"
? MAX_EDIT_SECONDS
: params.targetDuration ||
Math.min(
MAX_EDIT_SECONDS,
assets.reduce((sum, a) => sum + a.duration / SECOND, 0),
);
const visualLength = Math.max(5, Math.min(30, target / 12)) * SECOND;
for (const candidate of list) {
if (duration >= target * SECOND || clips.length >= 12) break;
const available = candidate.end - candidate.start,
remaining = target * 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 =
params.mode === "sequence"
? Math.min(available, remaining)
: speech
? available
: Math.min(available, remaining, visualLength);
if (speech && duration + length > MAX_EDIT_SECONDS * SECOND) continue;
if (params.mode !== "sequence" && params.beatSync && !speech && beats.length) {
const stride = Number(params.beatsPerCut);
const desired = Math.min(
visualLength,
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: `${{ sequence: "智能剪辑", auto: "默认模式", 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 (params.mode === "sequence" && assets.reduce((total, a) => total + a.duration, 0) > duration)
plan.warnings.push("时间轴已载入前 300 秒,超出的素材未加入;可在设置中另建剪辑方案。");
if (
params.mode !== "sequence" &&
params.targetDuration > 0 &&
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;
});
}