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.
- checksums.yaml +7 -0
- data/AGENTS.md +78 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +223 -0
- data/engine/bin/trailer.mjs +45 -0
- data/engine/lib/args.mjs +18 -0
- data/engine/lib/camera.mjs +140 -0
- data/engine/lib/check.mjs +134 -0
- data/engine/lib/clock.mjs +38 -0
- data/engine/lib/config.mjs +97 -0
- data/engine/lib/cut.mjs +169 -0
- data/engine/lib/ease.mjs +6 -0
- data/engine/lib/ffmpeg.mjs +46 -0
- data/engine/lib/film.mjs +161 -0
- data/engine/lib/index.mjs +5 -0
- data/engine/lib/showcase.mjs +83 -0
- data/engine/lib/sound.mjs +145 -0
- data/engine/lib/titles.mjs +43 -0
- data/engine/package.json +14 -0
- data/engine/titles.html +144 -0
- data/examples/posh-nosh/trailer-titles.css +26 -0
- data/examples/posh-nosh/trailer.config.mjs +168 -0
- data/lib/belt/commands/trailer_command.rb +123 -0
- data/lib/belt/generators/trailer_generator.rb +215 -0
- data/lib/belt/trailer/templates/showcase/build.mjs +24 -0
- data/lib/belt/trailer/templates/showcase/fixtures.js.erb +22 -0
- data/lib/belt/trailer/templates/showcase/showcase.html +14 -0
- data/lib/belt/trailer/templates/showcase/showcase.jsx +50 -0
- data/lib/belt/trailer/templates/trailer-storyboard.md.erb +37 -0
- data/lib/belt/trailer/templates/trailer-titles.css +22 -0
- data/lib/belt/trailer/templates/trailer.config.mjs.erb +69 -0
- data/lib/belt/trailer/version.rb +7 -0
- data/lib/belt/trailer.rb +69 -0
- data/lib/belt-trailer.rb +3 -0
- data/skills/belt-trailer/SKILL.md +128 -0
- data/skills/belt-trailer/references/beats.md +136 -0
- data/skills/belt-trailer/references/reviewing.md +90 -0
- data/skills/belt-trailer/references/showcase-and-fixtures.md +80 -0
- data/skills/belt-trailer/references/storyboard.md +87 -0
- 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
|
+
}
|
data/engine/lib/args.mjs
ADDED
|
@@ -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
|
+
}
|