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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ed9f2883e65374fed7efa5bddb143815548b666eeef890a4131c8a9fd2cdb0d7
4
+ data.tar.gz: db1be41d567472e59d903753807bef4931343bfacd16bb5d86759f19cbb21faf
5
+ SHA512:
6
+ metadata.gz: cbcbcb3fca347996debac439fea243eaf69046f91194f3c63468f3066dfd5a27277a0f5007230dbfbd02a1888e3f40b3a5ae253b0b8ad51adf977c541f21c064
7
+ data.tar.gz: 8e950ded3ad819261b365ea9b2e2baa0db8e2187bf65e67325429c50d0090ae3a365c2f0530716516e509cd9e2c3126def5090dd27f71b8b2f852703c61ce3e9
data/AGENTS.md ADDED
@@ -0,0 +1,78 @@
1
+ # AGENTS.md — belt-trailer
2
+
3
+ For agents (and humans) working **on this gem**. To *use* it, read
4
+ [README.md](README.md).
5
+
6
+ ## Layout
7
+
8
+ ```
9
+ engine/ # the Node engine: no Belt in here
10
+ ├── bin/trailer.mjs # CLI: film | cut | check
11
+ ├── titles.html # the titles page (themed by the app's CSS)
12
+ ├── lib/
13
+ │ ├── config.mjs # trailer.config.mjs + defaults; the app's Playwright
14
+ │ ├── showcase.mjs # build/serve the showcase, canned API, the router
15
+ │ ├── clock.mjs # freeze the page clock, step a frame
16
+ │ ├── camera.mjs # crop camera: focus, punch, shake, cues, sounds
17
+ │ ├── titles.mjs # film the titles page for a beat
18
+ │ ├── sound.mjs # capture init script, live sounds, effect mix
19
+ │ ├── film.mjs | cut.mjs | check.mjs # the three commands
20
+ │ ├── ffmpeg.mjs # ffmpeg/ffprobe helpers and report parsers
21
+ │ └── args.mjs # the CLI's options
22
+ └── test/ # node:test for the pure parts
23
+ lib/
24
+ ├── belt/trailer.rb # engine paths, requirements check
25
+ ├── belt/commands/trailer_command.rb # belt trailer film|cut|check → node engine
26
+ ├── belt/generators/trailer_generator.rb # belt g trailer
27
+ └── belt/trailer/templates/ # config, titles theme, showcase scaffold
28
+ examples/posh-nosh/ # Posh Nosh's trailer as a config
29
+ skills/belt-trailer/ # the skill for agents *using* the gem (npx skills add)
30
+ ```
31
+
32
+ ## Design rules
33
+
34
+ - **The engine never learns about Belt or any one app.** Anything Posh Nosh
35
+ needed (its soundboard, `__say`, its music stand-ins) is expressed in its
36
+ config. If a second app needs something, add a config option, not a branch.
37
+ - **The engine runs from the gem.** It loads Playwright from the app with
38
+ `createRequire(appDir/package.json)`. Don't add npm dependencies to the
39
+ engine; Node's standard library and ffmpeg cover it.
40
+ - **Every rule in the README's "learned the hard way" list was a real bug.**
41
+ Don't undo one (the half-pixel scale, the cut-is-done rule, overlaying on
42
+ the clean clip, the silent first `amix` input, one measured gain) without
43
+ filming Posh Nosh before and after.
44
+ - **Clips are exactly as long as the frames filmed.** Audio is trimmed to
45
+ the full-precision length and muxed with `-t`, never `-shortest`: an audio
46
+ track a fraction of a millisecond short drops the last video frame.
47
+
48
+ ## Keep the skill true
49
+
50
+ `skills/belt-trailer/` is what agents making a trailer actually read
51
+ (`belt trailer guide` prints it; `npx skills add stowzilla/belt-trailer`
52
+ installs it). When you change a command, an option, the `play()` API or a
53
+ default, update it in the same change, and keep every command in it runnable
54
+ as written. It installs on its own, without the gem, so its links must stay
55
+ inside its own directory (spec/belt/skill_spec.rb checks); refer to the gem's
56
+ files as `<gem>/…`, which `belt trailer guide` fills in.
57
+
58
+ ## Verifying a change to the engine
59
+
60
+ Unit tests cover the arithmetic. For anything that touches filming, film the
61
+ Posh Nosh example against a posh_nosh checkout with the trailer showcase
62
+ fixtures, and compare with the previous clips: the `.json` files should
63
+ match, and the pictures should agree to encoder noise (PSNR well above
64
+ 40 dB):
65
+
66
+ ```bash
67
+ cp examples/posh-nosh/trailer* ../posh_nosh/frontend/
68
+ node engine/bin/trailer.mjs film --app ../posh_nosh/frontend --out /tmp/after
69
+ ffmpeg -i /tmp/before/strike-wide.mp4 -i /tmp/after/strike-wide.mp4 -lavfi psnr -f null -
70
+ ```
71
+
72
+ ## Releasing
73
+
74
+ Bump `lib/belt/trailer/version.rb` (and the CHANGELOG) and merge to `main`.
75
+ `.github/workflows/auto-tag.yml` builds the gem, pushes it to RubyGems with
76
+ the `RUBYGEMS_API_KEY` secret, tags `vX.Y.Z` and makes the GitHub release.
77
+ It skips whatever is already done, so a failed run can be re-run from the
78
+ Actions tab (`workflow_dispatch`).
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - The engine, split out of Posh Nosh's launch trailer (Fizzy #1818) and made
6
+ generic: a frame-stepped crop camera, a titles layer themed by the app,
7
+ captured sound effects, the cut and mix levelled to −14 LUFS, and a check
8
+ that passes or fails the cut. Configured per app by `trailer.config.mjs`.
9
+ - `belt generate trailer`: the config, a titles theme, and a showcase for a
10
+ React + Vite frontend that has none.
11
+ - `belt trailer film|cut|check`.
12
+ - Beat clips and the cut are exactly as long as the frames filmed. Posh
13
+ Nosh's scripts trimmed the audio to the millisecond and muxed with
14
+ `-shortest`, which dropped a beat's last frame whenever rounding its length
15
+ to the millisecond made the audio shorter (Posh Nosh's `dragon` beat: 124
16
+ frames filmed, 123 in the clip).
17
+ - `examples/posh-nosh/`: Posh Nosh's trailer as a config.
18
+ - `skills/belt-trailer/`: a skill for the agents making a trailer
19
+ (storyboard, showcase and fixtures, beats, reviewing frames). Install it
20
+ with `npx skills add stowzilla/belt-trailer --skill belt-trailer`, or print
21
+ the copy in the gem with `belt trailer guide`; `belt generate trailer` writes `trailer-storyboard.md`
22
+ and points the app's `AGENTS.md` at the skill.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stowzilla
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # belt-trailer
2
+
3
+ Films a launch trailer of a web app from the app itself: its real screens, a frame at a time, at 1080p and 30 fps, with a moving camera, titles in the app's own fonts and the sound effects the app plays as it's filmed. Cuts the shots together with music under them, levelled to −14 LUFS, and checks whether the result is fit to publish.
4
+
5
+ Nothing is drawn or generated. Every frame is the app's own UI and every sound is one the app plays, so the trailer can't promise something the app doesn't do. Re-filming after a design change is one command and a few minutes.
6
+
7
+ It came out of Posh Nosh's launch trailer (Fizzy #1818). [`examples/posh-nosh/`](examples/posh-nosh/) is that trailer's config.
8
+
9
+ ## For AI agents
10
+
11
+ Most trailers will be made by an agent: writing the storyboard, the fixtures and the beats, then reviewing what it filmed. [`skills/belt-trailer/`](skills/belt-trailer/SKILL.md) is the skill for that (Agent Skills format), with references on the storyboard, the showcase and fixtures, scripting beats, and reviewing frames. Two ways to get it to an agent:
12
+
13
+ - **Install it** into a project or for a user, with the [skills CLI](https://github.com/vercel-labs/skills):
14
+
15
+ ```bash
16
+ npx skills add stowzilla/belt-trailer --skill belt-trailer # into this project (.claude/skills/ …)
17
+ npx skills add stowzilla/belt-trailer --skill belt-trailer -g # for every project
18
+ ```
19
+
20
+ The repo is private for now, so this needs git access to it (an SSH key or `gh auth login`).
21
+ - **Print it** from the installed gem: `belt trailer guide`. This copy always matches the gem's version. `belt generate trailer` also adds a pointer to it in the app's `AGENTS.md`.
22
+
23
+ ## Belt, or any website
24
+
25
+ The engine ([`engine/`](engine/)) is plain Node and knows nothing about Belt. It needs a page Playwright can load and drive, and a `trailer.config.mjs`. The Belt part is a thin layer on top:
26
+
27
+ - `belt generate trailer` scaffolds the config, a titles theme and a showcase for a React + Vite frontend.
28
+ - `belt trailer film|cut|check` runs the engine on the frontend.
29
+
30
+ The engine runs from the gem's directory and is never copied into the app. It loads the app's own Playwright from the frontend's `node_modules`.
31
+
32
+ ## Requirements
33
+
34
+ - Node 20 or later
35
+ - Playwright in the frontend: `npm install -D playwright && npx playwright install chromium`
36
+ - ffmpeg and ffprobe, with `drawtext` (for the contact sheet)
37
+
38
+ ## Quick start (Belt)
39
+
40
+ ```ruby
41
+ # Gemfile
42
+ gem 'belt-trailer', git: 'git@github.com:stowzilla/belt-trailer.git'
43
+ ```
44
+
45
+ ```bash
46
+ bundle install
47
+ belt generate trailer --url myapp.com # frontend/trailer.config.mjs, trailer-storyboard.md, trailer-titles.css, scripts/showcase/
48
+ cd frontend && npm install -D playwright && npx playwright install chromium && cd ..
49
+ belt trailer film --format wide # frontend/tmp/trailer/<beat>-wide.mp4 …
50
+ belt trailer cut --format wide # frontend/tmp/trailer/trailer-wide.mp4
51
+ belt trailer check --format wide # PASS or FAIL, and trailer-wide-sheet.png
52
+ ```
53
+
54
+ The starter films the Belt welcome page in two beats. From there it's yours: write the storyboard (`trailer-storyboard.md`), the fixtures it films, and the beats. `belt trailer guide` walks through each.
55
+
56
+ ## Any other site
57
+
58
+ ```bash
59
+ node path/to/belt-trailer/engine/bin/trailer.mjs film --app path/to/site
60
+ ```
61
+
62
+ `--app` is the directory with `trailer.config.mjs` and the `package.json` whose Playwright is used. The commands and options are the same as `belt trailer`'s.
63
+
64
+ ## Words
65
+
66
+ - **Showcase.** The app's screens rendered from hand-written states (fixtures) with no backend. A screen is chosen by name (`?view=<fixture>`), and `window.__setView(name)` switches state without reloading, so the app's own components animate and play their sounds as they would on a real update. API calls are answered with canned JSON by Playwright route interception.
67
+ - **Beat.** One shot of the storyboard, named by the app. `--only` takes those names. There are no built-in beat names.
68
+ - **Format.** An output shape: `wide` (1920×1080, filmed from a 1280×720 page) and `tall` (1080×1920, from a 432×768 phone-sized page) by default.
69
+ - **Story.** The fixtures every shot is built from: one session at later moments, so the same records appear in every shot.
70
+ - **Mark.** A named moment a beat records for the cut, e.g. `hush` where the music stops.
71
+
72
+ ## Commands
73
+
74
+ ```
75
+ belt trailer film [--only a,b] [--format wide|tall|both] [--clean] [--keep-frames] [--sounds bundled]
76
+ belt trailer cut [--format …] [--music PLAN] [--sounds bundled]
77
+ belt trailer check [--format …] [--music PLAN]
78
+ belt trailer guide # the agent skill, with full paths
79
+ ```
80
+
81
+ All take `--frontend NAME` (from `config/frontends.yml`; default `frontend/`), `--out DIR` and `--config FILE`.
82
+
83
+ **film** films each beat into the output directory (default `<frontend>/tmp/trailer`):
84
+
85
+ | File | |
86
+ |---|---|
87
+ | `<beat>-<format>.mp4` | H.264 with titles and the beat's effects |
88
+ | `<beat>-<format>-clean.mp4` | The same without titles, for an editor (and for `check`) |
89
+ | `<beat>-<format>.json` | The sounds the app played (time and file), the beat's marks |
90
+
91
+ `--clean` deletes those beats' old clips, leftover frame directories and the cached live sounds first. `--sounds bundled` plays the app's bundled files rather than the live site's replacements.
92
+
93
+ **cut** joins the beats in config order, lays every effect where the app played it (ringing on past its own beat) and the music by the plan, and levels the whole thing. Writes `trailer-<format>.mp4` and `trailer-<format>.json` (where each beat starts, and every audio file in the cut). `--music PLAN` cuts with a named plan from `music.plans` into `trailer-<format>-<PLAN>.mp4`.
94
+
95
+ **check** passes or fails a cut, exiting 1 on a fail:
96
+
97
+ - audio starts at 0 and lasts as long as the video (±0.05 s)
98
+ - integrated loudness −14 LUFS ±1
99
+ - true peak below −1 dBFS
100
+ - at most 30 s (the App Store preview limit; `check.maxSeconds`)
101
+ - every captured effect starts within one frame of where it was captured, measured on each beat's clean clip (a sound that starts while another is still ringing has no onset of its own, and is reported as masked)
102
+ - no title on screen in the last 0.5 s of any beat (the titled clip against the clean one; a beat that ends on the end card is exempt)
103
+
104
+ It also writes `trailer-<format>-sheet.png`, a contact sheet at 2 fps with timestamps, and lists every audio file in the cut, so the rights to each can be checked before publishing.
105
+
106
+ ## The config
107
+
108
+ `trailer.config.mjs` in the frontend `export default`s an object. Paths are relative to the frontend.
109
+
110
+ ```js
111
+ export default {
112
+ // Where the pages come from: a module that builds the showcase (it exports
113
+ // buildShowcase(), returning { distDir }), or a running site with { url }.
114
+ // A running site's data can change between takes, so shots aren't repeatable.
115
+ showcase: { build: './scripts/showcase/build.mjs', index: 'showcase.html', query: { sound: 1 } },
116
+
117
+ // Canned answers, first match wins; a key ending in * is a prefix. A value
118
+ // is JSON, or a function of the URL that returns it.
119
+ api: { '/api/matches': { matches: [] }, '/api/*': {} },
120
+
121
+ sounds: {
122
+ bundled: 'public', // where /sounds/x.mp3 lives on disk
123
+ // Optional: paths answered from the live site, files cached in tmp/trailer-sounds.
124
+ live: { origin: 'https://example.com', paths: ['/api/sounds', '/soundboard/'] },
125
+ ignore: ['/music-'], // URL patterns not captured (music)
126
+ },
127
+
128
+ // Set up every page: CSS to add, and an init script (runs before the app).
129
+ page: { css: '.chat { visibility: hidden; }', init: () => localStorage.setItem('sound', 'on') },
130
+ // unlock: async page => …, // default: a key press unlocks Web Audio
131
+
132
+ titles: {
133
+ css: './trailer-titles.css', // the app's fonts and colours
134
+ endCard: { logo: '/images/logo.png', lines: ['What it is', 'Free · No account needed'], url: 'example.com' },
135
+ leaveBefore: 0.8, // seconds a title leaves before its cut
136
+ },
137
+
138
+ music: {
139
+ level: 0.7, // under the effects; apps often play 0.5
140
+ fade: 1.5, // crossfade, seconds
141
+ tracks: { battle: 'public/sounds/theme.mp3', lobby: async ({ live }) => 'public/sounds/lobby.mp3' },
142
+ plan: { hook: 'battle', dragon: 'battle', end: 'lobby' }, // beat → track, or null
143
+ plans: { quiet: { hook: null, dragon: 'lobby', end: 'lobby' } },
144
+ },
145
+
146
+ formats: { wide: { page: [1280, 720], out: [1920, 1080] }, tall: { page: [432, 768], out: [1080, 1920] } },
147
+ fps: 30,
148
+ settle: 1500, // ms the page runs before a beat starts
149
+ check: { maxSeconds: 30 },
150
+
151
+ beats: [
152
+ { name: 'hook', view: 'battle-start', query: 'chat=on', css: '…', settle: 500,
153
+ play: async ({ page, cam, setView, drag, fire, ease, tall }) => { /* … */ } },
154
+ ],
155
+ };
156
+ ```
157
+
158
+ A beat's `play` gets:
159
+
160
+ | | |
161
+ |---|---|
162
+ | `cam.hold(ms)` | Film for `ms` |
163
+ | `cam.focus(selectors, { ms, pad, top, right, bottom, ease })` | Frame the union of the matching boxes over `ms` (0 cuts), with `pad` all round and room on one side for words, as fractions of its size, fitted to the format |
164
+ | `cam.punch(selectors, opts)` | A fast focus (260 ms, ease out) |
165
+ | `cam.look(rect, ms, ease)` | Frame a rect of the page |
166
+ | `cam.shake(ms, px)` | Shake the frame, fading out |
167
+ | `cam.move(from, to, ms)` | Move the pointer, filming as it goes |
168
+ | `cam.title({ kicker, line, place })` | A title from this frame: `place` is `side`, `band`, `top`, `bottom` or `{ wide, tall }` |
169
+ | `cam.untitle()`, `cam.endCard()` | Send the title out; bring the end card in |
170
+ | `cam.mark(name)` | Note a moment for the cut (`hush` stops the music) |
171
+ | `cam.boxOf(selectors)`, `cam.tall` | Where things are; whether the format is tall |
172
+ | `setView(name)` | Switch the showcase to another fixture without reloading |
173
+ | `drag(from, to, { ms, hold })` | Press `from` and carry it onto `to`, filming |
174
+ | `fire(name, ...args)` | Call an app-defined trigger, `window[name](...args)`, on cue |
175
+ | `ease` | `inOut`, `out`, `linear` |
176
+ | `page` | The Playwright page |
177
+
178
+ ## The showcase's two hooks
179
+
180
+ Behind query flags, so the showcase stays quiet for screenshots:
181
+
182
+ - `?sound=1` mounts the app's own sound hooks, so it plays what it would.
183
+ - An app-defined trigger on `window` (Posh Nosh: `window.__say(text, from)`) fires an event on cue rather than on a timer from page load. A beat calls it with `fire('__say', 'Hello', '1')`.
184
+
185
+ ## How it works
186
+
187
+ **Filmed a frame at a time.** Headless Chromium's video recording and screencast both capture at CSS pixels and ignore `deviceScaleFactor`, so 1080p comes out blurry. Instead the page's clock is installed and paused (`pauseAt`, retried further ahead when a busy page has passed it). Before each frame, timers run forward one frame and every `document.getAnimations()` entry is paused at its time, or finished once past its end so its events fire.
188
+
189
+ **The camera is a crop.** Each frame is `Page.captureScreenshot` over CDP with a clip and `scale = (outWidth + 0.5) / clipWidth` (without the half pixel Chromium rounds a pixel short). Chromium re-rasterises at that scale, so a 3× push is sharp, and the page is never transformed, so drags and hit-testing still work. A cut counts as done on the frame it's called, so a move straight after it starts from the cut.
190
+
191
+ **Titles are a second page**, transparent and the size of the output ([`engine/titles.html`](engine/titles.html), themed by the app's CSS), stepped the same way, screenshotted with `omitBackground` and overlaid on the encoded clean clip, never on raw frames.
192
+
193
+ **Sound is captured, not placed.** An init script tags each `Response.arrayBuffer` with its URL, carries the tag through `decodeAudioData` to the `AudioBuffer`, and records `AudioBufferSourceNode.start(when)` with its scheduled delay. Each frame the recorder drains the list and notes the frame. Chromium runs with `--autoplay-policy=no-user-gesture-required`, and a key press unlocks the app's audio as a first tap would.
194
+
195
+ **The cut.** `amix` takes its timeline from its first input, so that's `anullsrc` for the whole length; every input is delayed and resampled from 0. The mix is rendered to 32-bit float WAV and then muxed. Each music track is brought to −16 LUFS with one measured gain; the trailer is brought to −14 LUFS with one measured gain and limited at 0.63 (about 4 dB of headroom for the AAC encode). `loudnorm`'s dynamic mode ducked the music after every hit and isn't used.
196
+
197
+ ## Rules learned the hard way
198
+
199
+ **Pacing.** Each title stays up, fully landed, about 2 s. A moment the viewer has to follow (a dialog, a choice, its result) gets 2–3 s, with its title held until the result is on screen. Titles leave 0.8 s before their cut (built in).
200
+
201
+ **Camera.** End a shot on the framing the next opens with. A push of more than about 1.5× takes at least 0.9 s with easing. Keep the pointer off anything with a hover state. Hide the app's own heading when a title replaces it. Frame an element after its entrance animation has finished, or its box is wrong.
202
+
203
+ **Story.** Every shot is the same session at a later moment, built from one fixture function. Each state change gets a fixture with the data the server would really send: sounds come from the difference between two states, so a missing log line is a missing sound.
204
+
205
+ **Words.** Where nothing important is, never on the subject or the controls. In the product's voice, short enough to read twice in 2 s.
206
+
207
+ **Sound.** Music starts on the first frame (`cut` warns otherwise). Music sits higher under the effects than in the app. Check the source files: one of Posh Nosh's uploaded sounds was clipped in the file itself.
208
+
209
+ ## Before publishing
210
+
211
+ - Rights to every piece of music and every sound in the cut, for advertising (`check` lists them).
212
+ - The end card names only what is live and doesn't call paid content free.
213
+ - Re-film after the last visual change.
214
+
215
+ ## Development
216
+
217
+ ```bash
218
+ bundle exec rspec # the Belt layer
219
+ bundle exec rubocop
220
+ cd engine && npm test # the engine's pure parts (node:test)
221
+ ```
222
+
223
+ See [AGENTS.md](AGENTS.md).
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // The engine's command line. Belt runs it as `belt trailer …`; any other site
3
+ // can run it directly with node.
4
+ //
5
+ // trailer.mjs film [--app DIR] [--only beat,beat] [--format wide|tall|both] [--clean] [--keep-frames] [--sounds bundled]
6
+ // trailer.mjs cut [--app DIR] [--format …] [--music PLAN] [--sounds bundled]
7
+ // trailer.mjs check [--app DIR] [--format …] [--music PLAN]
8
+ //
9
+ // --app is the directory holding trailer.config.mjs and the package.json
10
+ // whose Playwright is used (default: the current directory). --config picks
11
+ // another config file there; --out another output directory.
12
+ import { film } from '../lib/film.mjs';
13
+ import { cut } from '../lib/cut.mjs';
14
+ import { check } from '../lib/check.mjs';
15
+ import { parse } from '../lib/args.mjs';
16
+
17
+ const USAGE = `Usage: trailer.mjs <film|cut|check> [options]
18
+
19
+ film Film each beat: <beat>-<format>.mp4, -clean.mp4 (no titles) and .json (sounds, marks)
20
+ cut Join the beats with their effects and the music: trailer-<format>.mp4
21
+ check Measure a cut (sync, loudness, peak, length, titles) and write a contact sheet
22
+
23
+ Options:
24
+ --app DIR The app's frontend directory (default: .)
25
+ --config FILE Config file in it (default: trailer.config.mjs)
26
+ --out DIR Output directory (default: the config's out, tmp/trailer)
27
+ --only a,b film: just these beats
28
+ --format NAME wide, tall, both (default) or a list
29
+ --clean film: delete those beats' old clips, leftover frames and cached live sounds first
30
+ --keep-frames film: keep the frame images
31
+ --music PLAN cut/check: a named plan from music.plans (default: music.plan)
32
+ --sounds bundled film/cut: the app's bundled sound files, not the live site's
33
+ `;
34
+
35
+ try {
36
+ const { command, opts } = parse(process.argv.slice(2));
37
+ const app = opts.app || process.cwd();
38
+ if (command === 'film') await film(app, opts);
39
+ else if (command === 'cut') await cut(app, opts);
40
+ else if (command === 'check') process.exitCode = (await check(app, opts)) ? 0 : 1;
41
+ else { process.stdout.write(USAGE); process.exitCode = command && !['help', '--help', '-h'].includes(command) ? 1 : 0; }
42
+ } catch (error) {
43
+ console.error(`Error: ${process.env.TRAILER_DEBUG ? error.stack : error.message}`);
44
+ process.exitCode = 1;
45
+ }
@@ -0,0 +1,18 @@
1
+ // The command line's options, as the engine's functions take them.
2
+ export function parse(argv) {
3
+ const [command, ...rest] = argv;
4
+ const opts = {};
5
+ const flags = { '--clean': 'clean', '--keep-frames': 'keepFrames' };
6
+ const values = { '--app': 'app', '--config': 'config', '--out': 'out', '--only': 'only', '--format': 'format', '--music': 'music', '--sounds': 'sounds' };
7
+ for (let i = 0; i < rest.length; i += 1) {
8
+ const [flag, inline] = rest[i].split(/=(.*)/s);
9
+ if (flags[flag]) { opts[flags[flag]] = true; continue; }
10
+ if (!values[flag]) throw new Error(`unknown option ${rest[i]}`);
11
+ const value = inline ?? rest[(i += 1)];
12
+ if (value == null) throw new Error(`${flag} needs a value`);
13
+ opts[values[flag]] = value;
14
+ }
15
+ if (opts.only) opts.only = opts.only.split(',').map(s => s.trim()).filter(Boolean);
16
+ if (opts.sounds && opts.sounds !== 'bundled' && opts.sounds !== 'live') throw new Error('--sounds is bundled or live');
17
+ return { command, opts };
18
+ }
@@ -0,0 +1,140 @@
1
+ // The camera is a crop. Each frame Chromium paints just the part of the page
2
+ // the camera is looking at (Page.captureScreenshot over CDP with a clip and a
3
+ // scale), at the size of the finished video. Chromium re-rasterises at that
4
+ // scale, so a 3× push-in stays sharp, and the page itself is never
5
+ // transformed, so drags and hit-testing still work where the pointer is.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { step } from './clock.mjs';
9
+ import { inOut, out, lerp } from './ease.mjs';
10
+
11
+ // Grows a rect to the frame's aspect around its centre, kept inside the page.
12
+ export function fitRect({ x, y, w, h }, page) {
13
+ const aspect = page.width / page.height;
14
+ const cx = x + w / 2;
15
+ const cy = y + h / 2;
16
+ if (w / h > aspect) h = w / aspect; else w = h * aspect;
17
+ if (w > page.width) { w = page.width; h = page.height; }
18
+ return {
19
+ x: Math.min(Math.max(cx - w / 2, 0), page.width - w),
20
+ y: Math.min(Math.max(cy - h / 2, 0), page.height - h),
21
+ w,
22
+ h,
23
+ };
24
+ }
25
+
26
+ // A rect around `box` with `pad` (a fraction of its size) all round, and room
27
+ // on one side for words: `top`, `right` and `bottom` are fractions of its
28
+ // size to add there.
29
+ export function roomAround(box, { pad = 0.08, top = 0, right = 0, bottom = 0 } = {}) {
30
+ const px = box.w * pad;
31
+ const py = box.h * pad;
32
+ const r = { x: box.x - px, y: box.y - py, w: box.w + px * 2, h: box.h + py * 2 };
33
+ r.y -= r.h * top;
34
+ r.h += r.h * (top + bottom);
35
+ r.w += r.w * right;
36
+ return r;
37
+ }
38
+
39
+ // A camera on one page, filming into `dir` as numbered JPEGs. Every `hold`,
40
+ // `move` and `shoot` adds frames; `look`, `focus` and `punch` move the frame;
41
+ // `title`, `untitle` and `endCard` are cues for the titles page.
42
+ export async function camera(page, { fps, format, dir }) {
43
+ const cdp = await page.context().newCDPSession(page);
44
+ const frameMs = 1000 / fps;
45
+ const [W, H] = format.page;
46
+ const size = { width: W, height: H };
47
+ let now = 0;
48
+ let count = 0;
49
+ let pan = { from: { x: 0, y: 0, w: W, h: H }, to: { x: 0, y: 0, w: W, h: H }, at: 0, ms: 1, ease: inOut };
50
+ let shaking = null;
51
+ const cues = [];
52
+ const sounds = [];
53
+ const marks = {};
54
+
55
+ const where = () => {
56
+ // A cut (ms 1) is already there, even on the frame it was called, so a
57
+ // move that follows it on the same frame starts from where it cut to.
58
+ // Before this, a shot could flash the wide view for a frame.
59
+ const k = pan.ms <= 1 ? 1 : Math.min(1, (now - pan.at) / pan.ms);
60
+ const e = pan.ease(k);
61
+ const { from, to } = pan;
62
+ return { x: lerp(from.x, to.x, e), y: lerp(from.y, to.y, e), w: lerp(from.w, to.w, e), h: lerp(from.h, to.h, e) };
63
+ };
64
+
65
+ const shoot = async () => {
66
+ now += frameMs;
67
+ await step(page, now, frameMs);
68
+ const v = where();
69
+ if (shaking && now < shaking.until) {
70
+ const fade = 1 - (now - shaking.at) / (shaking.until - shaking.at);
71
+ v.x = Math.min(Math.max(v.x + Math.sin(now * 0.09) * shaking.px * fade, 0), W - v.w);
72
+ v.y = Math.min(Math.max(v.y + Math.cos(now * 0.13) * shaking.px * fade, 0), H - v.h);
73
+ }
74
+ // Half a pixel over, or Chromium rounds the picture a pixel short.
75
+ const scale = (format.out[0] + 0.5) / v.w;
76
+ const { data } = await cdp.send('Page.captureScreenshot', {
77
+ format: 'jpeg', quality: 94, clip: { x: v.x, y: v.y, width: v.w, height: v.h, scale },
78
+ });
79
+ fs.writeFileSync(path.join(dir, `${String(count).padStart(5, '0')}.jpg`), Buffer.from(data, 'base64'));
80
+ // What the app started playing this frame (see listen() in sound.mjs).
81
+ for (const { url, delay } of await page.evaluate(() => (window.__heard || []).splice(0))) {
82
+ sounds.push({ at: count / fps + delay, url });
83
+ }
84
+ count += 1;
85
+ };
86
+
87
+ // The union of every box matching these selectors, or null.
88
+ const boxOf = async selectors => {
89
+ const boxes = [];
90
+ for (const selector of [selectors].flat()) {
91
+ for (const el of await page.locator(selector).all()) {
92
+ const b = await el.boundingBox();
93
+ if (b && b.width && b.height) boxes.push(b);
94
+ }
95
+ }
96
+ if (!boxes.length) return null;
97
+ const x = Math.min(...boxes.map(b => b.x));
98
+ const y = Math.min(...boxes.map(b => b.y));
99
+ return { x, y, w: Math.max(...boxes.map(b => b.x + b.width)) - x, h: Math.max(...boxes.map(b => b.y + b.height)) - y };
100
+ };
101
+
102
+ const cam = {
103
+ shoot,
104
+ async hold(ms) { for (let t = 0; t < ms; t += frameMs) await shoot(); },
105
+ // The pointer from `from` to `to` over `ms`, a frame per step.
106
+ async move(from, to, ms) {
107
+ const steps = Math.max(1, Math.round(ms / frameMs));
108
+ for (let i = 1; i <= steps; i += 1) {
109
+ const e = inOut(i / steps);
110
+ await page.mouse.move(lerp(from.x, to.x, e), lerp(from.y, to.y, e));
111
+ await shoot();
112
+ }
113
+ },
114
+ // Frame a rect of the page over `ms` (0 cuts).
115
+ look(rect, ms = 0, ease = inOut) {
116
+ pan = { from: where(), to: fitRect(rect, size), at: now, ms: Math.max(ms, 1), ease };
117
+ },
118
+ // Frame whatever matches `selectors`; see roomAround for the options.
119
+ // Frame an element after its entrance animation has finished, or its box
120
+ // is the wrong size.
121
+ async focus(selectors, { ms = 0, ease = inOut, ...room } = {}) {
122
+ const box = await boxOf(selectors);
123
+ if (box) cam.look(roomAround(box, room), ms, ease);
124
+ },
125
+ punch(selectors, opts) { return cam.focus(selectors, { ms: 260, ease: out, ...opts }); },
126
+ shake(ms, px) { shaking = { at: now, until: now + ms, px }; },
127
+ title(title) { cues.push({ frame: count, show: title }); },
128
+ untitle() { cues.push({ frame: count, hide: true }); },
129
+ endCard() { cues.push({ frame: count, end: true }); },
130
+ // A moment the cut needs to know about, e.g. 'hush' where the music stops.
131
+ mark(name) { marks[name] = Number((count / fps).toFixed(3)); },
132
+ boxOf,
133
+ get frames() { return count; },
134
+ tall: H > W,
135
+ cues,
136
+ sounds,
137
+ marks,
138
+ };
139
+ return cam;
140
+ }