@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.
- package/.agents/skills/vidfarm/SKILL.md +65 -0
- package/.agents/skills/vidfarm/harnesses/README.md +26 -0
- package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-harness.md +2 -1
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +2 -2
- package/SKILL.director.md +69 -3
- package/SKILL.md +76 -4
- package/clipper.md +40 -6
- package/dist/src/cli.js +104 -11
- package/dist/src/devcli/clipper-run.js +941 -0
- package/dist/src/devcli/doctor.js +23 -1
- package/dist/src/devcli/harness.js +60 -15
- package/dist/src/devcli/marketplace-gigs.js +545 -39
- package/dist/src/devcli/skill-docs.js +2 -1
- package/dist/src/devcli/update-check.js +340 -0
- package/experimental/animated-sticker-story.md +2252 -0
- package/experimental/flash-harness.md +549 -0
- package/experimental/google-news-to-video.md +9 -0
- package/experimental/unique-product-explainers.md +8 -0
- package/marketplace.md +549 -14
- package/package.json +6 -2
- package/public/serve-shells/editor.html +27 -0
- package/public/serve-shells/library-files.html +28 -1
- package/public/serve-shells/library-raws.html +27 -0
- package/public/serve-shells/tools-clipper.html +27 -0
- package/public/serve-shells/tools-image.html +27 -0
- package/public/serve-shells/tools-video.html +27 -0
- package/update.md +12 -2
|
@@ -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
|
+
```
|