@officexapp/vidfarm-devcli 0.21.54 → 0.21.56

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.
@@ -0,0 +1,2252 @@
1
+ ---
2
+ name: animated-sticker-story
3
+ video_type: Animated sticker story — a narrated paper puppet theater. One parchment stage, a cast of die-cut stickers, every element moved by one GSAP timeline. Narration carries the story, subtitles stay small (TikTok / Reels / Shorts)
4
+ checks:
5
+ duration_sec: 20-45 # target ~30s. Under 20 the story has no middle; over 45 the stage runs out of new things to do
6
+ aspect: 9:16
7
+ first_frame_visual: required
8
+ first_frame_text: required
9
+ text_by_sec: 0 # the loud first cue is up at frame 0 — see "The subtitle band"
10
+ hook_words_max: 9
11
+ audio: required # narration IS the spine of this format. A silent build is a different video
12
+ # `captions: required` looks right and is wrong here. It asserts CAPTION LAYERS —
13
+ # what `vidfarm captions generate` writes — and that command pages narration into
14
+ # 3-5-word kinetic cues at caption scale, which is precisely the thing Rule 7
15
+ # forbids in this format. The band is hand-authored timed TEXT layers instead, so
16
+ # it can be sized (38px), weighted (500), placed (78-86%) and cut to the ACTS.
17
+ # The band is still mandatory — and it IS kinetic here, just colour-only. The
18
+ # pre-flight checklist, gate 2's EMPTY BAND check and gate 2's BAND DOMINANCE
19
+ # check enforce it together, and they see what this key cannot.
20
+ captions: optional
21
+ font_regime: required
22
+ safe_zone: required
23
+ # CAREFUL: `scenes` counts CLIPS, and this format is ONE STAGE. Puppets are plain
24
+ # children INSIDE an act clip, not clips of their own — so a 5-act, 60-sticker story
25
+ # reports scenes:5, not scenes:60. If qa reports scenes:40 you built forty scenes
26
+ # instead of one theater, and every cross-act move you wrote is fighting the clip
27
+ # lifecycle. See "The stage is one clip per act, and puppets are not clips".
28
+ scenes: 1-8
29
+ max_scene_sec: 12
30
+ max_text_cards: 4 # the loud first cue + up to 3 stamps. Subtitle cues are not cards
31
+ max_simultaneous_text: 2 # one sentence + one number. Never two sentences
32
+ max_words_per_cue: 8 # subtitles are SUBTLE here. The picture is the show
33
+ max_dead_air_sec: 1.5
34
+ max_tail_sec: 1.5
35
+ forbid_text:
36
+ - link in bio
37
+ - sign up
38
+ - free trial
39
+ - get started
40
+ - book a demo
41
+ - download now
42
+ - download the app
43
+ - learn more
44
+ - swipe up
45
+ - visit our website
46
+ - comment below
47
+ - follow for more
48
+ ---
49
+
50
+ # Animated Sticker Story — the paper puppet theater
51
+
52
+ > A public Vidfarm prompt. Read it end to end before you build. Reference template:
53
+ > **`template_019f8aadb7cd7958b6c36e6cbb79f2d3`** — "VOX Style", 10.048s, `popularGroup: VOX Style`,
54
+ > sourced from `x.com`. Open it with `vidfarm serve template_019f8aadb7cd7958b6c36e6cbb79f2d3`.
55
+
56
+ You are building **a puppet theater out of paper**. One stage — an aged parchment surface that never
57
+ changes. A cast of die-cut stickers that walk onto it, do the story, and leave. A narrator over the
58
+ top. Subtitles small at the bottom, because the picture is doing the work.
59
+
60
+ The reference is a 10-second Vox-style time-lapse: a food stall appears on an old map, cutout people
61
+ walk in along dashed routes from every direction, a queue forms, the stall gets busy. No cuts. No
62
+ footage. No text. Everything that happens, happens *as motion on one surface* — and that single
63
+ constraint is what makes the format read as craft instead of as a slideshow with effects on it.
64
+
65
+ | Ingredient | What it is | Where it comes from |
66
+ |---|---|---|
67
+ | 🗺 **The stage** | One full-frame parchment surface. It is the floor of every act | One generated plate, or a free paper/map texture |
68
+ | ✂️ **The puppets** | Die-cut stickers with real alpha — figures, props, dishes, storefronts | `sticker-pack --generate` (hybrid) · `iconscout --free` (minimize) |
69
+ | 🎞 **The rig** | One paused GSAP timeline that moves every puppet on one clock | You. This is the actual craft |
70
+ | 🎙 **The narration** | One voice telling a story that has a turn in it | `vidfarm tts` |
71
+ | 🔡 **The band** | Small subtitles at the bottom. Subordinate, always | `vidfarm captions generate --srt` |
72
+
73
+ **The reference source was AI-generated video, and you are not going to do that.** The original
74
+ prompt (visible on the template) is a Gemini video prompt: ten seconds of generated animation, priced
75
+ in dollars, unrepeatable, unfixable, and un-rebrandable. This harness builds the same *look* out of
76
+ cheap transparent stickers and free HTML motion — which costs cents, renders in seconds, and can be
77
+ re-cut for the next client by swapping four PNGs and six lines of narration. **Buy the assets with
78
+ AI; get the motion for $0.**
79
+
80
+ ## The one test — the three-way lock
81
+
82
+ Three questions. **All three must be yes** or you have not got a sticker story yet.
83
+
84
+ | # | Question | If no |
85
+ |---|---|---|
86
+ | 1 | **Silent-legible** — mute it, hide the band. Does a stranger still follow the story? | The narration is carrying the plot and the stage is decoration. Rebuild the stage |
87
+ | 2 | **One surface** — is there a single cut to anything that is not this stage? | You made a slideshow. There are no cuts in a puppet theater |
88
+ | 3 | **Every element is die-cut** — does anything on screen have a straight rectangular edge? | A rectangle is a photograph pasted on paper. It kills the illusion instantly |
89
+
90
+ Test 1 is the one that gets skipped, and it is the whole format. **The narration is the caption of
91
+ the picture; the picture is never the illustration of the narration.** Write the stage first, and
92
+ then say out loud only the parts of it a viewer cannot see.
93
+
94
+ Test 3 is mechanical and you can measure it: every PNG on the stage must be a snug transparent cut
95
+ (see *The alpha has to actually be clean*). The stage plate is the only full-bleed rectangle in the
96
+ composition, and it is the floor, not an element.
97
+
98
+ ---
99
+
100
+ ## Part 0 — who this is for
101
+
102
+ **Someone scrolling who likes watching a thing get built.** Not a buyer, not a reader — a watcher.
103
+ The format's whole appeal is that it is *satisfying*: pieces arrive, a system assembles, and it
104
+ resolves. That is why it travels, and it is also why an ad wearing this costume works — the viewer
105
+ stays for the assembly and takes the mechanism home with them.
106
+
107
+ Before you write anything, put four lines at the top of your build log:
108
+
109
+ ```
110
+ STORY: One craving, one city, forty wasted minutes, and the turn
111
+ WATCHER: Someone who eats out and has had this exact evening
112
+ OFFER: dishcover.io — search restaurants by DISH instead of by restaurant
113
+ THE TURN: the map is indexed by restaurant; nobody indexes it by dinner
114
+ ```
115
+
116
+ - **Register: a documentary narrator, not a marketer.** Flat, certain, past or present tense, no
117
+ hedging, no second-person hype. The Vox register is "here is what happened, and here is why" — and
118
+ it is what buys you the right to state a mechanism at the end.
119
+ - **They watch muted first, then unmute if the stage earns it.** Which is why test 1 exists, and why
120
+ the subtitle band is required even though it is deliberately small.
121
+ - **They will watch it twice.** Sticker stories get re-watched more than any other format in this
122
+ catalog, because there is always a puppet in the corner they missed. Put something there.
123
+
124
+ ---
125
+
126
+ ## Structural DNA — the story
127
+
128
+ **Five acts, one stage, no cuts.** The reference is one act at 10s; a narrated story needs the full
129
+ arc, and the arc is what stops a beautiful stage from being a screensaver.
130
+
131
+ | Act | Seconds (of ~29) | Job | The stage |
132
+ |---|---|---|---|
133
+ | **1 — The want** | 0.0–3.4 | Establish one person and one specific desire | Empty stage. ONE figure enters. ONE hero object appears above them |
134
+ | **2 — The grind** | 3.4–10.4 | The time-lapse. Effort accumulating and failing | Routes draw. Props multiply. The X stamps land. This is the act people watch |
135
+ | **3 — The cost** | 10.4–14.4 | The honest, unflattering result | The stage goes quiet. One figure, seated, one grey object |
136
+ | **4 — The turn** | 14.4–21.0 | The mechanism, shown. No brand name yet | The stage RE-DRAWS. The system assembles in two fast gusts |
137
+ | **5 — The resolve + THE PROMO** | 21.0–29.4 | The want, paid — then the offer, by name, last | One route, the hero object in colour, then the wordmark stamps onto the paper |
138
+
139
+ **Act 2 is the video.** It is the one the algorithm is measuring and the one people describe to their
140
+ friends. Give it a quarter of the runtime and the largest number of moving pieces, and make it
141
+ *visibly a time-lapse* — the same figure repeating the same failure, on a fixed cadence.
142
+
143
+ **Act 4 shows the mechanism; act 5 names the offer.** The stage rearranges into the thing the offer
144
+ does *before* the offer is mentioned, so the viewer understands the idea while they still think they
145
+ are watching a story. Then the resolve pays the want and the name lands on the last beat. If your
146
+ act 4 is a logo, a screenshot or a UI mock, you have broken the format — see Rule 2.
147
+
148
+ ### Where the promo goes — default is the END
149
+
150
+ **One promo beat, and by default it is the close.** The video earns attention for 25 seconds and
151
+ spends it in the last four; that ordering is what makes the mention feel like a payoff rather than a
152
+ toll. The alternatives are real but each costs you something, so pick deliberately and write the
153
+ choice in the build log:
154
+
155
+ | Placement | When it is right | What it costs |
156
+ |---|---|---|
157
+ | **End** *(default)* | Almost always. Cold audiences, awareness plays, anything measured on saves and branded search | Some viewers leave before it. That is the trade, and it is the right one |
158
+ | **Middle** (end of act 4) | A warm audience that already knows the category, or a retargeting cut | The re-draw stops being a story beat and starts being a demo |
159
+ | **Start** | Only when the offer's NAME is the hook — a known brand, a launch, a sequel | You have spent frame 0, which is the most valuable second you own |
160
+
161
+ **Wherever it goes, it is named ONCE.** An end promo plus a mid-roll mention is two ads in a
162
+ 30-second video, and the second one is the one people comment about.
163
+
164
+ ### The stage is one clip per act, and puppets are not clips
165
+
166
+ This is the structural decision the whole build hangs on, and getting it backwards costs you a day.
167
+
168
+ ```html
169
+ <div id="root" data-composition-id="story" data-duration="30" data-width="1080" data-height="1920">
170
+
171
+ <!-- ONE clip per ACT. Five of these, butt-cut, covering 0 → 30. -->
172
+ <div id="act1" class="clip" data-hf-id="act1" data-layer-kind="image" data-track-index="0"
173
+ data-start="0" data-duration="5" data-label="act 1 — the want">
174
+
175
+ <!-- PUPPETS ARE PLAIN CHILDREN. No .clip, no data-start, no data-duration. -->
176
+ <div class="rig" id="rig-her">…</div>
177
+ <div class="rig" id="rig-bowl">…</div>
178
+ </div>
179
+
180
+ </div>
181
+ ```
182
+
183
+ - **A puppet is not a scene.** Give a sticker `class="clip"` and `data-start` and the HyperFrames
184
+ clip lifecycle now owns its visibility — which fights every opacity and transform you write, and
185
+ produces the exact failure this harness's first spike shipped: puppets that exist in the DOM, are
186
+ `visibility:visible` and `opacity:1`, and are nowhere on screen.
187
+ - **`vidfarm qa` counts clips as scenes.** One clip per act → `scenes: 5`, which is what the
188
+ `checks:` block above expects. If qa reports `scenes: 40` you built forty scenes.
189
+ - **The act clip carries the stage plate as its own background**, so the parchment is continuous
190
+ across the butt cuts and no transition is needed or wanted.
191
+ - **A puppet that must survive an act boundary belongs to the act it starts in, duplicated into the
192
+ next**, at exactly the position and rotation it ended on. There is no cross-clip element. Write the
193
+ hand-off position down; do not eyeball it.
194
+
195
+ ### Length
196
+
197
+ `20–45s`, target **30**. Length is an output of the acts, not an input.
198
+
199
+ - **Act 2 gets ~30% of the runtime.** Every other act is as short as it can be and still land.
200
+ - **Under 20s the turn arrives before the grind has cost anything**, and act 4 reads as a bait and
201
+ switch rather than as relief.
202
+ - **Over 45s and the stage runs out of new things to do.** The format's failure at length is not
203
+ boredom, it is *repetition* — the same three moves cycling. If you need 60 seconds, you need a
204
+ second stage, which means it is a different video.
205
+
206
+ ---
207
+
208
+ ## The narration — the only prose you write
209
+
210
+ **One paragraph, 55–90 words, read at ~2.9 words/second.** That is the whole script. Write it before
211
+ you source a single sticker, because the nouns in it are your shopping list.
212
+
213
+ ### The grammar
214
+
215
+ ```
216
+ It's seven, and you know exactly what you want.
217
+ So you check one place. Then another. Then four more.
218
+ None of them are searchable.
219
+ Forty minutes later you're eating something you didn't choose.
220
+ The whole city is indexed by restaurant.
221
+ Nobody indexes it by dinner.
222
+ So search the dish, not the restaurant. dishcover.io. 7,502 dishes, one search.
223
+ ```
224
+
225
+ - **Short declaratives.** ≤12 words a sentence, and one idea in each. The line has to fit under a
226
+ puppet move without either one waiting for the other.
227
+ - **Every sentence names a noun that is on the stage.** If the narration says "four more", four
228
+ storefronts had better be visible. A noun in the voice with nothing on the paper is the fastest
229
+ way to break test 1.
230
+ - **The turn sentence is the shortest one in the script.** "Nobody indexes it by dinner." It lands
231
+ in a hole in the music and it is the line people repeat.
232
+ - **No questions, no "imagine", no "what if", no second-person hype.** "You" is fine as a
233
+ documentary subject ("you're eating something you didn't choose"); "you'll love" is not.
234
+ - **Name the offer once, lowercase, in the closing lines.** The promo is a *sentence in the same
235
+ voice*, not a change of register: `So search the dish, not the restaurant. dishcover.io. 7,502
236
+ dishes, one search.` — never "check out", never "click the link", never an imperative aimed at the
237
+ viewer's thumb. The mechanism, the name, the number, stop.
238
+
239
+ ### Pace — say it fast
240
+
241
+ **Generate at whatever tempo the voice gives you, then speed it up and re-measure.** A documentary
242
+ register at natural TTS speed is *slow*, and slow is the failure this format ships when everything
243
+ else is right: the stage is beautiful, the story is sound, and nobody finishes it.
244
+
245
+ ```bash
246
+ ffmpeg -i vo/a2.mp3 -af "silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.05,\
247
+ areverse,silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.05,areverse,\
248
+ atempo=1.30,loudnorm=I=-16:TP=-1.5" vo/a2-t.mp3 -y
249
+ ```
250
+
251
+ - **`atempo=1.30` is the number**, measured. This harness's first dishcover build ran 1.12, which
252
+ read as a documentary *and* read as slow — 37.4s. The same script at 1.30 is 29.4s and does not
253
+ read as an ad-read. Past ~1.45 the consonants smear.
254
+ - **Trim the silence before you speed it up.** TTS pads both ends, and that padding is dead air the
255
+ pixel gate will call a frozen window.
256
+ - **Cut words as well as time.** On the same build, deleting one clause from act 2 and three words
257
+ from act 5 bought 2.5s that no tempo change could have.
258
+ - **Re-measure every clip after processing and paste the real numbers into the act table.** Timings
259
+ guessed from the pre-trim durations put the narration out of sync with the stage.
260
+
261
+ ### The narration is CUT to the stage, not the other way round
262
+
263
+ Generate the voice in **one clip per act**, not one clip for the whole script. Then each act's audio
264
+ can slide independently while you tune the puppets, and a re-record of act 3 does not invalidate the
265
+ timings of acts 1, 2, 4 and 5.
266
+
267
+ ```bash
268
+ # one file per act, ONE pinned voice and ONE style string across all five
269
+ npx -y dotenv-cli -e .env -- vidfarm tts "It's seven in the evening and you know exactly what you want." \
270
+ --voice Leda --style "flat documentary narrator, unhurried, no smile in the voice" \
271
+ --out vo/a1.mp3
272
+ ```
273
+
274
+ - **One voice, one style string, all five acts.** A style string that drifts act to act is audible
275
+ and reads as five different narrators.
276
+ - **Leave a real hole where the stage does the talking.** The strongest beat in a sticker story is
277
+ ~1.2s of no narration while a set piece assembles. Write it into the script as a blank line, not
278
+ as an afterthought in the edit.
279
+ - **`--engine local` is $0** once `pip install kokoro-onnx soundfile` has run (one ~340MB model
280
+ download). Until then it fails with an error that names the fix, and the run silently falls back
281
+ to a provider key. A BYOK gemini key costs a fraction of a cent per act. Both are inside budget;
282
+ say in the build log which one you used.
283
+
284
+ ---
285
+
286
+ ## The subtitle band
287
+
288
+ **The band may be beautiful and it may be kinetic. It may never be the loudest thing on screen.**
289
+ Those are compatible, and the way they are compatible is specific: the band is drawn in the stage's
290
+ own palette and its motion is **colour only**. Colour reads as design; geometry reads as motion, and
291
+ motion is the one currency the stage cannot afford to share.
292
+
293
+ ### It is painted from the stage's palette, not styled separately
294
+
295
+ The band is not a second colour system bolted under the drawing. Every value in it already exists on
296
+ the paper, which is what makes it look designed rather than captioned.
297
+
298
+ | Property | Value | Why |
299
+ |---|---|---|
300
+ | Size | **34–40px** on 1080×1920 | Half the caption size of the sibling harnesses. Legible at arm's length, not dominant |
301
+ | Weight | **500**, never 800 | 800 competes with the stage |
302
+ | Ink | **one step lighter and warmer than the drawing's** — `#3A2E22` against a `#2A2118` stage | It sits *behind* the stage in the hierarchy without being harder to read. Identical ink makes the type feel like a drawn element competing for the same layer |
303
+ | Highlight | **the stamp colour**, `#8C3B2E` — the exact red the X marks and stamps are drawn in | The palette already contains it, so the highlight reads as the documentary's own annotation. A brand blue or a caption yellow is an import from another video |
304
+ | Plate | **never** — a measured paper WASH instead, when the measurement asks for one | On parchment a plate is a card (Rule 4). A wash is the paper being lighter, not an object on it |
305
+ | Position | A fixed, flex-centred box in the lower half, **measured** by `place-text.py`, outside `.stagewrap` | Under the action, on the calmest ground available, and never dragged by a camera move |
306
+ | Words per cue | **≤8**, one line | Two lines is a paragraph at this size |
307
+ | Letter-spacing | `0.004em` | A hair open. Small warm type on a textured ground needs it; the headline weights do not |
308
+
309
+ **Six colours in the video, and the band spends none of its own.** If you find yourself picking a
310
+ caption colour, you have left the format — go and sample one off the stage.
311
+
312
+ ### Kinetic captions: the ember
313
+
314
+ Kinetic is allowed, and the permitted treatment is one **ember** — a single warm highlight that
315
+ travels the line word by word on the voice, and hands each word back to the band's ink behind it.
316
+
317
+ ```js
318
+ /* colour in, colour out. That is the entire kinetic vocabulary. */
319
+ EMBERS.forEach(([id, wordStart, wordEnd]) => {
320
+ tl.to("#" + id, { color: EMBER, duration: 0.10, ease: "none" }, wordStart);
321
+ tl.to("#" + id, { color: BAND_INK, duration: 0.18, ease: "none" }, wordEnd);
322
+ });
323
+ ```
324
+
325
+ **The rules that keep it subordinate.** Each one is the reason a caption steals focus, and together
326
+ they are what "kinetic but not distracting" actually means:
327
+
328
+ 1. **Colour and opacity only. Never geometry.** No translate, scale, rotate, blur or shadow on any
329
+ word, ever. A moving word is the fastest thing in the frame and the eye goes to it before it goes
330
+ to a walking puppet — which inverts the format in one cue.
331
+ 2. **Never animate font-weight.** It looks like colour's harmless cousin and it is geometry in
332
+ disguise: with a variable font, tweening the weight axis re-flows the line, so every word after
333
+ the active one jitters sideways. Verified on this harness's own build — it is why the ember is
334
+ colour-only rather than colour-and-800.
335
+ 3. **The line arrives whole.** Cross-fade the cue in and out at ≤0.18s. No word-by-word entrance, no
336
+ slide-up, no typewriter. The reader should be able to read ahead of the voice.
337
+ 4. **One highlight at a time, one step of change.** Active word = one colour step. Red *and* bold
338
+ *and* underlined *and* scaled is four steps and it is a karaoke video.
339
+ 5. **The ember never lands on the offer's name.** The brand has its own beat (the lockup); a
340
+ highlight on it turns a sentence into an ad-read.
341
+ 6. **The loud first cue and the lockup never animate their words.** The hook is up whole at frame 0;
342
+ the wordmark is stamped and static.
343
+
344
+ **Gate it, don't trust it.** Appendix C's *band dominance* check measures the band strip's share of
345
+ the video's total frame-to-frame motion. Colour-only kinetics land around 5–15%; this harness's
346
+ dishcover build measured **9%** against a 35% ceiling. Anything geometric blows past the ceiling
347
+ immediately, which is exactly what the check is for.
348
+
349
+ ### Timings come from whisper; WORDS come from the script
350
+
351
+ The ember has to key off real word timings or it drifts off the voice by the third line, and
352
+ `vidfarm stt --engine whisper` gives you word-level timestamps locally, free, in about two seconds
353
+ per clip.
354
+
355
+ ```bash
356
+ vidfarm stt vo/a2-t.mp3 --engine whisper --json # -> words[] with start/end per word
357
+ ```
358
+
359
+ ⚠️ **Use whisper's TIMINGS and your script's TEXT.** Never render the transcript. On this build the
360
+ transcriber heard *"Forty minutes"* as **"40"** — colliding with the `40 MINUTES` stamp that should
361
+ own the numeral — and heard *"7,502"* as **"7,000"**, which would have put a wrong number on screen
362
+ in a format whose Rule 8 is that every number is real. Zip the script's tokens onto whisper's
363
+ timings and **fail the build if the counts differ**; the fix is to re-word the script, not to trust
364
+ the machine.
365
+
366
+ **Chunk on clauses, not on a word count.** Break at a sentence end; if a sentence runs past 8 words,
367
+ back off to its last comma. A bare word cap produces cues like `something you didn't` / `choose.`,
368
+ which reads as a stutter.
369
+
370
+ **Delete the band cue that carries the brand** when the lockup is going to say it. Otherwise the name
371
+ appears in the band and on the stage 0.2s apart — two mentions in one breath, which reads as an ad
372
+ even though the script says it once.
373
+
374
+ ### The loud first cue
375
+
376
+ **There is no separate hook card.** The first subtitle cue — and only the first — renders at
377
+ **66px / weight 800** in the upper half, at a position `place-text.py` measures, and it holds for its
378
+ natural duration; the band then drops to subtitle scale in the lower half for the rest of the video
379
+ and never comes back.
380
+
381
+ This buys you three things at once: `first_frame_text` is satisfied at frame 0, `max_simultaneous_text`
382
+ stays at one sentence, and you do not spend two seconds on a title card before the story starts. It
383
+ is also the honest version of a hook: the first thing the narrator says is the first thing you read.
384
+
385
+ **The loud cue must be the whole claim.** "It's seven in the evening and you know exactly what you
386
+ want." is a situation and it works. "Ever had this problem?" is a question and it dies.
387
+
388
+ ### The promo lockup
389
+
390
+ The offer's name, on the last beat. **One text layer, in the stage's own ink at the stage's own
391
+ scale — a wordmark drawn on the paper, not a badge stuck over it.**
392
+
393
+ ```css
394
+ .lockup { font-family:'Montserrat'; font-weight:800; font-size:82px; color:#2A2118;
395
+ text-align:center; letter-spacing:-0.02em; } /* box top: MEASURED */
396
+ ```
397
+
398
+ ```js
399
+ /* it STAMPS. A promo that fades in is a title card, and a title card is a card. */
400
+ tl.fromTo("#lockup", { scale: 1.9, rotation: -3, opacity: 0 },
401
+ { scale: 1, rotation: 0, opacity: 1, duration: 0.22, ease: "power4.out" }, 24.2);
402
+ ```
403
+
404
+ - **The name and nothing else.** No logo, no `https://`, no button, no plate, no rule, no tagline
405
+ under it, no "available now". If the brand's own mark is a wordmark, you are already rendering it.
406
+ - **Same ink as the drawing** (`#2A2118`), not the stamp red and not a brand colour. A coloured
407
+ wordmark on parchment is a sticker from a different video.
408
+ - **It searches the upper half**, which the loud cue vacated 20 seconds earlier — so the closing
409
+ subtitle runs in the lower half at the same time and the two never share ground. Wordmark up,
410
+ stage in the middle, closing line down: that is the one moment two text layers are allowed, and it
411
+ is why `max_simultaneous_text` is 2.
412
+ - **It holds to the last frame.** Five seconds up is normal here, and it is the one thing in the
413
+ video that does not need a BEAT — a wordmark that breathes looks broken.
414
+ - **Nothing follows it.** No end card, no "follow", no second beat. The video stops.
415
+
416
+ ### Stamps are not subtitles
417
+
418
+ A **stamp** is a display-text layer that is a number or a label — `40 MINUTES`, `4 PLACES`,
419
+ `7,502 DISHES`. Up to three in the whole video.
420
+
421
+ - **A stamp is ≤2 words or one number.** If it is a sentence, it is a subtitle and it belongs in the
422
+ band.
423
+ - **A stamp lands like a rubber stamp** — see the STAMP move — and it lives **above the 60% line**,
424
+ so it can coexist with a subtitle without either being a second read.
425
+ - **Every number on a stamp is real.** `7,502 dishes` and `140 restaurants` are dishcover.io's own
426
+ published numbers. A number invented to sound specific is the one thing a viewer can catch you on.
427
+
428
+ ### Font regime
429
+
430
+ Two weights of ONE self-hosted family, no fallback chain. `vidfarm qa`'s `font-regime` rule errors on
431
+ Inter / Roboto / Arial / system-ui and warns outside the regime (Montserrat, TikTok Sans, Abel,
432
+ Source Code Pro, Yesteryear). **Montserrat 500/800** is the match for the paper look; a slab or a
433
+ typewriter face reads as a different documentary and fights the drawn stage.
434
+
435
+ **Montserrat ships as ONE variable woff2 that serves both weights** — Google's CSS returns the same
436
+ file URL for `wght@500` and `wght@800`, so fetching "the 800" separately gets you an HTML error page
437
+ that `@font-face` will silently ignore. Declare the range:
438
+
439
+ ```bash
440
+ curl -sL "https://fonts.gstatic.com/s/montserrat/v31/JTUSjIg1_i6t8kCHKm459Wlhyw.woff2" \
441
+ -o media/fonts/montserrat-var.woff2 # 38KB, latin subset
442
+ ```
443
+
444
+ ```css
445
+ @font-face{font-family:'Montserrat';font-weight:100 900;font-display:block;
446
+ src:url('media/fonts/montserrat-var.woff2') format('woff2')}
447
+ ```
448
+
449
+ `file media/fonts/*.woff2` should say *Web Open Font Format*. If it says *HTML document*, the fetch
450
+ 404'd and your video is about to render in a system font with nothing telling you.
451
+
452
+ ---
453
+
454
+ ## Casting the puppets
455
+
456
+ ### The art class — one, declared before you source anything
457
+
458
+ **The loudest possible failure in this format is mixed art classes**, and it is worse here than in a
459
+ slideshow, because the pieces are on screen *simultaneously* and moving. A flat vector person walking
460
+ past a photographic bowl is not a style choice, it is a bug that renders.
461
+
462
+ | Class | What it is | Use it for |
463
+ |---|---|---|
464
+ | **Paper cutout** *(the reference, and the default)* | Flat, muted, hand-cut edges with a white die-cut border and a soft drop shadow | Everything. Do not mix classes; mix *subjects* |
465
+ | **Hand-drawn mark** | Dashed route, arrow, circle, red X, check | The annotation layer only — see below |
466
+
467
+ **Hand-drawn marks are punctuation, not cast.** A dashed route between two points, an X over the
468
+ failed storefront, a circle around the one that works. They may appear alongside the cutouts and they
469
+ should — the reference's dashed routes are what turn "people standing near a stall" into "people
470
+ travelling to a stall". Marks are drawn in SVG, not sourced as PNGs, so they can be *drawn on* (see
471
+ the DRAW move).
472
+
473
+ ### The set pieces — go big, deliberately
474
+
475
+ The user brief for this format asked for large scene and landscape stickers, and it is right: a
476
+ puppet theater needs scenery, not just actors. `vidfarm sticker-pack` supports this directly —
477
+ **`--min-area` has a floor but no ceiling, so a whole landscape can be one sticker.**
478
+
479
+ A **set piece** is any cutout occupying more than ~25% of the frame: a street block, a storefront
480
+ row, a restaurant interior, a skyline strip, a map inset.
481
+
482
+ - **A set piece enters ONCE per act and then holds.** It does not bob, it does not float, it does not
483
+ breathe. Scenery that idles reads as an unstable render.
484
+ - **Only the camera may move a set piece** — the act-level scale/translate on the stage layer.
485
+ - **It must be die-cut against the parchment.** Its own silhouette has to be visible against the
486
+ paper. A set piece that reaches all four frame edges is a background photo, and test 3 fails.
487
+ - **Two set pieces on screen at once is the ceiling**, and they must not overlap silhouettes.
488
+ - **Set pieces are the cheapest depth you get.** One street-block cutout at 40% opacity behind the
489
+ action, one at full opacity in front, and the stage has three planes for one extra sticker.
490
+
491
+ ### Sourcing, cheapest first
492
+
493
+ The ladder, and stop at the first rung that answers the shot:
494
+
495
+ ```bash
496
+ # 1. FREE designer stickers — no key, no account, search is free
497
+ vidfarm iconscout "walking person side view" --style sticker --free
498
+ vidfarm iconscout get <uuid> --format png --size 1024 --out media/stickers/walker.png
499
+
500
+ # 2. FREE stock photo -> die-cut it locally for $0 (ONNX matting)
501
+ vidfarm media image "storefront awning"
502
+ vidfarm remove-background ./awning.jpg --out media/stickers/awning.png
503
+
504
+ # 3. Art the director already owns — lift elements out of their own site
505
+ vidfarm capture https://dishcover.io --out ./capture
506
+ vidfarm mask ./capture/screenshot.png --crop 120,340,600,600 --out media/stickers/dish.png
507
+
508
+ # 4. ONE generated SHEET per cast group — one billed image job, one art style
509
+ vidfarm sticker-pack --generate "<the ART STYLE STRING, identical every time>" \
510
+ --items "walking figure in olive coat,walking figure in terracotta coat,…" \
511
+ --out-dir media/stickers --prefix cast
512
+ ```
513
+
514
+ **Rung 4 is the hybrid default, and the unit is the SHEET, not the sticker.** One sheet holding eight
515
+ figures costs one image job and — far more important — guarantees one art class across all eight.
516
+ Eight separate `cutout --generate` calls cost eight times as much *and* produce eight slightly
517
+ different styles.
518
+
519
+ ### The art style string is a constant. Write it down once.
520
+
521
+ Every `--generate` call in the project passes the **identical** style string. Changing one word
522
+ between sheets is how a deck acquires two art classes.
523
+
524
+ ```
525
+ flat hand-cut paper-collage cutouts, Vox historical-documentary illustration style,
526
+ muted earthy palette (warm brown, terracotta, olive, dusty teal, cream), soft drop
527
+ shadow under each piece, simple stylized shapes, closed solidly filled outlines,
528
+ no text, no lettering
529
+ ```
530
+
531
+ Two clauses in there are load-bearing and are not decoration:
532
+
533
+ - **"closed solidly filled outlines"** — an open outline lets the item's fill reach the panel edge,
534
+ the keyer reads that fill as plate, and the sticker comes back as a hollow ring. `sticker-pack`
535
+ warns about this by name (`N% see-through`).
536
+ - **"no text, no lettering"** — a generated sign with fake words on it is the single most obvious
537
+ AI tell in an otherwise clean frame, and it is unfixable after the fact.
538
+
539
+ ### ⚠️ Generate the stickers WITHOUT shadows
540
+
541
+ **Ask the model for "NO drop shadow, NO white sticker border, NO glow" and add the shadow in CSS.**
542
+
543
+ ```css
544
+ .art { filter: drop-shadow(6px 10px 10px rgba(74,60,42,0.22)); }
545
+ ```
546
+
547
+ This is not a style preference, it is the fix for the format's most annoying defect. A baked drop
548
+ shadow is a *soft* edge, so on the sheet it blends with the chroma plate and comes back as a
549
+ coloured halo that is **baked into the pixels, not into the alpha** — which means no tolerance
550
+ setting, no key mode and no matting model removes it. On this harness's dishcover run the pink rim
551
+ survived `--tolerance` 0.12, 0.15 and 0.18, `cutout --key-mode smart`, and a 3px alpha erode.
552
+
553
+ The CSS shadow also buys you Rule 5 for free: one shadow declaration is one light direction across
554
+ the entire cast, forever, and it scales correctly when the camera moves.
555
+
556
+ ### What `sticker-pack --generate` actually hands back
557
+
558
+ Measured on this harness's own dishcover run, so you do not rediscover it:
559
+
560
+ - **The first output is usually the whole sheet.** `cast-01-*.png` came back at 788×922 — the entire
561
+ keyed 3×3 grid as one "sticker", panel lines included. **Delete it.** Check every output's pixel
562
+ dimensions: a cast figure is ~110×240; anything ten times that is the sheet.
563
+ - **The names are guessed, not assigned.** `--items` names the outputs in *reading order*, and the
564
+ model does not always fill panels in the order you asked. On the dishcover run `cast-01` and
565
+ `cast-05` came back sharing one name. **Open the pack and rename against the picture before you
566
+ place anything.**
567
+ - **Some panels come back empty.** Two of nine on the first run, with the warning naming the cause
568
+ (the item touched its panel edge). Ask for 8 items when you need 6.
569
+ - **Budget one re-roll per sheet** into the cost table.
570
+
571
+ ### ⚠️ When the sheet is perfect and the keyer still refuses to split it
572
+
573
+ The failure that cost this harness the most time, and it looks like a generation failure when it is
574
+ not. `sticker-pack --generate` asks for a **zoned** sheet — a colour-block grid with a *different*
575
+ plate colour under each item — and then keys it. When the model delivers exactly that, the keyer can
576
+ still report:
577
+
578
+ ```
579
+ Zones: Panel 1 keyed 10.8% of itself — there's no plate there, i.e. the sheet isn't
580
+ really a color-block grid, so it was re-keyed as ONE #FFFFFF plate.
581
+ ✓ prop-01-small-storefront-with-striped-awning.png (988×988, 1.4 MB)
582
+ Note: you named 8 item(s) but 1 were cut
583
+ ```
584
+
585
+ One 988×988 "sticker" out of eight items — **that is the whole sheet again.** Keying one plate
586
+ colour cannot remove the other, so the magenta panels vanish and the blue ones survive as opaque
587
+ rectangles, and the connected-component pass finds one blob. `--zones 3x3` did not fix it and
588
+ `--sheet-mode flat` produced a sheet that keyed 100% of itself and yielded nothing.
589
+
590
+ **Do not re-roll. Open the sheet and look at it.** If the grid is there, the art is fine and only the
591
+ split failed — so do the split yourself. You know the layout, so it is arithmetic:
592
+
593
+ ```bash
594
+ # crop the R×C grid into cells, then key each cell against ITS OWN plate colour
595
+ ./split-sheet.sh sheet.png 3 3 media/props "storefront,menu-scroll,x-stamp,…"
596
+ ```
597
+
598
+ `split-sheet.sh` is in Appendix F. It insets 4% per cell so a neighbouring panel's colour never
599
+ enters the crop, samples that cell's own corner for the plate hex, and calls `vidfarm mask --flat`.
600
+
601
+ **Pick the per-cell cutter by SUBJECT, and the two do not substitute for each other:**
602
+
603
+ | Cell contains | Use | What the other one does |
604
+ |---|---|---|
605
+ | **Flat graphic art** — an X mark, a clock, a storefront, a check | `vidfarm mask --flat "#<hex>" --tolerance 0.10` | ONNX matting reads a flat saturated plate as part of the subject and keeps it |
606
+ | **Organic / painterly** — a bowl of food, a person, a plant | `vidfarm remove-background` (local ONNX) | a chroma key leaves the shadow halo, and `cutout --key-mode smart` ate the whole bowl |
607
+
608
+ **Tolerance 0.10 is the number**, measured on the dishcover run: `0.05` leaves a visible blue rim,
609
+ `0.18` ate a teal ribbon whose colour sat near the blue plate's hue. Which is also the plate-choice
610
+ rule restated — never put teal art on a blue plate.
611
+
612
+ ### The retro-fix when a halo already shipped into your assets
613
+
614
+ If the art is already generated with baked shadows, `deshalo.sh` (Appendix F) is the recovery: it
615
+ hard-thresholds the alpha at 215 and erodes 1px, throwing away every partial-alpha pixel. That
616
+ removed the halo completely on flat art. **Do not run it on soft, furry or glassy subjects** — the
617
+ partial alpha there is the subject, not a defect.
618
+
619
+ ### The alpha has to actually be clean
620
+
621
+ On a near-white parchment stage a bad key shows every defect footage would hide. Check it, don't
622
+ trust it:
623
+
624
+ ```python
625
+ # any sticker: how much of it is actually opaque?
626
+ a = alpha_channel(png)
627
+ print((a == 0).mean(), ((a > 0) & (a < 255)).mean(), (a == 255).mean())
628
+ # healthy: mostly 0 or 255, a percent or two in between.
629
+ # tens of percent "partial" = a bad key, and on paper it WILL show as a grey box.
630
+ ```
631
+
632
+ Match the key mode to the shape — the same trap the greenscreen harness documents:
633
+
634
+ | Art | Key mode | Why |
635
+ |---|---|---|
636
+ | **Hollow** — a dashed ring, a frame, an arrow outline | `--key-mode flat` | the interior never touches the frame edge, so a connectivity keyer *keeps* it and you get a filled blob |
637
+ | **Solid** — a figure, a bowl, a storefront | `--key-mode smart` *(default)* | flat mode's soft alpha ramp eats a painted edge |
638
+
639
+ `--refine` re-cuts each item with local ONNX matting (free, ~1–2s each) and is the answer for
640
+ painterly or soft-edged art whose chroma cut looks chewed.
641
+
642
+ ### ⚠️ Attribution
643
+
644
+ IconScout free assets and most free stock require a credit line, **and a credit line on the frame is
645
+ brand chrome, which is banned.** Put every credit in the **post caption**. Never on the stage, never
646
+ in a corner at 12px, never on a credits beat at the end. If a licence requires the credit to be *on
647
+ the image*, do not use that asset — drop to matted stock or a generated sheet.
648
+
649
+ ---
650
+
651
+ ## Visual DNA
652
+
653
+ ### The stage
654
+
655
+ **One parchment plate, full frame, and it never changes.** Sampled off the reference: a warm
656
+ cream-beige, roughly `#E7DCC2`, with visible paper grain, faint grid lines and soft creases.
657
+
658
+ - **The plate is the act clip's background**, repeated identically in every act, so the butt cuts
659
+ between acts are invisible.
660
+ - **Ink is `#2A2118`**, not black. Palette is earthy and narrow: warm brown, terracotta, olive,
661
+ dusty teal, mustard, cream. **Six colours, total, across the whole video.**
662
+ - **No gradients, no vignette, no blur, no glow, no glass, no cards.** The one shadow in the video is
663
+ the soft contact shadow under each cutout, and it comes baked into the sticker.
664
+ - **One light direction.** If the figures' shadows fall to the lower right, so do the storefronts'.
665
+ Bake it into the art style string and it comes out consistent for free.
666
+ - **No footage. Ever.** A video layer on this stage is a hole cut in the paper.
667
+
668
+ ### Placing the type — measure the stage, then fit the words to it
669
+
670
+ **The stage art is full-bleed and stays that way.** Cropping a blank margin into it so the captions
671
+ have somewhere to sit throws away the thing the format is for — the drawing filling the frame is the
672
+ format. So the type gets fitted to the art, and that is a measurement, not a taste call.
673
+
674
+ ```
675
+ 0% ─── platform chrome — nothing lives here ───
676
+ 8% ┌──────────────────────────────────────────┐
677
+ │ UPPER HALF — the loud cue, then the │
678
+ │ promo lockup. Position MEASURED │
679
+ 46% └──────────────────────────────────────────┘
680
+ │ the drawing runs edge to edge, behind │
681
+ │ everything, all the way down │
682
+ 66% ┌──────────────────────────────────────────┐
683
+ │ LOWER HALF — the subtitle band. │
684
+ │ Position MEASURED │
685
+ 88% └──────────────────────────────────────────┘
686
+ platform chrome — nothing lives here
687
+ 100% ──────────────────────────────────────────
688
+ ```
689
+
690
+ **The halves are fixed; the exact position inside each half is measured.** Fixing the halves keeps
691
+ the layout stable — a band that hops from 70% to 84% between cues is worse than a band that sits
692
+ slightly busy. Measuring inside them is what stops the words landing on the one thing that would have
693
+ made them unreadable.
694
+
695
+ #### The two-pass build
696
+
697
+ You cannot choose placement off the plate alone: the puppets move, and a strip that is calm on the
698
+ empty map can have a walker standing in it. So build twice.
699
+
700
+ ```bash
701
+ python3 build.py v001 --no-text # 1. the stage, no captions
702
+ python3 place-text.py v001 # 2. snapshot every cue midpoint, scan, write place.json
703
+ python3 build.py v001 # 3. rebuild, reading the measured placement back in
704
+ ```
705
+
706
+ `place-text.py` (Appendix F) snapshots the stage at the **midpoint of every cue**, scans candidate
707
+ positions inside each zone's allowed range, and scores each by its **worst-case** variation across
708
+ that zone's own frames — worst case, not average, because one busy frame is one unreadable cue. It
709
+ writes `place.json`, which `build.py` reads on the next pass.
710
+
711
+ #### The treatment ladder — and a wash is not a plate
712
+
713
+ `vidfarm qa` calls a plate a `card-panel`, and Rule 4 bans it: a rounded slab under the words is a UI
714
+ element pasted on a drawing, and a stroke or a text-shadow is the same defect wearing a different
715
+ hat. What is allowed is a **paper wash** — the sheet's own colour, feathered to nothing at both
716
+ edges, with no border, no corner, no shadow. It is not an object on the paper; it is the paper being
717
+ lighter where the words are, the way a documentary's lower margin is.
718
+
719
+ | Worst-case variation behind the type | Treatment |
720
+ |---|---|
721
+ | **< 14** | Bare paper. No treatment — and do not add one "to be safe" |
722
+ | **14–20** | Light wash, `α ≈ 0.55` |
723
+ | **20–26** | Wash, `α ≈ 0.70` |
724
+ | **> 26** | `α ≈ 0.82`, and go back and look for a calmer position first |
725
+
726
+ ```css
727
+ /* the box, at its MEASURED top. Full-bleed horizontally so the wash has no
728
+ left/right edge either; the text measure is set on the inner .ln. */
729
+ background: linear-gradient(to bottom,
730
+ rgba(242,229,206,0) 0%, /* ← the PLATE's own paper, sampled, never invented */
731
+ rgba(242,229,206,0.82) 26%,
732
+ rgba(242,229,206,0.82) 74%,
733
+ rgba(242,229,206,0) 100%);
734
+ ```
735
+
736
+ **Sample the paper colour off the plate.** `place-text.py` takes the sheet's 93rd-percentile
737
+ brightness, so the wash is the paper's own cream rather than a white slab. An invented `#FFF` at 80%
738
+ reads as a plate no matter how well it is feathered.
739
+
740
+ **Boxes are fixed-height and flex-centred, not top-anchored**, so a one-line cue and a two-line cue
741
+ share an optical centre instead of the band creeping down the frame:
742
+
743
+ ```css
744
+ .band, .loud, .lockup { display:flex; align-items:center; justify-content:center }
745
+ .ln { display:block } /* ← see below */
746
+ ```
747
+
748
+ ⚠️ **Put the words in ONE inner `.ln` child.** Make the per-word spans direct flex items and the
749
+ browser drops the whitespace text nodes between them — `Soyoucheckoneplace.` — *and* refuses to wrap
750
+ the line, so a long cue is clipped at the box edge. Both failures render, export and pass every DOM
751
+ check. One wrapper element fixes both.
752
+
753
+ #### How gate 2 grades it
754
+
755
+ A wash does not *brighten* the strip — on parchment the paper is already near-white, and the 75th
756
+ percentile measures 228 with or without it. What a wash does is **flatten** it. So gate 2 compares
757
+ variation on frames carrying a cue against frames without one, and a drop of more than 4 is the
758
+ signature of a treatment actually being present. On the dishcover build: **23 bare → 19 washed**,
759
+ which passes; with no wash the same band failed at 23.
760
+
761
+ **Detect a caption by dark AREA, not by the darkest pixel, and sample it fine.** At a 64×8 downsample
762
+ a 38px stem averages away into the paper and every frame reads as empty; at 540×96 cue frames measure
763
+ 1.5–2.8% dark against 0.5–1.3% for gaps. Min-luma alone does not work either, because the drawing
764
+ puts thin dark marks in the strip too — a route line, a contact shadow — and one dark pixel is not a
765
+ caption.
766
+
767
+ ### Scale is the depth system
768
+
769
+ There is no perspective on paper. Scale is the only depth cue you have, so it has to be disciplined.
770
+
771
+ - **A near figure is ~240px tall; a far figure is ~110px.** Pick two sizes and one mid, and use only
772
+ those three across the whole video.
773
+ - **Scale tracks the horizon.** A figure at 68% of the frame is near and big; one at 40% is far and
774
+ small. A big figure high on the stage is the single most common "something is wrong and I can't
775
+ say what" defect in this format.
776
+ - **Relative scale between subjects must be plausible.** A bowl is not the size of a person. This
777
+ breaks every time two stickers came from different sheets — which is the other reason for the
778
+ one-sheet-per-group rule.
779
+
780
+ ---
781
+
782
+ ## Motion DNA — the puppet theater
783
+
784
+ This is the section the format lives or dies in. Everything above is casting; this is the show.
785
+
786
+ ### Pick the engine once: GSAP, on one paused timeline
787
+
788
+ | Engine | Verdict for this format |
789
+ |---|---|
790
+ | **GSAP 3.14.2 + MotionPathPlugin** | ✅ **Use this.** HyperFrames' default runtime adapter, seek-driven and deterministic. `MotionPathPlugin` is exactly the "figures travel along dashed routes" primitive, and `stagger` turns one tween into a crowd of thirty. One paused timeline = one clock, which is what a puppet theater *is* |
791
+ | **CSS `@keyframes`** *(via `vidfarm keyframes --preset`)* | ✅ **The cost-saving fallback, and the only option that survives the web editor.** Script-free, previews and renders identically. No path following, no stagger — you hand-write N animations and you will not attempt act 2 with it |
792
+ | **Anime.js** | ⚠️ Works, but you would reimplement motion paths and lose the default adapter. No reason |
793
+ | **Lottie** | ⚠️ A Lottie file is *someone else's finished animation*. You cannot cast your own stickers into it. Legitimate for ONE decorative loop — steam off a bowl, a spinning sun — and nothing else |
794
+ | **Three.js / TypeGPU** | ❌ 3D on a paper stage. Paper is two-dimensional by definition |
795
+
796
+ **Vendor GSAP into the project; do not rely on the CDN at render time.**
797
+
798
+ ```bash
799
+ mkdir -p vendor
800
+ curl -sL -o vendor/gsap.min.js https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js
801
+ curl -sL -o vendor/MotionPathPlugin.min.js https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js
802
+ ```
803
+
804
+ GSAP 3.14.2 and every plugin including MotionPath are free to use. Two files, 95KB, and the render
805
+ stops depending on a network fetch — which matters because a CDN miss at frame 300 renders a
806
+ composition with no motion in it *and exits 0*.
807
+
808
+ ### The HyperFrames contract, in five lines
809
+
810
+ ```html
811
+ <script src="vendor/gsap.min.js"></script>
812
+ <script src="vendor/MotionPathPlugin.min.js"></script>
813
+ <script>
814
+ gsap.registerPlugin(MotionPathPlugin);
815
+ window.__timelines = window.__timelines || {};
816
+ const tl = gsap.timeline({ paused: true });
817
+ /* … every move in the video … */
818
+ window.__timelines["story"] = tl; // key === data-composition-id on #root
819
+ </script>
820
+ ```
821
+
822
+ - **The registry key must equal `data-composition-id`.** No match, no motion, no error.
823
+ - **Never `tl.play()`.** HyperFrames seeks the timeline; playing it fights the renderer.
824
+ - **Build the timeline synchronously.** Not in a timer, a promise, or a load handler.
825
+ - **Finite repeats only.** `repeat: -1` on a 30s render is undefined behaviour. Compute the count:
826
+ a 0.26s bob over a 3.2s walk is `repeat: 11`.
827
+ - **Render duration comes from `data-duration` on `#root`**, not from the timeline's length. Do not
828
+ pad the timeline to match.
829
+ - **Animate transforms and opacity.** `x`, `y`, `scale`, `rotation`, `opacity`, plus `strokeDashoffset`
830
+ on the SVG marks. Never `top`/`left`/`width`/`height` — they reflow, and on a stage with sixty
831
+ children a reflow per frame is a slow render for no reason.
832
+
833
+ ⚠️ **A scripted composition is desktop-only.** `vidfarm lint` warns
834
+ `scripts_stripped_on_save`: the web editor strips `<script>` on save as a stored-XSS defence. This
835
+ composition is authored and rendered locally (`vidfarm serve` / `vidfarm hf render`) and it must not
836
+ be round-tripped through the browser editor, or every move in it is silently deleted. Say so in the
837
+ handoff.
838
+
839
+ ### The puppet rig — three nodes, and this is not optional
840
+
841
+ Every puppet is three nested elements. It looks like ceremony and it is the difference between a rig
842
+ you can direct and one where every new move breaks the last one.
843
+
844
+ ```html
845
+ <!-- TRAVEL node: zero-size, anchored at the canvas origin. Owns motionPath. -->
846
+ <div class="rig" id="rig-her">
847
+ <!-- BOB node: zero-size. Owns the walk bounce, the breath, the nod. -->
848
+ <div class="bob">
849
+ <!-- ART: the sticker, hung off the rig by its FEET. Owns pop-in scale + rotation. -->
850
+ <img class="art" src="media/stickers/cast-02-walker-olive.png"
851
+ style="left:-55px; top:-240px; width:110px">
852
+ </div>
853
+ </div>
854
+ ```
855
+
856
+ ```css
857
+ .rig { position:absolute; left:0; top:0; width:0; height:0 }
858
+ .bob { position:absolute; left:0; top:0; width:0; height:0 }
859
+ .art { position:absolute; transform-origin:50% 100% }
860
+ ```
861
+
862
+ **Three nodes because three transforms must not fight.** Travel, bob and pop are three separate
863
+ tweens on three separate properties of what would otherwise be one element — and GSAP resolves them
864
+ onto one matrix, so the last one written wins and your walk cycle cancels your walk.
865
+
866
+ ⚠️ **The trap that cost this harness its first spike: `motionPath: { align: "self" }`.** With
867
+ `align: "self"`, GSAP treats the path as *relative to the element's current position* — so a path
868
+ starting at `M 60 1780` moves the puppet 60 right and 1780 **down from wherever it already is**, and
869
+ a puppet at `top:0` ends up 190px above the frame. Every property reads
870
+ `visibility:visible, opacity:1` and nothing is on screen.
871
+
872
+ **The fix is the rig:** the travel node is zero-size at the canvas origin `(0,0)`, and the motionPath
873
+ tween carries **no `align` and no `alignOrigin` at all**. GSAP then writes the path's own numbers
874
+ straight into the node's translate — so **path coordinates ARE canvas coordinates**, `M 540 1200`
875
+ means the point 540px right and 1200px down on the 1080×1920 stage, and you can read a route off a
876
+ still with a ruler. The art hangs off the rig by its feet (`left: -width/2; top: -height`), so the
877
+ path traces where the puppet *walks*, not where its chest is.
878
+
879
+ ⚠️ **The same trap, second form: a travelling rig must have `left:0; top:0`.** It is tempting to
880
+ park every puppet with `style="left:300px; top:1500px"` and let the ones that walk start from there.
881
+ They do not. `left`/`top` is layout and the motionPath translate is a transform, so the browser
882
+ **adds** them: a rig at `left:300; top:1500` on a path starting `M 300 1560` renders at
883
+ `(600, 3060)`, which is off the bottom of a 1920px stage. It reads `opacity:1`, `visibility:visible`,
884
+ `display:block`, and it is not on screen. This harness's first dishcover build shipped exactly that
885
+ and the walkers were invisible in acts 2 and 5. **Two constructors, and never one:**
886
+
887
+ | Puppet | Position from | Constructor |
888
+ |---|---|---|
889
+ | Never travels — scenery, a prop, a seated figure | `left`/`top` | `puppet(id, src, w, h, x, y)` |
890
+ | Travels on a motionPath | the path's first point | `walker(id, src, w, h)` — `left:0; top:0` |
891
+
892
+ ### The seven moves
893
+
894
+ Every motion in this format is one of these seven, or a stack of two of them. If you are writing an
895
+ eighth, you are probably about to write something that reads as a slideshow effect.
896
+
897
+ **1. ENTER — paper lands, it does not fade.**
898
+
899
+ ```js
900
+ tl.fromTo("#rig-her .art",
901
+ { scale: 0, opacity: 0 },
902
+ { scale: 1, opacity: 1, duration: 0.42, ease: "back.out(1.6)" }, 0.3);
903
+ ```
904
+
905
+ `transform-origin: 50% 100%` means it grows up out of the floor. `back.out` is the paper-weight
906
+ overshoot; `power2.out` reads as digital. **Never cross-fade a puppet in** — a 50%-opacity sticker
907
+ is a ghost, and paper is opaque.
908
+
909
+ **2. WALK — travel plus bob, on two nodes.**
910
+
911
+ ```js
912
+ const t0 = 0.8;
913
+ tl.to("#rig-her", { // TRAVEL: absolute canvas coords, no align
914
+ motionPath: { path: "M 120 1620 C 260 1440 340 1180 500 1010" },
915
+ duration: 3.2, ease: "none"
916
+ }, t0);
917
+ tl.to("#rig-her .bob", { // BOB: 11 finite half-cycles over 3.2s
918
+ y: -14, duration: 0.26, ease: "sine.inOut", repeat: 11, yoyo: true
919
+ }, t0);
920
+ ```
921
+
922
+ `ease: "none"` on travel: a person crossing a map walks at a constant speed, and an eased walk reads
923
+ as a slide. Bob amplitude **12–16px on a 240px figure**; more is a cartoon hop. To turn a figure
924
+ around, `scaleX: -1` on the art node at the moment it changes direction — zero duration, not a tween.
925
+
926
+ **3. CROWD — one tween, N puppets, `stagger`.**
927
+
928
+ ```js
929
+ tl.fromTo(".crowd .art",
930
+ { scale: 0, opacity: 0 },
931
+ { scale: 1, opacity: 1, duration: 0.4, ease: "back.out(1.5)",
932
+ stagger: { each: 0.06, from: "random" } }, 6.0);
933
+ ```
934
+
935
+ This is the reference's whole third act in five lines. **Cap the group so the arrival reads as ONE
936
+ beat: `count × each ≤ ~0.6s`.** Thirty figures at 0.06 is 1.8s and reads as a slow drip; thirty at
937
+ 0.02 is one gust of paper, which is the effect you want. `from: "random"` is deterministic in GSAP
938
+ (it is an ordering, not `Math.random()`), so it is render-safe.
939
+
940
+ **4. BEAT — the puppet's own small life.**
941
+
942
+ ```js
943
+ tl.to("#rig-her .bob", { rotation: 2.5, duration: 0.9, ease: "sine.inOut",
944
+ repeat: 3, yoyo: true, transformOrigin: "50% 100%" }, 14.4);
945
+ ```
946
+
947
+ A slow sway, a breath, a head-shake. **This is what stops act 3 looking like a frozen render**, and
948
+ it is the move first-pass builds always forget. Rule: **no puppet on screen for more than 2.0s
949
+ without a BEAT.** The pixel gate in Appendix C measures exactly this.
950
+
951
+ ⚠️ **Amplitude, measured.** A ±2.4° sway on a 190px figure is **too small to register** — it is
952
+ under the pixel gate's 0.2%-of-frame threshold, and it is also under a viewer's. This harness's
953
+ first dishcover build used 2.4° everywhere and gate 2 reported four frozen windows totalling 14.7s
954
+ on a video where every one of those puppets was, technically, animating. **±3.6° plus a 9px rise is
955
+ the floor**; a hero prop wants 4.5–6° and 13–18px.
956
+
957
+ ⚠️ **A BEAT must FILL its hold, not punctuate it.** `repeat: 3` on a 0.9s half-cycle is 3.6s of
958
+ life, and if the puppet is on screen for 5s the last 1.4s are frozen. Size the repeat from the span:
959
+
960
+ ```js
961
+ const IDLE = (sel, at, span, o) => { // a BEAT sized to fill a hold
962
+ o = o || {}; const half = o.half || 1.05, n = Math.max(1, Math.round(span / half));
963
+ tl.to(sel + " .bob", { rotation: o.deg || 3.6, duration: half, ease: "sine.inOut",
964
+ repeat: n, yoyo: true, transformOrigin: "50% 100%" }, at);
965
+ tl.to(sel + " .art", { y: -(o.rise || 9), duration: half * 0.86, ease: "sine.inOut",
966
+ repeat: Math.max(1, Math.round(span / (half * 0.86))), yoyo: true }, at + 0.2);
967
+ };
968
+ ```
969
+
970
+ The two tweens are deliberately on different nodes and slightly different periods, so the sway and
971
+ the rise drift out of phase and the idle never looks like a metronome.
972
+
973
+ ⚠️ **The quiet act needs ambient life, not a bigger idle.** Act 3 has one figure and nothing
974
+ happening, which is the point — so the fix is not to make her wobble harder, it is to **walk two
975
+ background figures across behind her**. Two `walker`s and two motion paths bought act 3 its life in
976
+ the dishcover build, and they cost nothing because the cast sheet was already paid for.
977
+
978
+ **5. STAMP — a mark lands with weight.**
979
+
980
+ ```js
981
+ tl.fromTo("#stamp-x",
982
+ { scale: 2.4, opacity: 0, rotation: -9 },
983
+ { scale: 1, opacity: 1, rotation: -3, duration: 0.18, ease: "power4.out" }, 8.4);
984
+ ```
985
+
986
+ 0.18s and `power4.out` — fast in, hard stop. That is a rubber stamp. Anything slower is a fade and
987
+ carries no impact. Land the sound effect on the same frame or drop the sound entirely.
988
+
989
+ **6. DRAW — the route appears.**
990
+
991
+ The dashed routes are the reference's signature and they are SVG, not PNG, so they can be *drawn*:
992
+
993
+ ```html
994
+ <svg class="marks" viewBox="0 0 1080 1920" style="position:absolute;inset:0;overflow:visible">
995
+ <path id="route1" d="M 120 1620 C 260 1440 340 1180 500 1010"
996
+ fill="none" stroke="#2A2118" stroke-width="4" stroke-opacity="0.45"
997
+ stroke-dasharray="14 16" stroke-linecap="round"
998
+ pathLength="1000" style="stroke-dashoffset:1000"/>
999
+ </svg>
1000
+ ```
1001
+
1002
+ ```js
1003
+ tl.to("#route1", { strokeDashoffset: 0, duration: 1.1, ease: "power2.inOut" }, 0.8);
1004
+ ```
1005
+
1006
+ **`pathLength="1000"` is what makes this reliable.** It normalises the path's length so the initial
1007
+ `stroke-dashoffset` is a number you wrote rather than one you had to measure with
1008
+ `getTotalLength()` — which is a DOM read, which is exactly the kind of thing that behaves
1009
+ differently under a seek than under playback.
1010
+
1011
+ **Use the same `d` string for the route and for the walker's motionPath.** One source of truth: the
1012
+ line drawn is literally the line walked, and they cannot drift apart.
1013
+
1014
+ A menu unrolling, a banner dropping, a bar filling — same family, different property:
1015
+
1016
+ ```js
1017
+ tl.fromTo("#menu", { scaleY: 0 }, { scaleY: 1, duration: 0.5, ease: "power2.out",
1018
+ transformOrigin: "50% 0%" }, 5.2);
1019
+ ```
1020
+
1021
+ **7. CAMERA — the stage moves, once per act at most.**
1022
+
1023
+ ```js
1024
+ tl.to("#act2 .stagewrap", { scale: 1.18, x: -60, y: -140,
1025
+ duration: 2.4, ease: "power1.inOut" }, 9.0);
1026
+ ```
1027
+
1028
+ Put a `.stagewrap` div inside the act clip holding the plate and every puppet, and scale *that*. One
1029
+ move per act, **never during a subtitle cue's first 0.4s** (the viewer is reading; moving the world
1030
+ under them costs you the line), and never more than `scale: 1.35` — past that the parchment's grain
1031
+ resolves into mush and the paper illusion goes with it.
1032
+
1033
+ ### Choreography — the rules that make it read as one theater
1034
+
1035
+ - **One thing is the subject at any moment.** Sixty stickers may be on stage; exactly one is *doing
1036
+ the sentence*. Everything else is BEAT-level idle. A frame where four things start moving at once
1037
+ is a frame nobody can read.
1038
+ - **Motion has to overlap the narration, not follow it.** Start the move ~0.25s BEFORE its line. The
1039
+ eye is faster than the ear and a picture that arrives after its words feels like lag.
1040
+ - **Use at least three easings across the video.** `none` for travel, `back.out` for arrivals,
1041
+ `power4.out` for stamps, `sine.inOut` for idles. One easing everywhere is the machine tell.
1042
+ - **Nothing crosses a butt cut mid-move.** A move ends inside its act. Hand-offs are positional, not
1043
+ motional.
1044
+ - **Exits matter as much as entrances.** A puppet that is simply not there in the next act reads as
1045
+ a dropped frame. Walk it off, or shrink it out with the inverse of its ENTER.
1046
+
1047
+ ### Cost-saving mode: the CSS fallback
1048
+
1049
+ When the composition must round-trip through the web editor, there is no script, so there is no
1050
+ timeline. You get the `vidfarm keyframes` preset vocabulary and hand-written `@keyframes`:
1051
+
1052
+ ```bash
1053
+ vidfarm keyframes ./work --layer walker-1 --preset slide-in-left --duration 1.2
1054
+ vidfarm keyframes ./work --layer bowl --preset pop-in
1055
+ vidfarm keyframes ./work --layer stall --preset float
1056
+ vidfarm keyframes ./work --layer arrow --keyframes \
1057
+ '[{"offset":0,"opacity":0,"scale":2.2},{"offset":1,"opacity":1,"scale":1}]'
1058
+ ```
1059
+
1060
+ What you lose, stated plainly so nobody promises otherwise: **no motion paths** (walks become
1061
+ straight-line slides), **no stagger** (a crowd is N hand-written animations), and **no shared clock**
1062
+ (each layer's animation is relative to its own clip). ENTER, STAMP, and a straight-line WALK all
1063
+ survive; act 2 as specified does not. **Build the CSS version as a 3-act, 20s story with straight
1064
+ routes** rather than a degraded 5-act one.
1065
+
1066
+ ---
1067
+
1068
+ ## Audio DNA
1069
+
1070
+ **Narration is required. The bed is optional and quiet. There are no sound effects unless there are
1071
+ at least four of them.**
1072
+
1073
+ | Track | Level | Source |
1074
+ |---|---|---|
1075
+ | Narration | 1.0 | `vidfarm tts`, one clip per act, laid with `place --kind audio` |
1076
+ | Music bed | **0.12–0.18** | `vidfarm media bgm "<mood>"` — Openverse, CC/CC0, keyless, $0 |
1077
+ | SFX | 0.4–0.6 | `vidfarm media sfx` — only on STAMP and set-piece landings |
1078
+
1079
+ ```bash
1080
+ vidfarm place ./work --src ./vo/a1.mp3 --kind audio --at 0 --volume 1.0
1081
+ vidfarm place ./work --src ./vo/a2.mp3 --kind audio --at 5.0 --volume 1.0
1082
+ vidfarm place ./work --src ./media/bed.mp3 --kind audio --at 0 --volume 0.15
1083
+ ```
1084
+
1085
+ - **Measure the mix, do not vibe it.** `ffmpeg -i out.mp4 -af volumedetect -f null -` — speech ~12–15
1086
+ dB over the bed, peak below 0 dBFS.
1087
+ - **One sound effect is worse than none.** A single stamp thud in a 30s video reads as a glitch. Either
1088
+ every stamp and every set piece lands with a sound, or the video is narration and bed only.
1089
+ - **The bed never drives the cut.** Puppet timings come from the narration and the story. If you find
1090
+ yourself moving a walk to hit a downbeat, you are making a music video.
1091
+
1092
+ ---
1093
+
1094
+ ## The rules
1095
+
1096
+ Every rule has its reason attached. A rule without its reason gets argued away by the next agent that
1097
+ reads this file.
1098
+
1099
+ ### Rule 1 — one stage, no cuts, no footage
1100
+
1101
+ Five butt-cut act clips share one identical parchment plate, so no viewer can see a cut. A crossfade,
1102
+ a whip, a stock clip or a second background makes it a slideshow with a paper theme, and the entire
1103
+ value of the format — that it looks like one continuous handmade thing — is gone in one frame.
1104
+ **Enforced:** `transitions list` returns empty; `vidfarm qa` reports one canvas.
1105
+
1106
+ ### Rule 2 — the turn is a re-draw, and the promo is a wordmark on the last beat
1107
+
1108
+ Act 4 states the mechanism by *rearranging the stage into it* — no logo, no screenshot, no UI mock,
1109
+ no pricing, no button, and no brand name yet. Act 5 pays the want and then names the offer once, in
1110
+ the narration and as one wordmark on the paper. **Default placement is the end**; middle and start
1111
+ are allowed variants with the costs listed under *Where the promo goes*.
1112
+
1113
+ Two failures this rule exists to stop, and they are opposite. Showing the product in act 4 converts
1114
+ 19 seconds of goodwill into an ad the viewer has already decided to leave. Never naming the offer at
1115
+ all makes a beautiful video that sells nothing — which is the more common one among agents trying to
1116
+ be tasteful. **Show the mechanism, then say the name. Once, last, plainly.**
1117
+
1118
+ ### Rule 3 — silent-legible or rebuild
1119
+
1120
+ Mute the video and hide the band. If the story is gone, the narration was carrying the plot and you
1121
+ built a podcast with pictures. This is test 1, it is the format, and it is worth the rebuild.
1122
+
1123
+ ### Rule 4 — everything is die-cut
1124
+
1125
+ One rectangle in the composition: the stage plate. Every other element has its own silhouette against
1126
+ the paper. A pasted photo, a screenshot, a chart, a card, a rounded panel — all the same defect.
1127
+
1128
+ ### Rule 5 — one art class, one palette, one light direction
1129
+
1130
+ Six colours. One style string across every generated sheet. Shadows all fall the same way. A machine
1131
+ assembles a stage from four sources and it looks like a stage assembled by a machine from four
1132
+ sources.
1133
+
1134
+ ### Rule 6 — no puppet holds still for more than 2 seconds
1135
+
1136
+ A BEAT — a sway, a breath, a nod — on anything on screen. Scenery is exempt and is the *only*
1137
+ exemption. A stage where nothing idles reads as a frozen render, and a frozen render passes every
1138
+ duration, frame-count and audio-hash check there is.
1139
+
1140
+ ### Rule 7 — subtitles stay subordinate
1141
+
1142
+ 34–40px, weight 500, one line, ≤8 words, in the band at 78–86%. Exactly one cue may be loud, and it
1143
+ is the first one. The moment the type is the most interesting thing on screen, you have built a
1144
+ kinetic-caption video with wallpaper.
1145
+
1146
+ ### Rule 8 — every number is real
1147
+
1148
+ `7,502 dishes`, `140 restaurants`, `40 minutes`. Stamps are the most believed thing on the stage
1149
+ because they look official. A number invented to sound specific is the one claim a viewer can catch
1150
+ you on, and a viewer who catches one stops believing the rest.
1151
+
1152
+ ### Rule 9 — credits and bait live in the post caption
1153
+
1154
+ Attribution on the frame is brand chrome. The comment prompt, the save prompt and the follow prompt
1155
+ belong in the caption where the platform expects them. The video ends on the offer's NAME, which is a
1156
+ statement, not an ask — nothing on the stage ever tells the viewer to tap, follow, save or comment.
1157
+
1158
+ ### Rule 10 — production floor
1159
+
1160
+ 1080×1920, 30fps, 20–45s, one paused GSAP timeline registered under `data-composition-id`, GSAP
1161
+ vendored locally, `data-duration` on `#root` equal to the act sum, finite repeats, no script
1162
+ round-trip through the web editor.
1163
+
1164
+ ---
1165
+
1166
+ ## Cost
1167
+
1168
+ ### `hybrid` — the normal mode for this format
1169
+
1170
+ The brief for this harness allows **$1 per video and asks for $0.50 or less.** The measured build
1171
+ lands well under both, because **the unit of spend is the SHEET, not the sticker.**
1172
+
1173
+ | Ingredient | How | Cost |
1174
+ |---|---|---|
1175
+ | Stage plate | `vidfarm generate image --aspect-ratio 9:16` — one parchment map | ~$0.05 |
1176
+ | Cast sheet | `sticker-pack --generate`, 8 figures, ONE job | ~$0.05 |
1177
+ | Prop sheet | `sticker-pack --generate`, 8 props, ONE job | ~$0.05 |
1178
+ | Hero sheet | `sticker-pack --generate`, 6 dishes/products, ONE job | ~$0.05 |
1179
+ | One re-roll | budget it; you will use it | ~$0.05 |
1180
+ | Narration | `vidfarm tts` × 5 acts, BYOK gemini | <$0.01 |
1181
+ | Subtitles | `vidfarm captions generate --srt` | $0 |
1182
+ | Music bed | `vidfarm media bgm` (Openverse, keyless) | $0 |
1183
+ | Motion | GSAP, hand-authored | $0 |
1184
+ | Render | `vidfarm hf render` local | $0 |
1185
+ | **Total** | **4–5 image jobs** | **~$0.20–0.30** |
1186
+
1187
+ **Measured on the dishcover build:** 5 billed image jobs (cast sheet, prop sheet, dish sheet, one
1188
+ prop re-roll that failed to key, one parchment stage) and 7 narration clips on a BYOK gemini key —
1189
+ **~$0.25**, against a $1.00 ceiling and a $0.50 target. The re-roll is the honest overhead; budget
1190
+ it rather than pretending sheets land first time. Everything else — keying, splitting, matting,
1191
+ subtitles, motion, render — was local and free.
1192
+
1193
+ **The sheet count is the budget.** Four sheets is the number and six is the ceiling. If you need a
1194
+ seventh, the story has too many nouns in it — which is a script problem, not a budget problem, and
1195
+ cutting a noun will improve the video as well as the invoice.
1196
+
1197
+ **Where the money would leak if you let it:** one `cutout --generate` per sticker (30 jobs, ~$1.50,
1198
+ and 30 art classes), or a cloud render (~$0.01–0.10 for output identical to the free local one), or
1199
+ generated video for a beat HTML motion does for nothing.
1200
+
1201
+ ### `minimize` — the whole stage at $0
1202
+
1203
+ | Ingredient | Free route |
1204
+ |---|---|
1205
+ | Stage plate | `vidfarm media image "old parchment paper texture"` (Openverse/Pixabay) placed full frame |
1206
+ | Cast + props | `vidfarm iconscout "<noun>" --style sticker --free` — designer transparent PNGs, no key needed |
1207
+ | Photo cutouts | `vidfarm media image` → `vidfarm remove-background` (local ONNX) |
1208
+ | The client's own art | `vidfarm capture <url>` → `vidfarm mask --crop` per element |
1209
+ | Narration | `vidfarm tts --engine local` (Kokoro-82M, after `pip install kokoro-onnx soundfile`) |
1210
+ | Everything else | already free |
1211
+
1212
+ **The honest cost of `minimize` is the art class, not the money.** IconScout free assets come from
1213
+ different designers, so a free cast is a mixed class by construction — which Rule 5 forbids. Three
1214
+ mitigations, in order:
1215
+
1216
+ 1. **Search the pack, not the noun.** Find one asset you like, then pull its whole family — one
1217
+ designer's set is one class.
1218
+ 2. **Unify in CSS.** One identical `filter: drop-shadow(...)` and one `saturate()`/`sepia()` pair
1219
+ applied to `.art` pulls a mixed set into one palette. It is not as good as one sheet and it is
1220
+ dramatically better than nothing.
1221
+ 3. **In `minimize` + `interactive`, rung 4 is free.** `vidfarm handoff image` mints the sheet prompt,
1222
+ the user runs it in a free web generator, `vidfarm sticker-pack` splits the result locally. Free
1223
+ frontier image models are usually better per image than an API budget buys, so this is frequently
1224
+ the *best-looking* rung, not the compromise one.
1225
+
1226
+ **Claude Code has no image model.** `vidfarm agent-image` is genuinely free custom art on Antigravity
1227
+ / Gemini CLI / Codex; in a Claude session it is not available. Use the handoff rung and say so rather
1228
+ than pretending.
1229
+
1230
+ ---
1231
+
1232
+ ## Bulk generation
1233
+
1234
+ The rig is the reusable part. Once one story is built, the tenth is a manifest and a re-record.
1235
+
1236
+ - **Hold the stage, the cast and the rig; vary the STORY.** Same parchment, same eight figures, same
1237
+ seven moves, new narration and new hero object. That is a batch.
1238
+ - **The hero sheet is the only thing that must be re-generated per story** — the dish, the product,
1239
+ the object the want is about. One image job per variant, ~$0.05.
1240
+ - **Acts 4 and 5 may repeat verbatim across a campaign.** They are the product's mechanism and its
1241
+ name, and neither improves by being reworded. It does not
1242
+ improve by being reworded. Holding it constant also makes it the clean variable when you want to
1243
+ test it.
1244
+ - **Vary one thing per batch.** Same story with five different act-1 wants teaches you which craving
1245
+ sells. Same want with five different act-4 phrasings teaches you which mechanism lands. A batch
1246
+ that changed both teaches you nothing.
1247
+ - **`vidfarm dedupe` before a RE-post**, not before a first post.
1248
+
1249
+ Sizing a round, capacity and the ledger belong to `vidfarm experiment`, not to this file.
1250
+
1251
+ ---
1252
+
1253
+ ## Quality gates — what is ENFORCED, and by what
1254
+
1255
+ | Gate | Tool | What it can actually see |
1256
+ |---|---|---|
1257
+ | **0. Structure** | `vidfarm lint ./work` | Composition contract, clip timing, track indices, the `scripts_stripped_on_save` warning |
1258
+ | **1. Composition** | `vidfarm qa ./work --harness ./experimental/animated-sticker-story.md` | The DOM: font regime, slop, safe zone, frame 0, the `checks:` block |
1259
+ | **2. The pixels** | `python3 story-qa.py ./work/out.mp4` *(Appendix C)* | Frozen puppets, stage drift, an empty subtitle band, a popcorn frame, and whether the captions have taken the spotlight |
1260
+ | **3. The sequence** | **You**, on `stills/contact-sheet.png` | Whether it reads as one theater. Nothing else can see this |
1261
+ | **4. The story** | **You**, cold, muted | Whether it is worth watching. No tool has an opinion here |
1262
+
1263
+ Gates 0 and 1 are free and instant and never look at a pixel. **Gate 2 exists because this format's
1264
+ signature failure is invisible to gates 0 and 1 and to the agent that built it**: a composition where
1265
+ the timeline never registered, or a puppet whose tween silently cancelled, renders a beautiful
1266
+ static image for 30 seconds and passes duration, frame-count and audio-hash checks.
1267
+
1268
+ ### Findings that are EXPECTED on this format — do not "fix" them
1269
+
1270
+ Record your answer once and move on; chasing either one damages the video.
1271
+
1272
+ - **`scripts_stripped_on_save` (gate 0).** Correct and unavoidable. This composition is desktop-only
1273
+ by design. Note it in the handoff and never open the project in the web editor.
1274
+ - **`missing_track_index` on puppets.** Only clips need `data-track-index`. If lint reports it for a
1275
+ puppet, that puppet is a clip and you have made the structural mistake this harness opens with —
1276
+ that one IS a real finding.
1277
+ - **Low `scenes` count on `vidfarm qa`.** Five acts reports `scenes: 5` for a video with sixty
1278
+ animated elements. That is correct. It is one stage.
1279
+ - **`band motion share N%` (gate 2).** Informational, not a finding. Under 15% is where colour-only
1280
+ kinetics land; the check only fails past 35%.
1281
+ - **`SUBTITLE BAND is inked in 100% of samples` (gate 2).** The check is calibrated for formats where
1282
+ the band should rest. This one is narrated end to end by design, so the band is up almost
1283
+ continuously and the warning fires on a correct build. Rule 7 governs the band's *size*, not its
1284
+ duty cycle. Leave it.
1285
+ - **`REVISION LIMIT REACHED` from `vidfarm qa`.** The limit is 1 by design, to stop an agent looping.
1286
+ A format this mechanical genuinely needs a few passes — re-run with `--max-revisions 4`, and once
1287
+ that budget is spent, take the hint and ship.
1288
+
1289
+ ### The other failure this format ships: type on the drawing
1290
+
1291
+ Captions parked over the map are the complaint every viewer can feel and nobody can articulate —
1292
+ "hard to read", "messy", "the text is in the way". It is never fixed by restyling the type, and it is
1293
+ never fixed by cropping the art — **measure where the drawing is quietest, put the words there, and
1294
+ lay a feathered paper wash under them if the measurement asks for one.** Two-pass build, treatment
1295
+ ladder, both above. Gate 2's *band readable* check is the backstop.
1296
+
1297
+ ### The failure this format actually ships
1298
+
1299
+ **A composition that renders a still.** In order of how often it happens:
1300
+
1301
+ 1. `window.__timelines` key does not match `data-composition-id`. No error, no motion.
1302
+ 2. `motionPath: { align: "self" }` — every puppet is off-canvas, every computed style says visible.
1303
+ 3. A puppet given `class="clip"` — the clip lifecycle owns it and your transforms are overwritten.
1304
+ 4. A CDN GSAP fetch that failed at render time. Vendored files make this impossible.
1305
+
1306
+ All four render cleanly, exit 0, and produce a file of the right length. **Run gate 2 or you will
1307
+ ship one.**
1308
+
1309
+ ---
1310
+
1311
+ ## Pre-flight checklist
1312
+
1313
+ Run this before you build, and answer it honestly after. An unchecked box is a rewrite, not a fix in
1314
+ the edit.
1315
+
1316
+ **The story**
1317
+
1318
+ - [ ] The four build-log lines are written: STORY, WATCHER, OFFER, THE TURN.
1319
+ - [ ] Five acts exist, act 2 is the longest, and the whole thing is 20–45s.
1320
+ - [ ] Muting the video and hiding the band leaves a story a stranger can follow.
1321
+ - [ ] There is a real, unflattering cost in act 3 — not a mild inconvenience.
1322
+ - [ ] The turn is a mechanism, not an outcome, and it is stated once.
1323
+
1324
+ **The narration**
1325
+
1326
+ - [ ] 55–90 words, one paragraph, ≤12 words a sentence.
1327
+ - [ ] Every noun in the script is a sticker on the stage.
1328
+ - [ ] The offer is named once, lowercase, in the closing lines — and the placement (end / middle /
1329
+ start) is written in the build log with its reason.
1330
+ - [ ] The promo is a wordmark in the stage's ink: no logo, no URL, no button, no plate, no tagline.
1331
+ - [ ] Nothing follows the promo. No end card, no follow prompt, no second beat.
1332
+ - [ ] There is at least one deliberate ~1.2s hole where the stage talks alone.
1333
+ - [ ] One voice and one style string across all five act clips.
1334
+
1335
+ **The stage**
1336
+
1337
+ - [ ] One parchment plate, identical in every act clip. No cuts, no transitions, no footage.
1338
+ - [ ] Six colours or fewer in the whole video; one ink; one light direction.
1339
+ - [ ] The horizon sits at 55–70% and does not move between acts.
1340
+ - [ ] The two-pass build was run: `--no-text` → `place-text.py` → rebuild. Placement came from
1341
+ `place.json`, not from taste.
1342
+ - [ ] The stage art is still FULL-BLEED. Nothing was cropped to make room for type.
1343
+ - [ ] Where the measurement asked for a treatment, it is a feathered paper wash in the plate's own
1344
+ sampled colour — never a plate, a stroke or a text-shadow.
1345
+ - [ ] Nothing rests in the top 8% or the bottom 12%.
1346
+ - [ ] Every cue box is fixed-height and flex-centred, with the words in one inner `.ln` child.
1347
+ - [ ] At least one set piece over 25% of frame, die-cut, entering once and then holding.
1348
+
1349
+ **The puppets**
1350
+
1351
+ - [ ] One art class, declared in the build log before anything was sourced.
1352
+ - [ ] One art style string, reused verbatim on every `--generate` call.
1353
+ - [ ] The whole-sheet artifact was deleted and every sticker renamed against the picture.
1354
+ - [ ] Every sticker's alpha is mostly 0-or-255. No grey halo on the paper.
1355
+ - [ ] Three sizes only; scale tracks the horizon; relative scale between subjects is plausible.
1356
+ - [ ] Every credit is in the post caption. Nothing attributive is on the stage.
1357
+
1358
+ **The rig**
1359
+
1360
+ - [ ] GSAP is vendored into `vendor/`, not fetched from a CDN at render time.
1361
+ - [ ] `window.__timelines["<id>"]` matches `data-composition-id` exactly.
1362
+ - [ ] Every puppet is a three-node rig; no puppet has `class="clip"`.
1363
+ - [ ] No `align` / `alignOrigin` on any motionPath tween.
1364
+ - [ ] Every route's SVG `d` is the same string as its walker's motionPath.
1365
+ - [ ] Every repeat is finite and computed from its duration.
1366
+ - [ ] At least three different easings appear in the timeline.
1367
+ - [ ] No puppet is on screen for >2.0s without a BEAT.
1368
+ - [ ] At most one camera move per act, none inside a cue's first 0.4s, none past `scale: 1.35`.
1369
+
1370
+ **The band**
1371
+
1372
+ - [ ] Exactly one loud cue, and it is the first one.
1373
+ - [ ] Every other cue is 34–40px, weight 500, one line, ≤8 words, inside 78–86%.
1374
+ - [ ] The band's ink and its highlight were SAMPLED off the stage — no colour in the band exists
1375
+ anywhere else in the video.
1376
+ - [ ] Kinetics are colour-only: no translate, scale, rotate, blur, shadow, or font-weight on any word.
1377
+ - [ ] Cue timings came from `vidfarm stt --engine whisper`; cue TEXT came from the script, and the
1378
+ build fails if the token counts disagree.
1379
+ - [ ] Cues break on clauses, not on a word count.
1380
+ - [ ] The ember never lands on the offer's name, and no band cue duplicates the lockup.
1381
+ - [ ] Gate 2's band-dominance share is under 35% — ideally under 15%.
1382
+ - [ ] ≤3 stamps, each ≤2 words or one number, above the 60% line, every number real.
1383
+ - [ ] Two weights of ONE self-hosted family, no fallback chain.
1384
+
1385
+ **The output**
1386
+
1387
+ - [ ] Frame 0 works as a thumbnail: stage visible, loud cue legible, one subject.
1388
+ - [ ] Gate 2 ran and reported no frozen window, no stage drift, no popcorn frame.
1389
+ - [ ] Audio measured, not vibed: speech ~12–15 dB over the bed, peak < 0 dBFS.
1390
+ - [ ] The handoff says the composition is desktop-only and must not be opened in the web editor.
1391
+
1392
+ ---
1393
+
1394
+ ## The whole-video review — do this last, on the render
1395
+
1396
+ Gates 0–2 grade parts. This grades the theater, and it is the only pass that catches what actually
1397
+ ships broken. **Export a contact sheet and read it as one image**, then watch the file end to end at
1398
+ 1× with sound off, then again with sound on.
1399
+
1400
+ ```bash
1401
+ vidfarm stills ./work --sheet
1402
+ # if `stills` dies with "Cannot find package '@hyperframes/producer'", the devcli's bundled
1403
+ # engine is incomplete on this machine. Do NOT go hunting — the passthrough is the same renderer:
1404
+ vidfarm hf snapshot ./work --at 1,3,5,7,9,11,14,16,19,23,27 -o ./stills --describe false
1405
+ ```
1406
+
1407
+ - [ ] **Is the parchment the same colour on every still?** A set piece with a near-paper background
1408
+ rectangle makes its act read a different cream, and it only shows up beside its neighbours.
1409
+ - [ ] **Do the cutouts look like one set?** Same class, same light, plausible relative scale.
1410
+ - [ ] **Is the horizon in the same place on all ten?** Drift here is the "something is off" nobody
1411
+ can name.
1412
+ - [ ] **Pick any two adjacent stills — has the stage actually changed?** Two identical stills 3s
1413
+ apart is a frozen window gate 2 should have caught; if it did not, the gate's threshold is
1414
+ wrong for your build.
1415
+ - [ ] **Count the type sizes.** There should be two: the loud cue and the band. Stamps share the
1416
+ band's family and may be a third size — no more.
1417
+ - [ ] **Which act is the weakest?** There is one. Usually act 3, because it is quiet and got the
1418
+ least attention. Cut it shorter rather than adding to it.
1419
+ - [ ] **Is act 4 visibly the ad?** It must not be — it carries no brand name. If your eye finds it
1420
+ before act 5, you styled it differently and Rule 2 is broken.
1421
+ - [ ] **Does the promo read at arm's length in one glance?** It is the last thing they see and the
1422
+ only thing they might type into a search box.
1423
+ - [ ] **Would you watch it twice?** That is this format's actual bar, and it is a higher one than
1424
+ "is it correct".
1425
+
1426
+ ---
1427
+
1428
+ ## Diagnosing a flop — read the acts, not the video
1429
+
1430
+ | What the numbers say | The weak act | The fix |
1431
+ |---|---|---|
1432
+ | Almost no views | **Act 1 / frame 0** | The stage was empty at frame 0, or the loud cue was a question. Open on a figure already mid-move with the claim already readable |
1433
+ | Views, mass exit at 4–8s | **Act 2** | The grind is not visibly accumulating. More routes, faster stagger, make the failures repeat |
1434
+ | Watched through, no reaction | **Act 4** | The turn was an announcement, not a re-draw. Show the system assembling; say less |
1435
+ | Watched through, nobody remembers the brand | **Act 5** | The promo was too short, too small, or shared its beat with a caption. Hold the wordmark ≥3s alone |
1436
+ | Exit right at the promo | **The pace before it** | Not the promo. The video was too slow to have earned those four seconds — cut words and raise `atempo` |
1437
+ | Good retention, no comments | **The caption** | The bait lives there, and it is missing or generic |
1438
+ | "Looks AI-made" in the comments | **The art class** | Almost always two sheets with different style strings, or generated lettering on a sign |
1439
+ | Good numbers, no clicks | **Nothing is broken** | This format seeds a mechanism; it does not close. Judge it on saves, re-watches and branded search |
1440
+
1441
+ **Vary one act at a time.** A batch where the story, the cast and the turn all changed teaches you
1442
+ nothing, which is the entire point of running against a harness instead of just generating volume.
1443
+
1444
+ ---
1445
+
1446
+ ## Appendix A — the composition skeleton
1447
+
1448
+ Copy this, then fill acts 2–5. It renders as-is.
1449
+
1450
+ ```html
1451
+ <!doctype html>
1452
+ <html lang="en"><head><meta charset="UTF-8">
1453
+ <meta name="viewport" content="width=1080, height=1920">
1454
+ <title>sticker story</title>
1455
+ <style>
1456
+ @font-face{font-family:'Deck';font-weight:800;font-display:block;
1457
+ src:url('media/fonts/deck-800.woff2') format('woff2')}
1458
+ @font-face{font-family:'Deck';font-weight:500;font-display:block;
1459
+ src:url('media/fonts/deck-500.woff2') format('woff2')}
1460
+
1461
+ body{margin:0;background:#E7DCC2}
1462
+ #root{position:relative;width:1080px;height:1920px;overflow:hidden;background:#E7DCC2}
1463
+ .clip{position:absolute;inset:0}
1464
+
1465
+ /* The stage plate, identical in every act, so the butt cuts are invisible. */
1466
+ .stagewrap{position:absolute;inset:0;transform-origin:50% 55%}
1467
+ .plate{position:absolute;inset:0;width:1080px;height:1920px;object-fit:cover}
1468
+
1469
+ /* THE RIG. Three nodes so travel / bob / pop never share a matrix. */
1470
+ .rig{position:absolute;left:0;top:0;width:0;height:0}
1471
+ .bob{position:absolute;left:0;top:0;width:0;height:0}
1472
+ .art{position:absolute;transform-origin:50% 100%}
1473
+
1474
+ /* Marks are SVG so they can be DRAWN. pathLength normalises the dashoffset. */
1475
+ .marks{position:absolute;inset:0;overflow:visible}
1476
+
1477
+ .band{font-family:'Deck';font-weight:500;font-size:38px;line-height:1.3;
1478
+ color:#2A2118;text-align:center;letter-spacing:0}
1479
+ .loud{font-family:'Deck';font-weight:800;font-size:76px;line-height:1.18;
1480
+ color:#2A2118;text-align:center;letter-spacing:-0.01em}
1481
+ .stamp{font-family:'Deck';font-weight:800;font-size:56px;color:#8C3B2E;
1482
+ letter-spacing:0.02em;text-align:center}
1483
+ /* NO text-shadow, NO -webkit-text-stroke, NO plate. Ink on paper is already
1484
+ maximum contrast, and a plate on parchment is a card (Rule 4). */
1485
+ </style>
1486
+ </head><body>
1487
+
1488
+ <div id="root" data-composition-id="story" data-start="0"
1489
+ data-width="1080" data-height="1920" data-duration="30">
1490
+
1491
+ <div id="act1" class="clip" data-hf-id="act1" data-layer-kind="image" data-track-index="0"
1492
+ data-start="0" data-duration="5" data-label="act 1 — the want">
1493
+ <div class="stagewrap">
1494
+ <img class="plate" src="media/stage-map.png">
1495
+
1496
+ <svg class="marks" viewBox="0 0 1080 1920">
1497
+ <path id="route1" d="M 120 1620 C 260 1440 340 1180 500 1010"
1498
+ fill="none" stroke="#2A2118" stroke-width="4" stroke-opacity="0.4"
1499
+ stroke-dasharray="14 16" stroke-linecap="round"
1500
+ pathLength="1000" style="stroke-dashoffset:1000"/>
1501
+ </svg>
1502
+
1503
+ <div class="rig" id="rig-her">
1504
+ <div class="bob">
1505
+ <img class="art" src="media/stickers/cast-02-walker-olive.png"
1506
+ style="left:-55px;top:-240px;width:110px">
1507
+ </div>
1508
+ </div>
1509
+ </div>
1510
+
1511
+ <!-- The band is OUTSIDE .stagewrap: the camera must never move the subtitles. -->
1512
+ <div class="loud" id="cue-loud"
1513
+ style="inset:auto;left:8%;width:84%;top:38%;z-index:9">It's seven in the evening<br>and you know exactly what you want.</div>
1514
+ </div>
1515
+
1516
+ <!-- acts 2-5 … same shape, data-start 5 / 14 / 19 / 26 -->
1517
+
1518
+ </div>
1519
+
1520
+ <script src="vendor/gsap.min.js"></script>
1521
+ <script src="vendor/MotionPathPlugin.min.js"></script>
1522
+ <script>
1523
+ gsap.registerPlugin(MotionPathPlugin);
1524
+ window.__timelines = window.__timelines || {};
1525
+ const tl = gsap.timeline({ paused: true });
1526
+
1527
+ // ACT 1 -------------------------------------------------------------------
1528
+ tl.fromTo("#rig-her .art", { scale: 0, opacity: 0 },
1529
+ { scale: 1, opacity: 1, duration: 0.42, ease: "back.out(1.6)" }, 0.3);
1530
+ tl.to("#route1", { strokeDashoffset: 0, duration: 1.1, ease: "power2.inOut" }, 0.8);
1531
+ tl.to("#rig-her", { motionPath: { path: "M 120 1620 C 260 1440 340 1180 500 1010" },
1532
+ duration: 3.2, ease: "none" }, 0.8);
1533
+ tl.to("#rig-her .bob", { y: -14, duration: 0.26, ease: "sine.inOut",
1534
+ repeat: 11, yoyo: true }, 0.8);
1535
+ tl.to("#cue-loud", { opacity: 0, duration: 0.3 }, 2.4);
1536
+
1537
+ window.__timelines["story"] = tl; // MUST equal data-composition-id
1538
+ </script>
1539
+ </body></html>
1540
+ ```
1541
+
1542
+ **The one line in there that is easy to miss:** the band and the loud cue sit **outside**
1543
+ `.stagewrap`. A camera move that drags the subtitles with it is disorienting in a way viewers report
1544
+ as "it made me feel sick" and authors never notice, because they are watching the puppets.
1545
+
1546
+ ## Appendix B — `puppet.js`, the move library
1547
+
1548
+ Drop this above your timeline and the seven moves become one line each. It is deliberately tiny —
1549
+ this is a vocabulary, not a framework, and a framework here would hide exactly the timings you need
1550
+ to tune.
1551
+
1552
+ ```js
1553
+ /* puppet.js — the seven moves. Every function takes the timeline and a time.
1554
+ Nothing here reads the DOM, uses Math.random(), or schedules work: everything
1555
+ is deterministic from time alone, which is what a seek-driven renderer needs. */
1556
+
1557
+ const P = {
1558
+ // 1. ENTER — paper lands. Never a cross-fade.
1559
+ enter(tl, sel, at, { d = 0.42, from = 0 } = {}) {
1560
+ return tl.fromTo(`${sel} .art`, { scale: from, opacity: 0 },
1561
+ { scale: 1, opacity: 1, duration: d, ease: "back.out(1.6)" }, at);
1562
+ },
1563
+
1564
+ // 2. WALK — travel on the rig, bob on the bob node. Two tweens, never one.
1565
+ walk(tl, sel, at, path, { d = 3.2, bob = 14, step = 0.26, flip = false } = {}) {
1566
+ if (flip) tl.set(`${sel} .art`, { scaleX: -1 }, at);
1567
+ tl.to(sel, { motionPath: { path }, duration: d, ease: "none" }, at);
1568
+ // repeat is COMPUTED, never -1: half-cycles that fit inside the walk.
1569
+ tl.to(`${sel} .bob`, { y: -bob, duration: step, ease: "sine.inOut",
1570
+ repeat: Math.max(1, Math.round(d / step) - 1), yoyo: true }, at);
1571
+ return tl;
1572
+ },
1573
+
1574
+ // 3. CROWD — one tween, N puppets. Cap the group so it reads as ONE beat.
1575
+ crowd(tl, sel, at, { d = 0.4, each = 0.04, from = "random" } = {}) {
1576
+ return tl.fromTo(`${sel} .art`, { scale: 0, opacity: 0 },
1577
+ { scale: 1, opacity: 1, duration: d, ease: "back.out(1.5)",
1578
+ stagger: { each, from } }, at);
1579
+ },
1580
+
1581
+ // 4. BEAT / IDLE — the small life that stops the stage looking frozen (Rule 6).
1582
+ // 3.6deg is the MEASURED floor: 2.4deg is under the pixel gate's threshold
1583
+ // and under a viewer's. Pass a SPAN, not a repeat count — a beat that ends
1584
+ // before its hold does leaves a frozen window nobody sees while authoring.
1585
+ idle(tl, sel, at, span, { deg = 3.6, rise = 9, half = 1.05 } = {}) {
1586
+ tl.to(`${sel} .bob`, { rotation: deg, duration: half, ease: "sine.inOut",
1587
+ repeat: Math.max(1, Math.round(span / half)), yoyo: true,
1588
+ transformOrigin: "50% 100%" }, at);
1589
+ // different node, slightly different period — so the idle never metronomes
1590
+ tl.to(`${sel} .art`, { y: -rise, duration: half * 0.86, ease: "sine.inOut",
1591
+ repeat: Math.max(1, Math.round(span / (half * 0.86))), yoyo: true }, at + 0.2);
1592
+ return tl;
1593
+ },
1594
+
1595
+ // 5. STAMP — 0.18s, power4.out. Slower than this is a fade and carries no weight.
1596
+ stamp(tl, sel, at, { rot = -3 } = {}) {
1597
+ return tl.fromTo(sel, { scale: 2.4, opacity: 0, rotation: rot - 6 },
1598
+ { scale: 1, opacity: 1, rotation: rot, duration: 0.18, ease: "power4.out" }, at);
1599
+ },
1600
+
1601
+ // 6a. DRAW — an SVG route appears. Requires pathLength="1000" on the path.
1602
+ draw(tl, sel, at, { d = 1.1 } = {}) {
1603
+ return tl.to(sel, { strokeDashoffset: 0, duration: d, ease: "power2.inOut" }, at);
1604
+ },
1605
+ // 6b. UNROLL — a menu, a banner, a bar. Same family, different property.
1606
+ unroll(tl, sel, at, { d = 0.5, origin = "50% 0%" } = {}) {
1607
+ return tl.fromTo(sel, { scaleY: 0 },
1608
+ { scaleY: 1, duration: d, ease: "power2.out", transformOrigin: origin }, at);
1609
+ },
1610
+
1611
+ // 7. CAMERA — the stage moves. Once per act, never past 1.35. Give it the
1612
+ // ACT's duration, not a beat's: a camera that stops mid-act hands the pixel
1613
+ // gate a frozen window on any act whose puppets are small.
1614
+ camera(tl, sel, at, { scale = 1.18, x = 0, y = 0, d = 2.4 } = {}) {
1615
+ if (scale > 1.35) throw new Error("camera past 1.35 — the paper grain turns to mush");
1616
+ return tl.to(sel, { scale, x, y, duration: d, ease: "power1.inOut" }, at);
1617
+ },
1618
+
1619
+ // 8. LOCKUP — the promo. Same physics as a STAMP, because it IS one: a
1620
+ // wordmark pressed onto the paper, not a title card fading up.
1621
+ lockup(tl, sel, at) {
1622
+ return tl.fromTo(sel, { scale: 1.9, rotation: -3, opacity: 0 },
1623
+ { scale: 1, rotation: 0, opacity: 1, duration: 0.22, ease: "power4.out" }, at);
1624
+ },
1625
+ };
1626
+ ```
1627
+
1628
+ Used:
1629
+
1630
+ ```js
1631
+ P.enter (tl, "#rig-her", 0.2);
1632
+ P.draw (tl, "#route1", 0.6, { d: 0.45 });
1633
+ P.walk (tl, "#rig-her", 0.8, "M 120 1620 C 260 1440 340 1180 500 1010", { d: 6.0 });
1634
+ P.idle (tl, "#rig-her", 4.2, 3.1);
1635
+ P.crowd (tl, ".queue", 14.6, { each: 0.013 });
1636
+ P.stamp (tl, "#stamp-x", 8.4);
1637
+ P.lockup(tl, "#lockup", 24.2); // the close
1638
+ ```
1639
+
1640
+ ## Appendix C — `story-qa.py` (gate 2: enforce it on the RENDER)
1641
+
1642
+ `vidfarm qa` reads the DOM and cannot see that the timeline never ran. This runs on the pixels that
1643
+ ship, and it is the gate that catches the failure this format actually ships.
1644
+
1645
+ ```bash
1646
+ python3 story-qa.py ./work/out.mp4 --acts 0,3.4,10.4,14.4,21 --duration 29.4
1647
+ ```
1648
+
1649
+ ```python
1650
+ #!/usr/bin/env python3
1651
+ """
1652
+ Grade a rendered sticker story on the pixels, not on the markup.
1653
+
1654
+ Four checks, each one a defect that passes every DOM check and every glance at a
1655
+ single frame, and shows up only across the whole file:
1656
+
1657
+ 1. FROZEN WINDOW — any stretch >2.0s where consecutive frames are identical.
1658
+ This is the format's signature failure: an unregistered
1659
+ timeline, an align:"self" motionPath, or a CDN miss all
1660
+ render a beautiful still that passes duration, frame-count
1661
+ and audio-hash checks.
1662
+ 2. STAGE DRIFT — the parchment must read the same colour in every act. A set
1663
+ piece with a near-paper background rectangle changes its
1664
+ act's cream and is invisible except beside its neighbours.
1665
+ 3. EMPTY BAND — the subtitle band should carry ink for most of the runtime.
1666
+ A band that is blank throughout means the caption layers
1667
+ never rendered, which is silent and total.
1668
+ 4. POPCORN — a frame where too much of the stage changed at once is a
1669
+ frame nobody can read (the "one subject" rule).
1670
+ 5. BAND READABLE — is the caption band on calm ground, or on the drawing with
1671
+ no treatment? Type parked on the map is this format's
1672
+ biggest legibility failure. Two legal fixes: reserve the
1673
+ zone in the plate, or lay a measured paper wash. A plate,
1674
+ a stroke and a text-shadow are all cards (Rule 4).
1675
+ 6. BAND DOMINANCE — the kinetic band's share of the video's total motion. The
1676
+ band is allowed to move (a colour highlight travelling the
1677
+ line) but it may never be the LOUDEST thing on screen. It
1678
+ occupies ~8% of the frame; if it accounts for more than a
1679
+ third of the motion, the captions have taken the spotlight
1680
+ the stage is supposed to hold.
1681
+
1682
+ usage: story-qa.py <render.mp4> [--acts 0,5,14,19,26] [--duration 30]
1683
+ [--band 0.78,0.86] [--fps 6]
1684
+ """
1685
+ import subprocess, sys, os
1686
+
1687
+ src = sys.argv[1]
1688
+ opt = dict(zip(sys.argv[2::2], sys.argv[3::2]))
1689
+ acts = [float(x) for x in opt.get("--acts", "0").split(",")]
1690
+ dur = float(opt.get("--duration", "30"))
1691
+ band = [float(x) for x in opt.get("--band", "0.80,0.884").split(",")]
1692
+ fps = float(opt.get("--fps", "6"))
1693
+
1694
+ fails, warns = [], []
1695
+
1696
+
1697
+ def gray(t, crop=None, size="64:114"):
1698
+ """One frame at t as a small greyscale byte array — no PIL dependency."""
1699
+ vf = (f"crop={crop}," if crop else "") + f"format=gray,scale={size}"
1700
+ return subprocess.run(
1701
+ ["ffmpeg", "-v", "error", "-ss", str(t), "-i", src, "-vf", vf,
1702
+ "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "gray", "-"],
1703
+ capture_output=True).stdout
1704
+
1705
+
1706
+ def rgb(t, crop):
1707
+ raw = subprocess.run(
1708
+ ["ffmpeg", "-v", "error", "-ss", str(t), "-i", src, "-vf",
1709
+ f"crop={crop},scale=1:1", "-frames:v", "1", "-f", "rawvideo",
1710
+ "-pix_fmt", "rgb24", "-"], capture_output=True).stdout
1711
+ return tuple(raw[:3]) if len(raw) >= 3 else (0, 0, 0)
1712
+
1713
+
1714
+ def diff(a, b):
1715
+ if not a or not b or len(a) != len(b):
1716
+ return 1.0
1717
+ return sum(abs(x - y) for x, y in zip(a, b)) / (255.0 * len(a))
1718
+
1719
+
1720
+ # ------------------------------------------------- 1. frozen windows + 4. popcorn
1721
+ step = 1.0 / fps
1722
+ times = [round(i * step, 3) for i in range(int(dur * fps))]
1723
+ frames = [gray(t) for t in times]
1724
+ diffs = [diff(frames[i], frames[i + 1]) for i in range(len(frames) - 1)]
1725
+
1726
+ run_start, DEAD = None, 0.002 # 0.2% mean change = nothing moved
1727
+ for i, d in enumerate(diffs):
1728
+ if d < DEAD:
1729
+ run_start = times[i] if run_start is None else run_start
1730
+ else:
1731
+ if run_start is not None and times[i] - run_start > 2.0:
1732
+ fails.append(f"FROZEN {run_start:.1f}-{times[i]:.1f}s "
1733
+ f"({times[i] - run_start:.1f}s of no motion) — Rule 6, "
1734
+ f"or the timeline never registered")
1735
+ run_start = None
1736
+ if run_start is not None and dur - run_start > 2.0:
1737
+ fails.append(f"FROZEN {run_start:.1f}-{dur:.1f}s — the tail is a still image")
1738
+
1739
+ if diffs and max(diffs) < 0.004:
1740
+ fails.append("NO MOTION ANYWHERE — window.__timelines key almost certainly does "
1741
+ "not match data-composition-id, or motionPath used align:'self'")
1742
+
1743
+ for i, d in enumerate(diffs):
1744
+ if d > 0.16:
1745
+ warns.append(f"POPCORN at {times[i]:.1f}s ({d:.0%} of the stage changed in "
1746
+ f"one step) — more than one subject is moving")
1747
+
1748
+ # ---------------------------------------------------------------- 2. stage drift
1749
+ corners = [rgb(t + 0.4, "60:60:20:20") for t in acts if t + 0.4 < dur]
1750
+ if corners:
1751
+ base = corners[0]
1752
+ for t, c in zip(acts, corners):
1753
+ if max(abs(a - b) for a, b in zip(base, c)) > 4:
1754
+ fails.append(f"STAGE DRIFT at act t={t}: parchment {c} vs {base}")
1755
+
1756
+ # ----------------------------------------------------------------- 3. empty band
1757
+ # Detect a cue by the DARKEST pixel in the strip, not by an inked-area fraction.
1758
+ # Once the band sits on the plate's blank margin (which is where it belongs),
1759
+ # 38px type covers so little of a downsampled strip that an area test reads it
1760
+ # as empty — the area test only ever "worked" because it was counting the map's
1761
+ # own dark features as ink.
1762
+ W, H = 1080, 1920
1763
+ by, bh = int(H * band[0]), int(H * (band[1] - band[0]))
1764
+ # Detect a cue by the AREA of dark pixels, sampled fine enough that glyph cores
1765
+ # survive (540x96; at 64x8 a 38px stem averages away into the paper and every
1766
+ # frame reads as empty). Area, not min-luma: the drawing puts thin dark marks in
1767
+ # the strip too — a route line, a shadow — and one dark pixel is not a caption.
1768
+ # Measured on this build: cue frames 1.5-2.8% dark, gaps 0.5-1.3%.
1769
+ def inked_frac(t):
1770
+ raw = gray(t, crop=f"{W}:{bh}:0:{by}", size="540:96")
1771
+ return (sum(1 for b in raw if b < 110) / len(raw)) if raw else 0.0
1772
+
1773
+
1774
+ INKED = 0.014
1775
+ inked = sum(1 for t in times[::3] if inked_frac(t) > INKED)
1776
+ ratio = inked / max(1, len(times[::3]))
1777
+ if ratio < 0.25:
1778
+ fails.append(f"SUBTITLE BAND is inked in only {ratio:.0%} of samples — "
1779
+ f"the caption layers probably never rendered")
1780
+ elif ratio > 0.95:
1781
+ warns.append(f"SUBTITLE BAND is inked in {ratio:.0%} of samples — the band never "
1782
+ f"rests. Narration is probably wall-to-wall (Rule 7)")
1783
+
1784
+ # --------------------------------------------------- 5. is the band READABLE?
1785
+ # The single biggest legibility failure in this format is type parked on the
1786
+ # drawing. There are exactly two legal fixes and a plate is neither of them:
1787
+ # reserve the zone IN THE STAGE PLATE, or lay a measured paper WASH under the
1788
+ # band (the sheet's own colour, feathered to nothing, no edge/border/shadow).
1789
+ #
1790
+ # So: measure the BARE background on frames where no cue is up (the glyphs
1791
+ # themselves add variation, so a strip with words in it grades your captions,
1792
+ # not your background) — then check whether a wash is actually present, by
1793
+ # looking for a luma LIFT in the same strip on the frames that do carry a cue.
1794
+ import statistics as _st
1795
+
1796
+
1797
+ def _p(v, q):
1798
+ v = sorted(v)
1799
+ return v[min(len(v) - 1, int(len(v) * q))]
1800
+
1801
+
1802
+ bare, inked_lift = [], []
1803
+ for t in times:
1804
+ raw = list(gray(t, crop=f"{W}:{bh}:0:{by}", size="96:12"))
1805
+ if not raw:
1806
+ continue
1807
+ (inked_lift if inked_frac(t) > INKED else bare).append(raw)
1808
+ if not bare or not inked_lift:
1809
+ warns.append("band never sampled both ways (with a cue and without) — grade "
1810
+ "caption placement on the STAGE PLATE instead")
1811
+ else:
1812
+ bare_v = sum(_st.pstdev(r) for r in bare) / len(bare)
1813
+ cue_v = sum(_st.pstdev(r) for r in inked_lift) / len(inked_lift)
1814
+ # A wash FLATTENS the strip; it does not brighten it (the paper is already
1815
+ # near-white, so p75 does not move — measured 228 either way on this build).
1816
+ # So the signature of a treatment is cue_v dropping well BELOW bare_v, even
1817
+ # though the glyphs themselves push variation up.
1818
+ treated = cue_v < bare_v - 4
1819
+ if bare_v > 14 and not treated:
1820
+ (fails if bare_v > 22 else warns).append(
1821
+ f"BAND ON THE DRAWING — variation {bare_v:.0f} behind the caption "
1822
+ f"band (calm is <14) and no treatment detected. Reserve the zone in "
1823
+ f"the stage plate, or lay a measured paper wash. Never a plate")
1824
+ elif cue_v > 24:
1825
+ fails.append(f"BAND STILL BUSY — {cue_v:.0f} variation with the treatment "
1826
+ f"applied. Strengthen the wash or move the band")
1827
+ elif treated:
1828
+ print(f" · band background {bare_v:.0f} bare -> {cue_v:.0f} with the "
1829
+ f"paper wash (readable)")
1830
+ else:
1831
+ print(f" · band background variation {bare_v:.0f} (calm, no wash needed)")
1832
+
1833
+ # ------------------------------------------------------------- 6. band dominance
1834
+ # Measured on the SAME sample grid, band strip vs the stage above it. Colour-only
1835
+ # kinetics land around 5-15%; anything geometric (a word popping, sliding or
1836
+ # scaling) pushes it past 35% immediately, which is the whole point of the check.
1837
+ band_d, stage_d = [], []
1838
+ prev_b = prev_s = None
1839
+ for t in times:
1840
+ b = gray(t, crop=f"{W}:{bh}:0:{by}", size="64:16")
1841
+ st = gray(t, crop=f"{W}:{by}:0:0", size="64:96")
1842
+ if prev_b is not None:
1843
+ band_d.append(diff(prev_b, b) * bh) # weight by the area each covers
1844
+ stage_d.append(diff(prev_s, st) * by)
1845
+ prev_b, prev_s = b, st
1846
+ tot = sum(band_d) + sum(stage_d)
1847
+ if tot > 0:
1848
+ share = sum(band_d) / tot
1849
+ if share > 0.35:
1850
+ fails.append(f"BAND DOMINANCE {share:.0%} of all motion is in the caption "
1851
+ f"band — the captions are the loudest thing on screen. "
1852
+ f"Kinetics must be COLOUR ONLY: no word pop, slide or scale")
1853
+ elif share > 0.22:
1854
+ warns.append(f"band carries {share:.0%} of the motion — near the ceiling")
1855
+ else:
1856
+ print(f" · band motion share {share:.0%} (ceiling 35%)")
1857
+
1858
+ # --------------------------------------------------------------------- verdict
1859
+ for f in fails:
1860
+ print(f" ✗ {f}")
1861
+ for w in warns:
1862
+ print(f" ⚠ {w}")
1863
+ print(f"\n{len(times)} samples · {len(fails)} fail · {len(warns)} warn")
1864
+ print("\n▶ NOW OPEN THE CONTACT SHEET. This checked motion, not whether it reads as one theater.")
1865
+ sys.exit(1 if fails else 0)
1866
+ ```
1867
+
1868
+ ## Appendix D — a worked example: dishcover.io
1869
+
1870
+ Five acts, **29.4s**. Cost mode `hybrid`. Art class: **paper cutout**, four generated sheets.
1871
+
1872
+ **Build log header**
1873
+
1874
+ ```
1875
+ STORY: One craving, one city, forty wasted minutes, and the turn
1876
+ WATCHER: Someone in NYC who eats out twice a week and has had this exact evening
1877
+ OFFER: dishcover.io — 140 restaurants, 7,502 dishes, searched by DISH not by restaurant
1878
+ THE TURN: the whole city is indexed by restaurant; nobody indexes it by dinner
1879
+ PROMO: END (default) — act 4 shows the mechanism unbranded, act 5 names it last
1880
+ ART CLASS: paper cutout, one style string, four sheets (stage / cast / props / dishes)
1881
+ PACE: atempo 1.30 on every narration clip
1882
+ COST: hybrid — 5 image jobs + 7 tts clips on a BYOK gemini key, ~$0.25
1883
+ ```
1884
+
1885
+ **Narration** (66 words, one voice, one style string, `atempo=1.30`)
1886
+
1887
+ ```
1888
+ a1 (0.15s) It's seven, and you know exactly what you want.
1889
+ a2 (3.55s) So you check one place. Then another. Then four more.
1890
+ None of them are searchable.
1891
+ a3 (10.55s) Forty minutes later, you're eating something you didn't choose.
1892
+ a4a (14.55s) The whole city is indexed by restaurant.
1893
+ [1.1s hole — the map re-draws with nobody talking over it]
1894
+ a4b (18.05s) Nobody indexes it by dinner.
1895
+ a5 (21.20s) So search the dish, not the restaurant.
1896
+ Dishcover dot io. Seven thousand dishes, one search.
1897
+ ```
1898
+
1899
+ The offer is named **once, in act 5, last**. Act 4 carries the mechanism and no brand name at all.
1900
+
1901
+ **The stage, act by act**
1902
+
1903
+ | Act | s | On the paper | The moves |
1904
+ |---|---|---|---|
1905
+ | 1 | 0.0–3.4 | Parchment Manhattan, empty. One figure enters lower left. A bowl of vodka rigatoni pops above her | ENTER, IDLE, CAMERA |
1906
+ | 2 | 3.4–10.4 | Four storefronts appear on a **1.45s cadence** along her route; a menu unrolls at each; a red X stamps on each. The route draws ahead of her. `40 MINUTES` stamps | DRAW, WALK, UNROLL, STAMP ×5, CAMERA |
1907
+ | 3 | 10.4–14.4 | The stage empties. One seated figure, one grey sandwich — and two ambient walkers crossing behind, so the quiet act is not a still | ENTER, IDLE, WALK ×2, CAMERA |
1908
+ | 4 | 14.4–21.0 | The map RE-DRAWS: 24 pins for the 140 restaurants land in one gust, then 30 dish marks for the 7,502. They dim. ONE dashed route runs straight to one dish. `7,502 DISHES` stamps | CROWD ×2, DRAW, STAMP, CAMERA |
1909
+ | 5 | 21.0–29.4 | She walks that one route, the rigatoni is in colour — then **`dishcover.io` stamps onto the paper** and holds to the last frame | WALK, ENTER, IDLE, **LOCKUP** |
1910
+
1911
+ **Act 4 read against the lock**
1912
+
1913
+ - *Silent-legible?* The island filling with dishes and one straight route is the whole mechanism with
1914
+ the sound off. Passes.
1915
+ - *One surface?* No cuts. The re-draw happens on the same parchment. Passes.
1916
+ - *Die-cut?* Every mark is SVG or a snug PNG; the plate is the only rectangle. Passes.
1917
+
1918
+ **Post caption** (the topic, the bait and the credits live here, never on the stage)
1919
+
1920
+ ```
1921
+ 40 minutes to find one dish in a city that has 7,502 of them 🍝
1922
+
1923
+ what's the dish you'd search for first?
1924
+ ```
1925
+
1926
+ **Build and export**
1927
+
1928
+ ```bash
1929
+ # 1. the art — FOUR image jobs, one style string, ~$0.20
1930
+ npx -y dotenv-cli -e .env -- vidfarm generate image --prompt "<parchment map, 9:16>" --aspect-ratio 9:16
1931
+ npx -y dotenv-cli -e .env -- vidfarm sticker-pack --generate "<STYLE>" --items "…" --prefix cast --out-dir media/stickers
1932
+ npx -y dotenv-cli -e .env -- vidfarm sticker-pack --generate "<STYLE>" --items "…" --prefix prop --out-dir media/stickers
1933
+ npx -y dotenv-cli -e .env -- vidfarm sticker-pack --generate "<STYLE>" --items "…" --prefix dish --out-dir media/stickers
1934
+ # delete the whole-sheet artifact, rename the rest against the picture
1935
+
1936
+ # 2. the voice — one clip per act, one pinned voice
1937
+ for i in 1 2 3 4 5; do
1938
+ npx -y dotenv-cli -e .env -- vidfarm tts "$(cat vo/a$i.txt)" --voice Leda \
1939
+ --style "flat documentary narrator, unhurried" --out vo/a$i.mp3
1940
+ # trim the TTS padding, speed it up, normalise — then RE-MEASURE and paste the
1941
+ # real durations into the act table. Slow is this format's shipping failure.
1942
+ ffmpeg -i vo/a$i.mp3 -af "silenceremove=start_periods=1:start_threshold=-45dB:\
1943
+ start_silence=0.05,areverse,silenceremove=start_periods=1:start_threshold=-45dB:\
1944
+ start_silence=0.05,areverse,atempo=1.30,loudnorm=I=-16:TP=-1.5" vo/a$i-t.mp3 -y
1945
+ ffprobe -v error -show_entries format=duration -of csv=p=0 vo/a$i-t.mp3
1946
+ done
1947
+
1948
+ # 3. the rig — hand-authored, Appendix A + Appendix B
1949
+ vidfarm lint ./work # gate 0 — expect scripts_stripped_on_save
1950
+ vidfarm qa ./work --harness ./experimental/animated-sticker-story.md # gate 1
1951
+
1952
+ # 4. the render — local, free, ~5s per 6s of 1080x1920
1953
+ vidfarm hf render ./work --output ./work/out.mp4
1954
+
1955
+ # 5. the pixels — gate 2, the one that catches a rendered still
1956
+ python3 story-qa.py ./work/out.mp4 --acts 0,3.4,10.4,14.4,21 --duration 29.4
1957
+ vidfarm hf snapshot ./work --at 1,3,5,7,9,11,14,16,19,23,27 -o ./stills --describe false
1958
+ open ./stills/contact-sheet.png # gate 3 — the pass that actually matters
1959
+ ```
1960
+
1961
+ ⚠️ **`vidfarm render --dir <dir>` refuses a local directory** with *"render requires a fork id (or a
1962
+ --dir with composition.html)"* even when `composition.html` is sitting in it. Do not go hunting: the
1963
+ hyperframes passthrough is the same renderer and it works — `vidfarm hf render <dir> --output <file>`.
1964
+ Keep both filenames on disk regardless, because the two halves of the toolchain disagree:
1965
+
1966
+ | Tool | Wants |
1967
+ |---|---|
1968
+ | `vidfarm qa` / `vidfarm stills` / `vidfarm lint` | `composition.html` |
1969
+ | `vidfarm hf …` (the passthrough) | `index.html` |
1970
+
1971
+ A symlink is not enough — some passes resolve it and some do not. **Write both files.**
1972
+
1973
+ ### What the first pass actually got wrong
1974
+
1975
+ Recorded because it is what this format gets wrong, and every item below rendered cleanly, exited 0,
1976
+ and produced a file of exactly the right length.
1977
+
1978
+ 1. **Both walkers were off-canvas.** `puppet()` baked `left`/`top` on a rig that was then given a
1979
+ motionPath, so `left:300; top:1560` plus a path starting `M 300 1560` put her at `(600, 3120)`.
1980
+ Acts 2 and 5 had no protagonist in them. Every computed style said `visible`. Fixed by splitting
1981
+ the constructor into `puppet()` and `walker()`.
1982
+ 2. **Four frozen windows, 14.7s total** — gate 2 caught all four. The BEATs were real tweens at
1983
+ ±2.4°, which is below the threshold at which a viewer or a pixel gate reads motion. Fixed by
1984
+ raising to 3.6–6°, sizing every idle to fill its hold, and walking two ambient figures through
1985
+ act 3.
1986
+ 3. **A pink halo on every prop.** The generated stickers had baked drop shadows, which blend into
1987
+ the chroma plate and survive every tolerance, key mode and matting model. Fixed for the shipped
1988
+ build with `deshalo.sh`, and fixed properly by generating without shadows and adding one CSS
1989
+ `drop-shadow` to `.art`.
1990
+ 4. **The prop and dish sheets refused to split** — perfect zoned grids, one 988×988 "sticker" out.
1991
+ Fixed by cropping the grid locally and keying each cell against its own plate colour.
1992
+ 5. **The `40 MINUTES` stamp landed on the walker's head** at the end of act 2, because the route's
1993
+ last point was high in the frame and the stamp sat at 17%. Fixed by lowering the four stops, not
1994
+ by moving the stamp — the stamp's band is fixed and the puppets move.
1995
+ 6. **A `scale: 1.16` camera on act 2 drifted the storefronts off the island** and onto the river.
1996
+ Only visible at full zoom, invisible in the unzoomed authoring view. Pulled back to 1.09.
1997
+ 7. **The loud cue was 10 words** and failed `hook_words_max`. Fixed by shortening the *narration*
1998
+ and re-recording act 1 — not by trimming the cue, which would have desynced the band from the
1999
+ voice.
2000
+
2001
+ 8. **It was 37.4s and it was boring.** Every gate passed and the video was still too slow to finish
2002
+ — the defect no check in this file could see at the time, which is why the *Pace* section and the
2003
+ `atempo` rule now exist. Fixed by raising the narration to `atempo=1.30`, cutting one clause from
2004
+ act 2 and three words from act 5, and tightening act 2's failure cadence from 2.0s to 1.45s:
2005
+ **37.4s → 29.4s**, same story, same assets, no re-generation.
2006
+ 9. **The offer was named in act 4 and the video ended on a resolve with no brand in it.** Beautiful,
2007
+ and it sold nothing. Restructured: act 4 shows the mechanism unbranded, act 5 pays the want and
2008
+ stamps the wordmark. The promo is now the close by default.
2009
+
2010
+ **Measured result:** gate 0 clean (1 expected warning), gate 1 **23/23** machine checks, gate 2
2011
+ **0 fail / 1 expected warning**, audio `mean -17.6 dB / peak -0.6 dBFS`, **29.4s**, render 31s local.
2012
+
2013
+ ## Appendix E — the harness in one paragraph, for a handoff
2014
+
2015
+ > Build a 30s narrated sticker story, 9:16, 1080×1920, on ONE aged-parchment stage that never
2016
+ > changes — five butt-cut act clips sharing an identical plate, no transitions, no footage, no cuts.
2017
+ > Cast is die-cut paper-collage stickers from four generated sheets (stage / cast / props / hero),
2018
+ > all four generated with the SAME art style string, muted earthy palette, six colours total. Every
2019
+ > puppet is a three-node rig (travel / bob / art) animated by ONE paused GSAP 3.14.2 timeline
2020
+ > registered on `window.__timelines` under the composition id, with MotionPathPlugin for walks —
2021
+ > paths in absolute canvas coordinates, never `align:"self"`. Seven moves only: ENTER, WALK, CROWD,
2022
+ > BEAT, STAMP, DRAW, CAMERA. Narration is one 55–90 word paragraph, one voice, one clip per act, with
2023
+ > one deliberate hole where the stage talks alone; subtitles stay SMALL (38px, weight 500, one line,
2024
+ > ≤8 words, band at 78–86%) except the first cue, which is loud at 76px/800 and never returns. The
2025
+ > band is painted from the stage's own palette — ink one step lighter than the drawing, highlight in
2026
+ > the exact stamp red — and it MAY be kinetic: one ember travelling the line on whisper word timings,
2027
+ > animating COLOUR ONLY. No translate, scale, rotate or font-weight on a word, ever; gate 2 measures
2028
+ > the band's share of total motion and fails it past 35%. The
2029
+ > offer is named ONCE and LAST: act 4 re-draws the stage into the mechanism with no brand name on it,
2030
+ > and act 5 pays the want and then stamps the offer's wordmark onto the paper in the stage's own ink
2031
+ > — no logo, no URL, no button, no card, nothing after it. Narration runs at atempo 1.30; slow is the
2032
+ > failure this format ships. GSAP is vendored locally; the composition is DESKTOP-ONLY and must not be opened in the web
2033
+ > editor, which strips scripts. Render with `vidfarm hf render`, then run the pixel gate — a
2034
+ > composition whose timeline never registered renders a beautiful still and passes every other check.
2035
+ > Full rules: `vidfarm.cc/experimental/animated-sticker-story.md`.
2036
+
2037
+ ## Appendix F — the two asset scripts
2038
+
2039
+ Both are free, local and ffmpeg-only. Both exist because a generated sheet is right and the
2040
+ automatic split is wrong, which is a failure mode you will hit and should not spend a day on.
2041
+
2042
+ ### `split-sheet.sh` — cut a colour-block sheet the keyer refused to split
2043
+
2044
+ ```bash
2045
+ #!/usr/bin/env bash
2046
+ # Split a generated COLOUR-BLOCK sticker sheet into cells and key each one locally.
2047
+ #
2048
+ # Use this when the image model returned a perfectly good zoned grid but
2049
+ # `vidfarm sticker-pack` refuses to split it — alternating panel colours defeat
2050
+ # the single-plate keyer, so it re-keys the whole sheet as one plate and hands
2051
+ # back the sheet as "one sticker".
2052
+ #
2053
+ # Free: ffmpeg crop + `vidfarm mask --flat`, which chroma-keys each cell against
2054
+ # ITS OWN plate colour, sampled from that cell's own corner. ONNX matting is the
2055
+ # wrong tool here — it treats a flat saturated plate as part of the subject.
2056
+ #
2057
+ # usage: split-sheet.sh <sheet.png> <cols> <rows> <outdir> <name1,name2,...>
2058
+ set -euo pipefail
2059
+ SHEET="$1"; COLS="$2"; ROWS="$3"; OUT="$4"; IFS=',' read -ra NAMES <<< "$5"
2060
+
2061
+ W=$(ffprobe -v error -select_streams v -show_entries stream=width -of csv=p=0 "$SHEET")
2062
+ H=$(ffprobe -v error -select_streams v -show_entries stream=height -of csv=p=0 "$SHEET")
2063
+ CW=$((W / COLS)); CH=$((H / ROWS)); mkdir -p "$OUT"; i=0
2064
+
2065
+ for ((r=0; r<ROWS; r++)); do for ((c=0; c<COLS; c++)); do
2066
+ n="${NAMES[$i]:-item$i}"; i=$((i+1))
2067
+ # inset 4% so a neighbouring panel's colour never enters the crop
2068
+ IX=$((CW * 4 / 100)); IY=$((CH * 4 / 100))
2069
+ CELL="$OUT/_cell-$n.png"
2070
+ ffmpeg -v error -i "$SHEET" \
2071
+ -vf "crop=$((CW-2*IX)):$((CH-2*IY)):$((c*CW+IX)):$((r*CH+IY))" \
2072
+ -frames:v 1 "$CELL" -y
2073
+ # this cell's own plate colour, read off its top-left corner
2074
+ HEX=$(ffmpeg -v error -i "$CELL" -vf "crop=8:8:0:0,scale=1:1" -frames:v 1 \
2075
+ -f rawvideo -pix_fmt rgb24 - | xxd -p | head -c 6)
2076
+ if vidfarm mask "$CELL" --flat "#$HEX" --tolerance 0.10 --out "$OUT/$n.png" >/dev/null 2>&1; then
2077
+ echo " ✓ $n (plate #$HEX)"
2078
+ else
2079
+ echo " ✗ $n (plate #$HEX)"
2080
+ fi
2081
+ rm -f "$CELL"
2082
+ done; done
2083
+ ```
2084
+
2085
+ The ONNX twin, for organic subjects — same crop, `vidfarm remove-background` per cell instead of
2086
+ `vidfarm mask --flat`. Keep both and pick per sheet: flat graphics chroma-key, food and people matte.
2087
+
2088
+ ```bash
2089
+ #!/usr/bin/env bash
2090
+ # Same grid split, but matte each cell with local ONNX instead of chroma-keying it.
2091
+ # usage: split-onnx.sh <sheet.png> <cols> <rows> <outdir> <names,...>
2092
+ set -euo pipefail
2093
+ SHEET="$1"; COLS="$2"; ROWS="$3"; OUT="$4"; IFS=',' read -ra NAMES <<< "$5"
2094
+ W=$(ffprobe -v error -select_streams v -show_entries stream=width -of csv=p=0 "$SHEET")
2095
+ H=$(ffprobe -v error -select_streams v -show_entries stream=height -of csv=p=0 "$SHEET")
2096
+ CW=$((W / COLS)); CH=$((H / ROWS)); mkdir -p "$OUT"; i=0
2097
+ for ((r=0; r<ROWS; r++)); do for ((c=0; c<COLS; c++)); do
2098
+ n="${NAMES[$i]:-item$i}"; i=$((i+1))
2099
+ IX=$((CW * 4 / 100)); IY=$((CH * 4 / 100)); CELL="$OUT/_cell-$n.png"
2100
+ ffmpeg -v error -i "$SHEET" -vf "crop=$((CW-2*IX)):$((CH-2*IY)):$((c*CW+IX)):$((r*CH+IY))" -frames:v 1 "$CELL" -y
2101
+ vidfarm remove-background "$CELL" --out "$OUT/$n.png" >/dev/null 2>&1 && echo " ✓ $n" || echo " ✗ $n"
2102
+ rm -f "$CELL"
2103
+ done; done
2104
+ ```
2105
+
2106
+ ### `place-text.py` — measure the stage, then place the type
2107
+
2108
+ The second pass of the two-pass build. Run it between `build.py --no-text` and `build.py`.
2109
+
2110
+ ```bash
2111
+ python3 build.py v001 --no-text && python3 place-text.py v001 && python3 build.py v001
2112
+ ```
2113
+
2114
+ ```python
2115
+ import json, os, statistics, subprocess, sys
2116
+
2117
+ DIR = sys.argv[1] if len(sys.argv) > 1 else "v001"
2118
+ W, H = 1080, 1920
2119
+
2120
+ # zone -> (allowed top range %, box height %). The halves are fixed so the layout
2121
+ # stays stable: the hook and the promo live above the stage's midline, the band
2122
+ # below it. Only the exact position inside each half is measured.
2123
+ ZONES = {
2124
+ "loud": ((12, 44), 13.0),
2125
+ "band": ((66, 87), 9.0),
2126
+ # the lockup may take the upper half too: the loud cue vacated it 20s earlier
2127
+ "lockup": ((12, 72), 12.0),
2128
+ }
2129
+
2130
+ # variation behind the type -> the lightest treatment that survives it.
2131
+ # A PLATE is banned (Rule 4). A paper WASH is not a plate: it is the sheet's own
2132
+ # colour, feathered to nothing at both edges, with no border, corner or shadow —
2133
+ # the paper simply being lighter where the words are, like a documentary margin.
2134
+ def wash_for(v):
2135
+ if v < 14:
2136
+ return 0.0, "bare paper — no treatment"
2137
+ if v < 20:
2138
+ return 0.55, "light wash"
2139
+ if v < 26:
2140
+ return 0.70, "wash"
2141
+ return 0.82, "strong wash — consider moving the band instead"
2142
+
2143
+
2144
+ def snap(times):
2145
+ out = os.path.join(DIR, "stage-frames")
2146
+ subprocess.run(["vidfarm", "hf", "snapshot", DIR, "--at", ",".join(map(str, times)),
2147
+ "--no-end", "-o", out, "--describe", "false"],
2148
+ capture_output=True)
2149
+ pngs = sorted(f for f in os.listdir(out) if f.endswith(".png")
2150
+ and "sheet" not in f)
2151
+ return [os.path.join(out, f) for f in pngs]
2152
+
2153
+
2154
+ def strip(png, top_pct, h_pct):
2155
+ raw = subprocess.run(
2156
+ ["ffmpeg", "-v", "error", "-i", png, "-vf",
2157
+ f"crop={W}:{int(h_pct / 100 * H)}:0:{int(top_pct / 100 * H)},"
2158
+ f"format=gray,scale=96:12", "-frames:v", "1", "-f", "rawvideo",
2159
+ "-pix_fmt", "gray", "-"], capture_output=True).stdout
2160
+ v = list(raw)
2161
+ return (statistics.mean(v), statistics.pstdev(v)) if v else (0, 99)
2162
+
2163
+
2164
+ def paper_colour(png):
2165
+ """The sheet's own lightest paper, for the wash. Never an invented cream."""
2166
+ raw = subprocess.run(
2167
+ ["ffmpeg", "-v", "error", "-i", png, "-vf", "scale=32:56", "-frames:v", "1",
2168
+ "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True).stdout
2169
+ px = [tuple(raw[i:i + 3]) for i in range(0, len(raw) - 2, 3)]
2170
+ px.sort(key=lambda c: sum(c))
2171
+ hi = px[int(len(px) * 0.93)] # bright, but not a specular
2172
+ return ",".join(str(c) for c in hi)
2173
+
2174
+
2175
+ cues = json.load(open(os.path.join(DIR, "cues.json")))
2176
+ times = sorted({round((c["t0"] + c["t1"]) / 2, 2) for c in cues})
2177
+ frames = snap(times)
2178
+ if len(frames) != len(times):
2179
+ sys.exit(f"snapshot returned {len(frames)} frames for {len(times)} cues")
2180
+ at = dict(zip(times, frames))
2181
+
2182
+ place = {"_paper": paper_colour(frames[0])}
2183
+ for zone, ((lo, hi), boxh) in ZONES.items():
2184
+ mine = [c for c in cues if c["kind"] == zone]
2185
+ if not mine:
2186
+ continue
2187
+ pngs = [at[round((c["t0"] + c["t1"]) / 2, 2)] for c in mine]
2188
+ best = None
2189
+ for top in range(lo, int(hi - boxh) + 1):
2190
+ worst = max(strip(p, top, boxh)[1] for p in pngs)
2191
+ if best is None or worst < best[0]:
2192
+ best = (worst, top)
2193
+ worst, top = best
2194
+ wash, label = wash_for(worst)
2195
+ place[zone] = {"top": float(top), "wash": wash}
2196
+ print(f" {zone:7s} top {top:2d}% worst variation {worst:5.1f} -> {label}"
2197
+ f"{'' if not wash else f' ({wash:.2f})'}")
2198
+
2199
+ json.dump(place, open("place.json", "w"), indent=2)
2200
+ print(f"\npaper {place['_paper']} · wrote place.json")
2201
+ print("▶ now rebuild WITH text: python3 build.py " + DIR)
2202
+ ```
2203
+
2204
+ ### `band-measure.py` — the quick probe
2205
+
2206
+ `place-text.py` is the tool that decides. This is the one you reach for when you just want to look at
2207
+ a number: what is behind the band, on this plate or on this frame, right now?
2208
+
2209
+ ```bash
2210
+ python3 band-measure.py media/stage-map.png # the plate, before you build
2211
+ python3 band-measure.py v001/out.mp4 12.5 # the render, at a given second
2212
+ ```
2213
+
2214
+ ```python
2215
+ import subprocess, sys, statistics
2216
+ src = sys.argv[1]; t = sys.argv[2] if len(sys.argv) > 2 else None
2217
+ W, H = [int(x) for x in subprocess.run(["ffprobe","-v","error","-select_streams","v",
2218
+ "-show_entries","stream=width,height","-of","csv=p=0",src],
2219
+ capture_output=True, text=True).stdout.strip().split(",")[:2]]
2220
+ def strip(y0, y1, label):
2221
+ pre = ["-ss", t] if t else []
2222
+ raw = subprocess.run(["ffmpeg", "-v", "error", *pre, "-i", src, "-vf",
2223
+ f"crop={W}:{int((y1-y0)*H)}:0:{int(y0*H)},format=gray,scale=96:12",
2224
+ "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "gray", "-"],
2225
+ capture_output=True).stdout
2226
+ v = list(raw)
2227
+ print(f" {label:22s} luma {statistics.mean(v):6.1f} variation "
2228
+ f"{statistics.pstdev(v):5.1f} darkest {min(v):3d}")
2229
+ print(src + (f" @ {t}s" if t else ""))
2230
+ for a, b, n in [(0.08,0.20,"top margin 8-20%"),(0.37,0.47,"mid 37-47%"),
2231
+ (0.76,0.87,"band 76-87%"),(0.60,0.72,"lockup 60-72%")]:
2232
+ strip(a, b, n)
2233
+ ```
2234
+
2235
+ ### `deshalo.sh` — remove a coloured halo that is already in your assets
2236
+
2237
+ ```bash
2238
+ #!/usr/bin/env bash
2239
+ # Kill the soft coloured halo a chroma key leaves around a die-cut sticker.
2240
+ # The halo IS the sticker's own drop shadow blended with the plate, so it is baked
2241
+ # into the pixels and no tolerance setting removes it. What removes it is throwing
2242
+ # away every PARTIAL-alpha pixel: hard-threshold the alpha, then erode 1px.
2243
+ # Safe on flat/vector art. Do NOT run it on soft, furry or glassy subjects.
2244
+ # usage: deshalo.sh <dir> [threshold 0-255, default 215]
2245
+ set -euo pipefail
2246
+ D="$1"; T="${2:-215}"
2247
+ for f in "$D"/*.png; do
2248
+ ffmpeg -v error -i "$f" -filter_complex \
2249
+ "[0:v]alphaextract,lut=y='if(gt(val,$T),255,0)',erosion[a];[0:v][a]alphamerge" \
2250
+ -frames:v 1 "$f.tmp.png" -y && mv "$f.tmp.png" "$f" && echo " ✓ $(basename "$f")"
2251
+ done
2252
+ ```