159 lines
4.7 KiB
JavaScript
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;
|
|
});
|