@officexapp/vidfarm-devcli 0.21.53 → 0.21.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/vidfarm/SKILL.md +5 -1
- package/.agents/skills/vidfarm/harnesses/product-explainer.HARNESS.md +1 -1
- package/.agents/skills/vidfarm/harnesses/short-form.HARNESS.md +1 -1
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +1 -1
- package/.agents/skills/vidfarm/references/core-workflows.md +15 -0
- package/.agents/skills/vidfarm/references/editor-workflows.md +19 -13
- package/.agents/skills/vidfarm/references/reviewing-renders.md +1 -0
- package/SKILL.director.md +41 -15
- package/SKILL.md +3 -0
- package/dist/src/cli.js +1 -1
- package/dist/src/devcli/composition-edit.js +36 -3
- package/dist/src/devcli/qa-check.js +7 -3
- package/experimental/sticker-slideshow-tips.md +1486 -0
- package/experimental/ugc-reaction-greenscreen.md +1963 -0
- package/experimental/wall-text-pov-ugc.md +2036 -0
- package/package.json +2 -1
- package/public/assets/homepage-client-app.js +14 -14
|
@@ -0,0 +1,1486 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sticker-slideshow-tips
|
|
3
|
+
video_type: Tip slideshow — N still slides on a paper canvas, each one real tip + die-cut cutouts, one slide shilled. Ships as a photo carousel; a 3s-per-slide MP4 is the proof copy (TikTok / Reels / Shorts / Pinterest)
|
|
4
|
+
checks:
|
|
5
|
+
duration_sec: 15-27 # the VIDEO version. N slides x 3.0s exactly. 5 slides = 15s, 9 = 27s
|
|
6
|
+
aspect: 9:16
|
|
7
|
+
first_frame_visual: required
|
|
8
|
+
first_frame_text: required
|
|
9
|
+
text_by_sec: 0 # slide 1's headline is up at frame 0. There is no build-on
|
|
10
|
+
captions: optional # there is no narration to caption. The slide text IS the content
|
|
11
|
+
audio: optional # the carousel is silent; the poster adds sound in-app. A bed on the
|
|
12
|
+
# proof MP4 is allowed but never load-bearing — see "Audio DNA"
|
|
13
|
+
font_regime: required
|
|
14
|
+
safe_zone: required
|
|
15
|
+
# CAREFUL: `scenes` counts VISUAL LAYERS, not slides. One slide is 1 background
|
|
16
|
+
# (photo modes only) + 1-3 cutouts, so a 7-slide deck is 10 layers on paper and 24
|
|
17
|
+
# with backgrounds. The real slide-count gate is 5-9 SLIDES — under 5 reads as a
|
|
18
|
+
# single post, over 9 and nobody swipes to the end — and only gate 2 (slides-qa.py,
|
|
19
|
+
# which counts exported PNGs) can actually enforce it. This range is the loose
|
|
20
|
+
# upper bound: 9 slides x (1 bg + 3 cutouts).
|
|
21
|
+
scenes: 5-40
|
|
22
|
+
max_scene_sec: 3.2 # 3.0 is the number. The 0.2 is render tolerance, not licence
|
|
23
|
+
# A tip slide carries TWO text runs (headline + subtext) and there are up to 9 slides, so the
|
|
24
|
+
# whole-composition count is ~18. This is a READ format, not a subtitle format — the
|
|
25
|
+
# 1-3 ceilings in the sibling harnesses are for videos where a voice is doing the work.
|
|
26
|
+
max_text_cards: 20
|
|
27
|
+
max_simultaneous_text: 2 # headline + subtext. A third text run on a slide is clutter
|
|
28
|
+
max_words_per_cue: 16 # the headline is ONE read at arm's length, 3 lines maximum
|
|
29
|
+
max_dead_air_sec: 1.0 # there is never a frame without text. A gap means a broken slide
|
|
30
|
+
max_tail_sec: 0.5
|
|
31
|
+
forbid_text:
|
|
32
|
+
- link in bio
|
|
33
|
+
- sign up
|
|
34
|
+
- free trial
|
|
35
|
+
- get started
|
|
36
|
+
- book a demo
|
|
37
|
+
- download now
|
|
38
|
+
- download the app
|
|
39
|
+
- learn more
|
|
40
|
+
- swipe up
|
|
41
|
+
- visit our website
|
|
42
|
+
# the bait lives in the POST caption, never on a slide:
|
|
43
|
+
- comment below
|
|
44
|
+
- save this
|
|
45
|
+
- follow for more
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
# Sticker Slideshow — Tips
|
|
49
|
+
|
|
50
|
+
> A public Vidfarm prompt. Read it end to end before you build.
|
|
51
|
+
|
|
52
|
+
## The reference deck — watch it first
|
|
53
|
+
|
|
54
|
+
**Open it before you write a word.**
|
|
55
|
+
|
|
56
|
+
| | |
|
|
57
|
+
|---|---|
|
|
58
|
+
| **Template** | `template_019fed9bf792762397ca3dae9f49f995` |
|
|
59
|
+
| **Title** | "Cutout Tip Slides" |
|
|
60
|
+
| **Watch it** | <https://vidfarm.cc/discover/templates/feed?template=template_019fed9bf792762397ca3dae9f49f995> |
|
|
61
|
+
| **Fork it** | `vidfarm serve template_019fed9bf792762397ca3dae9f49f995` |
|
|
62
|
+
| **Origin** | tiktok.com |
|
|
63
|
+
| **Shape** | 18.077s · 6 slides · `popularGroup: Slideshows` |
|
|
64
|
+
|
|
65
|
+
**It runs at exactly 3.0s per slide** — 18.077/6. That is where the 3.0s constant comes from.
|
|
66
|
+
|
|
67
|
+
## The one test — the three-way lock
|
|
68
|
+
|
|
69
|
+
Three questions. **All three must be yes** or you have not got a tip slideshow yet.
|
|
70
|
+
|
|
71
|
+
| # | Question | If no |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| 1 | **Actionable** — could a stranger do this tonight, without the product, and be better off? | You wrote a fact, or a platitude. Both die |
|
|
74
|
+
| 2 | **The shill is a tip too** — delete the brand name from slide 3. Is what's left still one of the best tips in the deck? | You wrote an ad and put tips around it |
|
|
75
|
+
| 3 | **Still-complete** — freeze any slide as a PNG with no context. Does it stand alone? | You built a video. This format ships as pictures |
|
|
76
|
+
|
|
77
|
+
Test 3 is the one that gets skipped, and it is structural, not cosmetic: **the slides are the
|
|
78
|
+
master and the video is a rendering of them.** Nothing may carry meaning across a cut, nothing may
|
|
79
|
+
be revealed by motion, no slide may depend on the slide before it. The moment a slide only makes
|
|
80
|
+
sense in sequence, the carousel — which is the real deliverable — is broken, and you will not
|
|
81
|
+
notice, because you were watching the MP4.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Part 0 — who this is for
|
|
86
|
+
|
|
87
|
+
**A person who already wants the outcome and does not know the mechanics.** Not a buyer. Someone
|
|
88
|
+
saving a list.
|
|
89
|
+
|
|
90
|
+
Before you write anything, put three lines at the top of your build log:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
TOPIC: Finding the best eats in NYC
|
|
94
|
+
READER: Someone who lives here, eats out twice a week, and keeps ending up somewhere fine
|
|
95
|
+
OFFER: dishcover.io — search restaurants by DISH instead of by restaurant
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **The reader is not the buyer, and that is the point.** They are saving a list about eating in
|
|
99
|
+
NYC. The product reaches them because it is genuinely one of the six answers, not because the
|
|
100
|
+
deck was aimed at them.
|
|
101
|
+
- **Register: someone who knows the city, telling you what they know.** Flat, certain, no hedging,
|
|
102
|
+
no "pro tip", no "you might want to consider". A tip with a hedge in it reads as a guess.
|
|
103
|
+
- **They will read at arm's length, at speed, with sound off.** The headline gets one glance. If it
|
|
104
|
+
needs two, it is a paragraph.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Structural DNA — the deck
|
|
109
|
+
|
|
110
|
+
**N slides, 3.0 seconds each, no exceptions.** The reference is 6 slides / 18.077s. Five is the
|
|
111
|
+
floor (fewer reads as a single post) and nine is the ceiling (nobody swipes to slide ten).
|
|
112
|
+
|
|
113
|
+
| Slot | Slide | Job |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| **1** | **The cover** | Name the list literally + a hero picture. The thumbnail and the promise |
|
|
116
|
+
| **2** | Best tip | Pay the reader immediately. The single strongest thing you know |
|
|
117
|
+
| **3** | Second-best tip | Pay them again, before you ask for anything |
|
|
118
|
+
| **⌈N/2⌉** | **The shill** | The tip that happens to be what the product does. Dead centre |
|
|
119
|
+
| **…** | Tips | Keep the average high. A weak slide here is where people leave |
|
|
120
|
+
| **N** | The tip you'd tell a friend last | The one that earns the save. Not a CTA, not a recap |
|
|
121
|
+
|
|
122
|
+
**The shill sits in the MIDDLE — slide 4 of 7, slide 3 of 5, slide 5 of 9.** Not a fixed number: a
|
|
123
|
+
fixed number breaks the moment the deck changes length. What is fixed is the *condition* —
|
|
124
|
+
**at least two real tips must be paid before the ad, and at least two after it.**
|
|
125
|
+
|
|
126
|
+
Why both halves matter. Put the brand earlier and the reader has had one tip, which is not enough to
|
|
127
|
+
have decided the deck is worth their time; they read the ad as the point of the whole thing and
|
|
128
|
+
leave. Put it last and the deck *resolves* into a pitch, which is the same failure the meme harness
|
|
129
|
+
names in Rule 2 — a list that ends on the product retroactively turns every tip before it into
|
|
130
|
+
set-up. The middle is the only slot where the reader is committed and not yet suspicious.
|
|
131
|
+
|
|
132
|
+
**Counting the cover.** The cover is not a tip, so it does not pay anyone — with a cover, the
|
|
133
|
+
earliest legal shill slot is **slide 4**. A 7-slide deck is cover + 6 tips with the ad at 4: two
|
|
134
|
+
tips in front of it, three behind.
|
|
135
|
+
|
|
136
|
+
**Exactly one shill in the deck.** Two brand mentions and it stops being a list with a product in
|
|
137
|
+
it and becomes an ad with a list on it. There is no second-mention exception and no "but the last
|
|
138
|
+
slide is subtle".
|
|
139
|
+
|
|
140
|
+
**Slides do not build.** No "1 of 6" counters, no "and finally", no "but here's the thing" carrying
|
|
141
|
+
into the next slide, no numbered sequence where slide 4 depends on slide 3. Every slide is a
|
|
142
|
+
standalone poster. Shuffle the middle slides at random; if the deck breaks, it was a video.
|
|
143
|
+
|
|
144
|
+
### Length
|
|
145
|
+
|
|
146
|
+
`N × 3.0s`, exactly. Set `data-duration` on the root to `N * 3.0` and every slide clip to `3.0`.
|
|
147
|
+
|
|
148
|
+
**Identical beat lengths are the FORMAT here, not the defect.** The whole-video review pass in
|
|
149
|
+
`references/reviewing-renders.md` flags "N identically-long beats" as a machine-made tell, and it
|
|
150
|
+
is right about every other format in the catalog. It is wrong about this one: a slideshow that
|
|
151
|
+
lingers on slide 3 is a slideshow that is selling. Do not vary the durations to look hand-made.
|
|
152
|
+
Write it in the handoff so the next reviewer does not "fix" it.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## The tips — the only thing you actually write
|
|
157
|
+
|
|
158
|
+
### The grammar
|
|
159
|
+
|
|
160
|
+
Every headline is **one imperative sentence**, present tense, second person implied.
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
Never eat on the block with the menus outside.
|
|
164
|
+
Go at 5:30 or go at 9:45.
|
|
165
|
+
Walk two more blocks.
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- **Verb first where you can.** "Walk two more blocks" beats "It's worth walking two more blocks".
|
|
169
|
+
- **≤16 words, ≤3 lines.** Break the lines by hand at the sense break, not wherever the box wraps.
|
|
170
|
+
- **No question headlines.** A question defers the payoff to the subtext; the headline is the
|
|
171
|
+
payoff.
|
|
172
|
+
- **No "tip #4:" prefix, no numbering, no emoji in the headline.**
|
|
173
|
+
- **Two weights in one sentence** carry the emphasis — see *Typography DNA*. That is your only
|
|
174
|
+
emphasis tool: no colour, no underline, no size change mid-sentence.
|
|
175
|
+
|
|
176
|
+
**The subtext is the REASON, and it is optional.** One or two lines, smaller, lighter, directly
|
|
177
|
+
under the pictures. It answers "why" and never repeats the headline in other words.
|
|
178
|
+
|
|
179
|
+
**The shill slide, and only the shill slide, may run to three lines.** It has one job the others do
|
|
180
|
+
not — naming the offer — and squeezing that into two lines is what tempts you into cutting the
|
|
181
|
+
reason down to a slogan. Two lines of tip, one line naming the tool. Every other slide stays at two:
|
|
182
|
+
a three-line subtext elsewhere means the tip needed a paragraph, which means it was not a tip.
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
headline: Trust a 30-item menu over a 200-item one.
|
|
186
|
+
subtext: A short menu means a kitchen that repeats. A long one means a freezer.
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
A subtext that restates the headline is worse than no subtext — it tells the reader the deck has
|
|
190
|
+
nothing more to say, and they stop reading the subtexts for the rest of the carousel.
|
|
191
|
+
|
|
192
|
+
### The specificity ladder — the single biggest lever
|
|
193
|
+
|
|
194
|
+
Same tip, four rungs. Rung 1 gets scrolled. Rung 4 gets saved.
|
|
195
|
+
|
|
196
|
+
| Rung | Line | Why |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| 1 | Do your research before you go out | True of everything. Says nothing |
|
|
199
|
+
| 2 | Avoid tourist-trap restaurants | Names a category the reader already avoids |
|
|
200
|
+
| 3 | Walk two blocks further from the train | A real instruction, but no number to hold |
|
|
201
|
+
| 4 | **Every train exit has a trap in the first 200 feet. Walk past it.** | A mechanism + a number. This is the rung |
|
|
202
|
+
|
|
203
|
+
**Rung 4 is a claim you can be wrong about.** That is what makes it worth reading — and it is why
|
|
204
|
+
Rule 6 exists: a rung-4 number has to be a number you'd defend, not one you rounded into place.
|
|
205
|
+
|
|
206
|
+
### Banned tips
|
|
207
|
+
|
|
208
|
+
Each of these fails in a way that is invisible while you're writing it:
|
|
209
|
+
|
|
210
|
+
- **The tip that is really the product.** "Use an app that lets you search by dish" on slide 5 is a
|
|
211
|
+
second shill wearing a hat. One shill, and it is slide 3.
|
|
212
|
+
- **The tip nobody disputes.** "Read reviews before you go", "book ahead on weekends". Being true
|
|
213
|
+
is not the bar; being *told* is.
|
|
214
|
+
- **The tip that needs the reader to already be an expert.** If the instruction assumes they can
|
|
215
|
+
read a menu in Cantonese, they were never the reader.
|
|
216
|
+
- **The tip that is a preference.** "Sit at the bar" is a preference. "Sit at the bar — the bar
|
|
217
|
+
gets the pass first and the food is hotter" is a mechanism.
|
|
218
|
+
- **The tip that costs money to follow.** A list of things a reader can do for free travels; a list
|
|
219
|
+
of things they can do for $80 is an ad for restaurants.
|
|
220
|
+
- **The hedged tip.** "You might want to try…", "it's often worth…", "consider…". Cut the hedge and
|
|
221
|
+
the tip either stands up or was never a tip.
|
|
222
|
+
|
|
223
|
+
### The cold-viewer test
|
|
224
|
+
|
|
225
|
+
Cover the brand name on slide 3. Read all N slides. **Does the deck still read as a good list?**
|
|
226
|
+
If slide 3 is now visibly the weakest one, you did not write a tip — you wrote a placement and
|
|
227
|
+
dressed it. Rewrite slide 3, not the deck.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## The shill slide
|
|
232
|
+
|
|
233
|
+
The one that pays for the work. It obeys every rule above **plus** five of its own.
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
headline: Search the dish, not the restaurant.
|
|
237
|
+
subtext: "Best birria in Queens" finds you dinner. "Best Mexican near me" finds you a parking lot.
|
|
238
|
+
dishcover.io is built to search that way.
|
|
239
|
+
cutouts: a bowl of birria (photographic), a hand-drawn arrow pointing at it
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
1. **It is a mechanism, never an outcome.** "Search by dish" is a mechanism. "Find better food" is
|
|
243
|
+
an outcome, and an outcome is a promise the deck cannot keep.
|
|
244
|
+
2. **It looks identical to every other slide.** Same canvas, same type, same weights, same layout
|
|
245
|
+
grid, same number of cutouts. **No accent colour, no logo, no screenshot, no border.** The
|
|
246
|
+
moment slide 3 is visibly styled differently, the reader's eye finds the ad before they read it.
|
|
247
|
+
3. **The brand is named once, lowercase, in the subtext.** Never in the headline — a headline with
|
|
248
|
+
a brand in it is a banner. `dishcover.io`, not `Dishcover™`, not `Dishcover.io (free!)`.
|
|
249
|
+
4. **The tip works without the product.** A reader who never taps anything can still go search
|
|
250
|
+
`"best birria in queens"` in a normal search box and be better off. That is what makes the
|
|
251
|
+
slide honest, and it is exactly why it converts: you demonstrated the mechanism instead of
|
|
252
|
+
describing a feature.
|
|
253
|
+
5. **No competitor is named doing something badly.** "Best Mexican near me" is a *query shape*, not
|
|
254
|
+
a product. Naming a real competitor in a deck like this is a legal problem and a credibility
|
|
255
|
+
problem in the same sentence.
|
|
256
|
+
|
|
257
|
+
**Where the shill goes when the product is not a search tool.** The rule generalises: find the one
|
|
258
|
+
slide where the honest, product-free version of the tip is *the thing the product automates*, and
|
|
259
|
+
put the product name in that slide's subtext as the tool that does it. If no such tip exists, the
|
|
260
|
+
product does not fit this format — use `meme-recaption` or `ugc-reaction-greenscreen`.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Casting the pictures
|
|
265
|
+
|
|
266
|
+
### The art class — pick one, hold it across the whole deck
|
|
267
|
+
|
|
268
|
+
**The loudest possible failure in this format is mixed art classes.** A photographic cutout on
|
|
269
|
+
slide 1, a flat vector icon on slide 2 and a 3D render on slide 4 reads as three different people
|
|
270
|
+
built the deck. Pick one class in the build log before you source anything:
|
|
271
|
+
|
|
272
|
+
| Class | What it is | Best for |
|
|
273
|
+
|---|---|---|
|
|
274
|
+
| **Photographic cutout** *(the reference, and the default)* | A real photo, die-cut, no background, soft contact shadow | Anything you can eat, wear, hold or stand in front of |
|
|
275
|
+
| **Flat sticker** | IconScout `--style sticker` — thick-outline, limited palette | Abstract tips with no physical noun (time, money, queues) |
|
|
276
|
+
| **Hand-drawn mark** | Felt-tip arrow, circle, cross, underline | The *annotation* layer only — see below |
|
|
277
|
+
|
|
278
|
+
**Hand-drawn marks are not an art class, they are punctuation.** An arrow between two cutouts, a
|
|
279
|
+
cross over the wrong option, a circle around the right one. They may appear alongside either of the
|
|
280
|
+
first two classes, and they should — the reference's whole slide is `[crushed bottle] → [nice
|
|
281
|
+
bottle]`, and the arrow is what makes it a sentence instead of two objects.
|
|
282
|
+
|
|
283
|
+
### The picture must be the tip's noun
|
|
284
|
+
|
|
285
|
+
The cutout is not decoration and it is not a mood. **It is the thing the sentence is about.** If
|
|
286
|
+
the tip says "a 30-item menu beats a 200-item one", the cutouts are two menus of visibly different
|
|
287
|
+
length. If the sentence has no physical noun, either find the noun ("go at 5:30" → a clock face) or
|
|
288
|
+
**give the slide no picture at all** — an all-type slide inside a picture deck is a legitimate beat
|
|
289
|
+
and infinitely better than a stock photo of a smiling person that means nothing.
|
|
290
|
+
|
|
291
|
+
Test it the way the meme harness tests enactment: **cover the text. Can a stranger guess the
|
|
292
|
+
tip within about two seconds?** Not exactly — approximately. If the pictures could belong to any of
|
|
293
|
+
your six slides, they belong to none of them.
|
|
294
|
+
|
|
295
|
+
### Sourcing, cheapest first
|
|
296
|
+
|
|
297
|
+
The ladder, and stop at the first rung that answers the slide:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
# 1. FREE designer stickers/illustrations — no key, no account, search is free
|
|
301
|
+
vidfarm iconscout "bowl of birria" --style sticker --free
|
|
302
|
+
vidfarm iconscout "alarm clock" --style sticker --free
|
|
303
|
+
vidfarm iconscout get <uuid> --format png --size 1024 --out media/cutouts/clock.png
|
|
304
|
+
|
|
305
|
+
# 2. FREE stock photo -> die-cut it locally for $0 (ONNX matting, no plate needed)
|
|
306
|
+
vidfarm media search "birria tacos overhead" --type image
|
|
307
|
+
vidfarm remove-background ./birria.jpg --out media/cutouts/birria.png
|
|
308
|
+
|
|
309
|
+
# 3. Art the director already owns — lift an element out of their own site/menu/screenshot
|
|
310
|
+
vidfarm mask ./their-site.png --crop 120,340,600,600 --out media/cutouts/dish.png
|
|
311
|
+
|
|
312
|
+
# 4. ONE generated sheet for the WHOLE deck — one billed image job, one art style
|
|
313
|
+
vidfarm sticker-pack --generate "die-cut photographic food cutouts on a flat #FF00FF plate" \
|
|
314
|
+
--items "birria bowl,laminated menu,alarm clock,subway sign" --out-dir media/cutouts
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
**Rung 4 is the one to reach for in `hybrid`, and reach for it ONCE.** One sheet holding every
|
|
318
|
+
cutout in the deck costs one image job and — far more important — guarantees one art class. N
|
|
319
|
+
separate cutout jobs cost N times as much *and* produce N slightly different styles, which is the
|
|
320
|
+
failure this section opens with.
|
|
321
|
+
|
|
322
|
+
**In `minimize` + `interactive`, rung 4 is free.** Hand the user one prompt, get one sheet back,
|
|
323
|
+
split it locally:
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
vidfarm handoff image --theme "die-cut photographic food cutouts, NYC eating deck" \
|
|
327
|
+
--items "birria bowl,laminated menu board,alarm clock,subway exit sign,short menu,long menu"
|
|
328
|
+
# they paste it into meta.ai / free ChatGPT / a HuggingFace Space, hand back sheet.png
|
|
329
|
+
vidfarm sticker-pack ./sheet.png --items "birria bowl,laminated menu board,alarm clock,…" \
|
|
330
|
+
--out-dir media/cutouts
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### ⚠️ Attribution — the one that bites this format
|
|
334
|
+
|
|
335
|
+
**IconScout free assets and most free stock require a credit line, and a credit line on the frame
|
|
336
|
+
is brand chrome, which is banned.** This format has an out the sibling harnesses do not: a
|
|
337
|
+
carousel always ships with a **post caption**. Put every credit there, in the caption, one line at
|
|
338
|
+
the bottom. Never on a slide, never on a "credits" slide N+1, never in 12px grey in a corner.
|
|
339
|
+
|
|
340
|
+
If a licence genuinely requires the credit to be *on the image*, do not use that asset — go back to
|
|
341
|
+
rung 2 (matted stock, no attribution) or rung 4 (generated).
|
|
342
|
+
|
|
343
|
+
### The alpha has to actually be clean
|
|
344
|
+
|
|
345
|
+
A cutout on a near-white canvas shows every defect a cutout on footage would hide. **A grey halo,
|
|
346
|
+
a soft 40%-alpha edge, or a leftover rectangle of background is instantly visible on white and
|
|
347
|
+
invisible in a thumbnail.** Check it, don't trust it:
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
# any cutout: how much of it is actually opaque?
|
|
351
|
+
a = alpha_channel(png)
|
|
352
|
+
print((a == 0).mean(), ((a > 0) & (a < 255)).mean(), (a == 255).mean())
|
|
353
|
+
# healthy: mostly 0 or 255, a percent or two in between.
|
|
354
|
+
# tens of percent "partial" = a bad key, and on white it WILL show as a grey box.
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Match the key mode to the shape — this is the same trap the greenscreen harness documents, and it
|
|
358
|
+
costs a wasted job every time it is rediscovered:
|
|
359
|
+
|
|
360
|
+
| Art | Key mode | Why |
|
|
361
|
+
|---|---|---|
|
|
362
|
+
| **Hollow** — an arrow outline, a ring, a frame | `--key-mode flat` | the interior never touches the frame edge, so a connectivity keyer *keeps* it and you get a filled blob |
|
|
363
|
+
| **Solid** — a photographic dish, a bottle, a person | `--key-mode smart` *(default)* | flat mode's soft alpha ramp eats a photograph — measured at 65% partial alpha on a first attempt vs 0.6% in smart mode |
|
|
364
|
+
|
|
365
|
+
And pick the plate colour **against the subject**: a green plate behind green food keys holes
|
|
366
|
+
through the food. Magenta `#FF00FF` is the safe default for anything edible.
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## Visual DNA
|
|
371
|
+
|
|
372
|
+
### The canvas
|
|
373
|
+
|
|
374
|
+
**Near-white paper, and it never changes.** Sampled off the reference: `#FDFDFD`. Use
|
|
375
|
+
`#FDFCFA` (a hair warm, kinder on OLED) or plain `#FFFFFF`. Text is near-black `#111111`, not pure
|
|
376
|
+
`#000000`.
|
|
377
|
+
|
|
378
|
+
- **One canvas colour for the whole deck.** Not one per slide, not a slow tint drift, not a
|
|
379
|
+
different background for the shill.
|
|
380
|
+
- **No footage, ever.** No photo backgrounds, no blurred plate, no gradient, no texture, no
|
|
381
|
+
drop-shadowed cards. The page is a page.
|
|
382
|
+
- **No border, no frame, no margin rule, no page number.**
|
|
383
|
+
- **This is deliberately not the sibling harnesses' look.** Those put text on footage and need a
|
|
384
|
+
stroke to survive it. Here the contrast is already maximal, and a stroke or a text shadow on a
|
|
385
|
+
white page is exactly the thing that makes a slide read as machine-made. **No stroke, no text
|
|
386
|
+
shadow, no plate behind the type.** The only shadow anywhere in the deck is a soft contact
|
|
387
|
+
shadow under a photographic cutout, and even that is optional.
|
|
388
|
+
|
|
389
|
+
### Canvas DNA — four background modes
|
|
390
|
+
|
|
391
|
+
The reference is a white page, and that is still the default. But a photograph behind the type is
|
|
392
|
+
the other half of this format in the wild, and it changes one thing structurally: **on a page the
|
|
393
|
+
type needs nothing, on a photo the type needs help.** Pick a mode per deck — never per slide — and
|
|
394
|
+
the whole legibility contract comes with it.
|
|
395
|
+
|
|
396
|
+
| Mode | The picture | Ink | Reads as |
|
|
397
|
+
|---|---|---|---|
|
|
398
|
+
| **`paper`** *(default)* | none — flat `#FDFCFA` | `#111` | Editorial, clean, the reference. The cutouts are the only colour |
|
|
399
|
+
| **`photo-darken`** | blurred, darkened | **white** | Moody, premium, night-out. **The most legible of the three** |
|
|
400
|
+
| **`photo-lighten`** | blurred, brightened, desaturated | `#111` | Airy, daylight, magazine. Closest to `paper` with context |
|
|
401
|
+
| **`photo-gray`** | blurred, greyscale, neutral wash | **white** | Editorial monochrome. **The tightest on contrast — see below** |
|
|
402
|
+
|
|
403
|
+
**A photo mode is not decoration, it is context.** Each slide's background is the *place* the tip is
|
|
404
|
+
about — the storefront, the dining room at service, the subway platform. It is blurred hard enough
|
|
405
|
+
that it is atmosphere, never a picture competing with the cutout in front of it. If you cannot name
|
|
406
|
+
what a background is doing for its slide, use `paper`.
|
|
407
|
+
|
|
408
|
+
**Backgrounds are free.** `vidfarm media search "<place>" --type image` (pixabay/openverse) covers
|
|
409
|
+
this entirely at $0 — no generation, in any cost mode.
|
|
410
|
+
|
|
411
|
+
#### The legibility contract
|
|
412
|
+
|
|
413
|
+
Each mode is a *set* of values tuned together — photo filter, wash, ink colour, text shadow, cutout
|
|
414
|
+
shadow. **Changing one alone is how a deck ends up with a 3:1 headline that looked fine on the one
|
|
415
|
+
slide you opened.** They live in `MODES` in Appendix A.
|
|
416
|
+
|
|
417
|
+
- **The blur and the dim are DIFFERENT ELEMENTS.** A single backdrop that is both blurred and dimmed
|
|
418
|
+
is the website-modal staging pattern, and `vidfarm qa` is right to call it (`modal-scrim`). Blur
|
|
419
|
+
the photograph on the `<img>`; carry the dim on a separate flat colour layer above it. Identical
|
|
420
|
+
result, and it is not modal staging.
|
|
421
|
+
- **The wash is a BANDED GRADIENT, not a flat tint.** A flat wash has to be deep enough for the
|
|
422
|
+
worst band, which flattens the whole picture into mud. A gradient that is strong across the
|
|
423
|
+
headline band (0–32%) and the subtext band (72–100%) and weak through the cutout band (44–64%)
|
|
424
|
+
buys the contrast exactly where the text is and lets the photograph breathe where it doesn't.
|
|
425
|
+
- **The photo is scaled to 110% and offset −5%.** A blurred image at exactly 100% shows a pale
|
|
426
|
+
vignette on all four edges — the blur sampling past the frame — and it reads as a rendering bug.
|
|
427
|
+
- **Rule 5 inverts on a photo.** On `paper`, a text shadow is decoration and banned. On a photo it
|
|
428
|
+
is legibility, so `photo-*` modes carry a soft diffuse shadow (no stroke, still no plate, still no
|
|
429
|
+
card). The cutouts get a real contact shadow for the same reason: a die-cut object with no shadow
|
|
430
|
+
floats unconvincingly over a photograph.
|
|
431
|
+
- **Hand-drawn MARKS follow the ink; photographs never do.** A `#111` arrow on `photo-darken` is
|
|
432
|
+
invisible — and invisible in precisely the spot the eye is being pointed. Write `{ink}` into the
|
|
433
|
+
mark's filename (`arrow-{ink}.svg`) and the builder resolves `arrow-dark.svg` / `arrow-light.svg`
|
|
434
|
+
per mode. It is opt-in on purpose: recolouring a photographic cutout would ruin it.
|
|
435
|
+
|
|
436
|
+
#### Prove the text is readable — measure it, don't look at it
|
|
437
|
+
|
|
438
|
+
**This is the check that makes photo modes safe to ship**, and it cannot be done by eye: a headline
|
|
439
|
+
that reads perfectly on slide 2's dark doorway can dissolve on slide 5's bright tiled wall, and you
|
|
440
|
+
only find out on the slide you did not open.
|
|
441
|
+
|
|
442
|
+
`slides-qa.py --ink light|dark` (Appendix C) recovers each text band's background by **blurring the
|
|
443
|
+
band hard enough to erase the glyphs**, takes the worst-case patch of what is left, and computes the
|
|
444
|
+
WCAG contrast ratio against the mode's known ink colour.
|
|
445
|
+
|
|
446
|
+
| Ratio | Verdict |
|
|
447
|
+
|---|---|
|
|
448
|
+
| **< 4.5:1** | **Fail.** Deepen the wash, or switch polarity (`darken` ↔ `lighten`) |
|
|
449
|
+
| 4.5–7:1 | Passes AA. Fine for 70px display type; tighten if you can |
|
|
450
|
+
| **≥ 7:1** | Ship it |
|
|
451
|
+
|
|
452
|
+
Measured on the reference build: `photo-darken` and `photo-lighten` clear 7:1 on every band of every
|
|
453
|
+
slide. **`photo-gray` lands at 6.1–6.9:1 and is the mode with the least headroom** — greyscale
|
|
454
|
+
throws away the colour separation the other two lean on, so it is squeezed between "picture still
|
|
455
|
+
visible" and "white text still readable". It is legal and it looks good; it is just the one mode
|
|
456
|
+
where a bright new background can push a slide under, so re-measure whenever you swap its photos.
|
|
457
|
+
|
|
458
|
+
**Three checks in `slides-qa.py` are `paper`-only** and are skipped automatically once `--ink` is
|
|
459
|
+
set: canvas-drift (a photo deck is *supposed* to change picture every slide), headline-band
|
|
460
|
+
levelling (the detector locks onto photo detail instead of glyphs), and reserved-zone ink (every
|
|
461
|
+
full-bleed photograph has ink everywhere). On a photo deck the contrast gate replaces all three.
|
|
462
|
+
|
|
463
|
+
### The layout grid
|
|
464
|
+
|
|
465
|
+
Three horizontal bands, held identically on every slide. Read off the reference and expressed as
|
|
466
|
+
percentages of the 1080×1920 canvas:
|
|
467
|
+
|
|
468
|
+
```
|
|
469
|
+
0% ─────────────────────────────────────
|
|
470
|
+
(air — nothing lives here)
|
|
471
|
+
14% ┌───────────────────────────────────┐
|
|
472
|
+
│ HEADLINE centred, 1-3 lines │
|
|
473
|
+
30% └───────────────────────────────────┘
|
|
474
|
+
(air)
|
|
475
|
+
34% ┌───────────────────────────────────┐
|
|
476
|
+
│ │
|
|
477
|
+
│ CUTOUTS 1-3 objects + marks │
|
|
478
|
+
│ │
|
|
479
|
+
66% └───────────────────────────────────┘
|
|
480
|
+
(air)
|
|
481
|
+
73% ┌───────────────────────────────────┐
|
|
482
|
+
│ SUBTEXT centred, 1-2 lines │
|
|
483
|
+
80% └───────────────────────────────────┘
|
|
484
|
+
(air — nothing lives here)
|
|
485
|
+
100% ─────────────────────────────────────
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
- **The bands do not move between slides.** A headline that is 2 lines on slide 1 and 3 lines on
|
|
489
|
+
slide 2 still starts at 14%. Vertically centring each slide's content individually is the single
|
|
490
|
+
most common way a deck acquires a "wobble" nobody can name — and it is invisible slide by slide.
|
|
491
|
+
- **The bottom 20% and the top 14% stay empty.** That is where the platform puts the caption, the
|
|
492
|
+
username, the buttons, and the swipe affordance.
|
|
493
|
+
- **Everything is centred.** The headline block centres, the cutout group centres, the subtext
|
|
494
|
+
centres. The reference does exactly this and it is why the page reads as a page.
|
|
495
|
+
|
|
496
|
+
### Sticker economy — generous, but composed
|
|
497
|
+
|
|
498
|
+
The user asked for generous sticker use, and this format supports it in a way the video formats do
|
|
499
|
+
not: **a still frame can hold three objects, because the reader controls how long they look.**
|
|
500
|
+
|
|
501
|
+
- **1–3 cutouts per slide.** One hero object; two for a comparison; three only when they are a
|
|
502
|
+
*set* (three menu lengths, three times of day).
|
|
503
|
+
- **Plus one hand-drawn mark** — an arrow, a cross, a circle. Marks are not counted in the 1–3.
|
|
504
|
+
- **Slides with zero cutouts are legal and should exist.** One or two all-type slides in a six-slide
|
|
505
|
+
deck give the eye somewhere to rest, and they are the right answer for a tip with no noun.
|
|
506
|
+
- **Rotate them slightly.** ±3–8° on each object, different per object. Perfectly axis-aligned
|
|
507
|
+
cutouts on a white page read as a PowerPoint; a slight tilt reads as things laid on a table.
|
|
508
|
+
- **They may overlap the band edges but never each other's silhouettes.** Two cutouts touching read
|
|
509
|
+
as one confusing object.
|
|
510
|
+
- **Consistent scale relationship.** If a bottle and a person are both on a slide, the person is
|
|
511
|
+
bigger. Sounds obvious; gets broken every time the two cutouts came from different sources.
|
|
512
|
+
- **One light direction across the whole deck.** If the birria is lit from the upper left, so is
|
|
513
|
+
the menu board. Contact shadows all fall the same way or you drop them entirely.
|
|
514
|
+
|
|
515
|
+
### The comparison layout — the format's strongest slide
|
|
516
|
+
|
|
517
|
+
The reference is one: `[crushed plastic bottle] → [nice steel bottle]`. Two cutouts, an arrow
|
|
518
|
+
between them, and the tip is legible before a word is read.
|
|
519
|
+
|
|
520
|
+
Use it for any tip of the shape *stop doing X, do Y*. Left object is the wrong one (and it may be
|
|
521
|
+
visibly worse — crushed, greasy, cluttered); right object is the right one; the arrow is
|
|
522
|
+
hand-drawn and points right. **Do not put a cross over the left object AND an arrow** — one mark
|
|
523
|
+
per slide.
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## Typography DNA
|
|
528
|
+
|
|
529
|
+
### The regime
|
|
530
|
+
|
|
531
|
+
**Two weights of ONE family, self-hosted, no fallback chain.** A fallback chain means a missing
|
|
532
|
+
font silently renders as a different font and nothing tells you — the deck just quietly stops
|
|
533
|
+
looking like itself.
|
|
534
|
+
|
|
535
|
+
| Role | Face | Weight | Size | Line height |
|
|
536
|
+
|---|---|---|---|---|
|
|
537
|
+
| Headline — emphasis | TikTok Sans *or* Montserrat | **800** | 76px | 1.22 |
|
|
538
|
+
| Headline — body | same family | **500** | 76px | 1.22 |
|
|
539
|
+
| Subtext | same family | **500** | 40px | 1.34 |
|
|
540
|
+
|
|
541
|
+
`vidfarm qa`'s `font-regime` rule errors on Inter / Roboto / Arial / system-ui and warns on any
|
|
542
|
+
family outside the imported regime (Montserrat, TikTok Sans, Abel, Source Code Pro, Yesteryear).
|
|
543
|
+
**Montserrat is the closest match to the reference** and is in the regime.
|
|
544
|
+
|
|
545
|
+
> **Repo note:** `work/*/media/fonts/` currently carries `TikTokSans-700.woff2` and
|
|
546
|
+
> `TikTokSans-900.woff2` only. This format needs a **mid weight (400–500)** for the non-emphasised
|
|
547
|
+
> half of the headline. Fetch it once and copy it beside the others before you build — do not
|
|
548
|
+
> approximate a 500 by setting `opacity` on a 700, which produces grey text, not light text.
|
|
549
|
+
|
|
550
|
+
### Mixed weight inside one sentence — the whole voice
|
|
551
|
+
|
|
552
|
+
This is the reference's signature and it is not decoration. The **800** carries the words that make
|
|
553
|
+
the tip, the **500** carries the connective tissue:
|
|
554
|
+
|
|
555
|
+
```html
|
|
556
|
+
<b>Stop</b> buying <b>plastic bottle</b><br>
|
|
557
|
+
every day <b>bring your own</b><br>
|
|
558
|
+
at school/work
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
- **Emphasise the verb and the noun.** Not the adjectives, not "every day", not "the".
|
|
562
|
+
- **2–4 emphasised runs per headline.** One is a typo; five is a highlighter.
|
|
563
|
+
- **The emphasis is the same colour as the body.** Weight only. A coloured word on a white page is
|
|
564
|
+
a link, and nothing in a video is clickable.
|
|
565
|
+
- **Never emphasise the brand name** on slide 3. It rides in the 500 with everything else — that
|
|
566
|
+
is the difference between a tip that mentions a tool and an ad.
|
|
567
|
+
|
|
568
|
+
### Spacing
|
|
569
|
+
|
|
570
|
+
Getting this wrong is what makes a slide look "off" in a way nobody can point at:
|
|
571
|
+
|
|
572
|
+
- **Line height 1.22 on the headline.** Tighter reads as a logo; looser reads as a document.
|
|
573
|
+
- **Hand-break the lines at the sense break.** `every day / bring your own` — never let the box
|
|
574
|
+
decide. Aim for near-equal line lengths so the centred block has a shallow ragged edge.
|
|
575
|
+
- **Letter-spacing `-0.01em` on the headline**, `0` on the subtext. Large type needs the pull-in;
|
|
576
|
+
small type does not.
|
|
577
|
+
- **Max headline width 84% of canvas.** Full-bleed type is the `caption-oversize` failure and it
|
|
578
|
+
forces wraps you did not choose.
|
|
579
|
+
- **The gap between the headline block and the cutouts is fixed**, not proportional to how many
|
|
580
|
+
lines the headline took. This is the band grid again, and it is the same failure.
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## Motion DNA
|
|
585
|
+
|
|
586
|
+
**The default is no motion at all.** The reference has none. A deck of still slides, hard cut every
|
|
587
|
+
3.0s, is the correct build and it is what the carousel exports cleanly from.
|
|
588
|
+
|
|
589
|
+
What is allowed, and only in the MP4:
|
|
590
|
+
|
|
591
|
+
- **A cutout entrance**, if and only if it **completes within 0.5s** of the slide's start. The
|
|
592
|
+
still export is taken at slide start **+1.5s** for exactly this reason: the settled state is the
|
|
593
|
+
slide, and the entrance is a courtesy to the video viewer.
|
|
594
|
+
- **Nothing on the headline or subtext.** Text is up at the slide's frame 0, full opacity, final
|
|
595
|
+
position. A headline that types on, fades up, or pops per word cannot be exported as a PNG and
|
|
596
|
+
cannot be read in 3 seconds.
|
|
597
|
+
|
|
598
|
+
What is banned:
|
|
599
|
+
|
|
600
|
+
- **Ken Burns.** It is the right tool for a slideshow of *photographs*; it is wrong here, because
|
|
601
|
+
the canvas is a page and a drifting page is a rendering artifact.
|
|
602
|
+
- **Any transition between slides.** No crossfade, no whip, no slide-in. **Hard butt cuts only** —
|
|
603
|
+
a carousel swipes, it does not dissolve, and the MP4 should feel like a swipe.
|
|
604
|
+
- **An entrance transition on slide 1.** `vidfarm qa` errors on this (`thumbnail-fade-in`) and it
|
|
605
|
+
is right: frame 0 is the thumbnail and the first slide of the carousel.
|
|
606
|
+
- **Anything that reveals.** If motion tells the reader something the still does not, the carousel
|
|
607
|
+
is broken. See test 3.
|
|
608
|
+
|
|
609
|
+
---
|
|
610
|
+
|
|
611
|
+
## Audio DNA
|
|
612
|
+
|
|
613
|
+
**The carousel is silent.** There is nothing to author.
|
|
614
|
+
|
|
615
|
+
**The MP4 may carry a music bed and nothing else.** No voiceover — a voice reading the slides makes
|
|
616
|
+
the video the master and the slides the leftovers, which inverts the format. If you add a bed:
|
|
617
|
+
|
|
618
|
+
- Free and keyless: `vidfarm media search "<mood>" --type bgm` (Openverse, CC/CC0, no key).
|
|
619
|
+
- It is **never load-bearing.** No beat-matched cuts, no drop on slide 3. The cuts land on 3.0s
|
|
620
|
+
because that is the format, not because the music said so.
|
|
621
|
+
- Ship the MP4 **silent as well** if the proof will be judged on the slides. `export-versions.py`
|
|
622
|
+
in the greenscreen harness does this shape already.
|
|
623
|
+
|
|
624
|
+
The poster adds a trending sound in the platform's own editor. Say so in the handoff.
|
|
625
|
+
|
|
626
|
+
---
|
|
627
|
+
|
|
628
|
+
## The two deliverables
|
|
629
|
+
|
|
630
|
+
**One composition. Two exports. The slides are the master.**
|
|
631
|
+
|
|
632
|
+
| Deliverable | What it is | How it is made | Where it goes |
|
|
633
|
+
|---|---|---|---|
|
|
634
|
+
| 📸 **The slideshow** *(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 |
|
|
635
|
+
| 🎬 **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 |
|
|
636
|
+
|
|
637
|
+
### ⚠️ Name the file BOTH ways before you export anything
|
|
638
|
+
|
|
639
|
+
The two halves of the toolchain disagree about the filename, and each one fails with an error that
|
|
640
|
+
sounds like a missing project rather than a missing alias:
|
|
641
|
+
|
|
642
|
+
| Tool | Wants | Error when it's absent |
|
|
643
|
+
|---|---|---|
|
|
644
|
+
| `vidfarm stills` / `vidfarm qa` / `vidfarm render` | `composition.html` | *"No composition.html inside … Run `vidfarm pull` first"* |
|
|
645
|
+
| `vidfarm hf …` (the hyperframes passthrough) | `index.html` | *"No composition found … Run npx hyperframes init"* |
|
|
646
|
+
|
|
647
|
+
Write `composition.html` and symlink `index.html` beside it. One line, and it removes an entire
|
|
648
|
+
class of dead end:
|
|
649
|
+
|
|
650
|
+
```bash
|
|
651
|
+
ln -sf composition.html ./work/index.html
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
### Exporting the slides
|
|
655
|
+
|
|
656
|
+
```bash
|
|
657
|
+
# 6 slides at 3.0s -> settled-state stills at 1.5, 4.5, 7.5, 10.5, 13.5, 16.5
|
|
658
|
+
vidfarm stills ./work --at 1.5,4.5,7.5,10.5,13.5,16.5 --out ./slides --sheet
|
|
659
|
+
|
|
660
|
+
# If `stills` dies with "Cannot find package '@hyperframes/producer'", the devcli's
|
|
661
|
+
# bundled engine is incomplete on this machine. Do NOT go hunting — the passthrough
|
|
662
|
+
# is the same renderer and it works:
|
|
663
|
+
vidfarm hf snapshot ./work --at 1.5,4.5,7.5,10.5,13.5,16.5 --no-end \
|
|
664
|
+
-o ./slides --describe false
|
|
665
|
+
# and for the MP4, likewise: vidfarm hf render ./work --output ./work/final.mp4
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
`--sheet` is not optional here. The contact sheet **is** the carousel, laid out flat, and it is the
|
|
669
|
+
only view in which the sequence-level defects this format is prone to are visible: a wobbling
|
|
670
|
+
headline band, three type sizes, one cutout at the wrong scale, an art class that drifted by slide
|
|
671
|
+
5. Read it as one image before you upload anything. Appendix B automates the whole export.
|
|
672
|
+
|
|
673
|
+
### Uploading and ordering
|
|
674
|
+
|
|
675
|
+
The slides must live in **durable** My Files, not the 30-day temp store — a carousel that 404s in a
|
|
676
|
+
month is worse than one that never shipped.
|
|
677
|
+
|
|
678
|
+
```bash
|
|
679
|
+
for i in 1 2 3 4 5 6; do
|
|
680
|
+
vidfarm put-file ./slides/slide-0$i.png --folder decks/nyc-eats \
|
|
681
|
+
--notes "slide $i of 6 — NYC eats tip deck"
|
|
682
|
+
done
|
|
683
|
+
vidfarm files --search "NYC eats tip deck" # confirm all six, in order
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
Write the ordered URLs to `slides.json` beside the composition. **Order is data, not a filename
|
|
687
|
+
convention** — a client posting the carousel will paste them in the order you give, and slide 3
|
|
688
|
+
being the shill is load-bearing.
|
|
689
|
+
|
|
690
|
+
### Submitting to the marketplace
|
|
691
|
+
|
|
692
|
+
A gig proof has to be **playable**, which is why the MP4 exists. The slides travel beside it:
|
|
693
|
+
|
|
694
|
+
```bash
|
|
695
|
+
# 1. render the video version
|
|
696
|
+
vidfarm render ./work --wait # -> final.mp4
|
|
697
|
+
|
|
698
|
+
# 2. drop BOTH into the client's upload link, in your own subfolder
|
|
699
|
+
vidfarm shared put <upload-link> ./final.mp4 --subfolder <your-handle>/nyc-eats
|
|
700
|
+
vidfarm shared put <upload-link> ./slides.json --subfolder <your-handle>/nyc-eats
|
|
701
|
+
for f in ./slides/slide-*.png; do
|
|
702
|
+
vidfarm shared put <upload-link> "$f" --subfolder <your-handle>/nyc-eats/slides
|
|
703
|
+
done
|
|
704
|
+
|
|
705
|
+
# 3. submit the MP4 as the proof
|
|
706
|
+
vidfarm gigs submit <gig-id> --task <task-id> --proof https://…/final.mp4
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
**Say in the submission that the deliverable is the carousel and the MP4 is the preview.** A client
|
|
710
|
+
who receives an MP4 with no note assumes the MP4 is the product, posts it as a video, and gets a
|
|
711
|
+
fraction of the reach the format is for.
|
|
712
|
+
|
|
713
|
+
---
|
|
714
|
+
|
|
715
|
+
## The rules
|
|
716
|
+
|
|
717
|
+
Every rule has its reason attached. A rule without its reason gets argued away by the next agent
|
|
718
|
+
that reads this file.
|
|
719
|
+
|
|
720
|
+
### Rule 1 — one shill, on slide 3, written as a tip
|
|
721
|
+
|
|
722
|
+
Any other placement or count changes what the deck *is*. Slide 1 makes it an ad, slide N makes it a
|
|
723
|
+
pitch, two mentions make it a brochure. The shill is in the middle because that is where the reader
|
|
724
|
+
is already paid and not yet suspicious. **Enforced:** the cold-viewer test, and `slides-qa.py`
|
|
725
|
+
checks that exactly one slide contains the brand token.
|
|
726
|
+
|
|
727
|
+
### Rule 2 — every slide is a complete still
|
|
728
|
+
|
|
729
|
+
Nothing crosses a cut. No motion carries meaning. The PNG export at start+1.5s **is** the slide, so
|
|
730
|
+
if the PNG is missing something, the slide is missing it. This is the rule the whole format hangs
|
|
731
|
+
on, because the carousel is the deliverable and the video is not.
|
|
732
|
+
|
|
733
|
+
### Rule 3 — the layout bands never move
|
|
734
|
+
|
|
735
|
+
Headline at 14%, cutouts 34–66%, subtext at 73%, on every slide, whatever the content length. Per
|
|
736
|
+
slide it looks fine either way; across the deck, individually-centred content produces a vertical
|
|
737
|
+
wobble that reads as sloppy and that nobody can name while looking at one slide.
|
|
738
|
+
|
|
739
|
+
### Rule 4 — one art class, one light direction, one canvas colour
|
|
740
|
+
|
|
741
|
+
The fastest way for a deck to look like it was assembled by a machine from four sources is for it
|
|
742
|
+
to have been assembled by a machine from four sources. Source the whole deck in one pass, ideally
|
|
743
|
+
from one sheet.
|
|
744
|
+
|
|
745
|
+
### Rule 5 — no stroke, no text shadow, no plate, no card
|
|
746
|
+
|
|
747
|
+
On footage those are legibility. On a white page they are decoration, and `vidfarm qa` will call
|
|
748
|
+
the plate a `card-panel`. Black on white is already the highest contrast available.
|
|
749
|
+
|
|
750
|
+
### Rule 6 — every number on a slide is a number you would defend
|
|
751
|
+
|
|
752
|
+
"200 feet", "30-item menu", "5:30". Rung-4 specificity is what makes these tips worth saving, and
|
|
753
|
+
it only works if the numbers are real. A number invented to sound specific is the one thing in the
|
|
754
|
+
deck a reader can catch you on, and a reader who catches one stops believing the other five.
|
|
755
|
+
|
|
756
|
+
### Rule 7 — no competitor is named doing something badly
|
|
757
|
+
|
|
758
|
+
Name query shapes, habits, and categories. Never a real product. It is a legal exposure and a
|
|
759
|
+
credibility loss in the same line.
|
|
760
|
+
|
|
761
|
+
### Rule 8 — credits go in the post caption, never on a slide
|
|
762
|
+
|
|
763
|
+
An attribution line on the frame is brand chrome, and this format bans brand chrome for the same
|
|
764
|
+
reason the others do. The carousel always has a caption; use it. If a licence forces on-image
|
|
765
|
+
credit, change the asset.
|
|
766
|
+
|
|
767
|
+
### Rule 9 — the bait is in the post caption
|
|
768
|
+
|
|
769
|
+
The deck ends on a tip. The comment prompt, the save prompt and the follow prompt all live in the
|
|
770
|
+
caption where the platform expects them. A slide that says "save this" spends the slot that could
|
|
771
|
+
have held the tip that made them save it.
|
|
772
|
+
|
|
773
|
+
### Rule 10 — production floor
|
|
774
|
+
|
|
775
|
+
1080×1920, 30fps, `N × 3.0s` exactly, hard cuts, one canvas, no letterboxing, text at frame 0 of
|
|
776
|
+
every slide, `data-duration` on the root equal to the sum.
|
|
777
|
+
|
|
778
|
+
---
|
|
779
|
+
|
|
780
|
+
## Cost-saving mode — the whole deck at $0
|
|
781
|
+
|
|
782
|
+
This format is the cheapest in the catalog because it has no footage. In `minimize` the only thing
|
|
783
|
+
that can cost money is a cutout, and every cutout has a free rung.
|
|
784
|
+
|
|
785
|
+
| Ingredient | `minimize` ($0) | `hybrid` (~$0.05) |
|
|
786
|
+
|---|---|---|
|
|
787
|
+
| Canvas + type | Hand-authored HTML. Free, forever | same |
|
|
788
|
+
| Cutouts | `iconscout --free` → `media search --type image` + `remove-background` → `mask` | one `sticker-pack --generate` sheet for the whole deck |
|
|
789
|
+
| Fonts | Self-hosted woff2, already on disk | same |
|
|
790
|
+
| Slides export | `vidfarm stills` — local, in-process | same |
|
|
791
|
+
| Video render | `vidfarm render` local | same |
|
|
792
|
+
| Music bed | `media search --type bgm` (Openverse, keyless) | same |
|
|
793
|
+
| **Total** | **$0** | **~$0.05, one image job** |
|
|
794
|
+
|
|
795
|
+
**In `minimize` + `interactive`, one round trip buys the whole deck's art.** `vidfarm handoff image`
|
|
796
|
+
→ the user runs it in a free web generator → `vidfarm sticker-pack` splits it. Free frontier image
|
|
797
|
+
models are typically better per image than an API budget buys, so this is not the compromise rung —
|
|
798
|
+
it is frequently the best-looking one.
|
|
799
|
+
|
|
800
|
+
**Claude Code has no image model.** `vidfarm agent-image` is free custom art on Antigravity /
|
|
801
|
+
Gemini CLI / Codex, and it is genuinely $0 in `minimize` there. In a Claude session it is not
|
|
802
|
+
available; drop to the handoff rung and say so rather than pretending.
|
|
803
|
+
|
|
804
|
+
---
|
|
805
|
+
|
|
806
|
+
## Bulk generation — this format is built for it
|
|
807
|
+
|
|
808
|
+
A deck is a manifest plus a template. Once one is built, the tenth costs minutes.
|
|
809
|
+
|
|
810
|
+
- **Vary the TOPIC, hold the structure.** "Best eats in NYC" → "…in Austin" → "…on a $15 budget" →
|
|
811
|
+
"…that are open past midnight". Same six slots, same layout, same art class, new tips.
|
|
812
|
+
- **Never reuse a tip across decks in the same account.** A reader who follows you sees both.
|
|
813
|
+
- **The shill slide is the one thing that may repeat verbatim** across a campaign — it is the
|
|
814
|
+
product's mechanism, and it does not get better by being reworded. Hold it constant and vary
|
|
815
|
+
everything else; that also makes it the clean variable when you want to test it.
|
|
816
|
+
- **`vidfarm dedupe` before a re-post**, not before a first post. The MP4 is what a platform
|
|
817
|
+
fingerprints; the slides are not.
|
|
818
|
+
- **Batch the QA.** `vidfarm qa ./decks/* --json` grades N compositions in one pass; Appendix E's
|
|
819
|
+
`slides-qa.py` runs on the exported PNGs, which is where this format's real defects live.
|
|
820
|
+
|
|
821
|
+
Sizing a round, capacity, and the ledger belong to `vidfarm experiment`, not to this file.
|
|
822
|
+
|
|
823
|
+
---
|
|
824
|
+
|
|
825
|
+
## Quality gates — what is ENFORCED, and by what
|
|
826
|
+
|
|
827
|
+
| Gate | Tool | What it can actually see |
|
|
828
|
+
|---|---|---|
|
|
829
|
+
| **1. Composition** | `vidfarm qa ./work --harness ./sticker-slideshow-tips.md` | The DOM: font regime, slop, safe zone, frame 0, the `checks:` block above |
|
|
830
|
+
| **2. The stills** | `slides-qa.py ./slides [--ink light\|dark]` *(Appendix C)* | The exported PNGs: blank slides, canvas drift, band alignment, cutout alpha, the one-shill count |
|
|
831
|
+
| **3. The sequence** | **You**, on `slides/contact-sheet.png` | Whether it reads as one deck. Nothing else can see this |
|
|
832
|
+
| **4. The tips** | **You**, cold | Whether any of it is worth saving. No tool has an opinion here |
|
|
833
|
+
|
|
834
|
+
Gate 1 is free and instant and does not look at pixels. Gate 3 is the one that catches what
|
|
835
|
+
actually ships broken, and it cannot be automated — every first-pass deck in a batch has a
|
|
836
|
+
sequence-level defect its own author called "verified, looks good."
|
|
837
|
+
|
|
838
|
+
### Two findings that are EXPECTED on this format — do not "fix" them
|
|
839
|
+
|
|
840
|
+
Both are correct rules firing on a format they were not written for. Record your answer once and
|
|
841
|
+
move on; chasing either one damages the deck.
|
|
842
|
+
|
|
843
|
+
- **`wall-of-text` on the subtexts** (built-in, gate 1). It warns at 14+ words in one static text
|
|
844
|
+
layer, on the reasoning that a scrolling viewer reads none of it. **That reasoning does not hold
|
|
845
|
+
here** — a carousel reader sets their own dwell time, and the reason line is the thing they came
|
|
846
|
+
for. The fix it suggests (page it into 3–5-word kinetic cues) would destroy the format. Expect it
|
|
847
|
+
on any subtext over ~13 words and leave them alone.
|
|
848
|
+
- **`partial alpha` on a thin-structured cutout** (gate 2). The 8% threshold is calibrated for solid
|
|
849
|
+
blobs. An object made of thin members — a sign on a stand, a railing, cutlery, a chair — has far
|
|
850
|
+
more edge per unit area and lands at 8–12% while being visually perfect. **Judge it on the
|
|
851
|
+
pixels, not the number:** crop the cutout out of the rendered slide and look for a grey rim. A
|
|
852
|
+
real halo is obvious at a glance; antialiasing on thin metal is not.
|
|
853
|
+
|
|
854
|
+
### The failure this format actually ships — the invisible layout collision
|
|
855
|
+
|
|
856
|
+
Worth stating because it survived a clean render, a clean export and a human glance at one slide:
|
|
857
|
+
`.clip { inset: 0 }` must be reset per layer, that reset lives in the **inline** style attribute,
|
|
858
|
+
and an inline `inset:auto` **beats a `top:` in your stylesheet**. Put the band geometry in a CSS
|
|
859
|
+
class and every text layer silently resolves to `top:auto` — headline and subtext stack on top of
|
|
860
|
+
each other at the head of the frame. It renders, it exports, it passes gate 1, and it is unusable.
|
|
861
|
+
**Geometry inline, typography in the class.** Gate 2's *"no headline found in the band"* is the
|
|
862
|
+
check that catches it.
|
|
863
|
+
|
|
864
|
+
---
|
|
865
|
+
|
|
866
|
+
## Pre-flight checklist
|
|
867
|
+
|
|
868
|
+
Run this before you build, and answer it honestly after. An unchecked box is a rewrite, not a fix
|
|
869
|
+
in the edit.
|
|
870
|
+
|
|
871
|
+
**The deck**
|
|
872
|
+
|
|
873
|
+
- [ ] Slide 1 is a COVER and says "Tips for…" (or equivalent) literally — no clever hook.
|
|
874
|
+
- [ ] The cover carries a hero picture and a NUMBER, and there is only one cover.
|
|
875
|
+
- [ ] The topic is ALSO in the post caption, which is where the bait and any credits live.
|
|
876
|
+
- [ ] N is between 5 and 9 (cover included), and every slide is exactly 3.0s.
|
|
877
|
+
- [ ] Slide 2 is the strongest tip in the deck, not the easiest one to illustrate.
|
|
878
|
+
- [ ] Shuffling two middle tips breaks nothing.
|
|
879
|
+
|
|
880
|
+
**The tips**
|
|
881
|
+
|
|
882
|
+
- [ ] Every headline is one imperative sentence, ≤16 words, ≤3 lines, hand-broken.
|
|
883
|
+
- [ ] Every tip is on rung 3 or 4 of the specificity ladder.
|
|
884
|
+
- [ ] No headline is a question, and none contains a hedge.
|
|
885
|
+
- [ ] Every subtext gives a REASON and does not restate its headline.
|
|
886
|
+
- [ ] Every number on every slide is one you would defend if challenged.
|
|
887
|
+
- [ ] No competitor is named.
|
|
888
|
+
|
|
889
|
+
**The shill**
|
|
890
|
+
|
|
891
|
+
- [ ] It sits in the MIDDLE: at least two real tips before it and two after it, cover excluded.
|
|
892
|
+
- [ ] It is the only slide naming the offer.
|
|
893
|
+
- [ ] With the brand name covered, it is still one of the two best tips in the deck.
|
|
894
|
+
- [ ] It states a MECHANISM the reader could follow with no product at all.
|
|
895
|
+
- [ ] The brand is lowercase, in the subtext, in the 500 weight, named once.
|
|
896
|
+
- [ ] Slide 3 is visually indistinguishable from the others — same layout, type, colour, art class.
|
|
897
|
+
|
|
898
|
+
**The pictures**
|
|
899
|
+
|
|
900
|
+
- [ ] One art class, declared in the build log before sourcing.
|
|
901
|
+
- [ ] Every cutout is the tip's noun. Covering the text, a stranger could roughly guess each slide.
|
|
902
|
+
- [ ] 1–3 cutouts per slide, plus at most one hand-drawn mark.
|
|
903
|
+
- [ ] At least one all-type slide, if any tip lacks a physical noun.
|
|
904
|
+
- [ ] Every cutout's alpha is mostly 0-or-255. No grey halo on the white page.
|
|
905
|
+
- [ ] Objects are rotated ±3–8°, none overlapping another's silhouette.
|
|
906
|
+
- [ ] One light direction, or no contact shadows at all.
|
|
907
|
+
- [ ] Every required credit is in the post caption. Nothing attributive is on a slide.
|
|
908
|
+
|
|
909
|
+
**The page**
|
|
910
|
+
|
|
911
|
+
- [ ] ONE background mode across the whole deck — never per slide.
|
|
912
|
+
- [ ] Photo mode: every text band measured at ≥4.5:1, and you know which slide is the weakest.
|
|
913
|
+
- [ ] Photo mode: every background is the PLACE its tip is about, not a mood.
|
|
914
|
+
- [ ] Photo mode: hand-drawn marks use `{ink}`; photographic cutouts are never recoloured.
|
|
915
|
+
- [ ] Photo mode: the blur is on the image and the dim is a separate flat layer (no `modal-scrim`).
|
|
916
|
+
- [ ] Paper mode: one canvas colour across the deck. No footage, gradient, texture, border or card.
|
|
917
|
+
- [ ] Headline band starts at 14% on every slide, whatever its line count.
|
|
918
|
+
- [ ] Nothing in the top 14% or bottom 20%.
|
|
919
|
+
- [ ] Two weights of ONE self-hosted family, 800 and 500, no fallback chain in the stack.
|
|
920
|
+
- [ ] 2–4 emphasised runs per headline, weight-only, verb and noun.
|
|
921
|
+
- [ ] No stroke, no text shadow, no plate behind any text.
|
|
922
|
+
|
|
923
|
+
**The exports**
|
|
924
|
+
|
|
925
|
+
- [ ] Frame 0 is slide 1, fully settled, headline legible — it is the thumbnail AND the cover.
|
|
926
|
+
- [ ] No entrance transition on slide 1; no transition between any slides.
|
|
927
|
+
- [ ] N PNGs exported at start+1.5s, each one complete on its own.
|
|
928
|
+
- [ ] Slides uploaded to DURABLE My Files, ordered, recorded in `slides.json`.
|
|
929
|
+
- [ ] The MP4 is `N × 3.0s` and the cuts land on the second.
|
|
930
|
+
- [ ] The submission says the carousel is the deliverable and the MP4 is the preview.
|
|
931
|
+
|
|
932
|
+
---
|
|
933
|
+
|
|
934
|
+
## The whole-deck review — do this last, on the contact sheet
|
|
935
|
+
|
|
936
|
+
Gates 1 and 2 grade slides. This grades the deck, and it is the only pass that catches what
|
|
937
|
+
actually ships broken. **Open `slides/contact-sheet.png` and read it as one image.**
|
|
938
|
+
|
|
939
|
+
- [ ] **Does the headline sit at the same height on all N slides?** The wobble is invisible one
|
|
940
|
+
slide at a time and obvious on the sheet.
|
|
941
|
+
- [ ] **How many type sizes are there?** There should be two. Count them.
|
|
942
|
+
- [ ] **Do the cutouts look like one set?** Same class, same rendering, same light, plausible
|
|
943
|
+
relative scale.
|
|
944
|
+
- [ ] **Is the canvas the same white on all N?** A cutout with a near-white background rectangle
|
|
945
|
+
makes its slide read a different colour and only shows up beside its neighbours.
|
|
946
|
+
- [ ] **Which slide is the weakest?** There is one. Either rewrite it or delete it — a five-slide
|
|
947
|
+
deck with no weak slide beats a six-slide deck with one.
|
|
948
|
+
- [ ] **Can you tell which slide is the ad?** If your eye goes to slide 3 before you read it, it is
|
|
949
|
+
styled differently and Rule 1 is broken.
|
|
950
|
+
- [ ] **Would you save this?** Not "is it correct". Would you save it.
|
|
951
|
+
|
|
952
|
+
Then watch the MP4 end to end at 1× with sound off, which is how it will be seen.
|
|
953
|
+
|
|
954
|
+
---
|
|
955
|
+
|
|
956
|
+
## Diagnosing a flop
|
|
957
|
+
|
|
958
|
+
Diagnose by slot, never by declaring the deck bad.
|
|
959
|
+
|
|
960
|
+
| Symptom | The weak slot | The fix |
|
|
961
|
+
|---|---|---|
|
|
962
|
+
| Almost no impressions | **Slide 1 / the thumbnail** | Slide 1 is not your best tip, or its headline is unreadable at feed scale. Swap in the strongest one |
|
|
963
|
+
| Views, drop-off at slide 2–3 | **The shill** | It reads as an ad. Cover the brand: if the slide is now weak, rewrite it as a tip |
|
|
964
|
+
| Swiped to the end, no saves | **The tips** | They are on rung 1–2. Add a mechanism and a number to each |
|
|
965
|
+
| Saves, no comments | **The caption** | The bait is missing or generic. It lives in the caption, so fix the caption, not the deck |
|
|
966
|
+
| Good numbers, no clicks to the offer | **Nothing is broken** | This format seeds a mechanism; it does not close. Judge it on saves and branded search, not clicks |
|
|
967
|
+
| "Looks AI-made" in the comments | **The sequence** | Almost always the band wobble, mixed art classes, or a stroke on the type. Contact sheet, gate 3 |
|
|
968
|
+
|
|
969
|
+
---
|
|
970
|
+
|
|
971
|
+
## Appendix A — `build-deck.py` (a deck from a manifest)
|
|
972
|
+
|
|
973
|
+
Builds the whole composition from a TSV. The manifest is the artifact you iterate on; the HTML is
|
|
974
|
+
generated and should never be hand-edited after the first run.
|
|
975
|
+
|
|
976
|
+
```
|
|
977
|
+
# slides.tsv — one line per slide, TAB separated. '#' comments ignored.
|
|
978
|
+
# headline (use *asterisks* for the 800-weight runs, | for a hand line break)
|
|
979
|
+
# \t subtext (| for a line break, empty for none)
|
|
980
|
+
# \t cutouts: file@left%,top%,width%,rotate ; ... (empty for an all-type slide)
|
|
981
|
+
*Never* eat on the block with the|*menus outside*. If someone has to wave you in,|the food is not doing it. media/cutouts/menu-board.png@34,38,32,-5
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
```python
|
|
985
|
+
#!/usr/bin/env python3
|
|
986
|
+
"""
|
|
987
|
+
Build a sticker-slideshow-tips composition from a TSV manifest.
|
|
988
|
+
|
|
989
|
+
Four things this exists to get right:
|
|
990
|
+
|
|
991
|
+
* LAYERS ARE FLAT. Every layer is a direct child of #root carrying its own
|
|
992
|
+
data-start / data-duration / data-layer-kind. A "slide" is not a container —
|
|
993
|
+
it is just the set of layers that share a start time. Wrapping a slide's
|
|
994
|
+
layers in an outer div.clip renders correctly and is INVISIBLE to the
|
|
995
|
+
toolchain: `vidfarm qa` reports scenes:0, and first_frame_visual /
|
|
996
|
+
first_frame_text then fail on a composition whose frame 0 is actually fine.
|
|
997
|
+
* THE BANDS DO NOT MOVE. Every headline is pinned to the same top, whatever
|
|
998
|
+
its line count. Vertically centring per slide is the "wobble" that only
|
|
999
|
+
shows up on the contact sheet (Rule 3).
|
|
1000
|
+
* GEOMETRY IS INLINE, TYPOGRAPHY IS IN THE CLASS. `.clip{inset:0}` has to be
|
|
1001
|
+
reset per layer with `inset:auto`, that reset lives in the inline style
|
|
1002
|
+
attribute, and inline BEATS the stylesheet — so a `top:` in a CSS class
|
|
1003
|
+
resolves to `top:auto` and the subtext silently stacks on the headline.
|
|
1004
|
+
* THE BLUR AND THE DIM ARE DIFFERENT ELEMENTS. A single backdrop that is both
|
|
1005
|
+
blurred and dimmed is the website-modal staging pattern (`vidfarm qa` →
|
|
1006
|
+
modal-scrim). Blur the photo on the <img>; carry the dim on a separate flat
|
|
1007
|
+
colour layer above it. Same look, and it is not modal staging.
|
|
1008
|
+
|
|
1009
|
+
Background modes (--bg-mode):
|
|
1010
|
+
|
|
1011
|
+
paper flat near-white canvas, dark text, NO shadow (the reference)
|
|
1012
|
+
photo-darken photo, blurred + darkened, WHITE text
|
|
1013
|
+
photo-lighten photo, blurred + lightened, DARK text
|
|
1014
|
+
photo-gray photo, blurred + desaturated under a neutral wash, WHITE text
|
|
1015
|
+
|
|
1016
|
+
Any photo mode needs a 4th TSV column naming that slide's background image.
|
|
1017
|
+
|
|
1018
|
+
usage: build-deck.py <slides.tsv> <out.html> [--title=…] [--canvas=#FDFCFA]
|
|
1019
|
+
[--bg-mode=paper|photo-darken|photo-lighten|photo-gray]
|
|
1020
|
+
[--head-px=70] [--sub-px=40]
|
|
1021
|
+
"""
|
|
1022
|
+
import html, sys
|
|
1023
|
+
|
|
1024
|
+
SLIDE_SEC = 3.0
|
|
1025
|
+
W, H = 1080, 1920
|
|
1026
|
+
BANDS = dict(head_top=14.0, cut_top=34.0, cut_bot=66.0, sub_top=73.0)
|
|
1027
|
+
|
|
1028
|
+
# Each mode is a complete legibility contract: what happens to the photo, what
|
|
1029
|
+
# flat wash sits over it, what colour the text is, and what that text needs to
|
|
1030
|
+
# survive the picture. They are tuned together — changing one value alone is how
|
|
1031
|
+
# a deck ends up with 2.9:1 headlines that "looked fine" on one slide.
|
|
1032
|
+
MODES = {
|
|
1033
|
+
"paper": dict(
|
|
1034
|
+
filter=None, wash=None, ink="#111111",
|
|
1035
|
+
# On a flat page the contrast is already maximal and a shadow is the
|
|
1036
|
+
# thing that makes type read as machine-made (Rule 5).
|
|
1037
|
+
shadow="none", cut_shadow="none"),
|
|
1038
|
+
"photo-darken": dict(
|
|
1039
|
+
filter="blur(18px) brightness(0.38) saturate(0.90)",
|
|
1040
|
+
wash="linear-gradient(180deg,rgba(0,0,0,0.62) 0%,rgba(0,0,0,0.62) 32%,rgba(0,0,0,0.22) 44%,rgba(0,0,0,0.22) 64%,rgba(0,0,0,0.62) 72%,rgba(0,0,0,0.62) 100%)", ink="#FFFFFF",
|
|
1041
|
+
shadow="0 2px 18px rgba(0,0,0,0.55)",
|
|
1042
|
+
cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.55))"),
|
|
1043
|
+
"photo-lighten": dict(
|
|
1044
|
+
filter="blur(18px) brightness(1.62) saturate(0.42)",
|
|
1045
|
+
wash="linear-gradient(180deg,rgba(255,255,255,0.78) 0%,rgba(255,255,255,0.78) 32%,rgba(255,255,255,0.46) 44%,rgba(255,255,255,0.46) 64%,rgba(255,255,255,0.78) 72%,rgba(255,255,255,0.78) 100%)", ink="#111111",
|
|
1046
|
+
shadow="0 2px 18px rgba(255,255,255,0.75)",
|
|
1047
|
+
cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.30))"),
|
|
1048
|
+
"photo-gray": dict(
|
|
1049
|
+
filter="blur(14px) grayscale(1) brightness(0.48)",
|
|
1050
|
+
wash="linear-gradient(180deg,rgba(18,18,20,0.64) 0%,rgba(18,18,20,0.64) 32%,rgba(18,18,20,0.18) 44%,rgba(18,18,20,0.18) 64%,rgba(18,18,20,0.64) 72%,rgba(18,18,20,0.64) 100%)", ink="#FFFFFF",
|
|
1051
|
+
shadow="0 2px 18px rgba(0,0,0,0.50)",
|
|
1052
|
+
cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.50))"),
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
1056
|
+
opts = dict(a.split("=", 1) for a in sys.argv[1:] if a.startswith("--") and "=" in a)
|
|
1057
|
+
manifest, out = args[0], args[1]
|
|
1058
|
+
canvas = opts.get("--canvas", "#FDFCFA")
|
|
1059
|
+
title = opts.get("--title", "tip deck")
|
|
1060
|
+
head_px = int(opts.get("--head-px", 70))
|
|
1061
|
+
sub_px = int(opts.get("--sub-px", 40))
|
|
1062
|
+
bg_mode = opts.get("--bg-mode", "paper")
|
|
1063
|
+
if bg_mode not in MODES:
|
|
1064
|
+
sys.exit(f"unknown --bg-mode {bg_mode!r}; pick one of {', '.join(MODES)}")
|
|
1065
|
+
M = MODES[bg_mode]
|
|
1066
|
+
|
|
1067
|
+
rows = []
|
|
1068
|
+
for raw in open(manifest):
|
|
1069
|
+
raw = raw.rstrip("\n")
|
|
1070
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
1071
|
+
continue
|
|
1072
|
+
p = raw.split("\t")
|
|
1073
|
+
rows.append(tuple((p[i].strip() if len(p) > i else "") for i in range(4)))
|
|
1074
|
+
|
|
1075
|
+
if not 5 <= len(rows) <= 9:
|
|
1076
|
+
print(f"⚠ {len(rows)} slides — the harness range is 5-9", file=sys.stderr)
|
|
1077
|
+
if M["filter"] and any(not r[3] for r in rows):
|
|
1078
|
+
missing = [i + 1 for i, r in enumerate(rows) if not r[3]]
|
|
1079
|
+
sys.exit(f"--bg-mode {bg_mode} needs a background image per slide; "
|
|
1080
|
+
f"column 4 is empty on slide(s) {missing}")
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def rich(s):
|
|
1084
|
+
"""*bold* -> 800 weight; | -> a hand line break. Escape everything else."""
|
|
1085
|
+
s = html.escape(s)
|
|
1086
|
+
return "".join(f"<b>{c}</b>" if i % 2 else c
|
|
1087
|
+
for i, c in enumerate(s.split("*"))).replace("|", "<br>")
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
CSS = f"""
|
|
1091
|
+
/* Self-hosted, NO fallback chain. A font that fails to fetch would silently
|
|
1092
|
+
substitute and the deck would quietly stop looking like itself. Two weights
|
|
1093
|
+
of one family is the entire regime (Typography DNA). */
|
|
1094
|
+
@font-face{{font-family:'Deck';font-weight:800;font-display:block;
|
|
1095
|
+
src:url('media/fonts/deck-800.woff2') format('woff2')}}
|
|
1096
|
+
@font-face{{font-family:'Deck';font-weight:500;font-display:block;
|
|
1097
|
+
src:url('media/fonts/deck-500.woff2') format('woff2')}}
|
|
1098
|
+
body{{margin:0;background:{canvas}}}
|
|
1099
|
+
#root{{position:relative;width:{W}px;height:{H}px;overflow:hidden;background:{canvas}}}
|
|
1100
|
+
.clip{{position:absolute;inset:0}}
|
|
1101
|
+
|
|
1102
|
+
/* THE BANDS are set INLINE, not here — see the module docstring. Geometry
|
|
1103
|
+
inline, typography in the class. */
|
|
1104
|
+
.head{{font-family:'Deck';font-weight:500;font-size:{head_px}px;line-height:1.22;
|
|
1105
|
+
letter-spacing:-0.01em;color:{M['ink']};text-align:center;
|
|
1106
|
+
text-shadow:{M['shadow']}}}
|
|
1107
|
+
.head b{{font-weight:800}}
|
|
1108
|
+
.sub{{font-family:'Deck';font-weight:500;font-size:{sub_px}px;line-height:1.34;
|
|
1109
|
+
color:{M['ink']};text-align:center;text-shadow:{M['shadow']}}}
|
|
1110
|
+
.cut{{object-fit:contain;filter:{M['cut_shadow']}}}
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
/* The photo is scaled past the frame so the blur's own soft edge is cropped
|
|
1114
|
+
off — a blurred image at exactly 100% shows a pale vignette on all four
|
|
1115
|
+
sides, which reads as a rendering bug rather than a background. */
|
|
1116
|
+
.bg{{width:110%;height:110%;left:-5%;top:-5%;object-fit:cover;filter:{M['filter'] or 'none'}}}
|
|
1117
|
+
.wash{{background:{M['wash'] or 'transparent'}}}
|
|
1118
|
+
"""
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def layer(kind, idx, start, cls, style, inner="", tag="div", extra="", label=""):
|
|
1122
|
+
"""One FLAT layer: a direct child of #root, never nested inside another clip."""
|
|
1123
|
+
common = (f'class="clip {cls}" data-hf-id="{idx}" data-layer-mode="publish" '
|
|
1124
|
+
f'data-layer-kind="{kind}" data-start="{start}" '
|
|
1125
|
+
f'data-duration="{SLIDE_SEC}" data-end="{round(start+SLIDE_SEC,3)}" '
|
|
1126
|
+
f'data-label="{html.escape(label)}"')
|
|
1127
|
+
if tag == "img":
|
|
1128
|
+
return f' <img id="{idx}" {common} {extra} style="{style}">'
|
|
1129
|
+
return f' <div id="{idx}" {common} style="{style}">{inner}</div>'
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
layers = []
|
|
1133
|
+
for i, (head, sub, cuts, bg) in enumerate(rows):
|
|
1134
|
+
t = round(i * SLIDE_SEC, 3)
|
|
1135
|
+
n = i + 1
|
|
1136
|
+
if M["filter"] and bg:
|
|
1137
|
+
layers.append(layer("image", f"s{n:02d}-bg", t, "bg",
|
|
1138
|
+
"inset:auto;z-index:0", tag="img",
|
|
1139
|
+
extra=f'src="{html.escape(bg)}"',
|
|
1140
|
+
label=f"slide {n} background"))
|
|
1141
|
+
if M["wash"]:
|
|
1142
|
+
layers.append(layer("shape", f"s{n:02d}-wash", t, "wash",
|
|
1143
|
+
"inset:0;z-index:1", label=f"slide {n} wash"))
|
|
1144
|
+
layers.append(layer("text", f"s{n:02d}-head", t, "head",
|
|
1145
|
+
f"inset:auto;left:8%;width:84%;top:{BANDS['head_top']}%;z-index:3",
|
|
1146
|
+
rich(head), label=f"slide {n} headline"))
|
|
1147
|
+
if sub:
|
|
1148
|
+
layers.append(layer("text", f"s{n:02d}-sub", t, "sub",
|
|
1149
|
+
f"inset:auto;left:12%;width:76%;top:{BANDS['sub_top']}%;z-index:3",
|
|
1150
|
+
rich(sub), label=f"slide {n} subtext"))
|
|
1151
|
+
for j, spec in enumerate(filter(None, (c.strip() for c in cuts.split(";")))):
|
|
1152
|
+
src, geo = spec.split("@")
|
|
1153
|
+
src = src.replace("{ink}", "light" if M["ink"] == "#FFFFFF" else "dark")
|
|
1154
|
+
left, top, width, rot = (geo.split(",") + ["0"])[:4]
|
|
1155
|
+
if not BANDS["cut_top"] - 2 <= float(top) <= BANDS["cut_bot"]:
|
|
1156
|
+
print(f"⚠ slide {n}: cutout top {top}% is outside the "
|
|
1157
|
+
f"{BANDS['cut_top']}-{BANDS['cut_bot']}% band", file=sys.stderr)
|
|
1158
|
+
layers.append(layer(
|
|
1159
|
+
"image", f"s{n:02d}-cut{j+1}", t, "cut",
|
|
1160
|
+
f"inset:auto;left:{left}%;top:{top}%;width:{width}%;height:auto;"
|
|
1161
|
+
f"transform:rotate({rot}deg);z-index:2",
|
|
1162
|
+
tag="img", extra=f'src="{html.escape(src)}"',
|
|
1163
|
+
label=f"slide {n} cutout {j+1}"))
|
|
1164
|
+
|
|
1165
|
+
total = round(len(rows) * SLIDE_SEC, 3)
|
|
1166
|
+
open(out, "w").write(f"""<!doctype html>
|
|
1167
|
+
<html lang="en"><head><meta charset="UTF-8">
|
|
1168
|
+
<meta name="viewport" content="width={W}, height={H}">
|
|
1169
|
+
<title>{html.escape(title)}</title>
|
|
1170
|
+
<style>{CSS}</style></head><body>
|
|
1171
|
+
<div id="root" data-composition-id="tip_deck" data-start="0"
|
|
1172
|
+
data-width="{W}" data-height="{H}" data-duration="{total}">
|
|
1173
|
+
{chr(10).join(layers)}
|
|
1174
|
+
</div></body></html>
|
|
1175
|
+
""")
|
|
1176
|
+
print(f"{len(rows)} slides · {total}s · {len(layers)} flat layers · "
|
|
1177
|
+
f"bg-mode {bg_mode} · ink {M['ink']} · {out}")
|
|
1178
|
+
print("stills at: " + ",".join(str(round(i * SLIDE_SEC + 1.5, 1)) for i in range(len(rows))))
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
## Appendix B — `export-deck.sh` (both deliverables from one composition)
|
|
1182
|
+
|
|
1183
|
+
```bash
|
|
1184
|
+
#!/usr/bin/env bash
|
|
1185
|
+
# Export the carousel (the deliverable) and the MP4 (the preview) from one composition.
|
|
1186
|
+
# usage: export-deck.sh <work-dir> <n-slides> <folder-slug>
|
|
1187
|
+
set -euo pipefail
|
|
1188
|
+
DIR="$1"; N="$2"; SLUG="$3"
|
|
1189
|
+
|
|
1190
|
+
# Stills at start+1.5s of each 3.0s slide — the SETTLED state, never the entrance.
|
|
1191
|
+
AT=$(python3 -c "print(','.join(str(i*3.0+1.5) for i in range($N)))")
|
|
1192
|
+
|
|
1193
|
+
vidfarm stills "$DIR" --at "$AT" --out "$DIR/slides" --sheet
|
|
1194
|
+
echo "▶ OPEN $DIR/slides/contact-sheet.png AND READ IT AS ONE IMAGE — this is gate 3."
|
|
1195
|
+
|
|
1196
|
+
# Rename to an explicit order. The order is DATA: slide 3 being the shill is load-bearing.
|
|
1197
|
+
i=1; for f in "$DIR"/slides/*.png; do
|
|
1198
|
+
case "$f" in *contact-sheet*) continue;; esac
|
|
1199
|
+
mv "$f" "$DIR/slides/slide-$(printf %02d $i).png"; i=$((i+1))
|
|
1200
|
+
done
|
|
1201
|
+
|
|
1202
|
+
# DURABLE My Files — the temp store deletes after 30 days and the carousel would 404.
|
|
1203
|
+
: > "$DIR/slides.json"; echo "[" >> "$DIR/slides.json"
|
|
1204
|
+
for f in "$DIR"/slides/slide-*.png; do
|
|
1205
|
+
URL=$(vidfarm put-file "$f" --folder "decks/$SLUG" \
|
|
1206
|
+
--notes "$SLUG tip deck — $(basename "$f")" --json | python3 -c \
|
|
1207
|
+
'import json,sys; print(json.load(sys.stdin).get("viewUrl",""))')
|
|
1208
|
+
echo " \"$URL\"," >> "$DIR/slides.json"
|
|
1209
|
+
done
|
|
1210
|
+
sed -i '' '$ s/,$//' "$DIR/slides.json"; echo "]" >> "$DIR/slides.json"
|
|
1211
|
+
|
|
1212
|
+
# The preview copy. Same composition, no second build.
|
|
1213
|
+
vidfarm render "$DIR" --wait
|
|
1214
|
+
|
|
1215
|
+
echo "carousel: $DIR/slides.json ($N ordered durable URLs)"
|
|
1216
|
+
echo "preview : $DIR/final.mp4"
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
## Appendix C — `slides-qa.py` (gate 2: enforce it on the EXPORTED PNGs)
|
|
1220
|
+
|
|
1221
|
+
`vidfarm qa` reads the DOM and cannot see a grey halo, a drifting canvas or a wobbling band. This
|
|
1222
|
+
runs on the pixels that ship.
|
|
1223
|
+
|
|
1224
|
+
```bash
|
|
1225
|
+
python3 slides-qa.py ./work/slides --brand dishcover.io --shill-slide 3
|
|
1226
|
+
```
|
|
1227
|
+
|
|
1228
|
+
```python
|
|
1229
|
+
#!/usr/bin/env python3
|
|
1230
|
+
"""
|
|
1231
|
+
Grade an exported tip deck on the PNGs, not on the markup.
|
|
1232
|
+
|
|
1233
|
+
Six checks, each one a defect that passes every DOM check and every per-slide
|
|
1234
|
+
eyeball, and shows up only across the set:
|
|
1235
|
+
|
|
1236
|
+
1. no slide is blank — an entrance that had not settled at +1.5s
|
|
1237
|
+
2. the canvas is ONE colour — a cutout with a near-white background plate
|
|
1238
|
+
makes its slide read a different white, and
|
|
1239
|
+
that is only visible beside its neighbours
|
|
1240
|
+
3. the headline band is level — the vertical wobble of Rule 3
|
|
1241
|
+
4. nothing in the reserved zones— top 14% / bottom 20% belong to the platform
|
|
1242
|
+
5. exactly ONE slide names the — Rule 1, checked against the deck's text
|
|
1243
|
+
brand manifest rather than by OCR
|
|
1244
|
+
6. every cutout's alpha is
|
|
1245
|
+
mostly 0-or-255 — run separately on media/cutouts/*.png
|
|
1246
|
+
|
|
1247
|
+
usage: slides-qa.py <slides-dir> [--brand <token>] [--shill-slide <n>] [--ink light|dark]
|
|
1248
|
+
[--manifest <slides.tsv>] [--cutouts <dir>]
|
|
1249
|
+
"""
|
|
1250
|
+
import glob, os, subprocess, sys
|
|
1251
|
+
|
|
1252
|
+
d = sys.argv[1]
|
|
1253
|
+
opt = dict(zip(sys.argv[2::2], sys.argv[3::2]))
|
|
1254
|
+
brand = opt.get("--brand")
|
|
1255
|
+
shill = int(opt.get("--shill-slide", 3))
|
|
1256
|
+
manifest = opt.get("--manifest")
|
|
1257
|
+
cutdir = opt.get("--cutouts")
|
|
1258
|
+
ink = opt.get("--ink") # "light" | "dark" — the mode's text colour
|
|
1259
|
+
|
|
1260
|
+
fails, warns = [], []
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def probe(png, crop):
|
|
1264
|
+
"""Mean RGB of a crop, via ffmpeg — no PIL dependency."""
|
|
1265
|
+
raw = subprocess.run(
|
|
1266
|
+
["ffmpeg", "-v", "error", "-i", png, "-vf", f"crop={crop},scale=1:1",
|
|
1267
|
+
"-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
|
1268
|
+
capture_output=True).stdout
|
|
1269
|
+
return tuple(raw[:3]) if len(raw) >= 3 else (0, 0, 0)
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def rowdark(png, y, h, w=1080):
|
|
1273
|
+
"""Fraction of ink in a horizontal strip — how we find the headline."""
|
|
1274
|
+
raw = subprocess.run(
|
|
1275
|
+
["ffmpeg", "-v", "error", "-i", png, "-vf",
|
|
1276
|
+
f"crop={w}:{h}:0:{y},format=gray,scale=64:1", "-frames:v", "1",
|
|
1277
|
+
"-f", "rawvideo", "-pix_fmt", "gray", "-"],
|
|
1278
|
+
capture_output=True).stdout
|
|
1279
|
+
return sum(1 for b in raw if b < 140) / max(len(raw), 1)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
slides = sorted(f for f in glob.glob(os.path.join(d, "*.png"))
|
|
1283
|
+
if "contact-sheet" not in f)
|
|
1284
|
+
if not slides:
|
|
1285
|
+
sys.exit("no slides found")
|
|
1286
|
+
if not 5 <= len(slides) <= 9:
|
|
1287
|
+
warns.append(f"{len(slides)} slides — the harness range is 5-9")
|
|
1288
|
+
|
|
1289
|
+
# ---------------------------------------------- 1 + 2. blank slides, canvas drift
|
|
1290
|
+
canvases = []
|
|
1291
|
+
for s in slides:
|
|
1292
|
+
c = probe(s, "40:40:20:20") # a corner, always canvas
|
|
1293
|
+
canvases.append(c)
|
|
1294
|
+
# a slide whose whole frame equals its corner has nothing on it
|
|
1295
|
+
mid = probe(s, "600:600:240:520")
|
|
1296
|
+
if max(abs(a - b) for a, b in zip(c, mid)) < 4:
|
|
1297
|
+
fails.append(f"{os.path.basename(s)}: BLANK — nothing on the slide at +1.5s")
|
|
1298
|
+
base = canvases[0]
|
|
1299
|
+
# PAPER MODE ONLY. A photo deck is SUPPOSED to change picture every slide, and
|
|
1300
|
+
# the same goes for the two checks below: the headline detector finds photo
|
|
1301
|
+
# detail rather than glyphs, and "ink in the reserved zone" is true of every
|
|
1302
|
+
# full-bleed photograph. On a photo deck the contrast gate replaces all three.
|
|
1303
|
+
if not ink:
|
|
1304
|
+
for s, c in zip(slides, canvases):
|
|
1305
|
+
if max(abs(a - b) for a, b in zip(base, c)) > 3:
|
|
1306
|
+
fails.append(f"{os.path.basename(s)}: canvas {c} drifts from {base}")
|
|
1307
|
+
|
|
1308
|
+
# --------------------------------------------------- 3. the headline band is level
|
|
1309
|
+
tops = []
|
|
1310
|
+
for s in (slides if not ink else []):
|
|
1311
|
+
top = None
|
|
1312
|
+
for y in range(200, 700, 12): # scan the headline band, 10%-36%
|
|
1313
|
+
if rowdark(s, y, 12) > 0.06:
|
|
1314
|
+
top = y
|
|
1315
|
+
break
|
|
1316
|
+
tops.append(top)
|
|
1317
|
+
if top is None:
|
|
1318
|
+
fails.append(f"{os.path.basename(s)}: no headline found in the band")
|
|
1319
|
+
known = [t for t in tops if t is not None]
|
|
1320
|
+
if known and max(known) - min(known) > 24: # >24px = ~1.25% of canvas height
|
|
1321
|
+
warns.append(f"headline band WOBBLES: tops span {min(known)}-{max(known)}px. "
|
|
1322
|
+
f"Pin every headline to the same top (Rule 3)")
|
|
1323
|
+
|
|
1324
|
+
# ------------------------------------------------------- 4. the reserved zones
|
|
1325
|
+
for s in (slides if not ink else []):
|
|
1326
|
+
if rowdark(s, 0, 240) > 0.02:
|
|
1327
|
+
warns.append(f"{os.path.basename(s)}: ink in the top 14% (platform chrome)")
|
|
1328
|
+
if rowdark(s, 1536, 384) > 0.02:
|
|
1329
|
+
warns.append(f"{os.path.basename(s)}: ink in the bottom 20% (caption/buttons)")
|
|
1330
|
+
|
|
1331
|
+
# ------------------------------------------------------------ 5. exactly ONE shill
|
|
1332
|
+
if brand and manifest:
|
|
1333
|
+
hits = [i + 1 for i, line in enumerate(
|
|
1334
|
+
l for l in open(manifest) if l.strip() and not l.lstrip().startswith("#"))
|
|
1335
|
+
if brand.lower() in line.lower()]
|
|
1336
|
+
if hits != [shill]:
|
|
1337
|
+
fails.append(f"brand '{brand}' appears on slides {hits or 'none'} — "
|
|
1338
|
+
f"Rule 1 requires exactly [{shill}]")
|
|
1339
|
+
|
|
1340
|
+
# ------------------------------------------------- 5b. TEXT CONTRAST (readability)
|
|
1341
|
+
# The one thing a photo background can silently destroy. Measuring beats looking:
|
|
1342
|
+
# a headline that reads fine on slide 2's dark doorway can vanish on slide 5's
|
|
1343
|
+
# bright tiled wall, and you only notice on the slide you did not open.
|
|
1344
|
+
#
|
|
1345
|
+
# The background is recovered by BLURRING the band hard enough to erase the
|
|
1346
|
+
# glyphs — a boxblur wider than the stroke leaves the backdrop and nothing else —
|
|
1347
|
+
# then the WORST patch of that backdrop is compared against the known ink colour.
|
|
1348
|
+
# Worst case for light ink is the brightest patch; for dark ink, the darkest.
|
|
1349
|
+
def _lin(v):
|
|
1350
|
+
c = v / 255.0
|
|
1351
|
+
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def band_contrast(png, y, h, ink_is_light):
|
|
1355
|
+
raw = subprocess.run(
|
|
1356
|
+
["ffmpeg", "-v", "error", "-i", png, "-vf",
|
|
1357
|
+
f"crop=1080:{h}:0:{y},format=gray,boxblur=24:2,scale=48:8",
|
|
1358
|
+
"-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "gray", "-"],
|
|
1359
|
+
capture_output=True).stdout
|
|
1360
|
+
if not raw:
|
|
1361
|
+
return None
|
|
1362
|
+
vals = sorted(_lin(b) for b in raw)
|
|
1363
|
+
L_ink = 1.0 if ink_is_light else _lin(0x11)
|
|
1364
|
+
# 90th / 10th percentile rather than the absolute extreme: one stray blurred
|
|
1365
|
+
# highlight is not where the text sits, and grading against it fails a deck
|
|
1366
|
+
# that is genuinely fine.
|
|
1367
|
+
L_bg = vals[int(len(vals) * 0.90)] if ink_is_light else vals[int(len(vals) * 0.10)]
|
|
1368
|
+
hi, lo = max(L_ink, L_bg), min(L_ink, L_bg)
|
|
1369
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
if ink:
|
|
1373
|
+
light = ink == "light"
|
|
1374
|
+
for s in slides:
|
|
1375
|
+
for name, y, h in (("headline", 260, 340), ("subtext", 1395, 175)):
|
|
1376
|
+
r = band_contrast(s, y, h, light)
|
|
1377
|
+
if r is None:
|
|
1378
|
+
continue
|
|
1379
|
+
if r < 4.5:
|
|
1380
|
+
fails.append(f"{os.path.basename(s)}: {name} contrast {r:.1f}:1 — "
|
|
1381
|
+
f"unreadable (WCAG AA needs 4.5:1). Deepen the wash "
|
|
1382
|
+
f"or switch polarity")
|
|
1383
|
+
elif r < 7.0:
|
|
1384
|
+
warns.append(f"{os.path.basename(s)}: {name} contrast {r:.1f}:1 — "
|
|
1385
|
+
f"passes AA but thin for arm's-length reading")
|
|
1386
|
+
|
|
1387
|
+
# ------------------------------------------------------------ 6. cutout alpha health
|
|
1388
|
+
if cutdir:
|
|
1389
|
+
for png in sorted(glob.glob(os.path.join(cutdir, "*.png"))):
|
|
1390
|
+
raw = subprocess.run(
|
|
1391
|
+
["ffmpeg", "-v", "error", "-i", png, "-vf",
|
|
1392
|
+
"format=rgba,alphaextract,scale=64:64", "-frames:v", "1",
|
|
1393
|
+
"-f", "rawvideo", "-pix_fmt", "gray", "-"],
|
|
1394
|
+
capture_output=True).stdout
|
|
1395
|
+
if not raw:
|
|
1396
|
+
continue
|
|
1397
|
+
partial = sum(1 for b in raw if 8 < b < 247) / len(raw)
|
|
1398
|
+
if partial > 0.08:
|
|
1399
|
+
fails.append(f"{os.path.basename(png)}: {partial:.0%} partial alpha — "
|
|
1400
|
+
f"this WILL show as a grey halo on the white page")
|
|
1401
|
+
|
|
1402
|
+
# ------------------------------------------------------------------------ verdict
|
|
1403
|
+
for f in fails:
|
|
1404
|
+
print(f" ✗ {f}")
|
|
1405
|
+
for w in warns:
|
|
1406
|
+
print(f" ⚠ {w}")
|
|
1407
|
+
print(f"\n{len(slides)} slides · {len(fails)} fail · {len(warns)} warn")
|
|
1408
|
+
print("\n▶ NOW OPEN contact-sheet.png. This checked pixels, not whether it reads as one deck.")
|
|
1409
|
+
sys.exit(1 if fails else 0)
|
|
1410
|
+
```
|
|
1411
|
+
|
|
1412
|
+
## Appendix D — a worked example: dishcover.io, NYC eats
|
|
1413
|
+
|
|
1414
|
+
Six slides, 18.0s — the reference template's exact length. Cost mode `minimize`, interaction mode
|
|
1415
|
+
`interactive`. Art class: **photographic cutout**, one generated sheet.
|
|
1416
|
+
|
|
1417
|
+
**Build log header**
|
|
1418
|
+
|
|
1419
|
+
```
|
|
1420
|
+
TOPIC: Finding the best eats in NYC
|
|
1421
|
+
READER: Lives here, eats out twice a week, keeps ending up somewhere fine
|
|
1422
|
+
OFFER: dishcover.io — search restaurants by DISH instead of by restaurant
|
|
1423
|
+
ART CLASS: photographic cutout, one sheet, contact shadows off
|
|
1424
|
+
SHILL: slide 3
|
|
1425
|
+
```
|
|
1426
|
+
|
|
1427
|
+
**Post caption** (this is where the topic, the bait and the credits live)
|
|
1428
|
+
|
|
1429
|
+
```
|
|
1430
|
+
6 rules for eating well in NYC 🗽
|
|
1431
|
+
which one did you already know?
|
|
1432
|
+
```
|
|
1433
|
+
|
|
1434
|
+
**`slides.tsv`**
|
|
1435
|
+
|
|
1436
|
+
```
|
|
1437
|
+
*Never* eat on the block|with the *menus outside*. If someone has to wave you in,|the food is not doing the work. media/cutouts/menu-board.png@34,38,32,-5
|
|
1438
|
+
Go at *5:30* or go at *9:45*. The kitchen is calmest at the edges|of service. Same dish, better plate. media/cutouts/clock.png@22,40,24,-4 ; media/cutouts/plated-dish.png@54,38,28,6
|
|
1439
|
+
*Search the dish*, not|the *restaurant*. "Best birria in Queens" finds you dinner.|"Best Mexican near me" finds you a parking lot.|dishcover.io is built to search that way. media/cutouts/query-scribble.png@14,42,26,-3 ; media/cutouts/arrow.png@42,46,14,0 ; media/cutouts/birria.png@58,38,28,5
|
|
1440
|
+
*One dish* decides the place. Every good spot does one thing|better than anyone. Order that. media/cutouts/signature-dish.png@36,37,30,-6
|
|
1441
|
+
Trust a *30-item* menu|over a *200-item* one. A short menu means a kitchen that repeats.|A long one means a freezer. media/cutouts/menu-short.png@20,38,26,-5 ; media/cutouts/menu-long.png@54,36,26,7
|
|
1442
|
+
*Walk two more blocks.* Every train exit has a rent-funded|trap in the first 200 feet. media/cutouts/subway-exit.png@34,39,32,-4
|
|
1443
|
+
```
|
|
1444
|
+
|
|
1445
|
+
**Slide 3, read against the lock**
|
|
1446
|
+
|
|
1447
|
+
- *Actionable?* Yes — search a dish name in any search box tonight.
|
|
1448
|
+
- *Shill-as-tip?* Delete `dishcover.io is built to search that way.` The remaining two lines are
|
|
1449
|
+
arguably the best tip in the deck. Passes.
|
|
1450
|
+
- *Still-complete?* Scribble → arrow → birria bowl. The mechanism is legible with the text covered.
|
|
1451
|
+
|
|
1452
|
+
**Build and export**
|
|
1453
|
+
|
|
1454
|
+
```bash
|
|
1455
|
+
python3 build-deck.py slides.tsv work/index.html --title="dishcover — NYC eats deck" \
|
|
1456
|
+
--canvas="#FDFCFA"
|
|
1457
|
+
vidfarm qa ./work --harness ./experimental/sticker-slideshow-tips.md
|
|
1458
|
+
bash export-deck.sh ./work 6 nyc-eats
|
|
1459
|
+
python3 slides-qa.py ./work/slides --brand dishcover.io --shill-slide 3 \
|
|
1460
|
+
--manifest slides.tsv --cutouts ./work/media/cutouts
|
|
1461
|
+
open ./work/slides/contact-sheet.png # gate 3 — the pass that actually matters
|
|
1462
|
+
```
|
|
1463
|
+
|
|
1464
|
+
**What the first pass got wrong** (recorded because it is what this format gets wrong):
|
|
1465
|
+
|
|
1466
|
+
1. Slide 2 had two cutouts at the same scale, so the clock read as a plate. Scaled the clock down
|
|
1467
|
+
to 24% and the dish up to 28%.
|
|
1468
|
+
2. Slide 5's two menus came from different sources — one photographic, one a flat vector. Mixed art
|
|
1469
|
+
class, invisible on slide 5, obvious on the contact sheet. Regenerated both from the sheet.
|
|
1470
|
+
3. The headline on slide 6 was one line where every other slide was two, so its cutout band started
|
|
1471
|
+
40px high. Fixed by pinning the band, not by padding the headline.
|
|
1472
|
+
4. `dishcover.io` was originally in the 800 weight. It read as a banner. Dropped to 500 with the
|
|
1473
|
+
rest of the subtext and the slide stopped looking like the ad slide.
|
|
1474
|
+
|
|
1475
|
+
## Appendix E — the harness in one paragraph, for a handoff
|
|
1476
|
+
|
|
1477
|
+
> Build a 6-slide tip carousel, 9:16, 1080×1920, on a flat `#FDFCFA` page — no footage, no
|
|
1478
|
+
> gradients, no cards. Each slide: one imperative tip as a centred headline (TikTok Sans or
|
|
1479
|
+
> Montserrat, two weights 800/500 mixed inside the sentence, 76px, pinned at 14% from the top,
|
|
1480
|
+
> hand-broken lines) plus an optional reason line at 73% (40px, 500) and 1–3 die-cut photographic
|
|
1481
|
+
> cutouts in the 34–66% band, rotated ±3–8°. No stroke, no shadow, no plate on any text. Slide 3
|
|
1482
|
+
> names dishcover.io once, lowercase, in the subtext, and is written so it is still a good tip with
|
|
1483
|
+
> the brand removed. No title slide; the topic and the comment bait are the post caption. Ship the
|
|
1484
|
+
> six PNGs as the deliverable (durable My Files, ordered in `slides.json`) and an MP4 at exactly
|
|
1485
|
+
> 3.0s per slide with hard cuts as the preview. Full rules:
|
|
1486
|
+
> `vidfarm.cc/experimental/sticker-slideshow-tips.md`.
|