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,128 @@
1
+ ---
2
+ name: belt-trailer
3
+ license: MIT
4
+ description: >-
5
+ Make a launch trailer of a web app from the app itself with belt-trailer:
6
+ write the storyboard, build the showcase and its story fixtures, script the
7
+ beats (camera, titles, sounds) in trailer.config.mjs, film, review the frames,
8
+ cut with music, and pass `belt trailer check`. Use when asked to make, film,
9
+ re-film, fix or review a trailer, launch video, promo video or App Store
10
+ preview of a web app, or when working with trailer.config.mjs, a showcase,
11
+ `belt generate trailer`, `belt trailer film|cut|check`, or the belt-trailer
12
+ engine (engine/bin/trailer.mjs). Do NOT activate for movie trailers, video
13
+ editing of footage that wasn't filmed from the app, or vehicle trailers.
14
+ metadata:
15
+ author: stowzilla
16
+ version: "0.1.0"
17
+ ---
18
+
19
+ # belt-trailer
20
+
21
+ belt-trailer films a web app's real screens a frame at a time (1080p, 30 fps),
22
+ with a camera that pushes and pans, titles in the app's own fonts, and the sound
23
+ effects the app plays as it's filmed. It cuts the shots together with music,
24
+ levelled to −14 LUFS, and `check` says whether the result is fit to publish.
25
+
26
+ Your job as the agent is the part the engine can't do: **decide what the trailer
27
+ says, build the states it films, script every shot, and look at the result with
28
+ a critical eye.** The engine handles the plumbing. Don't reimplement any of it.
29
+
30
+ ## First: orient yourself
31
+
32
+ 0. Find the gem: `bundle show belt-trailer` prints its directory, written
33
+ `<gem>` here.
34
+ If this skill was installed with `npx skills add` and the gem is a different
35
+ version, `belt trailer guide` prints the copy that matches the gem; prefer it.
36
+ 1. Is there a `trailer.config.mjs` in the frontend (usually `frontend/`)? If not,
37
+ run `belt generate trailer --url <the app's address>`. Outside Belt, copy
38
+ the config and showcase from `<gem>/lib/belt/trailer/templates/`.
39
+ 2. Is there a showcase? Look for `scripts/showcase/` or `scripts/screenshots/`
40
+ with a `build.mjs`, and `?view=` handling in a showcase page. The generator
41
+ finds an existing one and films that.
42
+ 3. Is there a storyboard? `trailer-storyboard.md` beside the config (the
43
+ generator writes a template). **If it's empty, write it first and get it
44
+ agreed with a person before scripting beats.** A storyboard is cheap to
45
+ change; filmed beats aren't.
46
+ 4. Can it film? `belt trailer film --only <first beat> --format wide` must get
47
+ past the requirements check (Node, ffmpeg, Playwright's Chromium in the
48
+ frontend). It names whatever is missing.
49
+
50
+ `<gem>/examples/posh-nosh/` is a finished, reviewed trailer. Read it before
51
+ writing beats; most problems you'll meet are solved there.
52
+
53
+ ## The workflow
54
+
55
+ | Step | You produce | Reference |
56
+ |---|---|---|
57
+ | 1. Storyboard | `trailer-storyboard.md`: 5–7 beats, 20–30 s, each with what's on screen, the camera, the words, why | [references/storyboard.md](references/storyboard.md) |
58
+ | 2. Showcase + story | Fixtures for every state each beat shows, as one session moving forward, plus the `?sound=1` hook and any triggers | [references/showcase-and-fixtures.md](references/showcase-and-fixtures.md) |
59
+ | 3. Beats | `beats: [...]` in `trailer.config.mjs`: `play()` scripts | [references/beats.md](references/beats.md) |
60
+ | 4. Film, look, fix | One beat at a time, wide first, reviewed from contact sheets and frames | [references/reviewing.md](references/reviewing.md) |
61
+ | 5. Cut + check | `belt trailer cut`, `belt trailer check` until PASS, then a person watches it | [references/reviewing.md](references/reviewing.md) |
62
+
63
+ Iterate on one beat in one format: `belt trailer film --only hook --format wide`.
64
+ A beat takes about a minute to film; the whole trailer in both formats takes
65
+ 10+. Film both formats only once the wide one is right, and check tall framing
66
+ separately (`cam.tall` branches).
67
+
68
+ ## Golden rules
69
+
70
+ - **You can't watch video. Look at frames.** Every claim that a shot "works" must
71
+ come from images you've looked at: the contact sheet, then single frames around
72
+ every cut, move and title. Never report a trailer as done from `check` alone.
73
+ `check` measures sound, length and title clearance, not whether the shot is good.
74
+ - **Film the real app.** No mock components drawn for the trailer, no faked
75
+ numbers the app can't produce, no sounds added that the app doesn't play. If
76
+ a shot needs a state, make a fixture with the data the server would really send.
77
+ - **One story.** Every gameplay/product shot is the same session at a later
78
+ moment: same records, same people, same names. Build fixtures from one function.
79
+ - **Pace for reading.** Each title is up, fully landed, about 2 s. A moment the
80
+ viewer has to follow gets 2–3 s. Titles leave 0.8 s before the cut (built in).
81
+ - **Words go where nothing important is**, and in the product's voice. Never on
82
+ the subject or the controls.
83
+ - **Music on the first frame.** A silent cold open reads as a broken video.
84
+ - **Don't fight the engine.** If a beat seems to need something the config
85
+ can't express, say so (a Fizzy card on belt-trailer) rather than patching
86
+ around it in the app or forking engine code into the app.
87
+ - **Ask a person before publishing.** Rights to every audio file (`check` lists
88
+ them), and the end card names only what's live.
89
+
90
+ ## Commands
91
+
92
+ ```bash
93
+ belt generate trailer [--url ADDRESS] [--frontend NAME] # scaffold
94
+ belt trailer film [--only a,b] [--format wide|tall|both] [--clean] [--keep-frames] [--sounds bundled]
95
+ belt trailer cut [--format …] [--music PLAN]
96
+ belt trailer check [--format …] [--music PLAN] # exit 1 on FAIL
97
+ belt trailer guide # print this skill
98
+ ```
99
+
100
+ Output goes to `<frontend>/tmp/trailer/`: `<beat>-<format>.mp4` (titled),
101
+ `-clean.mp4` (no titles), `.json` (sounds captured with their times, marks),
102
+ `trailer-<format>.mp4`, `trailer-<format>.json` and `trailer-<format>-sheet.png`.
103
+
104
+ Outside Belt: `node <gem>/engine/bin/trailer.mjs film --app <frontend dir> …`
105
+ with the same options.
106
+
107
+ ## When something goes wrong
108
+
109
+ | Symptom | Likely cause | Fix |
110
+ |---|---|---|
111
+ | `drag: nothing matches …` / camera doesn't move | Selector matches nothing, or the element isn't rendered yet in that fixture | Check the selector against the fixture; `cam.boxOf(sel)` returns null when nothing matches |
112
+ | Camera "moves" but frame stays wide | The padded box is wider than the page, so it fits to the whole page | Smaller `pad`; a full-width element (an `h1`) needs `pad` ≈ 0.1 |
113
+ | Push-in snaps | Push of >1.5× in under 0.9 s | `ms: 900` or more, eased |
114
+ | Framed the wrong place | Measured mid-entrance-animation | `cam.hold()` until it lands, then `focus` |
115
+ | A sound is missing | The fixture change doesn't include the event that triggers it | Make the next fixture carry the data the server would send (the log line, the event) |
116
+ | Sound plays but `check` says it's off its frame | Usually the source file has a long or quiet lead-in | Look at the file; a masked sound (under another) is reported, not failed |
117
+ | `check` loudness fails | No music, or a very quiet/loud plan | Add tracks to `music.plan`; don't hand-tune gains, the cut levels it |
118
+ | Title on screen at a cut | A title cued too late in the beat | Cue it earlier or lengthen the beat; ~2 s landed + 0.8 s before the cut |
119
+ | Same beat looks different each take | Drag beats can differ run to run (known) | Judge from the take you have; re-film if a take is bad |
120
+ | Fonts wrong in titles | `@font-face` URLs don't resolve from the showcase build | Point them at files in `public/` (served at `/`) |
121
+
122
+ ## Deeper references
123
+
124
+ - [references/storyboard.md](references/storyboard.md): writing the storyboard, a template, timing budget, words
125
+ - [references/showcase-and-fixtures.md](references/showcase-and-fixtures.md): the showcase, story fixtures, sound hook, triggers, canned API
126
+ - [references/beats.md](references/beats.md): the `play()` API, camera recipes, tall vs wide, config options
127
+ - [references/reviewing.md](references/reviewing.md): looking at frames, the review checklist, reading `check`, when it's done
128
+ - `<gem>/README.md` ([on GitHub](https://github.com/stowzilla/belt-trailer#readme)): the full config reference and how the engine works
@@ -0,0 +1,136 @@
1
+ # Scripting beats
2
+
3
+ A beat is one entry in `beats` in `trailer.config.mjs`:
4
+
5
+ ```js
6
+ { name: 'dragon', view: 'trailer-dragon-offer', query: 'chat=off', css: '…', settle: 800,
7
+ play: async ({ page, cam, setView, drag, fire, ease, tall, format }) => { … } }
8
+ ```
9
+
10
+ The page opens on `view`, settles, and `play` films it. **Frames are only
11
+ filmed by `cam.hold`, `cam.move` and `cam.shoot`.** Everything else
12
+ (`focus`, `title`, `setView`) happens between frames, so the timing of a beat
13
+ is the sum of its holds and moves.
14
+
15
+ ## The API
16
+
17
+ | Call | Does |
18
+ |---|---|
19
+ | `await cam.hold(ms)` | Film for `ms` |
20
+ | `await cam.focus(sel, { ms, pad, top, right, bottom, ease })` | Frame the union of everything matching `sel` (a selector or array) over `ms`; `ms: 0` (default) cuts |
21
+ | `await cam.punch(sel, opts)` | A fast focus: 260 ms, ease out |
22
+ | `cam.look(rect, ms, ease)` | Frame a page rect `{ x, y, w, h }` |
23
+ | `cam.shake(ms, px)` | Shake, fading out over `ms` |
24
+ | `await cam.move(from, to, ms)` | Move the pointer `{x, y}` → `{x, y}`, filming |
25
+ | `cam.title({ kicker, line, place })` | Title in from this frame; `place`: `side`, `band`, `top`, `bottom`, or `{ wide, tall }` |
26
+ | `cam.untitle()` | Send the title out now |
27
+ | `cam.endCard()` | The end card (from `titles.endCard`) |
28
+ | `cam.mark('hush')` | Music stops here in the cut |
29
+ | `await cam.boxOf(sel)` | `{ x, y, w, h }` or null |
30
+ | `cam.tall` / `tall` | Filming the tall format |
31
+ | `await setView(name)` | Switch fixture without reloading |
32
+ | `await drag(from, to, { ms, hold })` | Press `from`, carry to `to` over `ms`, hold, release (filmed) |
33
+ | `await fire(name, ...args)` | `window[name](...args)` on the page |
34
+ | `ease.inOut`, `ease.out`, `ease.linear` | Curves for `focus` |
35
+ | `page` | The Playwright page, for anything else |
36
+
37
+ `pad`, `top`, `right` and `bottom` are **fractions of the subject's size**.
38
+ `pad: 0.1` is 10% all round; `right: 0.6` adds 60% of its width on the right
39
+ (room for a `side` title). The result is fitted to the format's aspect and kept
40
+ on the page, so a padded box wider than the page means the whole page.
41
+
42
+ ## Recipes
43
+
44
+ **Open wide, push in.** Cut to the establishing frame, hold, then move:
45
+
46
+ ```js
47
+ await cam.focus(['.board'], { pad: 0.03 }); // cut
48
+ await cam.hold(300);
49
+ await cam.focus('.card.active', { ms: 1000, pad: 0.3 }); // >1.5× push: ≥0.9 s
50
+ await cam.hold(400);
51
+ ```
52
+
53
+ **A hit.** Change state, punch in, shake, hold long enough to read the result:
54
+
55
+ ```js
56
+ await setView('trailer-hit');
57
+ await cam.punch('.enemy >> nth=1', { pad: 0.3 });
58
+ cam.shake(400, 10);
59
+ await cam.hold(1600);
60
+ ```
61
+
62
+ **A title beside the subject (wide) or in the band (tall):**
63
+
64
+ ```js
65
+ await cam.focus('.chart', { ms: 900, pad: 0.1, right: tall ? 0 : 0.7, top: tall ? 0.9 : 0 });
66
+ await cam.hold(300); // let the move land first
67
+ cam.title({ kicker: 'Every number, live', line: 'Watch it move', place: { tall: 'top' } });
68
+ await cam.hold(2900); // ~0.6 s landing + 2 s read + the 0.8 s exit
69
+ ```
70
+
71
+ **A dialog the viewer has to follow:** frame the dialog, slow drift (linear),
72
+ hold each step ~0.5–1.3 s, and only title the result:
73
+
74
+ ```js
75
+ await cam.focus('.dialog', { pad: 0.12 });
76
+ await cam.focus('.dialog', { ms: 2600, pad: 0.04, ease: ease.linear });
77
+ await cam.hold(700);
78
+ await page.mouse.move(2, 2); // pointer off anything with a hover state
79
+ await setView('trailer-dialog-done');
80
+ await cam.hold(1300);
81
+ ```
82
+
83
+ **Hand over to the next beat.** End on the framing the next beat opens with:
84
+
85
+ ```js
86
+ // end of beat A
87
+ await cam.focus(['.ally', '.foe'], { ms: 1000, pad: 0.06, right: 0.6 });
88
+ await cam.hold(1100);
89
+ // start of beat B: same selectors, same options, ms 0
90
+ await cam.focus(['.ally', '.foe'], { pad: 0.06, right: 0.6 });
91
+ ```
92
+
93
+ **Something arriving on cue** (a message, a notification):
94
+
95
+ ```js
96
+ await fire('__say', 'Is that all you’ve got?', '1');
97
+ await cam.hold(450); // let the bubble pop before framing it
98
+ await cam.focus(['.bubble.theirs', '.foe >> nth=3'], { ms: 450, pad: 0.35, ease: ease.out });
99
+ await cam.hold(1300);
100
+ ```
101
+
102
+ **The end card:**
103
+
104
+ ```js
105
+ { name: 'end', view: 'home', css: '#root { filter: blur(5px) brightness(0.6) saturate(0.8); }',
106
+ play: async ({ cam, ease }) => {
107
+ await cam.focus('#root', { pad: -0.08, ms: 3800, ease: ease.linear });
108
+ cam.endCard();
109
+ await cam.hold(3800);
110
+ } },
111
+ ```
112
+
113
+ ## Wide and tall
114
+
115
+ The same beat films both formats: wide from a 1280×720 page, tall from a
116
+ 432×768 phone layout. The app's layout differs between them, so selectors and
117
+ room for words differ too. Branch on `tall`:
118
+
119
+ - Wide: words `side`, camera leaves `right` room.
120
+ - Tall: words in the `band` across the middle, or `top` when the camera is in
121
+ close; the camera leaves `top` room instead of `right`.
122
+
123
+ Get wide right first, then film `--format tall` and review it separately.
124
+
125
+ ## Rules that are easy to break
126
+
127
+ - **Frame after entrance animations finish.** `boxOf` measures now; mid-pop the
128
+ box is the wrong size. `hold` first.
129
+ - **Keep the pointer off hover states** unless the hover is the point:
130
+ `page.mouse.move(2, 2)`. A hovered card lifts, then lifts again when picked.
131
+ There's no cursor in the video anyway.
132
+ - **Hide the app's heading** (`beat.css`) when a title replaces it.
133
+ - **Don't wait on wall time** (`page.waitForTimeout`) inside `play`. The page's
134
+ clock only moves with frames; use `cam.hold`.
135
+ - **Throwing is fine.** `drag` throws when a selector matches nothing. Fix the
136
+ selector or the fixture; don't wrap it in try/catch to film around it.
@@ -0,0 +1,90 @@
1
+ # Reviewing what you filmed
2
+
3
+ You can't watch the video. You review it as images, and that's enough if you
4
+ look in the right places. **Never call a beat done without looking at its frames.**
5
+ Read a few images at a time, not dozens at once.
6
+
7
+ ## The loop for one beat
8
+
9
+ ```bash
10
+ belt trailer film --only dragon --format wide
11
+ cd frontend/tmp/trailer
12
+ # 1. The whole beat at a glance: 3 frames a second, with timestamps
13
+ ffmpeg -y -loglevel error -i dragon-wide.mp4 \
14
+ -vf "fps=3,scale=320:-2,drawtext=text='%{pts\:hms}':x=4:y=4:fontsize=14:fontcolor=yellow,tile=6x4" \
15
+ -frames:v 1 dragon-sheet.png
16
+ # 2. Single frames where it matters (seconds)
17
+ ffmpeg -y -loglevel error -ss 2.1 -i dragon-wide.mp4 -frames:v 1 dragon-2.1.png
18
+ # 3. What it played, and when
19
+ cat dragon-wide.json
20
+ ```
21
+
22
+ Look at the sheet first, then single full-size frames at:
23
+
24
+ - **the first and last frame** (compare the last with the next beat's first frame),
25
+ - **every camera move**, its start, middle and end (a 0.9 s move is ~27
26
+ frames; look at 3 of them),
27
+ - **every title** once fully landed (~0.8 s after its cue) and as it leaves,
28
+ - **every state change** (`setView`, `drag`, `fire`): the frame before and ~0.5 s after,
29
+ - **every sound** in the `.json`: the frame at its time should show its cause.
30
+
31
+ For a move that looks wrong, step through it frame by frame:
32
+ `ffmpeg -ss 1.8 -t 1 -i dragon-wide.mp4 -vf "fps=15,scale=480:-2,tile=5x3" -frames:v 1 move.png`.
33
+
34
+ ## The checklist per beat
35
+
36
+ Answer each one from the frames:
37
+
38
+ - [ ] The first frame is a picture, not a blank, a spinner or a half-rendered page.
39
+ - [ ] The subject is in frame and sharp, and nothing important is cut off at the edges.
40
+ - [ ] Every move is smooth: no snap (a push covering the distance in 2–3
41
+ frames), no flash of the wide view on a cut.
42
+ - [ ] The words are fully on screen, readable at the sheet's size, and not on
43
+ the subject, a hand of cards, the controls or a face.
44
+ - [ ] Each title is fully landed for ~2 s (count frames: 60 at 30 fps).
45
+ - [ ] No title is on screen in the last 0.8 s.
46
+ - [ ] The app's own heading isn't showing through under a title.
47
+ - [ ] No hover lift, tooltip or focus ring the shot isn't about.
48
+ - [ ] Same records, people and numbers as the beats either side.
49
+ - [ ] The last frame matches the next beat's first frame in framing.
50
+ - [ ] Every expected sound is in the `.json` (and nothing odd, like a sound from
51
+ the page loading).
52
+ - [ ] **Tall:** the same again; the words are in the band or at the top, not on
53
+ the hand or the controls.
54
+
55
+ Fix the beat or its fixture, re-film just that beat, and look again. When a
56
+ fix is about pacing, change hold lengths; when it's about framing, change
57
+ `pad`, `top`, `right` or `bottom`; when a sound or animation is missing, the
58
+ fixture is missing data.
59
+
60
+ ## The cut
61
+
62
+ ```bash
63
+ belt trailer cut --format wide
64
+ belt trailer check --format wide
65
+ ```
66
+
67
+ `check` exits 1 on FAIL and prints a line per measurement:
68
+
69
+ | Line | When it fails |
70
+ |---|---|
71
+ | audio starts at 0 / audio vs video length | The cut is broken; re-run `cut`. If it persists, report it on belt-trailer |
72
+ | loudness −14 ±1 LUFS | Usually no music (−70 LUFS is silence): add tracks to `music.plan` |
73
+ | true peak below −1 dBFS | A clipped source file; find it in the audio list and tell a person |
74
+ | at most 30 s | Cut a beat or shorten holds; don't speed up the reading time |
75
+ | `<beat>`: effects n/m on their frame | A sound landed late: look at the source file's lead-in. "Under another sound" is reported, not failed |
76
+ | `<beat>`: no title in the last 0.5 s | A title is still up at the cut: cue it earlier or lengthen the beat |
77
+
78
+ Then look at `trailer-wide-sheet.png` (2 fps, the whole trailer) and at the
79
+ frames either side of **every cut** (the cut times are in
80
+ `trailer-wide.json`, `beats[].start`).
81
+
82
+ ## When it's done
83
+
84
+ - `check` passes in both formats.
85
+ - You've looked at every beat's frames against the checklist, in both formats.
86
+ - You report: the length, the check output, the sheets (attach them), every
87
+ audio file in the cut with where it came from, and anything you weren't sure
88
+ about.
89
+ - **A person watches it with sound before it's published**, and answers the
90
+ rights question for every audio file. You don't publish it.
@@ -0,0 +1,80 @@
1
+ # The showcase and the story
2
+
3
+ belt-trailer films a **showcase**: the app's screens rendered from hand-written
4
+ states (fixtures), with no backend and no one clicking. This is where most of a
5
+ trailer's work is, and it's app-specific: the engine can't write it for you.
6
+
7
+ ## What a showcase is
8
+
9
+ - A page built with Vite (`scripts/showcase/build.mjs`, `showcase.html`,
10
+ `showcase.jsx`) that renders one fixture, chosen by `?view=<name>`.
11
+ - `window.__setView(name)` switches to another fixture **without reloading**.
12
+ This matters: the app's components see a state change, as when a real update
13
+ arrives, so they run their own animations and play their own sounds. A beat
14
+ is mostly "open on fixture A, `setView('B')`, film what the app does".
15
+ - API calls never reach a server. The generated showcase answers them from the
16
+ current fixture's `api` map; anything left goes to `api` in
17
+ `trailer.config.mjs` (Playwright route interception). Modules that need the
18
+ network (sign-in, analytics) are swapped for mocks in `build.mjs` (Posh Nosh
19
+ swaps its Cognito module with a Vite `resolveId` plugin).
20
+
21
+ `belt generate trailer` writes a minimal one for a Belt React frontend (the app
22
+ inside a `MemoryRouter`, one `home` fixture). If the app already has a showcase
23
+ for screenshots, the generator films that instead. Extend it; don't build a
24
+ second one.
25
+
26
+ ## Fixtures for a trailer
27
+
28
+ 1. **One story.** Write a function that builds the session at any moment
29
+ (Posh Nosh: `storyG(options)` in `fixtures.js`) and make every trailer
30
+ fixture from it: `trailer-start`, `trailer-hit`, `trailer-won`. Never borrow
31
+ a screenshot fixture with different records: a unit, person or number that
32
+ changes between cuts reads as a different session, and viewers notice.
33
+ 2. **One fixture per state change**, holding **the data the server would really
34
+ send** after it. Sounds and animations come from the difference between two
35
+ states: if the app plays a knockout sound when a log line says "fell", the
36
+ next fixture needs that log line. A missing event is a missing sound.
37
+ 3. **Realistic content.** Real-looking names, amounts and dates in the product's
38
+ voice. No lorem ipsum, no "Test User", no numbers the product can't produce.
39
+ 4. **Name them for the beat**: `trailer-<what happens>`, so the config reads
40
+ as the storyboard.
41
+
42
+ ## The two hooks a trailer needs
43
+
44
+ Both behind query flags, so screenshots are unaffected:
45
+
46
+ - **`?sound=1`** mounts the app's own sound hooks or provider in the showcase,
47
+ so it plays what it would. The config sets `showcase.query: { sound: 1 }`.
48
+ Without this, the trailer has no effects.
49
+ - **A trigger for timed events.** Anything the app does on a timer or from a
50
+ socket (a message arriving, a notification) should be fired on cue instead:
51
+ expose it on `window` in the showcase (Posh Nosh: `window.__say(text, from)`)
52
+ and call it from a beat with `fire('__say', 'Hello', '1')`.
53
+
54
+ ## Page setup in the config
55
+
56
+ - `page.css`: CSS for every beat (hide a chat panel that's never the subject).
57
+ - `page.init`: runs before the app on every page; set the `localStorage` that
58
+ skips welcome screens and turns sound on. It's serialised into the page, so it
59
+ can't use variables from the config file.
60
+ - `beat.css`: CSS for one beat (hide the heading a title replaces; blur the
61
+ background of the end card).
62
+ - `unlock`: how the app's audio gets unlocked (default: a key press, as a first
63
+ tap). Change it only if the app waits for a click on something specific.
64
+ - `settle`: how long the page's own clock runs before filming (default
65
+ 1500 ms). Shorten it for a beat whose moment is on a timer from load.
66
+
67
+ ## Sounds the app gets from somewhere else
68
+
69
+ If someone can replace the app's sounds on the live site, set
70
+ `sounds.live: { origin, paths }`: those paths are answered from the live site
71
+ and its files cached, so the trailer plays what the live app plays.
72
+ `--sounds bundled` uses the files in `sounds.bundled` (default `public/`)
73
+ instead. List music URLs in `sounds.ignore` so they aren't captured as effects:
74
+ music is laid under the cut from `music.tracks`.
75
+
76
+ ## Checking a fixture before filming
77
+
78
+ Build and open the showcase in a browser (or a quick Playwright script) at
79
+ `?view=<fixture>` and look at it. A fixture that doesn't render is much faster
80
+ to find there than in a filmed beat.
@@ -0,0 +1,87 @@
1
+ # Writing the storyboard
2
+
3
+ Write it before any beat, in `trailer-storyboard.md` beside `trailer.config.mjs`,
4
+ and get a person to agree it. Everything after (fixtures, beats, filming) follows
5
+ from it, and changing it later means re-filming.
6
+
7
+ ## Before the shots: who and what
8
+
9
+ Answer these in the storyboard's first section, from the app's own site, README,
10
+ store listing and meta description. Don't invent a pitch the product doesn't make.
11
+
12
+ - **Who is it for**, in one sentence, and what are they doing when they'd want it?
13
+ - **The offer in one line.** Usually already written: the meta description, the
14
+ store subtitle. The trailer proves it rather than replacing it.
15
+ - **What makes it different, in order of how fast it reads on screen.** Motion
16
+ and visuals read in a second; "no sign-up" reads in two; depth takes a shot of
17
+ its own. That order is roughly the order of the beats.
18
+ - **What not to lead with.** Usually the rules, settings or onboarding. Nobody
19
+ learns an app from a 25-second trailer; they decide whether it looks worth trying.
20
+
21
+ ## The shots
22
+
23
+ Five to seven beats, 20–30 s in all (30 s is the App Store preview limit,
24
+ and `check` enforces it). One table, a row per beat:
25
+
26
+ | Time | Beat | Fixture(s) | Camera | On screen (words) | Why it's here |
27
+ |---|---|---|---|---|---|
28
+ | 0–2.8 s | `hook` | `board-fresh` → `board-hit` | wide on the board, slams in on the hit, shake | no words | something happens in the first second |
29
+ | … | | | | | |
30
+ | last 3.5–4 s | `end` | `home` (blurred) | slow drift | logo · the offer · terms · the address | the call to action, held long enough to type |
31
+
32
+ The beat names are yours (`--only` takes them), lower-case, one word.
33
+
34
+ ### The shape that works
35
+
36
+ 1. **Hook (2–3 s), no words.** The most striking thing the app does, mid-action
37
+ on the first frame. Not a logo, not a title card, not a loading screen.
38
+ 2. **Two to four beats, each one idea**, each with one title: the choice
39
+ that makes it yours, the depth, the biggest moment, the tone.
40
+ 3. **The payoff.** The result the viewer wants (the win, the finished report,
41
+ the shipped thing), earned by the beats before it.
42
+ 4. **End card (3.5–4 s).** Logo, the offer, the terms ("Free · No account
43
+ needed") and the address, on the app softened behind it.
44
+
45
+ ### Timing budget
46
+
47
+ - A titled beat: ~0.5 s to settle, ~0.6 s for the words to land, **2 s fully
48
+ landed**, then out **0.8 s before the cut**. That's about 4 s minimum.
49
+ - A moment to follow (a dialog, a choice, its result): **2–3 s on its own**,
50
+ with its title held back until the result is on screen.
51
+ - A push-in of more than 1.5× takes **at least 0.9 s**.
52
+ - Add the beats up in the table's Time column. Over 30 s means cut a beat, not
53
+ speed them all up: Posh Nosh's first cut at 22 s was too fast to read, and
54
+ the finished one is 29.2 s.
55
+
56
+ ### Continuity
57
+
58
+ - Every product shot is **the same session at a later moment**: the same
59
+ records, people, names and numbers moving forward. Say so in the
60
+ storyboard and name the fixture function that builds them.
61
+ - **Match framing across cuts**: end a shot on the framing the next one opens
62
+ with. Write the end framing and the next opening framing in the Camera column.
63
+
64
+ ## Words
65
+
66
+ - **Short enough to read twice in 2 s**: a kicker of 2–5 words over a line of
67
+ 2–4 words. "Every unit has an ability" over "Out-play your rival".
68
+ - **In the product's voice.** Read the app's own copy first. Words that don't
69
+ fit get noticed straight away ("darling" didn't fit Posh Nosh).
70
+ - **Where nothing important is.** Say where in the table: `side` (a panel on
71
+ the right of a wide frame, the camera leaving room), `band` (across the
72
+ middle of a tall frame), `top` or `bottom`. Never on the subject or the controls.
73
+ - The app's own text can be the words (Posh Nosh's chat bubbles were its
74
+ banter beat's words). Hide the app's own heading when a title replaces it.
75
+
76
+ ## Sound
77
+
78
+ Write one line per beat: which music, and which effects the viewer should hear
79
+ (they come from the app; if a beat needs a sound the app doesn't play there, the
80
+ beat is wrong, not the sound). Music from the first frame; a `hush` mark where
81
+ the music should stop (a win, say). Note where every audio file comes from:
82
+ **rights for advertising need a person's answer before anything is published.**
83
+
84
+ ## Open questions
85
+
86
+ End with what a person has to decide: rights, paid content on screen, which line
87
+ of copy, where it launches. Don't decide these yourself.
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: belt-trailer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stowzilla
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: belt
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.6.4
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.6.4
26
+ description: Belt plugin that films an app's real screens a frame at a time with Playwright,
27
+ at 1080p and 30 fps, with a moving camera, titles in the app's own fonts and the
28
+ sound effects the app plays; cuts the beats together with music levelled to -14
29
+ LUFS; and checks whether the cut is fit to publish. The engine is plain Node and
30
+ works for any website.
31
+ email:
32
+ - andy@stowzilla.com
33
+ - adam@stowzilla.com
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - AGENTS.md
39
+ - CHANGELOG.md
40
+ - LICENSE
41
+ - README.md
42
+ - engine/bin/trailer.mjs
43
+ - engine/lib/args.mjs
44
+ - engine/lib/camera.mjs
45
+ - engine/lib/check.mjs
46
+ - engine/lib/clock.mjs
47
+ - engine/lib/config.mjs
48
+ - engine/lib/cut.mjs
49
+ - engine/lib/ease.mjs
50
+ - engine/lib/ffmpeg.mjs
51
+ - engine/lib/film.mjs
52
+ - engine/lib/index.mjs
53
+ - engine/lib/showcase.mjs
54
+ - engine/lib/sound.mjs
55
+ - engine/lib/titles.mjs
56
+ - engine/package.json
57
+ - engine/titles.html
58
+ - examples/posh-nosh/trailer-titles.css
59
+ - examples/posh-nosh/trailer.config.mjs
60
+ - lib/belt-trailer.rb
61
+ - lib/belt/commands/trailer_command.rb
62
+ - lib/belt/generators/trailer_generator.rb
63
+ - lib/belt/trailer.rb
64
+ - lib/belt/trailer/templates/showcase/build.mjs
65
+ - lib/belt/trailer/templates/showcase/fixtures.js.erb
66
+ - lib/belt/trailer/templates/showcase/showcase.html
67
+ - lib/belt/trailer/templates/showcase/showcase.jsx
68
+ - lib/belt/trailer/templates/trailer-storyboard.md.erb
69
+ - lib/belt/trailer/templates/trailer-titles.css
70
+ - lib/belt/trailer/templates/trailer.config.mjs.erb
71
+ - lib/belt/trailer/version.rb
72
+ - skills/belt-trailer/SKILL.md
73
+ - skills/belt-trailer/references/beats.md
74
+ - skills/belt-trailer/references/reviewing.md
75
+ - skills/belt-trailer/references/showcase-and-fixtures.md
76
+ - skills/belt-trailer/references/storyboard.md
77
+ homepage: https://github.com/stowzilla/belt-trailer
78
+ licenses:
79
+ - MIT
80
+ metadata:
81
+ homepage_uri: https://github.com/stowzilla/belt-trailer
82
+ source_code_uri: https://github.com/stowzilla/belt-trailer
83
+ changelog_uri: https://github.com/stowzilla/belt-trailer/blob/main/CHANGELOG.md
84
+ rubygems_mfa_required: 'true'
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '3.3'
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 4.0.24
100
+ specification_version: 4
101
+ summary: Film a launch trailer of a web app from its own screens and sounds
102
+ test_files: []