@officexapp/vidfarm-devcli 0.21.52 → 0.21.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/vidfarm/SKILL.md +6 -2
- package/.agents/skills/vidfarm/harnesses/hooks.HARNESS.md +10 -3
- package/.agents/skills/vidfarm/harnesses/product-explainer.HARNESS.md +1 -1
- package/.agents/skills/vidfarm/harnesses/short-form.HARNESS.md +2 -0
- package/.agents/skills/vidfarm/references/assets-and-sourcing.md +44 -4
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +1 -1
- package/.agents/skills/vidfarm/references/content-ideas.md +72 -1
- package/.agents/skills/vidfarm/references/core-workflows.md +17 -2
- package/.agents/skills/vidfarm/references/editor-workflows.md +39 -9
- package/.agents/skills/vidfarm/references/reviewing-renders.md +1 -0
- package/SKILL.director.md +180 -19
- package/SKILL.md +9 -2
- package/clipper.md +22 -0
- package/dist/src/cli.js +98 -5
- package/dist/src/devcli/composition-edit.js +36 -3
- package/dist/src/devcli/marketplace-gigs.js +2 -2
- package/dist/src/devcli/qa-check.js +7 -3
- package/dist/src/devcli/shared-folder.js +333 -44
- package/dist/src/devcli/skill-docs.js +2 -2
- package/experimental/meme-recaption.md +1756 -0
- package/experimental/sticker-slideshow-tips.md +1486 -0
- package/experimental/ugc-reaction-greenscreen.md +1963 -0
- package/experimental/wall-text-pov-ugc.md +2036 -0
- package/marketplace.md +82 -8
- package/package.json +6 -1
- package/public/assets/file-directory-app.js +33 -33
- package/public/assets/homepage-client-app.js +14 -14
|
@@ -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
|
+
```
|