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
|
@@ -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: []
|