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

159 lines
4.7 KiB
JavaScript

// 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,
);
}
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;
});