@officexapp/vidfarm-devcli 0.21.53 → 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.
@@ -0,0 +1,2036 @@
1
+ ---
2
+ name: wall-text-pov-ugc
3
+ video_type: Wall of text over an ambient POV or UGC scene — one unbroken take, one static block of unplated type, no voiceover, no cuts (TikTok / Reels / Shorts)
4
+ checks:
5
+ duration_sec: 8-12 # 8s FLOOR so a trending audio overlay gets a real phrase of a
6
+ # track, not a fragment. See "Length". words/8 sets it inside the band.
7
+ aspect: 9:16
8
+ first_frame_visual: required
9
+ first_frame_text: required
10
+ text_by_sec: 0 # the whole wall is up at frame 0. There is no reveal, no build
11
+ captions: optional # there is NO subtitle track and no voiceover to caption. The
12
+ # wall IS the text layer. A caption run here would flag every
13
+ # video in the format for a defect it structurally cannot have
14
+ audio: required # a flat ambient bed. See "Audio DNA" — measured, not chosen by ear
15
+ font_regime: required
16
+ safe_zone: required
17
+ scenes: 1-2 # one unbroken take, plus the frame-0 poster still
18
+ max_scene_sec: 12
19
+ max_text_cards: 2 # the wall, and the handle. Nothing else
20
+ max_simultaneous_text: 2
21
+ # ⚠️ THIS IS THE FORMAT. Every other harness caps this at 7-12 to stop wall-of-text.
22
+ # Here the wall IS the mechanic, so the cap is opened right up.
23
+ #
24
+ # There is deliberately NO MINIMUM, here or anywhere else in this file. A floor would
25
+ # be an instruction to pad, and padding is the format's most common fatal failure —
26
+ # invisible to every check in this file and instantly obvious to a reader. Short walls
27
+ # are allowed and are often better. Read "Density" before you touch this number.
28
+ max_words_per_cue: 120
29
+ max_dead_air_sec: 12 # the wall never leaves the screen. There is no dead air here
30
+ max_tail_sec: 0.3 # the text is legible on the LAST frame too — it loops
31
+ forbid_text:
32
+ - link in bio
33
+ - sign up
34
+ - free trial
35
+ - get started
36
+ - book a demo
37
+ - download now
38
+ - learn more
39
+ - swipe up
40
+ - dm me
41
+ # the bait is the wall's own last line and the post caption. Never a CTA card:
42
+ - comment below
43
+ ---
44
+
45
+ # Wall Text × POV / UGC
46
+
47
+ > A public Vidfarm prompt. Read it end to end before you build. Derived by measurement from
48
+ > two reference templates — `template_019fd6b095d175578dc382aedd8c8f72` ("Night Drive
49
+ > Confession", 5.13s) and `template_019fd6b04c1775bbbafaeaecf157df6d` ("Night Drive Text
50
+ > Story", 5.10s), both from the same TikTok account, both catalogued under
51
+ > **Text / Story-Driven**. Every number in this file with a decimal point in it came off
52
+ > those two files, not off taste.
53
+
54
+ The simplest format in the catalog, and the one agents get wrong fastest — because every
55
+ instinct a video editor has is the wrong instinct here.
56
+
57
+ Two ingredients. There is no third.
58
+
59
+ | Ingredient | What it is | Where it comes from |
60
+ |---|---|---|
61
+ | 🎞 **The scene** | One unbroken ambient take. A POV the viewer looks *out of*, or a UGC scene with a person in it who is not talking to camera | Public raws (`ugc-reaction`, `lifestyle`), the customer's own footage, a phone out a car window, or a generated plate |
62
+ | 🧱 **The wall** | 40–120 words of static, unplated type, centred on the frame, up at frame 0 and unchanged to the last frame | You. It is the only original work in the video and it is ~95% of the outcome |
63
+
64
+ **No cuts. No voiceover. No subtitles. No music with a drop. No zoom, no Ken Burns, no
65
+ animation on the text.** Every one of those is an editor doing their job, and every one of
66
+ them destroys the format. What you are making is closer to a **screenshot that happens to
67
+ move** than to a video.
68
+
69
+ ---
70
+
71
+ ## The one test
72
+
73
+ > **Is every single line of the wall worth reading?**
74
+
75
+ Not "is it long enough". **Length is an output of having something to say, and it is the one
76
+ thing in this format that is trivially faked.** A wall padded to hit a word count is the format's
77
+ most common and most fatal failure, because padding is invisible to every machine check in this
78
+ file and instantly obvious to a reader — they feel the sentence that adds nothing, and they
79
+ leave on it.
80
+
81
+ So the test is per line: **cover any line and ask whether the wall got worse.** If it did not,
82
+ that line is gone. Run it on every line, including the first.
83
+
84
+ ### The second test — does one play get them to the halfway mark?
85
+
86
+ The retention mechanic, and the one number in this file the clip length is chosen from.
87
+
88
+ > **The clip runs long enough to read HALF the wall. No more.**
89
+
90
+ ```
91
+ read_time = words / 4.0 # ~4 words/sec, the fast end of phone reading
92
+ duration = words / 8.0 # half the read time — THIS IS THE RULE
93
+ ```
94
+
95
+ `duration = words / 8`, **clamped to 8–12 seconds**. A 64-word wall gets exactly 8.0s; a 90-word
96
+ wall gets 11.3s. **Never pick a duration by feel and never inherit one from the source clip.**
97
+
98
+ The 8-second floor is not about reading. It is so a poster can drop a trending sound on the
99
+ video in the platform's own editor and get **a recognisable phrase of a track** rather than a
100
+ fragment. That is worth more than the second or two of ratio it costs, because trending audio is
101
+ a distribution surface this format otherwise has no access to — it has no voiceover, no music of
102
+ its own, and nothing else the algorithm can match on.
103
+
104
+ #### Why half, and not a quarter
105
+
106
+ Expressed as a ratio, `read_time / duration`, half is **2.0** — and 2.0 is the number to build
107
+ to, not the highest number you can reach.
108
+
109
+ A viewer who reaches the halfway mark of a wall has **committed**. They are mid-sentence, they
110
+ know where they are, and one more pass finishes it — so they take it, and you bank two full
111
+ plays with near-certainty. That is the mechanic working.
112
+
113
+ Push the ratio to 4 and you have not doubled anything. The viewer now needs **four** passes,
114
+ and on the first one they get a quarter of the way in, feel the size of what is left, and
115
+ decide it is not worth the work. You traded a near-certain second play for an unlikely fourth,
116
+ which is a bad trade twice over: a bail at 25% is also a much worse retention signal than a
117
+ completed loop.
118
+
119
+ **Band: 1.8–2.4** (they finish 42–56% on the first pass). Outside it, move the *clip*.
120
+
121
+ | | Words | Read | Duration | Ratio | |
122
+ |---|---|---|---|---|---|
123
+ | Reference A — "Night Drive Confession" | 55 | 13.8s | 5.13s | 2.68 | a little past half |
124
+ | Reference B — "Night Drive Text Story" | 83 | 20.8s | 5.10s | 4.07 | quarter-of-the-way — too far |
125
+
126
+ **The references are not the model here.** A is close to right; B is the failure this rule
127
+ exists to prevent, and it is the more-viewed of the two only because the wall is very good. At
128
+ `words / 8` B would run 10.4s.
129
+
130
+ > ⚠️ **The trap, spelled out, because the arithmetic invites it.** `ratio = words / (4 × duration)`
131
+ > has two variables and an agent will reach for the wrong one. **Words are never the lever.**
132
+ > There is no minimum word count and no minimum ratio in this format — a 33-word wall carrying
133
+ > one clean observation, on a 4.1s clip, is a finished video. If the ratio is off, the clip is
134
+ > the wrong length. Change the clip.
135
+
136
+ #### The 8s floor sets the format's natural word range: 64–96
137
+
138
+ `words / 8` only lands inside 8–12s when the wall is **64 to 96 words**. That is the band where
139
+ the music floor and the 50% rule agree, and it is where most of a batch should sit.
140
+
141
+ Outside it, one of the two has to give, and **it is never the wall that gives**:
142
+
143
+ | Wall | What happens | Call it what it is |
144
+ |---|---|---|
145
+ | **64–96 words** | `words/8` lands in band. Ratio 2.00 | The sweet spot |
146
+ | **48–63 words** | Clamps up to 8.0s. Ratio 1.3–1.9 | **A partial loop.** They get most of the way through and still need a second pass. Barely worse than the sweet spot |
147
+ | **Under ~48 words** | Clamps up to 8.0s. At 33 words the ratio is **1.03** — 97% read in one play | **A single-read video.** The loop mechanic is gone. A legitimate choice, not a defect, but you traded it and you should know you did |
148
+ | **Over 96 words** | Clamps to 12.0s. Ratio climbs past 2.0 | Cut the wall. This is the one case where the wall is genuinely too long |
149
+
150
+ **Do not pad a short wall up to 64 words to recover the ratio.** That is the exact failure the
151
+ density section exists to prevent, and a padded 64-word wall performs worse than a sharp 33-word
152
+ one that only gets read once. If a short wall is the right wall, ship it and accept one play.
153
+
154
+ Everything a short wall loses in loops it partly returns in legibility: at 33 words the fitter
155
+ returns **52px**, its ceiling, against 39–41px for a 70-word wall. Bigger, bolder, read at a
156
+ glance.
157
+
158
+ `walltext-build.py` computes this for you: `--dur auto` is the default.
159
+
160
+ ---|---|---|---|---|
161
+ | Reference A — "Night Drive Confession" | 55 | 13.8s | 5.13s | **2.68** |
162
+ | Reference B — "Night Drive Text Story" | 83 | 20.8s | 5.10s | **4.07** |
163
+
164
+ A 5-second video that takes 21 seconds to read banks four plays per person who commits to it,
165
+ and the platform cannot distinguish four committed plays from four people who loved it.
166
+
167
+ **Read the ratio in one direction only: it tells you how long to cut the clip.** It is not a
168
+ target the wall has to reach.
169
+
170
+ > ⚠️ **This is the trap, and it is worth spelling out because the arithmetic invites it.**
171
+ > `loop_ratio = words / (4 × duration)` has two variables, and an agent looking to raise it will
172
+ > reach for the wrong one every time. **Words are the numerator you must never touch.** The clip
173
+ > length is free — cutting a 7s plate to 4.5s raises the ratio by 55% and costs nothing, and a
174
+ > shorter clip reads as tighter besides.
175
+ >
176
+ > There is no minimum word count in this format, and there is no minimum ratio. A 34-word wall
177
+ > that is one clean observation, on a 4-second clip, is a **2.1** and it is a better video than
178
+ > a 90-word wall padded to a 4.4. Ship the short one.
179
+
180
+ Above ~6.0 the mechanic does invert — the wall reads as homework, the viewer bails on the first
181
+ pass rather than committing — so that end is a real ceiling. The bottom end is not a floor; it
182
+ is a note that your clip could be shorter.
183
+
184
+ ---
185
+
186
+ ## Density — the wall earns every word
187
+
188
+ Everything an agent knows about writing works against this format. Agents write prose: a
189
+ run-up, a restatement, a hedge, a tidy close. **Every one of those habits is a line a reader
190
+ abandons the video on.**
191
+
192
+ Assume the first draft is **30–40% too long**, and go find the words. On the reference walls the
193
+ poster's own contribution is almost nothing — Reference A's entire authored content, outside the
194
+ quote, is *"I saw a tiktok saying"* and *"Is this true?"*. Eight words.
195
+
196
+ ### Cut on sight
197
+
198
+ | Habit | Looks like | Fix |
199
+ |---|---|---|
200
+ | **The wind-up** | *"my girlfriend said something in the kitchen last week that i haven't stopped thinking about. she said…"* | *"my girlfriend said…"*. 15 words to 3. The claim is the hook; the run-up is throat-clearing |
201
+ | **The permission slip** | *"i want to admit something embarrassing."* · *"this might be controversial but"* | Delete. Just say the thing. Announcing a confession is not confessing |
202
+ | **Restatement** | The same point in two sentences with different words | Keep the sharper one |
203
+ | **The hedge** | *"maybe"* · *"kind of"* · *"i think"* · *"sort of"* | Delete. A confession is not qualified. One hedge can be deliberate; three is a voice with no spine |
204
+ | **The tidy close** | *"anyway"* · *"just something i've been thinking about"* · any summary of what was just said | Delete. Stop on the sharpest clause, a beat before it feels finished |
205
+ | **The list** | Three examples where one specific one lands harder | Keep the most specific. Three abstractions do not add up to one rung-4 |
206
+ | **Explaining the joke** | Naming the feeling the wall already produced | Delete. The reader got there |
207
+
208
+ ### The two-thirds test
209
+
210
+ Take the finished wall and cut it to two-thirds the words without losing an idea. **If you can,
211
+ the two-thirds version was the wall.** If you genuinely cannot, it was already dense. This takes
212
+ four minutes and it is the highest-leverage four minutes in the build.
213
+
214
+ ### Short walls are allowed and often better
215
+
216
+ There is no floor. What matters is that the wall carries **one real thing** — an observation
217
+ that is specific, arguable, and true. A 34-word wall that does that beats a 90-word wall that
218
+ says it and then keeps talking.
219
+
220
+ Signals you have earned the length, rather than padded to it:
221
+
222
+ - Every sentence introduces a **new** fact, turn, or escalation — none restates.
223
+ - The wall reaches rung 3 or 4 by *naming a mechanism step by step*, which is long because the
224
+ mechanism has steps, not because you wrote more words.
225
+ - Cutting any sentence removes something a reader would have wanted.
226
+
227
+ If a long wall does not pass those, it is not a long wall. It is a short wall with padding.
228
+
229
+ ---
230
+
231
+ ## The second test — the cover test, both ways
232
+
233
+ Two questions, and **both** must be yes:
234
+
235
+ | Cover this | Ask | If no |
236
+ |---|---|---|
237
+ | The background | Does the wall still land as something a real person wrote? | You wrote ad copy. The scene was carrying it |
238
+ | The wall | Is the background boring on its own? | The scene is stealing. Recast it |
239
+
240
+ The second half is the one nobody believes. **The background is supposed to be boring.** It
241
+ is not b-roll, it is not illustration, and it is not "showing" anything. Its whole job is to
242
+ give the words a *mood* and a *place* while contributing **zero semantic content** — because
243
+ every scrap of meaning in the frame is a scrap of attention not spent reading, and reading is
244
+ the only thing happening in this video.
245
+
246
+ A night drive works because a road at night means nothing and feels like everything. A clip
247
+ of someone cooking does not work, no matter how pretty, because the viewer starts watching
248
+ the cooking.
249
+
250
+ ---
251
+
252
+ ## Part 0 — who this is for
253
+
254
+ **Not the customer's buyer in general — the customer's buyer at 11pm, alone, already
255
+ thinking about this.** Before you write a word, put three lines at the top of your build log:
256
+
257
+ ```
258
+ WHO: <the person, in six words>
259
+ MOMENT: <where they are and what time it is when this thought arrives>
260
+ THE ACHE: <the sentence they have never said out loud>
261
+ ```
262
+
263
+ The third line is the video. Everything else is production.
264
+
265
+ - **Register:** a person typing into their own phone. Not addressing an audience. Not
266
+ performing. The wall should read as though you are seeing something you were not quite
267
+ meant to see.
268
+ - **Never a brand voice.** No "we", no "introducing", no feature words, no benefit language.
269
+ The offer may appear (Rule 1) but the *voice* never becomes the offer's.
270
+ - **Lowercase is allowed and often correct.** Reference B is entirely lowercase. Reference A
271
+ is sentence case. Both read as typed; neither reads as designed. Pick one and hold it for
272
+ the whole wall.
273
+ - **The joke, if there is one, is never on the viewer.** This format has no joke. It has a
274
+ recognition, which is a different and more dangerous thing to get wrong.
275
+
276
+ ---
277
+
278
+ ## Structural DNA — there are no beats
279
+
280
+ The rule every agent breaks. It goes first and in bold.
281
+
282
+ > **A wall-text POV is ONE take with ONE static text block, and both are complete at frame 0.**
283
+
284
+ No hook beat. No build. No reveal. No word-pop, no fade-in, no line-by-line arrival, no cut,
285
+ no push-in, no transition, no end card. The four charges still exist but they **do not fire
286
+ in series** — three of them fire at frame 0 simultaneously and the fourth fires in the
287
+ comments:
288
+
289
+ | Charge | Where it lives |
290
+ |---|---|
291
+ | 🪝 Hook | The wall's first six words, plus the fact that a screen dense with text is itself an interrupt in a feed of faces |
292
+ | 🔄 Loop | **Literal.** The video ends before the reading does. The loop mechanic is the platform's own loop-around |
293
+ | 😍 Payoff | Recognition. "This is exactly it." There is no product reveal, no result, no number |
294
+ | 🎣 Bait | The wall's final line asking a judgeable question, **and** the post caption |
295
+
296
+ ### Why animating the text is fatal
297
+
298
+ An agent's reflex is to reveal the wall line by line, or to word-pop it, "so it's not static".
299
+ Do not. Three separate reasons, all load-bearing:
300
+
301
+ 1. **It breaks the read-loop.** The viewer's second pass through the loop is a *re-read from
302
+ where they got to*. If the text rebuilds itself every loop, they cannot resume — they have
303
+ to wait for line 9 to arrive again. The retention mechanic dies on the second play.
304
+ 2. **It breaks frame 0.** Frame 0 is the thumbnail and the scroll-stop. A wall that is
305
+ one-tenth built at frame 0 has nothing to stop anyone with.
306
+ 3. **It breaks the native tell.** Nobody's phone animates text into existence. Animation is
307
+ the single clearest signal that a video was made in an editor, and this format survives
308
+ entirely on not looking made.
309
+
310
+ ### Length
311
+
312
+ **`duration = words / 8`, clamped to 8–12 seconds.** Length is not a choice and it is not a
313
+ style. Write the wall, count the words, divide by eight. A 71-word wall is 8.9s; a 52-word wall
314
+ clamps up to the 8.0s floor.
315
+
316
+ That is well past the references' 5.1s, in both directions of the argument. They were cut to a
317
+ length the poster liked rather than to the wall, and they are too short to carry a trending
318
+ sound.
319
+
320
+ **Neither direction is free.** A clip that is too short leaves the viewer too early to feel
321
+ committed. A clip that is too long lets them finish in one play, which removes the loop
322
+ entirely — and a wall that can be finished in one pass is just a caption. Half is the point.
323
+
324
+ ### The two modes — MIRROR and TURN
325
+
326
+ Decide before you write a word. The mode changes the last line, whether the offer is named,
327
+ and what the gate allows.
328
+
329
+ | | **MIRROR** | **TURN** |
330
+ |---|---|---|
331
+ | What the wall is | A confession or truth-bomb that never resolves | A confession whose last 1–2 lines pivot to what changed |
332
+ | The offer | Never in the wall. Handle + post caption only | Named in the pivot, once, plainly |
333
+ | Last line | A judgeable question, or a flat unresolved statement | The pivot, then stop. No CTA after it |
334
+ | Reach | High. It is shareable because it is not an ad | Lower. People recognise the shape |
335
+ | Intent | Low. Almost nobody clicks. They comment and share | Higher. The people who do click are warm |
336
+ | Default | **Yes.** Run 4 MIRROR to 1 TURN | Only when you already have reach to convert |
337
+
338
+ **MIRROR is the default and it is not a compromise.** A pure mirror post is what earns the
339
+ account the distribution that makes the occasional TURN post worth anything. An account that
340
+ only posts TURN videos is an ad account and gets treated like one.
341
+
342
+ **The mode is declared in the build log before writing**, because a wall written as a MIRROR
343
+ and then "just given a little pivot at the end" is the most common failed build in this
344
+ format — the pivot arrives as a betrayal and the comments say so.
345
+
346
+ ---
347
+
348
+ ## The wall — the only thing you actually write
349
+
350
+ ### Pick the speaker frame first
351
+
352
+ Who is talking, and to whom. Four frames, and they are not interchangeable. **Reference A and
353
+ Reference B both use attributed speech**, which is not an accident — see below.
354
+
355
+ | Frame | Shape | Example opening | What it buys |
356
+ |---|---|---|---|
357
+ | 📻 **Overheard** | You are quoting something you saw or were told | *"I saw a tiktok saying …"* · *"my therapist said …"* | You do not own the claim, so you can post a harsh one without being harsh |
358
+ | 🗣 **Quoted** | You are reporting what a specific person said to you | *"during our last fight she looked at me and said …"* | Maximum specificity, zero exposure. The other person said it |
359
+ | 🕯 **Confession** | First person, present tense, admitting something | *"i keep telling people i'm fine and …"* | Highest intimacy. Highest risk — it is *you* on the hook |
360
+ | ⚔️ **Accusation** | Second person, naming what the viewer does | *"you don't actually want advice, you want …"* | Sharpest. Use rarely. Fires the comment section but burns goodwill fast |
361
+
362
+ #### Attribution is the mechanism, not a decoration
363
+
364
+ Both references wrap the claim in someone else's mouth — a tiktok, a partner mid-fight — and
365
+ then the poster says almost nothing. Reference A's *entire* contribution is three words:
366
+ **"Is this true?"**
367
+
368
+ That structure is doing three jobs at once and it is worth naming all of them:
369
+
370
+ 1. **The claim can be as hard as it needs to be.** A harsh truth stated flatly reads as
371
+ arrogant. The same truth quoted reads as reporting.
372
+ 2. **The comment section is pre-loaded.** The viewer is not asked to react to *you*, they are
373
+ asked to **adjudicate a third party**. People will not tell you their opinion; they will
374
+ absolutely tell a stranger they are wrong.
375
+ 3. **It survives being wrong.** If the claim is over-stated, the comments correct the quote,
376
+ not the account. The engagement is identical and the positioning is intact.
377
+
378
+ **On the ⚔️ accusation frame you lose all three at once.** That is the trade, and it is why
379
+ the accusation frame is a once-in-ten post, not a house style.
380
+
381
+ ### The grammar
382
+
383
+ - **One block. No headings, no bullets, no emoji, no numbering, no bold.** The moment the
384
+ wall has structure it reads as a slide.
385
+ - **Quotation marks around quoted speech, curly not straight** (`" "` and `'`). Straight
386
+ quotes are a typing tell from a code editor and they look wrong at 44px.
387
+ - **Ellipses and lowercase `i` are fine.** Reference B uses both. They read as typed.
388
+ - **No line breaks inside the quote.** Let it wrap. See *Typography DNA* — hand-breaking is
389
+ the single loudest "made in an editor" signal in this format.
390
+ - **One paragraph break, maximum**, and only to separate the poster's own closing line from
391
+ the quote (Reference A does exactly this: the quote, a blank line, *"Is this true?"*).
392
+ - **End on the sharpest clause.** No summary sentence. No "anyway". No tidy conclusion. The
393
+ wall should stop a beat before you are comfortable stopping.
394
+
395
+ ### The specificity ladder — the single biggest lever
396
+
397
+ The same claim, four rungs. Only rung 3 and above are postable.
398
+
399
+ | Rung | Example | Why it fails / works |
400
+ |---|---|---|
401
+ | 0 — abstraction | *"communication is important in relationships"* | Nobody has ever felt this |
402
+ | 1 — named feeling | *"i feel unheard sometimes"* | True of everyone, therefore about no one |
403
+ | 2 — named situation | *"every time we argue it gets worse"* | Getting closer. Still a category, not a memory |
404
+ | 3 — named mechanism | *"every small issue turns into an argument, then into distance, then into something way bigger than it needed to be"* | ✅ This is Reference B. The **escalation is described step by step**, so the reader recognises their own sequence |
405
+ | 4 — named consequence | *"i've learned to stay quiet, because it hurts less than trying to be understood"* | ✅ The behaviour the mechanism produced. The strongest line in either reference |
406
+
407
+ **Rung 3 is a chain, not an adjective.** "It escalates" is rung 2. "Argument → distance →
408
+ something bigger than it needed to be" is rung 3, and it is longer, which is a feature here
409
+ and nowhere else in the catalog.
410
+
411
+ **Climb the ladder inside one wall.** Reference B opens at rung 2 (*"it never feels safe"*),
412
+ walks through rung 3, and lands on rung 4. That is the shape: situation → mechanism →
413
+ consequence. It is also, not coincidentally, exactly how a real person describes a problem
414
+ they have thought about for months.
415
+
416
+ ### The judgeable claim — why "Is this true?" is the engine
417
+
418
+ A wall that is merely *relatable* gets a like. A wall that is **arguable** gets a comment, and
419
+ comments are distribution.
420
+
421
+ Make the wall contain something a reasonable person could push back on:
422
+
423
+ - A **generalisation about a group** — *"when a man loses interest, he will …"* (Reference A).
424
+ Half the comments will say "not all", and that is the point.
425
+ - A **causal claim** — *"a healthy relationship turns conflict into connection"* (Reference B).
426
+ Sounds like wisdom, is actually a thesis, and people will litigate it.
427
+ - A **behaviour someone will defend** — *"i've learned to stay quiet"*. The replies are half
428
+ "same" and half "that's the problem".
429
+
430
+ Then, optionally, **ask for the verdict in three words.** *"Is this true?"* · *"am i wrong"* ·
431
+ *"tell me it's just me"*. Set on its own line after a blank line.
432
+
433
+ **Never write a question that has an obvious answer**, and never write "thoughts?". The ask
434
+ has to be a real fork or it reads as engagement-farming, which it is, badly.
435
+
436
+ ### The cold-viewer test — cover the offer and read it again
437
+
438
+ Hand the wall, as plain text, with the brand name removed, to someone who has never heard of
439
+ the offer.
440
+
441
+ - **They must not be able to tell it is an ad.** Not "they forgive it" — they must not be able
442
+ to tell.
443
+ - **They must be able to name the person who wrote it.** If the answer is "a company", rewrite.
444
+ - **They should want to reply.** If their instinct is to nod and move on, the claim is not
445
+ judgeable. Go back a section.
446
+
447
+ ### Banned walls
448
+
449
+ Any of these and the build is dead, regardless of how it renders:
450
+
451
+ - **A list of benefits with the line breaks removed.** The most common failure. It has
452
+ paragraph shape and ad content, and readers detect it in about 400ms.
453
+ - **A fake statistic.** *"93% of founders never …"* — an invented number in a confession is a
454
+ lie wearing a confession's clothes, and it draws the harshest correction of any failure here.
455
+ - **A resolved MIRROR.** In MIRROR mode the wall does not get to feel better at the end.
456
+ - **A CTA in the wall.** Not at the end, not in parentheses, not as "(link in bio)". The front
457
+ matter blocks the obvious phrasings; the rule covers the clever ones.
458
+ - **A named real competitor doing something bad.** Not a rule about taste. It is a legal
459
+ exposure that belongs to the customer, not to you.
460
+ - **An earnings, health, or appearance outcome.** *"i went from 0 to $40k/mo"* attached to a
461
+ confession is exactly the shape platform enforcement is tuned for.
462
+ - **A wall written to fit a clip you already picked.** Wall first, always. A wall reverse-
463
+ engineered from footage lands on rung 1 every single time.
464
+
465
+ ---
466
+
467
+ ## Casting the scene
468
+
469
+ ### The two classes
470
+
471
+ | | 🚗 **POV** | 🙂 **UGC scene** |
472
+ |---|---|---|
473
+ | What it is | The camera is the viewer's eyes. No person on screen | A real person on camera, not talking, doing almost nothing |
474
+ | Reads as | The viewer's own thought | Someone else's thought, with a body attached to it |
475
+ | Best speaker frame | 📻 Overheard · 🕯 Confession | 🗣 Quoted · ⚔️ Accusation |
476
+ | Examples | Night drive through a windshield · walking a sidewalk · a train or bus window · rain on glass · a ceiling · a passenger-seat side window | Sitting in a parked car looking out · walking with headphones on · standing at a window · sitting on a floor |
477
+ | Risk | Too dark and it is a black card with text on it | The face becomes the subject and the reading stops |
478
+
479
+ Both references are POV. **POV is the default** because the empty gaze is what lets the wall
480
+ become the viewer's own inner voice — the strongest thing this format does.
481
+
482
+ **In UGC class the person must never look at the camera and must never move much.** A glance
483
+ to lens converts the video into a talking-head with no sound, which is a different and worse
484
+ format. Cast a profile, a back of a head, a figure walking away.
485
+
486
+ ### The congruence rule — mood matched, meaning empty
487
+
488
+ The scene must **agree with the wall's temperature** and **contain none of its content**.
489
+
490
+ | Wall temperature | Scene that agrees | Scene that steals |
491
+ |---|---|---|
492
+ | Late, tired, resigned | Night drive · empty street · rain on a window | Anyone crying. A face doing the emotion for the reader |
493
+ | Restless, unresolved | Walking fast · a train window · a ceiling fan | Anything with a destination in shot |
494
+ | Flat, cold, over it | A parked car at dusk · a grey window · still water | A sunset. Sunsets resolve, and MIRROR mode does not resolve |
495
+ | Quietly hopeful (TURN only) | First light · a road opening up · a window filling | Anything celebratory. It cashes the cheque too early |
496
+
497
+ **If the wall is about work and the scene is an office, you broke the rule.** Literal is the
498
+ enemy. The scene should be the *place the thought occurs*, never the place the problem lives.
499
+
500
+ ### The darkness budget — casting is a legibility decision before it is a mood decision
501
+
502
+ This is the non-obvious rule of the whole file, and it is measured.
503
+
504
+ The format uses **no plate behind the text — ever** (see *Visual DNA*). Plate-free type is
505
+ only readable over a dark, calm background. So the requirement "the scene must be dark" is
506
+ not an aesthetic; **it is what buys the plate-free look, and the plate-free look is what makes
507
+ the video read as native.** Cast bright and you are forced into a plate, and the plate turns
508
+ the video into an editor's export.
509
+
510
+ Measured across all 51 sampled frames of both references, inside the block's own rect —
511
+ these are `band-measure.py`'s own numbers, reproducible with the command in Appendix B:
512
+
513
+ | Measurement | Reference A | Reference B | **Gate** |
514
+ |---|---|---|---|
515
+ | Median luma behind the block, worst frame | 18 | 17 | **≤ 60** |
516
+ | Mean luma behind the block, worst frame | 38 | 39 | ≤ 90 |
517
+ | % of band pixels over 120, worst frame | 8.5% | 8.6% | **≤ 15%** |
518
+ | p98 luma (specular hits — headlights, streetlights) | 175 | 176 | no gate |
519
+ | Frame-to-frame motion | 3.6 | 3.8 | 1.5–20 |
520
+
521
+ Two things to take from that table:
522
+
523
+ - **The references sit far inside the gate.** The gates are set to pass them with real headroom
524
+ and to fail a daylight clip, not to be a tight fit around two videos.
525
+ - **Bright point-lights are FINE and the standard deviation lies about them.** The band's
526
+ standard deviation across both references is 43–47, which the general `HARNESS.md` treatment
527
+ table would read as "busy → needs a plate". It is wrong here. The variation is a handful of
528
+ streetlights a few pixels across; the type is 40px+ with a drop shadow and it sails over
529
+ them. **Gate on the median and on the fraction of bright pixels, never on the standard
530
+ deviation.** `band-measure.py` (Appendix B) implements exactly this.
531
+
532
+ Run Appendix B on a candidate **before** you build. It is free and it is the difference
533
+ between recasting in thirty seconds and recasting after a render.
534
+
535
+ ### Sourcing — and an honest warning about the free shelf
536
+
537
+ ```bash
538
+ vidfarm public-raws --category ugc-reaction --limit 200 # 180 clips — the UGC-class shelf
539
+ vidfarm public-raws --category lifestyle --limit 200 # people walking, outdoors, at night
540
+ vidfarm public-raws --category b-roll --limit 200
541
+ vidfarm public-raws --query walk # keyword search, one word at a time
542
+ ```
543
+
544
+ **The free public raws shelf is thin on true POV footage, and you should know that before you
545
+ start looking.** Searched at the time of writing: `pov` → 0 results. `driving` → 0. `road` → 0.
546
+ `window` → 0. `rain` → 0. `night` → 2. The shelf's `setting:car` bucket holds 5 clips, and
547
+ they are people *sitting in* cars, not windshield POV.
548
+
549
+ What the shelf **does** have, and what it is genuinely good for:
550
+
551
+ - **UGC class — but read this before you count on it.** `ugc-reaction` + `lifestyle` describe
552
+ people walking outdoors, sitting in parked cars, standing at night, and the descriptions are
553
+ accurate. What they do not say is that **the shelf is selfie footage**: a face, centred, on
554
+ lens, for the clip's entire duration. On the first dishcover.io batch all seven plausible
555
+ candidates failed gate 1 outright (worst-frame median luma 121–185 against a ceiling of 60),
556
+ and the two that passed after grading still put the wall on top of somebody's face while they
557
+ looked at the reader. **Treat this shelf as a last resort for this format**, and read a
558
+ contact sheet before you grade anything.
559
+ - **The `actor_<uuid>` token.** Every tagged public raw carries one actor id at the tail of its
560
+ `summary` string. Read it off a card you like, then `vidfarm public-raws --query actor_<uuid>`
561
+ to get every other clip of the same person. For a serialised account (see *Bulk generation*)
562
+ this is how you get one recurring face across thirty posts for free.
563
+
564
+ For **POV class**, the honest paths, cheapest first:
565
+
566
+ | Path | Cost | Notes |
567
+ |---|---|---|
568
+ | **Shoot it** | $0 | A phone on a dashboard mount for one 20-minute night drive yields a year of backgrounds. This is genuinely the right answer and agents skip it because it is not a CLI call |
569
+ | **Free-for-commercial stock** | $0 | The path that actually worked on the first dishcover.io batch. Pexels and Pixabay block a plain `curl` (403); **Mixkit does not**, and its free licence permits commercial use with no attribution. `https://mixkit.co/free-stock-video/<topic>/` lists the ids, and `https://assets.mixkit.co/videos/<id>/<id>-1080.mp4` is the file. Of 12 night clips pulled that way, **3 passed gate 1 ungraded** — better odds than the entire public-raws shelf |
570
+ | `vidfarm handoff raws --keywords "night drive pov,rain window" --purpose "…"` | $0 | Mints a copy-paste sourcing brief for a human: what to search, how to download, and the `vidfarm raws scan` follow-up |
571
+ | `vidfarm raws scan` / the clipper | $0 | Ingest footage you already have rights to |
572
+ | Generated plate | $ (cost mode `hybrid`+) | A generated night road loops cleanly and has no rights question. It also looks generated on a big screen — acceptable here **only** because the background is deliberately unremarkable and 60% covered in text |
573
+
574
+ **Whatever the path: one background clip is reusable across an entire batch.** See *Bulk
575
+ generation*. The cost of sourcing amortises to near zero, which is why it is worth doing
576
+ properly once instead of badly thirty times.
577
+
578
+ ### Rights tiers — read this before you cast for a paying customer
579
+
580
+ | Tier | Source | Safe for a client? |
581
+ |---|---|---|
582
+ | ✅ **Owned** | The customer's footage, or footage you shot | Yes, always |
583
+ | ✅ **Licensed** | Vidfarm public raws, cleared stock | Yes |
584
+ | ✅ **Generated** | AI-generated plate | Yes. Disclose it in the handoff |
585
+ | ⚠️ **Scraped ambient** | A night-drive clip pulled off a platform | **The customer's call, in writing.** Ambient POV with no identifiable person is the low end of the risk range, and it is still not yours |
586
+ | 🚫 **Identifiable person, scraped** | Any recognisable face you did not license | No. Not for a client, not ever |
587
+
588
+ **Declare the tier in the handoff.** An agent that quietly ships tier ⚠️ has moved a risk onto
589
+ the customer without telling them, and that is the failure mode, not the clip.
590
+
591
+ ---
592
+
593
+ ## Visual DNA
594
+
595
+ ### The canvas
596
+
597
+ **1080 × 1920, 30fps.** Both references, exactly. If the source is 4:3 or 16:9, **fill the
598
+ frame** — scale up and crop, do not letterbox and do not blur-bar. A blur-bar is an editor's
599
+ solution and the frame is supposed to look like it came off a phone.
600
+
601
+ ### The block geometry — measured, not guessed
602
+
603
+ Both references, measured off the rendered frames:
604
+
605
+ | Property | Reference A | Reference B | **Spec** |
606
+ |---|---|---|---|
607
+ | Block vertical centre | 50.0% | 49.9% | **48–52% of frame height** |
608
+ | Block vertical span | 34.9% → 65.2% | 30.7% → 69.0% | 30–40% of frame height |
609
+ | Ink width (leftmost to rightmost lit pixel) | 66.5% | 67.7% | **66–70% of frame width** |
610
+ | Box horizontal centre | 49.9% | 50.0% | **48–52% — centred** |
611
+ | Lines | 10 | 14 | an output, never a target |
612
+ | Font size | ~46px | ~40px | an output. See the fitter |
613
+ | Line height | 1.22 | 1.25 | **1.25** |
614
+
615
+ **The block is vertically centred on the frame.** Not on the lower third, not on the upper
616
+ third. Dead centre, both references, within a fifth of a percent. This is the single most
617
+ copyable number in the file and it is also the one a caption-placement habit will fight you
618
+ on — the general `HARNESS.md` rule about finding the quietest third does not apply, because
619
+ the whole frame is deliberately quiet and the block is 35% of it.
620
+
621
+ **Centred on both axes, and measure it per line, not per block.** Both references sit at
622
+ 49.9% and 50.0% horizontally.
623
+
624
+ That number is worth a warning, because it is easy to measure wrong and the wrong answer is
625
+ plausible. Take the union extent of every lit pixel in the block's rows and reference B reports
626
+ **43.9%** — a confident-looking 6%-off-centre reading that would have you deliberately
627
+ offsetting the box to imitate a "hand-dragged" placement that does not exist. The cause is a
628
+ streetlight sitting inside the block's rows, dragging the union's left edge outward. **Measure
629
+ each text line's own centre and take the median across lines** (Appendix C does this): the
630
+ light can only corrupt the one line it overlaps, and the median outvotes it. Both references
631
+ then land on 50.0%.
632
+
633
+ The general lesson, which applies to every measurement in this file: **on footage with point
634
+ lights, any statistic built from an extent or a variance is unreliable. Use medians.** The same
635
+ correction is why the darkness gate keys on the band's median rather than its standard
636
+ deviation.
637
+
638
+ ### No plate. Ever.
639
+
640
+ No background colour behind the text. No rounded rectangle. No frosted panel. No translucent
641
+ slab. No per-line highlight. Not at any opacity.
642
+
643
+ The plate is the thing that makes a video read as *made somewhere else and uploaded*, and it
644
+ gets worse the moment the text wraps, because the slab's own edges become a shape competing
645
+ with the frame. In a format whose entire proposition is "this is a real person's screen", a
646
+ plate is the only defect that cannot be fixed in the edit.
647
+
648
+ **Legibility comes from three places instead:**
649
+
650
+ 1. **The casting.** Median luma ≤ 60 behind the block. This does 80% of the work and it is
651
+ decided before you open an editor. (See *The darkness budget*.)
652
+ 2. **A soft drop shadow, no stroke.**
653
+ ```css
654
+ text-shadow: 0 2px 10px rgba(0,0,0,.60), 0 0 3px rgba(0,0,0,.50);
655
+ ```
656
+ Diffuse, dark, generous. Its job is the **specular case** — the measured p98 in both
657
+ references is ~176, meaning streetlights and headlights genuinely do sweep across the
658
+ block, for a few frames, over a few percent of its area. The shadow is insurance for those
659
+ frames. A hard stroke would survive them too and would look like a sticker; the shadow
660
+ survives them and looks like a phone.
661
+ 3. **A global grade on the background, not a gradient behind the text.** See below.
662
+
663
+ ### Pushing the background down — grade, don't gradient
664
+
665
+ If the scene measures a little too bright, the reflex is a dark gradient behind the text.
666
+ Don't: a gradient has a visible edge, and a visible edge is a plate with soft corners.
667
+
668
+ Grade the **whole clip** instead, uniformly:
669
+
670
+ ```bash
671
+ # lift blacks down and pull exposure — applies to the entire frame, so there is no edge
672
+ ffmpeg -i scene.mp4 -vf "eq=brightness=-0.10:contrast=1.05:saturation=0.90" -c:a copy graded.mp4
673
+ ```
674
+
675
+ Re-measure with Appendix B afterwards. Two things to hold:
676
+
677
+ - **Grade to the gate, not past it.** A background crushed to pure black stops being a place
678
+ and the video becomes a black card with text, which loses the entire mood contribution.
679
+ Median luma in the 15–45 range is where both references live and it is the right target.
680
+ - **Never blur the background.** Blur is this format's loudest slop tell — it says "I knew the
681
+ background was competing", and a background that needed blurring was miscast.
682
+
683
+ ### Everything else
684
+
685
+ Banned outright, and `vidfarm qa` flags most of them as slop:
686
+
687
+ logos · watermarks · a URL on screen · progress bars · pricing cards · feature grids · benefit
688
+ chips · CTA buttons · frosted panels · stickers · emoji on the frame · arrows · circles drawn
689
+ on things · zooms · push-ins · Ken Burns · shake · film grain overlays · light leaks · vignettes
690
+ added in post · a fade from or to black · a transition of any kind · a second clip.
691
+
692
+ The list is long on purpose. **Every item on it is something an editor adds to a video that
693
+ feels too plain**, and *too plain is the format*.
694
+
695
+ ---
696
+
697
+ ## Typography DNA — a screenshot, not a title card
698
+
699
+ ### The font regime
700
+
701
+ | Property | Value | Why |
702
+ |---|---|---|
703
+ | Family | **TikTok Sans**, self-hosted, weight 700 | The platform's own text tool. Anything else is legible and wrong |
704
+ | Fallback chain | **none** | A fallback means a missing font renders as a *different* font and nothing tells you. Self-host the `.woff2` so there is nothing to fall back *from* |
705
+ | Weight | 700 | Measured stem-to-cap ratio on both references is ≈0.21 — Bold, not Black. 900 reads as a meme caption, which is the wrong format |
706
+ | Case | sentence case **or** all lowercase | Both references, one each. **Never all-caps** — all-caps is shouting and this format whispers |
707
+ | Colour | `#FFFFFF` | Not off-white, not warm white |
708
+ | Alignment | **centre** | Each line centred, ragged both sides |
709
+ | Line height | 1.25 | |
710
+ | Letter spacing | 0 | |
711
+
712
+ ### Let it wrap. Do not hand-break the lines.
713
+
714
+ Look at Reference A: *"… he will / make it seem like he still likes / you but can't give you as
715
+ much time / as before because …"*. Every break lands mid-clause. **That is correct and it is
716
+ the point.** It is what a text box does when a human types into it and drags the sides.
717
+
718
+ A hand-broken block — lines broken at clauses, near-equal length, a shallow ragged edge — is
719
+ *typeset*. It reads as designed, and the moment it reads as designed the wall stops being
720
+ someone's private thought and becomes a quote card. This is the exact opposite of the
721
+ instruction in `meme-recaption`, where the caption *is* a card and hand-breaking is right.
722
+ **Two formats, opposite rules, and the reason is the same: match the tool the reader believes
723
+ made it.**
724
+
725
+ So: set a fixed box width, set `text-align:center`, and let the browser wrap. Then read the
726
+ result and only intervene if a line ends up as a single orphaned word — in which case adjust
727
+ the **box width by 1–2%**, never the line breaks.
728
+
729
+ **The one permitted manual break is the paragraph break** before the poster's own closing line
730
+ (Reference A's blank line before *"Is this true?"*). One, maximum.
731
+
732
+ ### Size is an output of word count
733
+
734
+ Never pick a font size. Solve for it. The block has a fixed height budget, so size falls out
735
+ of how many characters you wrote:
736
+
737
+ ```
738
+ block_height ≈ chars × ADV × line_height × size² / box_width
739
+ ```
740
+
741
+ where **`ADV = 0.58`** — the measured average glyph advance per character for TikTok Sans 700
742
+ in mixed case — and **`box_width = 0.72 × frame width`**.
743
+
744
+ Solving for a target block height of 34% of frame:
745
+
746
+ ```
747
+ size = sqrt(target_height_px × box_width_px / (chars × 0.58 × 1.25))
748
+ ```
749
+
750
+ Appendix A implements this, then does a **real greedy wrap** to confirm the line count and
751
+ reconciles the two in both directions, clamped to **[30, 52]px**. Run against the two
752
+ references' own walls it returns:
753
+
754
+ | | Fitter | Actual | |
755
+ |---|---|---|---|
756
+ | Reference A (55 words, 276 chars) | 47px | 46px | ✅ |
757
+ | Reference B (83 words, 470 chars) | 38px | 40px | ✅ |
758
+
759
+ **The line count will run 1–2 lines over the real render.** Greedy wrapping wastes each line's
760
+ tail and the integer chars-per-line truncates, so the fitter is systematically ~8%
761
+ conservative. That is the right direction to be wrong in — it never overflows the block — but
762
+ it means the fitter's reported span is a ceiling, not a prediction. If the rendered still
763
+ reads small, raise `--target-h` a point or two rather than hand-setting a size.
764
+
765
+ **If the fitter clamps at 30px, the wall is too long — cut words, do not shrink further.** Type
766
+ under 30px on a 1080-wide canvas is under 2.8% of frame width and stops being readable on a
767
+ phone held at arm's length, which is the only place this video will ever be watched.
768
+
769
+ ### Placement — centred, and the safe zone matters more than usual
770
+
771
+ The block sits at 48–52% vertical centre, so with a 30–40% span it occupies roughly **30% →
772
+ 70% of frame height**. That is comfortably inside the 8–85% safe zone at both ends.
773
+
774
+ **Check it anyway, and check it against the platform's own furniture.** A 40%-tall block
775
+ centred vertically is the largest single object this format ever places, and if the wall runs
776
+ long it grows from the centre outward in both directions at once. Reference B's span reaches
777
+ 69.0% — the caption row and the action rail on TikTok start eating the frame not far below
778
+ that. If the fitter returns a span over 40%, cut words.
779
+
780
+ ### The handle
781
+
782
+ In **MIRROR** mode the offer is not in the wall. It goes in the post caption, and — optionally
783
+ — as a handle.
784
+
785
+ ```css
786
+ .handle{
787
+ font-family:'TikTok Sans'; font-weight:700; font-size:26px;
788
+ color:rgba(255,255,255,.55);
789
+ letter-spacing:.02em;
790
+ /* no plate, same as the wall */
791
+ text-shadow:0 2px 8px rgba(0,0,0,.6);
792
+ }
793
+ ```
794
+
795
+ - **One line, under the block, centred with it, ~40px of air below the last line.**
796
+ - **26px and 55% opacity.** It is attribution, not a CTA. It should be findable, not readable
797
+ at a glance — someone who screenshots the post can read it, and that is the whole job.
798
+ - **The handle is the offer's name or domain. Nothing else.** No tagline. No "→". No emoji.
799
+ - **The handle is optional and often better omitted.** A pure MIRROR post with no handle at all
800
+ is the highest-reach configuration; the account name under the video is already attribution.
801
+ Use the handle when the post is likely to be reposted as a screenshot.
802
+ - **In TURN mode there is no handle**, because the offer is already named in the pivot and
803
+ naming it twice on one screen is the ad tell.
804
+
805
+ ---
806
+
807
+ ## Audio DNA
808
+
809
+ The most-skipped strand, and a silent upload is an instant fail — the platform down-ranks
810
+ soundless video and a muted autoplay of a silent clip has no signal that it is playing at all.
811
+
812
+ ### One flat ambient bed. That is the entire audio design.
813
+
814
+ Measured on both references:
815
+
816
+ | | Reference A | Reference B | **Spec** |
817
+ |---|---|---|---|
818
+ | Integrated loudness | −22.3 LUFS | −19.8 LUFS | **−23 to −18 LUFS** |
819
+ | Loudness range (LRA) | 2.1 LU | 0.7 LU | **≤ 3.0 LU** |
820
+ | Channels / rate | 2 / 44.1kHz | 2 / 44.1kHz | keep |
821
+
822
+ **LRA of 0.7 is the number to internalise.** It means the audio is essentially a constant —
823
+ road noise, tyre hum, wind, rain, a room tone. No melody, no beat, no build, no drop, and
824
+ categorically **no speech**.
825
+
826
+ Why it must be flat:
827
+
828
+ - **The reader is reading.** Any audio event — a drum hit, a vocal, a chord change — pulls a
829
+ reader's eye off the line they are on. A flat bed is *sonic wallpaper* and that is a
830
+ compliment here.
831
+ - **The video loops 3–5 times per viewer.** A track with any structure becomes maddening on
832
+ the third pass. A hum does not.
833
+ - **The loop seam must be inaudible.** A bed with rhythm has a seam you can hear at the
834
+ wrap-around, and an audible seam tells the viewer they have looped — which is exactly the
835
+ moment they decide to leave. See below.
836
+
837
+ ### Sourcing the bed
838
+
839
+ 1. **Default: mute the scene and lay your own bed.** `data-volume="0"` on the video, the bed
840
+ on its own track. This is the default because a background clip's own audio is a liability
841
+ you did not choose: it carries wind roar, a voice, a passing siren, someone's music — all of
842
+ which are audio *events*, all of which pull a reader off the line they are on, and one of
843
+ which is a rights question you now own. On the first dishcover.io batch both candidate plates
844
+ failed on their own audio, in opposite directions: one measured **LRA 7.5 LU** (structured)
845
+ and the other was **silent at −70 LUFS**. Neither is audible as a problem when you play it.
846
+
847
+ A flat bed is free to synthesise and always congruent:
848
+
849
+ ```bash
850
+ ffmpeg -f lavfi -i "anoisesrc=c=pink:r=44100:a=0.9:d=30" \
851
+ -af "lowpass=f=520,highpass=f=45,loudnorm=I=-21:LRA=1:TP=-2.0" -ac 2 bed/tone.m4a
852
+ ```
853
+
854
+ Keep the scene's own audio only when you have **measured** it (LRA ≤ 3.0, no speech), you own
855
+ the footage, and it is genuinely the right room tone. That is a deliberate exception, not the
856
+ starting point.
857
+ 2. **If the source audio is unusable** (wind roar, a voice in the car, a clipped mic), replace
858
+ it with a room tone or road-noise bed via `/media-use`. Match the scene: a night drive gets
859
+ road noise, a window scene gets rain, a walk gets footsteps and distant traffic.
860
+ 3. **Trending audio is a poster-side decision, not a build-side one.** Ship the video with its
861
+ ambient bed. If the poster wants a trending sound, the platform's own editor lays it over
862
+ the top — and it will replace your bed, which is fine, because you built a video that works
863
+ silent-with-captions-off *and* under someone else's audio.
864
+
865
+ Normalise to spec:
866
+
867
+ ```bash
868
+ ffmpeg -i in.mp4 -af "loudnorm=I=-21:LRA=3:TP=-1.5" -c:v copy out.mp4
869
+ ```
870
+
871
+ ### The loop seam
872
+
873
+ The video will be watched 3–5 times back to back. **The join from the last frame to the first
874
+ frame is a real cut that you never see in your editor and every viewer sees three times.**
875
+
876
+ - **Cut on continuous motion, at a matched moment.** A drive down a straight road at constant
877
+ speed joins almost invisibly. A drive that slows to a stop at the end and starts moving at
878
+ frame 0 has a visible lurch.
879
+ - **Never end on a brake light, a turn, a stop, or an arrival.** Anything that reads as
880
+ "finished" makes the wrap-around feel like a glitch.
881
+ - **Never fade audio out at the tail.** A fade-out followed by full level is the loudest
882
+ possible seam. Keep the bed level flat to the last sample — which the LRA ≤ 3.0 gate already
883
+ pushes you toward.
884
+ #### Ping-pong the plate — the seam problem, solved arithmetically
885
+
886
+ **Cut half the duration, then play it forward and reversed.** The last frame *is* the first
887
+ frame, so the join is not a cut at all.
888
+
889
+ This became necessary when `duration = words / 8` made the clips longer: more drift between
890
+ first and last frame means a bigger jump. Measured on an 8.9s aerial plate:
891
+
892
+ | | Seam (mean abs frame delta) | Clip's own median frame step | Join as a multiple |
893
+ |---|---|---|---|
894
+ | Straight cut | 18.0 | 3.4 | **5.3×** |
895
+ | Ping-ponged | **0.4** | 3.1 | **0.1×** |
896
+
897
+ 0.1× means the loop join is a *smaller* change than one ordinary frame of playback. It is the
898
+ default in `walltext-build.py`.
899
+
900
+ - **Drop the duplicated middle frame** (`trim=start_frame=1`), or the turnaround holds for two
901
+ frames and reads as a hitch.
902
+ - **Turn it off (`--no-pingpong`) when the plate has a directional subject.** A person walking
903
+ reverses into a moonwalk and it becomes the only thing anyone looks at. It is invisible on
904
+ ambient drift, aerials, water, rain and distant traffic — which is what this format casts.
905
+ - **Never ping-pong audio.** Reversed picture can be imperceptible; reversed audio never is.
906
+ `--keep-scene-audio` therefore forces `--no-pingpong`.
907
+ - **It halves the plate you need.** An 8.9s clip comes out of 4.45s of source, which matters
908
+ when a good plate is scarce.
909
+
910
+ - **Check it by concatenating the render to itself and watching the join.**
911
+ ```bash
912
+ printf "file '%s'\nfile '%s'\n" "$PWD/out.mp4" "$PWD/out.mp4" > /tmp/l.txt
913
+ ffmpeg -f concat -safe 0 -i /tmp/l.txt -c copy /tmp/looped.mp4
914
+ ```
915
+
916
+ ---
917
+
918
+ ## The rules
919
+
920
+ ### Rule 1 — the wall is written before the clip is chosen, and it is never adjusted to fit the clip
921
+
922
+ **Why:** a wall reverse-engineered from footage lands on the specificity ladder's rung 1 every
923
+ time, because footage suggests moods and moods are abstractions. The wall comes from the
924
+ `THE ACHE` line in the build log; the clip is cast to agree with it afterwards. If no clip in
925
+ the shelf agrees, grade one or shoot one — do not soften the wall.
926
+
927
+ ### Rule 2 — every line earns its place, and the clip is what gets cut
928
+
929
+ **Why:** the two are decided in that order and never together. Padding a wall to reach a
930
+ duration produces exactly the video this format is trying not to be — a reader who feels a
931
+ sentence that adds nothing leaves on that sentence, and no machine check in this file can see
932
+ it. Setting the duration from the finished wall costs nothing and is the whole retention design.
933
+
934
+ So: **run the deletion test on every line**, then set `duration = words / 8` so one play lands
935
+ the viewer at the halfway mark — committed, and one pass from done. Write both the ratio and
936
+ the line you cut into the build log. There is no minimum word count and no minimum ratio; a
937
+ 33-word wall carrying one clean observation is a finished video at 4.1s.
938
+
939
+ ### Rule 3 — nothing on screen moves except the background
940
+
941
+ **Why:** three separate failures at once (breaks the re-read on the second loop, breaks frame
942
+ 0 as a thumbnail, breaks the native tell). No fade, no word-pop, no line arrival, no scale, no
943
+ opacity ramp, no cut, no transition. The text block's `data-start` is `0` and its
944
+ `data-duration` equals the full composition duration. There is no other timing in this format.
945
+
946
+ ### Rule 4 — no plate, and the casting is what pays for that
947
+
948
+ **Why:** the plate is the single clearest "made in an editor" signal, and in a format built
949
+ entirely on looking un-made it is unfixable in the edit. That makes the darkness budget a hard
950
+ casting gate, not a preference: median luma ≤ 60 behind the block on every sampled frame, and
951
+ ≤ 15% of band pixels over 120. Measure with Appendix B **before** building, not after.
952
+
953
+ ### Rule 5 — frame 0 is the complete video
954
+
955
+ **Why:** frame 0 is the thumbnail, the scroll-stop, and the first thing anyone reads. The full
956
+ wall, the final background, and the handle are all present and legible at frame 0. In practice
957
+ this needs an explicit poster still (a one-shot composition renders frame 0 before the video
958
+ element has decoded anything, so frame 0 otherwise ships as an empty background). Appendix A
959
+ writes the poster; the QA script checks it.
960
+
961
+ ### Rule 6 — the background is boring, and it is not about the wall's subject
962
+
963
+ **Why:** every unit of meaning in the frame is a unit of attention not spent reading. Literal
964
+ scenes are the specific trap — a wall about work over an office, a wall about a relationship
965
+ over a couple. The scene is *where the thought happens*, never *where the problem lives*.
966
+
967
+ ### Rule 7 — MIRROR does not resolve, and TURN pivots exactly once
968
+
969
+ **Why:** an unresolved confession is shareable because it is not an ad. A resolved one is an
970
+ ad with a sad opening, the audience reads the shape instantly, and the comments say so. In
971
+ TURN mode the pivot is one clause, names the offer once, and the wall stops — no CTA after it,
972
+ no benefit, no second sentence.
973
+
974
+ ### Rule 8 — every specific in the wall is true, and there are no numbers
975
+
976
+ **Why:** a rung-3 or rung-4 specific is what makes the wall land, and an invented specific is a
977
+ fabricated confession — the harshest correction available in this format when someone catches
978
+ it. Statistics in particular are banned outright: a confession does not cite data, so a number
979
+ in a wall is *always* an ad tell, even when the number is real.
980
+
981
+ **One exception, and only one: the TURN pivot may carry a single product fact.** In TURN mode
982
+ the pivot *is* the ad — the wall has already stopped being a confession by the time it arrives —
983
+ and one concrete number there does the work a paragraph of adjectives cannot (*"indexes 7,502
984
+ dishes off real nyc menus"*). The exception is narrow on purpose: one number, inside the pivot
985
+ clause, in TURN mode only. A number anywhere in a MIRROR wall, or anywhere in a TURN wall before
986
+ the pivot, is the ad tell this rule exists to catch.
987
+
988
+ ### Rule 9 — the bait is the wall's last line and the post caption, never a card
989
+
990
+ **Why:** an on-screen "comment below" converts the video back into an ad in one frame. The ask
991
+ is either inside the wall as a judgeable question (Reference A's *"Is this true?"*) or it is in
992
+ the post caption, and it should be in both. The post caption carries the same ask because many
993
+ people reply from the caption without finishing the video.
994
+
995
+ ### Rule 10 — production floor
996
+
997
+ One take · no cuts · no transitions · 1080×1920 · TikTok Sans 700 self-hosted with no fallback
998
+ chain · text inside the 8–85% safe zone · the wall's `data-start` is 0 · a real frame-0 poster ·
999
+ audio present, flat, and normalised · no logo, watermark, URL, or brand chrome on screen · the
1000
+ rights tier declared in the handoff.
1001
+
1002
+ ---
1003
+
1004
+ ## Cost-saving mode — the whole build at $0
1005
+
1006
+ This format is the cheapest in the catalog. Nothing about it needs to cost money.
1007
+
1008
+ | Step | Free path |
1009
+ |---|---|
1010
+ | The wall | You write it. There is nothing to generate |
1011
+ | The scene | A phone on a dashboard for twenty minutes, or `vidfarm public-raws` (free), or footage the customer already owns |
1012
+ | Type + layout | Appendix A. Local HTML, no cloud |
1013
+ | Grade + normalise | `ffmpeg`, local |
1014
+ | Frame-0 poster | `ffmpeg -vframes 1`, local |
1015
+ | Render | local render (`vidfarm render --local`), not cloud |
1016
+ | QA | Appendices B and C, local, plus `vidfarm qa <dir> --harness ./experimental/wall-text-pov-ugc.md` |
1017
+
1018
+ Set `vidfarm cost-mode minimize` for the batch. There is no step in this file that a billed
1019
+ operation improves.
1020
+
1021
+ ---
1022
+
1023
+ ## Bulk generation — this format is built for it
1024
+
1025
+ The economics are unusual and worth being explicit about: **the expensive ingredient is
1026
+ reusable and the cheap ingredient is the variable.**
1027
+
1028
+ ```
1029
+ 1 background clip × N walls = N videos
1030
+ ```
1031
+
1032
+ One 20-minute night drive, cut into six 5-second segments, is six backgrounds. Six backgrounds
1033
+ × thirty walls is a quarter's posting schedule, and every video after the first costs you
1034
+ nothing but the writing.
1035
+
1036
+ **Serialise deliberately.** Both reference templates are the same creator, the same road, the
1037
+ same type treatment, published five days apart. That repetition is an asset, not laziness:
1038
+
1039
+ - The account becomes **recognisable in the feed at a glance**, which is worth more than
1040
+ variety in a format this simple.
1041
+ - The returning viewer knows the shape and starts reading immediately, which raises the
1042
+ completion rate on every subsequent post.
1043
+ - The variable that carries all the learning is the **wall** — which is exactly the variable
1044
+ you want to be testing.
1045
+
1046
+ ### Hold three constant, vary one
1047
+
1048
+ | Vary this | To learn |
1049
+ |---|---|
1050
+ | **The wall's rung** (2 vs 3 vs 4) | How specific your audience needs it |
1051
+ | **The speaker frame** (overheard / quoted / confession / accusation) | How much attribution your claims need |
1052
+ | **The loop ratio** (2.5 vs 4.0 vs 5.5) | Where your audience's patience ends |
1053
+ | **The mode** (MIRROR vs TURN) | What a TURN post actually costs you in reach |
1054
+ | **The scene** | Almost nothing. Vary it last, and only for visual fatigue |
1055
+
1056
+ **A batch where the wall and the background both changed teaches you nothing.** Hold the
1057
+ background — it is free to hold and it is the variable that matters least.
1058
+
1059
+ Appendix E is a driver that takes a TSV of walls and one background and emits N composition
1060
+ directories.
1061
+
1062
+ ---
1063
+
1064
+ ## Quality gates — what is enforced, and by what
1065
+
1066
+ Be honest about which half is machine-checkable. Three gates, and they catch different things.
1067
+
1068
+ | Gate | Runs on | Catches |
1069
+ |---|---|---|
1070
+ | **1. Casting** — `band-measure.py` (Appendix B) | a candidate clip, before you build | Too bright · too busy · specular load. The cheapest gate by an order of magnitude |
1071
+ | **2. Structure** — `vidfarm qa <dir> --harness ./experimental/wall-text-pov-ugc.md` | the composition DOM | Front-matter `checks:` · slop furniture · safe zone · font regime · duration · text timing |
1072
+ | **3. Render** — `walltext-qa.py` (Appendix C) | the finished MP4 | Loop ratio · word floor · frame 0 and the last frame both live and both carrying the wall · block geometry · band luma across all frames · plate detection · audio LUFS and LRA · motion continuity |
1073
+
1074
+ ### ⚠️ `vidfarm qa` will fire `wall-of-text` on every video in this format. Do not fix it.
1075
+
1076
+ Gate 2 emits this, verbatim, on a correct build:
1077
+
1078
+ ```
1079
+ ! warn [wall-of-text] One static text layer carries 78 words held for 5.6s — a paragraph,
1080
+ not a caption. A scrolling viewer reads none of it.
1081
+ → Page it into kinetic cues: `vidfarm captions generate <dir> --style word-pop` …
1082
+ ```
1083
+
1084
+ **The suggested fix would destroy the format.** Paging the wall into 3–5-word kinetic cues
1085
+ removes the read-loop (Rule 2), empties frame 0 (Rule 5), and animates the text (Rule 3) — all
1086
+ three of the format's load-bearing properties, in one command that looks like it is helping.
1087
+
1088
+ The warning is correct in general and wrong here. It is written for a format where text
1089
+ supports a voiceover; in this one the text **is** the video, and "a scrolling viewer reads none
1090
+ of it" is a description of the mechanic rather than a defect — the viewer is not supposed to
1091
+ finish it on the first pass.
1092
+
1093
+ Everything else in gate 2's output is live and should be read normally. Note also that
1094
+ `vidfarm qa` reads `composition.html`, so symlink or copy `index.html` to that name before
1095
+ running it.
1096
+
1097
+ **What no gate can settle**, and therefore lives in the pre-flight checklist as a `- [ ]`:
1098
+ whether the wall is on rung 3 or rung 1 · whether the claim is judgeable · whether the scene is
1099
+ congruent or literal · whether a cold reader can tell it is an ad · whether MIRROR mode
1100
+ actually failed to resolve. Those are the ones that decide the video, and pretending a linter
1101
+ settles them would be a lie.
1102
+
1103
+ ---
1104
+
1105
+ ## Pre-flight checklist
1106
+
1107
+ **The mechanic**
1108
+ - [ ] The loop ratio is computed and written in the build log, and it is between 1.8 and 2.4
1109
+ - [ ] The deletion test was run line by line — name the line you cut, or why nothing could go
1110
+ - [ ] The two-thirds test was attempted: the wall does not survive being cut to two-thirds without losing an idea
1111
+ - [ ] No wind-up, no permission slip, no restatement, no hedge stack, no tidy close
1112
+ - [ ] The clip length is `words / 8` (clamped 8–12s), so one play reaches the halfway mark — ratio 1.8–2.4
1113
+ - [ ] If the wall is under 64 words: the single-read trade was chosen deliberately, and the wall was NOT padded to recover the ratio
1114
+ - [ ] The wall was written first and the duration derived from it, never the other way round
1115
+ - [ ] Nothing on screen moves except the background: no fade, no word-pop, no line arrival, no cut, no transition
1116
+
1117
+ **The wall**
1118
+ - [ ] The mode (MIRROR / TURN) was declared before writing, and the wall matches it
1119
+ - [ ] The speaker frame was chosen deliberately, and attribution is doing the work it should
1120
+ - [ ] The wall reaches rung 3 or rung 4 of the specificity ladder — name the rung
1121
+ - [ ] There is a judgeable claim in it that a reasonable person could argue with
1122
+ - [ ] The cold-viewer test was actually run on the plain text with the brand removed
1123
+ - [ ] MIRROR mode: the wall does not resolve. TURN mode: it pivots exactly once and then stops
1124
+ - [ ] No invented specifics, no statistics, no earnings/health/appearance outcome, no named competitor
1125
+ - [ ] Lines are auto-wrapped, not hand-broken; at most one manual paragraph break
1126
+
1127
+ **The scene**
1128
+ - [ ] The scene agrees with the wall's temperature and contains none of its content — it is not literal
1129
+ - [ ] Appendix B was run on the clip **before** the build, and it passed
1130
+ - [ ] The clip is one unbroken take with continuous motion and no cut inside it
1131
+ - [ ] UGC class only: nobody looks at the camera
1132
+ - [ ] The rights tier is declared in the handoff, and tier ⚠️ was the customer's call
1133
+
1134
+ **Production**
1135
+ - [ ] No plate, no gradient behind the text, no blur on the background — legibility came from casting plus a soft shadow
1136
+ - [ ] Block centre is 48–52% on BOTH axes, and the horizontal centre was measured as a per-line median (not a union extent — a light in the block's rows corrupts that)
1137
+ - [ ] Font size was solved by the fitter, not chosen — and it did not clamp at 30px
1138
+ - [ ] TikTok Sans 700, self-hosted, **no fallback chain in the font stack**
1139
+ - [ ] Frame 0 carries the complete wall over the real background — there is an explicit poster still
1140
+ - [ ] The last frame also carries the wall (it loops; there is no tail)
1141
+ - [ ] Audio is present, flat (LRA ≤ 3.0), normalised to −23…−18 LUFS, and contains no speech
1142
+ - [ ] The plate is ping-ponged (or `--no-pingpong` is justified by a directional subject), and the seam was checked by concatenating the render to itself
1143
+ - [ ] No slop furniture, and `vidfarm qa` is otherwise clean
1144
+
1145
+ **Whole-video review** — on the render, not the plan
1146
+ - [ ] A contact sheet of ~8 stills was read as one image: the block does not drift, the type does not change size, the background does not brighten past the gate at any point
1147
+ - [ ] Two frames from opposite ends of the clip were compared — a frozen render passes duration, frame-count and audio-hash checks and looks fine in a single still
1148
+ - [ ] The video was watched three times through the loop without pausing, and the join was not distracting
1149
+ - [ ] The wall was read at natural speed on a phone-sized preview and every line was comfortably legible, including on the brightest frame
1150
+ - [ ] In a batch: this variant differs from its siblings in the **wall**, and the background was deliberately held constant
1151
+
1152
+ ---
1153
+
1154
+ ## Diagnosing a flop — read the mechanic, not the video
1155
+
1156
+ | What the numbers say | Weak part | Fix |
1157
+ |---|---|---|
1158
+ | Barely any views | The first six words, or the frame-0 thumbnail | The opening clause is not an interrupt. Rewrite it. Check the poster actually rendered |
1159
+ | Views, average watch time ≈ the duration (ratio ≈ 1.0) | **The loop** | Nobody committed to reading. The wall is too short, too abstract, or the type is too small |
1160
+ | Watch time is a multiple of duration, but no comments | **The claim** | It was relatable, not judgeable. Nothing to argue with. Add a generalisation or a causal claim |
1161
+ | Comments, but all hostile | **The frame** | You used ⚔️ accusation, or an unattributed harsh claim. Move it into someone else's mouth |
1162
+ | Good numbers, zero clicks | Working as designed in MIRROR mode | This is not a failure. Run a TURN post against the audience it built |
1163
+ | Comments say "this is an ad" | **The mode leaked** | A MIRROR wall that resolved, a plate, a CTA, a stat, or a handle that got too loud |
1164
+ | Saves are high, shares are low | The wall is about them, not about a situation they share | Move from 🕯 confession toward 📻 overheard or 🗣 quoted |
1165
+
1166
+ **Vary one thing at a time**, and the thing to vary is the wall. The background is free to
1167
+ hold constant and holding it is what makes the comparison mean anything.
1168
+
1169
+ ---
1170
+
1171
+ ## Appendix A — `walltext-build.py`
1172
+
1173
+ Builds a complete composition directory: the graded scene, the frame-0 poster, the fitted
1174
+ type, and `index.html`. One command, no cloud, $0.
1175
+
1176
+ ```python
1177
+ #!/usr/bin/env python3
1178
+ """
1179
+ Build one wall-text POV/UGC composition.
1180
+
1181
+ usage:
1182
+ walltext-build.py <scene.mp4> <wall.txt> <outdir>
1183
+ [--dur auto] [--in 0] [--handle "dishcover.io"]
1184
+ [--case lower|as-written] [--target-h 0.34]
1185
+ [--bed tone.m4a] [--keep-scene-audio] [--no-pingpong]
1186
+
1187
+ What it does, in order:
1188
+ 1. reads the wall, counts words, and REFUSES to build below the 40-word floor
1189
+ or above a loop ratio of 6.0 — the two failures that are invisible on the
1190
+ render and fatal in the feed
1191
+ 2. cuts the scene to <dur> from <in>, scaled+cropped to 1080x1920, audio kept
1192
+ 3. SOLVES the font size from the character count (see "Size is an output")
1193
+ and does a real greedy wrap to confirm the block height
1194
+ 4. writes media/poster.png — frame 0 of the cut scene. A one-shot composition
1195
+ renders frame 0 before the <video> element has decoded anything, so without
1196
+ this the thumbnail ships as an empty background. Frame 0 is the whole video
1197
+ in this format (Rule 5), so this is not a nicety.
1198
+ 5. writes index.html with the vidfarm data-* contract
1199
+
1200
+ What it deliberately does NOT do:
1201
+ * grade the clip. Grading is a judgement made against band-measure.py's
1202
+ numbers, so it stays a separate, visible step. A build script that quietly
1203
+ darkens footage hides the casting failure it is compensating for.
1204
+ * hand-break lines. See "Let it wrap".
1205
+ """
1206
+ import subprocess, sys, pathlib, html, math, shutil
1207
+
1208
+ if len(sys.argv) < 4:
1209
+ sys.exit(__doc__)
1210
+ scene, wallpath, outdir = sys.argv[1], sys.argv[2], pathlib.Path(sys.argv[3])
1211
+
1212
+ def opt(flag, default, cast=float):
1213
+ return cast(sys.argv[sys.argv.index(flag) + 1]) if flag in sys.argv else default
1214
+
1215
+ DUR = opt("--dur", 0.0) # 0 = auto: words/8, set after the wall is counted
1216
+ TIN = opt("--in", 0.0)
1217
+ HANDLE = opt("--handle", "", str)
1218
+ CASE = opt("--case", "as-written", str)
1219
+ BED = opt("--bed", "", str) # path to a bed. Empty = synthesise a flat one
1220
+ OFFSET = opt("--offset", 0.0) # box centre, as a fraction of W off frame centre.
1221
+ # 0.0 is correct: measured per-line, both references
1222
+ # centre at 49.9% and 50.0%. The knob exists only to
1223
+ # dodge a fixed bright object in a specific plate.
1224
+ TARGET_H = opt("--target-h", 0.34) # block height as a fraction of frame height
1225
+
1226
+ W, H, FPS = 1080, 1920, 30
1227
+ LINE_H = 1.25
1228
+ # The BOX is wider than the INK. Both references measure 0.665 and 0.677 of frame
1229
+ # width from the leftmost to the rightmost lit pixel — but that is the widest LINE,
1230
+ # not the container it wrapped inside, which has to be wider still. 0.72 is the box
1231
+ # that reproduces both references' line counts; 0.68 is what you will measure back
1232
+ # off the render. Do not "correct" one to the other.
1233
+ BOX_FRAC = 0.72
1234
+ ADV = 0.58 # measured glyph advance/char, TikTok Sans 700 mixed case
1235
+ SIZE_MIN, SIZE_MAX = 30, 52
1236
+ READ_WPS = 4.0 # plan against the FAST reader
1237
+
1238
+ box_w = int(W * BOX_FRAC)
1239
+
1240
+ # ---------------------------------------------------------------- 1. the wall
1241
+ raw = pathlib.Path(wallpath).read_text(encoding="utf-8").strip()
1242
+ if CASE == "lower":
1243
+ raw = raw.lower()
1244
+ # collapse soft wrapping in the source file but KEEP deliberate blank lines,
1245
+ # which are the one permitted manual break (Reference A's line before "Is this true?")
1246
+ paras = [" ".join(p.split()) for p in raw.split("\n\n") if p.strip()]
1247
+ if len(paras) > 2:
1248
+ sys.exit(f"FAIL: {len(paras)} paragraphs. The format allows one paragraph break, maximum.")
1249
+
1250
+ words = sum(len(p.split()) for p in paras)
1251
+ chars = sum(len(p) for p in paras) + (2 if len(paras) > 1 else 0)
1252
+ read = words / READ_WPS
1253
+
1254
+ # DURATION IS DERIVED, NOT CHOSEN. One play must land the viewer at the halfway
1255
+ # mark of the wall: committed, and exactly one pass from finishing. That is
1256
+ # duration = words/8 (read_time/2), i.e. a loop ratio of 2.0.
1257
+ #
1258
+ # Pushing the ratio higher does not bank more plays, it banks fewer: at 4.0 the
1259
+ # viewer gets a quarter of the way in, feels the size of what is left and bails,
1260
+ # and a bail at 25% is a worse signal than a completed loop. Reference B is that
1261
+ # mistake — 83 words in 5.1s, where words/8 would have run 10.4s.
1262
+ # The 8s FLOOR is a distribution decision, not a reading one: a poster dropping a
1263
+ # trending sound on this in the platform's editor needs a recognisable phrase of a
1264
+ # track, not a fragment. Trending audio is the only algorithmic surface this format
1265
+ # has — no voiceover, no music of its own.
1266
+ AUTO = DUR <= 0
1267
+ if AUTO:
1268
+ DUR = round(min(12.0, max(8.0, words / 8.0)), 2)
1269
+ ratio = read / DUR
1270
+
1271
+ print(f"wall: {words} words, {chars} chars")
1272
+ print(f"read {read:.1f}s / clip {DUR:.2f}s{' (auto = words/8)' if AUTO else ''}"
1273
+ f" -> loop ratio {ratio:.2f} ({100/ratio:.0f}% read per play)")
1274
+
1275
+ # NO word floor, on purpose. A floor is an instruction to pad, and padding is this
1276
+ # format's most common fatal failure. Anything out of band is a note about the CLIP.
1277
+ # Below 64 words the 8s music floor and the 50% rule cannot both hold, and the floor
1278
+ # wins. Saying "cut the clip" there would be advice to break the floor this script
1279
+ # just enforced, so that branch is suppressed and the trade is named instead.
1280
+ if ratio > 2.4:
1281
+ print(f"NOTE: ratio {ratio:.2f} — only {100/ratio:.0f}% read per play. The clip is too "
1282
+ f"SHORT; {words/8.0:.1f}s puts them at the halfway mark. Never add words.")
1283
+ elif ratio < 1.8 and words >= 64:
1284
+ print(f"NOTE: ratio {ratio:.2f} — {100/ratio:.0f}% read per play, so they can nearly finish "
1285
+ f"in one pass and the loop stops working. Cut the clip to {words/8.0:.1f}s.")
1286
+ elif words < 64:
1287
+ kind = ("PARTIAL LOOP — they get most of the way through and still need a second pass"
1288
+ if ratio >= 1.3 else
1289
+ "SINGLE-READ — they finish inside one play and the loop mechanic is gone")
1290
+ print(f"NOTE: {words} words vs the 8s music floor -> ratio {ratio:.2f} "
1291
+ f"({100/ratio:.0f}% read per play). {kind}.")
1292
+ print(f" This is a trade, not a defect. Do NOT pad the wall to 64 words to recover "
1293
+ f"the ratio — a padded 64 performs worse than a sharp {words}.")
1294
+ if AUTO and words / 8.0 > 12.0:
1295
+ print(f"WARNING: words/8 = {words/8.0:.1f}s exceeds the 12s ceiling, clamped to 12.0 "
1296
+ f"(ratio {ratio:.2f}). Cut the wall to ~96 words rather than accept this.")
1297
+
1298
+ # Cheap padding tells. None of these is conclusive; all of them are worth a second look.
1299
+ import re as _re
1300
+ _low = " ".join(paras).lower()
1301
+ _hedges = sum(_low.count(h) for h in (" maybe ", " kind of ", " sort of ", " i think ", " probably "))
1302
+ if _hedges >= 3:
1303
+ print(f"WARNING: {_hedges} hedges. A confession is not qualified — cut them.")
1304
+ for _p in ("i want to admit", "this might be controversial", "hear me out",
1305
+ "just something i", "anyway,", "at the end of the day"):
1306
+ if _p in _low:
1307
+ print(f'WARNING: "{_p}" — permission slip or tidy close. Delete it and start on the claim.')
1308
+
1309
+ # ------------------------------------------------- 2. solve the size, then verify by wrapping
1310
+ # Closed form (derivation in "Size is an output of word count"), then a real
1311
+ # greedy wrap to confirm — the closed form is accurate to ~5% and the wrap is
1312
+ # what actually decides the line count.
1313
+ size = math.sqrt((TARGET_H * H) * box_w / max(1, chars * ADV * LINE_H))
1314
+ size = max(SIZE_MIN, min(SIZE_MAX, int(round(size))))
1315
+
1316
+ def wrap(text, px):
1317
+ """Greedy wrap at the measured advance. Mirrors what the browser will do."""
1318
+ cpl = max(8, int(box_w / (ADV * px)))
1319
+ out, line = [], ""
1320
+ for w_ in text.split():
1321
+ trial = w_ if not line else line + " " + w_
1322
+ if len(trial) <= cpl:
1323
+ line = trial
1324
+ else:
1325
+ out.append(line); line = w_
1326
+ if line:
1327
+ out.append(line)
1328
+ return out
1329
+
1330
+ def block_lines(px):
1331
+ n = 0
1332
+ for i, p in enumerate(paras):
1333
+ n += len(wrap(p, px))
1334
+ if i < len(paras) - 1:
1335
+ n += 1 # the blank line
1336
+ return n
1337
+
1338
+ # Reconcile the closed form with the real wrap, in BOTH directions.
1339
+ #
1340
+ # Stepping down only is the obvious loop and it quietly costs type size: greedy
1341
+ # wrapping is ~8% more conservative than the closed form (integer chars-per-line
1342
+ # truncates, and every line wastes its own tail), so the wrap reports more lines
1343
+ # than the formula assumed and the size walks down from a start that was fine.
1344
+ # Letting it climb back is what puts the fitter within 1-2px of both references
1345
+ # (47 vs 46, 38 vs 40) instead of 4-5px under them.
1346
+ BUDGET = TARGET_H * H * 1.10
1347
+ while size > SIZE_MIN and block_lines(size) * size * LINE_H > BUDGET:
1348
+ size -= 1
1349
+ while size < SIZE_MAX and block_lines(size + 1) * (size + 1) * LINE_H <= BUDGET:
1350
+ size += 1
1351
+
1352
+ lines = block_lines(size)
1353
+ span = lines * size * LINE_H / H
1354
+ print(f"type: {size}px x{LINE_H} -> {lines} lines, block span {span*100:.1f}% of frame")
1355
+
1356
+ if size <= SIZE_MIN:
1357
+ print("WARNING: the fitter clamped at the minimum. The wall is too long — cut words.")
1358
+ if span > 0.40:
1359
+ print(f"WARNING: block span {span*100:.1f}% > 40%. It will crowd the platform's own UI. Cut words.")
1360
+
1361
+ # ------------------------------------------------------------- 3. cut the scene
1362
+ outdir.mkdir(parents=True, exist_ok=True)
1363
+ media = outdir / "media"; media.mkdir(exist_ok=True)
1364
+
1365
+ # scale+crop to fill 1080x1920. NEVER letterbox and never blur-bar.
1366
+ # (A line starting with "#" that also contains the three letters D-N-A gets
1367
+ # indexed as a strand heading by `vidfarm harness show`, even inside a fenced
1368
+ # code block. Hence the missing cross-reference here — it is deliberate.)
1369
+ # setpts/asetpts rebase to zero: after -ss the first frame carries a PTS a hair
1370
+ # above 0, and a nonzero start makes frame 0 render as nothing — which in this
1371
+ # format is the thumbnail.
1372
+ vf = (f"scale={W}:{H}:force_original_aspect_ratio=increase,"
1373
+ f"crop={W}:{H},fps={FPS},setpts=PTS-STARTPTS")
1374
+
1375
+ # PING-PONG: cut HALF the duration, then play it forward and reversed.
1376
+ #
1377
+ # The loop seam is a real cut that never appears in your editor and that every
1378
+ # viewer sees at least once — and `duration = words/8` made it worse, because a
1379
+ # longer clip means the plate drifted further between its first and last frame.
1380
+ # Measured on an 8.9s aerial plate: the straight cut's seam is 18.0 mean absolute
1381
+ # frame delta against a median frame-to-frame step of 3.4 — the loop join is a 5x
1382
+ # jump. Ping-ponged, the same plate measures 0.4, SMALLER than one ordinary frame
1383
+ # step, because the last frame IS the first frame.
1384
+ #
1385
+ # trim=start_frame=1 drops the duplicated middle frame; without it the turnaround
1386
+ # holds for two frames and reads as a hitch.
1387
+ #
1388
+ # Turn it off (--no-pingpong) when the plate has a clear DIRECTIONAL subject: a
1389
+ # person walking reverses into a moonwalk and it is the only thing anyone sees.
1390
+ # It is invisible on ambient drift, aerials, water, rain and distant traffic,
1391
+ # which is what this format casts anyway.
1392
+ PINGPONG = "--no-pingpong" not in sys.argv and "--keep-scene-audio" not in sys.argv
1393
+ cut_dur = DUR / 2 if PINGPONG else DUR
1394
+
1395
+ # THE SCENE IS MUTED (-an). Its own audio is a liability you did not choose — wind,
1396
+ # a voice, a siren, someone else's music. Every one of those is an audio EVENT, and
1397
+ # an event pulls a reader off the line they are on. Pass --keep-scene-audio only
1398
+ # after MEASURING it (LRA <= 3.0, no speech) on footage you own.
1399
+ # --keep-scene-audio forces --no-pingpong above: reversed audio is unmistakable
1400
+ # even where reversed picture is not.
1401
+ audio_args = ["-af", "asetpts=PTS-STARTPTS,loudnorm=I=-21:LRA=3:TP=-1.5",
1402
+ "-c:a", "aac", "-b:a", "128k"] if "--keep-scene-audio" in sys.argv else ["-an"]
1403
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-ss", str(TIN), "-t", str(cut_dur), "-i", scene,
1404
+ "-vf", vf, *audio_args,
1405
+ "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
1406
+ "-movflags", "+faststart", str(media / "scene.mp4")], check=True)
1407
+
1408
+ if PINGPONG:
1409
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(media / "scene.mp4"),
1410
+ "-filter_complex",
1411
+ "[0:v]split[a][b];[b]reverse,trim=start_frame=1,setpts=PTS-STARTPTS[r];"
1412
+ "[a][r]concat=n=2:v=1[v]",
1413
+ "-map", "[v]", "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
1414
+ "-movflags", "+faststart", str(media / "scene-pp.mp4")], check=True)
1415
+ shutil.move(str(media / "scene-pp.mp4"), str(media / "scene.mp4"))
1416
+ print(f"ping-pong: {cut_dur:.2f}s forward + reversed -> {DUR:.2f}s, seamless loop")
1417
+
1418
+ # The bed. Synthesised if none is given — free, flat, and congruent with any night
1419
+ # exterior. Measured on the references the bed is essentially a constant (LRA 2.1
1420
+ # and 0.7), so LRA=1 here is the right side to err on.
1421
+ if "--keep-scene-audio" not in sys.argv:
1422
+ bed = BED or str(media / "bed.m4a")
1423
+ if not BED:
1424
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi",
1425
+ "-i", f"anoisesrc=c=pink:r=44100:a=0.9:d={DUR + 1}",
1426
+ "-af", "lowpass=f=520,highpass=f=45,loudnorm=I=-21:LRA=1:TP=-2.0",
1427
+ "-ac", "2", "-c:a", "aac", "-b:a", "128k", bed], check=True)
1428
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(media / "scene.mp4"), "-i", bed,
1429
+ "-map", "0:v", "-map", "1:a", "-shortest", "-c:v", "copy",
1430
+ "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart",
1431
+ str(media / "scene-bed.mp4")], check=True)
1432
+ shutil.move(str(media / "scene-bed.mp4"), str(media / "scene.mp4"))
1433
+
1434
+ # ------------------------------------------------------- 4. the frame-0 poster
1435
+ # -vframes 1 with no -ss, so this is genuinely frame 0. Sampling with fps=N
1436
+ # would hand back the middle of the first interval instead.
1437
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(media / "scene.mp4"),
1438
+ "-vframes", "1", str(media / "poster.png")], check=True)
1439
+
1440
+ # the font must be self-hosted with NO fallback chain — a missing font would
1441
+ # silently render as a different one and nothing would report it
1442
+ font_src = pathlib.Path("assets/fonts/TikTokSans-700.woff2")
1443
+ fonts = media / "fonts"; fonts.mkdir(exist_ok=True)
1444
+ if font_src.exists():
1445
+ shutil.copy(font_src, fonts / "TikTokSans-700.woff2")
1446
+ else:
1447
+ print(f"WARNING: {font_src} not found. Put the self-hosted .woff2 there before rendering.")
1448
+
1449
+ # --------------------------------------------------------------- 5. index.html
1450
+ body = "\n".join(
1451
+ f'<p>{html.escape(p)}</p>' if i == 0 else f'<p class="tail">{html.escape(p)}</p>'
1452
+ for i, p in enumerate(paras))
1453
+
1454
+ handle_html = (f'<div class="handle">{html.escape(HANDLE)}</div>' if HANDLE else "")
1455
+ left_pct = (0.5 + OFFSET) * 100
1456
+
1457
+ HTML = f"""<!doctype html>
1458
+ <html lang="en"><head><meta charset="UTF-8">
1459
+ <meta name="viewport" content="width={W}, height={H}">
1460
+ <title>wall-text — {outdir.name}</title>
1461
+ <style>
1462
+ /* Self-hosted, NO fallback chain. A fallback means a missing font renders as a
1463
+ DIFFERENT font and nothing tells you — the video just quietly stops looking
1464
+ like it came out of the platform's own text tool. */
1465
+ @font-face{{font-family:'TikTok Sans';font-style:normal;font-weight:700;font-display:block;
1466
+ src:url('media/fonts/TikTokSans-700.woff2') format('woff2')}}
1467
+ body{{margin:0;background:#000}}
1468
+ #root{{position:relative;width:{W}px;height:{H}px;overflow:hidden;background:#000}}
1469
+ .clip{{position:absolute;inset:0}}
1470
+
1471
+ /* THE WALL.
1472
+ - NO plate, no background, no gradient. Legibility is bought by casting a dark
1473
+ scene (median luma <= 60) plus this shadow. A plate is the single clearest
1474
+ "made in an editor" tell and it is unfixable in the edit.
1475
+ - The shadow is insurance for the SPECULAR case: measured p98 luma behind the
1476
+ block in both references is ~176, i.e. streetlights genuinely do sweep across
1477
+ the text for a few frames over a few percent of its area.
1478
+ - text-align:center with a fixed box width, and NO hand-broken lines. Breaks
1479
+ landing mid-clause are what a dragged text box does; clause-aligned breaks
1480
+ read as typeset, and typeset reads as an ad.
1481
+ - The block is vertically CENTRED (both references: 50.0% and 49.9%), and the
1482
+ centred horizontally (both references measure 49.9% and 50.0% per line). */
1483
+ .wall{{
1484
+ position:absolute;
1485
+ left:{left_pct:.2f}%;
1486
+ top:50%;
1487
+ transform:translate(-50%,-50%);
1488
+ width:{box_w}px;
1489
+ font-family:'TikTok Sans';
1490
+ font-weight:700;
1491
+ font-size:{size}px;
1492
+ line-height:{LINE_H};
1493
+ color:#FFFFFF;
1494
+ text-align:center;
1495
+ letter-spacing:0;
1496
+ text-shadow:0 2px 10px rgba(0,0,0,.60), 0 0 3px rgba(0,0,0,.50);
1497
+ }}
1498
+ .wall p{{margin:0}}
1499
+ /* the ONE permitted manual break: the poster's own closing line, one blank line down */
1500
+ .wall p.tail{{margin-top:{int(size*LINE_H)}px}}
1501
+
1502
+ /* THE HANDLE. Attribution, not a CTA — findable on a screenshot, not readable at
1503
+ a glance. Same no-plate rule as the wall. Omitted entirely in TURN mode. */
1504
+ .handle{{
1505
+ position:absolute;left:0;right:0;
1506
+ top:calc(50% + {int(lines*size*LINE_H/2) + 40}px);
1507
+ font-family:'TikTok Sans';font-weight:700;font-size:26px;
1508
+ color:rgba(255,255,255,.55);letter-spacing:.02em;text-align:center;
1509
+ text-shadow:0 2px 8px rgba(0,0,0,.6);
1510
+ }}
1511
+ </style></head><body>
1512
+ <div id="root" data-composition-id="{outdir.name}" data-start="0"
1513
+ data-width="{W}" data-height="{H}" data-duration="{DUR}">
1514
+
1515
+ <!-- POSTER. Frame 0 renders before the <video> has decoded anything, so without
1516
+ this still the thumbnail ships as an empty background — and in this format
1517
+ frame 0 IS the video (Rule 5). -->
1518
+ <img id="poster" class="clip" data-start="0" data-duration="{DUR}" data-end="{DUR}"
1519
+ data-track-index="0" data-layer-kind="image" data-layer-mode="publish"
1520
+ data-hf-id="poster" data-label="frame-0 poster"
1521
+ src="media/poster.png" style="width:100%;height:100%;object-fit:cover">
1522
+
1523
+ <!-- ONE unbroken take, MUTED (data-volume 0). The bed is laid separately: a background
1524
+ clip's own audio is a liability you did not choose — wind, a voice, a siren, someone
1525
+ else's music — and every one of those is an audio EVENT that pulls a reader off the
1526
+ line they are on. No cuts exist in this format. -->
1527
+ <video id="scene" class="clip" data-start="0" data-duration="{DUR}" data-end="{DUR}"
1528
+ data-track-index="1" data-layer-kind="video" data-layer-mode="publish"
1529
+ data-hf-id="scene" data-label="ambient POV/UGC scene"
1530
+ data-volume="0" data-playback-start="0"
1531
+ src="media/scene.mp4" playsinline preload="auto"
1532
+ style="width:100%;height:100%;object-fit:cover"></video>
1533
+
1534
+ <!-- THE WALL. data-start="0" and data-duration = the full composition: it is
1535
+ complete at frame 0 and unchanged at the last frame. There is no other
1536
+ timing in this format, and nothing here animates (Rule 3). -->
1537
+ <div id="wall" class="wall" data-start="0" data-duration="{DUR}" data-end="{DUR}"
1538
+ data-track-index="2" data-layer-kind="text" data-layer-mode="publish"
1539
+ data-hf-id="wall" data-label="the wall">
1540
+ {body}
1541
+ </div>
1542
+ {handle_html}
1543
+
1544
+ </div></body></html>
1545
+ """
1546
+
1547
+ (outdir / "index.html").write_text(HTML, encoding="utf-8")
1548
+ print(f"\nwrote {outdir}/index.html")
1549
+ print(f"next: python3 band-measure.py {media/'scene.mp4'} --top {0.5-span/2:.3f} --bot {0.5+span/2:.3f}")
1550
+ print(f"then: vidfarm qa {outdir} --harness ./experimental/wall-text-pov-ugc.md")
1551
+ ```
1552
+
1553
+ ---
1554
+
1555
+ ## Appendix B — `band-measure.py` (gate 1: cast it, before you build)
1556
+
1557
+ The cheapest gate in the file by an order of magnitude. Run it on a candidate clip and recast
1558
+ in thirty seconds instead of after a render.
1559
+
1560
+ ```python
1561
+ #!/usr/bin/env python3
1562
+ """
1563
+ Measure the background behind the wall, across the WHOLE clip, and say whether
1564
+ plate-free type survives it.
1565
+
1566
+ usage: band-measure.py <clip.mp4> [--top 0.33] [--bot 0.67] [--l 0.16] [--r 0.84] [--fps 10]
1567
+
1568
+ Why the thresholds are what they are — measured across all 51 sampled frames of
1569
+ both reference templates, inside the block's own rect:
1570
+
1571
+ ref A ref B gate
1572
+ median luma, worst frame 18 17 <= 60
1573
+ mean luma, worst frame 38 39 <= 90
1574
+ % of band pixels > 120, worst 8.5% 8.6% <= 15%
1575
+ p98 luma (specular hits) 175 176 no gate
1576
+ frame-to-frame motion 3.6 3.8 1.5 .. 20
1577
+
1578
+ THE STANDARD DEVIATION LIES HERE, and that is the whole point of this script.
1579
+ Both references measure a band std of 43-47, which the general HARNESS.md
1580
+ treatment table reads as "busy -> needs a plate". It is wrong. The variation is
1581
+ a handful of streetlights a few pixels across; 40px+ type with a soft shadow
1582
+ sails straight over them. So this gates on the MEDIAN and on the FRACTION of
1583
+ bright pixels, never on the std — and it reports the std anyway so nobody
1584
+ re-derives this the hard way.
1585
+
1586
+ Text pixels are excluded (>190) so this works on a finished render as well as on
1587
+ a bare clip.
1588
+ """
1589
+ import subprocess, sys, numpy as np
1590
+
1591
+ clip = sys.argv[1]
1592
+ def opt(f, d): return float(sys.argv[sys.argv.index(f)+1]) if f in sys.argv else d
1593
+ TOP, BOT = opt("--top", 0.33), opt("--bot", 0.67)
1594
+ L, R = opt("--l", 0.16), opt("--r", 0.84)
1595
+ FPS = opt("--fps", 10)
1596
+
1597
+ SW, SH = 270, 480
1598
+ p = subprocess.run(["ffmpeg", "-v", "error", "-i", clip,
1599
+ "-vf", f"fps={FPS},scale={SW}:{SH}", "-f", "rawvideo",
1600
+ "-pix_fmt", "gray", "-"], capture_output=True).stdout
1601
+ n = len(p) // (SW * SH)
1602
+ if n == 0:
1603
+ sys.exit("no frames decoded — is that a video?")
1604
+ a = np.frombuffer(p, np.uint8)[:n*SW*SH].reshape(n, SH, SW).astype(float)
1605
+
1606
+ band = a[:, int(TOP*SH):int(BOT*SH), int(L*SW):int(R*SW)]
1607
+ bg = np.where(band > 190, np.nan, band) # drop the type's own pixels
1608
+
1609
+ med = np.array([np.nanmedian(f) for f in bg])
1610
+ mean = np.array([np.nanmean(f) for f in bg])
1611
+ std = np.array([np.nanstd(f) for f in bg])
1612
+ bright = np.array([np.nanmean(f > 120) for f in bg])
1613
+ p98 = np.array([np.nanpercentile(f, 98) for f in bg])
1614
+
1615
+ print(f"{clip} {n} frames sampled at {FPS}fps, band {TOP:.2f}-{BOT:.2f} x {L:.2f}-{R:.2f}")
1616
+ print(f" median luma mean {med.mean():6.1f} worst frame {med.max():6.1f} gate <= 60")
1617
+ print(f" mean luma mean {mean.mean():6.1f} worst frame {mean.max():6.1f} gate <= 90")
1618
+ print(f" %pixels >120 mean {bright.mean()*100:5.1f}% worst frame {bright.max()*100:5.1f}% gate <= 15%")
1619
+ print(f" p98 (specular) max {p98.max():6.0f} (no gate — this is what the shadow is for)")
1620
+ print(f" std mean {std.mean():6.1f} NOT a gate — see the docstring")
1621
+
1622
+ fail = []
1623
+ if med.max() > 60: fail.append(f"median luma {med.max():.0f} > 60 — too bright for plate-free type")
1624
+ if mean.max() > 90: fail.append(f"mean luma {mean.max():.0f} > 90")
1625
+ if bright.max() > 0.15: fail.append(f"{bright.max()*100:.0f}% of the band is over 120 — the scene is competing")
1626
+
1627
+ # a scene that never changes is a still, and a still has no loop seam to hide behind
1628
+ motion = np.abs(np.diff(a[:, ::4, ::4], axis=0)).mean()
1629
+ print(f" motion {motion:6.1f} (references: 3.6 and 3.8. < 1.5 is a freeze, > 20 is competing)")
1630
+ if motion < 1.5: fail.append(f"motion {motion:.1f} — this clip is effectively a still image")
1631
+ if motion > 20: fail.append(f"motion {motion:.1f} — too much is happening. The scene will steal the reading")
1632
+
1633
+ if fail:
1634
+ print("\nFAIL")
1635
+ for f in fail: print(" x", f)
1636
+ print("\n fix: recast, or grade uniformly and re-measure:")
1637
+ print(" ffmpeg -i in.mp4 -vf eq=brightness=-0.10:contrast=1.05:saturation=0.90 -c:a copy out.mp4")
1638
+ print(" do NOT fix it with a plate, a gradient behind the text, or a blur (Rule 4).")
1639
+ sys.exit(1)
1640
+ print("\nPASS — plate-free type survives this scene.")
1641
+ ```
1642
+
1643
+ ---
1644
+
1645
+ ## Appendix C — `walltext-qa.py` (gate 3: enforce it on the RENDER)
1646
+
1647
+ Front-matter `checks:` grade the composition and `vidfarm qa` grades the DOM. Neither can see
1648
+ the finished MP4, and this format's two signature failures — a loop ratio under 2.5, and a
1649
+ frame 0 that shipped empty — are only visible there.
1650
+
1651
+ ```python
1652
+ #!/usr/bin/env python3
1653
+ """
1654
+ Grade a rendered wall-text video.
1655
+
1656
+ usage: walltext-qa.py <out.mp4> --wall <wall.txt> [--mode mirror|turn] [--offer "dishcover.io"]
1657
+
1658
+ Checks, in order:
1659
+ 1. duration and canvas
1660
+ 2. frame 0 and the LAST frame are both live AND both carry the wall
1661
+ (it loops — there is no tail, and the last frame is a thumbnail too)
1662
+ 3. the block's geometry: vertical centre, span, horizontal offset
1663
+ 4. the loop ratio and the word floor
1664
+ 5. the band, across every sampled frame (the same gate as Appendix B)
1665
+ 6. NO PLATE — detected by looking for a rectangle of uniform luma behind the text
1666
+ 7. audio: present, flat, normalised, and not speech
1667
+ 8. motion continuity: one unbroken take, no cut inside the clip
1668
+ 9. the wall, as written: forbidden phrases, statistics, and the offer's placement
1669
+
1670
+ The pole/mode changes what is allowed (Rule 7). Default to MIRROR — it is the
1671
+ stricter of the two, so an unset mode can never silently pass a resolution that
1672
+ nobody decided to allow.
1673
+ """
1674
+ import subprocess, sys, re, json, pathlib, numpy as np
1675
+
1676
+ vid = sys.argv[1]
1677
+ def opt(f, d, c=str): return c(sys.argv[sys.argv.index(f)+1]) if f in sys.argv else d
1678
+ WALL = opt("--wall", None)
1679
+ MODE = opt("--mode", "mirror").lower()
1680
+ OFFER = opt("--offer", "").lower()
1681
+
1682
+ W, H = 1080, 1920
1683
+ fails, warns = [], []
1684
+ def bad(m): fails.append(m)
1685
+ def warn(m): warns.append(m)
1686
+
1687
+ # ------------------------------------------------------------- 1. container
1688
+ pr = json.loads(subprocess.run(
1689
+ ["ffprobe", "-v", "error", "-show_format", "-show_streams", "-of", "json", vid],
1690
+ capture_output=True, text=True).stdout)
1691
+ v = next((s for s in pr["streams"] if s["codec_type"] == "video"), None)
1692
+ au = next((s for s in pr["streams"] if s["codec_type"] == "audio"), None)
1693
+ dur = float(pr["format"]["duration"])
1694
+ if not v: sys.exit("no video stream")
1695
+ if (v["width"], v["height"]) != (W, H): bad(f"canvas {v['width']}x{v['height']}, expected {W}x{H}")
1696
+ if not 7.9 <= dur <= 12.1:
1697
+ bad(f"duration {dur:.2f}s outside 8-12s — the 8s floor is what lets a poster lay a "
1698
+ f"trending sound over this and get a real phrase of a track")
1699
+ if not au: bad("NO AUDIO — a silent upload is down-ranked and reads as broken")
1700
+
1701
+ # ------------------------------------- 2. frame 0 and the last frame, both live
1702
+ # -vframes 1 with an explicit -ss, never fps=N: `fps` hands back the middle of
1703
+ # the first interval, so a genuinely blank frame 0 would sample as a fine still.
1704
+ def grab(t):
1705
+ p = subprocess.run(["ffmpeg", "-v", "error", "-ss", f"{t}", "-i", vid, "-vframes", "1",
1706
+ "-f", "rawvideo", "-pix_fmt", "gray", "-"], capture_output=True).stdout
1707
+ if len(p) < W*H: return None
1708
+ return np.frombuffer(p[:W*H], np.uint8).reshape(H, W).astype(float)
1709
+
1710
+ f0, fl = grab(0.0), grab(max(0.0, dur - 0.06))
1711
+ for label, fr in (("frame 0", f0), ("last frame", fl)):
1712
+ if fr is None:
1713
+ bad(f"{label} did not decode"); continue
1714
+ if fr.mean() < 2.0:
1715
+ bad(f"{label} is black — no poster still, or the tail runs past the clip")
1716
+ txt = (fr > 200).sum()
1717
+ if txt < 4000:
1718
+ bad(f"{label} carries {txt}px of type — the wall is not up (Rule 5). "
1719
+ f"Frame 0 needs an explicit poster; the last frame needs data-duration to match.")
1720
+
1721
+ # ------------------------------------------------------ 3. the block geometry
1722
+ if f0 is not None:
1723
+ m = f0 > 210
1724
+ dens = m.sum(1) # lit columns per row
1725
+ lit = dens > 40
1726
+
1727
+ # Group lit rows into RUNS, keep runs >= 8 rows tall, then keep only runs
1728
+ # whose PEAK density is >= 40% of the strongest run's.
1729
+ #
1730
+ # Taking rows.min()/rows.max() straight off the mask is the obvious version
1731
+ # and it is wrong on exactly the footage this format casts. A streetlight
1732
+ # flare is a lit run 90+ rows tall, nowhere near the text. On reference A it
1733
+ # reads the block as 7%-65% (centre 36.1%, span 58.1%) instead of the real
1734
+ # 35.0%-65.1% — three failed checks, every one of them a phantom.
1735
+ #
1736
+ # Height alone does not separate them (the flare is TALLER than a text line).
1737
+ # Density does, by a wide margin: on reference A the text lines peak at
1738
+ # 176-388 lit columns and the flare peaks at 60. The %-of-strongest rule is
1739
+ # scale-free, so it survives a different font size and a brighter flare —
1740
+ # a hardcoded column threshold would not.
1741
+ #
1742
+ # 25%, not 40%. The closing ask is a STRUCTURAL feature of this format and it
1743
+ # is deliberately short — "is this true?" is three words against a 12-word
1744
+ # body line, so it lights ~37% of the peak columns and a 40% cut silently
1745
+ # drops it. The block then measures short and off-centre (25.8% span, 47.7%
1746
+ # centre) and the video fails a geometry check it actually passes. The flare
1747
+ # sits at 15%, so 25% still separates them cleanly.
1748
+ runs, s = [], None
1749
+ for i, val in enumerate(lit):
1750
+ if val and s is None: s = i
1751
+ if not val and s is not None:
1752
+ runs.append((s, i)); s = None
1753
+ if s is not None: runs.append((s, len(lit)))
1754
+ runs = [r for r in runs if r[1] - r[0] >= 8]
1755
+ if runs:
1756
+ peak = max(dens[a:b].max() for a, b in runs)
1757
+ runs = [r for r in runs if dens[r[0]:r[1]].max() >= 0.25 * peak]
1758
+
1759
+ if len(runs) < 3:
1760
+ bad(f"could not find a text block in frame 0 ({len(runs)} text-like rows runs)")
1761
+ else:
1762
+ r0, r1 = runs[0][0], runs[-1][1]
1763
+ top, bot = r0/H, r1/H
1764
+ centre, span = (top+bot)/2, bot-top
1765
+ # Horizontal centre: take the MEDIAN of each text line's own centre,
1766
+ # not the union extent of all of them. A light sitting inside the block's
1767
+ # rows drags a union extent hard (on reference B it moves the reported
1768
+ # reported centre from the true 50.0% to 43.9%),
1769
+ # but it can only corrupt the one line it overlaps, and the median
1770
+ # outvotes it. Lines are ragged either side, so their centres agree.
1771
+ centres = []
1772
+ for a, b in runs:
1773
+ c = np.where(m[a:b].sum(0) > 1)[0]
1774
+ if len(c) > 20:
1775
+ centres.append((c.min()+c.max())/2/W)
1776
+ hcentre = float(np.median(centres)) if centres else 0.5
1777
+ print(f"block: centre {centre*100:.1f}% span {span*100:.1f}% hcentre {hcentre*100:.1f}%")
1778
+ if not 0.48 <= centre <= 0.52:
1779
+ bad(f"block vertical centre {centre*100:.1f}% — the spec is 48-52% (both references: 50.0%)")
1780
+ if not 0.28 <= span <= 0.42:
1781
+ warn(f"block span {span*100:.1f}% — the references are 30.3% and 38.3%")
1782
+ if not 0.48 <= hcentre <= 0.52:
1783
+ warn(f"box horizontal centre {hcentre*100:.1f}% — the spec is 48-52%. "
1784
+ f"Both references measure 49.9% and 50.0%")
1785
+ if top < 0.08 or bot > 0.85:
1786
+ bad(f"block runs {top*100:.0f}%-{bot*100:.0f}%, outside the 8-85% safe zone")
1787
+
1788
+ # ---------------------------------------------- 4. the loop ratio and the floor
1789
+ wall = ""
1790
+ if WALL:
1791
+ wall = pathlib.Path(WALL).read_text(encoding="utf-8").strip()
1792
+ words = len(wall.split())
1793
+ ratio = (words / 4.0) / dur
1794
+ print(f"wall: {words} words -> read {words/4.0:.1f}s / {dur:.2f}s = loop ratio {ratio:.2f}")
1795
+ # No word floor — see Rule 2. Out of band is a note about the CLIP, never the wall.
1796
+ print(f" {100/ratio:.0f}% of the wall is read per play (target 50%, band 42-56%)")
1797
+ if ratio > 2.4:
1798
+ warn(f"ratio {ratio:.2f} — only {100/ratio:.0f}% read per play. The clip is too short; "
1799
+ f"words/8 = {words/8.0:.1f}s. Never fix this by adding words")
1800
+ elif ratio < 1.8 and words >= 64:
1801
+ warn(f"ratio {ratio:.2f} — {100/ratio:.0f}% read per play, near enough to finish in one "
1802
+ f"pass, so the loop stops working. Cut the clip to {words/8.0:.1f}s")
1803
+ elif ratio < 1.8:
1804
+ kind = "partial loop" if ratio >= 1.3 else "SINGLE-READ, no loop"
1805
+ print(f" {words} words vs the 8s music floor -> {kind} (ratio {ratio:.2f}). "
1806
+ f"A trade, not a defect — intended, if you chose it.")
1807
+ else:
1808
+ warn("no --wall given; skipped the loop ratio, which is the format's central check")
1809
+
1810
+ # ---------------------------------------------------- 5. the band, every frame
1811
+ p = subprocess.run(["ffmpeg", "-v", "error", "-i", vid, "-vf", "fps=10,scale=270:480",
1812
+ "-f", "rawvideo", "-pix_fmt", "gray", "-"], capture_output=True).stdout
1813
+ n = len(p)//(270*480)
1814
+ a = np.frombuffer(p, np.uint8)[:n*270*480].reshape(n, 480, 270).astype(float)
1815
+ band = a[:, int(.30*480):int(.70*480), int(.16*270):int(.84*270)]
1816
+ bg = np.where(band > 190, np.nan, band)
1817
+ med = max(np.nanmedian(f) for f in bg)
1818
+ bright = max(np.nanmean(f > 120) for f in bg)
1819
+ print(f"band: worst median {med:.0f} (gate 60) worst %>120 {bright*100:.1f}% (gate 15%)")
1820
+ if med > 60: bad(f"band median luma {med:.0f} > 60 — too bright for plate-free type (Rule 4)")
1821
+ if bright > 0.15: bad(f"{bright*100:.0f}% of the band is over 120 — the scene is competing with the wall")
1822
+
1823
+ # --------------------------------------------------------------- 6. NO PLATE
1824
+ # A plate is a rectangle of near-uniform luma sitting behind the type. Real
1825
+ # footage inside the block's rect is never uniform for long. So: take the band's
1826
+ # background pixels and ask how much of the frame's area sits in one narrow luma
1827
+ # bin. Footage spreads; a plate spikes.
1828
+ plate_hits = 0
1829
+ for f in bg[::3]:
1830
+ x = f[~np.isnan(f)]
1831
+ if len(x) < 500: continue
1832
+ hist, _ = np.histogram(x, bins=32, range=(0, 255))
1833
+ if hist.max() / len(x) > 0.72 and np.nanstd(x) < 9:
1834
+ plate_hits += 1
1835
+ if plate_hits > len(bg[::3]) * 0.5:
1836
+ bad("a PLATE is detected behind the wall — uniform luma across the block's rect. "
1837
+ "There is no plate in this format at any opacity (Rule 4)")
1838
+
1839
+ # --------------------------------------------------------------- 7. the audio
1840
+ if au:
1841
+ out = subprocess.run(["ffmpeg", "-hide_banner", "-i", vid, "-af", "ebur128=peak=true",
1842
+ "-f", "null", "-"], capture_output=True, text=True).stderr
1843
+ # findall()[-1], NOT search(). ebur128 streams a per-frame "I:" line for the
1844
+ # whole clip and only THEN prints the summary, so search() returns frame 0's
1845
+ # integrated loudness — which is -70 LUFS on every file ever made, and fails
1846
+ # every video for an audio defect none of them have.
1847
+ Is = re.findall(r"I:\s*(-?[\d.]+)\s*LUFS", out)
1848
+ LRAs = re.findall(r"LRA:\s*([\d.]+)\s*LU", out)
1849
+ if Is and LRAs:
1850
+ I, LRA = float(Is[-1]), float(LRAs[-1])
1851
+ print(f"audio: {I:.1f} LUFS, LRA {LRA:.1f} LU")
1852
+ if not -23.5 <= I <= -17.5:
1853
+ bad(f"{I:.1f} LUFS outside -23..-18. Run loudnorm=I=-21:LRA=3:TP=-1.5")
1854
+ if LRA > 3.0:
1855
+ bad(f"LRA {LRA:.1f} LU > 3.0 — the bed has structure. It must be flat: "
1856
+ f"any audio event pulls a reader off the line they are on (references: 2.1 and 0.7)")
1857
+
1858
+ # ------------------------------------------ 8. one take, no cut inside the clip
1859
+ d = np.abs(np.diff(a[:, ::4, ::4].reshape(n, -1), axis=0)).mean(1)
1860
+ if len(d) > 3:
1861
+ spike = d.max() / max(1e-6, np.median(d))
1862
+ print(f"motion: median {np.median(d):.1f}, worst jump x{spike:.1f} the median")
1863
+ if spike > 6:
1864
+ bad(f"a CUT is detected inside the clip (frame delta {spike:.1f}x the median). "
1865
+ f"This format is one unbroken take")
1866
+ if np.median(d) < 1.0:
1867
+ bad("the background is effectively frozen — a still with text on it, not a scene")
1868
+
1869
+ # ------------------------------------------------- 9. the wall, as written
1870
+ if wall:
1871
+ low = wall.lower()
1872
+ BANNED = ["link in bio", "sign up", "free trial", "get started", "book a demo",
1873
+ "download now", "learn more", "swipe up", "dm me", "comment below",
1874
+ "follow for", "part two", "part 2"]
1875
+ for b in BANNED:
1876
+ if b in low: bad(f'the wall contains "{b}" — the bait is the post caption, never the video (Rule 9)')
1877
+ if re.search(r"\b\d+\s?%|\b\d+x\b|\$\s?\d", low):
1878
+ bad("a statistic or a money figure is in the wall — a confession does not cite data (Rule 8)")
1879
+ if OFFER:
1880
+ named = OFFER in low
1881
+ if MODE == "mirror" and named:
1882
+ bad(f'MIRROR mode names "{OFFER}" in the wall. In MIRROR the offer lives in the '
1883
+ f'handle and the post caption only (Rule 7)')
1884
+ if MODE == "turn" and not named:
1885
+ bad(f'TURN mode does not name "{OFFER}" anywhere in the wall — then it is a MIRROR post')
1886
+ if MODE == "turn" and low.count(OFFER) > 1:
1887
+ bad(f'TURN mode names "{OFFER}" {low.count(OFFER)} times. Once, in the pivot, then stop')
1888
+
1889
+ # ------------------------------------------------------------------- verdict
1890
+ print()
1891
+ for w_ in warns: print(" ! ", w_)
1892
+ if fails:
1893
+ print("FAIL")
1894
+ for f in fails: print(" x ", f)
1895
+ sys.exit(1)
1896
+ print("PASS")
1897
+ ```
1898
+
1899
+ ---
1900
+
1901
+ ## Appendix D — a worked example
1902
+
1903
+ The build log for one MIRROR post. This is the whole artifact — three lines of thinking, one
1904
+ wall, one clip.
1905
+
1906
+ ```
1907
+ WHO: solo founder, ships alone, no team
1908
+ MOMENT: 1am, third night in a row, the launch post still unwritten
1909
+ THE ACHE: I am not blocked. I just cannot bear to be perceived getting it wrong.
1910
+
1911
+ MODE: MIRROR
1912
+ FRAME: 📻 overheard
1913
+ SCENE: POV — night drive, empty arterial road, streetlights
1914
+ RIGHTS: owned (shot on a dashboard mount, 2026-08-14)
1915
+ ```
1916
+
1917
+ The wall (`wall.txt`):
1918
+
1919
+ ```
1920
+ a founder told me “the reason you haven’t posted in four months isn’t
1921
+ that you’re busy. it’s that posting means someone can watch you be
1922
+ mediocre in public, and you’ve built an entire company specifically so
1923
+ that nobody ever gets to. every system you added, every process, every
1924
+ ‘let me just polish this first’ — that was the same instinct wearing a
1925
+ work shirt. you didn’t build a business, you built a place to hide.”
1926
+
1927
+ is he right?
1928
+ ```
1929
+
1930
+ The numbers, as the tools actually report them:
1931
+
1932
+ | | Value | Gate |
1933
+ |---|---|---|
1934
+ | Words | 78 | ≥ 40 ✅ |
1935
+ | Chars | 432 | |
1936
+ | Read time | 19.5s | |
1937
+ | Clip | 8.0s (`words/8`, floored) | 8–12s ✅ |
1938
+ | **Loop ratio** | **2.44** | 1.8–2.4 ✅ |
1939
+ | Fitted size | 38px | ≥ 30 ✅ |
1940
+ | Lines | 15 | |
1941
+ | Block span | 37.1% | ≤ 40% ✅ |
1942
+ | Band median luma, worst frame | 16 | ≤ 60 ✅ |
1943
+ | Band % > 120, worst frame | 7.3% | ≤ 15% ✅ |
1944
+ | Motion | 3.6 | 1.5–20 ✅ |
1945
+
1946
+ The build:
1947
+
1948
+ ```bash
1949
+ python3 band-measure.py raws/nightdrive-arterial.mp4 --top .32 --bot .68 # gate 1 — cast it
1950
+ python3 walltext-build.py raws/nightdrive-arterial.mp4 wall.txt work/v001 \
1951
+ --dur 5.6 --in 142 --case lower
1952
+ vidfarm qa work/v001 --harness ./experimental/wall-text-pov-ugc.md # gate 2 — the DOM
1953
+ vidfarm render work/v001 --local --out work/v001/out.mp4
1954
+ python3 walltext-qa.py work/v001/out.mp4 --wall wall.txt --mode mirror \
1955
+ --offer "dishcover.io" # gate 3 — the render
1956
+ ```
1957
+
1958
+ The post caption — where the offer and the second copy of the ask live:
1959
+
1960
+ ```
1961
+ he said this to me in a car park and I haven't recovered. is he right, or is that
1962
+ just something people say to founders who are genuinely busy?
1963
+
1964
+ (I'm the one who hid. dishcover.io shipped 11 months late for exactly this reason.)
1965
+ ```
1966
+
1967
+ **Note what the caption does that the wall is not allowed to do:** it names the offer, it
1968
+ concedes something unflattering and true, and it repeats the ask. All three would break the
1969
+ wall. The post caption is not an afterthought in this format — it is the second half of the
1970
+ build, and it carries everything the video deliberately cannot.
1971
+
1972
+ ---
1973
+
1974
+ ## Appendix E — `walltext-batch.py` (one background, N walls)
1975
+
1976
+ The format's whole economic argument, in one script.
1977
+
1978
+ ```python
1979
+ #!/usr/bin/env python3
1980
+ """
1981
+ Build N compositions from ONE background and a TSV of walls.
1982
+
1983
+ usage: walltext-batch.py <scene.mp4> <walls.tsv> <outroot> [--in 142] [--handle "…"]
1984
+
1985
+ walls.tsv — one row per video, tab-separated:
1986
+ <slug>\t<dur>\t<mode>\t<path/to/wall.txt>
1987
+
1988
+ Holding the background CONSTANT across the batch is deliberate, not lazy (see
1989
+ "Bulk generation"): it is free to hold, it is the variable that matters least,
1990
+ and it is what makes the comparison between walls mean anything. A batch where
1991
+ the wall and the background both changed teaches you nothing.
1992
+
1993
+ It also builds the account's recognisability — both reference templates are the
1994
+ same creator, the same road, the same treatment, five days apart.
1995
+ """
1996
+ import subprocess, sys, pathlib, csv
1997
+
1998
+ scene, tsv, root = sys.argv[1], sys.argv[2], pathlib.Path(sys.argv[3])
1999
+ passthru = sys.argv[4:]
2000
+ here = pathlib.Path(__file__).parent
2001
+
2002
+ rows = [r for r in csv.reader(open(tsv), delimiter="\t") if r and not r[0].startswith("#")]
2003
+ print(f"{len(rows)} walls -> {root}/\n")
2004
+
2005
+ ok, failed = [], []
2006
+ for slug, dur, mode, wallpath in rows:
2007
+ out = root / slug
2008
+ print(f"── {slug} ({mode}, {dur}s)")
2009
+ r = subprocess.run([sys.executable, str(here/"walltext-build.py"),
2010
+ scene, wallpath, str(out), "--dur", dur] + passthru)
2011
+ (failed if r.returncode else ok).append(slug)
2012
+ print()
2013
+
2014
+ print(f"built {len(ok)} failed {len(failed)}")
2015
+ if failed:
2016
+ print("failed:", ", ".join(failed))
2017
+ print("almost always the loop ratio or the 40-word floor — both are in the build output above.")
2018
+ ```
2019
+
2020
+ ---
2021
+
2022
+ ## What this format is NOT
2023
+
2024
+ Worth stating, because each of these is one small step away and each is a different harness:
2025
+
2026
+ | If you find yourself… | You are building | Use |
2027
+ |---|---|---|
2028
+ | …cutting between two clips | a short-form edit | `HARNESS.md` (round 1) |
2029
+ | …adding a voiceover and captions | a UGC testimonial or an explainer | `ugc-reaction-greenscreen` |
2030
+ | …hand-breaking the lines and plating the text | a meme caption | `meme-recaption` |
2031
+ | …putting the product on screen | a product demo | `product-demo` |
2032
+ | …animating the text in | a motion graphic | `/motion-graphics` |
2033
+
2034
+ **The discipline of this format is entirely subtractive.** Everything you know how to do makes
2035
+ it worse. The only two things you get to be good at are the wall and the casting, and the wall
2036
+ is 95% of it.