belt-trailer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +78 -0
  3. data/CHANGELOG.md +22 -0
  4. data/LICENSE +21 -0
  5. data/README.md +223 -0
  6. data/engine/bin/trailer.mjs +45 -0
  7. data/engine/lib/args.mjs +18 -0
  8. data/engine/lib/camera.mjs +140 -0
  9. data/engine/lib/check.mjs +134 -0
  10. data/engine/lib/clock.mjs +38 -0
  11. data/engine/lib/config.mjs +97 -0
  12. data/engine/lib/cut.mjs +169 -0
  13. data/engine/lib/ease.mjs +6 -0
  14. data/engine/lib/ffmpeg.mjs +46 -0
  15. data/engine/lib/film.mjs +161 -0
  16. data/engine/lib/index.mjs +5 -0
  17. data/engine/lib/showcase.mjs +83 -0
  18. data/engine/lib/sound.mjs +145 -0
  19. data/engine/lib/titles.mjs +43 -0
  20. data/engine/package.json +14 -0
  21. data/engine/titles.html +144 -0
  22. data/examples/posh-nosh/trailer-titles.css +26 -0
  23. data/examples/posh-nosh/trailer.config.mjs +168 -0
  24. data/lib/belt/commands/trailer_command.rb +123 -0
  25. data/lib/belt/generators/trailer_generator.rb +215 -0
  26. data/lib/belt/trailer/templates/showcase/build.mjs +24 -0
  27. data/lib/belt/trailer/templates/showcase/fixtures.js.erb +22 -0
  28. data/lib/belt/trailer/templates/showcase/showcase.html +14 -0
  29. data/lib/belt/trailer/templates/showcase/showcase.jsx +50 -0
  30. data/lib/belt/trailer/templates/trailer-storyboard.md.erb +37 -0
  31. data/lib/belt/trailer/templates/trailer-titles.css +22 -0
  32. data/lib/belt/trailer/templates/trailer.config.mjs.erb +69 -0
  33. data/lib/belt/trailer/version.rb +7 -0
  34. data/lib/belt/trailer.rb +69 -0
  35. data/lib/belt-trailer.rb +3 -0
  36. data/skills/belt-trailer/SKILL.md +128 -0
  37. data/skills/belt-trailer/references/beats.md +136 -0
  38. data/skills/belt-trailer/references/reviewing.md +90 -0
  39. data/skills/belt-trailer/references/showcase-and-fixtures.md +80 -0
  40. data/skills/belt-trailer/references/storyboard.md +87 -0
  41. metadata +102 -0
@@ -0,0 +1,134 @@
1
+ // Passes or fails a cut by measurement, and writes a contact sheet with
2
+ // timestamps for a person to look at. Each format's trailer must:
3
+ // - have audio from 0 that lasts as long as the video (±0.05 s)
4
+ // - sit at the target loudness (−14 LUFS ±1) with true peak below −1 dBFS
5
+ // - be no longer than the limit (30 s, an App Store preview)
6
+ // and each beat's clips must:
7
+ // - sound every captured effect within one frame of the frame it was
8
+ // captured on (measured on the clean clip, which has no music)
9
+ // - have no title left on screen in the last 0.5 s
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { ffmpeg, loudness, measure, onsets, probe } from './ffmpeg.mjs';
13
+ import { loadConfig, outDir, pickFormats } from './config.mjs';
14
+
15
+ const TOLERANCE = 0.05;
16
+ const CLEAR_AT_END = 0.5;
17
+ // Titled and clean clips differ only by the titles and the second encode;
18
+ // above this PSNR (dB) over the last half second, no title is on screen.
19
+ const SAME_PICTURE_DB = 38;
20
+
21
+ // Stretches of sound in a clip, from silencedetect's report.
22
+ export function soundSpans(report, length) {
23
+ const starts = [...report.matchAll(/silence_start: (-?[\d.]+)/g)].map(m => Number(m[1]));
24
+ const ends = [...report.matchAll(/silence_end: (-?[\d.]+)/g)].map(m => Number(m[1]));
25
+ const spans = [];
26
+ let from = starts.length && starts[0] <= 0.001 ? null : 0;
27
+ for (const event of [...starts.map(at => ({ at, start: true })), ...ends.map(at => ({ at, start: false }))].sort((a, b) => a.at - b.at)) {
28
+ if (event.start && from != null) { spans.push([from, event.at]); from = null; }
29
+ if (!event.start) from = event.at;
30
+ }
31
+ if (from != null) spans.push([from, length]);
32
+ return spans;
33
+ }
34
+
35
+ // Whether each captured sound starts in the clip where the JSON says, give
36
+ // or take a frame. The expected start is the capture time plus the file's own
37
+ // lead-in silence. A sound that starts while another is still ringing has no
38
+ // onset of its own to find, and is counted as masked rather than failed.
39
+ export function matchSounds(sounds, spans, frame) {
40
+ return sounds.map(s => {
41
+ if (s.lead == null) return { ...s, result: 'silent' };
42
+ const expected = s.at + s.lead;
43
+ const hit = spans.find(([from]) => Math.abs(from - expected) <= frame + 1e-3);
44
+ if (hit) return { ...s, expected, found: hit[0], result: 'ok' };
45
+ const inside = spans.find(([from, to]) => from < expected - frame && to > expected);
46
+ if (inside) return { ...s, expected, result: 'masked' };
47
+ const nearest = spans.map(([from]) => from).sort((a, b) => Math.abs(a - expected) - Math.abs(b - expected))[0];
48
+ return { ...s, expected, found: nearest, result: 'off' };
49
+ });
50
+ }
51
+
52
+ function contactSheet(video, seconds, format, out) {
53
+ const tall = format.out[1] > format.out[0];
54
+ const columns = tall ? 10 : 8;
55
+ const rows = Math.max(1, Math.ceil((seconds * 2) / columns));
56
+ ffmpeg(['-i', video, '-vf',
57
+ `fps=2,scale=${tall ? 160 : 240}:-2,drawtext=text='%{pts\\:hms}':x=4:y=4:fontsize=16:fontcolor=yellow:box=1:boxcolor=black@0.5,tile=${columns}x${rows}`,
58
+ '-frames:v', '1', '-update', '1', out]);
59
+ }
60
+
61
+ export async function check(appDir, opts = {}) {
62
+ const config = await loadConfig(appDir, opts.config);
63
+ const dir = outDir(config, opts.out);
64
+ const frame = 1 / config.fps;
65
+ const leadCache = new Map();
66
+ const leadOf = file => {
67
+ if (!leadCache.has(file)) leadCache.set(file, fs.existsSync(file) ? (onsets(file)[0] ?? null) : null);
68
+ return leadCache.get(file);
69
+ };
70
+ let pass = true;
71
+ const line = (ok, text) => { if (ok === false) pass = false; console.log(` ${ok === false ? '✗' : ok === true ? '✓' : '·'} ${text}`); };
72
+
73
+ for (const format of pickFormats(config, opts.format)) {
74
+ const suffix = opts.music && opts.music !== 'default' ? `-${opts.music}` : '';
75
+ const video = path.join(dir, `trailer-${format.name}${suffix}.mp4`);
76
+ console.log(`\n${path.relative(process.cwd(), video)}`);
77
+ if (!fs.existsSync(video)) { line(false, `missing: run belt trailer cut --format ${format.name}`); continue; }
78
+
79
+ const { streams, format: container } = probe(video);
80
+ const v = streams.find(s => s.codec_type === 'video');
81
+ const a = streams.find(s => s.codec_type === 'audio');
82
+ const length = Number(v?.duration ?? container.duration);
83
+ if (!a) line(false, 'no audio');
84
+ else {
85
+ const start = Number(a.start_time);
86
+ line(Math.abs(start) <= TOLERANCE, `audio starts at ${start.toFixed(3)} s (0 ±${TOLERANCE})`);
87
+ line(Math.abs(Number(a.duration) - length) <= TOLERANCE, `audio ${Number(a.duration).toFixed(3)} s, video ${length.toFixed(3)} s (±${TOLERANCE})`);
88
+ }
89
+ const { integrated, truePeak } = loudness(video);
90
+ const { loudness: target, tolerance, truePeak: ceiling, maxSeconds } = config.check;
91
+ line(Math.abs(integrated - target) <= tolerance, `loudness ${integrated} LUFS (${target} ±${tolerance})`);
92
+ line(truePeak < ceiling, `true peak ${truePeak} dBFS (below ${ceiling})`);
93
+ line(length <= maxSeconds + frame / 2, `${length.toFixed(2)} s long (at most ${maxSeconds})`);
94
+
95
+ for (const { name } of config.beats) {
96
+ const meta = path.join(dir, `${name}-${format.name}.json`);
97
+ const clean = path.join(dir, `${name}-${format.name}-clean.mp4`);
98
+ const titled = path.join(dir, `${name}-${format.name}.mp4`);
99
+ if (!fs.existsSync(meta) || !fs.existsSync(clean)) { line(false, `${name}: clips missing`); continue; }
100
+ const beat = JSON.parse(fs.readFileSync(meta, 'utf8'));
101
+
102
+ const report = measure(['-i', clean, '-af', 'highpass=f=200,silencedetect=n=-40dB:d=0.05']);
103
+ const sounds = beat.sounds.map(s => ({ ...s, lead: leadOf(path.resolve(config.appDir, s.file)) }));
104
+ const matched = matchSounds(sounds, soundSpans(report, beat.seconds), frame);
105
+ const off = matched.filter(m => m.result === 'off');
106
+ const masked = matched.filter(m => m.result === 'masked').length;
107
+ const counts = `${matched.filter(m => m.result === 'ok').length}/${matched.length} on their frame${masked ? `, ${masked} under another sound` : ''}`;
108
+ line(off.length === 0, `${name}: effects ${counts}`);
109
+ for (const m of off) {
110
+ console.log(` ${m.file} expected at ${m.expected.toFixed(3)} s, nearest onset ${m.found == null ? 'none' : `${m.found.toFixed(3)} s`}`);
111
+ }
112
+
113
+ if (beat.endCard) line(null, `${name}: ends on the end card`);
114
+ else if (fs.existsSync(titled)) {
115
+ const from = Math.max(0, beat.seconds - CLEAR_AT_END);
116
+ const psnr = measure(['-ss', String(from), '-i', titled, '-ss', String(from), '-i', clean, '-lavfi', '[0:v][1:v]psnr']);
117
+ const avg = psnr.match(/average:(\S+)/)?.[1];
118
+ const db = avg === 'inf' ? Infinity : Number(avg);
119
+ line(db >= SAME_PICTURE_DB, `${name}: no title in the last ${CLEAR_AT_END} s (PSNR ${avg} dB against the clean clip)`);
120
+ }
121
+ }
122
+
123
+ const sheet = video.replace(/\.mp4$/, '-sheet.png');
124
+ contactSheet(video, length, format, sheet);
125
+ console.log(` contact sheet: ${path.relative(process.cwd(), sheet)}`);
126
+ const manifest = video.replace(/\.mp4$/, '.json');
127
+ if (fs.existsSync(manifest)) {
128
+ const files = [...new Set(JSON.parse(fs.readFileSync(manifest, 'utf8')).sources.map(s => `${s.kind}: ${s.file}`))].sort();
129
+ console.log(` audio in the cut (check the rights to each before publishing):\n${files.map(f => ` ${f}`).join('\n') || ' none'}`);
130
+ }
131
+ }
132
+ console.log(pass ? '\nPASS' : '\nFAIL');
133
+ return pass;
134
+ }
@@ -0,0 +1,38 @@
1
+ // The page's clock, stopped, and moved on a frame at a time.
2
+ //
3
+ // Headless Chromium's recordVideo and its CDP screencast both capture at CSS
4
+ // pixels and ignore deviceScaleFactor, so 1080p comes out blurry. Instead the
5
+ // page's clock is installed and paused, and before each frame every timer and
6
+ // every animation is moved on by exactly one frame, so a clip runs at the
7
+ // app's real speed however long a frame takes to capture.
8
+
9
+ // Stops the page's installed clock, which runs at wall speed until paused.
10
+ // The pause has to be set a little ahead of the page's time, and a busy page
11
+ // can run past it before the call lands ("Cannot fast-forward to the past"),
12
+ // so it tries again further ahead.
13
+ export async function freeze(page) {
14
+ for (let ahead = 250; ; ahead *= 2) {
15
+ try {
16
+ await page.clock.pauseAt(await page.evaluate(() => Date.now()) + ahead);
17
+ return;
18
+ } catch (error) {
19
+ if (ahead > 8000 || !/past/.test(error.message)) throw error;
20
+ }
21
+ }
22
+ }
23
+
24
+ // Moves every timer and animation on the page to `now` ms of filming. Each
25
+ // animation keeps the time it was first seen at; one past its end is
26
+ // finished, so its animationend events fire.
27
+ export async function step(page, now, frameMs) {
28
+ await page.clock.runFor(frameMs);
29
+ await page.evaluate(t => {
30
+ const seen = (window.__filmed ||= new WeakMap());
31
+ for (const a of document.getAnimations()) {
32
+ if (!seen.has(a)) seen.set(a, t - (a.currentTime ?? 0));
33
+ const at = t - seen.get(a);
34
+ if (at >= a.effect.getComputedTiming().endTime) a.finish();
35
+ else { a.pause(); a.currentTime = at; }
36
+ }
37
+ }, now);
38
+ }
@@ -0,0 +1,97 @@
1
+ // An app's trailer.config.mjs, with the defaults filled in. Nothing here
2
+ // knows about Belt: the app is a directory with a package.json (for its own
3
+ // Playwright) and a config file.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createRequire } from 'node:module';
7
+ import { pathToFileURL } from 'node:url';
8
+
9
+ export const DEFAULTS = {
10
+ fps: 30,
11
+ out: 'tmp/trailer',
12
+ // Wide is YouTube, store pages and the site; tall is Shorts, Reels and
13
+ // TikTok, laid out as the app is on a phone.
14
+ formats: {
15
+ wide: { page: [1280, 720], out: [1920, 1080] },
16
+ tall: { page: [432, 768], out: [1080, 1920] },
17
+ },
18
+ // How long the page's own clock runs before a beat starts filming (fonts,
19
+ // art, entrance animations), and how long the app gets to load its sounds
20
+ // once audio is unlocked.
21
+ settle: 1500,
22
+ soundLoad: 1500,
23
+ titles: { css: null, endCard: null, leaveBefore: 0.8 },
24
+ sounds: { bundled: 'public', live: null, ignore: [], cache: 'tmp/trailer-sounds' },
25
+ music: { level: 0.7, fade: 1.5, loudness: -16, plan: {}, plans: {}, tracks: {} },
26
+ mix: { loudness: -14, limit: 0.63 },
27
+ check: { maxSeconds: 30, loudness: -14, tolerance: 1, truePeak: -1 },
28
+ };
29
+
30
+ // Loads `file` (relative to `appDir`) and fills in the defaults.
31
+ export async function loadConfig(appDir, file = 'trailer.config.mjs') {
32
+ const full = path.resolve(appDir, file);
33
+ if (!fs.existsSync(full)) throw new Error(`No ${path.relative(process.cwd(), full)}: run \`belt generate trailer\` first`);
34
+ const { default: raw } = await import(pathToFileURL(full).href);
35
+ return normalize(raw, appDir);
36
+ }
37
+
38
+ export function normalize(raw, appDir) {
39
+ if (!raw || typeof raw !== 'object') throw new Error('trailer.config.mjs must `export default` an object');
40
+ const config = {
41
+ ...DEFAULTS,
42
+ ...raw,
43
+ appDir: path.resolve(appDir),
44
+ formats: raw.formats || DEFAULTS.formats,
45
+ titles: { ...DEFAULTS.titles, ...raw.titles },
46
+ sounds: { ...DEFAULTS.sounds, ...raw.sounds },
47
+ music: { ...DEFAULTS.music, ...raw.music },
48
+ mix: { ...DEFAULTS.mix, ...raw.mix },
49
+ check: { ...DEFAULTS.check, ...raw.check },
50
+ };
51
+ if (!config.showcase || !(config.showcase.build || config.showcase.url)) {
52
+ throw new Error('trailer.config.mjs needs showcase: { build } (a module that builds the showcase) or showcase: { url }');
53
+ }
54
+ if (!Array.isArray(config.beats) || !config.beats.length) throw new Error('trailer.config.mjs has no beats');
55
+ const names = new Set();
56
+ for (const beat of config.beats) {
57
+ if (!beat.name || typeof beat.play !== 'function') throw new Error('every beat needs a name and a play function');
58
+ if (names.has(beat.name)) throw new Error(`two beats are called ${beat.name}`);
59
+ names.add(beat.name);
60
+ }
61
+ return config;
62
+ }
63
+
64
+ // The formats asked for: 'both' (every configured format), one name, or a
65
+ // comma-separated list.
66
+ export function pickFormats(config, wanted = 'both') {
67
+ const all = Object.entries(config.formats).map(([name, f]) => ({ name, ...f }));
68
+ if (!wanted || wanted === 'both' || wanted === 'all') return all;
69
+ const names = wanted.split(',');
70
+ const unknown = names.filter(n => !config.formats[n]);
71
+ if (unknown.length) throw new Error(`no format called ${unknown.join(', ')} (have ${Object.keys(config.formats).join(', ')})`);
72
+ return all.filter(f => names.includes(f.name));
73
+ }
74
+
75
+ // The beats asked for with --only, in storyboard order.
76
+ export function pickBeats(config, only) {
77
+ if (!only) return config.beats;
78
+ const unknown = only.filter(n => !config.beats.some(b => b.name === n));
79
+ if (unknown.length) throw new Error(`no beat called ${unknown.join(', ')} (have ${config.beats.map(b => b.name).join(', ')})`);
80
+ return config.beats.filter(b => only.includes(b.name));
81
+ }
82
+
83
+ // A module from the app's own node_modules (its Playwright, its Vite), so the
84
+ // engine runs from the gem's directory and is never copied into the app.
85
+ export function requireFromApp(appDir, name) {
86
+ const require = createRequire(path.join(appDir, 'package.json'));
87
+ try {
88
+ return require(name);
89
+ } catch (error) {
90
+ if (error.code === 'MODULE_NOT_FOUND') {
91
+ throw new Error(`${name} isn't installed in ${appDir}: npm install -D ${name}${name === 'playwright' ? ' && npx playwright install chromium' : ''}`);
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ export const outDir = (config, override) => path.resolve(config.appDir, override || config.out);
@@ -0,0 +1,169 @@
1
+ // Joins the filmed beats in storyboard order and lays the sound under them:
2
+ // every effect where the app played it, ringing on past its own beat, and the
3
+ // music by the plan. Writes trailer-<format>.mp4 and trailer-<format>.json
4
+ // (where each beat starts, and every audio file in the cut).
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { ffmpeg, loudness } from './ffmpeg.mjs';
8
+ import { loadConfig, outDir, pickFormats } from './config.mjs';
9
+ import { liveSounds } from './sound.mjs';
10
+
11
+ // Runs of beats under the same music track. A beat's 'hush' mark stops its
12
+ // music there (the moment of a win, say), and the rest of that beat is silent.
13
+ export function planRuns(beats) {
14
+ const runs = [];
15
+ for (const beat of beats) {
16
+ const last = runs[runs.length - 1];
17
+ const end = beat.start + (beat.hush ?? beat.seconds);
18
+ if (last && last.track === beat.track && !last.hushed) last.end = end;
19
+ else runs.push({ track: beat.track, start: beat.start, end });
20
+ if (beat.hush != null) {
21
+ runs[runs.length - 1].hushed = true;
22
+ runs.push({ track: null, start: end, end: beat.start + beat.seconds });
23
+ }
24
+ }
25
+ return runs;
26
+ }
27
+
28
+ // How one music run sits in the mix, in seconds. From one track to the next
29
+ // the crossfade ends on the cut, so the new track is all there when its beat
30
+ // lands; out of silence it comes in quickly, on the cut. Into the next track
31
+ // it fades out over the crossfade before the cut; into silence it fades after
32
+ // it; at the end of the trailer, over the last two seconds.
33
+ export function runEnvelope(runs, i, total, fade) {
34
+ const run = runs[i];
35
+ const fromMusic = Boolean(runs[i - 1]?.track);
36
+ const intoMusic = Boolean(runs[i + 1]?.track);
37
+ const begin = fromMusic ? Math.max(run.start - fade, 0) : run.start;
38
+ const last = run.end >= total - 1e-6;
39
+ return {
40
+ begin,
41
+ fadeIn: fromMusic ? run.start - begin : 0.3,
42
+ stop: last ? total : intoMusic ? run.end : run.end + fade,
43
+ fadeOut: last ? 2 : fade,
44
+ outAt: Math.max((intoMusic ? run.end - fade : last ? total - 2 : run.end) - begin, 0),
45
+ };
46
+ }
47
+
48
+ // The music plan to cut with: the default `music.plan`, or a named one from
49
+ // `music.plans`.
50
+ export function pickPlan(config, name) {
51
+ if (!name || name === 'default') return config.music.plan;
52
+ const plan = config.music.plans?.[name];
53
+ if (!plan) throw new Error(`no music plan called ${name} (have default${Object.keys(config.music.plans || {}).map(n => `, ${n}`).join('')})`);
54
+ return plan;
55
+ }
56
+
57
+ // The file a track plays: a path in the app, or a function given the live
58
+ // sounds (or null) that returns one.
59
+ async function trackFile(config, live, name) {
60
+ const track = config.music.tracks[name];
61
+ if (!track) throw new Error(`the music plan names a track called ${name}, but music.tracks has no ${name}`);
62
+ const file = typeof track === 'function' ? await track({ live }) : track;
63
+ return path.resolve(config.appDir, file);
64
+ }
65
+
66
+ export async function cut(appDir, opts = {}) {
67
+ const config = await loadConfig(appDir, opts.config);
68
+ const dir = outDir(config, opts.out);
69
+ const plan = pickPlan(config, opts.music);
70
+ const live = liveSounds(config, { bundled: opts.sounds === 'bundled' });
71
+ const { fade, level } = config.music;
72
+ const written = [];
73
+
74
+ for (const format of pickFormats(config, opts.format)) {
75
+ let t = 0;
76
+ const beats = config.beats.map(({ name }) => {
77
+ const file = path.join(dir, `${name}-${format.name}.mp4`);
78
+ const meta = path.join(dir, `${name}-${format.name}.json`);
79
+ if (!fs.existsSync(file) || !fs.existsSync(meta)) {
80
+ throw new Error(`${path.relative(process.cwd(), file)} is missing: film it first (belt trailer film --only ${name} --format ${format.name})`);
81
+ }
82
+ const { seconds, sounds, marks = {} } = JSON.parse(fs.readFileSync(meta, 'utf8'));
83
+ const beat = { name, track: plan[name] ?? null, file, start: t, seconds, sounds, hush: marks.hush };
84
+ t += seconds;
85
+ return beat;
86
+ });
87
+ const total = t;
88
+ const runs = planRuns(beats);
89
+ if (!runs[0].track) console.warn(` ! no music under ${beats[0].name}: a silent cold open reads as a broken video`);
90
+
91
+ const inputs = [];
92
+ const parts = [];
93
+ const sources = [];
94
+ // Input 0 is the video; sound n is input n + 1.
95
+ const add = file => { inputs.push(file); return inputs.length; };
96
+ const ms = s => Math.round(s * 1000);
97
+
98
+ for (const [i, run] of runs.entries()) {
99
+ if (!run.track) continue;
100
+ const file = await trackFile(config, live, run.track);
101
+ const n = add(file);
102
+ sources.push({ kind: 'music', track: run.track, file: path.relative(config.appDir, file), at: Number(run.start.toFixed(3)) });
103
+ const e = runEnvelope(runs, i, total, fade);
104
+ // Each track brought to the same loudness by one measured gain, so the
105
+ // tracks sit level with each other; a leveller working as it goes
106
+ // would duck them after every hit.
107
+ const gain = config.music.loudness - (loudness(file).integrated ?? config.music.loudness);
108
+ parts.push(`[${n}:a]aformat=sample_rates=48000:channel_layouts=stereo,volume=${gain.toFixed(2)}dB,`
109
+ + `atrim=0:${(e.stop - e.begin).toFixed(3)},asetpts=PTS-STARTPTS,afade=t=in:d=${e.fadeIn.toFixed(3)},`
110
+ + `afade=t=out:st=${e.outAt.toFixed(3)}:d=${e.fadeOut},volume=${level},`
111
+ + `adelay=${ms(e.begin)}|${ms(e.begin)},aresample=async=1:first_pts=0[m${n}]`);
112
+ }
113
+
114
+ for (const beat of beats) {
115
+ for (const sound of beat.sounds) {
116
+ const file = path.resolve(config.appDir, sound.file);
117
+ if (!fs.existsSync(file)) { console.warn(` ! ${sound.file} (${beat.name}) isn't on disk; left out`); continue; }
118
+ const n = add(file);
119
+ const at = ms(beat.start + sound.at);
120
+ sources.push({ kind: 'effect', beat: beat.name, file: sound.file, at: Number((beat.start + sound.at).toFixed(3)) });
121
+ parts.push(`[${n}:a]aformat=sample_rates=48000:channel_layouts=stereo,adelay=${at}|${at},aresample=async=1:first_pts=0[m${n}]`);
122
+ }
123
+ }
124
+
125
+ const work = fs.mkdtempSync(path.join(dir, '.cut-'));
126
+ try {
127
+ const list = path.join(work, 'list.txt');
128
+ fs.writeFileSync(list, beats.map(b => `file '${b.file.replace(/'/g, "'\\''")}'`).join('\n'));
129
+ const video = path.join(work, 'video.mp4');
130
+ ffmpeg(['-f', 'concat', '-safe', '0', '-i', list, '-an', '-c', 'copy', video]);
131
+
132
+ // amix takes its timeline from its first input, so that is silence
133
+ // from 0 for the whole trailer: with music first, everything before
134
+ // the music came in was dropped and the rest landed late. Each input
135
+ // is delayed and resampled from 0, the mix rendered to 32-bit float
136
+ // WAV (no start time to drift, and stacked sounds over full scale
137
+ // aren't clipped before the limiter), and then muxed.
138
+ const labels = parts.map(p => p.match(/\[m\d+\]$/)[0]).join('');
139
+ const anchor = `anullsrc=r=48000:cl=stereo,atrim=0:${total.toFixed(6)}[anchor]`;
140
+ const mix = `[anchor]${labels}amix=inputs=${parts.length + 1}:normalize=0:duration=first[mix]`;
141
+ const raw = path.join(work, 'raw.wav');
142
+ ffmpeg(['-i', video, ...inputs.flatMap(file => ['-i', file]),
143
+ '-filter_complex', [anchor, ...parts, mix].join(';'), '-map', '[mix]', '-c:a', 'pcm_f32le', raw]);
144
+ // Levelled with one gain for the whole trailer, measured first:
145
+ // loudnorm's dynamic mode turned the music down for seconds after
146
+ // every loud hit. The limiter holds peaks about 4 dB down, room for
147
+ // the AAC encode to overshoot on a harsh sound.
148
+ const measured = loudness(raw).integrated;
149
+ const gain = Number.isFinite(measured) ? config.mix.loudness - measured : 0;
150
+ const wav = path.join(work, 'sound.wav');
151
+ ffmpeg(['-i', raw, '-af', `volume=${gain.toFixed(2)}dB,alimiter=limit=${config.mix.limit}:level=false`, wav]);
152
+ const suffix = opts.music && opts.music !== 'default' ? `-${opts.music}` : '';
153
+ const out = path.join(dir, `trailer-${format.name}${suffix}.mp4`);
154
+ // Exactly as long as the beats: -shortest against a mix a fraction of a
155
+ // millisecond short dropped the last frames.
156
+ ffmpeg(['-i', video, '-i', wav, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-t', total.toFixed(6), out]);
157
+ fs.writeFileSync(out.replace(/\.mp4$/, '.json'), `${JSON.stringify({
158
+ format: format.name, music: opts.music || 'default', seconds: Number(total.toFixed(3)),
159
+ beats: beats.map(b => ({ name: b.name, start: Number(b.start.toFixed(3)), seconds: b.seconds, track: b.track })),
160
+ sources,
161
+ }, null, 2)}\n`);
162
+ console.log(`${path.relative(process.cwd(), out)} ${total.toFixed(1)} s`);
163
+ written.push(out);
164
+ } finally {
165
+ fs.rmSync(work, { recursive: true, force: true });
166
+ }
167
+ }
168
+ return written;
169
+ }
@@ -0,0 +1,6 @@
1
+ // Easing curves for camera and pointer moves: k runs 0 → 1.
2
+ export const inOut = k => (k < 0.5 ? 4 * k * k * k : 1 - ((-2 * k + 2) ** 3) / 2);
3
+ export const out = k => 1 - (1 - k) ** 3;
4
+ export const linear = k => k;
5
+
6
+ export const lerp = (a, b, k) => a + (b - a) * k;
@@ -0,0 +1,46 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+
3
+ export const ffmpeg = args => execFileSync('ffmpeg', ['-y', '-loglevel', 'error', ...args], { maxBuffer: 64 * 1024 * 1024 });
4
+
5
+ // ffmpeg's report (stderr) for a run that only measures.
6
+ export function measure(args) {
7
+ const run = spawnSync('ffmpeg', ['-hide_banner', '-nostats', ...args, '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
8
+ if (run.status !== 0) throw new Error(`ffmpeg ${args.join(' ')}: ${run.stderr.trim().split('\n').pop()}`);
9
+ return run.stderr;
10
+ }
11
+
12
+ export function probe(file) {
13
+ const run = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'stream=codec_type,start_time,duration,width,height,r_frame_rate',
14
+ '-show_entries', 'format=duration', '-of', 'json', file], { encoding: 'utf8' });
15
+ if (run.status !== 0) throw new Error(`ffprobe ${file}: ${run.stderr.trim()}`);
16
+ return JSON.parse(run.stdout);
17
+ }
18
+
19
+ // Integrated loudness (LUFS) and true peak (dBTP) as ffmpeg's EBU R128 meter
20
+ // reads them.
21
+ export function loudness(file) {
22
+ return parseLoudness(measure(['-i', file, '-af', 'ebur128=peak=true']));
23
+ }
24
+
25
+ export function parseLoudness(report) {
26
+ const last = re => [...report.matchAll(re)].pop()?.[1];
27
+ const integrated = last(/^\s+I:\s+(-?[\d.]+|-inf) LUFS/gm);
28
+ const peak = last(/^\s+Peak:\s+(-?[\d.]+|-inf) dBFS/gm);
29
+ const num = v => (v == null ? null : v === '-inf' ? -Infinity : Number(v));
30
+ return { integrated: num(integrated), truePeak: num(peak) };
31
+ }
32
+
33
+ // Where sound starts after silence, in seconds: each `silence_end`, plus 0
34
+ // when the sound starts on the very first sample.
35
+ export function onsets(file, { start = 0, duration } = {}) {
36
+ const input = [...(start ? ['-ss', String(start)] : []), ...(duration ? ['-t', String(duration)] : []), '-i', file];
37
+ return parseOnsets(measure([...input, '-af', 'highpass=f=200,silencedetect=n=-40dB:d=0.05']));
38
+ }
39
+
40
+ export function parseOnsets(report) {
41
+ const ends = [...report.matchAll(/silence_end: (-?[\d.]+)/g)].map(m => Number(m[1]));
42
+ const starts = [...report.matchAll(/silence_start: (-?[\d.]+)/g)].map(m => Number(m[1]));
43
+ // Silence that starts later than 0 means there was sound from the start.
44
+ if (!starts.length || starts[0] > 0.001) ends.unshift(0);
45
+ return ends;
46
+ }
@@ -0,0 +1,161 @@
1
+ // Films each beat of the storyboard, in each format, as
2
+ // <beat>-<format>.mp4 H.264 at the format's output size, with titles
3
+ // <beat>-<format>-clean.mp4 the same without titles, for an editor
4
+ // <beat>-<format>.json the sounds the app played and the beat's marks
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { camera } from './camera.mjs';
8
+ import { freeze } from './clock.mjs';
9
+ import * as ease from './ease.mjs';
10
+ import { ffmpeg } from './ffmpeg.mjs';
11
+ import { loadConfig, outDir, pickBeats, pickFormats, requireFromApp } from './config.mjs';
12
+ import { beatUrl, prepareShowcase, router } from './showcase.mjs';
13
+ import { fileFor, ignored, listen, liveSounds, mixEffects } from './sound.mjs';
14
+ import { filmTitles } from './titles.mjs';
15
+
16
+ const centre = box => ({ x: box.x + box.w / 2, y: box.y + box.h / 2 });
17
+
18
+ // What a beat's play() gets to work with.
19
+ function stage(page, cam, format) {
20
+ return {
21
+ page,
22
+ cam,
23
+ format,
24
+ tall: cam.tall,
25
+ ease,
26
+ // Switches the showcase to another fixture without reloading, so the
27
+ // app's components see a state change and animate and play their sounds
28
+ // as they would on a real update.
29
+ setView: view => page.evaluate(v => window.__setView(v), view),
30
+ // Calls an app-defined trigger on the page (window[name](...args)), for
31
+ // events that should happen on cue rather than on a timer from load.
32
+ fire: (name, ...args) => page.evaluate(([n, a]) => window[n](...a), [name, args]),
33
+ // Presses `from`, carries it onto `to` filming as it goes, holds, lets go.
34
+ async drag(from, to, { ms = 700, hold = 350 } = {}) {
35
+ const a = await cam.boxOf(from);
36
+ const b = await cam.boxOf(to);
37
+ if (!a || !b) throw new Error(`drag: nothing matches ${a ? to : from}`);
38
+ const start = centre(a);
39
+ await page.mouse.move(start.x, start.y);
40
+ await page.mouse.down();
41
+ await cam.move(start, centre(b), ms);
42
+ await cam.hold(hold);
43
+ await page.mouse.up();
44
+ },
45
+ };
46
+ }
47
+
48
+ // Puts the page in the state a beat starts from: the app's own setup, the
49
+ // page's clock stopped, the app settled, its audio unlocked and loaded, and
50
+ // nothing heard before the clip counts.
51
+ async function openBeat(browser, { site, route, config, beat, format }) {
52
+ const [width, height] = format.page;
53
+ const page = await browser.newPage({ viewport: { width, height } });
54
+ await page.route(`${site.origin}/**`, route);
55
+ await page.addInitScript(listen, ignored(config));
56
+ const css = [config.page?.css, beat.css].filter(Boolean).join('\n');
57
+ if (css) {
58
+ await page.addInitScript(text => {
59
+ addEventListener('DOMContentLoaded', () => {
60
+ document.head.append(Object.assign(document.createElement('style'), { textContent: text }));
61
+ });
62
+ }, css);
63
+ }
64
+ if (config.page?.init) await page.addInitScript(config.page.init, config.page.initArg);
65
+ await page.clock.install();
66
+ await page.goto(beatUrl(site, config, beat));
67
+ await page.waitForLoadState('networkidle').catch(() => {});
68
+ // The installed clock still runs at wall speed until paused; stopped, it
69
+ // only moves when the camera moves it.
70
+ await freeze(page);
71
+ await page.clock.runFor(beat.settle ?? config.settle);
72
+ // A key press unlocks the app's audio, as a person's first tap does.
73
+ if (config.unlock) await config.unlock(page); else await page.keyboard.press('Shift');
74
+ await page.waitForTimeout(config.soundLoad);
75
+ await page.evaluate(() => { window.__heard = []; });
76
+ return page;
77
+ }
78
+
79
+ const encode = (fps, args, file) => ffmpeg([...args, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '14', '-r', String(fps), file]);
80
+
81
+ const beatFiles = (dir, beat, format) => {
82
+ const base = path.join(dir, `${beat}-${format}`);
83
+ return { base, video: `${base}.mp4`, clean: `${base}-clean.mp4`, json: `${base}.json` };
84
+ };
85
+
86
+ export async function film(appDir, opts = {}) {
87
+ const config = await loadConfig(appDir, opts.config);
88
+ const formats = pickFormats(config, opts.format);
89
+ const beats = pickBeats(config, opts.only);
90
+ const dir = outDir(config, opts.out);
91
+ const live = liveSounds(config, { bundled: opts.sounds === 'bundled' });
92
+
93
+ if (opts.clean) {
94
+ for (const f of formats) {
95
+ for (const b of beats) for (const file of Object.values(beatFiles(dir, b.name, f.name)).slice(1)) fs.rmSync(file, { force: true });
96
+ fs.rmSync(path.join(dir, `trailer-${f.name}.mp4`), { force: true });
97
+ }
98
+ for (const leftover of fs.existsSync(dir) ? fs.readdirSync(dir) : []) {
99
+ if (leftover.startsWith('.frames-') || leftover.startsWith('.effects-')) fs.rmSync(path.join(dir, leftover), { recursive: true, force: true });
100
+ }
101
+ if (live) fs.rmSync(live.cache, { recursive: true, force: true });
102
+ }
103
+
104
+ const site = await prepareShowcase(config);
105
+ const route = router(site, config, live);
106
+ fs.mkdirSync(dir, { recursive: true });
107
+ const { chromium } = requireFromApp(config.appDir, 'playwright');
108
+ // Headless Chromium has no one to tap the page, and Web Audio waits for a
109
+ // tap; this lets it start anyway.
110
+ const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
111
+ const filmed = [];
112
+ try {
113
+ for (const format of formats) {
114
+ for (const beat of beats) {
115
+ const page = await openBeat(browser, { site, route, config, beat, format });
116
+ const files = beatFiles(dir, beat.name, format.name);
117
+ const frames = path.join(dir, `.frames-${beat.name}-${format.name}`);
118
+ const titles = `${frames}-titles`;
119
+ for (const d of [frames, titles]) { fs.rmSync(d, { recursive: true, force: true }); fs.mkdirSync(d, { recursive: true }); }
120
+
121
+ const cam = await camera(page, { fps: config.fps, format, dir: frames });
122
+ await beat.play(stage(page, cam, format));
123
+ await page.close();
124
+ if (!cam.frames) throw new Error(`beat ${beat.name} filmed no frames: its play() never called cam.hold, move or shoot`);
125
+ await filmTitles(browser, { site, route, config, format, cues: cam.cues, frames: cam.frames, dir: titles });
126
+
127
+ // The effects the beat played, for the cut to lay across the whole
128
+ // trailer (a knockout rings on past its own beat), and mixed into the
129
+ // clips as far as each one runs, for an editor.
130
+ const seconds = cam.frames / config.fps;
131
+ const sounds = cam.sounds.map(s => ({ at: Number(s.at.toFixed(3)), file: path.relative(config.appDir, fileFor(config, live, s.url)) }));
132
+ fs.writeFileSync(files.json, `${JSON.stringify({
133
+ beat: beat.name, format: format.name, fps: config.fps, frames: cam.frames, seconds,
134
+ marks: cam.marks, endCard: cam.cues.some(c => c.end), sounds,
135
+ }, null, 2)}\n`);
136
+ const effects = path.join(dir, `.effects-${beat.name}-${format.name}.wav`);
137
+ mixEffects(sounds.map(s => ({ ...s, file: path.resolve(config.appDir, s.file) })), seconds, effects);
138
+
139
+ // Cut to exactly the frames filmed: -shortest against an effects track
140
+ // a fraction of a millisecond short dropped the beat's last frame.
141
+ const [width, height] = format.out;
142
+ encode(config.fps, ['-framerate', String(config.fps), '-i', path.join(frames, '%05d.jpg'), '-i', effects,
143
+ '-vf', `scale=${width}:${height}:flags=lanczos,setsar=1`, '-c:a', 'aac', '-b:a', '192k', '-t', seconds.toFixed(6)], files.clean);
144
+ // Over the encoded clean clip, never the raw frames: a frame a pixel
145
+ // off size makes ffmpeg rebuild the overlay part-way and drop the titles.
146
+ encode(config.fps, ['-i', files.clean, '-framerate', String(config.fps), '-i', path.join(titles, '%05d.png'),
147
+ '-filter_complex', '[0:v][1:v]overlay=format=auto:eof_action=pass[v]', '-map', '[v]', '-map', '0:a', '-c:a', 'copy'], files.video);
148
+ fs.rmSync(effects, { force: true });
149
+ if (!opts.keepFrames) for (const d of [frames, titles]) fs.rmSync(d, { recursive: true, force: true });
150
+
151
+ const missing = sounds.filter(s => !fs.existsSync(path.resolve(config.appDir, s.file)));
152
+ console.log(`${path.relative(process.cwd(), files.video)} ${cam.frames} frames ${seconds.toFixed(2)} s ${sounds.length} sounds`);
153
+ for (const s of missing) console.warn(` ! ${s.file} (played at ${s.at} s) isn't on disk, so it's silent in the clip`);
154
+ filmed.push(files.video);
155
+ }
156
+ }
157
+ } finally {
158
+ await browser.close();
159
+ }
160
+ return filmed;
161
+ }
@@ -0,0 +1,5 @@
1
+ // The engine, for a site that drives it from its own script.
2
+ export { film } from './film.mjs';
3
+ export { cut } from './cut.mjs';
4
+ export { check } from './check.mjs';
5
+ export * as ease from './ease.mjs';