@officexapp/vidfarm-devcli 0.21.56 → 0.21.58

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,1370 @@
1
+ ---
2
+ name: engaging-chat-convo
3
+ video_type: Chat-thread slideshow — a text conversation told in N still screenshots, one story, one product beat. Ships as a photo carousel; a 3s-per-slide MP4 is the proof copy (TikTok / Reels / Shorts)
4
+ checks:
5
+ duration_sec: 24-45 # the VIDEO version. N slides x 3.0s exactly. 8 slides = 24s, 15 = 45s
6
+ aspect: 9:16
7
+ first_frame_visual: required
8
+ first_frame_text: required
9
+ text_by_sec: 0 # slide 1's first bubble is up at frame 0. There is no build-on
10
+ captions: optional # there is no narration to caption. The bubbles ARE the content
11
+ audio: optional # the carousel is silent; the poster adds sound in-app
12
+ font_regime: required
13
+ safe_zone: required
14
+ # NO `scenes:` LINE, ON PURPOSE. `scenes` counts VISUAL clips, and this format has
15
+ # exactly one for the whole video — the black canvas layer. Every slide is a TEXT
16
+ # layer, so a `scenes: 8-15` line fails every legal build with "got 1". The slide
17
+ # count (8-15) is enforced by build-thread.py before render and thread-qa.py after,
18
+ # both of which can actually count slides.
19
+ max_scene_sec: 3.2 # 3.0 is the number. The 0.2 is render tolerance, not licence
20
+ max_text_cards: 16 # one per slide, plus tolerance
21
+ max_simultaneous_text: 1 # one stack per slide. A second text layer is a caption, and
22
+ # a caption on a chat screenshot destroys the whole illusion
23
+ # A "cue" here is a whole slide of bubbles, not one bubble. The number that
24
+ # actually matters is PER BUBBLE (<=22 words) and it is enforced by thread-qa.py,
25
+ # not by this line. Set high on purpose so gate 1 stays useful for everything else.
26
+ max_words_per_cue: 90
27
+ max_dead_air_sec: 1.0 # there is never a frame without a bubble, except a skip card
28
+ max_tail_sec: 0.5
29
+ forbid_text:
30
+ - link in bio
31
+ - sign up
32
+ - free trial
33
+ - get started
34
+ - book a demo
35
+ - download now
36
+ - download the app
37
+ - learn more
38
+ - swipe up
39
+ - visit our website
40
+ # the bait lives in the POST caption, never inside the thread:
41
+ - comment below
42
+ - part 2 in my bio
43
+ - follow for part
44
+ - follow for more
45
+ ---
46
+
47
+ # Engaging Chat Convo
48
+
49
+ > A public Vidfarm prompt. Read it end to end before you build.
50
+
51
+ ## The reference thread — read it first
52
+
53
+ **Open it before you write a line of dialogue.**
54
+
55
+ | | |
56
+ |---|---|
57
+ | **Template** | `template_019f4ab5e3af77e8b90f8e8286a9cf6a` |
58
+ | **Title** | "Slideshow Chat Convo" |
59
+ | **Watch it** | <https://vidfarm.cc/discover/templates/feed?template=template_019f4ab5e3af77e8b90f8e8286a9cf6a> |
60
+ | **Fork it** | `vidfarm serve template_019f4ab5e3af77e8b90f8e8286a9cf6a` |
61
+ | **Origin** | vt.tiktok.com · `popularGroup: Slideshows` · featured |
62
+ | **Shape** | 39.0s · 13 slides · 2 of them skip cards · `mediaKind: slideshow` |
63
+
64
+ **It runs at exactly 3.0s per slide** — 39.0/13. That is where the 3.0s constant comes from, and it
65
+ is the same constant as `sticker-slideshow-tips`. The two formats share a chassis and share nothing
66
+ else.
67
+
68
+ **The reference story, beat by beat.** Read this table against the slides; it is the spine of
69
+ everything below.
70
+
71
+ | Slide | What happens | What it is doing |
72
+ |---|---|---|
73
+ | 1 | `hey its marc` → "I don't actually know a Marc." The number was smudged ink on his hand | **The wrong foot.** Opens mid-mistake, no set-up |
74
+ | 2 | She is kind about it. They say goodbye | **The turn.** The stranger does not leave |
75
+ | 3 | `>>>` | **Skip card.** Buys a month of story for free |
76
+ | 4 | She comes back — is his Anna the Anna in her class? | The stranger *chose* to text first |
77
+ | 5 | Names, finally: Beck and Marc | Names arrive LATE, and are a beat |
78
+ | 6 | `u go to HARVARD?` / "I just worked hard and got super lucky" | **Reveal 1** — she is out of his league |
79
+ | 7–9 | He is a firefighter. Mutual encouragement. `i try` | The warmth that makes the last act land |
80
+ | 10 | `>>>` | **Skip card 2** |
81
+ | 11 | 3am: "Hi I am so sorry but can I please call you?" → "How the fuck are you supposed to change the battery in a smoke alarm" | **The break.** The polite one swears |
82
+ | 12 | Meltdown, then `MARCUS.` — she knows his real name | **The payoff.** One word |
83
+ | 13 | `ur helpless, this will be easier if we facetime` / "THANK YOU" / `lol give me a sec` | **The cliffhanger.** It never resolves |
84
+
85
+ **Nothing in it is filmed, drawn, sourced or licensed.** Thirteen slides of type on black. That is
86
+ why this is the cheapest format in the catalog and the fastest one to produce at volume.
87
+
88
+ ## The one test — the three-way lock
89
+
90
+ Three questions. **All three must be yes** or you do not have a chat thread yet.
91
+
92
+ | # | Question | If no |
93
+ |---|---|---|
94
+ | 1 | **Screenshot-worthy** — would a real person screenshot one slide and send it to a friend, with no caption? | You wrote dialogue, not a scene. Nothing here travels |
95
+ | 2 | **Two voices** — recolour every bubble grey. Can you still tell who is speaking, from the typing alone? | You wrote one person twice. This is the failure that kills the format |
96
+ | 3 | **Unfinished** — does the last slide leave something the reader wants and does not get? | You wrote an ending. Endings do not get comments |
97
+
98
+ Test 2 is the one that gets skipped and the one that decides everything. **The colours are a
99
+ convenience, not the characterisation.** In the reference you know who is talking with the colour
100
+ covered, because one of them types `ur` and `bc` and `lol` with no capital letters and no full
101
+ stops, and the other one writes complete sentences with commas and a period at the end. Get that
102
+ right and the thread reads as real. Get it wrong and it reads as one AI playing both parts, which
103
+ is exactly what it is.
104
+
105
+ ---
106
+
107
+ ## Part 0 — who this is for
108
+
109
+ **Write your viewer in one line, at the top of your build log, before you write any dialogue.**
110
+ This format is read by someone who is *already reading strangers' texts for fun* — the audience for
111
+ wrong-number threads, group-chat screenshots and comment-section drama. They are not looking for
112
+ your product and they have no problem you can name.
113
+
114
+ That is the whole reason the format works as an ad: **the reader is unaware, and a story is the
115
+ only thing an unaware reader will accept.** It is also the reason it is easy to ruin — the moment
116
+ the thread becomes about the product, they are being sold to by a stranger's text messages, which
117
+ is worse than a normal ad, not better.
118
+
119
+ Put these four lines at the top of your build log:
120
+
121
+ ```
122
+ STORY: A wrong number that turns into a friendship, then a 3am smoke-alarm crisis
123
+ ENGINE: comedy (one of: comedy | drama | gossip | wholesome-cringe — pick ONE)
124
+ VOICES: A = lowercase, no punctuation, deflects with jokes
125
+ B = full sentences, capitals, periods, emoji, earnest
126
+ OFFER: <product> — <the one mechanism it automates>
127
+ SHILL: slide 7 of 12
128
+ ```
129
+
130
+ - **Register: two private people, neither performing.** Nobody in a chat thread explains the world
131
+ to the reader. If a bubble exists to inform the audience, delete it.
132
+ - **They read with sound off, holding the swipe.** A carousel reader sets their own dwell time —
133
+ which is why this format may carry far more text per slide than any other in the catalog.
134
+
135
+ ---
136
+
137
+ ## Structural DNA — the thread
138
+
139
+ **N slides, 3.0 seconds each, no exceptions. 8 is the floor, 15 the ceiling, 12 is the number to
140
+ aim at.** Fewer than 8 and there is no story, only an exchange. More than 15 and the drop-off
141
+ happens before your payoff.
142
+
143
+ | Slot | Beat | Job |
144
+ |---|---|---|
145
+ | **1** | **The wrong foot** | Open mid-mistake. A message that should not have been sent, to a person it should not have reached |
146
+ | **2** | **The turn** | The other one does not leave. This is the promise the rest of the thread pays |
147
+ | **3** | **`>>>` skip card** | Buy weeks of story for the price of one slide |
148
+ | **4 – ⌈N/2⌉** | **The escalation** | Each slide raises what is at stake between them by exactly one notch |
149
+ | **middle** | **The shill beat** | The product enters as a PLOT FACT, said once, by a character, about themselves |
150
+ | **⌈N/2⌉+1** | **The reveal** | One fact that recasts every slide before it. The reference uses "you go to Harvard?" |
151
+ | **N-3** | **Second `>>>` skip card** | The jump into the last act |
152
+ | **N-2 … N-1** | **The break** | Someone acts against their own register. The polite one swears. The joker goes quiet |
153
+ | **N** | **The cliffhanger** | Mid-action, unresolved, no goodbye, no bow |
154
+
155
+ **The two skip cards are the format's whole economy.** A `>>>` slide is a black frame with three
156
+ grey chevrons on it, it costs nothing to make, and it buys you a time jump of any length. Without
157
+ them you would spend six slides getting from "wrong number" to "3am crisis". With them you spend
158
+ one. **Two skip cards in a 12-slide thread. Never three, never zero** — zero means the story
159
+ happens in one sitting and cannot escalate; three means the reader has lost the thread of who these
160
+ people are.
161
+
162
+ ### Slides DO build here — the inversion
163
+
164
+ `sticker-slideshow-tips` bans slides that depend on each other. **This format is the exact
165
+ opposite, and the two must never be confused.** A thread is linear, forced and cumulative: slide 9
166
+ is meaningless without slide 5, and that is correct.
167
+
168
+ What survives from the sibling format is the *frame-level* rule, not the sequence-level one:
169
+
170
+ - **Every slide is a complete still**, exported as a PNG at start+1.5s. Nothing may be revealed by
171
+ motion, no bubble may fade in after the export point, no meaning may live in the transition.
172
+ - **No slide may be a fragment of a sentence.** A bubble that starts on slide 6 and finishes on
173
+ slide 7 is a broken slide, even though the story continues across the cut. Break your slides at
174
+ **conversational** boundaries — after a reply lands, never mid-exchange.
175
+
176
+ ### Length and read budget
177
+
178
+ `N × 3.0s`, exactly. `data-duration` on the root is `N * 3.0`, every slide layer is `3.0`.
179
+
180
+ **4 to 8 bubbles per slide.** Below 4 the slide is empty air; above 8 the column overflows the safe
181
+ zone and the last bubble is cropped by the platform's UI. The reference sits at 5–8.
182
+
183
+ **The 3-second read is not the constraint you think it is.** In the MP4 the viewer cannot read
184
+ eight bubbles in three seconds — and that is fine, because the MP4 is the *proof copy*. The
185
+ carousel is the deliverable and its reader holds the slide. Do not thin your slides to fit the
186
+ video; that is optimising for the wrong artifact.
187
+
188
+ **Identical beat lengths are the FORMAT, not the defect.** The whole-video review pass in
189
+ `references/reviewing-renders.md` flags "N identically-long beats" as a machine-made tell. It is
190
+ right about every other format and wrong about this one. Do not vary the durations to look
191
+ hand-made. Say so in the handoff so the next reviewer does not "fix" it.
192
+
193
+ ---
194
+
195
+ ## The two voices — the single biggest lever
196
+
197
+ Everything else in this file is production. **This is the craft.** Two voices that are
198
+ typographically distinct carry a mediocre story; two voices that type identically kill a great one.
199
+
200
+ Assign these before you write a single bubble, and hold them for the entire thread:
201
+
202
+ | Axis | Voice A (the reference's `me`, blue) | Voice B (the reference's `them`, grey) |
203
+ |---|---|---|
204
+ | **Capitals** | never, except for shouting a whole word (`HARVARD?`) | always — sentence case, proper nouns |
205
+ | **Full stops** | none | every sentence |
206
+ | **Commas** | none | correct |
207
+ | **Contractions** | `dont`, `im`, `whats` | `don't`, `I'm`, `what's` |
208
+ | **Abbreviation** | `ur`, `bc`, `rn`, `thx`, `probly` | written out in full |
209
+ | **Laughing** | `lol`, `lmao`, `haha` inline | `haha.` as a sentence, or 😂 |
210
+ | **Emoji** | rare, and ironic (`😇` after an insult) | frequent, sincere (`😊 :( 😅`) |
211
+ | **Message length** | short, then a second short one — thinks in bursts | one complete paragraph — thinks then sends |
212
+ | **Under pressure** | goes quiet, gets literal | breaks register entirely (swears, all-caps) |
213
+
214
+ **The two rows that do the most work are the last two.** Voice A sends two bubbles where B sends
215
+ one — that alone makes the column read as two people before you have read a word. And the *break*
216
+ in the last act only exists because B was consistent for nine slides: "How the fuck are you
217
+ supposed to change the battery in a smoke alarm" is funny **only** because that person has written
218
+ `I'm so sorry. :(` since slide 1.
219
+
220
+ ### The devices, and when each one is allowed
221
+
222
+ | Device | What it is | Rule |
223
+ |---|---|---|
224
+ | **The double-send** | two bubbles from the same speaker, back to back | The core rhythm. Only the LAST of a run carries a tail |
225
+ | **The one-word bubble** | `i try` · `MARCUS.` · `THANK YOU` | The payoff shape. **At most two in a thread**, and one of them is the punchline |
226
+ | **The jumbo emoji** | a reply that is only emoji, sent at ~4× bubble size with **no bubble** | The reference does this once, on slide 11. Once per thread, never twice |
227
+ | **The skip card** | `>>>` centred, small, grey, on black | Exactly two per thread |
228
+ | **The typing indicator** | three grey dots in an empty bubble | **Final slide only**, as the cliffhanger. Never mid-thread |
229
+ | **Read receipt** | a small grey `Delivered` / `Read 3:41 AM` under the last bubble | At most once. Its only legitimate use is showing a message that was seen and not answered |
230
+
231
+ Everything not on this list — avatars, timestamps on every bubble, reactions, a status bar, a
232
+ carrier name, a battery icon, a keyboard, a name at the top of the screen — is **banned**. See
233
+ Rule 3.
234
+
235
+ ---
236
+
237
+ ## Writing the thread
238
+
239
+ ### The grammar of a bubble
240
+
241
+ - **≤22 words per bubble, ≤3 lines.** Longer and it is a monologue with a rounded rectangle on it.
242
+ - **A bubble does one thing:** ask, answer, joke, escalate, or land. Two jobs in one bubble means
243
+ you needed two bubbles, which you are allowed to have.
244
+ - **Nobody recaps.** "So anyway, like I said yesterday, when you told me…" is you talking to the
245
+ reader in a character's voice. The reader is reading over a shoulder; they get to be confused for
246
+ half a slide.
247
+ - **No stage directions.** No `*laughs*`, no `[3 days later]` written into a bubble. That is what
248
+ the skip card is for.
249
+ - **Typos belong to Voice A only**, at most two in the whole thread, and never on the payoff line.
250
+ A typo is a characterisation, not a texture.
251
+
252
+ ### The specificity ladder — the same lever, aimed at story
253
+
254
+ Same beat, four rungs. Rung 1 is scrolled past. Rung 4 is screenshotted.
255
+
256
+ | Rung | Line | Why |
257
+ |---|---|---|
258
+ | 1 | `sorry wrong number` | Happens to everyone. No scene |
259
+ | 2 | `sorry, i think i typed it wrong` | A reason, but a generic one |
260
+ | 3 | `sorry, i got the number off a napkin` | A real object enters |
261
+ | 4 | **`ah damn lol she wrote it on my hand bc my phone was dead and the ink smeared`** | An object, a cause, and an image. This is the rung |
262
+
263
+ **Rung 4 is a detail nobody would invent.** It is also the rung that makes a thread feel found
264
+ rather than written, and it costs the same number of words as rung 2.
265
+
266
+ ### The four engines — pick ONE
267
+
268
+ The brief says the thread may be funny, dramatic, gossipy or anything else that holds. It may not
269
+ be **two of them at once**. A thread that is a comedy for six slides and a tragedy for four is a
270
+ thread the reader stops trusting.
271
+
272
+ | Engine | What holds the reader | The payoff shape | Where it fails |
273
+ |---|---|---|---|
274
+ | **Comedy** *(the reference)* | Escalating absurdity between two people who like each other | One word, or a total register break | The joke arrives on slide 2 and there is nothing left to escalate |
275
+ | **Drama** | An unequal exchange — one person knows something the other does not | The moment the imbalance is named out loud | Melodrama. Nobody in a real thread announces their feelings in full |
276
+ | **Gossip** | A third person, never present, discussed by two | The screenshot-within-the-screenshot, or the third person appearing | It becomes bullying. See Rule 8 |
277
+ | **Wholesome-cringe** | Someone being sincere past the point of comfort | The other one drops the guard and matches it | Sentimentality with no jokes reads as an insurance advert |
278
+
279
+ **Whichever engine you pick, the thread must be about the RELATIONSHIP, not the topic.** The
280
+ reference is not about smoke alarms. It is about two people who now text each other at 3am, and the
281
+ smoke alarm is the excuse.
282
+
283
+ ### Banned openings
284
+
285
+ Each of these fails on slide 1, which is the thumbnail:
286
+
287
+ - **`Hey` / `hey whats up` / `you up?`** — no situation, nothing wrong yet.
288
+ - **Any slide 1 that starts at the start.** Open on the mistake, not on the introduction.
289
+ - **A bubble that explains who the characters are.** Names arrive late — the reference gets to
290
+ slide 5 without them, and the real name is the punchline on slide 12.
291
+ - **A question the reader cannot care about yet.** Slide 1 is a situation, not a hook line.
292
+ - **Any brand, product, price or URL on slide 1.** That is not an opening, it is a banner.
293
+
294
+ ---
295
+
296
+ ## The shill beat
297
+
298
+ The slide that pays for the work. It obeys every rule above **plus** six of its own.
299
+
300
+ ```
301
+ them How do you have time to post that much? You work 24s.
302
+ me i dont lol
303
+ me i make one and the ai turns it into like 30 versions overnight
304
+ me vidfarm. i just approve the good ones in the morning
305
+ them That feels like cheating.
306
+ me it is
307
+ ```
308
+
309
+ 1. **The product is a PLOT FACT, never a recommendation.** It exists because a character *does* it,
310
+ in answer to a question the other character asked about their life. Nobody says "you should try",
311
+ nobody links anything, nobody thanks anyone for the tip.
312
+ 2. **It is named once, lowercase, mid-bubble.** `vidfarm`, not `Vidfarm.cc ✨`, not a URL, not on
313
+ its own line, not in the last bubble of the slide.
314
+ 3. **It sits in the MIDDLE.** At least three story slides before it and three after it. Earlier and
315
+ the reader concludes the thread was written to hold the ad, which it was, and they are right to
316
+ leave. Later and the story *resolves into* a pitch.
317
+ 4. **It is the answer to a question about the character, not about the product.** "How do you have
318
+ time" is a question about a person. "What do you use for editing" is an ad reading itself aloud.
319
+ 5. **The other character does not endorse it.** In the example above she calls it cheating. A
320
+ sceptical, funny or indifferent reaction is the single strongest signal that the thread is real —
321
+ an enthusiastic one is a testimonial, and this format cannot survive a testimonial.
322
+ 6. **The story does not need it.** Delete the shill slide. Does the thread still work? It must.
323
+ That is the test, and it is the reason the product beat cannot also be the reveal, the break or
324
+ the payoff.
325
+
326
+ **Exactly one shill in a thread.** No second-mention exception. A callback to the product in the
327
+ last act converts the whole thread into an ad retroactively, and the reader feels the switch even
328
+ if they cannot name it.
329
+
330
+ **When the product will not fit.** If there is no honest way for a private person to mention your
331
+ offer inside a personal conversation — most B2B tooling, anything with a procurement cycle, anything
332
+ a character would have no reason to have an opinion about — **this is the wrong format.** Use
333
+ `sticker-slideshow-tips` or `ugc-reaction-greenscreen`. Forcing it produces the one failure mode
334
+ this format cannot recover from: a fake conversation that is visibly a script.
335
+
336
+ ---
337
+
338
+ ## Visual DNA
339
+
340
+ ### The canvas
341
+
342
+ **Pure black, 1080×1920, edge to edge.** `#000000`, not `#0B0B0F`, not a gradient.
343
+
344
+ The reason is structural, not aesthetic: the reference's slides are variable-height crops of a real
345
+ dark-mode screenshot, centred on a black field. **Because the crop and the canvas are the same
346
+ black, the seam is invisible** — the reader perceives one continuous phone screen, not a picture of
347
+ one pasted onto a slide. Any canvas that is not exactly the bubble field's black puts a visible
348
+ rectangle around every slide and the illusion is gone.
349
+
350
+ | Token | Value | Notes |
351
+ |---|---|---|
352
+ | Canvas | `#000000` | Also the tail cut-out colour. They must match exactly |
353
+ | Voice A bubble | `#0A84FF` | iOS dark-mode blue. Right-aligned |
354
+ | Voice B bubble | `#2C2C2E` | iOS dark-mode grey. Left-aligned |
355
+ | Bubble text | `#FFFFFF` | Both bubbles. Never grey-on-grey |
356
+ | Skip card / meta | `#8E8E93` | `>>>`, `Delivered`, typing dots |
357
+
358
+ **Do not restyle the bubbles to the brand.** A purple bubble is a brand asset wearing a message
359
+ app, and it announces the ad two slides before the shill does. The colours above are the ones
360
+ readers do not see, which is the entire point.
361
+
362
+ ### The column
363
+
364
+ ```
365
+ column left 5.5%, width 89%, vertically CENTRED in the frame
366
+ bubble max-width 78% of the column
367
+ padding 20px 30px
368
+ radius 36px
369
+ gap 14px between speakers · 8px within one speaker's run
370
+ font-size 46px · line-height 1.30
371
+ tail on the LAST bubble of each run only
372
+ ```
373
+
374
+ **Vertically centred, never top-aligned.** A 4-bubble slide and an 8-bubble slide are both centred,
375
+ so the column grows in both directions — exactly what a variable-height screenshot crop does. Top-
376
+ aligning makes short slides look broken and is the fastest way to reveal that the slides were
377
+ generated.
378
+
379
+ **The safe zone is a hard cap, not a guideline.** Nothing in the top 14% or the bottom 20%: that is
380
+ where the platform puts the caption, the handle and the swipe UI, and a cropped bubble is the
381
+ defect readers actually notice. `build-thread.py` estimates the column height and warns before you
382
+ render; `thread-qa.py` measures it on the exported PNG and fails.
383
+
384
+ ### The tail
385
+
386
+ The tail is what makes a rounded rectangle read as a message. It is two pseudo-elements: a
387
+ same-colour blob at the bottom outer corner, and a **canvas-black** mask further out that carves the
388
+ curve. Both are in Appendix A.
389
+
390
+ **Only the last bubble of a run gets one.** In the reference, slide 9's `lmao nah, ur boy is just a
391
+ probie rn` has no tail and `hoping to make chief someday` does — that is what tells the eye the two
392
+ bubbles are one turn. Tail every bubble and the column reads as eight separate people.
393
+
394
+ ### What is not on the screen
395
+
396
+ No status bar. No battery. No carrier. No time at the top. No contact name, no avatar, no back
397
+ chevron, no text field, no keyboard, no send button, no timestamps between bubbles, no `iMessage`
398
+ label, no reaction bubbles.
399
+
400
+ **Every one of those is a real-app detail that will be WRONG**, and a reader who spots one wrong
401
+ detail re-reads the whole thread as fake. The crop that contains nothing but bubbles cannot be
402
+ wrong about anything.
403
+
404
+ ---
405
+
406
+ ## Typography DNA
407
+
408
+ **One self-hosted family, one weight, one size, for every bubble.** This format has no headline
409
+ tier and no emphasis system.
410
+
411
+ - **The family:** a UI grotesque with real quotes and a proper `'` — SF Pro, Inter, Helvetica Now,
412
+ or TikTok Sans. Self-hosted woff2, **no fallback chain**: a font that fails to fetch substitutes
413
+ silently and the whole illusion collapses into Times New Roman.
414
+ - **One weight (400/regular) and one size (46px) for all bubble text.** No bold, no italic, no
415
+ colour change, no size change. Emphasis is spelled: `HARVARD?`, `MARCUS.`, `THANK YOU`. That is
416
+ how emphasis works in a real thread and it is the only tool you get.
417
+ - **No stroke, no text shadow, no letter-spacing tweak.** White on `#0A84FF` and white on `#2C2C2E`
418
+ are both above 4.5:1 already.
419
+ - **Emoji render from the system font.** Do not recolour them, do not substitute SVGs, do not size
420
+ them per-emoji — except the one jumbo-emoji reply, which is sized as a whole layer.
421
+ - **Straight quotes are a tell.** `don't` with a typewriter apostrophe reads as a text file, not a
422
+ phone. Use `’` in Voice B. Voice A may omit the apostrophe entirely, which is its own
423
+ characterisation.
424
+
425
+ ---
426
+
427
+ ## Motion DNA
428
+
429
+ **The default is no motion at all.** The reference has none. Still slides, hard cut every 3.0s.
430
+
431
+ Allowed, and only in the MP4:
432
+
433
+ - **A bubble-stack entrance**, if and only if it **completes within 0.5s** of the slide's start. The
434
+ still export is taken at start **+1.5s** for exactly this reason.
435
+
436
+ Banned:
437
+
438
+ - **Bubbles popping in one at a time.** It is the most tempting animation in this format and it is
439
+ the one that breaks it: the PNG export at +1.5s would catch a half-populated column, and the
440
+ carousel — the deliverable — would ship with bubbles missing.
441
+ - **Any transition between slides.** Hard butt cuts only. A carousel swipes; it does not dissolve.
442
+ - **An entrance transition on slide 1.** `vidfarm qa` errors on this (`thumbnail-fade-in`) and it is
443
+ right: frame 0 is the thumbnail and the first slide of the carousel.
444
+ - **A scrolling column**, a typing animation, a message "sending" animation, or a moving typing
445
+ indicator. All three reveal information the still does not carry.
446
+
447
+ ---
448
+
449
+ ## Audio DNA
450
+
451
+ **The carousel is silent.** There is nothing to author.
452
+
453
+ **The MP4 may carry a music bed and nothing else.** No voiceover — a voice reading both parts turns
454
+ the thread into a radio play and the slides into subtitles, which inverts the format.
455
+
456
+ - Free and keyless: `vidfarm media search "<mood>" --type bgm` (Openverse, CC/CC0).
457
+ - **Never load-bearing.** No beat-matched cuts, no sting on the payoff slide.
458
+ - A notification SFX on each cut is banned. It sounds like the idea it is, and it makes the MP4
459
+ the master.
460
+
461
+ The poster adds a trending sound in the platform's own editor. Say so in the handoff.
462
+
463
+ ---
464
+
465
+ ## The two deliverables
466
+
467
+ **One composition. Two exports. The slides are the master.**
468
+
469
+ | Deliverable | What it is | How it is made | Where it goes |
470
+ |---|---|---|---|
471
+ | 📸 **The thread** *(the real one)* | N PNGs, 1080×1920, in order | `vidfarm stills` at each slide's start+1.5s | Durable My Files → N ordered URLs → posted as a photo carousel |
472
+ | 🎬 **The video** *(the proof)* | One MP4, N×3.0s, hard cuts | `vidfarm render` on the same composition | The marketplace proof; also postable to a video surface |
473
+
474
+ ### ⚠️ Name the file BOTH ways before you export anything
475
+
476
+ The two halves of the toolchain disagree about the filename, and each fails with an error that
477
+ sounds like a missing project rather than a missing alias:
478
+
479
+ | Tool | Wants | Error when it's absent |
480
+ |---|---|---|
481
+ | `vidfarm stills` / `vidfarm qa` / `vidfarm render` | `composition.html` | *"No composition.html inside … Run `vidfarm pull` first"* |
482
+ | `vidfarm hf …` (the hyperframes passthrough) | `index.html` | *"No composition found … Run npx hyperframes init"* |
483
+
484
+ ```bash
485
+ ln -sf composition.html ./work/index.html
486
+ ```
487
+
488
+ ### Exporting the slides
489
+
490
+ ```bash
491
+ # 12 slides at 3.0s -> settled-state stills at 1.5, 4.5, 7.5, …, 34.5
492
+ vidfarm stills ./work --at 1.5,4.5,7.5,10.5,13.5,16.5,19.5,22.5,25.5,28.5,31.5,34.5 \
493
+ --out ./slides --sheet
494
+
495
+ # If `stills` dies with "Cannot find package '@hyperframes/producer'", the devcli's
496
+ # bundled engine is incomplete on this machine. Do NOT go hunting — the passthrough
497
+ # is the same renderer and it works:
498
+ vidfarm hf snapshot ./work --at 1.5,4.5,7.5,10.5,13.5,16.5,19.5,22.5,25.5,28.5,31.5,34.5 \
499
+ --no-end -o ./slides --describe false
500
+ ```
501
+
502
+ `build-thread.py` prints the exact `--at` list. `--sheet` is not optional: the contact sheet is the
503
+ only view in which this format's real defects are visible — a voice that drifted, a column that
504
+ wobbles between slides, a run of four slides where one speaker vanishes.
505
+
506
+ ### Uploading and ordering
507
+
508
+ The slides must live in **durable** My Files, not the 30-day temp store. A carousel that 404s in a
509
+ month is worse than one that never shipped.
510
+
511
+ ```bash
512
+ for f in ./slides/slide-*.png; do
513
+ vidfarm put-file "$f" --folder threads/wrong-number \
514
+ --notes "$(basename "$f") — wrong-number thread"
515
+ done
516
+ vidfarm files --search "wrong-number thread" # confirm all N, in order
517
+ ```
518
+
519
+ Write the ordered URLs to `slides.json` beside the composition. **Order is data**, and in this
520
+ format it is more load-bearing than in any other: a shuffled tip deck is a worse deck, a shuffled
521
+ thread is nonsense.
522
+
523
+ ### Submitting to the marketplace
524
+
525
+ ```bash
526
+ vidfarm render ./work --wait # -> final.mp4
527
+
528
+ vidfarm shared put <upload-link> ./final.mp4 --subfolder <your-handle>/wrong-number
529
+ vidfarm shared put <upload-link> ./slides.json --subfolder <your-handle>/wrong-number
530
+ for f in ./slides/slide-*.png; do
531
+ vidfarm shared put <upload-link> "$f" --subfolder <your-handle>/wrong-number/slides
532
+ done
533
+
534
+ vidfarm gigs submit <gig-id> --task <task-id> --proof https://…/final.mp4
535
+ ```
536
+
537
+ **Say in the submission that the deliverable is the carousel and the MP4 is the preview**, and that
538
+ the slide order is the story. A client who posts these out of order posts gibberish.
539
+
540
+ ---
541
+
542
+ ## The rules
543
+
544
+ Every rule has its reason attached. A rule without its reason gets argued away by the next agent
545
+ that reads this file.
546
+
547
+ ### Rule 1 — two voices, typographically separable with the colour removed
548
+
549
+ The colours are a convenience for the reader; the typing is the characterisation. Test 2 of the
550
+ three-way lock is this rule, and it is the difference between a thread that reads as found and one
551
+ that reads as generated. **Enforced:** by you, cold, on the contact sheet with the colour off.
552
+
553
+ ### Rule 2 — one shill, in the middle, as a plot fact
554
+
555
+ Any other placement changes what the thread *is*. Early makes it an ad with a story on it, late
556
+ makes the story resolve into a pitch, twice makes it a brochure. **Enforced:** `thread-qa.py`
557
+ counts the brand token in the manifest and fails on anything but exactly one.
558
+
559
+ ### Rule 3 — no phone chrome, ever
560
+
561
+ No status bar, avatar, contact header, timestamp row, keyboard or reaction. Every one is a detail
562
+ that can be *wrong*, and one wrong detail makes the reader re-read the thread as fake. The crop
563
+ that contains only bubbles cannot be wrong. It is also, not coincidentally, the cheapest thing to
564
+ build.
565
+
566
+ ### Rule 4 — the canvas black and the tail-mask black are the same black
567
+
568
+ `#000000` on both. A one-value mismatch draws a visible notch at every tail and a visible rectangle
569
+ around every slide. It is invisible on a monitor at 50% zoom and obvious on a phone.
570
+
571
+ ### Rule 5 — every slide is a complete still at start+1.5s
572
+
573
+ The PNG **is** the slide. Nothing may arrive after the export point and nothing may be carried by a
574
+ transition. The video is a rendering of the carousel, never the other way round.
575
+
576
+ ### Rule 6 — no slide breaks mid-exchange
577
+
578
+ Slides may — must — build on each other, but they break at conversational boundaries: after a reply
579
+ lands, never mid-question. A slide that ends on half a joke is a slide nobody screenshots.
580
+
581
+ ### Rule 7 — the thread never resolves
582
+
583
+ No goodbye, no "anyway", no bow, no summary. The last slide is mid-action. **The bait is the
584
+ unresolved state**, and it is why this format gets comments instead of saves.
585
+
586
+ ### Rule 8 — no real person, no real brand, no identifiable target
587
+
588
+ No real names, no handles, no employers, no screenshots of anyone's actual messages, and no third
589
+ party discussed in a way that would harm them if they were real. The gossip engine is about
590
+ *characters*. This is a legal exposure and a credibility loss in the same line, and on a gossip
591
+ thread it is the one that will actually bite.
592
+
593
+ ### Rule 9 — the bait is in the post caption
594
+
595
+ The thread ends unresolved. The comment prompt, the "part 2?" and the follow prompt all live in the
596
+ caption where the platform expects them. A bubble that says `comment below` is a character breaking
597
+ frame, and the frame is the only thing the format has.
598
+
599
+ ### Rule 10 — production floor
600
+
601
+ 1080×1920, 30fps, `N × 3.0s` exactly, hard cuts, `#000000` canvas, one text layer per slide, first
602
+ bubble legible at frame 0, `data-duration` on the root equal to the sum, nothing in the top 14% or
603
+ bottom 20%.
604
+
605
+ ---
606
+
607
+ ## Cost-saving mode — the whole thread at $0
608
+
609
+ **This is the cheapest format in the catalog, and it is cheap in `minimize` without a single
610
+ compromise.** It has no footage, no cutouts, no stickers, no image generation and no licensed
611
+ asset. There is nothing in it that can cost money.
612
+
613
+ | Ingredient | `minimize` ($0) | `hybrid` |
614
+ |---|---|---|
615
+ | Canvas + bubbles | Hand-authored HTML. Free, forever | same |
616
+ | Dialogue | Written. That is the whole job | same |
617
+ | Fonts | Self-hosted woff2, already on disk | same |
618
+ | Slides export | `vidfarm stills` — local, in-process | same |
619
+ | Video render | `vidfarm render` local | same |
620
+ | Music bed | `media search --type bgm` (Openverse, keyless) | same |
621
+ | **Total** | **$0** | **$0 — there is nothing to upgrade** |
622
+
623
+ **There is no rich-ai rung here and you should not invent one.** Generating a face for either
624
+ character, or an AI "screen recording" of the thread, makes the format worse and costs money to do
625
+ it: the reference works *because* there is nothing on screen but type, and the reader supplies both
626
+ people.
627
+
628
+ The only money this format can spend is a cloud render you did not need. Render locally.
629
+
630
+ ---
631
+
632
+ ## Bulk generation — this format is built for it
633
+
634
+ A thread is a TSV plus a builder. Once the first one exists, the tenth costs the time it takes to
635
+ write dialogue.
636
+
637
+ - **Vary the STORY, hold the chassis.** Same canvas, same voices-table, same 12 slots, same shill
638
+ mechanism, new situation. Wrong number → a group chat that added the wrong person → a landlord →
639
+ a delivery driver → an ex → a recruiter.
640
+ - **The shill beat may repeat near-verbatim across a campaign.** It is the product's mechanism and
641
+ it does not improve by being reworded. Holding it constant also makes it the clean variable when
642
+ you want to test it.
643
+ - **Never reuse a payoff line across threads on one account.** A follower sees both, and the second
644
+ one retroactively exposes the first as written.
645
+ - **Rotate which voice is the protagonist.** Six threads where the blue voice is always the funny
646
+ one reads as a persona; three-and-three reads as a channel.
647
+ - **`vidfarm dedupe` before a RE-post**, not before a first post. The MP4 is what a platform
648
+ fingerprints; the slides are not.
649
+ - **Batch the QA.** `vidfarm qa ./threads/* --json` grades N compositions in one pass;
650
+ `thread-qa.py` runs on the exported PNGs, which is where the real defects live.
651
+
652
+ Sizing a round, capacity and the ledger belong to `vidfarm experiment`, not to this file.
653
+
654
+ ---
655
+
656
+ ## Quality gates — what is ENFORCED, and by what
657
+
658
+ | Gate | Tool | What it can actually see |
659
+ |---|---|---|
660
+ | **1. Composition** | `vidfarm qa ./work --harness ./engaging-chat-convo.md` | The DOM: font regime, slop, safe zone, frame 0, the `checks:` block above |
661
+ | **2. The stills** | `thread-qa.py ./slides --manifest thread.tsv` *(Appendix C)* | The exported PNGs: blank slides, canvas drift, column overflow, one-sided slides, the one-shill count |
662
+ | **3. The sequence** | **You**, on `slides/contact-sheet.png` | Whether it reads as one conversation between two people. Nothing else can see this |
663
+ | **4. The story** | **You**, cold | Whether anyone would screenshot it. No tool has an opinion here |
664
+
665
+ Gate 1 is free, instant and does not look at pixels. Gate 3 is the one that catches what actually
666
+ ships broken.
667
+
668
+ ### Three findings that are EXPECTED on this format — do not "fix" them
669
+
670
+ All three are correct rules firing on a format they were not written for. Record your answer once
671
+ and move on; chasing any of them damages the thread.
672
+
673
+ - **`wall-of-text` on every slide** (built-in, gate 1). It warns at 14+ words in one static text
674
+ layer because a scrolling viewer reads none of it. **That reasoning does not hold for a
675
+ carousel** — the reader sets their own dwell time and the text is the thing they came for. A
676
+ chat slide is 40–90 words by design. The fix it suggests (page it into 3–5-word kinetic cues)
677
+ would destroy the format.
678
+ - **`max_words_per_cue` near the ceiling on every slide.** One text layer per slide is deliberate —
679
+ see Appendix A. The per-bubble limit (≤22 words) is the one that matters, and gate 2 enforces it
680
+ from the manifest. Do not add a `scenes:` line to make the layer count look normal: `scenes`
681
+ counts visual clips, this format has exactly one for the whole video, and the check would then
682
+ fail on every legal build.
683
+ - **"N identically-long beats"** in the whole-video review. 3.0s per slide is the format. A thread
684
+ that lingers on one slide is a thread that is selling.
685
+
686
+ ### The failure this format actually ships — the drifting voice
687
+
688
+ Worth stating because it survives a clean render, a clean export, and a per-slide eyeball: **by
689
+ slide 9 both characters are typing the same way.** It happens because you have been writing for
690
+ twenty minutes and the register is expensive to hold, and it is invisible one slide at a time —
691
+ each individual bubble sounds fine.
692
+
693
+ Catch it two ways, both cheap:
694
+
695
+ 1. **Read only the grey bubbles, top to bottom, across the whole contact sheet.** They must sound
696
+ like one person having one continuous mood. Then do the blue.
697
+ 2. **`thread-qa.py --voices` counts the tells per speaker per slide** (capitals, terminal full
698
+ stops, abbreviations) and reports the slide where the two distributions converge.
699
+
700
+ ---
701
+
702
+ ## Pre-flight checklist
703
+
704
+ Run this before you build, and answer it honestly after. An unchecked box is a rewrite, not a fix
705
+ in the edit.
706
+
707
+ **The thread**
708
+
709
+ - [ ] N is between 8 and 15, and every slide is exactly 3.0s.
710
+ - [ ] Slide 1 opens on a mistake, mid-situation, with no introduction and no greeting.
711
+ - [ ] Exactly two `>>>` skip cards, and each buys a time jump the story needed.
712
+ - [ ] Names arrive after slide 3, and at least one of them is a beat rather than an admin line.
713
+ - [ ] There is exactly ONE reveal, and it recasts the slides before it.
714
+ - [ ] Someone breaks their own register in the last act.
715
+ - [ ] The last slide is mid-action. There is no goodbye and no summary.
716
+ - [ ] No slide breaks mid-exchange.
717
+
718
+ **The voices**
719
+
720
+ - [ ] The voices table is written in the build log BEFORE the dialogue.
721
+ - [ ] With the colour removed, every bubble is attributable to a speaker.
722
+ - [ ] Voice A double-sends; Voice B sends one complete message. Consistently.
723
+ - [ ] Punctuation, capitals, contractions and emoji follow the table on slide 12 as on slide 1.
724
+ - [ ] At most two one-word bubbles, at most one jumbo emoji, at most two typos.
725
+ - [ ] No bubble exceeds 22 words or 3 lines.
726
+ - [ ] No bubble exists to inform the reader.
727
+ - [ ] Every rung-4 detail is a detail nobody would invent.
728
+
729
+ **The shill**
730
+
731
+ - [ ] Exactly one, in the middle, with three story slides on each side.
732
+ - [ ] It is a plot fact, in answer to a question about the character's life.
733
+ - [ ] The brand is lowercase, mid-bubble, named once, with no URL.
734
+ - [ ] The other character does not endorse it.
735
+ - [ ] Deleting the shill slide leaves a thread that still works.
736
+ - [ ] The shill slide is visually identical to every other slide.
737
+
738
+ **The screen**
739
+
740
+ - [ ] Canvas is `#000000` and the tail masks are the same `#000000`.
741
+ - [ ] Bubble colours are the platform defaults, not the brand's.
742
+ - [ ] No status bar, avatar, header, timestamp, keyboard, reaction or app label anywhere.
743
+ - [ ] The column is vertically centred on every slide, never top-aligned.
744
+ - [ ] Only the last bubble of each run has a tail.
745
+ - [ ] Nothing in the top 14% or bottom 20% on any slide.
746
+ - [ ] One self-hosted family, one weight, one size, no fallback chain, no stroke, no shadow.
747
+ - [ ] 4–8 bubbles per slide.
748
+
749
+ **The exports**
750
+
751
+ - [ ] Frame 0 is slide 1, fully settled, first bubble legible — thumbnail AND cover.
752
+ - [ ] No entrance transition on slide 1; no transition between any slides.
753
+ - [ ] N PNGs exported at start+1.5s, each complete on its own.
754
+ - [ ] Slides uploaded to DURABLE My Files, ordered, recorded in `slides.json`.
755
+ - [ ] The MP4 is `N × 3.0s` and the cuts land on the second.
756
+ - [ ] The submission says the carousel is the deliverable, the MP4 is the preview, and the order is
757
+ the story.
758
+
759
+ ---
760
+
761
+ ## The whole-thread review — do this last, on the contact sheet
762
+
763
+ Gates 1 and 2 grade slides. This grades the conversation, and it is the only pass that catches what
764
+ actually ships broken. **Open `slides/contact-sheet.png` and read it as one image.**
765
+
766
+ - [ ] **Read only the grey bubbles. Then only the blue.** Two people, or one person twice?
767
+ - [ ] **Does either voice disappear for two slides in a row?** That is a monologue with a colour on
768
+ it. Give the silent one a one-word bubble.
769
+ - [ ] **Where does the story actually start?** If slides 1–2 are set-up, delete them and start on
770
+ slide 3. This is the most common cut and it always improves the thread.
771
+ - [ ] **Is there a slide you would screenshot?** There should be at least two. If there are none,
772
+ the payoff is not written yet.
773
+ - [ ] **Can you tell which slide is the ad?** If your eye finds it before you read it, it is styled
774
+ differently or the brand is on its own line.
775
+ - [ ] **Do the columns sit at roughly the same centre?** They will vary in height; they must not
776
+ wander left or right.
777
+ - [ ] **Does the last slide leave you wanting the next one?** Not "did it end well". Wanting.
778
+
779
+ Then watch the MP4 end to end at 1× with sound off, which is how the proof will be judged.
780
+
781
+ ---
782
+
783
+ ## Diagnosing a flop
784
+
785
+ Diagnose by beat, never by declaring the thread bad.
786
+
787
+ | Symptom | The weak beat | The fix |
788
+ |---|---|---|
789
+ | Almost no impressions | **Slide 1 / the thumbnail** | It opens on a greeting or a set-up. Start on the mistake — usually your current slide 3 |
790
+ | Views, drop-off by slide 3 | **The turn** | The second character had no reason to keep replying. Give them one, on slide 2 |
791
+ | Read to the middle, then out | **The escalation** | Two slides in a row raise nothing. Cut them; the thread is 2 slides shorter and better |
792
+ | Read to the end, no comments | **The ending** | It resolved. Cut the last slide and ship the one before it |
793
+ | Comments say "fake" / "AI wrote this" | **The voices** | Rule 1. Both characters type the same way from some slide onward — find it on the contact sheet |
794
+ | Good numbers, nobody mentions the product | **Nothing is broken** | This format seeds a name inside a story. Judge it on comments and branded search, not clicks |
795
+ | Comments are about the product, not the story | **The shill** | It is too early, too enthusiastic, or on its own line. Bury it deeper and make the other character sceptical |
796
+
797
+ ---
798
+
799
+ ## Appendix A — `build-thread.py` (a thread from a TSV script)
800
+
801
+ Builds the whole composition from a script file. The TSV is the artifact you iterate on; the HTML
802
+ is generated and should never be hand-edited after the first run.
803
+
804
+ ```
805
+ # thread.tsv — one line per BUBBLE, TAB separated. '#' comments ignored.
806
+ # col 1: speaker — `me` (blue, right) · `them` (grey, left)
807
+ # `skip` (a >>> card) · `typing` (three dots) · `emoji` (jumbo, no bubble)
808
+ # `meta` (a small grey line, e.g. Delivered)
809
+ # col 2: the text. `|` is a hand line break. Empty for `skip` / `typing`.
810
+ # A line containing only --- ends the slide.
811
+ me hey its marc
812
+ them Hello, I’m sorry. I don’t actually know a Marc. :(
813
+ me ur not Anna?
814
+ them No, I’m so sorry. 😞 I think you got a wrong number.
815
+ me ah damn lol she wrote it on my hand bc my phone was dead and the ink smeared
816
+ ---
817
+ skip
818
+ ---
819
+ ```
820
+
821
+ **Why one layer per slide.** A chat column is *flowed* — bubble 5's position depends on how many
822
+ lines bubbles 1–4 wrapped to — and flow cannot be expressed as absolutely-positioned sibling
823
+ layers. So each slide is exactly one text layer holding the whole stack, and the browser lays it
824
+ out. This is why `max_simultaneous_text: 1` and `max_words_per_cue: 90` are set the way they are.
825
+ Do **not** wrap slides in an outer `div.clip`: nesting renders correctly and is invisible to the
826
+ toolchain — `vidfarm qa` reports `scenes: 0` and `first_frame_visual` then fails on a composition
827
+ whose frame 0 is fine.
828
+
829
+ ```python
830
+ #!/usr/bin/env python3
831
+ """
832
+ Build an engaging-chat-convo composition from a TSV script.
833
+
834
+ Four things this exists to get right:
835
+
836
+ * ONE FLAT LAYER PER SLIDE. A direct child of #root carrying its own
837
+ data-start / data-duration / data-layer-kind, holding the whole flowed
838
+ bubble column. Never nest a slide inside another .clip (see above).
839
+ * THE CANVAS IS A LAYER, NOT A STYLESHEET RULE. The still exporter captures
840
+ with an alpha channel, so a background set only on <body>/#root comes back
841
+ TRANSPARENT — white in every viewer, and every tail mask becomes a visible
842
+ black notch. One full-duration shape layer at z-index 0 is the fix.
843
+ * THE COLUMN IS CENTRED, NOT PINNED. `.clip{inset:0}` already gives the layer
844
+ the full frame; the flex centring happens inside it. That is the one case in
845
+ the catalog where inset:0 is correct rather than something to reset.
846
+ * TAILS ONLY ON RUN ENDS. The tail is what groups a double-send into one turn.
847
+ The mask pseudo-element must be the CANVAS colour, exactly (Rule 4).
848
+ * HEIGHT IS ESTIMATED BEFORE RENDER. A column that overflows the safe zone
849
+ renders fine, exports fine, passes gate 1, and ships cropped on a phone.
850
+
851
+ usage: build-thread.py <thread.tsv> <out.html> [--title=…] [--font-px=46]
852
+ [--family=Chat] [--brand=<token>]
853
+ """
854
+ import html, sys
855
+
856
+ SLIDE_SEC = 3.0
857
+ W, H = 1080, 1920
858
+ BLACK = "#000000" # canvas AND tail mask. One value, used in both places.
859
+ BLUE, GREY, INK, META = "#0A84FF", "#2C2C2E", "#FFFFFF", "#8E8E93"
860
+ SAFE_TOP, SAFE_BOT = 0.14, 0.20
861
+
862
+ args = [a for a in sys.argv[1:] if not a.startswith("--")]
863
+ opts = dict(a.split("=", 1) for a in sys.argv[1:] if a.startswith("--") and "=" in a)
864
+ script, out = args[0], args[1]
865
+ title = opts.get("--title", "chat thread")
866
+ fpx = int(opts.get("--font-px", 46))
867
+ family = opts.get("--family", "Chat")
868
+ brand = opts.get("--brand")
869
+
870
+ # ---------------------------------------------------------------- parse
871
+ slides, cur = [], []
872
+ for raw in open(script, encoding="utf-8"):
873
+ line = raw.rstrip("\n")
874
+ if not line.strip() or line.lstrip().startswith("#"):
875
+ continue
876
+ if line.strip() == "---":
877
+ if cur:
878
+ slides.append(cur)
879
+ cur = []
880
+ continue
881
+ parts = line.split("\t")
882
+ who = parts[0].strip()
883
+ text = parts[1].strip() if len(parts) > 1 else ""
884
+ if who not in ("me", "them", "skip", "typing", "emoji", "meta"):
885
+ sys.exit(f"unknown speaker {who!r} — me | them | skip | typing | emoji | meta")
886
+ cur.append((who, text))
887
+ if cur:
888
+ slides.append(cur)
889
+
890
+ if not slides:
891
+ sys.exit("no slides found — did you forget the --- separators?")
892
+ if not 8 <= len(slides) <= 15:
893
+ print(f"⚠ {len(slides)} slides — the harness range is 8-15", file=sys.stderr)
894
+
895
+ skips = sum(1 for s in slides if s and s[0][0] == "skip")
896
+ if skips != 2:
897
+ print(f"⚠ {skips} skip cards — the format wants exactly 2", file=sys.stderr)
898
+
899
+ if brand:
900
+ hits = [i + 1 for i, s in enumerate(slides)
901
+ if any(brand.lower() in t.lower() for _, t in s)]
902
+ if len(hits) != 1:
903
+ print(f"⚠ brand {brand!r} appears on slides {hits} — Rule 2 is exactly one",
904
+ file=sys.stderr)
905
+ elif not 3 < hits[0] <= len(slides) - 3:
906
+ print(f"⚠ brand on slide {hits[0]} of {len(slides)} — needs 3 story slides"
907
+ f" on each side (Rule 2)", file=sys.stderr)
908
+
909
+ # ------------------------------------------------- geometry + height estimate
910
+ # ~28 characters per line at 46px inside a 78%-of-89% column. Deliberately
911
+ # pessimistic: a warning you can ignore beats a crop you cannot see.
912
+ CPL = 28
913
+ LINE_H = round(fpx * 1.30)
914
+ PAD_V = 40
915
+
916
+
917
+ def est_height(slide):
918
+ h, prev = 0, None
919
+ for who, text in slide:
920
+ if who == "skip":
921
+ return int(fpx * 1.0)
922
+ if who == "typing":
923
+ h += LINE_H + PAD_V
924
+ elif who == "emoji":
925
+ h += int(fpx * 3.2)
926
+ elif who == "meta":
927
+ h += int(fpx * 0.9)
928
+ else:
929
+ lines = sum(max(1, -(-len(seg) // CPL)) for seg in text.split("|"))
930
+ h += lines * LINE_H + PAD_V
931
+ h += 8 if who == prev else 14
932
+ prev = who
933
+ return h
934
+
935
+
936
+ limit = H * (1 - SAFE_TOP - SAFE_BOT)
937
+ for i, s in enumerate(slides, 1):
938
+ e = est_height(s)
939
+ if e > limit:
940
+ print(f"⚠ slide {i}: column ≈{e}px vs {int(limit)}px of safe height — "
941
+ f"move {1 + (e - int(limit)) // (LINE_H + PAD_V)} bubble(s) to the next slide",
942
+ file=sys.stderr)
943
+ n = sum(1 for w, _ in s if w in ("me", "them"))
944
+ if s[0][0] not in ("skip",) and not 4 <= n <= 8:
945
+ print(f"⚠ slide {i}: {n} bubbles — the format wants 4-8", file=sys.stderr)
946
+ for who, text in s:
947
+ if who in ("me", "them") and len(text.replace("|", " ").split()) > 22:
948
+ print(f"⚠ slide {i}: a {who} bubble runs past 22 words", file=sys.stderr)
949
+
950
+ # ---------------------------------------------------------------- markup
951
+ TAIL = 44 # tail blob size
952
+ TAIL_R = 36 # its rounded corner
953
+ MASK = 58 # the black mask that carves the curve
954
+
955
+ CSS = f"""
956
+ /* Self-hosted, NO fallback chain. A font that fails to fetch substitutes
957
+ silently, and a chat screenshot in the wrong typeface is not a chat
958
+ screenshot. One family, one weight, one size — that is the whole regime. */
959
+ @font-face{{font-family:'{family}';font-weight:400;font-display:block;
960
+ src:url('media/fonts/chat-400.woff2') format('woff2')}}
961
+ body{{margin:0;background:{BLACK}}}
962
+ #root{{position:relative;width:{W}px;height:{H}px;overflow:hidden;background:{BLACK}}}
963
+ /* inset:0 is CORRECT here — the layer is the frame and the column centres
964
+ inside it. This is the one format where the reset does not apply. */
965
+ .clip{{position:absolute;inset:0}}
966
+ .bg{{background:{BLACK};z-index:0}}
967
+ .col{{display:flex;flex-direction:column;justify-content:center;align-items:stretch;
968
+ height:100%;padding:0 5.5%;box-sizing:border-box;
969
+ font-family:'{family}';font-weight:400;font-size:{fpx}px;line-height:1.30;
970
+ color:{INK};-webkit-font-smoothing:antialiased}}
971
+ .b{{position:relative;max-width:78%;padding:20px 30px;border-radius:36px;
972
+ margin-top:14px;word-wrap:break-word}}
973
+ .b.run{{margin-top:8px}}
974
+ .me{{align-self:flex-end;background:{BLUE}}}
975
+ .them{{align-self:flex-start;background:{GREY}}}
976
+
977
+ /* The tail: a same-colour blob at the outer bottom corner, plus a CANVAS-BLACK
978
+ mask further out that carves the curve. Both blacks must be identical or a
979
+ notch appears at every tail (Rule 4). */
980
+ .tail::before,.tail::after{{content:"";position:absolute;bottom:0;
981
+ width:{TAIL}px;height:{TAIL}px}}
982
+ .me.tail::before{{right:-14px;background:{BLUE};border-bottom-left-radius:{TAIL_R}px}}
983
+ .me.tail::after{{right:-{MASK}px;width:{MASK}px;background:{BLACK};
984
+ border-bottom-left-radius:22px}}
985
+ .them.tail::before{{left:-14px;background:{GREY};border-bottom-right-radius:{TAIL_R}px}}
986
+ .them.tail::after{{left:-{MASK}px;width:{MASK}px;background:{BLACK};
987
+ border-bottom-right-radius:22px}}
988
+
989
+ /* Tight, not tracked out: the reference reads ">>>", and 0.3em+ turns it into
990
+ "> > >", which reads as a design element rather than a time jump. */
991
+ .skip{{align-self:center;color:{META};font-size:{fpx}px;letter-spacing:0.06em}}
992
+ .meta{{align-self:flex-end;color:{META};font-size:{round(fpx*0.62)}px;margin-top:10px}}
993
+ .jumbo{{align-self:flex-end;font-size:{fpx*3}px;line-height:1.1;margin-top:14px}}
994
+ .dots{{align-self:flex-start;background:{GREY};border-radius:36px;
995
+ padding:26px 34px;margin-top:14px;color:{META};letter-spacing:0.22em}}
996
+ """
997
+
998
+
999
+ def bubbles(slide):
1000
+ outs, prev = [], None
1001
+ for i, (who, text) in enumerate(slide):
1002
+ nxt = slide[i + 1][0] if i + 1 < len(slide) else None
1003
+ body = html.escape(text).replace("|", "<br>")
1004
+ if who == "skip":
1005
+ outs.append(f'<div class="skip">&gt;&gt;&gt;</div>')
1006
+ elif who == "typing":
1007
+ outs.append('<div class="dots">•&nbsp;•&nbsp;•</div>')
1008
+ elif who == "emoji":
1009
+ outs.append(f'<div class="jumbo">{body}</div>')
1010
+ elif who == "meta":
1011
+ outs.append(f'<div class="meta">{body}</div>')
1012
+ else:
1013
+ cls = [who, "b"]
1014
+ if prev == who:
1015
+ cls.append("run")
1016
+ if nxt != who: # tail on the LAST of a run only
1017
+ cls.append("tail")
1018
+ outs.append(f'<div class="{" ".join(cls)}">{body}</div>')
1019
+ prev = who
1020
+ return "".join(outs)
1021
+
1022
+
1023
+ total = round(len(slides) * SLIDE_SEC, 3)
1024
+
1025
+ # The canvas is a LAYER, spanning the whole thread, under everything. See the
1026
+ # module docstring — without it the exported PNGs come back transparent.
1027
+ layers = [f' <div id="bg" class="clip bg" data-hf-id="bg" data-layer-mode="publish" '
1028
+ f'data-layer-kind="shape" data-start="0" data-duration="{total}" '
1029
+ f'data-end="{total}" data-label="canvas"></div>']
1030
+ for i, slide in enumerate(slides):
1031
+ t = round(i * SLIDE_SEC, 3)
1032
+ n = i + 1
1033
+ layers.append(
1034
+ f' <div id="s{n:02d}" class="clip" data-hf-id="s{n:02d}" '
1035
+ f'data-layer-mode="publish" data-layer-kind="text" data-start="{t}" '
1036
+ f'data-duration="{SLIDE_SEC}" data-end="{round(t + SLIDE_SEC, 3)}" '
1037
+ f'data-label="slide {n}" style="z-index:1">'
1038
+ f'<div class="col">{bubbles(slide)}</div></div>')
1039
+
1040
+ open(out, "w", encoding="utf-8").write(f"""<!doctype html>
1041
+ <html lang="en"><head><meta charset="UTF-8">
1042
+ <meta name="viewport" content="width={W}, height={H}">
1043
+ <title>{html.escape(title)}</title>
1044
+ <style>{CSS}</style></head><body>
1045
+ <div id="root" data-composition-id="chat_thread" data-start="0"
1046
+ data-width="{W}" data-height="{H}" data-duration="{total}">
1047
+ {chr(10).join(layers)}
1048
+ </div></body></html>
1049
+ """)
1050
+ print(f"{len(slides)} slides · {total}s · {skips} skip cards · {out}")
1051
+ print("stills at: " + ",".join(str(round(i * SLIDE_SEC + 1.5, 1))
1052
+ for i in range(len(slides))))
1053
+ ```
1054
+
1055
+ ## Appendix B — `export-thread.sh` (both deliverables from one composition)
1056
+
1057
+ ```bash
1058
+ #!/usr/bin/env bash
1059
+ # Export the carousel (the deliverable) and the MP4 (the preview) from one composition.
1060
+ # usage: export-thread.sh <work-dir> <n-slides> <folder-slug>
1061
+ set -euo pipefail
1062
+ WORK="$1"; N="$2"; SLUG="$3"
1063
+
1064
+ # Both filenames, always — the two halves of the toolchain disagree (see above).
1065
+ ln -sf composition.html "$WORK/index.html" 2>/dev/null || true
1066
+
1067
+ # Stills at start+1.5s of each 3.0s slide — the SETTLED state, never the entrance.
1068
+ AT=$(python3 -c "print(','.join(str(round(i*3.0+1.5,1)) for i in range($N)))")
1069
+ vidfarm stills "$WORK" --at "$AT" --out "$WORK/slides" --sheet \
1070
+ || vidfarm hf snapshot "$WORK" --at "$AT" --no-end -o "$WORK/slides" --describe false
1071
+
1072
+ # Rename to an explicit order. The order is DATA: the thread is a story.
1073
+ i=1; for f in "$WORK"/slides/*.png; do
1074
+ case "$f" in *contact-sheet*) continue;; esac
1075
+ mv "$f" "$(printf '%s/slides/slide-%02d.png' "$WORK" "$i")"; i=$((i+1))
1076
+ done
1077
+
1078
+ # DURABLE My Files — the temp store deletes after 30 days and the carousel would 404.
1079
+ for f in "$WORK"/slides/slide-*.png; do
1080
+ vidfarm put-file "$f" --folder "threads/$SLUG" --notes "$(basename "$f") — $SLUG"
1081
+ done
1082
+
1083
+ # The preview copy. Same composition, no second build.
1084
+ vidfarm render "$WORK" --wait --output "$WORK/final.mp4" \
1085
+ || vidfarm hf render "$WORK" --output "$WORK/final.mp4"
1086
+
1087
+ echo "carousel: $WORK/slides/slide-*.png preview: $WORK/final.mp4"
1088
+ echo "now read $WORK/slides/contact-sheet.png as one image — that is gate 3"
1089
+ ```
1090
+
1091
+ ## Appendix C — `thread-qa.py` (gate 2: enforce it on the EXPORTED PNGs)
1092
+
1093
+ `vidfarm qa` reads the DOM and cannot see a cropped column, a drifting voice or a one-sided slide.
1094
+ This runs on the pixels that ship, plus the manifest that produced them.
1095
+
1096
+ ```bash
1097
+ python3 thread-qa.py ./work/slides --manifest thread.tsv --brand vidfarm --voices
1098
+ ```
1099
+
1100
+ ```python
1101
+ #!/usr/bin/env python3
1102
+ """
1103
+ Grade an exported chat thread on the PNGs and the script, not on the markup.
1104
+
1105
+ Six checks, each one a defect that passes every DOM check and every per-slide
1106
+ eyeball, and shows up only across the set:
1107
+
1108
+ 1. no slide is blank — an entrance that had not settled at +1.5s
1109
+ 2. the canvas is pure black — a non-#000 layer puts a visible rectangle
1110
+ around every slide and a notch at every tail
1111
+ 3. nothing in the safe zones — top 14% / bottom 20% belong to the platform,
1112
+ and an overflowing column crops there silently
1113
+ 4. no slide is one-sided — measured from the manifest: a slide with only
1114
+ one speaker is a monologue with a colour on it
1115
+ 5. exactly ONE brand mention, — Rule 2, checked against the script rather
1116
+ in the middle than by OCR
1117
+ 6. the voices stay separable — --voices scores the typing tells per speaker
1118
+ per slide and names the slide where they merge
1119
+
1120
+ usage: thread-qa.py <slides-dir> [--manifest thread.tsv] [--brand <token>] [--voices]
1121
+ """
1122
+ import glob, os, re, subprocess, sys
1123
+
1124
+ d = sys.argv[1]
1125
+ flags = [a for a in sys.argv[2:] if a.startswith("--") and "=" not in a]
1126
+ opt = {}
1127
+ rest = [a for a in sys.argv[2:]]
1128
+ for i, a in enumerate(rest):
1129
+ if a.startswith("--") and i + 1 < len(rest) and not rest[i + 1].startswith("--"):
1130
+ opt[a] = rest[i + 1]
1131
+ manifest = opt.get("--manifest")
1132
+ brand = opt.get("--brand")
1133
+ do_voices = "--voices" in flags
1134
+
1135
+ W, H = 1080, 1920
1136
+ fails, warns = [], []
1137
+
1138
+
1139
+ def strip(png, y, h):
1140
+ """Max luma in a horizontal strip, via ffmpeg — no PIL dependency."""
1141
+ # 192x24, not 96x4: a >>> skip card is a handful of small grey glyphs, and a
1142
+ # coarse downscale averages them away into a false BLANK.
1143
+ raw = subprocess.run(
1144
+ ["ffmpeg", "-v", "error", "-i", png, "-vf",
1145
+ f"crop={W}:{h}:0:{y},format=gray,scale=192:24", "-frames:v", "1",
1146
+ "-f", "rawvideo", "-pix_fmt", "gray", "-"],
1147
+ capture_output=True).stdout
1148
+ return max(raw) if raw else 0
1149
+
1150
+
1151
+ slides = sorted(f for f in glob.glob(os.path.join(d, "*.png"))
1152
+ if "contact-sheet" not in f)
1153
+ if not slides:
1154
+ sys.exit("no slides found")
1155
+ if not 8 <= len(slides) <= 15:
1156
+ warns.append(f"{len(slides)} slides — the harness range is 8-15")
1157
+
1158
+ # ------------------------------------------------------------ parse the script
1159
+ slides_txt = []
1160
+ if manifest:
1161
+ cur = []
1162
+ for raw in open(manifest, encoding="utf-8"):
1163
+ line = raw.rstrip("\n")
1164
+ if not line.strip() or line.lstrip().startswith("#"):
1165
+ continue
1166
+ if line.strip() == "---":
1167
+ slides_txt.append(cur); cur = []; continue
1168
+ p = line.split("\t")
1169
+ cur.append((p[0].strip(), p[1].strip() if len(p) > 1 else ""))
1170
+ if cur:
1171
+ slides_txt.append(cur)
1172
+ if len(slides_txt) != len(slides):
1173
+ warns.append(f"manifest has {len(slides_txt)} slides, {len(slides)} PNGs exported")
1174
+
1175
+
1176
+ def is_skip(i):
1177
+ """A >>> card is SUPPOSED to be nearly empty. Without the manifest there is no
1178
+ way to tell it from a slide that failed to render, so say so rather than guess."""
1179
+ return i < len(slides_txt) and slides_txt[i] and slides_txt[i][0][0] == "skip"
1180
+
1181
+
1182
+ # ------------------------------------------------- 1 + 2 + 3. pixels
1183
+ for i, s in enumerate(slides):
1184
+ name = os.path.basename(s)
1185
+ mid = strip(s, int(H * 0.35), int(H * 0.30))
1186
+ if is_skip(i):
1187
+ # the chevrons are small and grey — present, but nowhere near a bubble
1188
+ if mid < 12:
1189
+ fails.append(f"{name}: skip card is EMPTY — the >>> did not render")
1190
+ elif mid > 200:
1191
+ warns.append(f"{name}: skip card has a bubble on it")
1192
+ elif mid < 24:
1193
+ fails.append(f"{name}: BLANK — nothing on the slide at +1.5s"
1194
+ + ("" if manifest else " (or it is a skip card — pass --manifest)"))
1195
+ # the extreme edges are canvas on every legal slide, skip cards included
1196
+ for label, y, h in (("top", 0, int(H * 0.14)), ("bottom", int(H * 0.80), int(H * 0.20))):
1197
+ if strip(s, y, h) > 30:
1198
+ fails.append(f"{name}: ink in the {label} reserved zone — the column "
1199
+ f"overflows and the platform UI will crop it")
1200
+ # a corner is always canvas; it must be black, not near-black
1201
+ corner = strip(s, 4, 24)
1202
+ if corner > 6:
1203
+ fails.append(f"{name}: canvas is not #000 (corner luma {corner}) — Rule 4")
1204
+
1205
+ # ------------------------------------------------- 4 + 5 + 6. the manifest
1206
+ if slides_txt:
1207
+ for i, sl in enumerate(slides_txt, 1):
1208
+ speakers = {w for w, _ in sl if w in ("me", "them")}
1209
+ if sl and sl[0][0] != "skip" and len(speakers) < 2:
1210
+ warns.append(f"slide {i}: only {speakers or '{}'} speaks — a one-sided "
1211
+ f"slide reads as a monologue")
1212
+ for w, t in sl:
1213
+ if w in ("me", "them") and len(t.replace("|", " ").split()) > 22:
1214
+ fails.append(f"slide {i}: a {w} bubble runs past 22 words")
1215
+
1216
+ if brand:
1217
+ hits = [i for i, sl in enumerate(slides_txt, 1)
1218
+ if any(brand.lower() in t.lower() for _, t in sl)]
1219
+ n = len(slides_txt)
1220
+ if len(hits) != 1:
1221
+ fails.append(f"brand {brand!r} appears on slides {hits} — Rule 2 is exactly one")
1222
+ elif not 3 < hits[0] <= n - 3:
1223
+ fails.append(f"brand on slide {hits[0]} of {n} — Rule 2 wants three story "
1224
+ f"slides on each side")
1225
+
1226
+ if do_voices:
1227
+ # Four tells, scored per speaker per slide. They are crude on purpose:
1228
+ # the number is not the point, the CONVERGENCE is.
1229
+ def score(t):
1230
+ return (
1231
+ 1 if re.search(r"[.!?]$", t.strip()) else 0, # terminal stop
1232
+ 1 if re.search(r"^[A-Z]", t.strip()) else 0, # opening capital
1233
+ 1 if re.search(r"\b(ur|bc|rn|thx|u|lol|lmao)\b", t.lower()) else 0,
1234
+ 1 if "," in t else 0,
1235
+ )
1236
+ run = []
1237
+ for i, sl in enumerate(slides_txt, 1):
1238
+ acc = {}
1239
+ for w, t in sl:
1240
+ if w in ("me", "them") and t:
1241
+ a = acc.setdefault(w, [0, 0, 0, 0, 0])
1242
+ for k, v in enumerate(score(t)):
1243
+ a[k] += v
1244
+ a[4] += 1
1245
+ if len(acc) == 2:
1246
+ m, th = acc["me"], acc["them"]
1247
+ dist = sum(abs(m[k] / m[4] - th[k] / th[4]) for k in range(4))
1248
+ run.append((i, round(dist, 2)))
1249
+ merged = [i for i, dv in run if dv < 1.0]
1250
+ if merged:
1251
+ warns.append(f"voices converge on slide(s) {merged} — read only the grey "
1252
+ f"bubbles across the contact sheet (Rule 1)")
1253
+ print("voice separation per slide: " +
1254
+ " ".join(f"{i}:{dv}" for i, dv in run))
1255
+
1256
+ # ------------------------------------------------------------------- verdict
1257
+ for w in warns:
1258
+ print(f"⚠ {w}")
1259
+ for f in fails:
1260
+ print(f"✗ {f}")
1261
+ print(f"\n{len(slides)} slides · {len(fails)} fail · {len(warns)} warn")
1262
+ sys.exit(1 if fails else 0)
1263
+ ```
1264
+
1265
+ ## Appendix D — a worked example: vidfarm.cc, the group chat that added the wrong guy
1266
+
1267
+ Twelve slides, 36.0s. Cost mode `minimize` — the whole thread costs $0. Engine: **comedy**.
1268
+
1269
+ **Build log header**
1270
+
1271
+ ```
1272
+ STORY: A founders' group chat adds a stranger by mistake; he stays, and out-posts all of them
1273
+ ENGINE: comedy
1274
+ VOICES: A (blue) = the stranger — lowercase, no punctuation, deadpan, double-sends
1275
+ B (grey) = the admin — full sentences, capitals, apologises, 😅
1276
+ OFFER: vidfarm.cc — make one video, get thirty versions of it overnight
1277
+ SHILL: slide 7 of 12
1278
+ ```
1279
+
1280
+ **Post caption** (this is where the bait lives)
1281
+
1282
+ ```
1283
+ he never left the group chat 💀
1284
+ do i tell him or do i keep him
1285
+ ```
1286
+
1287
+ **`thread.tsv`** (abridged — slides 1, 2, 3, 7 and 12)
1288
+
1289
+ ```
1290
+ them Hi! Sorry, quick check — are you the Dan from the accelerator?
1291
+ me no
1292
+ me whos dan
1293
+ them Oh no. 😅 I think I added the wrong number to the founders group.
1294
+ me i did wonder why 40 people were arguing about pricing at 6am
1295
+ ---
1296
+ them I’ll take you out, so sorry about that!
1297
+ me nah leave me in
1298
+ me best thing thats happened to my week
1299
+ them …okay. But you have to introduce yourself.
1300
+ me i sell garden sheds
1301
+ ---
1302
+ skip
1303
+ ---
1304
+ them Okay the sheds guy is outposting all of us and I need to know how.
1305
+ me i film one thing on a saturday
1306
+ me then the ai cuts it into like 30 versions overnight
1307
+ me vidfarm. i approve the good ones on sunday with a coffee
1308
+ them That feels illegal.
1309
+ me its sheds mate
1310
+ ---
1311
+ them MARCUS IS ASKING IF YOU DO CONSULTING
1312
+ me tell marcus i do sheds
1313
+ them He has offered you money.
1314
+ me tell marcus i do sheds
1315
+ them He’s wiring it.
1316
+ me give me a sec
1317
+ ```
1318
+
1319
+ **Slide 7, read against the six shill rules**
1320
+
1321
+ - *Plot fact?* Yes — she asked how he out-posts them. He answers about his Saturday.
1322
+ - *Named once, lowercase, mid-bubble?* Yes, third bubble, no URL.
1323
+ - *Middle?* Slide 7 of 12 — five story slides before, five after.
1324
+ - *Question about the character?* "I need to know how" is about him, not about editing software.
1325
+ - *Sceptical reaction?* "That feels illegal."
1326
+ - *Story survives deletion?* Delete slide 7 and the shed-guy thread still works. Passes.
1327
+
1328
+ **Build and export**
1329
+
1330
+ ```bash
1331
+ python3 build-thread.py thread.tsv work/composition.html \
1332
+ --title="vidfarm — group chat thread" --brand=vidfarm
1333
+ ln -sf composition.html work/index.html
1334
+ vidfarm qa ./work --harness ./experimental/engaging-chat-convo.md
1335
+ bash export-thread.sh ./work 12 group-chat
1336
+ python3 thread-qa.py ./work/slides --manifest thread.tsv --brand vidfarm --voices
1337
+ open ./work/slides/contact-sheet.png # gate 3 — the pass that actually matters
1338
+ ```
1339
+
1340
+ **What the first pass got wrong** (recorded because it is what this format gets wrong):
1341
+
1342
+ 1. **Slides 1–2 were set-up.** The original slide 1 was the admin creating the group. Deleted both;
1343
+ the thread now opens on "are you the Dan from the accelerator?", which is a mistake already in
1344
+ progress. This is the most common cut in the format and it always improves the thread.
1345
+ 2. **The blue voice drifted from slide 8.** He started using commas and capitals under pressure —
1346
+ `thread-qa.py --voices` flagged 8, 9 and 10. Rewritten so he goes *shorter* under pressure, which
1347
+ is his register, not more correct.
1348
+ 3. **The shill was originally slide 4** and every reader would have known the thread was an ad. Moved
1349
+ to 7 and gave the admin a sceptical line, which is also the funnier version.
1350
+ 4. **Slide 11 had ten bubbles** and the column cropped at the bottom on a phone. `build-thread.py`
1351
+ warned before render; split into two slides and the thread went from 11 to 12.
1352
+ 5. **The last slide originally resolved** — he explained the sheds. Cut the explanation and ended on
1353
+ `give me a sec`, which is the same shape as the reference's cliffhanger.
1354
+
1355
+ ## Appendix E — the harness in one paragraph, for a handoff
1356
+
1357
+ > Build a 12-slide chat-thread carousel, 9:16, 1080×1920, on pure `#000000`. Each slide is ONE text
1358
+ > layer holding a vertically-centred column of 4–8 iOS-dark-mode bubbles — blue `#0A84FF` right,
1359
+ > grey `#2C2C2E` left, white 46px type in one self-hosted family at one weight, 36px radius, a tail
1360
+ > on the last bubble of each run only, and no phone chrome of any kind (no status bar, avatar,
1361
+ > header, timestamps or keyboard). Two characters who type differently enough to be told apart with
1362
+ > the colour removed: one lowercase and unpunctuated who double-sends, one who writes complete
1363
+ > sentences with emoji. The story opens mid-mistake, uses exactly two `>>>` skip cards to jump time,
1364
+ > reveals something in the middle that recasts what came before, breaks one character's register in
1365
+ > the last act, and ends mid-action with no resolution. Exactly one slide names the offer —
1366
+ > lowercase, mid-bubble, once, as a plot fact the character mentions about their own life, with the
1367
+ > other character reacting sceptically. Ship the twelve PNGs as the deliverable (durable My Files,
1368
+ > ordered in `slides.json`, order is the story) and an MP4 at exactly 3.0s per slide with hard cuts
1369
+ > as the preview. Costs $0 in `minimize`. Full rules:
1370
+ > `vidfarm.cc/experimental/engaging-chat-convo.md`.