mirror of
https://github.com/digitalsamba/claude-code-video-toolkit.git
synced 2026-09-18 19:41:13 +08:00
80795b36ee
* FEAT: render-baseline harness — A/B render diff across Remotion bumps (#23 part 2) tests/render-baseline/: fixed 6-block composition (interpolate/spring, Sequence+text in a bundled font, OffthreadVideo on a committed testsrc clip, Audio, TransitionSeries with lib/transitions glitch+zoomBlur, lib/components) with duration from calculateMetadata. No system fonts, no network, no Math.random. scripts/render-baseline.mjs: render <label> (bundle once, one still per block at --scale=0.25 --gl=swangle), diff <a> <b> (pixelmatch perceptual + exact counts, diff PNGs, report.md/json, exit 1 over --threshold, default 0), install <version>, and ab <version> for local one-shot use. .github/workflows/render-baseline.yml: on PRs touching package.json under templates/examples/showcase, tests/render-baseline, lib/ or the script — renders base-branch pin and PR pin on the same runner, diffs, comments the table, uploads stills, fails on any difference so a human looks. workflow_dispatch takes a candidate version. Verified locally: same-version render is byte-identical (0 px on all six); 4.0.425 → 4.0.518 (93 patches) is also 0 px on all six. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * FIX: render-baseline workflow falls back to PR pin when base branch lacks the harness Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
204 lines
9.3 KiB
JavaScript
204 lines
9.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Render-baseline harness — detects visual changes across Remotion bumps.
|
|
*
|
|
* The idea: render a fixed composition (tests/render-baseline/) twice on the
|
|
* SAME machine — once at the current pin, once at the candidate version — and
|
|
* diff the two. Nothing is committed as a "golden" image, so cross-machine
|
|
* font/GPU/Chrome noise never enters the picture; only the bump does.
|
|
*
|
|
* Commands (run from anywhere; paths are resolved from the repo root):
|
|
*
|
|
* node scripts/render-baseline.mjs render <label>
|
|
* Render one PNG per block into tests/render-baseline/out/<label>/.
|
|
* Uses whatever Remotion version is installed in tests/render-baseline.
|
|
*
|
|
* node scripts/render-baseline.mjs diff <labelA> <labelB> [--threshold=0]
|
|
* Compare the two sets. Writes diff PNGs + report.json/report.md into
|
|
* tests/render-baseline/out/diff-<labelA>-vs-<labelB>/.
|
|
* Exit 1 if any frame's changed-pixel share exceeds --threshold (percent).
|
|
* Default 0 → any change fails, which is what we want until we have seen
|
|
* what "noise" looks like on a few real bumps.
|
|
*
|
|
* node scripts/render-baseline.mjs install <version>
|
|
* Rewrite remotion/@remotion/* pins in tests/render-baseline/package.json
|
|
* to <version> and npm install. Used by CI to set up the "after" side.
|
|
*
|
|
* node scripts/render-baseline.mjs ab <versionB> [--threshold=0]
|
|
* Convenience: render "before" at the current pin, install <versionB>,
|
|
* render "after", diff, then restore the original pin. Local use.
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const PROJECT = path.join(ROOT, 'tests', 'render-baseline');
|
|
const OUT = path.join(PROJECT, 'out');
|
|
const ENTRY = 'src/index.ts';
|
|
const COMP = 'Baseline';
|
|
const FRAMES_PER_BLOCK = 30;
|
|
const BLOCK_LABELS = ['easing', 'sequence-text', 'offthreadvideo', 'audio', 'transitions', 'components'];
|
|
const SCALE = 0.25;
|
|
|
|
function sh(cmd, args, opts = {}) {
|
|
const r = spawnSync(cmd, args, { cwd: PROJECT, stdio: 'inherit', shell: process.platform === 'win32', ...opts });
|
|
if (r.status !== 0) {
|
|
throw new Error(`${cmd} ${args.join(' ')} failed (exit ${r.status})`);
|
|
}
|
|
}
|
|
|
|
function installedVersion() {
|
|
const pj = path.join(PROJECT, 'node_modules', 'remotion', 'package.json');
|
|
return fs.existsSync(pj) ? JSON.parse(fs.readFileSync(pj, 'utf8')).version : null;
|
|
}
|
|
|
|
function pinnedVersion() {
|
|
const pj = JSON.parse(fs.readFileSync(path.join(PROJECT, 'package.json'), 'utf8'));
|
|
return pj.dependencies.remotion;
|
|
}
|
|
|
|
// ─── render ──────────────────────────────────────────────────
|
|
|
|
function render(label) {
|
|
if (!label) throw new Error('render needs a label');
|
|
const dir = path.join(OUT, label);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const ver = installedVersion();
|
|
console.log(`\n▶ render "${label}" with remotion ${ver}`);
|
|
// Bundle once, then render every still from the bundle (saves ~6 webpack runs).
|
|
const bundle = path.join(OUT, `bundle-${label}`);
|
|
fs.rmSync(bundle, { recursive: true, force: true });
|
|
sh('npx', ['remotion', 'bundle', ENTRY, `--out-dir=${bundle}`, '--log=error']);
|
|
BLOCK_LABELS.forEach((name, i) => {
|
|
const frame = i * FRAMES_PER_BLOCK + Math.floor(FRAMES_PER_BLOCK / 2);
|
|
const file = path.join(dir, `${String(i + 1).padStart(2, '0')}-${name}.png`);
|
|
sh('npx', [
|
|
'remotion', 'still', bundle, COMP, file,
|
|
`--frame=${frame}`, `--scale=${SCALE}`, '--image-format=png', '--gl=swangle', '--log=error',
|
|
]);
|
|
});
|
|
fs.rmSync(bundle, { recursive: true, force: true });
|
|
fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify({ label, remotion: ver, scale: SCALE, renderedAt: new Date().toISOString() }, null, 2));
|
|
console.log(` ${BLOCK_LABELS.length} stills → ${path.relative(ROOT, dir)}`);
|
|
}
|
|
|
|
// ─── diff ────────────────────────────────────────────────────
|
|
|
|
async function diff(a, b, thresholdPct = 0) {
|
|
if (!a || !b) throw new Error('diff needs two labels');
|
|
const { PNG } = await import(path.join(PROJECT, 'node_modules', 'pngjs', 'lib', 'png.js'));
|
|
const pixelmatch = (await import(path.join(PROJECT, 'node_modules', 'pixelmatch', 'index.js'))).default;
|
|
const dirA = path.join(OUT, a);
|
|
const dirB = path.join(OUT, b);
|
|
const dirD = path.join(OUT, `diff-${a}-vs-${b}`);
|
|
fs.rmSync(dirD, { recursive: true, force: true });
|
|
fs.mkdirSync(dirD, { recursive: true });
|
|
const metaA = JSON.parse(fs.readFileSync(path.join(dirA, 'meta.json'), 'utf8'));
|
|
const metaB = JSON.parse(fs.readFileSync(path.join(dirB, 'meta.json'), 'utf8'));
|
|
|
|
const rows = [];
|
|
let failed = false;
|
|
for (const f of fs.readdirSync(dirA).filter((n) => n.endsWith('.png')).sort()) {
|
|
const pa = PNG.sync.read(fs.readFileSync(path.join(dirA, f)));
|
|
const pbPath = path.join(dirB, f);
|
|
if (!fs.existsSync(pbPath)) {
|
|
rows.push({ frame: f, status: 'missing-in-b', changedPct: 100 });
|
|
failed = true;
|
|
continue;
|
|
}
|
|
const pb = PNG.sync.read(fs.readFileSync(pbPath));
|
|
if (pa.width !== pb.width || pa.height !== pb.height) {
|
|
rows.push({ frame: f, status: 'size-mismatch', changedPct: 100, a: `${pa.width}x${pa.height}`, b: `${pb.width}x${pb.height}` });
|
|
failed = true;
|
|
continue;
|
|
}
|
|
const out = new PNG({ width: pa.width, height: pa.height });
|
|
// Perceptual diff (what gates) plus an exact byte diff (for calibrating the threshold later).
|
|
const changed = pixelmatch(pa.data, pb.data, out.data, pa.width, pa.height, { threshold: 0.1, includeAA: false });
|
|
const exact = pixelmatch(pa.data, pb.data, null, pa.width, pa.height, { threshold: 0 });
|
|
const pct = (changed / (pa.width * pa.height)) * 100;
|
|
const status = pct > thresholdPct ? 'DIFFERS' : 'ok';
|
|
if (status === 'DIFFERS') {
|
|
failed = true;
|
|
fs.writeFileSync(path.join(dirD, f.replace('.png', '.diff.png')), PNG.sync.write(out));
|
|
}
|
|
rows.push({ frame: f, status, changedPixels: changed, changedPct: Number(pct.toFixed(3)), exactPixels: exact });
|
|
}
|
|
|
|
const report = { a: metaA, b: metaB, thresholdPct, failed, frames: rows };
|
|
fs.writeFileSync(path.join(dirD, 'report.json'), JSON.stringify(report, null, 2));
|
|
|
|
const md = [
|
|
`### Render baseline: remotion ${metaA.remotion} → ${metaB.remotion}`,
|
|
'',
|
|
`| Frame | Status | Changed px (perceptual) | % | Exact px |`,
|
|
`|---|---|---:|---:|---:|`,
|
|
...rows.map((r) => `| ${r.frame} | ${r.status === 'ok' ? '✅ ok' : `⚠️ ${r.status}`} | ${r.changedPixels ?? '—'} | ${r.changedPct} | ${r.exactPixels ?? '—'} |`),
|
|
'',
|
|
failed
|
|
? `**Result: differs** (threshold ${thresholdPct}%). Diff images in \`${path.relative(ROOT, dirD)}\` — review before merging.`
|
|
: `**Result: identical** within threshold ${thresholdPct}%.`,
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(dirD, 'report.md'), md + '\n');
|
|
console.log('\n' + md);
|
|
return !failed;
|
|
}
|
|
|
|
// ─── install ─────────────────────────────────────────────────
|
|
|
|
function install(version) {
|
|
if (!version) throw new Error('install needs a version');
|
|
const pjPath = path.join(PROJECT, 'package.json');
|
|
const pj = JSON.parse(fs.readFileSync(pjPath, 'utf8'));
|
|
for (const section of ['dependencies', 'devDependencies']) {
|
|
for (const name of Object.keys(pj[section] || {})) {
|
|
if (name === 'remotion' || name.startsWith('@remotion/')) pj[section][name] = version;
|
|
}
|
|
}
|
|
fs.writeFileSync(pjPath, JSON.stringify(pj, null, 2) + '\n');
|
|
console.log(`\n▶ install remotion ${version}`);
|
|
sh('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error']);
|
|
const got = installedVersion();
|
|
if (got !== version) throw new Error(`expected remotion ${version}, got ${got}`);
|
|
}
|
|
|
|
// ─── main ────────────────────────────────────────────────────
|
|
|
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
const flags = Object.fromEntries(rest.filter((x) => x.startsWith('--')).map((x) => x.slice(2).split('=')));
|
|
const args = rest.filter((x) => !x.startsWith('--'));
|
|
const threshold = Number(flags.threshold ?? 0);
|
|
|
|
try {
|
|
if (cmd === 'render') {
|
|
render(args[0]);
|
|
} else if (cmd === 'diff') {
|
|
const ok = await diff(args[0], args[1], threshold);
|
|
process.exit(ok ? 0 : 1);
|
|
} else if (cmd === 'install') {
|
|
install(args[0]);
|
|
} else if (cmd === 'ab') {
|
|
const original = pinnedVersion();
|
|
if (installedVersion() !== original) install(original);
|
|
render('before');
|
|
let ok = false;
|
|
try {
|
|
install(args[0]);
|
|
render('after');
|
|
ok = await diff('before', 'after', threshold);
|
|
} finally {
|
|
install(original);
|
|
}
|
|
process.exit(ok ? 0 : 1);
|
|
} else {
|
|
console.error('usage: render-baseline.mjs render <label> | diff <a> <b> [--threshold=0] | install <version> | ab <version>');
|
|
process.exit(2);
|
|
}
|
|
} catch (err) {
|
|
console.error(`\n✖ ${err.message}`);
|
|
process.exit(1);
|
|
}
|