Docs/Skills and code/skill video-visual-production
.claude/skills/video-visual-production/scripts/stitch.js
What the file says about itself
Stitch the built beats into one video: transitions between beats, one continuous ambient score, one mix. node stitch.js [--beats B0,B1,...] [--out <dir>] [--spec <score-spec.json>] [--no-score] [--video-only] [--audio-only] [--no-seams]…
#!/usr/bin/env node
// Stitch the built beats into one video: transitions between beats, one continuous ambient score, one mix.
// node stitch.js [--beats B0,B1,...] [--out <dir>] [--spec <score-spec.json>] [--no-score] [--video-only] [--audio-only] [--no-seams]
// Inputs per beat (from build-beat.js): <build>/B#/B#.mp4 (video), audio/voice.wav + audio/sfx.wav (32-bit float stems, mono,
// the same length as the video), audio/mix-report.json, B#.srt, and research/<topic>/video/timeline-B#.json.
// Video: only the frames around a transition are blended; every piece is cut by frame number, encoded once, then joined with -c copy.
// dissolve the outgoing 0.6 s end fade is trimmed and the last 12 frames cross-dissolve into the first 12 frames of the next beat
// dip the outgoing beat keeps its fade to black, the next beat fades in from black over 12 frames
// cut plain join (after a beat that ends on its own black scene)
// Audio: voice and effects stems are placed on the programme timeline (voice levels evened to the median, at most 4 dB), the score is
// one continuous synthesised track (score.js, one spec per beat, 3 s equal-power crossfades at the joins, ducked 3 dB under speech, gated
// in the beats' hard-stop black scenes), the sum is normalised to -14 LUFS with a true-peak ceiling and muxed as AAC.
// Writes in <out>: final.mp4, final-mix.wav, final-score-only.wav, final.srt, chapters.txt, stitch-plan.json, seams.png, and
// research/<topic>/video/STITCH-REPORT.md. It measures; it does not judge the music or the transitions.
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const U = require('./lib/util');
const D = require('./lib/dsp');
const SC = require('./score');
const MIX = require('./mix');
const FPS = 30, SR = 48000, SPF = SR / FPS; // samples per frame: 1600
const O = 12, TRIM = 18, FADEIN = 12; // dissolve overlap, outgoing fade trimmed, dip fade-in (frames)
const topic = U.arg('topic', 'the-fall-of-moser-baer'), slug = U.arg('slug', 'moser-baer-v7-hinglish');
const vdir = path.join(U.ROOT, 'research', topic, 'video');
const bdir = (b) => path.join(process.env.VIDEO_BUILD_DIR || 'C:/video-build', slug, b);
const spec = U.readJSON(U.fromRoot(U.arg('spec', path.join('research', topic, 'video', 'score-spec.json'))));
const G = spec.global;
const allBeats = Array.from({ length: 14 }, (_, i) => 'B' + i);
const beats = U.arg('beats', allBeats.join(',')).split(',').filter(Boolean);
const outDir = U.arg('out', path.join(process.env.VIDEO_BUILD_DIR || 'C:/video-build', slug, beats.length === 14 ? 'FINAL' : 'FINAL-test'));
fs.mkdirSync(path.join(outDir, 'pieces'), { recursive: true });
fs.mkdirSync(path.join(outDir, 'cache'), { recursive: true });
const fwd = (p) => p.split(path.sep).join('/');
// ---------------------------------------------------------------- plan: where every beat starts on the programme timeline
const B = beats.map((b) => {
const tl = U.readJSON(path.join(vdir, `timeline-${b}.json`));
const rep = U.readJSON(path.join(bdir(b), 'audio', 'mix-report.json'));
const status = fs.readFileSync(path.join(vdir, `STATUS-video-${b}.md`), 'utf8');
return { id: b, tl, rep, N: tl.total_frames, title: (status.match(/^# STATUS: video \S+ (.+)$/m) || [])[1] || b, mp4: path.join(bdir(b), b + '.mp4') };
});
B.forEach((x, i) => {
const next = B[i + 1];
x.out = next ? (spec.transitions[`${x.id}>${next.id}`] || spec.transitions.default || 'dissolve') : 'end';
x.in = i ? B[i - 1].out : 'start';
x.eff = x.out === 'dissolve' ? x.N - TRIM : x.N;
x.S = i ? B[i - 1].S + B[i - 1].eff - (B[i - 1].out === 'dissolve' ? O : 0) : 0;
});
const OUTRO = Math.round((G.outro_s || 1.5) * FPS);
const last = B[B.length - 1];
const TOTAL = last.S + last.N + OUTRO;
U.say(`plan: ${B.length} beats, ${TOTAL} frames = ${(TOTAL / FPS / 60).toFixed(2)} min; joins: ` + B.slice(0, -1).map((x) => `${x.id}>${B[B.indexOf(x) + 1].id} ${x.out}`).join(', '));
const tc = (s) => { const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); return (h ? h + ':' + String(m).padStart(2, '0') : String(m)) + ':' + String(Math.floor(s % 60)).padStart(2, '0'); };
const srtT = (s) => { const ms = Math.round(s * 1000), p = (n, w) => String(n).padStart(w, '0'); return `${p(Math.floor(ms / 3600000), 2)}:${p(Math.floor((ms % 3600000) / 60000), 2)}:${p(Math.floor((ms % 60000) / 1000), 2)},${p(ms % 1000, 3)}`; };
// ---------------------------------------------------------------- video
const ENC = ['-c:v', 'libx264', '-preset', 'medium', '-crf', '17', '-pix_fmt', 'yuv420p', '-r', String(FPS), '-an'];
const packets = (f) => parseInt(U.mustRun('ffprobe', ['-v', 'error', '-count_packets', '-select_streams', 'v:0', '-show_entries', 'stream=nb_read_packets', '-of', 'csv=p=0', f]).stdout.trim(), 10);
function piece(name, key, inputs, filter, frames) {
const hash = require('crypto').createHash('sha1').update(key + filter + ENC.join(' ')).digest('hex').slice(0, 10);
const file = path.join(outDir, 'pieces', `${name}-${hash}.mp4`);
if (fs.existsSync(file) && packets(file) === frames) return file;
const args = ['-y', '-v', 'error'];
inputs.forEach((i) => args.push('-i', i));
if (filter.includes(';')) args.push('-filter_complex', filter, '-map', '[v]'); else args.push('-vf', filter);
U.mustRun('ffmpeg', [...args, ...ENC, file]);
const n = packets(file);
if (n !== frames) U.die(`${name}: wrote ${n} frames, expected ${frames}`);
return file;
}
// the closing black needs a lavfi input, which piece() cannot express
function blackPiece() {
const hash = require('crypto').createHash('sha1').update(`black${OUTRO}` + ENC.join(' ')).digest('hex').slice(0, 10);
const file = path.join(outDir, 'pieces', `outro-black-${hash}.mp4`);
if (!(fs.existsSync(file) && packets(file) === OUTRO)) U.mustRun('ffmpeg', ['-y', '-v', 'error', '-f', 'lavfi', '-i', `color=c=black:s=1920x1080:r=${FPS}`, '-frames:v', String(OUTRO), ...ENC, file]);
return file;
}
function joinVideo() {
const files = [];
B.forEach((x, i) => {
const a = x.in === 'dissolve' ? O : 0, b = x.out === 'dissolve' ? x.eff - O : x.N;
const st = fs.statSync(x.mp4), key = `${x.id}|${st.size}|${st.mtimeMs}|${a}|${b}|${x.in}`;
let vf = `trim=start_frame=${a}:end_frame=${b},setpts=PTS-STARTPTS`;
if (x.in === 'dip') vf += `,fade=t=in:s=0:n=${FADEIN}`;
U.say(` video ${x.id} (${x.in} in, ${x.out} out): frames ${a}-${b}`);
files.push(piece(`${x.id}-body`, key, [x.mp4], vf, b - a));
if (x.out === 'dissolve') {
const y = B[i + 1], sy = fs.statSync(y.mp4);
const f = `[0:v]trim=start_frame=${x.eff - O}:end_frame=${x.eff},setpts=PTS-STARTPTS[a];[1:v]trim=start_frame=0:end_frame=${O},setpts=PTS-STARTPTS[b];[a][b]xfade=transition=fade:duration=${O / FPS}:offset=0[v]`;
files.push(piece(`${x.id}-${y.id}-dissolve`, `${key}|${sy.size}|${sy.mtimeMs}`, [x.mp4, y.mp4], f, O));
}
});
files.push(blackPiece());
const list = path.join(outDir, 'pieces', 'list.txt');
fs.writeFileSync(list, files.map((f) => `file '${fwd(f)}'`).join('\n') + '\n');
const out = path.join(outDir, 'video-only.mp4');
U.mustRun('ffmpeg', ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', out]);
const n = packets(out);
if (n !== TOTAL) U.die(`joined video has ${n} frames, the plan says ${TOTAL}`);
return out;
}
// ---------------------------------------------------------------- audio
function readMono(file) {
const r = spawnSync('ffmpeg', ['-v', 'error', '-i', file, '-f', 'f32le', '-ac', '1', '-ar', String(SR), 'pipe:1'], { maxBuffer: 1 << 30 });
if (r.status !== 0) U.die('could not read ' + file);
return D.f32FromBuffer(r.stdout);
}
// A growing window over the programme timeline that is written to disk as soon as nothing more will be added to it.
class Stream {
constructor(file) { this.fd = fs.openSync(file, 'w'); this.base = 0; this.L = new Float32Array(0); this.R = new Float32Array(0); this.written = 0; }
ensure(endSample) { const need = endSample - this.base; if (need > this.L.length) { const L = new Float32Array(need + SR * 5), R = new Float32Array(need + SR * 5); L.set(this.L); R.set(this.R); this.L = L; this.R = R; } }
add(start, l, r, gain = 1) { this.ensure(start + l.length); const o = start - this.base; if (o < 0) U.die('stream: add before the flushed point'); for (let i = 0; i < l.length; i++) { this.L[o + i] += l[i] * gain; this.R[o + i] += (r || l)[i] * gain; } }
flush(upTo) {
const n = Math.min(upTo - this.base, this.L.length); if (n <= 0) return;
const buf = Buffer.alloc(n * 8);
for (let i = 0; i < n; i++) { buf.writeFloatLE(this.L[i], i * 8); buf.writeFloatLE(this.R[i], i * 8 + 4); }
fs.writeSync(this.fd, buf); this.L = this.L.slice(n); this.R = this.R.slice(n); this.base += n; this.written += n;
}
close(total) { this.ensure(total); this.flush(total); fs.closeSync(this.fd); }
}
const dbv = (x) => 20 * Math.log10(Math.max(x, 1e-9));
const lin = (db) => Math.pow(10, db / 20);
function envelope(voice) { // 100 Hz speech activity, smoothed: quick to duck, slow to come back
const hop = SR / 100, n = Math.ceil(voice.length / hop), env = new Float32Array(n), thr = lin(-42);
const att = Math.exp(-1 / (0.12 * 100)), rel = Math.exp(-1 / (0.9 * 100));
let y = 0;
for (let k = 0; k < n; k++) {
let s = 0, c = 0; for (let i = k * hop; i < Math.min(voice.length, (k + 1) * hop); i += 4) { s += voice[i] * voice[i]; c++; }
const x = Math.sqrt(s / Math.max(1, c)) > thr ? 1 : 0;
y = x > y ? att * y + (1 - att) * x : rel * y + (1 - rel) * x; env[k] = y;
}
return env;
}
function gateEnv(n, ranges) { // 1 normally, 0 inside a hard-stop black scene, 0.35 s cosine ramps either side
const g = new Float32Array(n).fill(1);
for (const [f0, f1] of ranges) {
const t0 = f0 / FPS, t1 = (f1 + 1) / FPS;
for (let k = 0; k < n; k++) { const t = k / 100, d = t < t0 ? t0 - t : t > t1 ? t - t1 : -1; const v = d < 0 ? 0 : d >= 0.35 ? 1 : 0.5 - 0.5 * Math.cos((Math.PI * d) / 0.35); if (v < g[k]) g[k] = v; }
}
return g;
}
function synthScore(x, ws, we, atTail) { // one beat's score over [ws, we) samples, equal-power faded at both ends, cached
const bs = { ...(spec.defaults || {}), ...(spec.beats[x.id] || {}) };
const len = we - ws, dur = len / SR;
const key = require('crypto').createHash('sha1').update(JSON.stringify(bs) + '|' + len + '|' + fs.readFileSync(path.join(__dirname, 'score.js'))).digest('hex').slice(0, 12);
const cf = path.join(outDir, 'cache', `score-${x.id}-${key}.f32`);
let L, R;
if (fs.existsSync(cf)) { const a = D.f32FromBuffer(fs.readFileSync(cf)); const h = a.length / 2; L = a.subarray(0, h); R = a.subarray(h); }
else { [L, R] = SC.synthBeat(bs, dur + 1); const j = new Float32Array(L.length * 2); j.set(L); j.set(R, L.length); fs.writeFileSync(cf, D.f32ToBuffer(j)); }
const n12 = Math.round(dur * SC.SR);
const l = SC.upsample4(L, 0, n12), r = SC.upsample4(R, 0, n12);
const out = [new Float32Array(len), new Float32Array(len)];
const fin = Math.round((x.first ? 2.5 : G.crossfade_s) * SR), fout = Math.round((atTail ? 5 : G.crossfade_s) * SR);
for (let i = 0; i < len; i++) {
const a = i < fin ? Math.sin((Math.PI / 2) * (i / fin)) : 1, b = i > len - fout ? Math.cos((Math.PI / 2) * ((i - (len - fout)) / fout)) : 1;
out[0][i] = l[i] * a * b; out[1][i] = r[i] * a * b;
}
return { L: out[0], R: out[1], bs };
}
function buildAudio() {
const med = (a) => a.slice().sort((p, q) => p - q)[Math.floor(a.length / 2)];
const vT = med(B.map((x) => x.rep.voice_rms_dbfs));
const mixS = new Stream(path.join(outDir, 'premix.f32')), scoreS = new Stream(path.join(outDir, 'score-only.f32'));
const rows = [];
B.forEach((x, i) => {
x.first = i === 0;
const t0 = Date.now(), voice = readMono(path.join(bdir(x.id), 'audio', 'voice.wav')), sfx = readMono(path.join(bdir(x.id), 'audio', 'sfx.wav'));
const gv = Math.max(-4, Math.min(4, vT - x.rep.voice_rms_dbfs)), g = lin(gv), at = x.S * SPF;
const X = G.crossfade_s * SR, ws = i ? Math.max(0, at - Math.round(X / 2)) : 0;
const nextAt = i + 1 < B.length ? B[i + 1].S * SPF : TOTAL * SPF;
const we = i + 1 < B.length ? nextAt + Math.round(X / 2) : TOTAL * SPF;
mixS.flush(ws); scoreS.flush(ws);
// voice and effects (they stop short of the dissolve overlap: the tails are silent)
const keep = x.out === 'dissolve' ? (x.eff) * SPF : voice.length;
mixS.add(at, voice.subarray(0, keep), null, g); mixS.add(at, sfx.subarray(0, keep), null, g);
if (!U.flag('no-score')) {
const sc = synthScore(x, ws, we, i + 1 === B.length);
const rms = Math.sqrt(sc.L.reduce((s, v, k) => (k % 5 ? s : s + v * v), 0) / Math.ceil(sc.L.length / 5)) || 1e-9;
const tgt = lin(vT + G.under_voice_db + (sc.bs.level_db || 0)), scale = tgt / rms;
// ducking and black gating on a 100 Hz grid, in programme time
const env = envelope(voice), nHop = Math.ceil((we - ws) / (SR / 100));
const gate = sc.bs.gate_black === false ? null : gateEnv(nHop, x.tl.declared_black.filter(([a, b]) => b - a >= 30).map(([a, b]) => [a + x.S - ws / SPF, b + x.S - ws / SPF]));
for (let k = 0; k < nHop; k++) {
const vt = (ws + k * (SR / 100) - at) / (SR / 100), e = vt >= 0 && vt < env.length ? env[Math.floor(vt)] : 0;
const gain = (1 - (1 - lin(-G.duck_db)) * e) * (gate ? gate[k] : 1) * scale;
for (let s = k * (SR / 100); s < Math.min((k + 1) * (SR / 100), sc.L.length); s++) { sc.L[s] *= gain; sc.R[s] *= gain; }
}
mixS.add(ws, sc.L, sc.R); scoreS.add(ws, sc.L, sc.R);
x.score = { mood: sc.bs.mood, level_db: sc.bs.level_db || 0, rms_dbfs: +dbv(tgt).toFixed(1), window_s: [+(ws / SR).toFixed(1), +(we / SR).toFixed(1)] };
}
x.gv = +gv.toFixed(1);
U.say(` audio ${x.id}: voice ${gv >= 0 ? '+' : ''}${gv.toFixed(1)} dB, ${((Date.now() - t0) / 1000).toFixed(0)} s`);
rows.push(x.id);
});
mixS.close(TOTAL * SPF); scoreS.close(TOTAL * SPF);
const tmp = path.join(outDir, 'premix.wav');
U.mustRun('ffmpeg', ['-y', '-v', 'error', '-f', 'f32le', '-ar', String(SR), '-ac', '2', '-i', path.join(outDir, 'premix.f32'), '-c:a', 'pcm_f32le', tmp]);
const mixOut = path.join(outDir, 'final-mix.wav');
let nz = MIX.normalize(tmp, mixOut, G.target_lufs, G.true_peak_db);
fs.unlinkSync(path.join(outDir, 'premix.f32'));
return { nz, vT, mixOut, tmp };
}
// ---------------------------------------------------------------- captions, chapters
function writeSrt() {
const cues = [];
for (const x of B) {
const f = path.join(bdir(x.id), x.id + '.srt'); if (!fs.existsSync(f)) continue;
const text = fs.readFileSync(f, 'utf8').replace(/\r/g, '');
for (const blk of text.split('\n\n')) {
const ln = blk.split('\n').filter(Boolean); if (ln.length < 3) continue;
const m = ln[1].match(/(\d+):(\d+):(\d+),(\d+) --> (\d+):(\d+):(\d+),(\d+)/); if (!m) continue;
const a = +m[1] * 3600 + +m[2] * 60 + +m[3] + +m[4] / 1000, b = +m[5] * 3600 + +m[6] * 60 + +m[7] + +m[8] / 1000, off = x.S / FPS;
if (x.out === 'dissolve' && a >= x.eff / FPS) continue;
cues.push([a + off, b + off, ln.slice(2).join('\n')]);
}
}
fs.writeFileSync(path.join(outDir, 'final.srt'), cues.map((c, i) => `${i + 1}\n${srtT(c[0])} --> ${srtT(c[1])}\n${c[2]}\n`).join('\n'));
fs.writeFileSync(path.join(outDir, 'chapters.txt'), B.map((x) => `${tc(x.S / FPS)} ${x.title}`).join('\n') + '\n');
return cues.length;
}
// ---------------------------------------------------------------- checks and seams
function blackIntervals(file) {
const r = U.run('ffmpeg', ['-hide_banner', '-nostats', '-i', file, '-vf', 'blackdetect=d=0.25:pic_th=0.97:pix_th=0.06', '-an', '-f', 'null', '-']);
const out = []; const re = /black_start:([0-9.]+) black_end:([0-9.]+)/g; let m;
while ((m = re.exec(r.stderr))) out.push([+m[1], +m[2]]);
return out;
}
function seams(finalFile) {
if (U.flag('no-seams')) return null;
const dir = path.join(outDir, 'seams'); fs.mkdirSync(dir, { recursive: true });
let k = 0;
for (let i = 0; i + 1 < B.length; i++) {
const t = B[i + 1].S / FPS; // where the next beat begins
for (const dt of [-0.45, 0.2, 0.7]) { k++; U.mustRun('ffmpeg', ['-y', '-v', 'error', '-ss', String(Math.max(0, t + dt)), '-i', finalFile, '-frames:v', '1', '-vf', 'scale=480:-1', path.join(dir, `s${String(k).padStart(3, '0')}.png`)]); }
}
const sheet = path.join(outDir, 'seams.png');
U.mustRun('ffmpeg', ['-y', '-v', 'error', '-framerate', '1', '-i', path.join(dir, 's%03d.png'), '-vf', `tile=3x${B.length - 1}`, '-frames:v', '1', sheet]);
return sheet;
}
// ---------------------------------------------------------------- run
function main() {
const t0 = Date.now();
let videoFile = path.join(outDir, 'video-only.mp4');
if (!U.flag('audio-only')) { U.say('video:'); videoFile = joinVideo(); }
if (U.flag('video-only')) return U.say('video only: ' + videoFile);
U.say('audio:');
const au = buildAudio();
const finalFile = path.join(outDir, 'final.mp4');
const mux = () => U.mustRun('ffmpeg', ['-y', '-v', 'error', '-i', videoFile, '-i', au.mixOut, '-c:v', 'copy', '-c:a', 'aac', '-b:a', '256k', '-ar', String(SR), '-movflags', '+faststart', finalFile]);
mux();
let m = MIX.measure(finalFile);
// AAC adds a little overshoot: if the file's true peak is over the ceiling, normalise again to a lower ceiling and mux again
for (let tries = 0; tries < 3 && m.TP > -1; tries++) {
G.true_peak_db -= m.TP + 1 + 0.2;
U.say(` true peak ${m.TP} dBTP on the AAC file; normalising again with a ${G.true_peak_db.toFixed(1)} dBTP ceiling`);
au.nz = MIX.normalize(au.tmp, au.mixOut, G.target_lufs, G.true_peak_db);
mux(); m = MIX.measure(finalFile);
}
U.mustRun('ffmpeg', ['-y', '-v', 'error', '-f', 'f32le', '-ar', String(SR), '-ac', '2', '-i', path.join(outDir, 'score-only.f32'), '-af', `volume=${au.nz.gain_db}dB`, '-c:a', 'pcm_s16le', path.join(outDir, 'final-score-only.wav')]);
fs.unlinkSync(path.join(outDir, 'score-only.f32')); fs.unlinkSync(au.tmp);
const srtN = writeSrt();
const dur = (f, s) => parseFloat(U.mustRun('ffprobe', ['-v', 'error', '-select_streams', s, '-show_entries', 'stream=duration', '-of', 'csv=p=0', f]).stdout.trim());
const dv = dur(finalFile, 'v:0'), da = dur(finalFile, 'a:0');
// black stretches: expected ones are each beat's declared black and the outro
const expected = [];
B.forEach((x) => x.tl.declared_black.forEach(([a, b]) => expected.push([(a + x.S) / FPS - 0.5, (b + 1 + x.S) / FPS + 0.5])));
B.forEach((x) => { if (x.in === 'dip') expected.push([x.S / FPS - 0.2, x.S / FPS + 0.6]); });
expected.push([(TOTAL - OUTRO) / FPS - 1, TOTAL / FPS + 1]);
const blacks = blackIntervals(videoFile).map(([a, b]) => ({ from: +a.toFixed(2), to: +b.toFixed(2), declared: expected.some(([p, q]) => a >= p && b <= q + 0.05) }));
const seamSheet = seams(finalFile);
const rows = [
['frames = plan', packets(videoFile) === TOTAL, `${packets(videoFile)} of ${TOTAL}`],
['audio and video lengths agree (under 100 ms)', Math.abs(dv - da) < 0.1, `${Math.round((dv - da) * 1000)} ms`],
['integrated loudness -14 LUFS +-1', Math.abs(m.I - G.target_lufs) <= 1, `${m.I} LUFS, LRA ${m.LRA}`],
['true peak at most -1 dBTP (measured on the AAC file)', m.TP <= -1, `${m.TP} dBTP`],
['no undeclared black', blacks.every((b) => b.declared), blacks.filter((b) => !b.declared).map((b) => `${b.from}-${b.to}s`).join(', ') || 'none'],
];
rows.forEach((r) => U.say(` ${r[1] ? 'pass' : 'FAIL'} ${r[0]}: ${r[2]}`));
const plan = { generated: new Date().toISOString(), total_frames: TOTAL, duration: tc(TOTAL / FPS), voice_target_dbfs: au.vT, normalised: au.nz, measured_final: m, beats: B.map((x) => ({ id: x.id, title: x.title, start_s: +(x.S / FPS).toFixed(2), frames: x.N, in: x.in, out: x.out, voice_gain_db: x.gv, score: x.score })), black_intervals: blacks, checks: rows.map((r) => ({ check: r[0], pass: r[1], detail: r[2] })) };
U.writeJSON(path.join(outDir, 'stitch-plan.json'), plan);
if (beats.length === 14) writeReport(plan, rows, srtN, seamSheet, blacks);
U.say(`done in ${((Date.now() - t0) / 1000 / 60).toFixed(1)} min -> ${finalFile}${seamSheet ? '; seams: ' + seamSheet : ''}`);
if (rows.some((r) => !r[1])) process.exitCode = 2;
}
function writeReport(plan, rows, srtN, seamSheet, blacks) {
const L = [];
L.push('# STITCH REPORT: the whole video', '');
L.push(`Generated ${plan.generated}. Programme ${plan.duration} (${plan.total_frames} frames at 30 fps), ${B.length} beats. Measured by script; nothing here says the video is good, the music suits it, or the transitions feel right. Only the user can judge those.`, '');
L.push('| Check | Result | Detail |', '|---|---|---|');
rows.forEach((r) => L.push(`| ${r[0]} | ${r[1] ? 'pass' : '**FAIL**'} | ${r[2]} |`));
L.push('', `Black stretches found: ${blacks.map((b) => `${b.from}-${b.to}s${b.declared ? '' : ' UNDECLARED'}`).join(', ') || 'none'}. Captions: ${srtN} cues in final.srt (the beats' own captions, shifted). Chapters: chapters.txt.`, '');
L.push('## Beats, joins, score', '', '| Start | Beat | In | Out | Voice gain | Score mood |', '|---|---|---|---|---|---|');
plan.beats.forEach((b) => L.push(`| ${tc(b.start_s)} | ${b.id} ${b.title} | ${b.in} | ${b.out} | ${b.voice_gain_db >= 0 ? '+' : ''}${b.voice_gain_db} dB | ${b.score ? b.score.mood : 'none'} |`));
L.push('', '## How it was done', '',
'- **Joins.** dissolve: the outgoing beat\'s 0.6 s end fade is trimmed and its last 12 frames cross-dissolve into the next beat\'s first 12 frames (the next beat\'s voice starts 0.5 s in, so no word is covered). dip: the outgoing fade to black stays and the next beat fades in over 12 frames; used at the act breaks (' + Object.keys(spec.transitions).filter((k) => spec.transitions[k] === 'dip').join(', ') + '). cut: after a beat that ends on its own black scene (' + Object.keys(spec.transitions).filter((k) => spec.transitions[k] === 'cut').join(', ') + '). Change them in `score-spec.json` and rerun `stitch.js`; only the join pieces re-encode.',
'- **Voice.** Each beat\'s voice and effects stems are placed on the programme timeline. Voice levels differ between beats (measured RMS from -21.9 to -14.4 dBFS); each is moved toward the median by at most 4 dB so no beat jumps.',
'- **Score.** One continuous synthesised pad per beat (chords, a sub drone, faint air, a soft pulse in the tense beats), 3 s equal-power crossfades at each join, about 23 dB under the voice, ducked another 3 dB while the voice speaks, silent in the hard-stop black scenes (B0, B8), allowed to ring out over B13\'s closing black. `final-score-only.wav` is the score alone with the final gain.',
'- **Loudness.** The sum is normalised to -14 LUFS with a true-peak ceiling of ' + G.true_peak_db + ' dBTP, then encoded to AAC 256 kbps and measured again on the file.',
'', '## What is unproven', '',
'- Nobody has listened to the score. The moods are my reading of each beat\'s script, rendered by additive synthesis (sine partials through a small reverb); that is a plain pad, not a composed score. If it reads as synthetic or too present, `under_voice_db` and each beat\'s `level_db` in `score-spec.json` are the controls, and any licensed library track can replace `final-score-only.wav` in the mix.',
'- Nobody has watched the joins. `seams.png` shows three frames at each join (before, just after, later) so a cut or a dip can be checked at a glance, but motion and timing are for the user.',
'- Each beat still carries its own open items (see its STATUS and REVIEW files); stitching does not change them.');
fs.writeFileSync(path.join(vdir, 'STITCH-REPORT.md'), L.join('\n') + '\n');
}
try { main(); } catch (e) { U.die(e.stack || e.message); }
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.