@officexapp/vidfarm-devcli 0.21.58 → 0.21.60
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 +3 -3
- package/.agents/skills/vidfarm/recipes/retheme-template.md +6 -5
- package/.agents/skills/vidfarm/references/content-ideas.md +97 -14
- package/.agents/skills/vidfarm/references/core-workflows.md +4 -0
- package/.agents/skills/vidfarm/references/editor-workflows.md +25 -0
- package/SKILL.director.md +135 -22
- package/SKILL.md +9 -2
- package/clipper.md +7 -3
- package/dist/src/cli.js +201 -16
- package/dist/src/devcli/clipper-panel.js +1828 -0
- package/dist/src/devcli/clipper-run.js +643 -126
- package/dist/src/devcli/marketplace-gigs.js +307 -4
- package/dist/src/devcli/proof-verify.js +465 -0
- package/dist/src/devcli/skill-docs.js +14 -0
- package/experimental/flash-harness.md +142 -18
- package/experimental/meme-recaption.md +1044 -79
- package/experimental/sticker-slideshow-tips.md +511 -86
- package/marketplace.md +427 -12
- package/package.json +6 -1
- package/update.md +21 -1
|
@@ -20,14 +20,22 @@ checks:
|
|
|
20
20
|
# upper bound: 9 slides x (1 bg + 3 cutouts).
|
|
21
21
|
scenes: 5-40
|
|
22
22
|
max_scene_sec: 3.2 # 3.0 is the number. The 0.2 is render tolerance, not licence
|
|
23
|
-
# A tip slide carries
|
|
24
|
-
# whole-composition count is ~
|
|
25
|
-
# 1-3 ceilings in the sibling harnesses are for videos where a voice is doing
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
# A tip slide carries THREE text runs (topic rail + headline + subtext) and there are up to
|
|
24
|
+
# 9 slides, so the whole-composition count is ~26. This is a READ format, not a subtitle
|
|
25
|
+
# format — the 1-3 ceilings in the sibling harnesses are for videos where a voice is doing
|
|
26
|
+
# the work.
|
|
27
|
+
max_text_cards: 28
|
|
28
|
+
max_simultaneous_text: 3 # topic rail + headline + subtext. A FOURTH text run is clutter.
|
|
29
|
+
# The rail is the same string on every slide, so it costs the
|
|
30
|
+
# reader one read, not N — see "The topic rail"
|
|
28
31
|
max_words_per_cue: 16 # the headline is ONE read at arm's length, 3 lines maximum
|
|
29
32
|
max_dead_air_sec: 1.0 # there is never a frame without text. A gap means a broken slide
|
|
30
33
|
max_tail_sec: 0.5
|
|
34
|
+
# THE DECK HAS A CTA — it is the close on slide N, and it is an ADDRESS
|
|
35
|
+
# (`dishcover.io — every dish in NYC, searchable`). None of the phrases below
|
|
36
|
+
# are that. Each is an INSTRUCTION the reader did not need, because the address
|
|
37
|
+
# is already on the frame, and each is phrase-matched for suppression on a
|
|
38
|
+
# carousel. Keeping them banned is what makes the CTA shippable. See "The close".
|
|
31
39
|
forbid_text:
|
|
32
40
|
- link in bio
|
|
33
41
|
- sign up
|
|
@@ -71,7 +79,7 @@ Three questions. **All three must be yes** or you have not got a tip slideshow y
|
|
|
71
79
|
| # | Question | If no |
|
|
72
80
|
|---|---|---|
|
|
73
81
|
| 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 | **
|
|
82
|
+
| 2 | **Both brand touches survive deletion** — delete the brand from the shill and delete the close's last line. Is the shill still one of the best tips in the deck, and is slide N still a complete tip? | You wrote an ad and put tips around it |
|
|
75
83
|
| 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
84
|
|
|
77
85
|
Test 3 is the one that gets skipped, and it is structural, not cosmetic: **the slides are the
|
|
@@ -112,12 +120,12 @@ floor (fewer reads as a single post) and nine is the ceiling (nobody swipes to s
|
|
|
112
120
|
|
|
113
121
|
| Slot | Slide | Job |
|
|
114
122
|
|---|---|---|
|
|
115
|
-
| **1** | **The cover** | Name the list literally + a hero picture. The thumbnail and the promise |
|
|
123
|
+
| **1** | **The cover** | Name the list literally + a hero picture. The thumbnail and the promise. The only slide with **no topic rail** — its headline is the title |
|
|
116
124
|
| **2** | Best tip | Pay the reader immediately. The single strongest thing you know |
|
|
117
125
|
| **3** | Second-best tip | Pay them again, before you ask for anything |
|
|
118
126
|
| **⌈N/2⌉** | **The shill** | The tip that happens to be what the product does. Dead centre |
|
|
119
127
|
| **…** | 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.
|
|
128
|
+
| **N** | **The close** — the tip you'd tell a friend last, plus the destination | The one that earns the save. Still a tip, never a recap. Its subtext carries the deck's ONE call to action, on a third line |
|
|
121
129
|
|
|
122
130
|
**The shill sits in the MIDDLE — slide 4 of 7, slide 3 of 5, slide 5 of 9.** Not a fixed number: a
|
|
123
131
|
fixed number breaks the moment the deck changes length. What is fixed is the *condition* —
|
|
@@ -133,14 +141,79 @@ set-up. The middle is the only slot where the reader is committed and not yet su
|
|
|
133
141
|
earliest legal shill slot is **slide 4**. A 7-slide deck is cover + 6 tips with the ad at 4: two
|
|
134
142
|
tips in front of it, three behind.
|
|
135
143
|
|
|
136
|
-
**Exactly
|
|
137
|
-
|
|
138
|
-
|
|
144
|
+
**Exactly TWO brand touches in the deck, and they do different jobs.** Not one, and never three:
|
|
145
|
+
|
|
146
|
+
| | Slide | What it is | What it does |
|
|
147
|
+
|---|---|---|---|
|
|
148
|
+
| **The shill** | the middle | The **mechanism** — the tip that happens to be what the product does | *Hints* the offer. The reader learns the product exists by watching it be useful |
|
|
149
|
+
| **The close** | **N** | The **destination** — the bare address, on the last slide's third subtext line | *Asks.* The one call to action in the deck |
|
|
150
|
+
|
|
151
|
+
A third mention is a brochure. Two mentions of the *same kind* — two mechanisms, or a destination
|
|
152
|
+
in the middle as well as the end — is also three too many: **the deck may name the mechanism once
|
|
153
|
+
and the address once.** There is no "but this one is subtle" exception.
|
|
139
154
|
|
|
140
155
|
**Slides do not build.** No "1 of 6" counters, no "and finally", no "but here's the thing" carrying
|
|
141
156
|
into the next slide, no numbered sequence where slide 4 depends on slide 3. Every slide is a
|
|
142
157
|
standalone poster. Shuffle the middle slides at random; if the deck breaks, it was a video.
|
|
143
158
|
|
|
159
|
+
### The topic rail — the title, quietly, on every slide
|
|
160
|
+
|
|
161
|
+
**Slide 1 names the list. Slides 2…N carry that name again, small, in the same place, unchanged.**
|
|
162
|
+
|
|
163
|
+
The reference deck does not do this and it is the one thing it is missing. The reason is the same
|
|
164
|
+
reason test 3 exists: **a slide travels alone.** Somebody screenshots slide 4 and sends it to a
|
|
165
|
+
friend. Somebody's swipe drops them on slide 5 with no cover. Somebody opens a save three weeks
|
|
166
|
+
later. In every one of those cases the cover — the only thing that said what the list was about — is
|
|
167
|
+
not on screen, and a great tip with no topic is a fortune cookie.
|
|
168
|
+
|
|
169
|
+
The rail costs one line and fixes all three.
|
|
170
|
+
|
|
171
|
+
| | |
|
|
172
|
+
|---|---|
|
|
173
|
+
| **What it says** | The deck's title, compressed to **≤5 words**, no article, no verb needed |
|
|
174
|
+
| **Where** | A band at **14%**, directly above the headline, centred. Same y on every slide |
|
|
175
|
+
| **How it looks** | Uppercase, 32px, weight **500**, letter-spacing `+0.16em`, the mode's ink at **55% opacity on `paper`, 78% on any `photo-*` mode** |
|
|
176
|
+
| **Which slides** | **2 through N.** Never slide 1 — the cover's headline already IS the title |
|
|
177
|
+
| **If the deck has no cover** | Then **all N**, slide 1 included. A coverless deck has nothing else naming the list, so the rail is the only thing doing that job |
|
|
178
|
+
| **How much it varies** | **Not at all.** Byte for byte identical on every slide that has it |
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
cover headline: 6 rules for eating well in NYC
|
|
182
|
+
topic rail: EATING WELL IN NYC
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- **It is the topic, never the brand.** A rail that reads `DISHCOVER.IO` is not a rail, it is a
|
|
186
|
+
watermark, and it turns one shill into N. See Rule 11.
|
|
187
|
+
- **It is not a counter.** No `3/6`, no `TIP 4`, no progress dots. A counter is a build, and
|
|
188
|
+
*slides do not build* — it also tells a reader who landed mid-deck that they missed something,
|
|
189
|
+
which is the opposite of what the rail is for.
|
|
190
|
+
- **It never comments on its slide.** `EATING WELL IN NYC` on all of them. The moment it becomes
|
|
191
|
+
`EATING WELL IN NYC — TIMING` it is a second headline competing with the first.
|
|
192
|
+
- **Repetition is the mechanism, not a defect.** After slide 2 the reader stops seeing it, which is
|
|
193
|
+
correct: it is not addressed to the person swiping the deck. It is addressed to the person who
|
|
194
|
+
arrives at one slide with no deck around it.
|
|
195
|
+
- **The 55% opacity is deliberate and it is the one place opacity is allowed here.** Elsewhere this
|
|
196
|
+
file bans faking a font weight with opacity, because that yields grey text where you wanted light
|
|
197
|
+
text. Here grey *is* the intent: the rail must be readable and must never be read first. Set it on
|
|
198
|
+
the rail layer, not on the type inside it.
|
|
199
|
+
- **On a photo background the opacity goes UP to 78%, not down.** Translucent ink over a picture
|
|
200
|
+
loses contrast far faster than over a flat page, because the background it is compositing with is
|
|
201
|
+
neither flat nor known. `paper` is the only mode that can afford 55%. Carrying 55% into a photo
|
|
202
|
+
mode is the deck's first unreadable element and the last one you would look at — which is why
|
|
203
|
+
`slides-qa.py` grades the rail band on its own, against the ink's *composited* value.
|
|
204
|
+
- **It sits on the shill slide too, identical.** Anything the rail does differently on slide 3 is a
|
|
205
|
+
free tell that slide 3 is the ad.
|
|
206
|
+
|
|
207
|
+
**Why 32px and not smaller.** `vidfarm qa`'s `font-size` rule errors under 2.6% of canvas width
|
|
208
|
+
(28px at 1080). A 24px rail would be both flagged and genuinely unreadable at feed scale. 32px is
|
|
209
|
+
the floor with headroom.
|
|
210
|
+
|
|
211
|
+
> **Note on the cover.** The main body of this harness requires slide 1 to be a cover; Appendix D
|
|
212
|
+
> and Appendix E predate that and describe a coverless deck. Both shapes are buildable — pass
|
|
213
|
+
> `--no-cover` to `build-deck.py` and the rail goes on all N slides instead of 2…N. **Decide which
|
|
214
|
+
> shape you are building before you write the manifest**, because the shill slot moves with it: a
|
|
215
|
+
> cover pushes the earliest legal shill from slide 3 to slide 4.
|
|
216
|
+
|
|
144
217
|
### Length
|
|
145
218
|
|
|
146
219
|
`N × 3.0s`, exactly. Set `data-duration` on the root to `N * 3.0` and every slide clip to `3.0`.
|
|
@@ -176,10 +249,11 @@ Walk two more blocks.
|
|
|
176
249
|
**The subtext is the REASON, and it is optional.** One or two lines, smaller, lighter, directly
|
|
177
250
|
under the pictures. It answers "why" and never repeats the headline in other words.
|
|
178
251
|
|
|
179
|
-
**
|
|
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
|
|
182
|
-
a three-line subtext
|
|
252
|
+
**Two slides may run to a three-line subtext: the shill and the close.** Both have a job the others
|
|
253
|
+
do not — naming the offer — and squeezing that into two lines is what tempts you into cutting the
|
|
254
|
+
reason down to a slogan. Two lines of tip, one line naming the tool or the address. **Every other
|
|
255
|
+
slide stays at two:** a three-line subtext anywhere else means the tip needed a paragraph, which
|
|
256
|
+
means it was not a tip.
|
|
183
257
|
|
|
184
258
|
```
|
|
185
259
|
headline: Trust a 30-item menu over a 200-item one.
|
|
@@ -208,7 +282,8 @@ Rule 6 exists: a rung-4 number has to be a number you'd defend, not one you roun
|
|
|
208
282
|
Each of these fails in a way that is invisible while you're writing it:
|
|
209
283
|
|
|
210
284
|
- **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
|
|
285
|
+
second shill wearing a hat. One mechanism, in the middle — and the close names an address, not a
|
|
286
|
+
mechanism, which is exactly why the two do not collide.
|
|
212
287
|
- **The tip nobody disputes.** "Read reviews before you go", "book ahead on weekends". Being true
|
|
213
288
|
is not the bar; being *told* is.
|
|
214
289
|
- **The tip that needs the reader to already be an expert.** If the instruction assumes they can
|
|
@@ -222,9 +297,11 @@ Each of these fails in a way that is invisible while you're writing it:
|
|
|
222
297
|
|
|
223
298
|
### The cold-viewer test
|
|
224
299
|
|
|
225
|
-
Cover the brand name on
|
|
226
|
-
If
|
|
227
|
-
dressed it. Rewrite
|
|
300
|
+
Cover the brand name on the shill slide and cover the close's last line. Read all N slides.
|
|
301
|
+
**Does the deck still read as a good list?** If the shill is now visibly the weakest one, you did
|
|
302
|
+
not write a tip — you wrote a placement and dressed it. Rewrite the shill, not the deck. And if
|
|
303
|
+
slide N now reads as unfinished, the close was carrying it: the address was doing the work its
|
|
304
|
+
headline should have done.
|
|
228
305
|
|
|
229
306
|
---
|
|
230
307
|
|
|
@@ -261,6 +338,72 @@ product does not fit this format — use `meme-recaption` or `ugc-reaction-green
|
|
|
261
338
|
|
|
262
339
|
---
|
|
263
340
|
|
|
341
|
+
## The close — where the call to action goes
|
|
342
|
+
|
|
343
|
+
The shill hints. **The close asks.** It is the last slide, it is still a tip, and its subtext runs
|
|
344
|
+
to a third line that names the destination.
|
|
345
|
+
|
|
346
|
+
```
|
|
347
|
+
headline: Ask what the kitchen is proud of.
|
|
348
|
+
subtext: Not "what's good" — everything is good. "Proud of" gets you the one
|
|
349
|
+
dish the chef would put their name on.
|
|
350
|
+
dishcover.io — every dish in NYC, searchable
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Why slide N and not anywhere else:
|
|
354
|
+
|
|
355
|
+
- **The reader has been paid N−1 times.** An address on slide 2 is a toll booth; an address on the
|
|
356
|
+
last slide is a business card handed over at the end of a conversation that went well.
|
|
357
|
+
- **It cannot retroactively turn the deck into an ad**, which is the failure the middle-shill rule
|
|
358
|
+
exists to prevent — because slide N is still a *tip*. The deck resolves on the tip; the address
|
|
359
|
+
rides underneath it. Delete the third line and slide N is unchanged. That is the test.
|
|
360
|
+
- **It is the slide with the lowest reach and the highest intent.** Everyone sees slide 1. The
|
|
361
|
+
people who reach slide N swiped past five tips on purpose. That is who the ask is for.
|
|
362
|
+
|
|
363
|
+
### The grammar of the close
|
|
364
|
+
|
|
365
|
+
**It is an address, not an instruction.** This is the whole rule and it is where decks go wrong.
|
|
366
|
+
|
|
367
|
+
| | |
|
|
368
|
+
|---|---|
|
|
369
|
+
| ✅ | `dishcover.io — every dish in NYC, searchable` |
|
|
370
|
+
| ✅ | `dishcover.io` (bare, no clause — always legal) |
|
|
371
|
+
| ✅ | `built this: dishcover.io` |
|
|
372
|
+
| ❌ | `Link in bio 👇` — an instruction, and a phrase the platforms down-rank |
|
|
373
|
+
| ❌ | `Sign up free at dishcover.io` — an offer, not an address. Now the slide is an ad |
|
|
374
|
+
| ❌ | `Find better restaurants tonight` — an outcome the deck cannot keep, and no address at all |
|
|
375
|
+
|
|
376
|
+
- **≤6 words after the address**, one clause, describing what it *is* — never what it does for them.
|
|
377
|
+
- **Lowercase, weight 500, same size as the rest of the subtext.** No 800, no colour, no arrow, no
|
|
378
|
+
emoji, no box. A styled CTA is the single loudest "this was an ad" tell in the format.
|
|
379
|
+
- **No verb aimed at the reader.** Not "visit", "try", "check out", "grab", "get". The address is
|
|
380
|
+
the verb.
|
|
381
|
+
- **No urgency, no price, no "free".** Those belong to a landing page, and on a slide they cost
|
|
382
|
+
more reach than they buy clicks.
|
|
383
|
+
- **It never repeats the shill's mechanism.** The middle slide already said *how* it works. Saying
|
|
384
|
+
it twice is the third brand touch wearing a different hat.
|
|
385
|
+
|
|
386
|
+
### What stays in the caption
|
|
387
|
+
|
|
388
|
+
**The engagement bait does not move.** `save this`, `follow for more`, `comment below`, `link in
|
|
389
|
+
bio` are still in `forbid_text` and still belong in the post caption — not because a CTA is
|
|
390
|
+
forbidden, but because *those* CTAs are platform-gamed phrases that read as spam on a frame and
|
|
391
|
+
get matched by suppression. The deck's on-frame ask is an address. The caption's ask is engagement.
|
|
392
|
+
They are different asks and they live in different places.
|
|
393
|
+
|
|
394
|
+
> **If you want `link in bio` on the frame anyway**, delete it from `forbid_text` in the front
|
|
395
|
+
> matter — it is your deck. Know the trade: you gain an instruction the reader did not need (the
|
|
396
|
+
> address is already on screen) and you take a reach penalty on every slide the phrase appears on.
|
|
397
|
+
|
|
398
|
+
### When the product has no address worth naming
|
|
399
|
+
|
|
400
|
+
Some offers are a person, a newsletter, a shop inside another platform. The close still works —
|
|
401
|
+
name the thing a reader would type. If there is genuinely nothing to type, **drop the close** and
|
|
402
|
+
end on a plain tip. A deck with a good last tip and no CTA outperforms a deck with a CTA nobody
|
|
403
|
+
can act on. `slides-qa.py --no-close` grades that shape without complaining.
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
264
407
|
## Casting the pictures
|
|
265
408
|
|
|
266
409
|
### The art class — pick one, hold it across the whole deck
|
|
@@ -419,9 +562,12 @@ slide you opened.** They live in `MODES` in Appendix A.
|
|
|
419
562
|
the photograph on the `<img>`; carry the dim on a separate flat colour layer above it. Identical
|
|
420
563
|
result, and it is not modal staging.
|
|
421
564
|
- **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–
|
|
565
|
+
worst band, which flattens the whole picture into mud. A gradient that is strong across the rail
|
|
566
|
+
+ headline band (0–36%) and the subtext band (72–100%) and weak through the cutout band (48–64%)
|
|
424
567
|
buys the contrast exactly where the text is and lets the photograph breathe where it doesn't.
|
|
568
|
+
**The rail lives inside the headline's half of the gradient**, so it inherits the headline's
|
|
569
|
+
contrast for free — that is why it was put above the headline rather than in the air below the
|
|
570
|
+
cutouts, where it would have needed a fourth wash stop of its own.
|
|
425
571
|
- **The photo is scaled to 110% and offset −5%.** A blurred image at exactly 100% shows a pale
|
|
426
572
|
vignette on all four edges — the blur sampling past the frame — and it reads as a rendering bug.
|
|
427
573
|
- **Rule 5 inverts on a photo.** On `paper`, a text shadow is decoration and banned. On a photo it
|
|
@@ -441,7 +587,9 @@ only find out on the slide you did not open.
|
|
|
441
587
|
|
|
442
588
|
`slides-qa.py --ink light|dark` (Appendix C) recovers each text band's background by **blurring the
|
|
443
589
|
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.
|
|
590
|
+
WCAG contrast ratio against the mode's known ink colour. It measures **three** bands — rail,
|
|
591
|
+
headline, subtext — and the rail is graded against its *effective* ink, ink colour composited at the
|
|
592
|
+
rail's own opacity, because a 78%-opacity white is not white.
|
|
445
593
|
|
|
446
594
|
| Ratio | Verdict |
|
|
447
595
|
|---|---|
|
|
@@ -449,6 +597,10 @@ WCAG contrast ratio against the mode's known ink colour.
|
|
|
449
597
|
| 4.5–7:1 | Passes AA. Fine for 70px display type; tighten if you can |
|
|
450
598
|
| **≥ 7:1** | Ship it |
|
|
451
599
|
|
|
600
|
+
**The rail is graded on a different bar: fail under 3:1, warn under 3.5:1.** It is 32px, which
|
|
601
|
+
clears WCAG's large-text threshold, and it is deliberately quieter than the headline — the
|
|
602
|
+
documented `paper` default measures about 3.9:1. Grading it at 4.5 would fail every correct deck.
|
|
603
|
+
|
|
452
604
|
Measured on the reference build: `photo-darken` and `photo-lighten` clear 7:1 on every band of every
|
|
453
605
|
slide. **`photo-gray` lands at 6.1–6.9:1 and is the mode with the least headroom** — greyscale
|
|
454
606
|
throws away the colour separation the other two lean on, so it is squeezed between "picture still
|
|
@@ -462,17 +614,21 @@ full-bleed photograph has ink everywhere). On a photo deck the contrast gate rep
|
|
|
462
614
|
|
|
463
615
|
### The layout grid
|
|
464
616
|
|
|
465
|
-
|
|
466
|
-
percentages of the 1080×1920 canvas:
|
|
617
|
+
Four horizontal bands, held identically on every slide. Read off the reference (which has three;
|
|
618
|
+
the rail is the addition) and expressed as percentages of the 1080×1920 canvas:
|
|
467
619
|
|
|
468
620
|
```
|
|
469
621
|
0% ─────────────────────────────────────
|
|
470
622
|
(air — nothing lives here)
|
|
471
623
|
14% ┌───────────────────────────────────┐
|
|
624
|
+
│ TOPIC RAIL 1 line, slides 2..N │
|
|
625
|
+
16.5%└───────────────────────────────────┘
|
|
626
|
+
(air)
|
|
627
|
+
19% ┌───────────────────────────────────┐
|
|
472
628
|
│ HEADLINE centred, 1-3 lines │
|
|
473
|
-
|
|
629
|
+
35% └───────────────────────────────────┘
|
|
474
630
|
(air)
|
|
475
|
-
|
|
631
|
+
37% ┌───────────────────────────────────┐
|
|
476
632
|
│ │
|
|
477
633
|
│ CUTOUTS 1-3 objects + marks │
|
|
478
634
|
│ │
|
|
@@ -485,9 +641,15 @@ percentages of the 1080×1920 canvas:
|
|
|
485
641
|
100% ─────────────────────────────────────
|
|
486
642
|
```
|
|
487
643
|
|
|
644
|
+
- **The headline starts at 19%, not 14%.** The rail took the top band, so the headline moved down
|
|
645
|
+
and the cutout band lost 3% off its top. Nothing else changed. If you are reading an older build
|
|
646
|
+
whose headlines start at 14%, it predates the rail — move them, do not add the rail above them
|
|
647
|
+
and let it collide with the reserved zone.
|
|
488
648
|
- **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
|
|
649
|
+
slide 2 still starts at 19%. Vertically centring each slide's content individually is the single
|
|
490
650
|
most common way a deck acquires a "wobble" nobody can name — and it is invisible slide by slide.
|
|
651
|
+
- **Slide 1 leaves the rail band EMPTY.** It does not move its headline up to fill the gap. The
|
|
652
|
+
cover's headline sits at 19% like every other headline, or the deck wobbles on its first cut.
|
|
491
653
|
- **The bottom 20% and the top 14% stay empty.** That is where the platform puts the caption, the
|
|
492
654
|
username, the buttons, and the swipe affordance.
|
|
493
655
|
- **Everything is centred.** The headline block centres, the cutout group centres, the subtext
|
|
@@ -534,10 +696,15 @@ looking like itself.
|
|
|
534
696
|
|
|
535
697
|
| Role | Face | Weight | Size | Line height |
|
|
536
698
|
|---|---|---|---|---|
|
|
699
|
+
| Topic rail | same family | **500** | 32px, uppercase, `+0.16em`, 55% opacity | 1.2 |
|
|
537
700
|
| Headline — emphasis | TikTok Sans *or* Montserrat | **800** | 76px | 1.22 |
|
|
538
701
|
| Headline — body | same family | **500** | 76px | 1.22 |
|
|
539
702
|
| Subtext | same family | **500** | 40px | 1.34 |
|
|
540
703
|
|
|
704
|
+
**Three sizes now, not two.** The old count was two and the contact-sheet review asked you to count
|
|
705
|
+
them; the answer is three, and the third is the rail at one fixed size on every slide. A fourth size
|
|
706
|
+
anywhere is the defect that check is looking for.
|
|
707
|
+
|
|
541
708
|
`vidfarm qa`'s `font-regime` rule errors on Inter / Roboto / Arial / system-ui and warns on any
|
|
542
709
|
family outside the imported regime (Montserrat, TikTok Sans, Abel, Source Code Pro, Yesteryear).
|
|
543
710
|
**Montserrat is the closest match to the reference** and is in the regime.
|
|
@@ -717,12 +884,14 @@ fraction of the reach the format is for.
|
|
|
717
884
|
Every rule has its reason attached. A rule without its reason gets argued away by the next agent
|
|
718
885
|
that reads this file.
|
|
719
886
|
|
|
720
|
-
### Rule 1 —
|
|
887
|
+
### Rule 1 — two brand touches: the mechanism in the middle, the address at the end
|
|
721
888
|
|
|
722
|
-
Any other placement or count changes what the deck *is*.
|
|
723
|
-
|
|
724
|
-
is
|
|
725
|
-
|
|
889
|
+
Any other placement or count changes what the deck *is*. A mechanism on slide 1 makes it an ad; a
|
|
890
|
+
mechanism on slide N makes the whole list retroactive set-up for a pitch; a third touch anywhere
|
|
891
|
+
makes it a brochure. The shill is in the middle because that is where the reader is already paid
|
|
892
|
+
and not yet suspicious. The close is at the end because that is where they have been paid N−1
|
|
893
|
+
times and are choosing to still be there. **Enforced:** the cold-viewer test, and `slides-qa.py`
|
|
894
|
+
checks the brand token appears on exactly the shill slide and the close slide.
|
|
726
895
|
|
|
727
896
|
### Rule 2 — every slide is a complete still
|
|
728
897
|
|
|
@@ -732,9 +901,10 @@ on, because the carousel is the deliverable and the video is not.
|
|
|
732
901
|
|
|
733
902
|
### Rule 3 — the layout bands never move
|
|
734
903
|
|
|
735
|
-
|
|
736
|
-
slide it looks fine either way; across the deck, individually-centred content produces a
|
|
737
|
-
wobble that reads as sloppy and that nobody can name while looking at one slide.
|
|
904
|
+
Rail at 14%, headline at 19%, cutouts 37–66%, subtext at 73%, on every slide, whatever the content
|
|
905
|
+
length. Per slide it looks fine either way; across the deck, individually-centred content produces a
|
|
906
|
+
vertical wobble that reads as sloppy and that nobody can name while looking at one slide. The cover
|
|
907
|
+
has no rail and still starts its headline at 19% — an empty band is not a gap to close.
|
|
738
908
|
|
|
739
909
|
### Rule 4 — one art class, one light direction, one canvas colour
|
|
740
910
|
|
|
@@ -764,17 +934,30 @@ An attribution line on the frame is brand chrome, and this format bans brand chr
|
|
|
764
934
|
reason the others do. The carousel always has a caption; use it. If a licence forces on-image
|
|
765
935
|
credit, change the asset.
|
|
766
936
|
|
|
767
|
-
### Rule 9 — the
|
|
937
|
+
### Rule 9 — the frame carries an address, the caption carries the bait
|
|
768
938
|
|
|
769
|
-
The deck ends on a tip
|
|
770
|
-
|
|
771
|
-
|
|
939
|
+
The deck ends on a tip **with the destination under it** — one line, lowercase, no verb (see *The
|
|
940
|
+
close*). Everything else that asks the reader for something stays in the caption: the comment
|
|
941
|
+
prompt, the save prompt, the follow prompt. A slide that says "save this" spends the slot that
|
|
942
|
+
could have held the tip that made them save it, and the phrase is down-ranked besides. An address
|
|
943
|
+
is information; "link in bio" is an instruction the reader did not need, because the address is
|
|
944
|
+
already on screen.
|
|
772
945
|
|
|
773
946
|
### Rule 10 — production floor
|
|
774
947
|
|
|
775
948
|
1080×1920, 30fps, `N × 3.0s` exactly, hard cuts, one canvas, no letterboxing, text at frame 0 of
|
|
776
949
|
every slide, `data-duration` on the root equal to the sum.
|
|
777
950
|
|
|
951
|
+
### Rule 11 — the topic rail names the TOPIC, on slides 2…N, unchanged
|
|
952
|
+
|
|
953
|
+
One string, ≤5 words, identical byte for byte, at 14% on every slide except the cover. It exists
|
|
954
|
+
because a slide travels alone — screenshotted, reposted, landed on mid-swipe, reopened from a save
|
|
955
|
+
three weeks later — and a tip with no topic attached is a fortune cookie. **It is never the brand**
|
|
956
|
+
(that is N shills, and Rule 1 says one), **never a counter** (that is a build, and Rule 2 says
|
|
957
|
+
none), and never a comment on the slide it sits on. If it varies at all, it has stopped being a rail
|
|
958
|
+
and become a second headline. **Enforced:** `slides-qa.py` measures its band on every slide and
|
|
959
|
+
rejects the brand token appearing in it.
|
|
960
|
+
|
|
778
961
|
---
|
|
779
962
|
|
|
780
963
|
## Cost-saving mode — the whole deck at $0
|
|
@@ -827,7 +1010,7 @@ Sizing a round, capacity, and the ledger belong to `vidfarm experiment`, not to
|
|
|
827
1010
|
| Gate | Tool | What it can actually see |
|
|
828
1011
|
|---|---|---|
|
|
829
1012
|
| **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
|
|
1013
|
+
| **2. The stills** | `slides-qa.py ./slides [--ink light\|dark]` *(Appendix C)* | The exported PNGs: blank slides, canvas drift, band alignment, cutout alpha, the two-brand-touch placement, the topic rail on 2…N, the close's grammar |
|
|
831
1014
|
| **3. The sequence** | **You**, on `slides/contact-sheet.png` | Whether it reads as one deck. Nothing else can see this |
|
|
832
1015
|
| **4. The tips** | **You**, cold | Whether any of it is worth saving. No tool has an opinion here |
|
|
833
1016
|
|
|
@@ -876,6 +1059,9 @@ in the edit.
|
|
|
876
1059
|
- [ ] N is between 5 and 9 (cover included), and every slide is exactly 3.0s.
|
|
877
1060
|
- [ ] Slide 2 is the strongest tip in the deck, not the easiest one to illustrate.
|
|
878
1061
|
- [ ] Shuffling two middle tips breaks nothing.
|
|
1062
|
+
- [ ] The topic rail is written once, ≤5 words, and is the TOPIC — not the brand, not a counter.
|
|
1063
|
+
- [ ] It appears on slides 2…N, identical byte for byte, and NOT on the cover.
|
|
1064
|
+
- [ ] Screenshot slide 4 alone and show it to someone: they can say what list it came from.
|
|
879
1065
|
|
|
880
1066
|
**The tips**
|
|
881
1067
|
|
|
@@ -889,11 +1075,23 @@ in the edit.
|
|
|
889
1075
|
**The shill**
|
|
890
1076
|
|
|
891
1077
|
- [ ] 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.
|
|
1078
|
+
- [ ] It is the only slide naming the offer's MECHANISM. The close names the address, and nothing
|
|
1079
|
+
else in the deck names either.
|
|
893
1080
|
- [ ] With the brand name covered, it is still one of the two best tips in the deck.
|
|
894
1081
|
- [ ] It states a MECHANISM the reader could follow with no product at all.
|
|
895
1082
|
- [ ] The brand is lowercase, in the subtext, in the 500 weight, named once.
|
|
896
|
-
- [ ]
|
|
1083
|
+
- [ ] The shill slide is visually indistinguishable from the others — same layout, type, colour,
|
|
1084
|
+
art class.
|
|
1085
|
+
|
|
1086
|
+
**The close**
|
|
1087
|
+
|
|
1088
|
+
- [ ] Slide N is still a TIP. Delete its last line and the slide is unchanged.
|
|
1089
|
+
- [ ] The last line is an ADDRESS, plus at most 6 words saying what it is.
|
|
1090
|
+
- [ ] No verb aimed at the reader, no "free", no urgency, no emoji, no arrow, no box.
|
|
1091
|
+
- [ ] It does not restate the shill's mechanism.
|
|
1092
|
+
- [ ] Lowercase, weight 500, same size as the subtext. Nothing about it is styled differently.
|
|
1093
|
+
- [ ] Exactly two brand touches in the whole deck: the shill and the close.
|
|
1094
|
+
- [ ] If there is nothing worth typing, there is no close — and slide N is a plain tip.
|
|
897
1095
|
|
|
898
1096
|
**The pictures**
|
|
899
1097
|
|
|
@@ -914,8 +1112,11 @@ in the edit.
|
|
|
914
1112
|
- [ ] Photo mode: hand-drawn marks use `{ink}`; photographic cutouts are never recoloured.
|
|
915
1113
|
- [ ] Photo mode: the blur is on the image and the dim is a separate flat layer (no `modal-scrim`).
|
|
916
1114
|
- [ ] Paper mode: one canvas colour across the deck. No footage, gradient, texture, border or card.
|
|
917
|
-
- [ ]
|
|
1115
|
+
- [ ] Rail band at 14%, headline band at 19% on every slide, whatever its line count.
|
|
1116
|
+
- [ ] The cover leaves the rail band empty and does NOT move its headline up to fill it.
|
|
918
1117
|
- [ ] Nothing in the top 14% or bottom 20%.
|
|
1118
|
+
- [ ] Rail measured at ≥3:1 (its own bar, not the headline's 4.5): 0.55 opacity on `paper`,
|
|
1119
|
+
0.78 on any photo mode.
|
|
919
1120
|
- [ ] Two weights of ONE self-hosted family, 800 and 500, no fallback chain in the stack.
|
|
920
1121
|
- [ ] 2–4 emphasised runs per headline, weight-only, verb and noun.
|
|
921
1122
|
- [ ] No stroke, no text shadow, no plate behind any text.
|
|
@@ -938,15 +1139,22 @@ actually ships broken. **Open `slides/contact-sheet.png` and read it as one imag
|
|
|
938
1139
|
|
|
939
1140
|
- [ ] **Does the headline sit at the same height on all N slides?** The wobble is invisible one
|
|
940
1141
|
slide at a time and obvious on the sheet.
|
|
941
|
-
- [ ] **How many type sizes are there?** There should be
|
|
1142
|
+
- [ ] **How many type sizes are there?** There should be three — rail, headline, subtext. Count
|
|
1143
|
+
them.
|
|
1144
|
+
- [ ] **Cover the headline and the subtext on every slide. Does the rail still read the same on
|
|
1145
|
+
all of them, at the same height?** This is the check the pixel gate cannot do properly at
|
|
1146
|
+
32px, and a rail that drifted or changed wording is obvious here and nowhere else.
|
|
1147
|
+
- [ ] **Did your eye go to the rail first on any slide?** Then it is too loud. Drop the opacity.
|
|
942
1148
|
- [ ] **Do the cutouts look like one set?** Same class, same rendering, same light, plausible
|
|
943
1149
|
relative scale.
|
|
944
1150
|
- [ ] **Is the canvas the same white on all N?** A cutout with a near-white background rectangle
|
|
945
1151
|
makes its slide read a different colour and only shows up beside its neighbours.
|
|
946
1152
|
- [ ] **Which slide is the weakest?** There is one. Either rewrite it or delete it — a five-slide
|
|
947
1153
|
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
|
|
949
|
-
styled differently and Rule 1 is broken.
|
|
1154
|
+
- [ ] **Can you tell which slide is the ad?** If your eye goes to the shill before you read it, it
|
|
1155
|
+
is styled differently and Rule 1 is broken.
|
|
1156
|
+
- [ ] **Does the last slide read as a tip or as an ending card?** Cover its last line. If what is
|
|
1157
|
+
left feels like it is missing something, the address was carrying the slide.
|
|
950
1158
|
- [ ] **Would you save this?** Not "is it correct". Would you save it.
|
|
951
1159
|
|
|
952
1160
|
Then watch the MP4 end to end at 1× with sound off, which is how it will be seen.
|
|
@@ -963,8 +1171,10 @@ Diagnose by slot, never by declaring the deck bad.
|
|
|
963
1171
|
| 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
1172
|
| Swiped to the end, no saves | **The tips** | They are on rung 1–2. Add a mechanism and a number to each |
|
|
965
1173
|
| 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 | **
|
|
1174
|
+
| Good numbers, no clicks to the offer | **The close** | Check it exists and is an address, not a promise. Then check expectations: this format seeds a mechanism and hands over an address — most of its return arrives as branded search, not as clicks. Judge it on saves and branded search first |
|
|
1175
|
+
| Reach collapsed on an otherwise fine deck | **The close** | An instruction phrase reached the frame — "link in bio", "sign up free". Gate 2 check 8 catches it; a suppressed deck looks exactly like a bad deck |
|
|
967
1176
|
| "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 |
|
|
1177
|
+
| Reshares of one slide, no traffic back to the deck | **The topic rail** | It is missing, or it names the brand instead of the topic. A lifted slide has to say what list it came from |
|
|
968
1178
|
|
|
969
1179
|
---
|
|
970
1180
|
|
|
@@ -996,7 +1206,13 @@ Four things this exists to get right:
|
|
|
996
1206
|
first_frame_text then fail on a composition whose frame 0 is actually fine.
|
|
997
1207
|
* THE BANDS DO NOT MOVE. Every headline is pinned to the same top, whatever
|
|
998
1208
|
its line count. Vertically centring per slide is the "wobble" that only
|
|
999
|
-
shows up on the contact sheet (Rule 3).
|
|
1209
|
+
shows up on the contact sheet (Rule 3). The cover leaves the rail band
|
|
1210
|
+
empty and STILL starts its headline at head_top — do not close the gap.
|
|
1211
|
+
* THE TOPIC RAIL IS EMITTED FROM ONE STRING. --topic is written to slides
|
|
1212
|
+
2..N verbatim, so it cannot drift, cannot pick up a counter, and cannot
|
|
1213
|
+
say something different on the shill slide (Rule 11). It is deliberately
|
|
1214
|
+
NOT a TSV column: a per-slide column is a per-slide value, and a rail with
|
|
1215
|
+
per-slide values is a second headline.
|
|
1000
1216
|
* GEOMETRY IS INLINE, TYPOGRAPHY IS IN THE CLASS. `.clip{inset:0}` has to be
|
|
1001
1217
|
reset per layer with `inset:auto`, that reset lives in the inline style
|
|
1002
1218
|
attribute, and inline BEATS the stylesheet — so a `top:` in a CSS class
|
|
@@ -1015,15 +1231,19 @@ Background modes (--bg-mode):
|
|
|
1015
1231
|
|
|
1016
1232
|
Any photo mode needs a 4th TSV column naming that slide's background image.
|
|
1017
1233
|
|
|
1018
|
-
usage: build-deck.py <slides.tsv> <out.html>
|
|
1234
|
+
usage: build-deck.py <slides.tsv> <out.html> --topic="EATING WELL IN NYC"
|
|
1235
|
+
[--title=…] [--canvas=#FDFCFA] [--no-cover]
|
|
1019
1236
|
[--bg-mode=paper|photo-darken|photo-lighten|photo-gray]
|
|
1020
|
-
[--head-px=70] [--sub-px=40]
|
|
1237
|
+
[--head-px=70] [--sub-px=40] [--rail-px=32]
|
|
1238
|
+
|
|
1239
|
+
--no-cover says slide 1 is a TIP, not a cover — then the rail goes on all N
|
|
1240
|
+
slides, because nothing else in the deck names the list.
|
|
1021
1241
|
"""
|
|
1022
1242
|
import html, sys
|
|
1023
1243
|
|
|
1024
1244
|
SLIDE_SEC = 3.0
|
|
1025
1245
|
W, H = 1080, 1920
|
|
1026
|
-
BANDS = dict(
|
|
1246
|
+
BANDS = dict(rail_top=14.0, head_top=19.0, cut_top=37.0, cut_bot=66.0, sub_top=73.0)
|
|
1027
1247
|
|
|
1028
1248
|
# Each mode is a complete legibility contract: what happens to the photo, what
|
|
1029
1249
|
# flat wash sits over it, what colour the text is, and what that text needs to
|
|
@@ -1034,36 +1254,61 @@ MODES = {
|
|
|
1034
1254
|
filter=None, wash=None, ink="#111111",
|
|
1035
1255
|
# On a flat page the contrast is already maximal and a shadow is the
|
|
1036
1256
|
# thing that makes type read as machine-made (Rule 5).
|
|
1037
|
-
shadow="none", cut_shadow="none"
|
|
1257
|
+
shadow="none", cut_shadow="none",
|
|
1258
|
+
# The rail must recede without disappearing. A flat page can carry 0.55;
|
|
1259
|
+
# a photograph cannot — translucent ink over a picture loses contrast far
|
|
1260
|
+
# faster than over paper, so every photo mode runs the rail at 0.78.
|
|
1261
|
+
rail_opacity=0.55),
|
|
1038
1262
|
"photo-darken": dict(
|
|
1039
1263
|
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)
|
|
1264
|
+
wash="linear-gradient(180deg,rgba(0,0,0,0.62) 0%,rgba(0,0,0,0.62) 36%,rgba(0,0,0,0.22) 48%,rgba(0,0,0,0.22) 64%,rgba(0,0,0,0.62) 72%,rgba(0,0,0,0.62) 100%)", ink="#FFFFFF",
|
|
1041
1265
|
shadow="0 2px 18px rgba(0,0,0,0.55)",
|
|
1042
|
-
cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.55))"
|
|
1266
|
+
cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.55))",
|
|
1267
|
+
rail_opacity=0.78),
|
|
1043
1268
|
"photo-lighten": dict(
|
|
1044
1269
|
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)
|
|
1270
|
+
wash="linear-gradient(180deg,rgba(255,255,255,0.78) 0%,rgba(255,255,255,0.78) 36%,rgba(255,255,255,0.46) 48%,rgba(255,255,255,0.46) 64%,rgba(255,255,255,0.78) 72%,rgba(255,255,255,0.78) 100%)", ink="#111111",
|
|
1046
1271
|
shadow="0 2px 18px rgba(255,255,255,0.75)",
|
|
1047
|
-
cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.30))"
|
|
1272
|
+
cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.30))",
|
|
1273
|
+
rail_opacity=0.78),
|
|
1048
1274
|
"photo-gray": dict(
|
|
1049
1275
|
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)
|
|
1276
|
+
wash="linear-gradient(180deg,rgba(18,18,20,0.64) 0%,rgba(18,18,20,0.64) 36%,rgba(18,18,20,0.18) 48%,rgba(18,18,20,0.18) 64%,rgba(18,18,20,0.64) 72%,rgba(18,18,20,0.64) 100%)", ink="#FFFFFF",
|
|
1051
1277
|
shadow="0 2px 18px rgba(0,0,0,0.50)",
|
|
1052
|
-
cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.50))"
|
|
1278
|
+
cut_shadow="drop-shadow(0 14px 30px rgba(0,0,0,0.50))",
|
|
1279
|
+
rail_opacity=0.78),
|
|
1053
1280
|
}
|
|
1054
1281
|
|
|
1055
1282
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
1056
1283
|
opts = dict(a.split("=", 1) for a in sys.argv[1:] if a.startswith("--") and "=" in a)
|
|
1284
|
+
flags = {a for a in sys.argv[1:] if a.startswith("--") and "=" not in a}
|
|
1285
|
+
has_cover = "--no-cover" not in flags
|
|
1057
1286
|
manifest, out = args[0], args[1]
|
|
1058
1287
|
canvas = opts.get("--canvas", "#FDFCFA")
|
|
1059
1288
|
title = opts.get("--title", "tip deck")
|
|
1060
1289
|
head_px = int(opts.get("--head-px", 70))
|
|
1061
1290
|
sub_px = int(opts.get("--sub-px", 40))
|
|
1291
|
+
rail_px = int(opts.get("--rail-px", 32))
|
|
1062
1292
|
bg_mode = opts.get("--bg-mode", "paper")
|
|
1063
1293
|
if bg_mode not in MODES:
|
|
1064
1294
|
sys.exit(f"unknown --bg-mode {bg_mode!r}; pick one of {', '.join(MODES)}")
|
|
1065
1295
|
M = MODES[bg_mode]
|
|
1066
1296
|
|
|
1297
|
+
# The topic rail (Rule 11). Required, because "the deck with no rail" is not a
|
|
1298
|
+
# variant of this format — it is the failure mode the rail was added to fix.
|
|
1299
|
+
topic = opts.get("--topic", "").strip().upper()
|
|
1300
|
+
if not topic:
|
|
1301
|
+
sys.exit('--topic is required: the deck title in <=5 words, e.g. '
|
|
1302
|
+
'--topic="EATING WELL IN NYC". It goes on slides 2..N unchanged.')
|
|
1303
|
+
if len(topic.split()) > 5:
|
|
1304
|
+
sys.exit(f"--topic is {len(topic.split())} words; the rail holds 5. "
|
|
1305
|
+
f"Compress it — the cover carries the full title.")
|
|
1306
|
+
if any(ch.isdigit() for ch in topic) and "/" in topic:
|
|
1307
|
+
sys.exit("--topic looks like a counter. The rail is not a progress indicator (Rule 11).")
|
|
1308
|
+
# `vidfarm qa`'s font-size rule errors below 2.6% of canvas width.
|
|
1309
|
+
if rail_px < W * 0.026:
|
|
1310
|
+
sys.exit(f"--rail-px {rail_px} is below the {W * 0.026:.0f}px floor; qa will error on it.")
|
|
1311
|
+
|
|
1067
1312
|
rows = []
|
|
1068
1313
|
for raw in open(manifest):
|
|
1069
1314
|
raw = raw.rstrip("\n")
|
|
@@ -1101,6 +1346,14 @@ CSS = f"""
|
|
|
1101
1346
|
|
|
1102
1347
|
/* THE BANDS are set INLINE, not here — see the module docstring. Geometry
|
|
1103
1348
|
inline, typography in the class. */
|
|
1349
|
+
|
|
1350
|
+
/* The topic rail. Uppercase + tracked + small is what makes it subordinate;
|
|
1351
|
+
the opacity is what stops it being read FIRST. This is the one place in the
|
|
1352
|
+
deck where opacity is legitimate — everywhere else it is a way of faking a
|
|
1353
|
+
font weight, which yields grey text where you wanted light text. */
|
|
1354
|
+
.rail{{font-family:'Deck';font-weight:500;font-size:{rail_px}px;line-height:1.2;
|
|
1355
|
+
letter-spacing:0.16em;text-transform:uppercase;color:{M['ink']};
|
|
1356
|
+
opacity:{M['rail_opacity']};text-align:center;text-shadow:{M['shadow']}}}
|
|
1104
1357
|
.head{{font-family:'Deck';font-weight:500;font-size:{head_px}px;line-height:1.22;
|
|
1105
1358
|
letter-spacing:-0.01em;color:{M['ink']};text-align:center;
|
|
1106
1359
|
text-shadow:{M['shadow']}}}
|
|
@@ -1141,6 +1394,14 @@ for i, (head, sub, cuts, bg) in enumerate(rows):
|
|
|
1141
1394
|
if M["wash"]:
|
|
1142
1395
|
layers.append(layer("shape", f"s{n:02d}-wash", t, "wash",
|
|
1143
1396
|
"inset:0;z-index:1", label=f"slide {n} wash"))
|
|
1397
|
+
# The rail: slides 2..N when there is a cover. Slide 1's headline IS the
|
|
1398
|
+
# title, so a rail above it would repeat itself — but the cover still leaves
|
|
1399
|
+
# the band EMPTY rather than moving its headline up, or the deck wobbles on
|
|
1400
|
+
# its first cut. With --no-cover nothing else names the list, so all N.
|
|
1401
|
+
if n > 1 or not has_cover:
|
|
1402
|
+
layers.append(layer("text", f"s{n:02d}-rail", t, "rail",
|
|
1403
|
+
f"inset:auto;left:8%;width:84%;top:{BANDS['rail_top']}%;z-index:3",
|
|
1404
|
+
html.escape(topic), label=f"slide {n} topic rail"))
|
|
1144
1405
|
layers.append(layer("text", f"s{n:02d}-head", t, "head",
|
|
1145
1406
|
f"inset:auto;left:8%;width:84%;top:{BANDS['head_top']}%;z-index:3",
|
|
1146
1407
|
rich(head), label=f"slide {n} headline"))
|
|
@@ -1174,7 +1435,8 @@ open(out, "w").write(f"""<!doctype html>
|
|
|
1174
1435
|
</div></body></html>
|
|
1175
1436
|
""")
|
|
1176
1437
|
print(f"{len(rows)} slides · {total}s · {len(layers)} flat layers · "
|
|
1177
|
-
f"bg-mode {bg_mode} · ink {M['ink']} · {
|
|
1438
|
+
f"bg-mode {bg_mode} · ink {M['ink']} · rail “{topic}” "
|
|
1439
|
+
f"on slides {2 if has_cover else 1}-{len(rows)} · {out}")
|
|
1178
1440
|
print("stills at: " + ",".join(str(round(i * SLIDE_SEC + 1.5, 1)) for i in range(len(rows))))
|
|
1179
1441
|
```
|
|
1180
1442
|
|
|
@@ -1222,7 +1484,8 @@ echo "preview : $DIR/final.mp4"
|
|
|
1222
1484
|
runs on the pixels that ship.
|
|
1223
1485
|
|
|
1224
1486
|
```bash
|
|
1225
|
-
python3 slides-qa.py ./work/slides --brand dishcover.io --shill-slide 3
|
|
1487
|
+
python3 slides-qa.py ./work/slides --brand dishcover.io --shill-slide 3 \
|
|
1488
|
+
--topic "EATING WELL IN NYC"
|
|
1226
1489
|
```
|
|
1227
1490
|
|
|
1228
1491
|
```python
|
|
@@ -1230,7 +1493,7 @@ python3 slides-qa.py ./work/slides --brand dishcover.io --shill-slide 3
|
|
|
1230
1493
|
"""
|
|
1231
1494
|
Grade an exported tip deck on the PNGs, not on the markup.
|
|
1232
1495
|
|
|
1233
|
-
|
|
1496
|
+
Seven checks, each one a defect that passes every DOM check and every per-slide
|
|
1234
1497
|
eyeball, and shows up only across the set:
|
|
1235
1498
|
|
|
1236
1499
|
1. no slide is blank — an entrance that had not settled at +1.5s
|
|
@@ -1243,22 +1506,44 @@ eyeball, and shows up only across the set:
|
|
|
1243
1506
|
brand manifest rather than by OCR
|
|
1244
1507
|
6. every cutout's alpha is
|
|
1245
1508
|
mostly 0-or-255 — run separately on media/cutouts/*.png
|
|
1509
|
+
7. the topic rail is on slides — Rule 11. A missing rail is invisible while
|
|
1510
|
+
2..N and NOT on slide 1 you read the deck in order and is the whole
|
|
1511
|
+
point of the deck when a slide travels alone
|
|
1512
|
+
8. the close is an ADDRESS, not — Rule 9. "Sign up free" and "link in bio"
|
|
1513
|
+
an instruction are what a CTA turns into when nobody checks
|
|
1246
1514
|
|
|
1247
1515
|
usage: slides-qa.py <slides-dir> [--brand <token>] [--shill-slide <n>] [--ink light|dark]
|
|
1248
1516
|
[--manifest <slides.tsv>] [--cutouts <dir>]
|
|
1517
|
+
[--topic <rail text>] [--rail-opacity 0.55] [--no-cover]
|
|
1518
|
+
[--no-close]
|
|
1249
1519
|
"""
|
|
1250
1520
|
import glob, os, subprocess, sys
|
|
1251
1521
|
|
|
1252
1522
|
d = sys.argv[1]
|
|
1253
|
-
opt
|
|
1523
|
+
# `opt` pairs positionally, so a bare flag would shift every option after it by
|
|
1524
|
+
# one and silently give you --brand="--shill-slide". Strip the bare flags first.
|
|
1525
|
+
BARE = {"--no-cover", "--no-close"}
|
|
1526
|
+
argv = [a for a in sys.argv[2:] if a not in BARE]
|
|
1527
|
+
has_cover = "--no-cover" not in sys.argv
|
|
1528
|
+
no_close = "--no-close" in sys.argv
|
|
1529
|
+
opt = dict(zip(argv[0::2], argv[1::2]))
|
|
1254
1530
|
brand = opt.get("--brand")
|
|
1255
1531
|
shill = int(opt.get("--shill-slide", 3))
|
|
1256
1532
|
manifest = opt.get("--manifest")
|
|
1257
1533
|
cutdir = opt.get("--cutouts")
|
|
1258
1534
|
ink = opt.get("--ink") # "light" | "dark" — the mode's text colour
|
|
1535
|
+
topic = (opt.get("--topic") or "").strip()
|
|
1536
|
+
rail_alpha = float(opt.get("--rail-opacity", 0.78 if ink else 0.55))
|
|
1259
1537
|
|
|
1260
1538
|
fails, warns = [], []
|
|
1261
1539
|
|
|
1540
|
+
# The band geometry, in pixels on a 1080x1920 canvas. Kept here rather than
|
|
1541
|
+
# inlined so the rail's arrival could not silently leave the headline scan
|
|
1542
|
+
# looking at the rail — which is exactly what happened on the first attempt.
|
|
1543
|
+
RAIL_Y, RAIL_H = 262, 52 # 14.0% .. 16.5%
|
|
1544
|
+
HEAD_Y, HEAD_H = 355, 300 # 19.0% .. 34.6% (3 lines at 76/1.22)
|
|
1545
|
+
SUB_Y, SUB_H = 1395, 175 # 73.0% .. 82.1%
|
|
1546
|
+
|
|
1262
1547
|
|
|
1263
1548
|
def probe(png, crop):
|
|
1264
1549
|
"""Mean RGB of a crop, via ffmpeg — no PIL dependency."""
|
|
@@ -1306,10 +1591,13 @@ if not ink:
|
|
|
1306
1591
|
fails.append(f"{os.path.basename(s)}: canvas {c} drifts from {base}")
|
|
1307
1592
|
|
|
1308
1593
|
# --------------------------------------------------- 3. the headline band is level
|
|
1594
|
+
# The scan starts BELOW the rail band. Starting at 200px (as this did before the
|
|
1595
|
+
# rail existed) locks onto the rail on every slide except the cover, reports a
|
|
1596
|
+
# perfectly level 269px on 5 of 6 slides, and hides a real headline wobble.
|
|
1309
1597
|
tops = []
|
|
1310
1598
|
for s in (slides if not ink else []):
|
|
1311
1599
|
top = None
|
|
1312
|
-
for y in range(
|
|
1600
|
+
for y in range(HEAD_Y - 20, 760, 12): # scan the headline band, 17%-40%
|
|
1313
1601
|
if rowdark(s, y, 12) > 0.06:
|
|
1314
1602
|
top = y
|
|
1315
1603
|
break
|
|
@@ -1328,14 +1616,92 @@ for s in (slides if not ink else []):
|
|
|
1328
1616
|
if rowdark(s, 1536, 384) > 0.02:
|
|
1329
1617
|
warns.append(f"{os.path.basename(s)}: ink in the bottom 20% (caption/buttons)")
|
|
1330
1618
|
|
|
1331
|
-
#
|
|
1619
|
+
# ------------------------------------------- 5. exactly TWO brand touches, placed
|
|
1620
|
+
# The shill (mechanism, middle) and the close (address, slide N). A third is a
|
|
1621
|
+
# brochure; a mechanism on slide N turns every tip before it into set-up.
|
|
1332
1622
|
if brand and manifest:
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
if hits !=
|
|
1623
|
+
lines = [l for l in open(manifest) if l.strip() and not l.lstrip().startswith("#")]
|
|
1624
|
+
hits = [i + 1 for i, line in enumerate(lines) if brand.lower() in line.lower()]
|
|
1625
|
+
want = [shill] if no_close else sorted({shill, len(lines)})
|
|
1626
|
+
if hits != want:
|
|
1337
1627
|
fails.append(f"brand '{brand}' appears on slides {hits or 'none'} — "
|
|
1338
|
-
f"Rule 1 requires exactly
|
|
1628
|
+
f"Rule 1 requires exactly {want} "
|
|
1629
|
+
f"({'shill only, --no-close' if no_close else 'shill + close'})")
|
|
1630
|
+
# The close is an ADDRESS. A mechanism restated on slide N is the third touch
|
|
1631
|
+
# wearing a hat, and it is the one the eye forgives and the reader does not.
|
|
1632
|
+
if not no_close and len(lines) >= 1 and brand.lower() in lines[-1].lower():
|
|
1633
|
+
close_sub = (lines[-1].split("\t") + ["", ""])[1]
|
|
1634
|
+
if close_sub.count("|") > 2:
|
|
1635
|
+
warns.append(f"the close's subtext runs to {close_sub.count('|') + 1} lines — "
|
|
1636
|
+
f"two lines of tip, one line of address, and no more")
|
|
1637
|
+
|
|
1638
|
+
# -------------------------------------------- 8. the close is an address, not an ad
|
|
1639
|
+
# The CTA is the one line in the deck with gravity: every rewrite pulls it toward
|
|
1640
|
+
# "sign up free today". These are the phrases it lands on, and each one costs
|
|
1641
|
+
# reach on a carousel while adding nothing the address did not already say.
|
|
1642
|
+
INSTRUCTIONS = ("link in bio", "sign up", "free trial", "get started", "book a demo",
|
|
1643
|
+
"download now", "download the app", "learn more", "swipe up",
|
|
1644
|
+
"visit our", "check out", "click", "tap the", "try it", "try now",
|
|
1645
|
+
"grab your", "don't miss", "limited time", "act now", "save this",
|
|
1646
|
+
"follow for more", "comment below")
|
|
1647
|
+
if manifest and not no_close:
|
|
1648
|
+
lines = [l for l in open(manifest) if l.strip() and not l.lstrip().startswith("#")]
|
|
1649
|
+
if lines:
|
|
1650
|
+
close = lines[-1].lower()
|
|
1651
|
+
for phrase in INSTRUCTIONS:
|
|
1652
|
+
if phrase in close:
|
|
1653
|
+
fails.append(f"the close contains '{phrase}' — the CTA is an ADDRESS, not "
|
|
1654
|
+
f"an instruction (Rule 9). The reader can already see the "
|
|
1655
|
+
f"address; the phrase only costs reach")
|
|
1656
|
+
# An outcome promise is the other direction the close drifts in, and it
|
|
1657
|
+
# only ever appears in the subtext — the headline is still a plain tip.
|
|
1658
|
+
close_sub = (lines[-1].split("\t") + ["", ""])[1].lower()
|
|
1659
|
+
for phrase in ("faster", "easier", "never again", "in seconds", "in minutes"):
|
|
1660
|
+
if phrase in close_sub:
|
|
1661
|
+
warns.append(f"the close may be promising an outcome ('{phrase}') — it "
|
|
1662
|
+
f"names what the product IS, never what it does for them")
|
|
1663
|
+
|
|
1664
|
+
# --------------------------------------------------------- 7. the topic rail
|
|
1665
|
+
# The rail is the one element that does nothing for the reader who swipes the
|
|
1666
|
+
# deck in order — so nothing about reading the deck tells you it is missing.
|
|
1667
|
+
# It earns its place when ONE slide travels alone, which is the moment nobody
|
|
1668
|
+
# is testing. Hence a machine check.
|
|
1669
|
+
if topic and brand and brand.lower() in topic.lower():
|
|
1670
|
+
fails.append(f"the topic rail contains the brand '{brand}' — that is N shills on "
|
|
1671
|
+
f"N slides, not one (Rule 11). The rail names the TOPIC")
|
|
1672
|
+
if topic and (len(topic.split()) > 5 or "/" in topic):
|
|
1673
|
+
fails.append(f"topic rail '{topic}' is a counter or over 5 words (Rule 11)")
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
def bandtexture(png, y, h):
|
|
1677
|
+
"""How much of a band deviates from its own median.
|
|
1678
|
+
|
|
1679
|
+
Glyphs deviate hard; a flat page does not, and neither does a
|
|
1680
|
+
hard-blurred photo under a wash. This is a SMOKE TEST — 32px type is
|
|
1681
|
+
below what a 256x8 downscale can characterise properly, so a missing
|
|
1682
|
+
rail is a warn, not a fail, and the contact sheet is the real gate.
|
|
1683
|
+
"""
|
|
1684
|
+
raw = subprocess.run(
|
|
1685
|
+
["ffmpeg", "-v", "error", "-i", png, "-vf",
|
|
1686
|
+
f"crop=1080:{h}:0:{y},format=gray,scale=256:8", "-frames:v", "1",
|
|
1687
|
+
"-f", "rawvideo", "-pix_fmt", "gray", "-"], capture_output=True).stdout
|
|
1688
|
+
if not raw:
|
|
1689
|
+
return 0.0
|
|
1690
|
+
vals = sorted(raw)
|
|
1691
|
+
med = vals[len(vals) // 2]
|
|
1692
|
+
return sum(1 for b in raw if abs(b - med) > 25) / len(raw)
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
rail_tex = [bandtexture(s, RAIL_Y, RAIL_H) for s in slides]
|
|
1696
|
+
railed = list(zip(slides, rail_tex))[1:] if has_cover else list(zip(slides, rail_tex))
|
|
1697
|
+
if has_cover and rail_tex[0] > 0.03:
|
|
1698
|
+
warns.append(f"{os.path.basename(slides[0])}: the COVER has something in the rail "
|
|
1699
|
+
f"band. Slide 1's headline is the title; it carries no rail (Rule 11)")
|
|
1700
|
+
for s, tex in railed:
|
|
1701
|
+
if tex <= 0.03:
|
|
1702
|
+
warns.append(f"{os.path.basename(s)}: no topic rail found at 14%. Every slide "
|
|
1703
|
+
f"but the cover carries it — a slide that travels alone has "
|
|
1704
|
+
f"nothing else naming the list (Rule 11)")
|
|
1339
1705
|
|
|
1340
1706
|
# ------------------------------------------------- 5b. TEXT CONTRAST (readability)
|
|
1341
1707
|
# The one thing a photo background can silently destroy. Measuring beats looking:
|
|
@@ -1351,7 +1717,15 @@ def _lin(v):
|
|
|
1351
1717
|
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
|
1352
1718
|
|
|
1353
1719
|
|
|
1354
|
-
def band_contrast(png, y, h, ink_is_light):
|
|
1720
|
+
def band_contrast(png, y, h, ink_is_light, alpha=1.0):
|
|
1721
|
+
"""Contrast of a band's text against its own background.
|
|
1722
|
+
|
|
1723
|
+
`alpha` is the text layer's opacity. It is NOT cosmetic: a 55%-opacity
|
|
1724
|
+
white is not white, and grading the topic rail as if it were is how a rail
|
|
1725
|
+
that measures 3.1:1 gets signed off as "the headline is 9:1, we're fine".
|
|
1726
|
+
The composite is done in gray against the SAME worst-case patch the
|
|
1727
|
+
background is graded on, which is what the eye sees.
|
|
1728
|
+
"""
|
|
1355
1729
|
raw = subprocess.run(
|
|
1356
1730
|
["ffmpeg", "-v", "error", "-i", png, "-vf",
|
|
1357
1731
|
f"crop=1080:{h}:0:{y},format=gray,boxblur=24:2,scale=48:8",
|
|
@@ -1359,20 +1733,44 @@ def band_contrast(png, y, h, ink_is_light):
|
|
|
1359
1733
|
capture_output=True).stdout
|
|
1360
1734
|
if not raw:
|
|
1361
1735
|
return None
|
|
1362
|
-
vals = sorted(
|
|
1363
|
-
L_ink = 1.0 if ink_is_light else _lin(0x11)
|
|
1736
|
+
vals = sorted(raw)
|
|
1364
1737
|
# 90th / 10th percentile rather than the absolute extreme: one stray blurred
|
|
1365
1738
|
# highlight is not where the text sits, and grading against it fails a deck
|
|
1366
1739
|
# that is genuinely fine.
|
|
1367
|
-
|
|
1740
|
+
g_bg = vals[int(len(vals) * 0.90)] if ink_is_light else vals[int(len(vals) * 0.10)]
|
|
1741
|
+
g_ink = alpha * (255 if ink_is_light else 0x11) + (1 - alpha) * g_bg
|
|
1742
|
+
L_ink, L_bg = _lin(g_ink), _lin(g_bg)
|
|
1368
1743
|
hi, lo = max(L_ink, L_bg), min(L_ink, L_bg)
|
|
1369
1744
|
return (hi + 0.05) / (lo + 0.05)
|
|
1370
1745
|
|
|
1371
1746
|
|
|
1747
|
+
# The rail is graded in EVERY mode, including `paper` — its opacity means it is
|
|
1748
|
+
# the only element whose contrast is a choice rather than a consequence.
|
|
1749
|
+
# Thresholds are lower than the headline's on purpose: at 32px it clears WCAG's
|
|
1750
|
+
# large-text bar (>=24px), and it is SUPPOSED to be quieter than the headline.
|
|
1751
|
+
# A rail as loud as its headline is a second headline.
|
|
1752
|
+
#
|
|
1753
|
+
# The documented default — #111 at 0.55 on a #FDFCFA page — measures ~3.9:1
|
|
1754
|
+
# through this same blur, which is why the warn sits at 3.5 and not at 4.5: a
|
|
1755
|
+
# rail graded like a headline fires on every correct paper deck. If you DO see
|
|
1756
|
+
# the warn on `paper`, the canvas is not the canvas — something is sitting under
|
|
1757
|
+
# the rail band.
|
|
1758
|
+
light = ink == "light"
|
|
1759
|
+
for s, _ in railed:
|
|
1760
|
+
r = band_contrast(s, RAIL_Y, RAIL_H, light, rail_alpha)
|
|
1761
|
+
if r is None:
|
|
1762
|
+
continue
|
|
1763
|
+
if r < 3.0:
|
|
1764
|
+
fails.append(f"{os.path.basename(s)}: topic rail contrast {r:.1f}:1 — below the "
|
|
1765
|
+
f"3:1 large-text floor. Raise --rail-opacity (0.78 on any photo "
|
|
1766
|
+
f"mode) or deepen the wash")
|
|
1767
|
+
elif r < 3.5:
|
|
1768
|
+
warns.append(f"{os.path.basename(s)}: topic rail contrast {r:.1f}:1 — legal but "
|
|
1769
|
+
f"thin. A rail nobody can read is a layer nobody needed")
|
|
1770
|
+
|
|
1372
1771
|
if ink:
|
|
1373
|
-
light = ink == "light"
|
|
1374
1772
|
for s in slides:
|
|
1375
|
-
for name, y, h in (("headline",
|
|
1773
|
+
for name, y, h in (("headline", HEAD_Y, HEAD_H), ("subtext", SUB_Y, SUB_H)):
|
|
1376
1774
|
r = band_contrast(s, y, h, light)
|
|
1377
1775
|
if r is None:
|
|
1378
1776
|
continue
|
|
@@ -1422,9 +1820,13 @@ READER: Lives here, eats out twice a week, keeps ending up somewhere fine
|
|
|
1422
1820
|
OFFER: dishcover.io — search restaurants by DISH instead of by restaurant
|
|
1423
1821
|
ART CLASS: photographic cutout, one sheet, contact shadows off
|
|
1424
1822
|
SHILL: slide 3
|
|
1823
|
+
RAIL: EATING WELL IN NYC (all 6 slides — this deck is coverless, never the brand)
|
|
1824
|
+
SHAPE: coverless (--no-cover). See the note under "The topic rail"
|
|
1825
|
+
CLOSE: slide 6, third subtext line — "dishcover.io — every dish in NYC, searchable"
|
|
1425
1826
|
```
|
|
1426
1827
|
|
|
1427
|
-
**Post caption** (this is where the topic, the bait and the credits live
|
|
1828
|
+
**Post caption** (this is where the topic, the bait and the credits live — the *address* is on
|
|
1829
|
+
slide 6, so the caption never repeats it)
|
|
1428
1830
|
|
|
1429
1831
|
```
|
|
1430
1832
|
6 rules for eating well in NYC 🗽
|
|
@@ -1439,9 +1841,13 @@ Go at *5:30* or go at *9:45*. The kitchen is calmest at the edges|of service. Sa
|
|
|
1439
1841
|
*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
1842
|
*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
1843
|
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
|
|
1844
|
+
*Walk two more blocks.* Every train exit has a rent-funded|trap in the first 200 feet.|dishcover.io — every dish in NYC, searchable media/cutouts/subway-exit.png@34,39,32,-4
|
|
1443
1845
|
```
|
|
1444
1846
|
|
|
1847
|
+
Slide 6 is **the close**: a real tip, with the address on a third subtext line. Delete that line and
|
|
1848
|
+
slide 6 is unchanged — which is the test. Note what the line does *not* say: no verb, no "free", no
|
|
1849
|
+
"link in bio", and no repeat of slide 3's mechanism.
|
|
1850
|
+
|
|
1445
1851
|
**Slide 3, read against the lock**
|
|
1446
1852
|
|
|
1447
1853
|
- *Actionable?* Yes — search a dish name in any search box tonight.
|
|
@@ -1453,10 +1859,12 @@ Trust a *30-item* menu|over a *200-item* one. A short menu means a kitchen that
|
|
|
1453
1859
|
|
|
1454
1860
|
```bash
|
|
1455
1861
|
python3 build-deck.py slides.tsv work/index.html --title="dishcover — NYC eats deck" \
|
|
1456
|
-
--canvas="#FDFCFA"
|
|
1862
|
+
--canvas="#FDFCFA" --no-cover \
|
|
1863
|
+
--topic="EATING WELL IN NYC"
|
|
1457
1864
|
vidfarm qa ./work --harness ./experimental/sticker-slideshow-tips.md
|
|
1458
1865
|
bash export-deck.sh ./work 6 nyc-eats
|
|
1459
1866
|
python3 slides-qa.py ./work/slides --brand dishcover.io --shill-slide 3 \
|
|
1867
|
+
--topic "EATING WELL IN NYC" --no-cover \
|
|
1460
1868
|
--manifest slides.tsv --cutouts ./work/media/cutouts
|
|
1461
1869
|
open ./work/slides/contact-sheet.png # gate 3 — the pass that actually matters
|
|
1462
1870
|
```
|
|
@@ -1471,16 +1879,33 @@ open ./work/slides/contact-sheet.png # gate 3 — the pass that actually mat
|
|
|
1471
1879
|
40px high. Fixed by pinning the band, not by padding the headline.
|
|
1472
1880
|
4. `dishcover.io` was originally in the 800 weight. It read as a banner. Dropped to 500 with the
|
|
1473
1881
|
rest of the subtext and the slide stopped looking like the ad slide.
|
|
1882
|
+
5. The topic rail was first written as `DISHCOVER.IO` — which looked tidy, and was six brand
|
|
1883
|
+
mentions in a deck allowed one. Replaced with `EATING WELL IN NYC` (Rule 11).
|
|
1884
|
+
6. The close was first written `Find better food tonight → dishcover.io (free!)`. Three failures in
|
|
1885
|
+
one line: an outcome the deck cannot keep, an instruction the reader did not need, and a price.
|
|
1886
|
+
Rewritten as the address plus five words saying what it is.
|
|
1887
|
+
7. The rail is the one element whose contrast is set by a number you chose rather than by the
|
|
1888
|
+
mode, so carrying `paper`'s 0.55 opacity into a `photo-darken` variant is the easy mistake:
|
|
1889
|
+
the headline still measures 7:1+ and nothing looks wrong on any slide you open. Run
|
|
1890
|
+
`slides-qa.py` with `--ink light --rail-opacity` set to whatever you actually shipped, and let
|
|
1891
|
+
the rail band be graded on its own.
|
|
1474
1892
|
|
|
1475
1893
|
## Appendix E — the harness in one paragraph, for a handoff
|
|
1476
1894
|
|
|
1477
1895
|
> Build a 6-slide tip carousel, 9:16, 1080×1920, on a flat `#FDFCFA` page — no footage, no
|
|
1478
|
-
> gradients, no cards. Each slide:
|
|
1479
|
-
>
|
|
1480
|
-
>
|
|
1481
|
-
>
|
|
1482
|
-
>
|
|
1483
|
-
>
|
|
1484
|
-
>
|
|
1485
|
-
>
|
|
1896
|
+
> gradients, no cards. Each slide: the deck's topic as a small rail at 14% (`EATING WELL IN NYC`,
|
|
1897
|
+
> uppercase, 32px, weight 500, `+0.16em`, 55% opacity, IDENTICAL on every slide — it is what tells
|
|
1898
|
+
> a screenshotted slide what list it came from, and it is never the brand and never a counter), one
|
|
1899
|
+
> imperative tip as a centred headline (TikTok Sans or Montserrat, two weights 800/500 mixed inside
|
|
1900
|
+
> the sentence, 76px, pinned at 19% from the top, hand-broken lines), an optional reason line at 73%
|
|
1901
|
+
> (40px, 500), and 1–3 die-cut photographic cutouts in the 37–66% band, rotated ±3–8°. No stroke, no
|
|
1902
|
+
> shadow, no plate on any text. Slide 3 names dishcover.io once, lowercase, in the subtext, as the
|
|
1903
|
+
> MECHANISM the tip describes, and is written so it is still a good tip with the brand removed.
|
|
1904
|
+
> Slide 6 is the close: a real tip whose subtext runs to a third line carrying the ADDRESS
|
|
1905
|
+
> (`dishcover.io — every dish in NYC, searchable`) — lowercase, 500 weight, same size as the rest of
|
|
1906
|
+
> the subtext, no verb aimed at the reader, no "free", no "link in bio", and delete-safe (cut that
|
|
1907
|
+
> line and slide 6 is unchanged). Exactly two brand touches in the deck, no more. No title slide;
|
|
1908
|
+
> the full topic and the comment/save bait are the post caption. Ship the six PNGs as the
|
|
1909
|
+
> deliverable (durable My Files, ordered
|
|
1910
|
+
> in `slides.json`) and an MP4 at exactly 3.0s per slide with hard cuts as the preview. Full rules:
|
|
1486
1911
|
> `vidfarm.cc/experimental/sticker-slideshow-tips.md`.
|