259 lines
9.9 KiB
TypeScript
259 lines
9.9 KiB
TypeScript
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;
|
||
});
|
||
}
|