@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.
- package/.agents/skills/vidfarm/SKILL.md +26 -2
- package/.agents/skills/vidfarm/harnesses/README.md +1 -0
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +6 -1
- package/.agents/skills/vidfarm/references/reviewing-renders.md +11 -2
- package/SKILL.director.md +43 -5
- package/SKILL.md +5 -2
- package/dist/src/cli.js +96 -1
- package/dist/src/devcli/local-frontend-server.js +10 -2
- package/dist/src/devcli/local-render.js +28 -2
- package/dist/src/devcli/qa-check.js +27 -1
- package/dist/src/lib/engine-globals.js +138 -0
- package/dist/src/lib/frozen-render.js +130 -0
- package/dist/src/services/composition-lint.js +16 -0
- package/experimental/engaging-chat-convo.md +1370 -0
- package/package.json +7 -1
- package/update.md +15 -0
|
@@ -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">>>></div>')
|
|
1006
|
+
elif who == "typing":
|
|
1007
|
+
outs.append('<div class="dots">• • •</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`.
|