@officexapp/vidfarm-devcli 0.21.51 → 0.21.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1756 @@
1
+ ---
2
+ name: meme-recaption
3
+ video_type: Meme recaption — one borrowed meme clip, one new caption, aimed at the problem space of the offer (TikTok / Reels / Shorts)
4
+ checks:
5
+ duration_sec: 4-15
6
+ aspect: 9:16
7
+ first_frame_visual: required
8
+ first_frame_text: required
9
+ text_by_sec: 0 # the caption IS the video. It is up at frame 0 or the format did not happen
10
+ captions: optional # the caption is a static CARD, not a subtitle track. See "Typography DNA"
11
+ audio: required # the meme's own audio is usually the punchline — see "Audio DNA"
12
+ font_regime: required
13
+ safe_zone: required
14
+ scenes: 1-2
15
+ max_scene_sec: 15
16
+ max_text_cards: 3 # the caption, the offer handle, and (rarely) a second-beat turn
17
+ max_simultaneous_text: 2
18
+ max_words_per_cue: 22 # a meme caption is ONE read, not a paged subtitle
19
+ max_dead_air_sec: 15 # there is no dead air in a meme — a hold IS the joke
20
+ max_tail_sec: 0.8
21
+ forbid_text:
22
+ - link in bio
23
+ - sign up
24
+ - free trial
25
+ - get started
26
+ - book a demo
27
+ - download now
28
+ - learn more
29
+ # the bait lives in the POST caption, never on the meme:
30
+ - comment below
31
+ ---
32
+
33
+ # Meme Recaption
34
+
35
+ > A public Vidfarm prompt. Read it end to end before you build. It assumes the offer has a
36
+ > **problem space** — a recurring, annoying, nameable experience the product removes. If it does
37
+ > not, this format has nothing to write about and you should use `ugc-reaction-greenscreen`.
38
+
39
+ You are not making an ad. You are making **a meme that happens to be about the thing the product
40
+ fixes.** The distinction is the whole file. A meme that is secretly an ad gets read as an ad in
41
+ about 400ms and dies. A meme that is actually a meme gets shared by people who have never heard of
42
+ the product, and a fraction of them go looking.
43
+
44
+ Two ingredients, no more:
45
+
46
+ | Ingredient | What it is | Where it comes from |
47
+ |---|---|---|
48
+ | 🎬 **The meme** | A borrowed clip everyone already half-recognises, doing one absurd physical thing | `vidfarm.cc/explore` → public raws, `sourceType: MemeScreens` (851 of them, once you page past what the CLI shows you) |
49
+ | ✍️ **The caption** | One sentence naming a specific lived experience, in the second person or the first | You. This is the only original work in the video, and it is 90% of the outcome |
50
+
51
+ **The offer is named on the video.** First choice: inside the caption, as a character in the setup
52
+ (`dishcover.io watching me open 10 tabs since 7pm…`). Fallback when that will not read elegantly: a
53
+ small dimmed handle under the caption plate. Rule 1 is the whole decision.
54
+
55
+ **What the offer never is: the answer.** No logo, no screenshot, no feature, no CTA, and never in
56
+ the caption's final clause. The product can stand in the joke; it cannot end it. The *experience the
57
+ caption names* is the one the product removes, and that stays implicit — that is the format's entire
58
+ mechanism, and every failure mode below is a variation on breaking it.
59
+
60
+ ## The one test — the three-way lock
61
+
62
+ Three questions. **All three must be yes** or you have not got a meme recaption yet.
63
+
64
+ | # | Question | If no |
65
+ |---|---|---|
66
+ | 1 | **Standalone** — Show it to someone who has never heard of the offer. Do they laugh? | You wrote an ad with a meme stapled on |
67
+ | 2 | **Problem space** — Is the experience it names one the product actually removes? | You wrote a meme. Funny, worthless. Post it from a personal account |
68
+ | 3 | **Enactment** — Is the meme's *physical action* the literal body of the feeling the caption names? | You wrote a caption over stock footage |
69
+
70
+ Test 3 is the one editors skip, and it is the one that separates a meme that lands from a caption
71
+ sitting on top of a clip. The meme is not an illustration of the caption. **The meme is what the
72
+ caption's subject is doing with their body.** If the caption says "quietly giving up" and the clip
73
+ is someone dancing, the joke has no floor, and no amount of caption polish will give it one.
74
+
75
+ ---
76
+
77
+ ## Part 0 — who this is for
78
+
79
+ **Not the customer's buyer in general — the customer's buyer at one specific bad moment.** Before
80
+ you write anything, put two lines at the top of your build log:
81
+
82
+ ```
83
+ WHO: <the person, in six words>
84
+ MOMENT: <the exact 30 seconds of their life the product exists to delete>
85
+ ```
86
+
87
+ Everything below is decided by the second line. A meme recaption is a **compressed complaint**, and
88
+ you cannot compress a complaint you have not located.
89
+
90
+ - **Register:** a peer, posting at 11pm, mildly annoyed, not selling anything.
91
+ - **Never a brand voice.** No "we", no "introducing", no feature words. The offer's *name* may be in
92
+ the caption as a character (Rule 1); brand *voice* never is.
93
+ - **The joke is on the shared situation, or on you. Never on the viewer**, and never on a named
94
+ real competitor.
95
+
96
+ ---
97
+
98
+ ## Structural DNA — there are no beats
99
+
100
+ This is the rule every agent breaks, so it goes first and in bold.
101
+
102
+ > **A meme recaption is ONE shot with ONE caption, and both are on screen at frame 0.**
103
+
104
+ No hook beat, no concession beat, no payoff beat, no bait card. No cuts. No build. The four charges
105
+ (hook / loop / payoff / bait) still exist, but they **fire simultaneously**, not in series:
106
+
107
+ | Charge | Where it lives in a meme recaption |
108
+ |---|---|
109
+ | 🪝 Hook | The caption's first three words, plus the fact that the visual is instantly weird |
110
+ | 🔄 Loop | The ~0.8s gap between finishing the caption and the clip's action landing. That is the entire loop |
111
+ | 😍 Payoff | Recognition — "this is literally me". Not a product reveal. There is no product |
112
+ | 🎣 Bait | **The post caption, not the video.** An on-screen "comment below" turns a meme back into an ad. The handle is attribution, not bait |
113
+
114
+ The read order the viewer actually performs, in about 1.2 seconds:
115
+
116
+ 1. **Frame 0** — sees a weird image, starts reading the caption on reflex.
117
+ 2. **~0.6s** — finishes the caption, now holds a specific situation in their head.
118
+ 3. **~1.0s** — the clip's action lands and *is* that situation. This is the laugh.
119
+ 4. **The rest** — they are already deciding whether to send it to someone.
120
+
121
+ **So the clip's funniest frame must not be frame 0.** If the meme's peak action happens
122
+ immediately, the caption has not been read yet and the joke fires into an empty room. Trim the clip
123
+ so there is **0.6–1.0s of the subject existing** before the action, and no more than that.
124
+
125
+ ### Length
126
+
127
+ **4–15 seconds, target 7.** A meme recaption is short because the joke is instantaneous and every
128
+ second after the laugh is a second in which someone scrolls. If the clip is 30s, you are using 7s
129
+ of it. Loop it if it is shorter than 4s — a 3s clip looping twice reads as intentional, a 3s clip
130
+ that ends reads as a mistake.
131
+
132
+ ---
133
+
134
+ ## The caption — the only thing you actually write
135
+
136
+ ### First, pick a pole: PAIN or WIN
137
+
138
+ Every meme recaption points at one of two feelings. **Decide which before you write a word**, because
139
+ the pole changes the casting, the caption grammar, whether the offer is named, and what the gate
140
+ allows.
141
+
142
+ | | 😖 **Pain** | 🎉 **Win** |
143
+ |---|---|---|
144
+ | What it names | the annoying, tedious, humiliating or absurd moment in the audience's day | the small win, the relief, the flex, the moment something finally worked |
145
+ | What the clip supplies | the reaction they wish they could have | the celebration |
146
+ | Where the offer sits | absent, or a **witness** to the mess | named, and it is **what worked** |
147
+ | Does it resolve? | **No.** The video ends on the problem | **Yes, by design.** The resolution is the joke |
148
+ | Example | *"me at 1am realising the receipts folder is just 40 photos of receipts"* | *"me watching the reconciliation finish in 4 seconds after 3 years of spreadsheets"* |
149
+ | Travels | furthest — everyone in the niche has had that day | less far — it is closer to an ad and reads like one sooner |
150
+ | Converts | slowly, to cold viewers | better, to warm ones |
151
+ | The bar | low. Pain is easy to make funny | **high.** See below |
152
+
153
+ **Run both poles in every batch.** They do different jobs, and which one your market responds to is
154
+ the single most useful thing bulk generation can tell you.
155
+
156
+ #### The win pole is allowed, and it is harder
157
+
158
+ A win meme shows the product working, so it is one bad sentence away from a testimonial ad. Three
159
+ things keep it a meme:
160
+
161
+ 1. **The comedy is the SIZE OF THE RELIEF, never the feature.** *"3 years of spreadsheets"* is the
162
+ joke; *"auto-reconcile v2"* is a changelog. The exaggeration lives in how much suffering just
163
+ ended, not in what the software does.
164
+ 2. **The win must be one the niche has ached for.** If the audience has never felt the pain, the
165
+ relief is meaningless and the meme is an announcement.
166
+ 3. **It still has to pass the cold-viewer test.** Cover the brand and the feature names — does it
167
+ still read as a true, funny moment from their week?
168
+
169
+ **If you cannot make the win funny in one line, write the pain instead.** A flat win meme is an ad
170
+ with a reaction GIF on it; a good pain meme is always available and always travels.
171
+
172
+ ### The grammar
173
+
174
+ The caption names **somebody's experience**, in one of eight frames. Pick one. Do not invent a
175
+ ninth, and do not mix two.
176
+
177
+ | Frame | Shape | Who the joke is about | Use when |
178
+ |---|---|---|---|
179
+ | `When I …` | first person, past or present | you | The complaint is embarrassing and you're owning it |
180
+ | `Me when …` / `Me trying to …` | first person, present | you | The clip is a single sustained face or posture |
181
+ | `When you …` | second person | the viewer, flatteringly | The experience is near-universal in the niche |
182
+ | `My friend when …` / `Why my friend …` | third person, affectionate | someone you love | The behaviour is irrational and you're not defending it |
183
+ | `<Entity> when they …` | personified institution | the villain | Something non-human keeps doing this to you |
184
+ | `<Entity> with …` / `<Entity> after …` | personified institution | the villain | The villain's *state*, not their action |
185
+ | `<Entity> watching me …` | a personified observer — **including the offer itself** | you, seen | **The tier-1 naming frame** (Rule 1). Something is watching you fail |
186
+ | `us after …` / `my clients when …` | first person plural, or the customer's customer | the group | A shared win or a shared groan. The natural **win-pole** frame |
187
+
188
+ **`<Entity> watching me …` is how you get the offer into the caption without the joke resolving.**
189
+ The product becomes a witness to your dysfunction rather than the cure for it — it is present, named
190
+ and powerless, which is both funnier and safer. See Rule 1.
191
+
192
+ **`<Entity>` is the format's sharpest tool.** Personify the thing that causes the pain — the group
193
+ chat, my calendar, the algorithm, my landlord's portal, the 47 open tabs, every delivery app, the
194
+ search bar. An institution behaving like an idiot person is funnier than a person behaving like an
195
+ idiot person, and it moves the blame off the viewer, which is what makes them comfortable enough to
196
+ share it.
197
+
198
+ > ⚠️ **`<Entity>` is also where Rule 8 gets broken, every time.** The funniest villain is always a
199
+ > real company by name, and naming one doing something stupid is exactly what Rule 8 forbids. The
200
+ > first draft of the worked example in Appendix D personified **Resy** — a real, named, adjacent
201
+ > booking company — and it took a reader to catch it, because on the page it just looked like a
202
+ > good specific joke.
203
+ >
204
+ > **Personify the CATEGORY or the OBJECT, never the company.** "every delivery app", "the search
205
+ > bar", "10 tabs" are all jokes. A named company is a letter, and it also makes the customer look
206
+ > like they are punching at a competitor rather than describing a life.
207
+
208
+ **Hard rules on the sentence itself:**
209
+
210
+ - **Subject first.** Never open with a verb, never open with "POV" (dated), never open with a
211
+ question.
212
+ - **One sentence. One clause of setup, one clause of collapse.** The collapse is the last four
213
+ words and it must be the last four words.
214
+ - **8–20 words.** Under 8 and it's a slogan; over 20 and it isn't read before the clip lands.
215
+ - **No punctuation at the end.** No period, no exclamation mark, no emoji. A meme caption that
216
+ punctuates itself reads as written *at* you.
217
+ - **Lowercase or sentence case. Never ALL CAPS**, which reads as a 2013 image macro.
218
+
219
+ ### The specificity ladder — the single biggest lever
220
+
221
+ Generic captions get nothing. Specific captions get "how did you film me". Climb the ladder until
222
+ it hurts, then stop one rung earlier.
223
+
224
+ | Rung | Example (a "can't find where to eat" problem space) | Result |
225
+ |---|---|---|
226
+ | 0 — a feeling | `when you're hungry` | Nothing. This is not a situation |
227
+ | 1 — a situation | `when you can't decide where to eat` | Nothing. True of everyone, felt by no one |
228
+ | 2 — a situation with a number | `when you've had 10 tabs open for an hour` | Starting to work |
229
+ | 3 — **+ a named object and a real outcome** | `when i've had 10 tabs open since 7pm for one scallop roll and we're getting pizza again` | This is the rung. Ship this |
230
+ | 4 — too far | `when i've had 10 tabs open since 6:47 for the scallop roll from that place on ludlow` | Now it's your story, not theirs |
231
+
232
+ **Rung 3 is: a number, a concrete named object, and a deflating outcome.** The number makes it true,
233
+ the object makes it real, the outcome makes it funny. Miss any one of the three and you are on
234
+ rung 2, where nothing lives.
235
+
236
+ > **"Named object" means a THING, not a BRAND.** `scallop roll`, `the 7am standup`, `row 400 of the
237
+ > spreadsheet` — all rung 3. A company name feels like it does the same job and it does not; it does
238
+ > Rule 8's job instead, which is to get you a letter. See the warning under `<Entity>` above.
239
+
240
+ ### The cold-viewer test — cover the brand and read it again
241
+
242
+ The specificity ladder tells you to get *more* specific. This is the rule that stops you getting
243
+ specific about the **wrong thing**.
244
+
245
+ > Read the caption as somebody who works in the niche but has **never heard of the offer** — not the
246
+ > brand, not the feature names, not the category jargon. If they cannot get the joke in one read,
247
+ > the caption failed, however clever it is to the team.
248
+
249
+ **Write about the NICHE, not the FEATURE.** A meme about a product feature needs product context to
250
+ land, so it lands only on people who already bought — which defeats the point of a meme ad. A meme
251
+ about the niche's lived experience lands on everybody in the niche.
252
+
253
+ - ❌ *"me after the auto-reconcile v2 sync finally clears"* — "auto-reconcile v2" means nothing cold.
254
+ - ✅ *"me at 1am matching bank lines to receipts by hand"* — every bookkeeper has had that night.
255
+
256
+ **The test, mechanically:** cover the brand name and every feature word. If the caption still reads
257
+ as a true, funny moment from the audience's week, keep it. If it collapses into nonsense, rewrite it
258
+ around the **feeling** instead of the feature.
259
+
260
+ **No invented vocabulary, no inside jokes, no setups that only the founder's demo explains.** Slang
261
+ the niche already uses is fine; slang only the product uses is not. This is the difference between
262
+ a rung-3 specific (`scallop roll`, `the 7am standup`) and a rung-0 nothing (`the v2 sync`) — both
263
+ feel specific when you write them, and only one survives being read by a stranger.
264
+
265
+ > This test is also **elegance-test question 5** (Rule 1). It is listed twice on purpose: it decides
266
+ > whether the caption works at all, *and* whether the offer can be named inside it.
267
+
268
+ **Get the specifics from the customer, not from your imagination.** Their testimonials, their
269
+ support inbox and their landing-page copy are full of rung-3 sentences written by actual users. In
270
+ the worked example in Appendix D, the shipped caption is a lightly compressed version of a
271
+ testimonial printed on the customer's own homepage — which also happens to satisfy Rule 6.
272
+
273
+ ### Banned captions
274
+
275
+ - **The product as the thing that WORKS.** The name may appear as a subject in the setup (Rule 1,
276
+ tier 1); it may never appear in the payoff or as the fix. Checked by `offer_not_in_payoff` and
277
+ `caption_does_not_resolve`.
278
+ - **Category nouns and feature words**, even when the brand name is allowed. `dishcover.io` is a
279
+ character; "dish search app" is a description.
280
+ - Anything that resolves. `when i couldn't find X … so now i just` — the meme is the complaint. The
281
+ solution is not in the video. **Ever.**
282
+ - "POV:" — dated, and it forces present tense on a clip that is usually past tense.
283
+ - Two-part setups split across a cut. That is a different format and it is weaker.
284
+ - Anything naming a real competitor doing something bad. Personify a *category* ("every delivery
285
+ app"), not a company you can be sued by.
286
+
287
+ ---
288
+
289
+ ## Casting the meme
290
+
291
+ ### Browse the shelf. Do not search it.
292
+
293
+ > ⚠️ **The descriptions on this shelf were written by a model that has never heard of the meme.**
294
+ > This is not a small problem — it is the defining fact of sourcing this format.
295
+ >
296
+ > `my-brain-every-10-seconds` is described as *"A man in a suit gestures and looks at the camera
297
+ > with a neutral expression."* `jonah-jameson-laugh-you-serious` is described as *"A solid green
298
+ > screen background."* A semantic query for `hungry food eating` returns **zero results** on a shelf
299
+ > that contains a dozen clips of people eating.
300
+ >
301
+ > **The `slugId` is the index. The description is noise.** Pull the whole shelf as JSON, read the
302
+ > slugs, and only look at descriptions to break a tie.
303
+
304
+ ```bash
305
+ vidfarm public-raws --categories # the shelves + live counts
306
+ vidfarm public-raws --category greenscreen --limit 200 # 193 of these 200 are MemeScreens
307
+ # ...and this is only the FIRST 200. See below
308
+ vidfarm public-raws --category text-graphics --limit 200
309
+ vidfarm public-raws --category lifestyle --limit 200 # also almost entirely MemeScreens
310
+ ```
311
+
312
+ > 🚨 **`vidfarm public-raws` shows you 200 clips and stops, and it does not tell you.** The server
313
+ > caps a shelf at 200 per call and returns a `next_cursor` — but the CLI has **no `--cursor` flag**,
314
+ > so raising `--limit` to 1200 still returns exactly 200. Paging the same shelves through
315
+ > `vidfarm api GET` instead turns **200 clips into 959** (851 of them MemeScreens). Cast from the
316
+ > first 200 and you are picking from under a quarter of the library, with nothing anywhere saying so.
317
+ >
318
+ > **Build the full index once (Appendix C) and cast from the TSV, not from the CLI.**
319
+
320
+ **Every shelf the CLI exposes is dominated by MemeScreens** — `b-roll`, `lifestyle` and
321
+ `text-graphics` all come back as greenscreen memes. Good news for this format, bad news for
322
+ backgrounds (see below).
323
+
324
+ ### The enactment test, in practice
325
+
326
+ You are casting for a **verb**, not a mood. Write down the verb your caption's subject is doing
327
+ with their body, then find the slug that does that verb:
328
+
329
+ | The caption's verb | Slugs that enact it |
330
+ |---|---|
331
+ | deflating / accepting a bad outcome | `my-disappointment-is-immeasurable-and-my-day-is-ruined`, `jonah-hill-sigh`, `girl-eyerolls` |
332
+ | not understanding at all | `huh-cat`, `hangover-dog`, `zoolander-staring`, `nervous` |
333
+ | the same thought on a loop | `my-brain-every-10-seconds`, `monkey-cymbal-jolly-chimp`, `zoning-out` |
334
+ | smug, having solved it | `gigachad`, `chad-head-bopping`, `check-out-the-big-brain-on-brad` |
335
+ | overreacting to something tiny | `neon-screaming`, `jontron-nooooooo`, `girl-fake-crying` |
336
+ | telling you you're wrong | `jonathan-frakes-telling-you-youre-wrong-for-47-seconds`, `women-mocking` |
337
+ | unbothered while it burns | `my-favorite-thing-in-the-world-to-do-is-nothing`, `boss-baby-chilling` |
338
+
339
+ **Then confirm by eye.** Build a contact sheet before you commit — a slug promises an action the
340
+ clip does not always deliver, and half these clips are 16:9 with the subject small at the bottom:
341
+
342
+ ```bash
343
+ ffmpeg -v error -i meme.mp4 -vf "fps=1,scale=240:-1,tile=7x1" -frames:v 1 sheet.png
344
+ ```
345
+
346
+ ### Match the INTENSITY, not just the verb
347
+
348
+ The enactment test gets you the right action. This gets you the right *size* of it — and it is
349
+ where the comedy actually comes from.
350
+
351
+ > **The joke is the MISMATCH between the size of the feeling and the size of the reaction.**
352
+
353
+ - A **tiny pain** + a wildly over-the-top reaction clip. (Someone screaming into the void because a
354
+ meeting could have been an email.)
355
+ - A **small win** + a stadium-scale celebration. (Confetti and a trophy lift because a build
356
+ passed.)
357
+ - Matching a big feeling to a big reaction is not a joke, it is a re-enactment. Matching a small
358
+ feeling to a small reaction is not anything at all.
359
+
360
+ **Choose the pain or win that the clip's energy already fits, rather than fighting the footage.**
361
+ You have 851 clips and one caption you have not written yet; it is far cheaper to pick the feeling
362
+ that suits a great clip than to hunt for a clip that suits a feeling you are attached to. When a
363
+ strong clip and a strong line refuse to sit together, keep the clip and rewrite the line — the
364
+ clip is the part you cannot edit.
365
+
366
+ ### 🚨 Rights tiers — read this before you cast for a paying customer
367
+
368
+ The MemeScreens shelf is **keyed celebrities, studio characters, TV clips and music-sync memes.**
369
+ That is completely normal for a meme repost from an owned account. It is a different thing entirely
370
+ on a customer's promoted ad. Sort every candidate into a tier and **put the tier in the build log**:
371
+
372
+ | Tier | What's in it | Client work? |
373
+ |---|---|---|
374
+ | 🟢 **Safe** | Animals, babies, anonymous adults, generic dancers, abstract/original animation. `huh-cat`, `hangover-dog`, `cat-kung-fu`, `monkey-cymbal-jolly-chimp`, `nanalan`, `girl-eyerolls` | ✅ Default for client work |
375
+ | 🟡 **Named individual** | A real, identifiable, non-famous-brand person. `my-disappointment-is-immeasurable…`, `hey-vsauce-michael-here`, `mutahar-laughing` | ⚠️ Organic owned accounts only. Flag it to the customer in writing |
376
+ | 🔴 **Studio / celebrity** | SpongeBob, Naruto, JoJo, Mr. Krabs, Joker, John Cena, Charli D'Amelio, Game of Thrones, Rick Astley | ❌ Not on paid placement. Not on a customer's account without their lawyer |
377
+ | ⛔️ **Reputation** | Figures who bring their own controversy regardless of rights — e.g. `tate-evil-laugh`, `jordan-belfort`, political slugs | ❌ Never on client work. The clip's politics become the customer's politics |
378
+
379
+ **The tier is the customer's decision, not the editor's.** Default to 🟢. If a 🟡 or 🔴 clip is the
380
+ only one that enacts the verb, build it, ship it with the tier named in the handoff, and build the
381
+ 🟢 alternate alongside it so they have something to say yes to.
382
+
383
+ ---
384
+
385
+ ## The two production classes
386
+
387
+ Every meme on the shelf is one of these, and they are built completely differently. **Decide the
388
+ class before you write a single ffmpeg command.**
389
+
390
+ | | 🟩 **Class A — Keyed** | 🎞 **Class B — Baked** |
391
+ |---|---|---|
392
+ | What it is | Subject on a flat chroma plate. `sourceType: MemeScreens`, plate is usually pure `#00FF00` | A finished clip with its own world, often with text already burned in |
393
+ | What it needs | **A background.** Ships green otherwise | **Text removal or avoidance.** Ships someone else's caption otherwise |
394
+ | Layout | Layout B — the world | Layout A — the plate-and-band |
395
+ | Where the caption goes | On the background, above the subject | On a plate above the clip, outside the picture |
396
+ | Share of the shelf | The large majority | A minority — check for burned-in text on the contact sheet |
397
+
398
+ **Check the plate colour before you key anything.** These clips are re-encodes and the plate is not
399
+ always what it looks like:
400
+
401
+ ```bash
402
+ python3 -c "
403
+ import subprocess,numpy as np
404
+ p=subprocess.run(['ffmpeg','-v','error','-i','meme.mp4','-vf','fps=1,crop=200:200:20:20',
405
+ '-pix_fmt','rgb24','-f','rawvideo','-'],capture_output=True)
406
+ a=np.frombuffer(p.stdout,np.uint8).reshape(-1,200,200,3)
407
+ m=a.reshape(-1,3).mean(0).astype(int); print('plate rgb',m,'hex %02X%02X%02X'%tuple(m))"
408
+ ```
409
+
410
+ A pure `00FF00` reading means a digital plate: key tight (`0.18:0.03`) and **skip despill.** A
411
+ reading like `1F D3 1F` means a filmed plate: key looser and despill lightly. Getting this backwards
412
+ is the first defect in the list below.
413
+
414
+ ### Class B — dealing with the burned-in text
415
+
416
+ The clip arrives carrying somebody else's joke. Three options, best first:
417
+
418
+ 1. **Crop it out.** The original caption is nearly always in a top or bottom band. Crop the band
419
+ off, then put your caption on your own plate above the picture. Free, clean, no artefacts.
420
+ 2. **Cover it.** Only when the text overlaps the picture. Put an opaque plate over it — sampled
421
+ from the pixels beside it, not a guessed grey — and your caption on top of the plate. A plate
422
+ that doesn't match reads as a censor bar.
423
+ 3. **Never blur it.** A blurred rectangle is the single most recognisable "this is a reposted ad"
424
+ tell on the platform.
425
+
426
+ ---
427
+
428
+ ## Visual DNA
429
+
430
+ ### The canvas
431
+
432
+ **1080×1920, 30fps, 9:16, one canvas, no letterboxing of the canvas itself.** The clip inside it is
433
+ a different question — see below.
434
+
435
+ ### ⚠️ Do NOT crop the meme to 9:16
436
+
437
+ Most of this shelf is 16:9 (the worked example is 1280×720). The instinct is to crop to fill the
438
+ tall frame. **Don't.** Cropping a 16:9 meme to 9:16 throws away the sides, where the joke usually
439
+ is, and it takes away the empty space the caption needs.
440
+
441
+ The empty space above the clip is **not a defect to be filled — it is the format's signature.** A
442
+ meme in a band with a caption on a plate above it is what a meme looks like on this platform. A
443
+ meme cropped edge-to-edge looks like a video someone re-uploaded.
444
+
445
+ ### Layout A — the plate-and-band (Class B, and any clip with its own world)
446
+
447
+ ```
448
+ ┌──────────────────┐ 0
449
+ │ │
450
+ │ CAPTION PLATE │ flat white (#FFF) or the meme's own black
451
+ │ black text │ height: whatever the caption needs, 22–34% of frame
452
+ │ │
453
+ ├──────────────────┤ ~30%
454
+ │ │
455
+ │ THE MEME │ full canvas width, native aspect, 1080×608 for a 16:9 source
456
+ │ │
457
+ ├──────────────────┤ ~62%
458
+ │ │
459
+ │ (plate colour │ the plate colour continues. Do NOT put anything here
460
+ │ continues) │
461
+ └──────────────────┘ 1920
462
+ ```
463
+
464
+ The clip sits **high**, not centred: its top edge at ~30–34% of frame height. Centring it drops the
465
+ picture behind the platform's own UI chrome at the bottom of the screen.
466
+
467
+ ### Layout B — the world (Class A, keyed)
468
+
469
+ ```
470
+ ┌──────────────────┐ 0
471
+ │ background │ fills the whole canvas
472
+ │ │
473
+ │ the caption │ on a translucent plate, y ≈ 12–30%
474
+ │ │
475
+ │ │
476
+ │ ██████ │ the keyed subject, bottom-anchored,
477
+ │ ████████ │ full canvas width, running off the bottom edge
478
+ └──────────────────┘ 1920
479
+ ```
480
+
481
+ **Bottom-anchor the subject and let it run off the bottom edge.** These clips are torsos framed
482
+ small in the middle of a big green field, and the measured subject box in the worked example was
483
+ 676×536 inside 1280×720 — 53% of the width, already touching the bottom of frame. Crop to the
484
+ subject's own box plus a margin, scale it to the full canvas width, and sit it on the floor. A
485
+ keyed subject floating in the middle of a background with air under it reads as a sticker, and a
486
+ sticker is not a person.
487
+
488
+ Appendix A measures the box and does all of this in one pass.
489
+
490
+ ### Backgrounds — a keyed meme needs a WORLD, not a colour
491
+
492
+ > **A keyed meme without a background is not finished.** The subject was cut out of a green field;
493
+ > if you drop it on a flat plate you have a sticker floating in a void. The background is what makes
494
+ > the joke *happen somewhere*, and "somewhere" is half the comedy.
495
+
496
+ > ⚠️ **There is no background shelf in public-raws.** Every category — `b-roll`, `lifestyle`,
497
+ > `food` — comes back as MemeScreens greenscreen clips. Do not spend an hour looking for the clean
498
+ > stock library. It is not there. Use the ladder.
499
+
500
+ **Pick the world before you search.** One line, written down: *where is this person when this
501
+ happens to them?* Then search for that, not for the product's category.
502
+
503
+ | # | Source | Cost | Licence | When |
504
+ |---|---|---|---|---|
505
+ | 1 | **`vidfarm media image "<the world>"`** — Openverse + Pixabay stock | **$0** | ✅ Pixabay is royalty-free, **no attribution** | **The default in `minimize`.** This is the free-tier answer and it is genuinely good |
506
+ | 2 | **A still from the customer's own capture**, blurred and darkened | $0 | ✅ theirs | When the joke's world really is the product's world |
507
+ | 3 | **`vidfarm image-search "<the world>"`** — Google Images | ~$0.0003/call, **paid plans only** | ⚠️ **none — these are LINKS** | Pro accounts. Best for finding the *look*. See the warning below |
508
+ | 4 | **`vidfarm handoff image --theme "…" --single`** → the human pastes into a free web generator | $0, human in the loop | ✅ | `mode interactive`. The best-looking result on the ladder |
509
+ | 5 | **`vidfarm generate image` / `create-overlay`** | billed | ✅ | `rich-ai` only. **Refused in `minimize` without `--yes`** |
510
+ | 6 | A flat brand plate from `capture/extracted/tokens.json` | $0 | ✅ | Fallback only. Honest, clean, and the least funny option on this list |
511
+
512
+ #### Tier 1 — the free path, in full
513
+
514
+ ```bash
515
+ vidfarm media image "dark empty restaurant interior night" --provider pixabay --limit 8 --json
516
+ ```
517
+
518
+ - **Force `--provider pixabay`.** Openverse results are largely **CC-BY**, which requires a credit
519
+ line on screen — and a credit line is brand chrome this format forbids. The CLI prints
520
+ `⚠ credit:` next to every one of them. Pixabay needs none. If you must use a CC-BY image, the
521
+ attribution goes in the **post caption**, never on the video.
522
+ - **Download with a browser user-agent.** Pixabay's CDN answers a bare `urllib`/`python-requests`
523
+ fetch with **HTTP 403**: `curl -sL -A "Mozilla/5.0" -o bg.jpg "<url>"`.
524
+ - **Look at the candidates as one sheet** before you pick, exactly as you do for the meme.
525
+
526
+ #### Tier 3 — what the pro path actually returns
527
+
528
+ `vidfarm image-search` is Google Images, and the CLI says so itself: *"These are LINKS with no
529
+ licence attached."* A real run for `empty restaurant interior night` returned **iStock and Vecteezy
530
+ preview JPEGs** — watermarked comps that you cannot ship on a customer's ad.
531
+
532
+ > **So use tier 3 to find the LOOK, then get the asset from tier 1.** It is a fast, cheap art
533
+ > director. It is not an asset source. Shipping an iStock preview because it appeared in a search
534
+ > result is the one mistake on this page that ends in a letter.
535
+
536
+ #### Push the background down — always
537
+
538
+ The background loses. It is a backdrop for a joke, not a picture. **Blur it, darken it, desaturate
539
+ it**, and crop it to 9:16 deliberately rather than letting a centre-crop pick for you:
540
+
541
+ ```bash
542
+ # crop the interesting part -> 9:16 -> blur into a set -> take it down under the caption
543
+ ffmpeg -y -i cand3.jpg -vf \
544
+ "crop=480:853:230:0,scale=1080:1920,gblur=sigma=18,eq=brightness=-0.06:saturation=0.8,setsar=1" \
545
+ -update 1 bg.png
546
+ ```
547
+
548
+ Two failures, both real, both from the run that produced this file:
549
+
550
+ - **Too little grade** and the background competes with the caption. `meme-qa.py` measures this
551
+ (`background_pushed_down`) in the strip *above* the caption.
552
+ - **Too much grade** and you get a black rectangle. The first attempt here used `sigma=28` +
553
+ `brightness=-0.22` on a default centre-crop and produced an image with no readable content at all
554
+ — and it still passed every check that existed at the time, because "dark" and "pushed down" look
555
+ identical to a naive metric. **Look at the background on its own before you composite.**
556
+
557
+ ### Everything else
558
+
559
+ - **One shot. No cuts, no transitions, no zoom, no shake, no "meme" whip.**
560
+ - **The clip runs at native speed.** Speeding a meme up destroys the timing that made it a meme.
561
+ - **At most one piece of brand chrome: the handle** — and only when the caption is not already
562
+ carrying the name (Rule 1). No logo, no end card, no CTA, no progress bar, no second mark.
563
+ - **No B-roll, no cutaway, no second clip.** If you feel the video needs one, the caption is weak.
564
+
565
+ ---
566
+
567
+ ## Typography DNA — a card, not a subtitle track
568
+
569
+ > **This is the one harness in the set where kinetic word-by-word captions are WRONG.**
570
+
571
+ Every other format in this repo wants TikTok-native word-pop subtitles. This one does not, and the
572
+ reason is structural: a meme caption must be **read in one gulp, before the clip acts.** Words that
573
+ arrive one at a time cannot be read ahead, so the action lands before the setup exists and the joke
574
+ has no floor.
575
+
576
+ - **The whole caption is on screen at `data-start="0"`, in its final state, and never changes.**
577
+ `text_by_sec: 0` grades the frame, not the intent.
578
+ - **One text object. No animation. No fade-in, no pop, no typewriter.**
579
+ - **Weight 600–700, not 900.** A meme caption is typed, not shouted. 900 is an ad.
580
+ - **Sizing:** ~52–62px on the 1080 canvas for a 3-line caption. It should look like a person typed
581
+ it into the app, because that is the look the platform's own caption tool produces.
582
+ - **Font:** the platform's own caption face (`TikTok Sans`), or Helvetica/Arial. **Self-host it** —
583
+ a fallback chain means a missing font silently renders as a different font and the video just
584
+ quietly stops looking native.
585
+ - **Never Impact. Never a drop shadow. Never an outline. Never gradient text.**
586
+ - **Plate, always.** Black on white for Layout A; white on `rgba(0,0,0,0.55)` with ~24px padding for
587
+ Layout B. An unplated caption over a busy background is unreadable on half of frames, and you will
588
+ not notice because you already know what it says.
589
+
590
+ ### Placement — centre the block, left-align the words
591
+
592
+ The commonest way this format looks amateur is not the font or the colour. It is a caption plate
593
+ sitting **flush against one edge** with a lake of dead space on the other side.
594
+
595
+ > **The BLOCK is horizontally centred. The WORDS inside it are left-aligned.**
596
+
597
+ These are two different decisions and they pull in opposite directions, which is why they get
598
+ collapsed into one and why the result is always wrong:
599
+
600
+ - **Centred words** read as a title card — a designed, corporate thing. Wrong for a meme.
601
+ - **A left-pinned block** reads as an accident, because the plate is an `inline-block` that hugs its
602
+ longest line. Give it `left:7%` and it does *not* fill that width — it sits at 7% and ends
603
+ wherever the text ends, leaving the frame visibly lopsided.
604
+
605
+ So: a full-width container with `text-align:center`, an `inline-block` plate riding that centre, and
606
+ `text-align:left` inside the plate. Appendix B is the exact CSS.
607
+
608
+ - **Hand-break the lines to NEAR-EQUAL length**, at the clause, with the collapse on its own line.
609
+ This is what makes a left-aligned block look balanced instead of ragged — and it is checked
610
+ (`caption_lines_balanced`, raggedness < 45%).
611
+ - **2–4 lines.** One line is a slogan; five is a paragraph.
612
+ - **Vertical: `top` around 12–16%, not 8–10%.** Pinned to the very top the caption reads as a
613
+ platform overlay rather than as part of the picture. In Layout B it must also clear the subject's
614
+ head — the caption sits in the upper third, the subject occupies the lower half, and the gap
615
+ between them is what makes the frame breathe.
616
+ - **Safe zone 8–85%.** In Layout A the caption lives in the top plate; in Layout B keep it clear of
617
+ the top 8% (platform UI) and well above the subject's head.
618
+ - **Side margins ≥ 4%.** A plate that touches either edge reads as a crop error.
619
+
620
+ **None of this is a matter of taste, and none of it is checked by `vidfarm qa`.** `meme-qa.py`
621
+ (Appendix E) measures the rendered frame: block centre within 3% of 50%, side margins, vertical
622
+ band, and line balance. If the numbers are wrong the frame is wrong, whatever it looked like at 2am.
623
+
624
+ ### The handle — typography
625
+
626
+ The offer's name is the one piece of brand chrome allowed (Rule 1), and it is styled to be *skipped*:
627
+
628
+ - **Directly under the caption plate, on the same centre.** A corner mark works too, but under the
629
+ plate is safer — the bottom of frame is platform UI, and in Layout B the subject owns it.
630
+ - **≤72% of the caption's size.** 34px against a 54px caption reads as a byline; parity reads as a
631
+ second headline, which is a CTA wearing a small font.
632
+ - **Alpha ≤0.85**, on its own faint plate so it stays legible over a busy background.
633
+ - **Lowercase, the bare domain or handle.** `dishcover.io`, not `Visit Dishcover.io →`, not
634
+ `www.`, not a tagline. The moment it acquires a verb it is a CTA.
635
+ - **Static, present at frame 0, never animated.** It is furniture.
636
+ - **One mark. Never a logo AND a handle** — pick the words, because words survive a re-encode and a
637
+ screenshot at 30% scale, and a logo does not.
638
+
639
+ ---
640
+
641
+ ## Audio DNA
642
+
643
+ > **Keep the meme's own audio.** This is the opposite of every other harness here, and it is not
644
+ > negotiable.
645
+
646
+ For a large share of these clips the sound **is** the meme. Muting `never-gonna-give-you-up` or
647
+ `jonah-jameson-laugh` leaves you with a stranger moving silently. The audio is the half of the
648
+ recognition you did not have to build.
649
+
650
+ - **Normalise it, don't replace it.** `loudnorm=I=-16:TP=-1.5:LRA=11`.
651
+ - **Measure the render**, not the stem: `ffmpeg -i out.mp4 -af ebur128=peak=true -f null -`.
652
+ - **No voiceover. No TTS. No added music bed** on top of the meme's own audio — two audio ideas at
653
+ once is noise, and the meme's audio was going to win anyway.
654
+ - **The exception:** a clip that was silent, or one whose audio is a copyrighted music track you
655
+ cannot ship. Then go silent and let the poster attach trending audio in the platform's own
656
+ editor. **Say which of the two it is in the handoff** — it is a different decision for them.
657
+
658
+ > ⚠️ **A lot of this shelf ships a silent AAC track, not no track.** `ffprobe` reports an audio
659
+ > stream, `loudnorm` runs without complaint, the render has audio metadata, and `vidfarm qa`'s
660
+ > `audio: required` passes — on a video nobody can hear. Of the two clips in Appendix D, one
661
+ > measured -26 LUFS and the other **-70 LUFS, which is digital silence.**
662
+ >
663
+ > **Measure the SOURCE before you build, and the RENDER before you ship:**
664
+ > ```bash
665
+ > ffmpeg -v info -i <clip>.mp4 -af ebur128 -f null - 2>&1 | grep -A2 "Integrated loudness"
666
+ > ```
667
+ > Below about -50 LUFS the clip is silent. That is not a defect — it is a fact that decides the
668
+ > handoff, and the one thing you cannot do is not know it.
669
+ - **A music-sync meme is a 🔴 rights clip** even when the picture is fine. The track is the part
670
+ that gets the video muted.
671
+
672
+ ---
673
+
674
+ ## The rules
675
+
676
+ ### Rule 1 — name the offer in the CAPTION if it reads; fall back to the handle if it doesn't
677
+
678
+ **The offer is named on the video, always.** A meme nobody can trace builds someone else's page, and
679
+ this format gets screenshotted and reposted more than anything else in the set.
680
+
681
+ There are two ways to name it, and they are a **priority order, not a choice**:
682
+
683
+ | Tier | How | Reach | Use when |
684
+ |---|---|---|---|
685
+ | **1 — in the caption** ✅ *try this first* | The offer is a word in the sentence: `dishcover.io watching me open 10 tabs since 7pm for one scallop roll` | Strongest. The name is inside the thing people screenshot and re-type | The offer can be a **character** in the setup without bending the joke |
686
+ | **2 — the subtext handle** | The offer sits under the caption plate, small and dimmed, as a byline | Good. Survives a repost, but the eye skips it | Tier 1 would be forced, wordy, or would make the joke resolve |
687
+
688
+ **Tier 1 is better and should be the default attempt.** Fall back to tier 2 the moment it stops
689
+ being elegant — a forced brand mention in a meme caption is more damaging than no mention at all,
690
+ because it announces that the meme is an ad before the joke gets a chance.
691
+
692
+ #### Where the offer may stand in the sentence
693
+
694
+ This is the whole discipline, and it is one line:
695
+
696
+ > **The offer belongs in the SETUP, as a subject. Never in the PAYOFF, as the solution.**
697
+
698
+ | | Example | Why |
699
+ |---|---|---|
700
+ | ✅ **Witness** | `dishcover.io watching me open 10 tabs since 7pm for one scallop roll` | The product observes your dysfunction. The collapse is still yours |
701
+ | ✅ **Interlocutor** | `me explaining to dishcover.io that i've had 10 tabs open since 7pm` | You are confessing to it |
702
+ | ✅ **Reactor** | `dishcover.io when i've had 10 tabs open since 7pm and we're getting pizza again` | It is disappointed in you |
703
+ | ❌ **Solution** | `…and then dishcover.io found it` | The joke resolves. This is an ad |
704
+ | ❌ **Sponsor** | `10 tabs open since 7pm — try dishcover.io` | A CTA with a meme attached |
705
+
706
+ **The payoff — the caption's last clause — must always land on the person, never on the product
707
+ working.** `for one scallop roll` is a payoff. `and dishcover.io found it` is a press release. Both
708
+ are checked (`offer_not_in_payoff`, `caption_does_not_resolve`).
709
+
710
+ #### The elegance test — five questions, any "no" means fall back to tier 2
711
+
712
+ 1. Does the caption still fit one of the **eight frames**, with the offer as the entity?
713
+ 2. Is it still **≤20 words** with the offer in it?
714
+ 3. Does the offer read as a **character**, or as a sponsor who bought a slot?
715
+ 4. Does the **collapse still land on the person**?
716
+ 5. Would it still be funny to someone who has **never heard of the offer**?
717
+
718
+ Question 5 is the one that catches most failures. If the joke needs the reader to know what the
719
+ product does, the product is not a character — it is an explanation, and explanations are not funny.
720
+
721
+ #### The handle (tier 2), specced
722
+
723
+ - Directly under the caption plate, on the same centre. Corners work; bottom-of-frame does not.
724
+ - **≤72% of the caption's font size** — 34px against 54px reads as a byline; parity reads as a
725
+ second headline, which is a CTA in a small font.
726
+ - **Alpha ≤0.85**, on its own faint plate so it stays legible over a busy background.
727
+ - **Lowercase, the bare domain.** `dishcover.io`, not `Visit Dishcover.io →`, not `www.`, not a
728
+ tagline. The moment it takes a verb it is a CTA.
729
+ - Static, present at frame 0, never animated. It is furniture.
730
+ - **One mark only.** Never a logo *and* a handle — pick the words. Words survive a re-encode and a
731
+ screenshot at 30% scale; a logo does not.
732
+ - **When tier 1 succeeds, the handle is optional.** Keep it only if the caption uses the brand word
733
+ and you also want the domain (`dishcover` in the sentence, `dishcover.io` underneath). Two
734
+ identical mentions is one too many.
735
+
736
+ **The deletion test, either tier:** remove the offer's name. Is it still a complete joke? If yes,
737
+ you have a meme with a byline. If no, the offer is load-bearing and you have written an ad.
738
+
739
+ ### Rule 2 — on the PAIN pole, never resolve. On the WIN pole, the relief IS the joke
740
+
741
+ This rule is pole-dependent, and reading it without the pole is how a good pain meme gets a
742
+ "…and then we fixed it" bolted onto the end.
743
+
744
+ **Pain pole — the meme is the complaint.** The video ends on the problem. It does not turn, it does
745
+ not resolve, and it does not hint. A pain meme that resolves is an ad with extra steps, and the
746
+ resolution is reliably the least funny part of every draft you will write. The offer may stand in
747
+ the setup as a witness; it may never be in the last clause.
748
+
749
+ **Win pole — the meme is the relief, so it resolves by definition.** The product is named and it is
750
+ what worked. What still must not happen is the joke's weight moving off the *feeling* and onto the
751
+ *mechanism*: the comedy is three years of spreadsheets ending, not the sync clearing. Say what the
752
+ relief felt like, never what the feature is called.
753
+
754
+ | | Pain | Win |
755
+ |---|---|---|
756
+ | Last clause may name the offer | ❌ never | ✅ that is the point |
757
+ | Last clause may name a **feature** | ❌ | ❌ |
758
+ | Comedy comes from | the absurdity of the problem | the scale of the relief |
759
+ | Gate | `caption_does_not_resolve` enforced | pass `--pole win` to relax it; `no_feature_words` still enforced |
760
+
761
+ Rule 1 and Rule 2 meet at the payoff. On the pain pole the offer may be in the sentence but never in
762
+ the last clause; on the win pole it may be in both, and the discipline moves to keeping features out
763
+ of it. **If you did not decide the pole, you are on the pain pole** — it is the safer default and the
764
+ one that travels.
765
+
766
+ ### Rule 3 — cast for the verb, not the mood
767
+
768
+ Written above as the enactment test; repeated here because it is the rule most often lost between
769
+ casting and the timeline. Name the physical action in the build log before you open the shelf.
770
+
771
+ ### Rule 4 — the action lands AFTER the caption is read, and frame 0 has a person on it
772
+
773
+ 0.6–1.0s of the subject existing, then the action. Trim the clip to make this true. If the meme's
774
+ peak is at frame 0 of the source, start later — or if the peak *is* the first frame and cannot be
775
+ moved, that clip is not castable for this format.
776
+
777
+ > ⚠️ **These clips open and close on empty plate, and keyed empty plate is an INVISIBLE frame.**
778
+ > The clip in Appendix D is pure green for its first 0.4s and again from 6.4s to its 7.1s end.
779
+ > Keyed, that ships a **blank thumbnail and a blank tail** — and nothing reports it. `vidfarm qa`
780
+ > passes (the markup is fine), the duration is right, the audio is right, and a contact sheet built
781
+ > at `fps=1` samples at 0.5s and shows you a perfect first frame that is not the first frame.
782
+ >
783
+ > Two separate causes, both of which produce the identical blank frame 0:
784
+ >
785
+ > 1. **The source is empty at its head.** Measure the subject's *presence window* and trim to it —
786
+ > Appendix A does this automatically and prints the window.
787
+ > 2. **`overlay` emits one background-only frame at t=0**, because after `-ss` the keyed stream's
788
+ > first PTS is a hair above zero. Fix with `setpts=PTS-STARTPTS` on the keyed chain. Without it
789
+ > the trim looks like it did nothing, which sends you hunting the wrong bug.
790
+ >
791
+ > **Check frame 0 as a frame, never as a sample:**
792
+ > ```bash
793
+ > ffmpeg -v error -i scene.mp4 -vframes 1 -update 1 f0.png # then LOOK at it
794
+ > ```
795
+ > `-vf fps=N` gives you the middle of the first interval, not frame 0. That is how this defect
796
+ > survives a review.
797
+
798
+ ### Rule 5 — one caption, one clip, one shot
799
+
800
+ No cuts. No second beat. No text card at the end. If the joke needs two beats, it is a different
801
+ format.
802
+
803
+ ### Rule 6 — the rung-3 specifics are traceable
804
+
805
+ Every number and proper noun in the caption comes from the customer's own copy, testimonials, or
806
+ support inbox — not from you. It keeps the joke true, it keeps the customer defensible, and it is
807
+ also, reliably, funnier than what you would have invented.
808
+
809
+ ### Rule 7 — the rights tier is declared, and it is the customer's call
810
+
811
+ Every build log names the tier. 🟢 is the default for client work. A 🟡 or 🔴 build ships with a 🟢
812
+ alternate beside it. ⛔️ never ships.
813
+
814
+ ### Rule 8 — no real competitor is named doing something bad
815
+
816
+ Personify the category, not the company. "every delivery app" is a joke. A named company is a
817
+ letter.
818
+
819
+ ### Rule 9 — the bait is in the post caption
820
+
821
+ Ask one question, in the post caption, in the same voice as the meme. Never on screen. The
822
+ north-star metric is comments, and a meme's comment section is people adding *their* version of the
823
+ same complaint — so ask for exactly that.
824
+
825
+ > Post caption pattern: `<the caption's situation, restated in one line>. <one question inviting
826
+ > their version>`
827
+
828
+ ---
829
+
830
+ ## Cost-saving mode — the whole build at $0
831
+
832
+ This format is the cheapest one in the set, and it is the one to reach for when `cost-mode` is
833
+ `minimize`. **Nothing below costs anything.**
834
+
835
+ ```bash
836
+ vidfarm cost-mode # confirm: minimize
837
+ vidfarm mode # interactive raises quality here; autonomous still works
838
+ ```
839
+
840
+ | Step | Tool | Cost |
841
+ |---|---|---|
842
+ | Source the meme | `vidfarm public-raws --category greenscreen` | $0 |
843
+ | Background | ladder tier 1–3 (brand plate / blurred capture / frozen frame) | $0 |
844
+ | Key + composite | local ffmpeg, one pass (Appendix A) | $0 |
845
+ | Caption | a static layer in the composition | $0 |
846
+ | Audio | the meme's own track, normalised locally | $0 |
847
+ | Render | `hyperframes render` locally | $0 |
848
+
849
+ > ⚠️ **Do not key to an alpha intermediate.** `vidfarm remove-greenscreen --local` is free and it
850
+ > works, but on a machine whose ffmpeg cannot encode VP9-alpha it silently writes a **ProRes 4444
851
+ > `.mov` — 75MB for a 7-second clip** — which the browser-based renderer cannot play. Nothing errors;
852
+ > you find it at render time.
853
+ >
854
+ > **Composite straight to an opaque MP4 in one ffmpeg pass instead** (Appendix A). It is free,
855
+ > smaller, browser-safe, and one command. `remove-greenscreen --cloud` gives you a real transparent
856
+ > WebM, but it is billed and this format does not need transparency in the output at all.
857
+
858
+ **Interactive mode is worth it here.** One background image from a free web generator is the single
859
+ biggest quality jump available at $0, and it costs one copy-paste:
860
+
861
+ ```bash
862
+ vidfarm handoff image --theme "<the joke's world>" --single
863
+ ```
864
+
865
+ ---
866
+
867
+ ## Bulk generation — this format is built for it
868
+
869
+ A meme recaption is two independent variables, which makes it the best format in the set for the
870
+ hold-three-constant-vary-one discipline. **Build the matrix, not the video.**
871
+
872
+ | Vary | Hold | What it measures |
873
+ |---|---|---|
874
+ | **Pole** (pain vs win) | same clip, same problem | Whether the market wants to be *seen* or wants *relief*. Run this first — it is the biggest split |
875
+ | Caption frame (`When I` vs `<Entity> when they`) | same clip | Whether the audience wants to be seen or wants a villain |
876
+ | Specificity rung (2 vs 3) | same clip, same frame | Almost always: rung 3 wins, by a lot |
877
+ | The clip | same caption | Enactment. The clearest signal you can buy |
878
+ | The problem | everything else | Which pain the market actually feels |
879
+
880
+ **Enumerate the pains and the wins ONCE, then recaption across the whole list.** This is the
881
+ highest-output, lowest-cost loop in the format and it is why the pole matters: sit down with the
882
+ customer's testimonials and support inbox, write ten pains and ten wins from the niche's real week,
883
+ and you have twenty videos before you have opened the shelf. The list is the asset; the clips are
884
+ interchangeable.
885
+
886
+ **One caption × five clips is five videos and about twenty minutes of work**, because the composite
887
+ is one ffmpeg command and the caption layer does not change. Do that before you write a second
888
+ caption — you will learn more from five castings of one joke than from five jokes.
889
+
890
+ Keep the index (Appendix C) and a `captions.tsv` of rung-3 lines. The pairing is the creative act;
891
+ everything downstream is a loop.
892
+
893
+ ---
894
+
895
+ ## Quality gates — what is ENFORCED, and by what
896
+
897
+ A rule nobody measures is a rule nobody follows. This format has three gates, and they check
898
+ different things. **Running only the first one is the same as running none.**
899
+
900
+ | Gate | Tool | Sees | Catches |
901
+ |---|---|---|---|
902
+ | 1. Markup | `vidfarm qa <dir> --harness ./experimental/meme-recaption.md` | the composition DOM | duration, aspect, font regime, banned strings, layer counts. **22 checks, zero pixels** |
903
+ | 2. Render | `python3 meme-qa.py <render>.mp4 <composition>.html` (Appendix E) | the finished MP4 | blank thumbnail, empty tail, residual green, caption centring/balance/placement, background not pushed down, silent audio, caption grammar |
904
+ | 3. Human | your eyes | the joke | the three-way lock. **No script will ever check whether it is funny** |
905
+
906
+ > 🚨 **Gate 1 passes on a broken video.** Every defect this format actually produces is invisible to
907
+ > a DOM check. During the run that produced this file, a build passed `vidfarm qa` **19/19 while
908
+ > shipping a completely blank thumbnail** — correct duration, correct frame count, correct audio
909
+ > metadata, clean markup, and no subject on frame 0. That is not a criticism of `vidfarm qa`; it is
910
+ > a static check and it says so itself. It is the reason gate 2 exists.
911
+
912
+ > **`vidfarm qa` stops answering after one revision of `composition.html`.** It prints *"REVISION
913
+ > LIMIT REACHED"* and withholds its findings — a deliberate anti-loop guard, not a failure. On a
914
+ > second pass use `--reset-revisions` (or `--max-revisions 2`). Read it as the tool telling you that
915
+ > what is left is usually taste; if you know it is not, reset and carry on.
916
+
917
+ **Gate 2 is not advisory. It exits non-zero.** Wire it into the build:
918
+
919
+ ```bash
920
+ vidfarm qa work/<job>/v001 --harness ./experimental/meme-recaption.md
921
+ python3 meme-qa.py renders/<job>-v001.mp4 work/<job>/v001/composition.html \
922
+ --offer dishcover.io || exit 1
923
+ ```
924
+
925
+ What gate 2 enforces, and the number it uses:
926
+
927
+ | Check | Threshold | The defect it exists for |
928
+ |---|---|---|
929
+ | `first_frame_has_subject` / `last_frame_has_subject` | lower-band stdev > 12 | Blank thumbnail / empty tail from un-trimmed plate |
930
+ | `no_residual_green` | < 0.5% green pixels | A key that was too tight |
931
+ | `caption_horizontally_centred` | block centre 50% ± 3% | The flush-left plate with a lake of dead space |
932
+ | `caption_side_margins` | > 4% and < 96% | A plate touching an edge |
933
+ | `caption_vertical_placement` | inside 8–85% | Under the platform UI, or on the subject's face |
934
+ | `caption_lines_balanced` | raggedness < 45% | Lines not hand-broken to near-equal length |
935
+ | `background_pushed_down` | detail above the caption < 6.0 | A background competing with the joke |
936
+ | `background_not_blown_out` | top-strip luma < 150 | A bright background eating a white caption |
937
+ | `audio_level` | −20…−13 LUFS, **or** < −50 with `--silent-ok` | A silent clip shipped as if it had sound |
938
+ | `caption_word_count` | 8–20 words | Slogan, or unreadable in 0.6s |
939
+ | `caption_uses_a_frame` | one of the six | A caption that is a statement, not an experience |
940
+ | `caption_no_cta_or_resolution` | no CTA strings, no `so i` / `then i found` | Rules 1 and 2 — the ad leaking back in |
941
+ | `caption_lines_hand_broken` | a `<br>` is present | Renderer wrap landing mid-thought |
942
+ | `offer_named_somewhere` | in the caption **or** in the handle | An untraceable meme that builds someone else's page. Also prints which tier you are on |
943
+ | `offer_not_in_payoff` | offer absent from the caption's **last clause** | Rules 1+2 — the joke resolving into a pitch |
944
+ | `caption_does_not_resolve` | *(pain pole only)* the offer's name followed by a success verb, or a turn phrase (`so i`, `then i`, `finally`, `turns out`) | Rule 2 — a pain meme resolving into a pitch. Pass `--pole win` when the relief IS the joke |
945
+ | `no_feature_words` | *(both poles)* no `v2` / `sync` / `integration` / `api` / `dashboard` / `workflow` / `platform` … | The cold-viewer test, mechanised. A feature only parses for people who already bought |
946
+ | `handle_is_subordinate` | ≤72% of the caption's font size *(only if a handle exists)* | A handle that has become a second headline |
947
+ | `handle_is_dimmed` | alpha ≤0.85 *(only if a handle exists)* | Same |
948
+ | `single_shot` | exactly 1 `<video>` | "There are no beats" |
949
+ | `no_animation_on_caption` | no `animation` in `.memecap` | Kinetic captions, which break the read order |
950
+
951
+ **Pass `--offer <domain>`** or the handle checks are skipped silently — an unset offer is not a
952
+ passing offer. **Pass `--pole win`** only when the caption is deliberately a win meme; the gate
953
+ defaults to `pain`, the stricter of the two, so nobody accidentally ships a resolution.
954
+
955
+ **Two things gate 2 deliberately does NOT check**, because a script cannot: whether the meme enacts
956
+ the caption's verb, and the rights tier. Those stay on the human checklist below, and the rights
957
+ tier stays in the build log in writing.
958
+
959
+ **Calibrate a checker before you trust it.** Two of these thresholds were wrong on their first
960
+ version and fired on a correct build — `caption_lines_balanced` measured pixel rows instead of text
961
+ lines (a row clipping an apostrophe made every caption "100% ragged"), and `background_pushed_down`
962
+ measured a strip that included the caption plate's own hard edges. A gate that cries wolf gets
963
+ switched off within a day, which is worse than not having it. **Run any new check against a build
964
+ you have already looked at and agree is good, and make it pass before you let it fail anything.**
965
+
966
+ ---
967
+
968
+ ## Pre-flight checklist
969
+
970
+ Run this **before** you build. An unchecked box is a rewrite, not a fix in the edit.
971
+
972
+ **The joke**
973
+ - [ ] **Pole chosen and written down** — pain or win (default pain)
974
+ - [ ] If win: the comedy is the size of the relief, not the feature
975
+ - [ ] `WHO:` and `MOMENT:` are written at the top of the build log
976
+ - [ ] Test 1 — someone who has never heard of the offer laughs
977
+ - [ ] Test 2 — the experience named is one the product removes
978
+ - [ ] Test 3 — the meme's physical verb IS the caption's feeling
979
+ - [ ] The clip's INTENSITY mismatches the feeling's size (that mismatch is the joke)
980
+ - [ ] Cold-viewer test — cover the brand and every feature word; does it still read?
981
+ - [ ] The caption is on rung 3: a number, a proper noun, a deflating outcome
982
+ - [ ] The numbers and proper nouns are traceable to the customer's own copy
983
+ - [ ] One of the eight frames, unmixed, subject first, 8–20 words, no end punctuation
984
+ - [ ] The caption does not resolve, and does not contain the product
985
+
986
+ **The clip**
987
+ - [ ] Rights tier declared. 🟢 for client work, or a 🟢 alternate is built alongside
988
+ - [ ] Plate colour sampled, not assumed
989
+ - [ ] Contact sheet built and looked at
990
+ - [ ] The action lands 0.6–1.0s in, and frame 0 has the subject on it (`-vframes 1`, looked at)
991
+ - [ ] Native speed, no cuts, 4–15s
992
+
993
+ **The background** (Class A / keyed only)
994
+ - [ ] The joke's **world** is named in one line before searching
995
+ - [ ] Sourced from tier 1 (`vidfarm media image --provider pixabay`) or tier 2, not from a Google
996
+ Images link
997
+ - [ ] Licence recorded in the build log. No CC-BY image without the credit in the **post caption**
998
+ - [ ] Cropped to 9:16 **deliberately**, not centre-cropped by default
999
+ - [ ] Blurred and darkened, and **looked at on its own** before compositing
1000
+
1001
+ **The build**
1002
+ - [ ] 1080×1920, 30fps
1003
+ - [ ] The meme is NOT cropped to 9:16
1004
+ - [ ] Layout A or B chosen deliberately; in B the subject is bottom-anchored and full width
1005
+ - [ ] Caption **block centred**, words left-aligned, lines hand-broken to near-equal length
1006
+ - [ ] Caption fully legible at frame 0, static, plated, clear of the subject's head
1007
+ - [ ] Poster layer present, so frame 0 is never bare background
1008
+ - [ ] **Tier 1 attempted first** — the offer named inside the caption, as a subject in the setup
1009
+ - [ ] Elegance test run; if any of the five answers was "no", fell back to tier 2 and said so
1010
+ - [ ] The offer is NOT in the caption's last clause, and nothing resolves
1011
+ - [ ] If tier 2: handle subordinate (≤72%), dimmed, centred with the caption, up at frame 0
1012
+ - [ ] Deletion test: remove the offer's name — is it still a complete joke?
1013
+ - [ ] No logo, no CTA, no end card, no second brand mark
1014
+ - [ ] Audio kept and normalised, measured on the render — or deliberately silent and said so
1015
+ - [ ] **Gate 1:** `vidfarm qa <dir> --harness ./experimental/meme-recaption.md`
1016
+ - [ ] **Gate 2:** `python3 meme-qa.py <render>.mp4 <dir>/composition.html` exits 0
1017
+ - [ ] **Gate 3:** you watched it
1018
+ - [ ] Post caption written, carrying the one comment ask
1019
+
1020
+ ---
1021
+
1022
+ ## Diagnosing a flop
1023
+
1024
+ Diagnose by charge. Do not declare the video bad.
1025
+
1026
+ | Symptom | The weak charge | The actual cause, in order of likelihood |
1027
+ |---|---|---|
1028
+ | Almost no views | Hook | The first three words are generic · the frame-0 image is not weird enough · the caption is too long to read in 0.6s |
1029
+ | Views, everyone leaves at 3s | Loop / enactment | The action landed before the caption was read (Rule 4) · the meme does not enact the verb (Test 3) |
1030
+ | Watched to the end, no reaction | Payoff / specificity | Rung 2. Add the number and the proper noun · or the joke resolves (Rule 2) |
1031
+ | Good watch time, dead comments | Bait | No post caption ask · or the ask is a poll, not an invitation to add their own version |
1032
+ | Comments are "what is this an ad for" | Rule 1 | The product leaked in — a logo, a screenshot, a category noun in the caption |
1033
+ | Reach collapsed after 2 hours | Audio / rights | A music-sync meme got muted · a 🔴 clip got claimed |
1034
+
1035
+ ---
1036
+
1037
+ ## Appendix A — `meme-composite.py`
1038
+
1039
+ Class A (keyed) in one pass: measure the subject's real box, crop to it, scale to canvas width,
1040
+ key, bottom-anchor onto the background, keep the audio. No alpha intermediate, no cloud, $0.
1041
+
1042
+ ```python
1043
+ #!/usr/bin/env python3
1044
+ """
1045
+ Composite a keyed MemeScreens clip onto a background, straight to an opaque MP4.
1046
+
1047
+ Why not `vidfarm remove-greenscreen` + a transparent layer: on a machine whose
1048
+ ffmpeg lacks VP9-alpha, the local key silently writes a 75MB ProRes 4444 .mov
1049
+ that the browser renderer cannot play. Nothing errors. You find out at render.
1050
+ Compositing in one pass avoids the intermediate entirely and is free either way.
1051
+
1052
+ The two things this does that a hand-written filter_complex forgets:
1053
+
1054
+ * It MEASURES the subject's bounding box instead of assuming the subject
1055
+ fills the frame. These clips frame a torso small in a big green field —
1056
+ 676x536 inside 1280x720 in the clip this was written against. Scaling the
1057
+ whole frame to canvas width leaves the subject about half the size it
1058
+ should be, floating, reading as a sticker.
1059
+ * It reads the PLATE COLOUR off the corners instead of assuming 0x00FF00.
1060
+ A pure digital plate wants a tight key and NO despill; a filmed plate wants
1061
+ a looser key and a light despill. Backwards, and either green survives at
1062
+ the edges or the despill eats the subject's skin tones.
1063
+ * It TRIMS to the subject's presence window. These clips routinely open and
1064
+ close on several frames of empty plate — the clip this was written against
1065
+ is empty for 0.0-0.3s and again from 6.3s to its 7.1s end. Keyed, empty
1066
+ plate is a fully transparent frame, so the video ships with a blank
1067
+ thumbnail and a blank tail and nothing anywhere reports it.
1068
+
1069
+ usage: meme-composite.py <meme.mp4> <background.png|mp4> <out.mp4>
1070
+ [--start auto] [--dur auto] [--width-frac 1.0]
1071
+ [--bottom 0] [--tolerance auto]
1072
+ """
1073
+ import subprocess, sys, numpy as np
1074
+
1075
+ meme, bg, out = sys.argv[1], sys.argv[2], sys.argv[3]
1076
+
1077
+ def opt(flag, default, cast=float):
1078
+ if flag not in sys.argv:
1079
+ return default
1080
+ v = sys.argv[sys.argv.index(flag) + 1]
1081
+ return default if v == "auto" else cast(v)
1082
+
1083
+ START = opt("--start", None) # None = auto, from the presence window
1084
+ DUR = opt("--dur", None)
1085
+ WIDTH_FRAC = opt("--width-frac", 1.0) # subject width as a fraction of canvas width
1086
+ BOTTOM = opt("--bottom", 0, int) # px of air under the subject. 0 = on the floor
1087
+ CANVAS_W, CANVAS_H, FPS = 1080, 1920, 30
1088
+
1089
+ def probe(path):
1090
+ r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0",
1091
+ "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", path],
1092
+ capture_output=True, text=True).stdout.strip()
1093
+ return [int(v) for v in r.split("x")[:2]]
1094
+
1095
+ W, H = probe(meme)
1096
+
1097
+ def frames(path, w, h, vf):
1098
+ p = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-vf", vf,
1099
+ "-pix_fmt", "rgb24", "-f", "rawvideo", "-"], capture_output=True)
1100
+ n = len(p.stdout) // (w * h * 3)
1101
+ return np.frombuffer(p.stdout, np.uint8)[:n * w * h * 3].reshape(n, h, w, 3).astype(np.int16)
1102
+
1103
+ # --- 1. read the plate colour off the top-left corner, which is always plate ---
1104
+ corner = frames(meme, 200, 200, "fps=1,crop=200:200:20:20")
1105
+ plate = corner.reshape(-1, 3).mean(0).astype(int)
1106
+ digital = bool(plate[1] > 240 and plate[0] < 40 and plate[2] < 40)
1107
+ key_hex = "0x%02X%02X%02X" % tuple(plate)
1108
+ tol, soft, despill = (0.18, 0.03, False) if digital else (0.30, 0.08, True)
1109
+ print(f"plate {key_hex} ({'digital' if digital else 'filmed'}) "
1110
+ f"-> chromakey {tol}:{soft}, despill={despill}")
1111
+
1112
+ # --- 2. sample at 10fps: the subject's PRESENCE WINDOW and its bounding box ---
1113
+ FPS_S = 10
1114
+ sw, sh = 320, int(320 * H / W) // 2 * 2
1115
+ fr = frames(meme, sw, sh, f"fps={FPS_S},scale={sw}:{sh}")
1116
+ r, g, b = fr[..., 0], fr[..., 1], fr[..., 2]
1117
+ subject = ~((g > 90) & (g - r > 40) & (g - b > 40))
1118
+ coverage = subject.mean(axis=(1, 2))
1119
+
1120
+ # "present" = at least a third of this clip's own peak coverage. A fixed
1121
+ # threshold fails on clips where the subject is genuinely tiny.
1122
+ present = np.nonzero(coverage > max(0.02, coverage.max() * 0.33))[0]
1123
+ if len(present) == 0:
1124
+ sys.exit("no subject found — is this really a keyed clip?")
1125
+ t_in, t_out = present[0] / FPS_S, (present[-1] + 1) / FPS_S
1126
+ print(f"subject present {t_in:.2f}s -> {t_out:.2f}s")
1127
+ if START is None:
1128
+ START = round(t_in + 0.05, 2) # a hair inside, so frame 0 has a person on it
1129
+ if DUR is None:
1130
+ DUR = round(min(t_out - START - 0.05, 15.0), 2)
1131
+ if DUR < 3.5:
1132
+ print(f"WARNING: only {DUR}s of subject — loop it or cast something else")
1133
+
1134
+ X0, Y0, X1, Y1 = sw, sh, 0, 0
1135
+ for i in present:
1136
+ ys, xs = np.nonzero(subject[i])
1137
+ if len(xs) < 50: # a frame that is all plate tells us nothing
1138
+ continue
1139
+ X0, X1 = min(X0, xs.min()), max(X1, xs.max())
1140
+ Y0, Y1 = min(Y0, ys.min()), max(Y1, ys.max())
1141
+
1142
+ pad = 20
1143
+ cx0 = max(0, (X0 * W // sw) - pad)
1144
+ cx1 = min(W, (X1 * W // sw) + pad)
1145
+ cy0 = max(0, (Y0 * H // sh) - pad)
1146
+ cy1 = min(H, (Y1 * H // sh) + pad)
1147
+ cw, ch = (cx1 - cx0) // 2 * 2, (cy1 - cy0) // 2 * 2
1148
+ print(f"subject box {cw}x{ch} at {cx0},{cy0} (source {W}x{H})")
1149
+
1150
+ target_w = int(CANVAS_W * WIDTH_FRAC) // 2 * 2
1151
+
1152
+ # --- 2b. AUTO-TUNE the key radius against the subject, not against the plate ---
1153
+ # chromakey works on CHROMA distance, so a pale, low-saturation subject (white
1154
+ # fur, grey hoodie, a blown-out cheek) sits closer to the plate in chroma space
1155
+ # than it looks. A radius chosen to kill the plate then makes those pixels
1156
+ # PARTIALLY transparent, and over a dark background they composite muddy — the
1157
+ # dog this was written against lost more than half its brightness (peak 228 ->
1158
+ # 108) and read grey-green, while the plate looked perfectly keyed.
1159
+ #
1160
+ # Nothing catches this: the key "worked", the alpha is doing what it was asked.
1161
+ # So: probe one mid-clip frame at each radius and keep the LARGEST one that
1162
+ # still retains the subject's brightness.
1163
+ def probe_key(tol_try, soft_try, despill_try, t):
1164
+ chain_p = (f"[0:v]crop={cw}:{ch}:{cx0}:{cy0},scale={target_w}:-2,setpts=PTS-STARTPTS,"
1165
+ f"chromakey={key_hex}:{tol_try}:{soft_try}")
1166
+ if despill_try:
1167
+ chain_p += ",despill=type=green:mix=0.35:expand=0"
1168
+ chain_p += ("[k];color=c=black:s=%dx%d[bgp];[bgp][k]overlay=0:0:shortest=1,format=rgb24[v]"
1169
+ % (target_w, target_w))
1170
+ p = subprocess.run(["ffmpeg", "-v", "error", "-ss", str(t), "-i", meme,
1171
+ "-filter_complex", chain_p, "-map", "[v]", "-vframes", "1",
1172
+ "-pix_fmt", "rgb24", "-f", "rawvideo", "-"], capture_output=True)
1173
+ n = target_w * target_w * 3
1174
+ if len(p.stdout) < n:
1175
+ return None
1176
+ a = np.frombuffer(p.stdout[:n], np.uint8).reshape(target_w, target_w, 3).astype(np.int16)
1177
+ flat = a.reshape(-1, 3)
1178
+ lum = flat.mean(1)
1179
+ lit = flat[lum > max(lum.max() * 0.75, 1)] # the subject's bright side
1180
+ green_left = ((flat[:, 1] - flat[:, 0] > 40) & (flat[:, 1] - flat[:, 2] > 40)).mean()
1181
+ return lit.mean(), green_left
1182
+
1183
+ t_probe = START + DUR / 2
1184
+ # chromakey's similarity floor is 1e-05, not 0 — a literal 0 is a hard filter error
1185
+ src_lit, _ = probe_key(1e-5, 0.0, False, t_probe) # ~nothing keyed = the truth
1186
+ best = None
1187
+ for tol_try in (0.34, 0.28, 0.22, 0.18, 0.14, 0.10, 0.07):
1188
+ r = probe_key(tol_try, soft, despill, t_probe)
1189
+ if r is None:
1190
+ continue
1191
+ lit, green_left = r
1192
+ keep = lit / src_lit if src_lit else 1.0
1193
+ ok = keep > 0.88 and green_left < 0.02 # subject intact AND plate gone
1194
+ print(f" tol {tol_try:.2f}: brightness kept {keep*100:5.1f}%, "
1195
+ f"green left {green_left*100:4.1f}% {'ok' if ok else ''}")
1196
+ if ok:
1197
+ best = tol_try
1198
+ break
1199
+ if best is None:
1200
+ print(" no radius satisfies both — keeping the sampled default, CHECK THE FRAME")
1201
+ else:
1202
+ tol = best
1203
+ if "--tolerance" in sys.argv and sys.argv[sys.argv.index("--tolerance") + 1] != "auto":
1204
+ tol = opt("--tolerance", tol)
1205
+ print(f"key radius {tol}")
1206
+
1207
+ # --- 3. one pass: crop -> scale -> key -> bottom-anchored overlay, audio kept ---
1208
+ # setpts=PTS-STARTPTS is load-bearing: after -ss the keyed stream's first frame
1209
+ # carries a PTS a hair above 0, so overlay emits one BACKGROUND-ONLY frame at
1210
+ # t=0 — and t=0 is the thumbnail. Rebasing to zero is the whole fix.
1211
+ chain = (f"[1:v]crop={cw}:{ch}:{cx0}:{cy0},scale={target_w}:-2,setpts=PTS-STARTPTS,"
1212
+ f"chromakey={key_hex}:{tol}:{soft}")
1213
+ if despill:
1214
+ chain += ",despill=type=green:mix=0.35:expand=0"
1215
+ chain += "[k];[0:v]scale=%d:%d:force_original_aspect_ratio=increase," % (CANVAS_W, CANVAS_H)
1216
+ chain += f"crop={CANVAS_W}:{CANVAS_H},setsar=1[bgv];"
1217
+ chain += f"[bgv][k]overlay=(W-w)/2:H-h-{BOTTOM}:shortest=1,format=yuv420p[v]"
1218
+
1219
+ bg_in = ["-loop", "1", "-i", bg] if bg.lower().endswith((".png", ".jpg", ".jpeg")) else ["-i", bg]
1220
+ cmd = (["ffmpeg", "-y", "-v", "error"] + bg_in +
1221
+ ["-ss", str(START), "-t", str(DUR), "-i", meme,
1222
+ "-filter_complex", chain,
1223
+ "-map", "[v]", "-map", "1:a?",
1224
+ "-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
1225
+ "-r", str(FPS), "-t", str(DUR),
1226
+ "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
1227
+ "-c:a", "aac", "-b:a", "128k", out])
1228
+ subprocess.run(cmd, check=True)
1229
+ print("wrote", out)
1230
+ ```
1231
+
1232
+ Then look at it before you build the composition — always:
1233
+
1234
+ ```bash
1235
+ ffmpeg -v error -i out.mp4 -vf "fps=1,scale=200:-1,tile=7x1" -frames:v 1 check.png
1236
+ ```
1237
+
1238
+ **Green surviving at the edges** → the plate was filmed, not digital; raise `--tolerance`.
1239
+ **Grey, lifeless skin** → despill ran on a digital plate; it should not have.
1240
+
1241
+ ---
1242
+
1243
+ ## Appendix B — the caption layer
1244
+
1245
+ A static, plated card: block centred, words left-aligned, no animation, `data-start="0"`.
1246
+ This is the exact markup from the worked example, which measures 49.8% block centre.
1247
+
1248
+ ```html
1249
+ <!-- Layout B: caption on a translucent plate over the background, above the subject.
1250
+ The WRAPPER is full width and centres; the PLATE is an inline-block that rides
1251
+ that centre. left:7% would pin the plate to 7% and leave the frame lopsided. -->
1252
+ <div id="caption" data-start="0" data-duration="5.9" data-end="5.9"
1253
+ data-track-index="2" data-layer-kind="text" data-layer-mode="publish"
1254
+ style="position:absolute; left:0; top:13%; width:100%;">
1255
+ <div class="capwrap"><div class="memecap">when i've had 10 tabs open<br>since 7pm for one scallop roll<br>and we're getting pizza again</div><br><span class="handle">dishcover.io</span></div>
1256
+ </div>
1257
+
1258
+ <style>
1259
+ @font-face { font-family:"TikTok Sans"; src:url("media/fonts/TikTokSans-700.woff2") format("woff2");
1260
+ font-weight:700; font-display:block; }
1261
+ .capwrap{ text-align:center; } /* centres the BLOCK and the handle under it */
1262
+ .handle{ /* the offer, as a BYLINE — see Rule 1 */
1263
+ display:inline-block;
1264
+ margin-top:16px;
1265
+ font-family:"TikTok Sans";
1266
+ font-weight:700;
1267
+ font-size:34px; /* 63% of the caption — must stay <=72% */
1268
+ letter-spacing:.02em;
1269
+ color:rgba(255,255,255,.72); /* alpha <=0.85 */
1270
+ background:rgba(0,0,0,.42);
1271
+ padding:8px 18px;
1272
+ border-radius:10px;
1273
+ }
1274
+ .memecap{
1275
+ font-family:"TikTok Sans"; /* no fallback chain on purpose — see Typography DNA */
1276
+ font-weight:700;
1277
+ font-size:54px;
1278
+ line-height:1.26;
1279
+ color:#fff;
1280
+ text-align:left; /* keeps the WORDS ragged-right, not title-carded */
1281
+ background:rgba(0,0,0,.58);
1282
+ padding:26px 32px;
1283
+ border-radius:18px;
1284
+ display:inline-block;
1285
+ max-width:88%;
1286
+ /* no text-shadow, no -webkit-text-stroke, no animation, no transition */
1287
+ }
1288
+ </style>
1289
+ ```
1290
+
1291
+ For **Layout A**, the same block with `background:#fff; color:#111;` sitting in the top plate, and
1292
+ the meme on its own layer below it at `top:30%`.
1293
+
1294
+ **Break the lines yourself** with `<br>`, at the clause, to **near-equal length**. The collapse
1295
+ (`and we're getting pizza again`) starts its own line. Never let the renderer wrap a meme caption —
1296
+ its break lands mid-thought about half the time and the joke reads as two half-jokes.
1297
+
1298
+ The `<br>` before the handle is deliberate: it drops the byline onto its own line under the plate
1299
+ rather than beside it, and it inherits the wrapper's centring so plate and handle share one axis.
1300
+
1301
+ **Add the poster layer.** A one-shot composition renders frame 0 before the video element has
1302
+ decoded, so frame 0 ships as bare background — and frame 0 is the thumbnail:
1303
+
1304
+ ```html
1305
+ <img id="poster" class="clip" data-start="0" data-duration="5.9" data-end="5.9"
1306
+ data-track-index="0" data-layer-kind="image" data-layer-mode="publish"
1307
+ src="media/poster.png" style="width:100%;height:100%;object-fit:cover">
1308
+ ```
1309
+
1310
+ ```bash
1311
+ ffmpeg -i media/scene.mp4 -vframes 1 -update 1 media/poster.png # frame 0 of the same clip
1312
+ ```
1313
+
1314
+ **Symlink `index.html` → `composition.html`** or `hyperframes render` exits with
1315
+ *"No composition found."*
1316
+
1317
+ ---
1318
+
1319
+ ## Appendix C — build the shelf index once
1320
+
1321
+ The shelf pages at 200, the CLI cannot follow the cursor, and the slugs are the index — so pull the
1322
+ whole thing once through the REST route and grep the file instead of re-querying.
1323
+
1324
+ ```python
1325
+ #!/usr/bin/env python3
1326
+ """meme-index.py — pull every MemeScreens raw into one greppable TSV.
1327
+
1328
+ The `--query` filter on this endpoint is thin and inconsistent (a search for
1329
+ "hungry food eating" returns nothing on a shelf full of people eating), and the
1330
+ AI descriptions are blind to the meme's identity. So: pull it all, index by slug.
1331
+
1332
+ Paging note: `vidfarm public-raws` has NO --cursor flag and the server caps a
1333
+ shelf at 200 per call, so the CLI alone can only ever show you the first 200 of
1334
+ a 1000-clip shelf — silently, with a next_cursor you cannot use. Go through
1335
+ `vidfarm api GET` instead, which does take one.
1336
+ """
1337
+ import json, subprocess, re
1338
+
1339
+ SHELVES = ("greenscreen", "text-graphics", "lifestyle", "b-roll", "reaction", "scroll-stopper")
1340
+
1341
+ def fetch(cat, cursor):
1342
+ path = f"/api/v1/public-raws?category={cat}&limit=200"
1343
+ if cursor:
1344
+ path += f"&cursor={cursor}"
1345
+ out = subprocess.run(["vidfarm", "api", "GET", path],
1346
+ capture_output=True, text=True).stdout
1347
+ brace = out.find("{") # first line is a coloured "GET … → 200" status line
1348
+ if brace < 0:
1349
+ return None
1350
+ try:
1351
+ return json.JSONDecoder().raw_decode(out[brace:])[0]
1352
+ except ValueError:
1353
+ return None
1354
+
1355
+ rows, seen = [], set()
1356
+ for cat in SHELVES:
1357
+ cursor, pages = None, 0
1358
+ while pages < 20: # backstop; a shelf that never ends is a bug, not a shelf
1359
+ d = fetch(cat, cursor)
1360
+ if not d or not d.get("raws"):
1361
+ break
1362
+ for x in d["raws"]:
1363
+ if x["rawId"] in seen:
1364
+ continue
1365
+ seen.add(x["rawId"])
1366
+ name = re.sub(r"-[0-9a-f]{6,}$", "", x["slugId"] or "").replace("-", " ")
1367
+ dur = x.get("durationSeconds") # nullable on some rows — do not assume
1368
+ rows.append((x["rawId"], x.get("sourceType") or "?",
1369
+ round(dur, 1) if dur else "?",
1370
+ name, (x.get("description") or "").replace("\t", " ")))
1371
+ cursor, pages = d.get("next_cursor"), pages + 1
1372
+ if not cursor:
1373
+ break
1374
+ print(f" {cat}: {pages} page(s), {len(rows)} unique so far")
1375
+
1376
+ with open("meme-index.tsv", "w") as f:
1377
+ f.write("rawId\tsourceType\tsec\tslug\tdescription\n")
1378
+ for r in rows:
1379
+ f.write("\t".join(str(v) for v in r) + "\n")
1380
+ print(len(rows), "raws -> meme-index.tsv")
1381
+ ```
1382
+
1383
+ ```bash
1384
+ python3 meme-index.py
1385
+ grep -iE "confus|huh|stare|blank" meme-index.tsv | cut -f1,3,4 # cast by VERB, on the slug
1386
+ ```
1387
+
1388
+ The preview URL is derivable from the id, so downloading is a loop:
1389
+
1390
+ ```
1391
+ https://vidfarmprodstack-vidfarmbucket335ee12f-0vsvtd5earqy.s3.us-east-1.amazonaws.com/users/system/public-raws/<rawId>/source.mp4
1392
+ ```
1393
+
1394
+ ---
1395
+
1396
+ ## Appendix D — a worked example
1397
+
1398
+ **Offer:** Dishcovery (dishcover.io) — search NYC restaurants **by dish**, not by restaurant. Its own
1399
+ homepage says *"Don't search for a restaurant. Search for dinner."*
1400
+
1401
+ ```
1402
+ WHO: someone in NYC who already knows what they want to eat
1403
+ MOMENT: the 40 minutes between the craving and giving up on it
1404
+ ```
1405
+
1406
+ | | |
1407
+ |---|---|
1408
+ | Frame | `When I …` — the complaint is embarrassing, so first person |
1409
+ | Verb cast for | *deflating; accepting a bad outcome* |
1410
+ | Caption | `when i've had 10 tabs open` / `since 7pm for one scallop roll` / `and we're getting pizza again` |
1411
+ | Clip (🟡) | `my-disappointment-is-immeasurable-and-my-day-is-ruined` · 1280×720 · plate `0x00FF00` digital · key 0.34 |
1412
+ | Clip (🟢) | `hangover-dog` · 2160×3840 · plate `0x00DA00` filmed · key **0.22** |
1413
+ | Background | tier 1 — Pixabay, *"dark empty restaurant interior night"*, royalty-free, no attribution. Cropped to the window and table, `gblur=18`, `brightness=-0.06` |
1414
+ | Handle | `dishcover.io` — 34px against a 54px caption (63%), alpha .72, under the plate, up at frame 0 |
1415
+ | Audio | 🟡 clip: its own, −15.9 LUFS. 🟢 clip: **source is silent (−70 LUFS)** — ships silent, trending audio at upload |
1416
+
1417
+ **Rung 3, and not invented.** A testimonial on the customer's own homepage reads *"Do you realize I
1418
+ had 10 tabs open on Resy trying to find a sushi place with a scallop roll?"* — so the number (10)
1419
+ and the object (scallop roll) are the customer's own words, per Rule 6.
1420
+
1421
+ > ⚠️ **The first draft of this caption kept "resy" in it, and that was a Rule 8 violation** —
1422
+ > a real, named, adjacent company personified as the villain. It survived a full build, two renders
1423
+ > and a review, because a named brand *reads* like good rung-3 specificity. It was caught by a
1424
+ > human, not by a check. The fix keeps the specificity (`10 tabs`, `7pm`, `scallop roll`) and drops
1425
+ > the company. **When a real brand name is what makes your caption feel sharp, that is the signal to
1426
+ > replace it, not to keep it.**
1427
+
1428
+ **Three-way lock:** *standalone* — 10 tabs since 7pm ending in pizza is funny with no context;
1429
+ *problem space* — deleting that hour is the product's entire pitch; *enactment* — both clips hold a
1430
+ deadpan while something disappointing is already true.
1431
+
1432
+ **The background is the joke's world, not the product's.** An empty restaurant at night is the place
1433
+ they never got to. Blurred so it reads as a set, dark so the plate and the keyed subject both pop.
1434
+
1435
+ **Gates:** `vidfarm qa` 18/18 · `meme-qa.py` **26/26** on both builds (block centre measured at
1436
+ **49.8%**). The 🟢 build needs `--silent-ok`, and fails without it — which is the check working.
1437
+
1438
+ **Naming the offer — both tiers, built and measured.**
1439
+
1440
+ | | **v003 — tier 1** ✅ preferred | **v001 / v002 — tier 2** |
1441
+ |---|---|---|
1442
+ | Caption | `dishcover.io watching me` / `open 10 tabs since 7pm` / `for one scallop roll` | `when i've had 10 tabs open` / `since 7pm for one scallop roll` / `and we're getting pizza again` |
1443
+ | Frame | `<Entity> watching me …` — the offer as witness | `When I …` |
1444
+ | Naming | in the sentence, 12 words | subtext handle, 34px @ alpha .72 |
1445
+ | Handle | none — the caption already carries it | `dishcover.io` under the plate |
1446
+
1447
+ Tier 1 reads cleanly here because the meme is a face **looking at you**: the dog *is* dishcover.io,
1448
+ watching you fail. The payoff is `for one scallop roll` — the person's own dysfunction — so the
1449
+ offer is named and the joke still does not resolve. That is the whole trick.
1450
+
1451
+ **Elegance test on v003:** eight frames ✅ · 12 words ✅ · reads as a character not a sponsor ✅ ·
1452
+ collapse lands on the person ✅ · funny without knowing the product ✅. Five yeses, so tier 1 ships
1453
+ and the handle comes off.
1454
+
1455
+ **Six negative controls confirm the gates bite.** Tier 2: domain in the sentence, handle at 52px,
1456
+ handle deleted. Tier 1: offer moved into the payoff (`and dishcover.io found it`), resolution
1457
+ language (`so i finally found it`), and the offer removed from both places — each produces fatal
1458
+ failures, none silent.
1459
+
1460
+ The bait is in the post caption:
1461
+
1462
+ > 10 tabs open since 7pm and we ordered pizza again. what's the one dish you can never find who
1463
+ > serves it
1464
+
1465
+ ---
1466
+
1467
+ ## Appendix E — `meme-qa.py` (gate 2: enforce it on the RENDER)
1468
+
1469
+ Exits 1 on any fatal failure. Run it after every render, in CI if you have one.
1470
+
1471
+ ```bash
1472
+ python3 meme-qa.py renders/job-v001.mp4 work/job/v001/composition.html --offer dishcover.io --pole pain
1473
+ python3 meme-qa.py renders/job-v002.mp4 work/job/v002/composition.html --offer dishcover.io --pole win --silent-ok
1474
+ ```
1475
+
1476
+ ```python
1477
+ #!/usr/bin/env python3
1478
+ """
1479
+ meme-qa.py — enforce the meme-recaption harness on the RENDER, not on the markup.
1480
+
1481
+ `vidfarm qa` reads the composition DOM. It has never seen a pixel, so every
1482
+ defect this format actually produces walks straight past it: a blank thumbnail,
1483
+ an empty tail, a subject eaten by a loose key, a caption pinned flush-left, a
1484
+ background that competes with the joke, a silent audio track, a caption that
1485
+ names the product. All of those pass `vidfarm qa` today.
1486
+
1487
+ This checks the finished MP4 and the composition text together, and EXITS 1.
1488
+
1489
+ usage: meme-qa.py <render.mp4> <composition.html> [--offer dishcover.io]
1490
+ [--pole pain|win] [--silent-ok] [--json]
1491
+ """
1492
+ import json, re, subprocess, sys
1493
+ import numpy as np
1494
+
1495
+ render, comp = sys.argv[1], sys.argv[2]
1496
+ SILENT_OK = "--silent-ok" in sys.argv
1497
+ OFFER = (sys.argv[sys.argv.index("--offer") + 1].strip().lower()
1498
+ if "--offer" in sys.argv else None)
1499
+ # The pole changes what the payoff is allowed to do (Rule 2). Default to PAIN:
1500
+ # it is the stricter of the two, so an unset pole can never silently pass a
1501
+ # resolution that nobody decided to allow.
1502
+ POLE = (sys.argv[sys.argv.index("--pole") + 1].strip().lower()
1503
+ if "--pole" in sys.argv else "pain")
1504
+ html = open(comp).read()
1505
+ results = []
1506
+
1507
+
1508
+ def check(name, ok, detail, fatal=True):
1509
+ results.append({"check": name, "ok": bool(ok), "detail": detail, "fatal": fatal})
1510
+
1511
+
1512
+ def probe(path, vf, w, h, pre=(), post=("-vframes", "1")):
1513
+ # -ss/-sseof are INPUT options and go before -i; -vframes is an OUTPUT option
1514
+ # and goes after. Getting that backwards decodes nothing and reports no frame.
1515
+ p = subprocess.run(["ffmpeg", "-v", "error", *pre, "-i", path, "-vf", vf, *post,
1516
+ "-pix_fmt", "rgb24", "-f", "rawvideo", "-"], capture_output=True)
1517
+ n = len(p.stdout) // (w * h * 3)
1518
+ if n == 0:
1519
+ return None
1520
+ return np.frombuffer(p.stdout, np.uint8)[:n * w * h * 3].reshape(n, h, w, 3).astype(np.int16)
1521
+
1522
+
1523
+ W, H = 1080, 1920
1524
+ dur = float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
1525
+ "-of", "csv=p=0", render], capture_output=True,
1526
+ text=True).stdout.strip())
1527
+
1528
+ # ---------------------------------------------------------------- 1. duration
1529
+ check("duration_4_15s", 4.0 <= dur <= 15.0, f"{dur:.1f}s")
1530
+
1531
+ # ------------------------------------------- 2. frame 0 and the last frame LIVE
1532
+ # Sampled with -vframes 1, never with fps=N: `fps` hands you the middle of the
1533
+ # first interval, so a genuinely blank frame 0 renders as a perfect still.
1534
+ first = probe(render, "scale=270:480", 270, 480)
1535
+ last = probe(render, "scale=270:480", 270, 480, pre=("-sseof", "-0.3"))
1536
+ for label, fr in (("first", first), ("last", last)):
1537
+ if fr is None:
1538
+ check(f"{label}_frame_decodes", False, "no frame decoded")
1539
+ continue
1540
+ a = fr[0]
1541
+ # "alive" = real spatial detail, not a flat plate with a caption pasted on it
1542
+ band = a[int(480 * 0.42):, :] # below the caption zone = the subject's half
1543
+ check(f"{label}_frame_has_subject", band.std() > 12,
1544
+ f"lower-band stdev {band.std():.1f} (>12)")
1545
+
1546
+ # ------------------------------------------------------ 3. no residual green key
1547
+ mid = probe(render, "scale=270:480", 270, 480, pre=("-ss", str(dur / 2)))
1548
+ if mid is not None:
1549
+ a = mid[0]
1550
+ r, g, b = a[..., 0], a[..., 1], a[..., 2]
1551
+ green = ((g - r > 45) & (g - b > 45) & (g > 80)).mean()
1552
+ check("no_residual_green", green < 0.005, f"{green*100:.2f}% green pixels (<0.5%)")
1553
+
1554
+ # ------------------------------------- 4. caption block is horizontally CENTRED
1555
+ # Find the caption plate in frame 0 by its own darkness against the background,
1556
+ # then compare its horizontal centre to the canvas centre.
1557
+ if first is not None:
1558
+ a = first[0]
1559
+ top = a[: int(480 * 0.42)] # the caption zone
1560
+ lum = top.mean(2)
1561
+ text = lum > 170 # white type on a dark plate
1562
+ cols = np.nonzero(text.any(0))[0]
1563
+ rows = np.nonzero(text.any(1))[0]
1564
+ if len(cols) < 5:
1565
+ check("caption_visible_at_frame_0", False, "no bright text found in the top zone")
1566
+ else:
1567
+ check("caption_visible_at_frame_0", True, f"{len(cols)} text columns")
1568
+ x0, x1 = cols.min(), cols.max()
1569
+ centre = (x0 + x1) / 2 / 270
1570
+ off = abs(centre - 0.5)
1571
+ check("caption_horizontally_centred", off <= 0.03,
1572
+ f"block centre at {centre*100:.1f}% of width (target 50%, tol 3%)")
1573
+ # margins: the block must not run edge to edge, and must not hug one side
1574
+ check("caption_side_margins", x0 / 270 > 0.04 and x1 / 270 < 0.96,
1575
+ f"left {x0/270*100:.1f}%, right {x1/270*100:.1f}%")
1576
+ # vertical: inside the safe zone, and not jammed against the very top
1577
+ y0, y1 = rows.min() / 480, rows.max() / 480
1578
+ check("caption_vertical_placement", 0.08 <= y0 and y1 <= 0.85,
1579
+ f"spans {y0*100:.1f}%-{y1*100:.1f}% (safe zone 8-85%)")
1580
+ # Balance: hand-broken lines should be near-equal so the ragged edge is
1581
+ # shallow. Measure per LINE, not per pixel row — a row clipping the top of
1582
+ # an apostrophe is 3px wide and makes any caption look 100% ragged.
1583
+ rowhas = text.any(1)
1584
+ lines, run = [], None
1585
+ for i, on in enumerate(rowhas):
1586
+ if on and run is None:
1587
+ run = i
1588
+ elif not on and run is not None:
1589
+ lines.append((run, i)); run = None
1590
+ if run is not None:
1591
+ lines.append((run, len(rowhas)))
1592
+ lines = [(a_, b_) for a_, b_ in lines if b_ - a_ >= 4] # drop stray marks
1593
+ widths = []
1594
+ for a_, b_ in lines:
1595
+ c = np.nonzero(text[a_:b_].any(0))[0]
1596
+ if len(c):
1597
+ widths.append(c.max() - c.min())
1598
+ if len(widths) >= 2:
1599
+ rag = 1 - (min(widths) / max(widths))
1600
+ check("caption_lines_balanced", rag < 0.45,
1601
+ f"{len(widths)} lines, raggedness {rag*100:.0f}% (<45%)", fatal=False)
1602
+
1603
+ # ------------------------------------------ 5. the background is PUSHED DOWN
1604
+ # A background that competes with the caption is the format's quietest failure.
1605
+ # Measure high-frequency detail in the top corners — the part that is background
1606
+ # on every layout — and require it to be soft.
1607
+ if mid is not None:
1608
+ a = mid[0].mean(2)
1609
+ # Measure ABOVE the caption (top 11%), never through it — the plate's own hard
1610
+ # edges read as background detail and the check fires on a correct build.
1611
+ strip = a[: int(480 * 0.11), :]
1612
+ lap = np.abs(np.diff(strip, axis=1)).mean()
1613
+ check("background_pushed_down", lap < 6.0,
1614
+ f"detail above the caption {lap:.2f} (<6.0 — blur/darken it)", fatal=False)
1615
+ check("background_not_blown_out", strip.mean() < 150,
1616
+ f"top-strip luma {strip.mean():.0f} (<150)", fatal=False)
1617
+
1618
+ # ------------------------------------------------------------------- 6. audio
1619
+ out = subprocess.run(["ffmpeg", "-v", "info", "-i", render, "-af", "ebur128", "-f", "null", "-"],
1620
+ capture_output=True, text=True).stderr
1621
+ m = re.search(r"Integrated loudness:\s*\n\s*I:\s*(-?[\d.]+)\s*LUFS", out)
1622
+ lufs = float(m.group(1)) if m else None
1623
+ if lufs is None:
1624
+ check("audio_measured", False, "could not measure loudness")
1625
+ elif lufs < -50:
1626
+ check("audio_level", SILENT_OK,
1627
+ f"{lufs:.1f} LUFS = SILENT. Declare it in the handoff and pass --silent-ok")
1628
+ else:
1629
+ check("audio_level", -20 <= lufs <= -13, f"{lufs:.1f} LUFS (target -16, band -20..-13)")
1630
+
1631
+ # ------------------------------------------------- 7. the caption, as written
1632
+ cap = re.search(r'class="memecap"[^>]*>(.*?)</div>', html, re.S)
1633
+ if not cap:
1634
+ check("caption_found", False, "no .memecap block in the composition")
1635
+ else:
1636
+ check("caption_found", True, "")
1637
+ text_raw = re.sub(r"<br\s*/?>", " ", cap.group(1))
1638
+ text_cap = re.sub(r"<[^>]+>", "", text_raw).strip()
1639
+ words = text_cap.split()
1640
+ check("caption_word_count", 8 <= len(words) <= 20, f"{len(words)} words (8-20): {text_cap!r}")
1641
+ check("caption_no_end_punctuation", not re.search(r"[.!?]$", text_cap),
1642
+ f"ends {text_cap[-12:]!r}")
1643
+ check("caption_not_all_caps", not text_cap.isupper(), "casing")
1644
+ # NOTE the entity frame allows . and - so a domain can BE the subject
1645
+ # ("dishcover.io watching me ..."), which is the tier-1 naming vehicle.
1646
+ FRAMES = (r"^when i\b", r"^when you\b", r"^me when\b", r"^me trying\b",
1647
+ r"^my \w+ when\b", r"^why my\b",
1648
+ r"^[\w][\w'.\- ]{0,24} (when|with|after|watching|reading|seeing|hearing) ")
1649
+ check("caption_uses_a_frame", any(re.search(f, text_cap, re.I) for f in FRAMES),
1650
+ f"must open with one of the seven frames — got {' '.join(words[:4])!r}")
1651
+ check("caption_is_lowercase_start", text_cap[:1].islower(),
1652
+ "meme captions are typed, not headlined", fatal=False)
1653
+ # the product must not be in the video (Rule 1), and it must not resolve (Rule 2)
1654
+ BANNED = ("link in bio", "sign up", "free trial", "get started", "book a demo",
1655
+ "download now", "learn more", "comment below", "so i", "so now",
1656
+ "until i found", "then i found")
1657
+ hit = [b for b in BANNED if b in text_cap.lower()]
1658
+ check("caption_no_cta_or_resolution", not hit, f"found {hit}")
1659
+ # hand-broken lines are required — the renderer's own wrap lands mid-thought
1660
+ check("caption_lines_hand_broken", "<br" in cap.group(1),
1661
+ "use <br> at the clause; never let the renderer wrap a meme caption")
1662
+
1663
+ # ------------------------------------------------- 7b. naming the offer
1664
+ # The offer IS named on the video. Two ways, in priority order:
1665
+ # tier 1 — inside the caption, as the SUBJECT of the setup
1666
+ # tier 2 — the subtext handle, when tier 1 will not read elegantly
1667
+ # At least one must be true. What must NEVER be true is the offer appearing in
1668
+ # the PAYOFF — the caption's final clause — because that is the joke resolving
1669
+ # into a pitch, and a meme that resolves is an ad with extra steps (Rule 2).
1670
+ if OFFER:
1671
+ stem = OFFER.split(".")[0]
1672
+ hnd = re.search(r'class="handle"[^>]*>(.*?)</', html, re.S)
1673
+ handle_txt = hnd.group(1).lower() if hnd else ""
1674
+ has_handle = bool(hnd) and OFFER in handle_txt
1675
+ cap_txt = text_cap.lower() if cap else ""
1676
+ in_caption = stem in cap_txt
1677
+
1678
+ check("offer_named_somewhere", in_caption or has_handle,
1679
+ f"tier 1 caption={in_caption}, tier 2 handle={has_handle} — name it in one of them")
1680
+ if in_caption:
1681
+ print(f" · pole: {POLE.upper()} · naming strategy: TIER 1 (in-caption)")
1682
+ elif has_handle:
1683
+ print(f" · pole: {POLE.upper()} · naming strategy: TIER 2 (subtext handle)")
1684
+
1685
+ # THE line that must not be crossed on the PAIN pole: the offer in the payoff.
1686
+ # On the WIN pole the relief IS the joke, so the offer belongs in the last
1687
+ # clause and this check is deliberately not run.
1688
+ if in_caption and cap and POLE == "pain":
1689
+ raw_lines = [re.sub(r"<[^>]+>", "", l).strip()
1690
+ for l in re.split(r"<br\s*/?>", cap.group(1))]
1691
+ raw_lines = [l for l in raw_lines if l]
1692
+ payoff = raw_lines[-1].lower() if raw_lines else ""
1693
+ check("offer_not_in_payoff", stem not in payoff,
1694
+ f"payoff is {payoff!r} — the offer belongs in the SETUP, not the collapse")
1695
+ # ...and the offer must not be the thing that WORKS.
1696
+ # Matching literal phrases ("found it") is not enough: "dishcover.io found
1697
+ # the scallop roll" resolves just as hard and contains none of them. So
1698
+ # look for a SUCCESS VERB anywhere after the offer's name, plus the
1699
+ # generic turn phrases that resolve without naming anything.
1700
+ SUCCESS = (r"found|fixed|saved|solved|cleared|handled|sorted|caught|nailed|"
1701
+ r"works?|worked|delivered|got it|did it|sent|booked|showed me|told me")
1702
+ turn = [r for r in (r"\bso i\b", r"\bthen i\b", r"\buntil i\b", r"\bnow i\b",
1703
+ r"\bfinally\b", r"\bturns out\b")
1704
+ if re.search(r, cap_txt)]
1705
+ after = cap_txt.split(stem, 1)[1] if stem in cap_txt else ""
1706
+ verb = re.search(rf"\b({SUCCESS})\b", after)
1707
+ hit = turn + ([f"{stem} … {verb.group(1)}"] if verb else [])
1708
+ check("caption_does_not_resolve", not hit,
1709
+ f"resolution language {hit} — on the pain pole the meme is the complaint "
1710
+ f"(use --pole win if the relief IS the joke)")
1711
+
1712
+ # Both poles: the joke may name the OFFER, never a FEATURE. A feature needs
1713
+ # product context to parse, so it lands only on people who already bought —
1714
+ # which is the cold-viewer test, mechanised.
1715
+ if cap:
1716
+ FEATURE = (r"\bv\d+\b", r"\bversion \d", r"\bbeta\b", r"\bapi\b", r"\bdashboard\b",
1717
+ r"\bintegration\b", r"\bsync\b", r"\bplugin\b", r"\bworkflow\b",
1718
+ r"\bplatform\b", r"\bfeature\b", r"\bpremium\b", r"\bpro plan\b",
1719
+ r"\bautomation\b", r"\bAI-powered\b")
1720
+ fhit = [f for f in FEATURE if re.search(f, cap_txt, re.I)]
1721
+ check("no_feature_words", not fhit,
1722
+ f"feature language {fhit} — write the niche's experience, not the product's mechanism")
1723
+
1724
+ # If a handle is present it is a BYLINE: subordinate and dimmed, or it is a CTA.
1725
+ if hnd:
1726
+ fs = {c: int(m.group(1)) for c in ("memecap", "handle")
1727
+ for m in [re.search(rf"\.{c}\s*\{{[^}}]*font-size:\s*(\d+)px", html, re.S)] if m}
1728
+ if "memecap" in fs and "handle" in fs:
1729
+ ratio = fs["handle"] / fs["memecap"]
1730
+ check("handle_is_subordinate", ratio <= 0.72,
1731
+ f"handle {fs['handle']}px vs caption {fs['memecap']}px = {ratio*100:.0f}% (<=72%)")
1732
+ dim = re.search(r"\.handle\s*\{[^}]*color:\s*rgba\([^)]*?,\s*([\d.]+)\s*\)",
1733
+ html, re.S)
1734
+ check("handle_is_dimmed", bool(dim) and float(dim.group(1)) <= 0.85,
1735
+ f"handle alpha {dim.group(1) if dim else 'opaque'} (<=0.85)", fatal=False)
1736
+
1737
+ # --------------------------------------------- 8. one shot, no brand chrome
1738
+ check("single_shot", len(re.findall(r"<video\b", html)) == 1,
1739
+ f"{len(re.findall(r'<video', html))} video layer(s) — a meme recaption is ONE shot")
1740
+ check("no_animation_on_caption",
1741
+ not re.search(r"\.memecap\s*\{[^}]*animation", html, re.S),
1742
+ "the caption is a static card — no animation, no word-pop")
1743
+
1744
+ # ----------------------------------------------------------------- verdict
1745
+ fails = [r for r in results if not r["ok"]]
1746
+ hard = [r for r in fails if r["fatal"]]
1747
+ if "--json" in sys.argv:
1748
+ print(json.dumps(results, indent=1))
1749
+ else:
1750
+ for r in results:
1751
+ mark = "PASS" if r["ok"] else ("FAIL" if r["fatal"] else "WARN")
1752
+ print(f" [{mark}] {r['check']:32s} {r['detail']}")
1753
+ print(f"\n{len(results)-len(fails)}/{len(results)} passed"
1754
+ f"{'' if not fails else f' — {len(hard)} fatal, {len(fails)-len(hard)} warning'}")
1755
+ sys.exit(1 if hard else 0)
1756
+ ```