ai-chatbot/scripts/qa/video-canvas-smart-edit.cjs

242 lines
7.4 KiB
JavaScript
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.

// Offline integration: real PySceneDetect/librosa and FFmpeg, no provider generation requests.
const fs = require("node:fs"),
path = require("node:path"),
assert = require("node:assert/strict"),
{ execFileSync } = require("node:child_process");
(async () => {
const root = process.cwd(),
dir = path.resolve(".qa/smart-edit");
fs.mkdirSync(dir, { recursive: true });
execFileSync("python3", ["scripts/qa/fixtures/smart-edit.py"]);
execFileSync("ffmpeg", [
"-v",
"error",
"-y",
...["red", "blue", "green"].flatMap((c) => [
"-f",
"lavfi",
"-i",
`color=c=${c}:s=320x180:r=30:d=2`,
]),
"-filter_complex",
"[0:v][1:v][2:v]concat=n=3:v=1:a=0[v]",
"-map",
"[v]",
"-c:v",
"libx264",
"-threads",
"2",
path.join(dir, "colors.mp4"),
]);
execFileSync("ffmpeg", [
"-v",
"error",
"-y",
"-i",
path.join(dir, "colors.mp4"),
"-i",
path.join(dir, "speech.wav"),
"-c:v",
"copy",
"-c:a",
"aac",
path.join(dir, "with-audio.mp4"),
]);
const esbuild = require(
path.resolve(
"node_modules/.pnpm",
fs.readdirSync("node_modules/.pnpm").find((n) => n.startsWith("esbuild@0.25.")),
"node_modules/esbuild",
),
);
await esbuild.build({
entryPoints: ["lib/video-canvas/smart-edit/render.ts", "lib/video-canvas/smart-edit/local.ts"],
outdir: ".qa/smart-edit/build",
bundle: true,
platform: "node",
format: "cjs",
packages: "external",
alias: { "@": root },
});
const { renderEditPlan } = require(path.join(dir, "build/render.js")),
{ probeMedia, pythonAnalysis, prepareAnalysis } = require(path.join(dir, "build/local.js"));
const a = {
id: "a",
name: "无音轨",
url: "https://example.com/colors.mp4",
hash: "a".repeat(64),
...(await probeMedia(path.join(dir, "colors.mp4"))),
subtitles: [{ start: 0, end: 1000000, text: "红色测试" }],
},
b = {
id: "b",
name: "有音轨",
url: "https://example.com/with-audio.mp4",
hash: "b".repeat(64),
...(await probeMedia(path.join(dir, "with-audio.mp4"))),
subtitles: [{ start: 3000000, end: 4000000, text: "第二句" }],
};
const plan = {
version: 1,
title: "QA",
assets: [a, b],
ratio: "16:9",
resolution: "720p",
transition: 0,
subtitles: false,
warnings: [],
clips: [
{ id: "1", assetId: "a", sourceIn: 0, sourceOut: 2000000, speed: 1, gain: 1, label: "red" },
{
id: "2",
assetId: "b",
sourceIn: 2000000,
sourceOut: 6000000,
speed: 2,
gain: 1,
label: "blue-green",
},
],
};
const files = { a: path.join(dir, "colors.mp4"), b: path.join(dir, "with-audio.mp4") };
for (const transition of [0, 0.5]) {
const out = path.join(dir, "render-" + transition);
fs.mkdirSync(out, { recursive: true });
const result = await renderEditPlan(
{
...plan,
transition,
subtitles: true,
music: { url: "https://example.com/beats.wav", gain: 0.2, start: 0 },
},
files,
path.join(dir, "beats.wav"),
out,
);
const probe = await probeMedia(result.file);
assert.equal(probe.width, 1280);
assert.equal(probe.height, 720);
assert.equal(probe.hasAudio, true);
assert.ok(Math.abs(probe.duration / 1e6 - (4 - transition)) < 0.15, JSON.stringify(probe));
const srt = fs.readFileSync(result.srt, "utf8");
assert.match(srt, /红色测试/);
assert.match(srt, /第二句/);
console.log(
"PASS: actual FFmpeg " +
transition +
"s transition, speed 2x, silent source, music, subtitles, 720p and duration " +
probe.duration / 1e6,
);
}
for (const type of ["fade", "fadeblack", "fadewhite"]) {
const out = path.join(dir, "manual-" + type);
fs.mkdirSync(out, { recursive: true });
const mixed = {
...plan,
subtitles: true,
clips: Array.from({ length: 4 }, (_, i) => ({
...plan.clips[0],
id: "mixed-" + i,
sourceIn: (i % 3) * 1000000,
sourceOut: ((i % 3) + 1) * 1000000,
transition: { type, duration: i === 2 ? 0 : 0.3 },
})),
captions: [
{
id: "manual",
start: 0,
end: 2000000,
text: "手动字幕 测试",
style: {
fontSize: 36,
color: "#ffff00",
outlineColor: "#000000",
outlineWidth: 2,
opacity: 1,
spacing: 2,
},
},
],
};
const result = await renderEditPlan(mixed, files, undefined, out);
const probe = await probeMedia(result.file);
assert.ok(Math.abs(probe.duration / 1e6 - 3.4) < 0.15, JSON.stringify(probe));
assert.match(fs.readFileSync(path.join(out, "subtitles.ass"), "utf8"), /Noto Sans CJK SC,36/);
assert.match(fs.readFileSync(result.srt, "utf8"), /手动字幕/);
const pixel = execFileSync("ffmpeg", [
"-v",
"error",
"-ss",
"0.833333",
"-i",
result.file,
"-frames:v",
"1",
"-vf",
"crop=2:2:400:200",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-",
]);
if (type === "fadeblack") assert.ok(pixel[0] < 70, "black transition must darken the midpoint");
if (type === "fadewhite")
assert.ok(pixel[1] > 180, "white transition must brighten the midpoint");
if (type === "fade")
assert.ok(pixel[0] > 220 && pixel[1] < 20, "crossfade must preserve equal red sources");
console.log(
"PASS: real " +
type +
" → hard cut → " +
type +
", styled manual caption, duration " +
probe.duration / 1e6,
);
}
for (const ratio of ["21:9", "16:9", "9:16", "1:1", "4:3", "3:4"]) {
const out = path.join(dir, "ratio-" + ratio.replace(":", "-"));
fs.mkdirSync(out, { recursive: true });
const result = await renderEditPlan(
{ ...plan, ratio, resolution: "480p", clips: [{ ...plan.clips[0], sourceOut: 1000000 }] },
files,
undefined,
out,
);
const probe = await probeMedia(result.file);
const [w, h] = ratio.split(":").map(Number);
assert.equal(Math.min(probe.width, probe.height), 480);
assert.ok(Math.abs(probe.width / probe.height - w / h) < 0.005, JSON.stringify(probe));
assert.equal(probe.width % 2, 0);
assert.equal(probe.height % 2, 0);
console.log("PASS: real 480P " + ratio + " " + probe.width + "×" + probe.height);
}
if (process.env.SMART_EDIT_PYTHON) {
const prepared = await prepareAnalysis(files.b, "fixture", dir, true);
const analysis = await pythonAnalysis(
{ version: 1, video: prepared.proxy, audio: prepared.audio, duration: 6000000 },
dir,
);
assert.ok(analysis.scenes.length >= 3, JSON.stringify(analysis));
assert.ok(analysis.active.length >= 2, JSON.stringify(analysis));
const beats = await pythonAnalysis(
{ version: 1, audio: path.join(dir, "beats.wav"), beats: true },
dir,
);
assert.ok(beats.beats.length > 10, JSON.stringify(beats));
console.log(
"PASS: actual PySceneDetect found " +
analysis.scenes.length +
" scenes; RMS gate " +
analysis.active.length +
" active intervals; librosa " +
beats.beats.length +
" beats",
);
}
fs.writeFileSync(path.join(dir, "fixture-plan.json"), JSON.stringify(plan, null, 2));
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});