@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.
@@ -28,6 +28,15 @@ checks:
28
28
  - learn more
29
29
  # the bait lives in the POST caption, never on the meme:
30
30
  - comment below
31
+ # Rule 10 — context debt. Each of these is an admission that the caption needs
32
+ # something the viewer does not have on the frame. See "The four contexts a
33
+ # viewer does not have". Banned here as literal substrings so gate 1 catches
34
+ # them before a render exists:
35
+ - iykyk
36
+ - if you know you know
37
+ - you already know
38
+ - as promised
39
+ - part 2
31
40
  ---
32
41
 
33
42
  # Meme Recaption
@@ -45,7 +54,7 @@ Two ingredients, no more:
45
54
 
46
55
  | Ingredient | What it is | Where it comes from |
47
56
  |---|---|---|
48
- | 🎬 **The meme** | A borrowed clip everyone already half-recognises, doing one absurd physical thing | `vidfarm.cc/explore` → public raws, `sourceType: MemeScreens` (851 of them, once you page past what the CLI shows you) |
57
+ | 🎬 **The meme** | A borrowed clip everyone already half-recognises, doing one absurd physical thing | `vidfarm.cc/explore` → public raws, `sourceType: MemeScreens` (851 of them reached with `vector_q`, **not** with the CLI, which shows you 200 and does not say so) |
49
58
  | ✍️ **The caption** | One sentence naming a specific lived experience, in the second person or the first | You. This is the only original work in the video, and it is 90% of the outcome |
50
59
 
51
60
  **The offer is named on the video.** First choice: inside the caption, as a character in the setup
@@ -63,14 +72,54 @@ Three questions. **All three must be yes** or you have not got a meme recaption
63
72
 
64
73
  | # | Question | If no |
65
74
  |---|---|---|
66
- | 1 | **Standalone** — Show it to someone who has never heard of the offer. Do they laugh? | You wrote an ad with a meme stapled on |
75
+ | 1 | **Standalone** — Show it to someone who has never heard of the offer, and who did not see your last post, and who has never seen the source meme. Do they laugh? | You wrote something that needs a briefing. See Rule 10 |
67
76
  | 2 | **Problem space** — Is the experience it names one the product actually removes? | You wrote a meme. Funny, worthless. Post it from a personal account |
68
- | 3 | **Enactment** — Is the meme's *physical action* the literal body of the feeling the caption names? | You wrote a caption over stock footage |
77
+ | 3 | **Enactment** — Does the clip *enact* the caption — same situation, told twice, once in a body and once in words? Run the two covers below. **Not "does it suit the vibe"** | You wrote a caption over stock footage. See Rule 3 |
69
78
 
70
- Test 3 is the one editors skip, and it is the one that separates a meme that lands from a caption
71
- sitting on top of a clip. The meme is not an illustration of the caption. **The meme is what the
72
- caption's subject is doing with their body.** If the caption says "quietly giving up" and the clip
73
- is someone dancing, the joke has no floor, and no amount of caption polish will give it one.
79
+ > **Amended.** Test 3 used to read *"is the meme's physical action the literal body of the feeling
80
+ > the caption names?"* That is still what it means, but it was a single look, and a single look
81
+ > cannot fail see the read-order paragraph below. It is now two covers and a comparison, and the
82
+ > word *literal* has been pinned down: **literal about the VERB, never about the NOUNS.** A caption
83
+ > about 10 tabs enacted by someone staring at 10 tabs is a stock photo, not a meme.
84
+
85
+ ### The two-cover test — the enactment test, made testable
86
+
87
+ You cannot check a match by looking at the finished frame, because by the time you look at it you
88
+ already know the answer. So you take the two channels apart, describe each one **cold and in
89
+ writing**, and then compare the two sentences you wrote — not the video.
90
+
91
+ | Step | What you do | What you write down | If it fails |
92
+ |---|---|---|---|
93
+ | **Cover the caption** | Watch the clip alone, muted, with the caption hidden | `clip_reads_blind:` one sentence — what is happening to that person, named by a stranger | The clip is a mood, not an action. **Recast.** No caption rescues a clip that does nothing |
94
+ | **Cover the visual** | Read the caption alone, cold, with no clip at all | `caption_reads_blind:` one sentence — the situation a stranger holds after one read | The caption is a stage direction waiting for a picture. **Rewrite.** The caption must already be a complete joke |
95
+ | **Uncover both** | Read your two sentences side by side | — | **They are two different situations.** This is the defect. Change one of them |
96
+
97
+ Both sentences go in the composition, in the enactment declaration, and gate 2 reads them. See
98
+ "Declare the enactment" under Casting.
99
+
100
+ #### The failure mode, and why it is invisible to you
101
+
102
+ The defect this catches has a name: **vibe-adjacency** — a clip that is emotionally *compatible*
103
+ with the caption without being the same event. A caption about quietly giving up, over a clip of
104
+ someone rolling their eyes: both are "annoyed", both are the right *temperature*, and neither is
105
+ the other one. It is the most common ship-blocking defect in this format, and it is the one the
106
+ author is structurally unable to see.
107
+
108
+ *(Its opposite twin is worth naming here too: a clip that is the same event and also the same
109
+ picture — the caption about 10 tabs over someone glaring at 10 tabs. That one is not adjacent, it
110
+ is redundant, and it is Rule 3.3. Both are failures of the same knob, turned the two different
111
+ ways.)*
112
+
113
+ > **The reason is read order.** You wrote the caption first. So you approach the clip already
114
+ > holding the situation in your head, and you see the clip *as* that situation. Any clip that does
115
+ > not actively contradict the caption looks like a perfect match to you, and only to you. The
116
+ > viewer has no such pre-load: they get 1.2 seconds and two channels, and if the channels are only
117
+ > adjacent, the recognition never fires. They do not think "that's a mismatch" — they feel nothing
118
+ > and keep scrolling, which is why this defect never shows up as a complaint, only as a flat
119
+ > number.
120
+
121
+ That is why the test is written as two covers and a comparison rather than as one look. The two
122
+ covers force you to meet each channel without the other one loading the answer for you.
74
123
 
75
124
  ---
76
125
 
@@ -265,11 +314,111 @@ feel specific when you write them, and only one survives being read by a strange
265
314
  > This test is also **elegance-test question 5** (Rule 1). It is listed twice on purpose: it decides
266
315
  > whether the caption works at all, *and* whether the offer can be named inside it.
267
316
 
317
+ ### The four contexts a viewer does not have
318
+
319
+ The cold-viewer test above covers **one** source of missing context — the product. There are three
320
+ more, and they kill a caption just as dead. The rule underneath all four is Rule 10:
321
+
322
+ > **Every word the caption needs must already be on the frame or already in the viewer's life.
323
+ > Nothing may be borrowed from off-screen.**
324
+
325
+ | # | The context they do not have | What it looks like when you lean on it | Why it is fatal |
326
+ |---|---|---|---|
327
+ | 1 | **The product** | brand names, feature words, category jargon, the founder's demo | Lands only on people who already bought — which defeats a meme ad. *(the existing cold-viewer test)* |
328
+ | 2 | **Your previous post** | `part 2`, `as promised`, `like i said`, a callback to another video | The platform serves this video cold, on purpose. Most people who see video 2 never saw video 1, and the ones who did are the ones you already have |
329
+ | 3 | **The source meme's own lore** | `for 47 seconds`, `and my day is ruined`, naming the character, riffing on what the meme originally meant | Caps your reach at **the meme's fandom**, which is not your niche. The clip is borrowed *equipment*, not a shared reference. Checked by `caption_does_not_borrow_lore` |
330
+ | 4 | **Your own life** | a detail only you can verify — the specific street, the specific colleague, the actual date | This is **rung 4** on the ladder above, arriving by a different road. It is your story, not theirs |
331
+
332
+ **And the phrase that admits all four at once: `iykyk`.** *"If you know, you know"* is a caption
333
+ telling you it did not do its job, while explicitly excluding everyone you needed to reach. It is
334
+ banned in the front matter, so gate 1 stops it before a render exists.
335
+
336
+ **The mechanical version:** hand the caption, as plain text, to someone who has seen none of your
337
+ posts, does not know the product, and has never seen the clip. If they need one question answered
338
+ before they get it, the caption is not finished. **A cold viewer with zero setup gets it, or it
339
+ does not ship.**
340
+
268
341
  **Get the specifics from the customer, not from your imagination.** Their testimonials, their
269
342
  support inbox and their landing-page copy are full of rung-3 sentences written by actual users. In
270
343
  the worked example in Appendix D, the shipped caption is a lightly compressed version of a
271
344
  testimonial printed on the customer's own homepage — which also happens to satisfy Rule 6.
272
345
 
346
+ ### Strictness vs relatability — the two rules above do NOT mean "play it safe"
347
+
348
+ Read Rule 3 and Rule 10 naively and you get this:
349
+
350
+ > ❌ `me when my code doesn't work`
351
+
352
+ It passes both. It is enacted by literally any frowning clip. It needs no context at all. It is
353
+ also completely dead, and if this file let you ship it, the file would be worse than useless. So
354
+ this section is not optional colour — it is the half of the format the strict rules do not carry.
355
+
356
+ **The diagnosis:** that caption is standalone *because* it is universal, and universal is the same
357
+ thing as unfelt. The ladder already says so at rung 1: *"true of everyone, felt by no one."*
358
+ Tightening for context and tightening for specificity feel like the same move — they are opposites.
359
+
360
+ #### The one distinction that resolves it: context vs detail
361
+
362
+ > **Context is what the viewer must be TOLD. Detail is what the viewer already HAS.
363
+ > Rule 10 turns context to zero. The ladder turns detail as high as it goes. They are two dials.**
364
+
365
+ A safe, dead caption is what you get by turning both down at once — which is the default mistake,
366
+ because at 2am both feel like "things I'm assuming the reader knows".
367
+
368
+ **The test that separates them, one question per specific in your caption:**
369
+
370
+ > **Who had to have been there?**
371
+
372
+ | The specific | Who had to have been there | Verdict |
373
+ |---|---|---|
374
+ | `10 tabs` | anyone who has ever tried to pick dinner | **Detail.** Keep it. Add more |
375
+ | `since 7pm` | anyone with an evening | **Detail** |
376
+ | `one scallop roll` | anyone who has wanted one exact thing | **Detail** — and it is the best word in the caption |
377
+ | `the v2 sync` | someone who bought the product | **Context.** Cut |
378
+ | `part 2` | someone who saw your last post | **Context.** Cut |
379
+ | `for 47 seconds` | someone who has seen the source meme | **Context.** Cut |
380
+ | `that place on ludlow` | you | **Context.** Cut — this is rung 4 |
381
+
382
+ Detail is *shared* and specific. Context is *private* and specific. They look identical while you
383
+ are typing, which is exactly why you write the question down and answer it per word.
384
+
385
+ #### Strictness makes the caption MORE relatable, in two mechanical ways
386
+
387
+ Not as a consolation. As arithmetic:
388
+
389
+ 1. **The word budget.** A caption is 8–20 words and it spends every one of them on either context
390
+ or detail. Every word you win back by cutting context is a word you can spend on detail —
391
+ **so spend it.** Cutting `on resy` and not replacing it leaves a weaker caption; cutting it and
392
+ buying `since 7pm` leaves a stronger one. **Deleting context without re-spending the refund is
393
+ how a correct edit produces a worse video**, and it is the most common way this section gets
394
+ misread.
395
+ 2. **The cover-the-caption step will not accept a flat line.** You have to write down, in one
396
+ sentence, the situation a stranger holds after reading the caption cold. *"A person is sad"* is
397
+ not a situation and will not survive being compared to a clip's blind read — the two sentences
398
+ have nothing to agree on. **The test that looks like it would flatten the caption is the test
399
+ that refuses a flat one.**
400
+
401
+ #### And the same distinction on the visual side
402
+
403
+ > **Enactment is about the VERB, not the WORLD. The verb must match; the world must not.**
404
+
405
+ - The clip must **do** the caption's verb — deflate, blank, loop, stare.
406
+ - The clip must **not depict** the caption's nouns. Ten tabs, a laptop, a restaurant, a
407
+ spreadsheet: if the clip shows the thing the caption is about, you have filmed a re-enactment,
408
+ and a re-enactment is a stock photo with a joke written under it.
409
+
410
+ The comedy lives in the gap between where the feeling came from (10 tabs at 7pm) and the body it
411
+ arrives in (a hungover dog). That gap is the same one "Match the INTENSITY" is talking about, and
412
+ it is why `caption_does_not_borrow_lore` fails a caption that reuses a word from the clip's slug:
413
+ a caption that names what the clip literally *is* has closed the gap.
414
+
415
+ #### If you cut the context and the caption goes generic
416
+
417
+ Then you never had a caption — you had a reference. **Go back to Rule 6.** A sentence a real user
418
+ actually said is reliably both context-free and specific, because they said it to a stranger who
419
+ also had none of the context. That is why testimonials outperform anything you would invent, and
420
+ it is the cheapest route out of a caption that got safe.
421
+
273
422
  ### Banned captions
274
423
 
275
424
  - **The product as the thing that WORKS.** The name may appear as a subject in the setup (Rule 1,
@@ -283,58 +432,262 @@ testimonial printed on the customer's own homepage — which also happens to sat
283
432
  - Two-part setups split across a cut. That is a different format and it is weaker.
284
433
  - Anything naming a real competitor doing something bad. Personify a *category* ("every delivery
285
434
  app"), not a company you can be sued by.
435
+ - **Anything with context debt** (Rule 10): `iykyk`, `if you know you know`, `you already know`,
436
+ `part 2`, `as promised`, `like i said`, `see my last`. Each one asks the viewer for something
437
+ they do not have. Banned in the front matter and checked by `caption_no_context_debt`.
438
+ - **Anything riffing on the source meme's own lore.** The clip is borrowed equipment, not a shared
439
+ reference. If the caption only works for people who recognise the meme, its ceiling is that
440
+ meme's fandom. Checked by `caption_does_not_borrow_lore`.
441
+ - **Anything that narrates the clip.** `me quietly giving up` over a clip of someone quietly giving
442
+ up is a subtitle, not a joke — the caption's job is to supply the *situation* the body is
443
+ reacting to, and a clip that already contains the situation leaves it nothing to do.
286
444
 
287
445
  ---
288
446
 
289
447
  ## Casting the meme
290
448
 
291
- ### Browse the shelf. Do not search it.
449
+ ### Two channels: the slug is the IDENTITY, the vector is the BODY
450
+
451
+ The shelf answers two completely different questions and it answers them through two different
452
+ fields. Casting badly is almost always the result of asking one of them the other's question.
453
+
454
+ | You want to know | Ask | Why |
455
+ |---|---|---|
456
+ | **which meme is this** | the `slugId` | The AI descriptions are blind to the meme's identity — see the warning below |
457
+ | **what does the body DO** | `vector_q`, semantic search | The embedding is built from the description **plus the action / emotion / motion / subject tags**, which is exactly the verb axis this format casts on |
292
458
 
293
459
  > ⚠️ **The descriptions on this shelf were written by a model that has never heard of the meme.**
294
- > This is not a small problem — it is the defining fact of sourcing this format.
295
- >
296
460
  > `my-brain-every-10-seconds` is described as *"A man in a suit gestures and looks at the camera
297
461
  > with a neutral expression."* `jonah-jameson-laugh-you-serious` is described as *"A solid green
298
- > screen background."* A semantic query for `hungry food eating` returns **zero results** on a shelf
299
- > that contains a dozen clips of people eating.
462
+ > screen background."*
300
463
  >
301
- > **The `slugId` is the index. The description is noise.** Pull the whole shelf as JSON, read the
302
- > slugs, and only look at descriptions to break a tie.
464
+ > **The `slugId` is the index of identity. The description is noise about identity.** But that same
465
+ > description says *what the body is doing*, and **that is the only thing this format casts on.** A
466
+ > model that does not know it is looking at J. Jonah Jameson can still tell you the man is laughing
467
+ > in your face — so the descriptions are worthless for the question you were not going to ask and
468
+ > useful for the one you were.
469
+
470
+ #### 🚨 `--query` is LEXICAL. Semantic search is a different parameter, and the CLI has no flag for it
471
+
472
+ This is the single most expensive mistake in sourcing this format, and an earlier draft of this file
473
+ made it and drew the wrong conclusion from it.
474
+
475
+ `vidfarm public-raws --query "…"` sends `?q=`, and `q` is a **substring filter that runs first**. It
476
+ throws away every clip whose text does not literally contain your words, and only *then* does the
477
+ server rank whatever survived. On a shelf whose descriptions never use your vocabulary, the filter
478
+ empties the set before the ranking gets to see it — so a perfectly good semantic query returns
479
+ nothing, and it looks like semantic search is broken. It is not. It never ran.
303
480
 
304
481
  ```bash
305
- vidfarm public-raws --categories # the shelves + live counts
306
- vidfarm public-raws --category greenscreen --limit 200 # 193 of these 200 are MemeScreens
307
- # ...and this is only the FIRST 200. See below
308
- vidfarm public-raws --category text-graphics --limit 200
309
- vidfarm public-raws --category lifestyle --limit 200 # also almost entirely MemeScreens
482
+ # lexical filter. Measured: 0 results, on a shelf full of blank stares
483
+ vidfarm api GET "/api/v1/public-raws?category=greenscreen&limit=8&q=blank%20stare%20confused"
484
+
485
+ # semantic. Same shelf, same words, ranked by MEANING
486
+ vidfarm api GET "/api/v1/public-raws?category=greenscreen&limit=8&vector_q=blank%20stare%20confused%20no%20comprehension"
487
+ # cat-staring · mrbean-wtf-greenscreen · james-franco-shocked · spongebob-panting …
310
488
  ```
311
489
 
490
+ > **Pass `vector_q` and do NOT pass `q`.** The server defaults `vector_q` to `q` when you omit it,
491
+ > which means the CLI's `--query` gets you the lexical filter *and* the ranking — and the filter has
492
+ > already deleted the answer. Sending only `vector_q` skips the filter and ranks the whole shelf.
493
+ >
494
+ > **`vidfarm public-raws` cannot do this.** It maps `--query` to `q` and has no vector flag. Every
495
+ > semantic cast in this file goes through `vidfarm api GET`.
496
+
497
+ **It needs a login and an embedding key, and it fails SILENTLY without one.** The ranking requires a
498
+ logged-in customer with a saved gemini or openai provider key. With no key — or logged out — the
499
+ server skips the ranking, returns **HTTP 200 with a full page of results**, and says nothing. You
500
+ get the shelf in recency order wearing the costume of a search result.
501
+
502
+ ```bash
503
+ vidfarm whoami # must show a login
504
+ vidfarm api GET /api/v1/user/me/provider-keys | grep -iE "gemini|openai"
505
+ ```
506
+
507
+ **Confirm the ranking is live before you trust a cast:** query `arms up jumping celebration confetti`
508
+ and query `shoulders drop giving up defeated sigh`. If the top result is the same clip for both, the
509
+ embedder is not running and you are reading a recency list.
510
+
511
+ #### Semantic search reaches the whole shelf. The CLI reaches a quarter of it
512
+
312
513
  > 🚨 **`vidfarm public-raws` shows you 200 clips and stops, and it does not tell you.** The server
313
514
  > caps a shelf at 200 per call and returns a `next_cursor` — but the CLI has **no `--cursor` flag**,
314
- > so raising `--limit` to 1200 still returns exactly 200. Paging the same shelves through
315
- > `vidfarm api GET` instead turns **200 clips into 959** (851 of them MemeScreens). Cast from the
316
- > first 200 and you are picking from under a quarter of the library, with nothing anywhere saying so.
317
- >
318
- > **Build the full index once (Appendix C) and cast from the TSV, not from the CLI.**
515
+ > so raising `--limit` to 1200 still returns exactly 200.
516
+
517
+ `vector_q` on a **curated shelf** (`greenscreen`, `reaction`, `lifestyle`, `text-graphics`, …) is
518
+ not a rerank of that 200. The server scans **1500 records** for a curated category and ranks all of
519
+ them, so one call sees essentially the entire MemeScreens library and hands you its top N.
520
+
521
+ Measured on the `greenscreen` shelf, ten verb queries against one plain `--limit 200` page:
522
+
523
+ | | |
524
+ |---|---|
525
+ | Distinct clips the ten queries returned | **143** |
526
+ | Of those, **not present** in the CLI's first 200 | **93 — 65%** |
527
+ | Of those, hallucinated / not really on the shelf | **0.** All 143 verified against the fully-paged 693 |
528
+
529
+ **Two thirds of what semantic search finds is invisible to the CLI**, and the two thirds it hides
530
+ are not the boring ones — they are the animal and anonymous clips (`cat-staring`, `cat-sleeping`,
531
+ `hangover-dog`) that are 🟢 **safe tier**, which is the tier client work is supposed to default to.
532
+ Casting from the first 200 quietly pushes you toward the celebrity clips you are not allowed to use.
533
+
534
+ ```bash
535
+ vidfarm public-raws --categories # the shelves + live counts. Start here
536
+ vidfarm public-raws --category greenscreen --limit 200 # the first 200 only — for a browse, not a cast
537
+ ```
319
538
 
320
539
  **Every shelf the CLI exposes is dominated by MemeScreens** — `b-roll`, `lifestyle` and
321
540
  `text-graphics` all come back as greenscreen memes. Good news for this format, bad news for
322
541
  backgrounds (see below).
323
542
 
543
+ ### The diversity sweep — one query per verb, then dedupe
544
+
545
+ > **A single vector query is a relevance tool. It is the OPPOSITE of a diversity tool.** It returns
546
+ > the twenty clips nearest one point, and near-neighbours are near-duplicates: four `cat-sitting`
547
+ > variants of the same cat. Cast ten videos off one query and you will ship ten videos that look
548
+ > like one video.
549
+
550
+ Diversity comes from **querying ten different points and taking the union.** The ten points are
551
+ already written down — they are the ten verb tokens in the table below, which is why the list is
552
+ closed. One query per verb, top 20 each, deduped, is a 143-clip casting pool spread across the whole
553
+ shelf by meaning instead of by upload date.
554
+
555
+ ```python
556
+ #!/usr/bin/env python3
557
+ """meme-sweep.py — one vector query per verb token -> a deduped casting pool.
558
+
559
+ Needs a vidfarm login + a saved gemini/openai key, or the ranking silently
560
+ does not happen (see above). Run it once per session; it is ~10 API calls.
561
+ """
562
+ import json, subprocess, urllib.parse
563
+
564
+ VERBS = {
565
+ "deflate": "shoulders drop eyes go flat accepting a bad outcome without a fight",
566
+ "blank": "total non-comprehension head tilt dead eyes nothing processing",
567
+ "loop": "the same thought going round again mechanically forever repetitive",
568
+ "smug": "arms folded self-satisfied already solved it insufferable",
569
+ "overreact": "full body screaming sobbing flailing at something trivial",
570
+ "correct": "turning to camera to tell you patiently that you are wrong",
571
+ "unbothered": "completely relaxed doing nothing while chaos happens calm",
572
+ "celebrate": "arms up jumping celebration confetti scale joy",
573
+ "stare": "holding eye contact with the camera saying nothing silent",
574
+ "wait": "still waiting visibly longer than is reasonable impatient",
575
+ }
576
+ SHELVES = ("greenscreen", "reaction", "lifestyle", "text-graphics")
577
+
578
+ def get(path):
579
+ out = subprocess.run(["vidfarm", "api", "GET", path], capture_output=True, text=True).stdout
580
+ brace = out.find("{") # first line is a coloured "GET … → 200" status line
581
+ return json.JSONDecoder().raw_decode(out[brace:])[0] if brace >= 0 else {}
582
+
583
+ pool, tops = {}, {}
584
+ for shelf in SHELVES:
585
+ for verb, q in VERBS.items():
586
+ d = get(f"/api/v1/public-raws?category={shelf}&limit=20"
587
+ f"&vector_q={urllib.parse.quote(q)}") # vector_q ONLY. Never q
588
+ raws = d.get("raws", [])
589
+ if raws:
590
+ tops.setdefault(shelf, []).append(raws[0]["rawId"])
591
+ for rank, raw in enumerate(raws):
592
+ slug = raw.get("slugId") or raw["rawId"]
593
+ pool.setdefault(slug, {"verb": verb, "rank": rank, "id": raw["rawId"],
594
+ "desc": (raw.get("description") or "")[:90]})
595
+
596
+ # The ranking fails SILENTLY (no login / no embedding key) and still returns a
597
+ # full page. The only reliable tell is differential: ten different queries that
598
+ # rank must not all return the same clip first. Do NOT test this by looking for
599
+ # a slugId — several shelves legitimately return bare UUIDs.
600
+ for shelf, first in tops.items():
601
+ if len(set(first)) < len(first) / 2:
602
+ raise SystemExit(f"{shelf}: {len(set(first))} distinct top hits from {len(first)} queries — "
603
+ "the embedder is not running. Check `vidfarm whoami` and your gemini/openai key.")
604
+
605
+ with open("casting-pool.tsv", "w") as f:
606
+ f.write("slug\tverb\trank\trawId\tdescription\n")
607
+ for slug, v in sorted(pool.items(), key=lambda kv: (kv[1]["verb"], kv[1]["rank"])):
608
+ f.write(f"{slug}\t{v['verb']}\t{v['rank']}\t{v['id']}\t{v['desc']}\n")
609
+ print(len(pool), "distinct clips -> casting-pool.tsv")
610
+ ```
611
+
612
+ **Then cast off the TSV, and cast off a verb you have not shipped this week:**
613
+
614
+ ```bash
615
+ awk -F'\t' '$2=="unbothered"' casting-pool.tsv | cut -f1,5 # the whole neighbourhood, one verb
616
+ cut -f2 casting-pool.tsv | sort | uniq -c # how deep each verb actually goes
617
+ ```
618
+
619
+ **Three rules for keeping the pool diverse, all of them cheap:**
620
+
621
+ 1. **Drop the near-duplicate families.** `cat-sitting-5e58…` and `cat-sitting-8edd…` are one casting
622
+ choice, not two. Collapse on the slug stem (the slug minus its trailing hex) before you count
623
+ your options, or you will believe you have 143 and be choosing among about 110.
624
+ 2. **Take the top ~20 per verb, never the top 60 of one.** The tail of one query is further from
625
+ that verb than the head of the next query is — depth on one point buys worse clips, breadth
626
+ across points buys better ones.
627
+ 3. **Subtract the delivery log before you look, not after.** `join` the pool against
628
+ `DELIVERED.tsv` so a retired clip is never on the sheet you are choosing from. A clip you have
629
+ already sent is not an option you rejected, it is an option you should not have seen.
630
+
631
+ ```bash
632
+ cut -f5 CLIPPER/DELIVERED.tsv | sort -u > /tmp/used.txt
633
+ grep -vFf /tmp/used.txt casting-pool.tsv > casting-pool.fresh.tsv # cast from THIS
634
+ ```
635
+
636
+ > **The sweep is how "there was nothing else" stops being sayable.** Rule 11 asks you not to reuse a
637
+ > clip you have already delivered; the honest objection has always been that finding a fresh one
638
+ > meant paging a 693-clip shelf by hand. It is now ten API calls and a TSV.
639
+
640
+ > **Delivery work: open your delivery log in the same breath as the sweep.** Anything you already
641
+ > sent this client is out (Rule 11), and anything that has been passed on twice is out for everybody.
642
+ > **A clip you have already used is not a shortcut, it is the shelf you did not read** — and the
643
+ > sweep hands you 143 candidates, so "there was nothing else" is almost never the real reason.
644
+ >
645
+ > ```bash
646
+ > cut -f1,5,8 CLIPPER/DELIVERED.tsv # client · clip · verdict — read it BEFORE the contact sheet
647
+ > ```
648
+ >
649
+ > Reusing a clip that has *earned* it, on a different client, is correct and encouraged. See
650
+ > *Across clients* under Rule 11.
651
+
324
652
  ### The enactment test, in practice
325
653
 
326
654
  You are casting for a **verb**, not a mood. Write down the verb your caption's subject is doing
327
- with their body, then find the slug that does that verb:
655
+ with their body, then find the slug that does that verb.
328
656
 
329
- | The caption's verb | Slugs that enact it |
330
- |---|---|
331
- | deflating / accepting a bad outcome | `my-disappointment-is-immeasurable-and-my-day-is-ruined`, `jonah-hill-sigh`, `girl-eyerolls` |
332
- | not understanding at all | `huh-cat`, `hangover-dog`, `zoolander-staring`, `nervous` |
333
- | the same thought on a loop | `my-brain-every-10-seconds`, `monkey-cymbal-jolly-chimp`, `zoning-out` |
334
- | smug, having solved it | `gigachad`, `chad-head-bopping`, `check-out-the-big-brain-on-brad` |
335
- | overreacting to something tiny | `neon-screaming`, `jontron-nooooooo`, `girl-fake-crying` |
336
- | telling you you're wrong | `jonathan-frakes-telling-you-youre-wrong-for-47-seconds`, `women-mocking` |
337
- | unbothered while it burns | `my-favorite-thing-in-the-world-to-do-is-nothing`, `boss-baby-chilling` |
657
+ **The verb comes from a closed list of ten tokens.** Not because ten is the number of human
658
+ feelings, but because a free-text verb is not comparable to anything — and the whole point of the
659
+ enactment test is a comparison. A closed token makes "does the clip do what the caption does" a
660
+ string equality instead of a judgement, which is the only form of it a gate can enforce:
661
+
662
+ | Verb token | The body doing it | Slugs that enact it |
663
+ |---|---|---|
664
+ | `deflate` | shoulders drop, eyes go flat, the bad outcome is accepted without a fight | `my-disappointment-is-immeasurable-and-my-day-is-ruined`, `jonah-hill-sigh`, `girl-eyerolls` |
665
+ | `blank` | total non-comprehension — head tilt, dead eyes, nothing processing | `huh-cat`, `hangover-dog`, `zoolander-staring`, `nervous` |
666
+ | `loop` | the same thought going round again, mechanically, forever | `my-brain-every-10-seconds`, `monkey-cymbal-jolly-chimp`, `zoning-out` |
667
+ | `smug` | arms folded, already solved it, insufferable about it | `gigachad`, `chad-head-bopping`, `check-out-the-big-brain-on-brad` |
668
+ | `overreact` | a full-body scream, sob or flail at something trivial | `neon-screaming`, `jontron-nooooooo`, `girl-fake-crying` |
669
+ | `correct` | turning to camera to tell you, patiently, that you are wrong | `jonathan-frakes-telling-you-youre-wrong-for-47-seconds`, `women-mocking` |
670
+ | `unbothered` | the room is on fire and the body has not moved | `my-favorite-thing-in-the-world-to-do-is-nothing`, `boss-baby-chilling` |
671
+ | `celebrate` | arms up, jumping, confetti-scale joy | 🔎 `celebration-dance`, `girl-dancing`, `jojo-siwa-dancing` — the natural win-pole verb |
672
+ | `stare` | holding eye contact with the camera and saying nothing | 🔎 `cat-staring` (🟢), `mortal-kombat-1-shang-tsung` (🔴) — the `<Entity> watching me …` verb (Rule 1, tier 1) |
673
+ | `wait` | still present, still waiting, visibly longer than is reasonable | 🔎 `wait-hold-on-wait` — for captions whose collapse is elapsed time |
674
+
675
+ **The queries that produce this table are the `VERBS` dict in `meme-sweep.py` above** — the table is
676
+ its cached output, not a separate list to maintain. Re-run the sweep and you regenerate it.
677
+
678
+ > 🔎 **marks a slug found by the vector sweep and NOT yet confirmed on a contact sheet.** The first
679
+ > seven rows were cast by hand and shipped; the last three were empty until the sweep filled them,
680
+ > and the sweep ranks descriptions, not footage. **Build the sheet before you use one** — that rule
681
+ > does not relax because a machine agreed with you.
682
+
683
+ **Do not add an eleventh token** to avoid the work of choosing among ten. If a clip genuinely does
684
+ something none of the ten covers, that is a real finding: add the token here, **with its body line
685
+ and its vector query**, before you use it.
686
+
687
+ > **The sweep also grades the token list.** `deflate` and `celebrate` came back with 20 and 19 clips
688
+ > the other nine queries had not already found; `stare` and `wait` came back with 9 each. A verb
689
+ > whose neighbourhood is that thin is a verb the shelf cannot really cast — treat a low count as a
690
+ > warning that you are about to force a clip, not as a challenge.
338
691
 
339
692
  **Then confirm by eye.** Build a contact sheet before you commit — a slug promises an action the
340
693
  clip does not always deliver, and half these clips are 16:9 with the subject small at the bottom:
@@ -343,6 +696,63 @@ clip does not always deliver, and half these clips are 16:9 with the subject sma
343
696
  ffmpeg -v error -i meme.mp4 -vf "fps=1,scale=240:-1,tile=7x1" -frames:v 1 sheet.png
344
697
  ```
345
698
 
699
+ ### Declare the enactment — in the composition, where the gate can read it
700
+
701
+ The two-cover test is a human act. What a script can enforce is that **you actually performed it**
702
+ and that the answers are self-consistent. So the answers live in `composition.html`, at the top,
703
+ above the first layer:
704
+
705
+ ```html
706
+ <!-- meme-recaption: enactment
707
+ clip_slug: my-disappointment-is-immeasurable-and-my-day-is-ruined
708
+ caption_verb: deflate
709
+ clip_verb: deflate
710
+ feeling_size: small
711
+ clip_size: large
712
+ clip_reads_blind: a man in a suit goes flat and accepts something without arguing
713
+ caption_reads_blind: someone gives up on dinner after an hour and settles for pizza
714
+ -->
715
+ ```
716
+
717
+ **It is an HTML comment, not a layer, on purpose.** A text layer would count against
718
+ `max_text_cards`, would need a `data-start`, and — worst — could render. This must never reach a
719
+ pixel. It is build metadata that happens to live in the file the gate already reads, so there is
720
+ no second file to lose.
721
+
722
+ | Field | Rule | Why the gate can hold you to it |
723
+ |---|---|---|
724
+ | `clip_slug` | the raw's slug, as cast | Feeds `caption_does_not_borrow_lore` — the caption may not reuse a word from it |
725
+ | `caption_verb` / `clip_verb` | both from the ten tokens, and **equal** | This is Test 3 as a string equality. Two different tokens is a declared mismatch and the build stops |
726
+ | `feeling_size` / `clip_size` | `small` or `large`, and **different** | "Match the INTENSITY" made enforceable — see below |
727
+ | `clip_reads_blind` | ≥5 words, written while the caption was covered, and it may **not reuse a content word from the caption** | The cover-the-caption step, on the record — and the one field the gate can genuinely interrogate |
728
+ | `caption_reads_blind` | ≥5 words, written while the clip was covered, and not the same sentence as `clip_reads_blind` | The cover-the-visual step, on the record |
729
+
730
+ #### Why `clip_reads_blind` may not borrow the caption's words
731
+
732
+ This is the check with actual teeth, and the reason is the read-order problem again:
733
+
734
+ > **If the only way you can describe the clip is with the caption's own nouns, one of two things is
735
+ > true, and both are fatal.** Either you never saw the clip cold — you projected the caption onto
736
+ > it, which is exactly the blindness the two covers exist to break — or the clip really does depict
737
+ > the caption's nouns, which is a re-enactment and fails Rule 3.3.
738
+
739
+ A correct pair reads with almost no vocabulary in common, because the two channels are *supposed*
740
+ to be different worlds:
741
+
742
+ - ✅ caption `when i've had 10 tabs open since 7pm for one scallop roll and we're getting pizza again`
743
+ · clip `a man in a suit goes flat and accepts something without arguing` — nothing shared, and
744
+ they are obviously one situation
745
+ - ❌ clip `a guy who has had 10 tabs open gives up` — you wrote the caption again with "a guy" in
746
+ front of it. `clip_read_is_independent` fails
747
+
748
+ > **What no gate can check, and this file will not claim otherwise:** whether the two sentences
749
+ > describe the *same* situation. Lexical overlap does not measure it — an early draft of this
750
+ > section required the two blind reads to share a word, and a correct pair shares none, so the
751
+ > check would have fired on every good build and been switched off within a day. **The comparison
752
+ > is gate 3. It always was.** What the declaration buys is that the comparison becomes a discrete
753
+ > act with a written output, performed before you have seen the finished frame — not a feeling you
754
+ > had at 2am with both channels already loaded in your head.
755
+
346
756
  ### Match the INTENSITY, not just the verb
347
757
 
348
758
  The enactment test gets you the right action. This gets you the right *size* of it — and it is
@@ -357,6 +767,17 @@ where the comedy actually comes from.
357
767
  - Matching a big feeling to a big reaction is not a joke, it is a re-enactment. Matching a small
358
768
  feeling to a small reaction is not anything at all.
359
769
 
770
+ **Declare both sizes and they must differ.** `feeling_size` and `clip_size` in the enactment block
771
+ are `small` or `large`, and `intensity_mismatched` fails when they are equal. Two coarse buckets,
772
+ not a scale, because a scale invites a 3-vs-4 that means nothing; the question the format actually
773
+ asks is only ever *"is the reaction the wrong size for the cause?"* — and the answer to that is
774
+ binary.
775
+
776
+ > **This does not contradict "the verb must match".** The verb is *what* the body does; the size is
777
+ > *how much*. Enactment requires the same verb; comedy requires a different amount of it. A caption
778
+ > about a mildly annoying evening (`small`) enacted by a man whose day is immeasurably ruined
779
+ > (`large`) is both rules satisfied at once — same verb, wrong size — and it is the worked example.
780
+
360
781
  **Choose the pain or win that the clip's energy already fits, rather than fighting the footage.**
361
782
  You have 851 clips and one caption you have not written yet; it is far cheaper to pick the feeling
362
783
  that suits a great clip than to hunt for a clip that suits a feeling you are attached to. When a
@@ -395,21 +816,68 @@ class before you write a single ffmpeg command.**
395
816
  | Where the caption goes | On the background, above the subject | On a plate above the clip, outside the picture |
396
817
  | Share of the shelf | The large majority | A minority — check for burned-in text on the contact sheet |
397
818
 
398
- **Check the plate colour before you key anything.** These clips are re-encodes and the plate is not
399
- always what it looks like:
819
+ ### Decide the class MECHANICALLY, then read the plate
400
820
 
401
- ```bash
402
- python3 -c "
403
- import subprocess,numpy as np
404
- p=subprocess.run(['ffmpeg','-v','error','-i','meme.mp4','-vf','fps=1,crop=200:200:20:20',
405
- '-pix_fmt','rgb24','-f','rawvideo','-'],capture_output=True)
406
- a=np.frombuffer(p.stdout,np.uint8).reshape(-1,200,200,3)
407
- m=a.reshape(-1,3).mean(0).astype(int); print('plate rgb',m,'hex %02X%02X%02X'%tuple(m))"
821
+ > **The class is not a judgement call and it must not be one.** "Is there a plate" has to be
822
+ > answered before "what colour is the plate", or you key a Class B clip against a muddy average of
823
+ > somebody's living room and punch holes in it. **Nothing downstream catches that** — the key
824
+ > "works", a subject is found, the render is quietly ruined, and `no_residual_green` passes because
825
+ > there was never any green.
826
+
827
+ Run this on every clip, before Appendix A. It answers both questions in the right order:
828
+
829
+ ```python
830
+ #!/usr/bin/env python3
831
+ """plate-check.py <meme.mp4> — Class A or Class B, and the plate if there is one."""
832
+ import subprocess, sys, numpy as np
833
+
834
+ src, W, H = sys.argv[1], 320, 320
835
+ p = subprocess.run(["ffmpeg", "-v", "error", "-i", src, "-vf", f"fps=2,scale={W}:{H}",
836
+ "-pix_fmt", "rgb24", "-f", "rawvideo", "-"], capture_output=True)
837
+ a = np.frombuffer(p.stdout, np.uint8)
838
+ a = a[: len(a) // (W * H * 3) * (W * H * 3)].reshape(-1, H, W, 3).astype(np.int16)
839
+
840
+ # All four corners, not just the top-left: on a keyed clip every corner is plate,
841
+ # on a baked clip they are four different bits of a room.
842
+ c = np.concatenate([a[:, :24, :24], a[:, :24, -24:],
843
+ a[:, -24:, :24], a[:, -24:, -24:]], axis=2).reshape(-1, 3)
844
+ plate = c.mean(0).astype(int)
845
+ flat = c.std(0).mean() # one colour, or a scene
846
+ cover = (np.abs(a - plate).max(3) < 40).mean() # how much of the frame IS plate
847
+ sat = int(plate.max() - plate.min()) # letterbox bars read flat too
848
+
849
+ print(f"plate rgb {plate.tolist()} hex {'%02X%02X%02X' % tuple(plate)} "
850
+ f"corner stdev {flat:.1f} coverage {cover*100:.0f}% saturation {sat}")
851
+
852
+ if flat > 12 or cover < 0.25 or sat < 60:
853
+ sys.exit("CLASS B — no saturated flat plate. Do NOT key. Layout A, crop the burned-in text")
854
+
855
+ digital = plate[1] > 240 and plate[0] < 40 and plate[2] < 40
856
+ print("CLASS A — " + ("digital plate: chromakey 0.18:0.03, NO despill" if digital
857
+ else "filmed plate: chromakey 0.30:0.08, light despill"))
408
858
  ```
409
859
 
410
- A pure `00FF00` reading means a digital plate: key tight (`0.18:0.03`) and **skip despill.** A
411
- reading like `1F D3 1F` means a filmed plate: key looser and despill lightly. Getting this backwards
412
- is the first defect in the list below.
860
+ **The three thresholds and what each one is for:** `flat > 12` is a scene wearing a plate's
861
+ costume, `cover < 0.25` is a plate too small to be the background, `sat < 60` is black or white
862
+ letterbox bars, which are flat and are not a plate. All three are first-pass numbers measured on one
863
+ shelf — **calibrate them against a clip you already agree about before you let them fail anything.**
864
+
865
+ **Then read the colour, because these clips are re-encodes and the plate is not always what it looks
866
+ like.** A pure `00FF00` reading means a digital plate: key tight (`0.18:0.03`) and **skip despill.**
867
+ A reading like `1F D3 1F` means a filmed plate: key looser and despill lightly. Getting this
868
+ backwards is the first defect in the list below.
869
+
870
+ > **The shelf is overwhelmingly green, and the gate only knows green.** `no_residual_green` (gate 2)
871
+ > tests a green hue, so it catches both defects that matter on this shelf — a fringe from a tight
872
+ > key, and a key that never ran at all. `no_unkeyed_plate` sits beside it and is hue-agnostic, for
873
+ > the blue or magenta plate the green test is blind to. **If `plate-check.py` prints a plate that is
874
+ > not green, say so in the build log** — the named check is no longer the one protecting you.
875
+
876
+ > **Why this harness does not use `vidfarm remove-greenscreen` for the video.** The skill's keying
877
+ > surface (`remove-greenscreen --local` / `--smart`, `cutout`, `sticker-pack`) is the right answer
878
+ > almost everywhere else, and it is a **transparent-output** surface. This format never needs
879
+ > transparency in its output — it composites onto a background and ships an opaque MP4 — and the
880
+ > alpha intermediate is where the free path breaks. See the warning under "Cost-saving mode".
413
881
 
414
882
  ### Class B — dealing with the burned-in text
415
883
 
@@ -713,10 +1181,14 @@ are checked (`offer_not_in_payoff`, `caption_does_not_resolve`).
713
1181
  2. Is it still **≤20 words** with the offer in it?
714
1182
  3. Does the offer read as a **character**, or as a sponsor who bought a slot?
715
1183
  4. Does the **collapse still land on the person**?
716
- 5. Would it still be funny to someone who has **never heard of the offer**?
1184
+ 5. Would it still be funny to someone who has **never heard of the offer**, **did not see your last
1185
+ post**, and has **never seen the source meme**? (Rule 10 — all four contexts, not just the
1186
+ product)
717
1187
 
718
1188
  Question 5 is the one that catches most failures. If the joke needs the reader to know what the
719
1189
  product does, the product is not a character — it is an explanation, and explanations are not funny.
1190
+ The same is true of the other three borrowed contexts: each one turns the caption into something
1191
+ that has to be introduced, and nobody introduces a meme.
720
1192
 
721
1193
  #### The handle (tier 2), specced
722
1194
 
@@ -763,10 +1235,32 @@ the last clause; on the win pole it may be in both, and the discipline moves to
763
1235
  of it. **If you did not decide the pole, you are on the pain pole** — it is the safer default and the
764
1236
  one that travels.
765
1237
 
766
- ### Rule 3 — cast for the verb, not the mood
1238
+ ### Rule 3 — the clip ENACTS the caption. Same verb, wrong size, different world
1239
+
1240
+ Written above as the two-cover test; repeated here because it is the rule most often lost between
1241
+ casting and the timeline, and because "cast for the verb" alone was too easy to satisfy.
767
1242
 
768
- Written above as the enactment test; repeated here because it is the rule most often lost between
769
- casting and the timeline. Name the physical action in the build log before you open the shelf.
1243
+ > **Amended.** Rule 3 used to read *"cast for the verb, not the mood"*, and that is still the first
1244
+ > half of it. It was not enough: a verb named only in the build log is a verb nobody compares, so
1245
+ > the rule passed every time it was read. Rule 3 is now three claims, all of which are declared in
1246
+ > the composition and two of which the gate can fail you on.
1247
+
1248
+ 1. **Same verb.** `caption_verb` and `clip_verb` are the same token from the closed list of ten.
1249
+ Not similar. The same. — `enactment_verbs_match`
1250
+ 2. **Wrong size.** `feeling_size` ≠ `clip_size`. The mismatch between the size of the cause and the
1251
+ size of the reaction is where the comedy actually is. — `intensity_mismatched`
1252
+ 3. **Different world.** The clip does the caption's verb; it must not depict the caption's nouns. A
1253
+ clip that shows the thing the caption is about is a re-enactment, and the gap is the joke. —
1254
+ partially caught by `caption_does_not_borrow_lore`
1255
+
1256
+ **And the test you run before any of that:** cover the caption, cover the visual, write one
1257
+ sentence for each, compare the sentences. The failure it exists for is vibe-adjacency, and the
1258
+ reason it is written as two covers is that you read the caption first and can no longer see the
1259
+ clip cold. Both sentences go in the composition, and `clip_reads_blind` may not reuse a content
1260
+ word from the caption. — `enactment_declared`, `clip_read_is_independent`
1261
+
1262
+ **The comparison itself is yours.** No script decides whether two sentences name one situation.
1263
+ The gate makes sure you wrote them, wrote them cold, and did not write the caption twice.
770
1264
 
771
1265
  ### Rule 4 — the action lands AFTER the caption is read, and frame 0 has a person on it
772
1266
 
@@ -825,6 +1319,108 @@ same complaint — so ask for exactly that.
825
1319
  > Post caption pattern: `<the caption's situation, restated in one line>. <one question inviting
826
1320
  > their version>`
827
1321
 
1322
+ ### Rule 10 — the caption stands alone. Zero setup, zero borrowed context
1323
+
1324
+ **A cold viewer with nothing gets it, or it does not ship.** Nothing they see was explained
1325
+ anywhere else, because nowhere else is reachable: the platform hands them one muted 7-second video
1326
+ between two strangers' videos, and everything the joke needs has to already be on that frame or
1327
+ already in their life.
1328
+
1329
+ Four things you may not borrow — the product, your previous post, the source meme's lore, and your
1330
+ own private history. They are set out with their reasons under "The four contexts a viewer does not
1331
+ have". `iykyk` is banned outright, in the front matter, because it is the caption admitting it
1332
+ failed while excluding the people you needed.
1333
+
1334
+ **Rule 10 is not a licence to go generic, and read carelessly it produces exactly that.** Context
1335
+ and detail are two separate dials: Rule 10 turns *context* to zero, the specificity ladder turns
1336
+ *detail* to maximum, and the caption gets more relatable, not less, as long as you **re-spend the
1337
+ words you win back.** Cutting `on resy` and stopping there leaves a weaker caption; cutting it and
1338
+ buying `since 7pm` leaves a stronger one. The one question that sorts a word into the right dial is
1339
+ **"who had to have been there?"** — see "Strictness vs relatability", which is the load-bearing
1340
+ half of this rule.
1341
+
1342
+ Checked by `caption_no_context_debt` and `caption_does_not_borrow_lore`, and by the front matter's
1343
+ `forbid_text` list at gate 1.
1344
+
1345
+ ### Rule 11 — one client never gets the same meme, or the same joke, twice
1346
+
1347
+ **This rule only applies when you are delivering to somebody** — a marketplace buyer, a retainer
1348
+ client, a customer's own account. On an account you own, ignore it and run the matrix.
1349
+
1350
+ > **No two videos delivered to the same client may share a `clip_slug`, a caption frame, or a pain —
1351
+ > unless that client asked for a series, in writing.**
1352
+
1353
+ Three separate keys, because this format repeats in three separate ways and only the first one is
1354
+ obvious:
1355
+
1356
+ | Key | The repeat | Why it is fatal here specifically |
1357
+ |---|---|---|
1358
+ | `clip_slug` | the same borrowed clip twice | It is not *similar* footage, it is the **same footage**. The buyer recognises it instantly, and the second video looks like the first one with the words changed — which is exactly what it is |
1359
+ | the caption frame | `When I …` twice, or `<Entity> when they …` twice | There are only **eight**, so two pains in one frame land as one joke. The words differ and the shape does not, and the shape is what a viewer remembers |
1360
+ | the pain | the same complaint restaged | You sold one insight three times. This is the one the buyer notices last and resents most |
1361
+
1362
+ **The rights tier compounds it.** 🟢 is the default for client work, which is a small shelf, so a
1363
+ clipper working one buyer for a month runs out of safe clips faster than they expect and starts
1364
+ reaching for one they already used. Cast against your delivery log, not against your memory. When
1365
+ the 🟢 shelf really is exhausted for that client, that is a signal to change the *pain* and cast
1366
+ fresh — not to re-run a clip.
1367
+
1368
+ **Your style repeats. Your joke does not.** The caption plate, the font, the 62% band, the grade on
1369
+ the background, the handle — that is the visual identity the client hired and it should be identical
1370
+ across every delivery. Rule 11 is about the two ingredients at the top of this file: the meme and
1371
+ the caption. Nothing else.
1372
+
1373
+ **The exception, and how narrow it is.** A client who asked for a series, a variant set, or "five
1374
+ cuts of this one joke" has asked for controlled repetition, and then repeating is the deliverable.
1375
+ It must be **written** — in the task payload, in the gig terms, or in their feedback on a proof they
1376
+ kept (`"More like this one."` counts). **An approval is not an ask.** A meme that worked is a reason
1377
+ to write another meme, not a reason to send the same one again.
1378
+
1379
+ #### Across clients — reuse a great raw, retire a mid one
1380
+
1381
+ Rule 11 is a hard rule *within* one client. Across clients it relaxes into a preference, and the
1382
+ thing that decides it is **not novelty, it is how the raw has actually performed.**
1383
+
1384
+ > **A raw that lands is an asset. Carry it to the next buyer. A raw that was only ever okay gets one
1385
+ > outing, and then it is done.**
1386
+
1387
+ Refusing to reuse a proven clip in the name of variety throws away the only edge casting gives you.
1388
+ Five buyers who each see `hangover-dog` once is five good videos. The failure is the other one, and
1389
+ it is specific: **one mediocre clip appearing in most of your recent deliveries**, because it was
1390
+ nearby and you had already keyed it. That is not a style, it is a rut, and it is visible to everyone
1391
+ except you.
1392
+
1393
+ | What the raw has collected in `DELIVERED.tsv` | What you may do with it |
1394
+ |---|---|
1395
+ | nothing — never delivered | **one use.** It is unproven no matter how good the contact sheet looked |
1396
+ | an approval, no passes | **reuse it across other clients**, once each. This is what a proven raw is for |
1397
+ | passed on twice, never approved | **retire it.** Two buyers said no to that face. The third will too |
1398
+ | mixed | a fit question, not a quality one. Reuse it only where the pain genuinely matches |
1399
+
1400
+ **The shelf is public, which makes this sharper than it looks.** You cast from `vidfarm public-raws`
1401
+ and so does every other clipper on the marketplace. A slug that is easy to find is easy for everyone
1402
+ to find, so the buyer may have seen it this week from somebody else — and 851 MemeScreens means the
1403
+ excuse "there was nothing else" is almost never true. **Run the diversity sweep before you reach for
1404
+ a clip you have already used**; it costs ten API calls and it deletes the excuse.
1405
+
1406
+ **And note what "easy to find" now means.** Every clipper on this marketplace reads the same CLI, and
1407
+ the CLI shows everybody the same first 200. If you cast from that page you are competing for 200
1408
+ clips while 65% of the shelf sits unread. The sweep is not only how you avoid repeating *yourself* —
1409
+ it is how you avoid arriving at the buyer with the same clip as everyone who did not run it.
1410
+
1411
+ **The number, if you want one:** the same `clip_slug` in more than about **3 of your last 20
1412
+ deliveries** is a signature you did not choose. Either it has the approvals to justify it, or you
1413
+ are casting from memory instead of from the shelf.
1414
+
1415
+ ```bash
1416
+ cut -f5 CLIPPER/DELIVERED.tsv | tail -20 | sort | uniq -c | sort -rn | head
1417
+ ```
1418
+
1419
+ **No gate checks this and none can.** Gate 1 sees one composition, gate 2 sees one MP4, and neither
1420
+ has ever seen the other videos you sent that buyer. It is a **ledger** rule, not a frame rule: one
1421
+ row per delivery, checked before you cast. The marketplace-wide version, with the file format, is
1422
+ `https://vidfarm.cc/marketplace.md` § *never send one client the same video twice*.
1423
+
828
1424
  ---
829
1425
 
830
1426
  ## Cost-saving mode — the whole build at $0
@@ -887,8 +1483,56 @@ interchangeable.
887
1483
  is one ffmpeg command and the caption layer does not change. Do that before you write a second
888
1484
  caption — you will learn more from five castings of one joke than from five jokes.
889
1485
 
890
- Keep the index (Appendix C) and a `captions.tsv` of rung-3 lines. The pairing is the creative act;
891
- everything downstream is a loop.
1486
+ > ⚠️ **Take the five clips from five DIFFERENT verb neighbourhoods, not from the top five of one
1487
+ > query.** This is where a matrix quietly stops being an experiment. The top five of one vector
1488
+ > query are five near-neighbours — often the same performer in four crops — so the five videos
1489
+ > differ by almost nothing and the five data points are one data point with error bars. Pull one
1490
+ > clip per verb from `casting-pool.fresh.tsv` and the five castings actually span the shelf.
1491
+
1492
+ Keep three files: the identity index (Appendix C), a fresh `casting-pool.fresh.tsv` from the sweep,
1493
+ and a `captions.tsv` of rung-3 lines. **The sweep is the only one of the three that goes stale** —
1494
+ re-run it per session, because it is also the file that has your delivery log subtracted from it.
1495
+ The pairing is the creative act; everything downstream is a loop.
1496
+
1497
+ ### ⚠️ The matrix is YOUR test set. It is not one client's inbox
1498
+
1499
+ Everything above is a testing discipline for an account you own, where five castings of one joke are
1500
+ five data points and the audience never sees them as a set. **The moment these become proofs on the
1501
+ marketplace, that arithmetic inverts.** A buyer does not see five data points. They open a swipe deck
1502
+ and see the same joke five times from the same name, and the fifth card is not a fifth chance — it is
1503
+ the reason they stop opening your cards.
1504
+
1505
+ > **Vary the clip if you are testing. Vary the JOKE if you are delivering.**
1506
+
1507
+ | | You are testing (your own account, one client's paid campaign, an asked-for A/B set) | You are delivering (proofs to a buyer) |
1508
+ |---|---|---|
1509
+ | One caption × five clips | ✅ the point of the exercise | ❌ one idea in five costumes |
1510
+ | Five pains × one clip | ✅ | ❌ the buyer sees your one clip five times |
1511
+ | What varies between proofs | one variable, deliberately | the **pain**, the **caption**, and the **clip**, all three |
1512
+ | Who sees the set | an algorithm | one person, in one sitting, next to each other |
1513
+
1514
+ **So build the matrix, ship one cell of it.** The matrix is still the right way to work — sit down
1515
+ with the testimonials, write ten pains, cast twenty clips, and pair them. Then send this buyer the
1516
+ one best pairing, and hold the rest for the next brief or the next buyer. The pairings you did not
1517
+ send are not waste; they are your inventory, and they are why the second delivery is fast.
1518
+
1519
+ **This format repeats more invisibly than any other in the set, for two reasons.** The clip is
1520
+ borrowed, so two videos built from the same `slugId` are *literally the same footage* — not similar,
1521
+ identical. And the caption sits in one of only eight frames, so two different pains written in the
1522
+ same frame land as the same joke even when every word differs. `When I …` twice in a row is one
1523
+ voice; `When I …` then `<Entity> watching me …` is two.
1524
+
1525
+ **Log the creative key on every delivery, per client.** Four fields, and no two proofs to one buyer
1526
+ may match on any of them (see Rule 11):
1527
+
1528
+ ```
1529
+ client · clip_slug · caption frame · pain
1530
+ ```
1531
+
1532
+ `clip_slug` is already in the enactment declaration and the frame and the pain are already in the
1533
+ caption, so logging this costs one line appended to `CLIPPER/DELIVERED.tsv` at submit time.
1534
+ **Read it back before you cast, not before you render** — the clip is the part you cannot edit, and
1535
+ finding the repeat after the composite is a rebuild.
892
1536
 
893
1537
  ---
894
1538
 
@@ -899,9 +1543,15 @@ different things. **Running only the first one is the same as running none.**
899
1543
 
900
1544
  | Gate | Tool | Sees | Catches |
901
1545
  |---|---|---|---|
902
- | 1. Markup | `vidfarm qa <dir> --harness ./experimental/meme-recaption.md` | the composition DOM | duration, aspect, font regime, banned strings, layer counts. **22 checks, zero pixels** |
903
- | 2. Render | `python3 meme-qa.py <render>.mp4 <composition>.html` (Appendix E) | the finished MP4 | blank thumbnail, empty tail, residual green, caption centring/balance/placement, background not pushed down, silent audio, caption grammar |
904
- | 3. Human | your eyes | the joke | the three-way lock. **No script will ever check whether it is funny** |
1546
+ | 1. Markup | `vidfarm qa <dir> --harness ./experimental/meme-recaption.md` | the composition DOM | duration, aspect, font regime, banned strings, layer counts. **Zero pixels.** One check per `forbid_text` phrase, so the total moves whenever that list grows — do not quote a number here, it will be wrong within a month |
1547
+ | 2. Render | `python3 meme-qa.py <render>.mp4 <composition>.html` (Appendix E) | the finished MP4 **and the enactment declaration** | blank thumbnail, empty tail, residual green, caption centring/balance/placement, background not pushed down, silent audio, caption grammar, context debt, borrowed lore, and the declared enactment |
1548
+ | 3. Human | your eyes, and a pen | the joke | **the two covers**, written down and compared — then the three-way lock. **No script will ever check whether it is funny** |
1549
+
1550
+ **And one rule sits outside all three, on purpose: Rule 11.** Every gate here sees exactly one video.
1551
+ Whether you have already sent this client that clip, that caption frame or that pain is a fact about
1552
+ your *other* videos, so no check that opens this composition can ever reach it. It lives in
1553
+ `CLIPPER/DELIVERED.tsv` and it is checked **before you cast**, which is also the only moment it is
1554
+ cheap.
905
1555
 
906
1556
  > 🚨 **Gate 1 passes on a broken video.** Every defect this format actually produces is invisible to
907
1557
  > a DOM check. During the run that produced this file, a build passed `vidfarm qa` **19/19 while
@@ -927,7 +1577,8 @@ What gate 2 enforces, and the number it uses:
927
1577
  | Check | Threshold | The defect it exists for |
928
1578
  |---|---|---|
929
1579
  | `first_frame_has_subject` / `last_frame_has_subject` | lower-band stdev > 12 | Blank thumbnail / empty tail from un-trimmed plate |
930
- | `no_residual_green` | < 0.5% green pixels | A key that was too tight |
1580
+ | `no_residual_green` | < 0.5% green pixels | A key that was too tight — a fringe on the shelf's usual `00FF00` plate |
1581
+ | `no_unkeyed_plate` | largest flat saturated colour < 25% of frame, **or** `--flat-bg-ok` | The key never ran, or it ran on a blue/magenta plate the green test is blind to. Pass `--flat-bg-ok` only on ladder tier 6, where a flat brand plate really is the background |
931
1582
  | `caption_horizontally_centred` | block centre 50% ± 3% | The flush-left plate with a lake of dead space |
932
1583
  | `caption_side_margins` | > 4% and < 96% | A plate touching an edge |
933
1584
  | `caption_vertical_placement` | inside 8–85% | Under the platform UI, or on the subject's face |
@@ -936,7 +1587,7 @@ What gate 2 enforces, and the number it uses:
936
1587
  | `background_not_blown_out` | top-strip luma < 150 | A bright background eating a white caption |
937
1588
  | `audio_level` | −20…−13 LUFS, **or** < −50 with `--silent-ok` | A silent clip shipped as if it had sound |
938
1589
  | `caption_word_count` | 8–20 words | Slogan, or unreadable in 0.6s |
939
- | `caption_uses_a_frame` | one of the six | A caption that is a statement, not an experience |
1590
+ | `caption_uses_a_frame` | one of the **eight** frames | A caption that is a statement, not an experience |
940
1591
  | `caption_no_cta_or_resolution` | no CTA strings, no `so i` / `then i found` | Rules 1 and 2 — the ad leaking back in |
941
1592
  | `caption_lines_hand_broken` | a `<br>` is present | Renderer wrap landing mid-thought |
942
1593
  | `offer_named_somewhere` | in the caption **or** in the handle | An untraceable meme that builds someone else's page. Also prints which tier you are on |
@@ -947,14 +1598,31 @@ What gate 2 enforces, and the number it uses:
947
1598
  | `handle_is_dimmed` | alpha ≤0.85 *(only if a handle exists)* | Same |
948
1599
  | `single_shot` | exactly 1 `<video>` | "There are no beats" |
949
1600
  | `no_animation_on_caption` | no `animation` in `.memecap` | Kinetic captions, which break the read order |
1601
+ | `caption_no_context_debt` | none of `iykyk` / `if you know you know` / `you already know` / `part 2` / `as promised` / `like i said` / `see my last` … | Rule 10 — a caption asking the viewer for setup they were never given |
1602
+ | `enactment_declared` | the `<!-- meme-recaption: enactment -->` block, all seven fields, both blind reads ≥5 words and not the same sentence | Rule 3 — the two-cover test not run at all. An undeclared enactment is not a passing enactment |
1603
+ | `enactment_verbs_match` | `caption_verb` == `clip_verb`, both in the ten tokens | Rule 3.1 — vibe-adjacency, declared out loud. The clip does one thing and the caption is about another |
1604
+ | `intensity_mismatched` | `feeling_size` ≠ `clip_size`, both `small`/`large` | Rule 3.2 — a big feeling on a big reaction is a re-enactment; a small one on a small one is nothing |
1605
+ | `clip_read_is_independent` | `clip_reads_blind` shares no content word with the caption (stemmed, ≥4 chars, not a stopword) | Rule 3 — you described the clip with the caption's nouns, so either you never saw it cold or it is a re-enactment. **The gate cannot check that the two reads name one situation** — that stays gate 3 |
1606
+ | `caption_does_not_borrow_lore` | no content token of `clip_slug` (≥4 chars, stemmed, minus the offer's name) appears in the caption | Rule 10, context 3 — a joke that needs the source meme. Also catches the literal case, where the caption names what the clip already *is* |
950
1607
 
951
1608
  **Pass `--offer <domain>`** or the handle checks are skipped silently — an unset offer is not a
952
1609
  passing offer. **Pass `--pole win`** only when the caption is deliberately a win meme; the gate
953
1610
  defaults to `pain`, the stricter of the two, so nobody accidentally ships a resolution.
954
1611
 
955
- **Two things gate 2 deliberately does NOT check**, because a script cannot: whether the meme enacts
956
- the caption's verb, and the rights tier. Those stay on the human checklist below, and the rights
957
- tier stays in the build log in writing.
1612
+ > **Amended gate 2 now reaches into enactment, halfway.** This paragraph used to say gate 2
1613
+ > deliberately does not check *"whether the meme enacts the caption's verb, and the rights tier,
1614
+ > because a script cannot."* Half of that was true and half was an excuse. A script cannot judge a
1615
+ > match; it **can** hold you to a declaration and check the declaration against itself. So the
1616
+ > enactment block is now mandatory and five checks run on it, and the sentence is narrowed:
1617
+ >
1618
+ > **What gate 2 still cannot do** is tell you the declaration is *honest* — that the sentence you
1619
+ > wrote about the clip is what a stranger would actually write. That is gate 3, and it is why the
1620
+ > two covers stay on the human checklist below. **The rights tier is unchanged and remains
1621
+ > unchecked**, in the build log, in writing.
1622
+ >
1623
+ > Do not read the five green enactment lines as "the match is verified". Read them as "the
1624
+ > comparison was performed and its answers do not contradict each other". That is a much smaller
1625
+ > claim, and it is the one the gate is entitled to make.
958
1626
 
959
1627
  **Calibrate a checker before you trust it.** Two of these thresholds were wrong on their first
960
1628
  version and fired on a correct build — `caption_lines_balanced` measured pixel rows instead of text
@@ -969,15 +1637,42 @@ you have already looked at and agree is good, and make it pass before you let it
969
1637
 
970
1638
  Run this **before** you build. An unchecked box is a rewrite, not a fix in the edit.
971
1639
 
1640
+ **The client** — delivery work only. Skip the whole block on an account you own
1641
+ - [ ] `CLIPPER/DELIVERED.tsv` read for this client **before casting**, not before submitting
1642
+ - [ ] `clip_slug` not already delivered to them (Rule 11 — the same slug is the *same footage*)
1643
+ - [ ] Caption frame not already delivered to them — there are only eight
1644
+ - [ ] The pain not already delivered to them, restaged
1645
+ - [ ] If any of the three repeat: the client asked for a series **in writing**, and you can quote it
1646
+ - [ ] **Reusing this clip on a DIFFERENT client?** It has an approval on it, or it is unproven and
1647
+ this is its one outing. Passed on twice with no approval = retired
1648
+ - [ ] This `clip_slug` is not in more than ~3 of your last 20 deliveries
1649
+ - [ ] **The diversity sweep ran this session** and you cast off `casting-pool.fresh.tsv`, not off
1650
+ the CLI's first 200 and not off memory
1651
+ - [ ] The sweep's ranking was **live** — `vidfarm whoami` shows a login, a gemini/openai key is
1652
+ saved, and two different verb queries returned two different top clips. No key = no ranking,
1653
+ silently, with a full page of results to hide it
1654
+ - [ ] The style — plate, font, band, grade, handle — is **identical** to the last delivery. That part
1655
+ is meant to repeat
1656
+
972
1657
  **The joke**
973
1658
  - [ ] **Pole chosen and written down** — pain or win (default pain)
974
1659
  - [ ] If win: the comedy is the size of the relief, not the feature
975
1660
  - [ ] `WHO:` and `MOMENT:` are written at the top of the build log
976
- - [ ] Test 1 — someone who has never heard of the offer laughs
1661
+ - [ ] Test 1 — someone with no knowledge of the offer, your last post, or the source meme laughs
977
1662
  - [ ] Test 2 — the experience named is one the product removes
978
- - [ ] Test 3 — the meme's physical verb IS the caption's feeling
979
- - [ ] The clip's INTENSITY mismatches the feeling's size (that mismatch is the joke)
1663
+ - [ ] Test 3 — **the two covers are written down**, not just felt:
1664
+ - [ ] Cover the caption `clip_reads_blind:` one sentence, what a stranger names in the clip
1665
+ - [ ] Cover the visual → `caption_reads_blind:` one sentence, the situation the caption leaves
1666
+ - [ ] The two sentences are **the same situation**, told twice. Not adjacent. Not compatible
1667
+ - [ ] `caption_verb` == `clip_verb`, both from the ten tokens
1668
+ - [ ] `feeling_size` ≠ `clip_size` — the clip's INTENSITY mismatches the feeling's (that is the joke)
1669
+ - [ ] The clip does the caption's **verb** but does not depict its **nouns** (no re-enactment)
1670
+ - [ ] `clip_reads_blind` reuses **none** of the caption's words (if it does, you projected)
1671
+ - [ ] All seven enactment fields are in `composition.html` as an HTML comment, not a layer
980
1672
  - [ ] Cold-viewer test — cover the brand and every feature word; does it still read?
1673
+ - [ ] **Rule 10** — nothing borrowed from the product, your last post, the meme's lore, or your life
1674
+ - [ ] Every specific survived **"who had to have been there?"** — detail kept, context cut
1675
+ - [ ] Context cut, and the freed words **re-spent on detail** (not just deleted)
981
1676
  - [ ] The caption is on rung 3: a number, a proper noun, a deflating outcome
982
1677
  - [ ] The numbers and proper nouns are traceable to the customer's own copy
983
1678
  - [ ] One of the eight frames, unmixed, subject first, 8–20 words, no end punctuation
@@ -985,6 +1680,8 @@ Run this **before** you build. An unchecked box is a rewrite, not a fix in the e
985
1680
 
986
1681
  **The clip**
987
1682
  - [ ] Rights tier declared. 🟢 for client work, or a 🟢 alternate is built alongside
1683
+ - [ ] **`plate-check.py` run, and the class it printed is the class you built** — Class A keys and
1684
+ needs a background, Class B is never keyed and needs the burned-in text cropped
988
1685
  - [ ] Plate colour sampled, not assumed
989
1686
  - [ ] Contact sheet built and looked at
990
1687
  - [ ] The action lands 0.6–1.0s in, and frame 0 has the subject on it (`-vframes 1`, looked at)
@@ -1002,6 +1699,8 @@ Run this **before** you build. An unchecked box is a rewrite, not a fix in the e
1002
1699
  - [ ] 1080×1920, 30fps
1003
1700
  - [ ] The meme is NOT cropped to 9:16
1004
1701
  - [ ] Layout A or B chosen deliberately; in B the subject is bottom-anchored and full width
1702
+ - [ ] **Class A: the plate is gone and a background is behind the subject.** No green edge on the
1703
+ hair or shoulders, no green in the gaps between limbs, nothing green in the last frame
1005
1704
  - [ ] Caption **block centred**, words left-aligned, lines hand-broken to near-equal length
1006
1705
  - [ ] Caption fully legible at frame 0, static, plated, clear of the subject's head
1007
1706
  - [ ] Poster layer present, so frame 0 is never bare background
@@ -1014,8 +1713,14 @@ Run this **before** you build. An unchecked box is a rewrite, not a fix in the e
1014
1713
  - [ ] Audio kept and normalised, measured on the render — or deliberately silent and said so
1015
1714
  - [ ] **Gate 1:** `vidfarm qa <dir> --harness ./experimental/meme-recaption.md`
1016
1715
  - [ ] **Gate 2:** `python3 meme-qa.py <render>.mp4 <dir>/composition.html` exits 0
1017
- - [ ] **Gate 3:** you watched it
1716
+ - [ ] **Gate 3 the whole-render pass, in this order:**
1717
+ - [ ] Watch it muted with the caption covered. Does the clip still *do something namable*?
1718
+ - [ ] Read the caption alone, out loud, cold. Is it already a complete joke?
1719
+ - [ ] Watch it whole, once, at speed. Did the recognition fire, or did you only *approve* of it?
1720
+ - [ ] Ask the last one honestly: **would a stranger need one question answered?** If yes, Rule 10
1018
1721
  - [ ] Post caption written, carrying the one comment ask
1722
+ - [ ] **Delivery work:** one row appended to `CLIPPER/DELIVERED.tsv` — `client · clip_slug · frame ·
1723
+ pain`. An unlogged delivery is the repeat you will ship next week
1019
1724
 
1020
1725
  ---
1021
1726
 
@@ -1026,11 +1731,18 @@ Diagnose by charge. Do not declare the video bad.
1026
1731
  | Symptom | The weak charge | The actual cause, in order of likelihood |
1027
1732
  |---|---|---|
1028
1733
  | Almost no views | Hook | The first three words are generic · the frame-0 image is not weird enough · the caption is too long to read in 0.6s |
1029
- | Views, everyone leaves at 3s | Loop / enactment | The action landed before the caption was read (Rule 4) · the meme does not enact the verb (Test 3) |
1734
+ | Views, everyone leaves at 3s | Loop / enactment | The action landed before the caption was read (Rule 4) · the meme does not enact the verb (Rule 3, test 3) |
1735
+ | **Watched to the end. Nothing. No leave, no laugh, no share** | **Enactment** | **Vibe-adjacency.** The clip was compatible with the caption instead of being the same event. This is the default cause and it is the one you cannot see, because you read the caption first. Run the two covers on the shipped video — write both sentences down before you look at them together |
1030
1736
  | Watched to the end, no reaction | Payoff / specificity | Rung 2. Add the number and the proper noun · or the joke resolves (Rule 2) |
1737
+ | Only your existing followers engaged; no cold reach | **Rule 10** | Context debt. The caption borrowed from your last post, or the product, or the meme's lore. The algorithm did try strangers; the strangers needed a briefing |
1738
+ | It reached people who love the meme, not people with the problem | Rule 10, context 3 | The joke was about the meme, not about their week. Ceiling = the meme's fandom (`caption_does_not_borrow_lore`) |
1739
+ | "True but not funny" · reads like a caption on a stock photo | Rule 3.2 / 3.3 | The sizes matched (a big feeling on a big reaction = re-enactment) · or the clip depicted the caption's nouns instead of its verb |
1740
+ | Nobody in the niche felt seen; it could be about anyone | **Strictness overshot** | You cut context and did not re-spend the words on detail. Rung 1. Go back to Rule 6 and take a sentence a real user said |
1031
1741
  | Good watch time, dead comments | Bait | No post caption ask · or the ask is a poll, not an invitation to add their own version |
1032
1742
  | Comments are "what is this an ad for" | Rule 1 | The product leaked in — a logo, a screenshot, a category noun in the caption |
1033
1743
  | Reach collapsed after 2 hours | Audio / rights | A music-sync meme got muted · a 🔴 clip got claimed |
1744
+ | **The client passed on a video that would have worked on its own** | **Rule 11** | Repetition. They already had this clip, this caption frame, or this pain from you. It is judged against your *last* delivery, not on its own — check `DELIVERED.tsv` for the collision, then change the pain and cast fresh |
1745
+ | The client's approvals dropped off across a batch, from the first card to the last | **Rule 11** | You shipped a row of the matrix instead of one video. Five castings of one joke is a test set, not a delivery |
1034
1746
 
1035
1747
  ---
1036
1748
 
@@ -1100,9 +1812,19 @@ def frames(path, w, h, vf):
1100
1812
  n = len(p.stdout) // (w * h * 3)
1101
1813
  return np.frombuffer(p.stdout, np.uint8)[:n * w * h * 3].reshape(n, h, w, 3).astype(np.int16)
1102
1814
 
1103
- # --- 1. read the plate colour off the top-left corner, which is always plate ---
1815
+ # --- 1. confirm there IS a plate, THEN read its colour off the corner ---
1816
+ # The order is load-bearing. Handed a Class B clip (its own world, no plate),
1817
+ # every line below still runs: it keys against a muddy average of somebody's
1818
+ # living room, step 2 finds a "subject", and the render ships with holes punched
1819
+ # in it. Nothing catches that downstream — no_residual_green passes, because
1820
+ # there was never any green. So refuse here, not at render.
1104
1821
  corner = frames(meme, 200, 200, "fps=1,crop=200:200:20:20")
1822
+ flat = corner.reshape(-1, 3).std(0).mean()
1105
1823
  plate = corner.reshape(-1, 3).mean(0).astype(int)
1824
+ if flat > 12 or int(plate.max() - plate.min()) < 60:
1825
+ sys.exit(f"corner stdev {flat:.1f}, plate {plate.tolist()} — not a flat saturated "
1826
+ "chroma plate. This is CLASS B: do not key it. Use Layout A and crop "
1827
+ "the burned-in text. (Run plate-check.py for the full read.)")
1106
1828
  digital = bool(plate[1] > 240 and plate[0] < 40 and plate[2] < 40)
1107
1829
  key_hex = "0x%02X%02X%02X" % tuple(plate)
1108
1830
  tol, soft, despill = (0.18, 0.03, False) if digital else (0.30, 0.08, True)
@@ -1178,11 +1900,21 @@ def probe_key(tol_try, soft_try, despill_try, t):
1178
1900
  lum = flat.mean(1)
1179
1901
  lit = flat[lum > max(lum.max() * 0.75, 1)] # the subject's bright side
1180
1902
  green_left = ((flat[:, 1] - flat[:, 0] > 40) & (flat[:, 1] - flat[:, 2] > 40)).mean()
1181
- return lit.mean(), green_left
1903
+ # A radius that erased the whole subject leaves NO bright pixels, and
1904
+ # `lit.mean()` on an empty slice is nan, not 0. nan then loses every
1905
+ # comparison below in silence: `nan > 0.88` is False, so the radius is
1906
+ # rejected for the right reason by accident — but if the UN-keyed probe
1907
+ # comes back nan too, every `keep` is nan, `best` stays None, and the run
1908
+ # falls through to the sampled default with only a printed line. Return a
1909
+ # real 0.0 so a dead frame scores like a dead frame.
1910
+ return (float(lit.mean()) if lit.size else 0.0), green_left
1182
1911
 
1183
1912
  t_probe = START + DUR / 2
1184
1913
  # chromakey's similarity floor is 1e-05, not 0 — a literal 0 is a hard filter error
1185
1914
  src_lit, _ = probe_key(1e-5, 0.0, False, t_probe) # ~nothing keyed = the truth
1915
+ if not src_lit:
1916
+ sys.exit(f"the un-keyed probe frame at {t_probe:.2f}s has no bright pixels — "
1917
+ "the clip is dark or the presence window is wrong. Check it by eye")
1186
1918
  best = None
1187
1919
  for tol_try in (0.34, 0.28, 0.22, 0.18, 0.14, 0.10, 0.07):
1188
1920
  r = probe_key(tol_try, soft, despill, t_probe)
@@ -1246,6 +1978,19 @@ A static, plated card: block centred, words left-aligned, no animation, `data-st
1246
1978
  This is the exact markup from the worked example, which measures 49.8% block centre.
1247
1979
 
1248
1980
  ```html
1981
+ <!-- meme-recaption: enactment
1982
+ Rule 3 + Rule 10, on the record. A COMMENT, never a layer: a layer would count
1983
+ against max_text_cards, would need a data-start, and could render. Gate 2 reads
1984
+ it out of this file, so there is no second file to lose.
1985
+ clip_slug: my-disappointment-is-immeasurable-and-my-day-is-ruined
1986
+ caption_verb: deflate
1987
+ clip_verb: deflate
1988
+ feeling_size: small
1989
+ clip_size: large
1990
+ clip_reads_blind: a man in a suit goes flat and accepts something without arguing
1991
+ caption_reads_blind: someone gives up on dinner after an hour and settles for pizza
1992
+ -->
1993
+
1249
1994
  <!-- Layout B: caption on a translucent plate over the background, above the subject.
1250
1995
  The WRAPPER is full width and centres; the PLATE is an inline-block that rides
1251
1996
  that centre. left:7% would pin the plate to 7% and leave the frame lopsided. -->
@@ -1318,16 +2063,24 @@ ffmpeg -i media/scene.mp4 -vframes 1 -update 1 media/poster.png # frame 0 of t
1318
2063
 
1319
2064
  ## Appendix C — build the shelf index once
1320
2065
 
1321
- The shelf pages at 200, the CLI cannot follow the cursor, and the slugs are the index so pull the
1322
- whole thing once through the REST route and grep the file instead of re-querying.
2066
+ > **This is the IDENTITY index, and it is the second of the two tools.** `meme-sweep.py` (in
2067
+ > *The diversity sweep*, above) is how you **cast** — it ranks the whole shelf by what the body does
2068
+ > and hands you a fresh pool in ten calls. This appendix builds the full local mirror you **grep**:
2069
+ > for a slug you half-remember, for the rights tier of a name, for the ledger joins in Rule 11, and
2070
+ > for working with no login or no embedding key. Sweep to choose a clip. Index to look one up.
2071
+
2072
+ The shelf pages at 200, the CLI cannot follow the cursor, and the slugs are the index of identity —
2073
+ so pull the whole thing once through the REST route and grep the file instead of re-querying.
1323
2074
 
1324
2075
  ```python
1325
2076
  #!/usr/bin/env python3
1326
2077
  """meme-index.py — pull every MemeScreens raw into one greppable TSV.
1327
2078
 
1328
- The `--query` filter on this endpoint is thin and inconsistent (a search for
1329
- "hungry food eating" returns nothing on a shelf full of people eating), and the
1330
- AI descriptions are blind to the meme's identity. So: pull it all, index by slug.
2079
+ Note this pages with `q` unset entirely: the `q` filter is a LEXICAL substring
2080
+ match that runs BEFORE ranking, so any query here would silently shrink the
2081
+ mirror. Semantic search is a different parameter (`vector_q`) and lives in
2082
+ meme-sweep.py. The AI descriptions are blind to the meme's identity, so this
2083
+ file is indexed by slug.
1331
2084
 
1332
2085
  Paging note: `vidfarm public-raws` has NO --cursor flag and the server caps a
1333
2086
  shelf at 200 per call, so the CLI alone can only ever show you the first 200 of
@@ -1406,7 +2159,7 @@ MOMENT: the 40 minutes between the craving and giving up on it
1406
2159
  | | |
1407
2160
  |---|---|
1408
2161
  | Frame | `When I …` — the complaint is embarrassing, so first person |
1409
- | Verb cast for | *deflating; accepting a bad outcome* |
2162
+ | Verb cast for | `deflate` shoulders drop, the bad outcome is accepted without a fight |
1410
2163
  | Caption | `when i've had 10 tabs open` / `since 7pm for one scallop roll` / `and we're getting pizza again` |
1411
2164
  | Clip (🟡) | `my-disappointment-is-immeasurable-and-my-day-is-ruined` · 1280×720 · plate `0x00FF00` digital · key 0.34 |
1412
2165
  | Clip (🟢) | `hangover-dog` · 2160×3840 · plate `0x00DA00` filmed · key **0.22** |
@@ -1425,9 +2178,39 @@ and the object (scallop roll) are the customer's own words, per Rule 6.
1425
2178
  > the company. **When a real brand name is what makes your caption feel sharp, that is the signal to
1426
2179
  > replace it, not to keep it.**
1427
2180
 
1428
- **Three-way lock:** *standalone* 10 tabs since 7pm ending in pizza is funny with no context;
1429
- *problem space* — deleting that hour is the product's entire pitch; *enactment* — both clips hold a
1430
- deadpan while something disappointing is already true.
2181
+ **The enactment declaration, as shipped** (back-filled onto both builds when the block became
2182
+ mandatory):
2183
+
2184
+ ```
2185
+ clip_slug: my-disappointment-is-immeasurable-and-my-day-is-ruined
2186
+ caption_verb: deflate
2187
+ clip_verb: deflate
2188
+ feeling_size: small # one annoying evening
2189
+ clip_size: large # a day that is immeasurably ruined
2190
+ clip_reads_blind: a man in a suit goes flat and accepts something without arguing
2191
+ caption_reads_blind: someone gives up on dinner after an hour and settles for pizza
2192
+ ```
2193
+
2194
+ **Read the two blind lines against each other.** They share no vocabulary at all — no "tabs", no
2195
+ "flat", nothing — and they are plainly one situation. That is what a correct pair looks like, and
2196
+ it is why `clip_read_is_independent` bans overlap instead of requiring it: the clip's world (a man
2197
+ in a suit) and the caption's world (dinner, tabs, pizza) are *supposed* to be different. The verb
2198
+ is the only thing they have in common, and the verb is the one thing that is declared rather than
2199
+ described.
2200
+
2201
+ **Sizes:** `small` vs `large`. One annoying evening arriving in the body of a man whose day is
2202
+ immeasurably ruined. Same verb, wrong size — Rule 3.1 and 3.2 at once, and that gap is the laugh.
2203
+
2204
+ **Rule 10, checked field by field:** nothing here needs the product (`10 tabs`, `7pm`, `scallop
2205
+ roll` are the niche's week), nothing needs a previous post, nothing needs the source meme — the
2206
+ caption never says "disappointment", "immeasurable" or "ruined", so `caption_does_not_borrow_lore`
2207
+ passes — and nothing needs to have been *you*: **who had to have been there?** Anyone who has ever
2208
+ tried to pick dinner.
2209
+
2210
+ **Three-way lock:** *standalone* — 10 tabs since 7pm ending in pizza is funny with no context, no
2211
+ previous post and no knowledge of the meme; *problem space* — deleting that hour is the product's
2212
+ entire pitch; *enactment* — both blind reads name one situation, both clips hold a deadpan while
2213
+ something disappointing is already true.
1431
2214
 
1432
2215
  **The background is the joke's world, not the product's.** An empty restaurant at night is the place
1433
2216
  they never got to. Blurred so it reads as a set, dark so the plate and the keyed subject both pop.
@@ -1435,6 +2218,12 @@ they never got to. Blurred so it reads as a set, dark so the plate and the keyed
1435
2218
  **Gates:** `vidfarm qa` 18/18 · `meme-qa.py` **26/26** on both builds (block centre measured at
1436
2219
  **49.8%**). The 🟢 build needs `--silent-ok`, and fails without it — which is the check working.
1437
2220
 
2221
+ > Those two counts are **historical**, from the run that produced this file. Both gates have grown
2222
+ > since: gate 1 gained five `forbid_text` phrases and gate 2 gained the six Rule 3 / Rule 10
2223
+ > checks. The builds still pass — the declaration above is what was back-filled to make that true —
2224
+ > but do not treat `18/18` or `26/26` as the number you should see. **A check count is not a
2225
+ > measurement of anything; the verdict is.**
2226
+
1438
2227
  **Naming the offer — both tiers, built and measured.**
1439
2228
 
1440
2229
  | | **v003 — tier 1** ✅ preferred | **v001 / v002 — tier 2** |
@@ -1486,14 +2275,23 @@ names the product. All of those pass `vidfarm qa` today.
1486
2275
 
1487
2276
  This checks the finished MP4 and the composition text together, and EXITS 1.
1488
2277
 
2278
+ It also reads the `<!-- meme-recaption: enactment -->` declaration out of the
2279
+ composition (Rule 3, Rule 10). It cannot judge whether the clip enacts the
2280
+ caption — nothing can — but it CAN require that the two-cover test was performed
2281
+ and that its answers do not contradict each other. That is a smaller claim than
2282
+ "the match is verified", and it is the one this script is entitled to make.
2283
+
1489
2284
  usage: meme-qa.py <render.mp4> <composition.html> [--offer dishcover.io]
1490
- [--pole pain|win] [--silent-ok] [--json]
2285
+ [--pole pain|win] [--silent-ok] [--flat-bg-ok] [--json]
1491
2286
  """
1492
2287
  import json, re, subprocess, sys
1493
2288
  import numpy as np
1494
2289
 
1495
2290
  render, comp = sys.argv[1], sys.argv[2]
1496
2291
  SILENT_OK = "--silent-ok" in sys.argv
2292
+ # Ladder tier 6 only: a flat brand plate really is the background. Everywhere
2293
+ # else a slab of one saturated colour is a plate that never got keyed.
2294
+ FLAT_BG_OK = "--flat-bg-ok" in sys.argv
1497
2295
  OFFER = (sys.argv[sys.argv.index("--offer") + 1].strip().lower()
1498
2296
  if "--offer" in sys.argv else None)
1499
2297
  # The pole changes what the payoff is allowed to do (Rule 2). Default to PAIN:
@@ -1509,6 +2307,55 @@ def check(name, ok, detail, fatal=True):
1509
2307
  results.append({"check": name, "ok": bool(ok), "detail": detail, "fatal": fatal})
1510
2308
 
1511
2309
 
2310
+ # --------------------------------------------- word tools for Rule 3 / Rule 10
2311
+ # These decide FATAL checks, so they are deliberately crude in the direction of
2312
+ # UNDER-firing. A gate that cries wolf on a correct build gets switched off
2313
+ # within a day, which is worse than not having it — see "Calibrate a checker
2314
+ # before you trust it". Concretely: a 4-letter floor (so `cat`, `dog`, `huh` in
2315
+ # a slug are ignored), a stemmer that is a suffix chop and misses give/giving,
2316
+ # and a stopword list that gives up any word that is also ordinary English.
2317
+ STOP = {
2318
+ "that", "this", "with", "when", "what", "your", "have", "from", "they", "them",
2319
+ "their", "there", "then", "than", "into", "just", "been", "being", "will",
2320
+ "would", "could", "should", "about", "over", "under", "some", "more", "most",
2321
+ "very", "really", "going", "gonna", "still", "again", "like", "know", "make",
2322
+ "made", "take", "took", "look", "time", "times", "thing", "things", "something",
2323
+ "someone", "anyone", "everyone", "nothing", "anything", "everything", "without",
2324
+ "while", "after", "before", "because", "which", "where", "here", "does", "much",
2325
+ "many", "other", "another", "same", "each", "every", "both", "since", "getting",
2326
+ "gets", "went", "goes", "want", "wants", "need", "needs", "keep", "keeps",
2327
+ "meme", "clip", "screen", "green", "video", "person", "people", "guy", "girl",
2328
+ }
2329
+
2330
+
2331
+ def stem_word(w):
2332
+ """A suffix chop, not a linguist. Enough to tie accepts/accepting together.
2333
+
2334
+ NOT called `stem`: section 7b binds a local `stem = OFFER.split(".")[0]` for
2335
+ the offer's name, and a helper called `stem` gets shadowed by it halfway down
2336
+ the file. The first run of this section died exactly there.
2337
+ """
2338
+ for suf in ("ies", "ing", "ed", "es", "s"):
2339
+ if w.endswith(suf) and len(w) - len(suf) >= 4:
2340
+ return w[: -len(suf)] + ("y" if suf == "ies" else "")
2341
+ return w
2342
+
2343
+
2344
+ def content(text):
2345
+ """Stemmed content words of >=4 LETTERS. Apostrophes and digits are dropped
2346
+ on purpose: `i've`, `we're`, `10` and `7pm` carry no comparison value, and
2347
+ the numbers are the caption's best specifics precisely because they are
2348
+ shared, not private."""
2349
+ out = set()
2350
+ for w in re.findall(r"[a-z]{4,}", text.lower()):
2351
+ if w in STOP:
2352
+ continue
2353
+ s = stem_word(w)
2354
+ if s not in STOP:
2355
+ out.add(s)
2356
+ return out
2357
+
2358
+
1512
2359
  def probe(path, vf, w, h, pre=(), post=("-vframes", "1")):
1513
2360
  # -ss/-sseof are INPUT options and go before -i; -vframes is an OUTPUT option
1514
2361
  # and goes after. Getting that backwards decodes nothing and reports no frame.
@@ -1543,7 +2390,21 @@ for label, fr in (("first", first), ("last", last)):
1543
2390
  check(f"{label}_frame_has_subject", band.std() > 12,
1544
2391
  f"lower-band stdev {band.std():.1f} (>12)")
1545
2392
 
1546
- # ------------------------------------------------------ 3. no residual green key
2393
+ # -------------------------------------------- 3. the plate is GONE, in any hue
2394
+ # Two checks, because these are two different defects. `no_residual_green` is a
2395
+ # FRINGE test on the shelf's usual 00FF00 plate — a key that was slightly too
2396
+ # tight. `no_unkeyed_plate` is hue-agnostic and catches the bigger, dumber one:
2397
+ # the key never ran, or it ran on a blue/magenta plate the green test cannot see.
2398
+ #
2399
+ # Measured on four fixtures: an unkeyed 00FF00 plate fails both; a photographic
2400
+ # background passes no_unkeyed_plate at 15%; a flat brand plate fails it at 79%
2401
+ # and needs --flat-bg-ok; an unkeyed MAGENTA plate reads 0.00% green and is
2402
+ # caught ONLY by no_unkeyed_plate. That last row is the whole reason it exists.
2403
+ #
2404
+ # Watch the green check if the joke's world is a lawn, a forest or a pool table:
2405
+ # 0.5% is tight, and a genuinely green BACKGROUND can trip it. That is a false
2406
+ # positive, not a bad key — confirm by eye, then pick a different world rather
2407
+ # than loosening the number.
1547
2408
  mid = probe(render, "scale=270:480", 270, 480, pre=("-ss", str(dur / 2)))
1548
2409
  if mid is not None:
1549
2410
  a = mid[0]
@@ -1551,6 +2412,23 @@ if mid is not None:
1551
2412
  green = ((g - r > 45) & (g - b > 45) & (g > 80)).mean()
1552
2413
  check("no_residual_green", green < 0.005, f"{green*100:.2f}% green pixels (<0.5%)")
1553
2414
 
2415
+ # A quarter of the frame in ONE saturated colour is a plate, not a
2416
+ # background: a background is a photograph, and photographs vary. The
2417
+ # caption plate is white or black, so it is not saturated and not counted.
2418
+ # Ladder tier 6 (a flat brand plate as the background) is a legitimate
2419
+ # exception and the only one — pass --flat-bg-ok, the same way --silent-ok
2420
+ # works, so the exception is a decision somebody made rather than a default.
2421
+ sat_px = (a.max(2) - a.min(2)) > 60
2422
+ if sat_px.any():
2423
+ q = a[sat_px] // 32 # 8^3 colour buckets
2424
+ keys = q[:, 0] * 64 + q[:, 1] * 8 + q[:, 2]
2425
+ biggest = float(np.bincount(keys).max()) / a[..., 0].size
2426
+ else:
2427
+ biggest = 0.0
2428
+ check("no_unkeyed_plate", FLAT_BG_OK or biggest < 0.25,
2429
+ f"largest flat saturated colour = {biggest*100:.0f}% of frame (<25%)"
2430
+ + (" [--flat-bg-ok]" if FLAT_BG_OK else ""))
2431
+
1554
2432
  # ------------------------------------- 4. caption block is horizontally CENTRED
1555
2433
  # Find the caption plate in frame 0 by its own darkness against the background,
1556
2434
  # then compare its horizontal centre to the canvas centre.
@@ -1647,7 +2525,7 @@ else:
1647
2525
  r"^my \w+ when\b", r"^why my\b",
1648
2526
  r"^[\w][\w'.\- ]{0,24} (when|with|after|watching|reading|seeing|hearing) ")
1649
2527
  check("caption_uses_a_frame", any(re.search(f, text_cap, re.I) for f in FRAMES),
1650
- f"must open with one of the seven frames — got {' '.join(words[:4])!r}")
2528
+ f"must open with one of the eight frames — got {' '.join(words[:4])!r}")
1651
2529
  check("caption_is_lowercase_start", text_cap[:1].islower(),
1652
2530
  "meme captions are typed, not headlined", fatal=False)
1653
2531
  # the product must not be in the video (Rule 1), and it must not resolve (Rule 2)
@@ -1659,6 +2537,19 @@ else:
1659
2537
  # hand-broken lines are required — the renderer's own wrap lands mid-thought
1660
2538
  check("caption_lines_hand_broken", "<br" in cap.group(1),
1661
2539
  "use <br> at the clause; never let the renderer wrap a meme caption")
2540
+ # Rule 10 — CONTEXT DEBT. Each of these asks the viewer for something they were
2541
+ # never given: a previous post, the source meme's lore, or a shared secret. The
2542
+ # platform serves this video cold to strangers, on purpose, so the debt is never
2543
+ # repaid — it just quietly costs you everyone who is not already following you.
2544
+ # "again" is deliberately NOT here: `and we're getting pizza again` is the
2545
+ # collapse of the worked example, and it borrows nothing.
2546
+ DEBT = ("iykyk", "if you know you know", "you know the one", "you already know",
2547
+ "part 2", "part two", "part 3", "part three", "as promised",
2548
+ "like i said", "as i said", "as mentioned", "see my last",
2549
+ "from my last", "same as last time", "remember when i said")
2550
+ dhit = [d for d in DEBT if d in text_cap.lower()]
2551
+ check("caption_no_context_debt", not dhit,
2552
+ f"context debt {dhit} — the viewer has no previous slide and no briefing (Rule 10)")
1662
2553
 
1663
2554
  # ------------------------------------------------- 7b. naming the offer
1664
2555
  # The offer IS named on the video. Two ways, in priority order:
@@ -1734,6 +2625,80 @@ if OFFER:
1734
2625
  check("handle_is_dimmed", bool(dim) and float(dim.group(1)) <= 0.85,
1735
2626
  f"handle alpha {dim.group(1) if dim else 'opaque'} (<=0.85)", fatal=False)
1736
2627
 
2628
+ # ------------------------------------- 7c. the enactment declaration (Rule 3)
2629
+ # The two-cover test is a human act, and no script will ever judge whether a clip
2630
+ # enacts a caption. What a script CAN do is refuse a build where the test was
2631
+ # never performed, or where its own answers contradict each other. So the answers
2632
+ # live in the composition as an HTML COMMENT — a comment and not a layer, because
2633
+ # a layer counts against max_text_cards and could render, and this must never
2634
+ # reach a pixel.
2635
+ VERBS = {"deflate", "blank", "loop", "smug", "overreact",
2636
+ "correct", "unbothered", "celebrate", "stare", "wait"}
2637
+ FIELDS = ("clip_slug", "caption_verb", "clip_verb", "feeling_size", "clip_size",
2638
+ "clip_reads_blind", "caption_reads_blind")
2639
+
2640
+ decl_m = re.search(r"<!--\s*meme-recaption:\s*enactment\b(.*?)-->", html, re.S)
2641
+ decl = {}
2642
+ if decl_m:
2643
+ for line in decl_m.group(1).splitlines():
2644
+ kv = re.match(r"\s*([a-z_]+)\s*:\s*(.+?)\s*$", line)
2645
+ if kv and kv.group(1) in FIELDS:
2646
+ decl[kv.group(1)] = re.sub(r"\s*#.*$", "", kv.group(2)).strip()
2647
+
2648
+ missing = [f for f in FIELDS if not decl.get(f)]
2649
+ short = [f for f in ("clip_reads_blind", "caption_reads_blind")
2650
+ if len(decl.get(f, "").split()) < 5]
2651
+ same = (decl.get("clip_reads_blind", "x").lower() ==
2652
+ decl.get("caption_reads_blind", "y").lower())
2653
+ check("enactment_declared",
2654
+ bool(decl_m) and not missing and not short and not same,
2655
+ ("no <!-- meme-recaption: enactment --> block — run the two covers and write them down"
2656
+ if not decl_m else
2657
+ f"missing {missing}" if missing else
2658
+ f"{short} must be a real sentence (>=5 words)" if short else
2659
+ "the two blind reads are the same sentence — you wrote one and pasted it twice"
2660
+ if same else "7 fields, both reads written"))
2661
+
2662
+ if decl_m and not missing:
2663
+ cv, kv_ = decl["caption_verb"].lower(), decl["clip_verb"].lower()
2664
+ # Rule 3.1. Two different tokens is a mismatch you declared out loud; a token
2665
+ # outside the ten is a verb nobody can compare, which is the same as no verb.
2666
+ check("enactment_verbs_match",
2667
+ cv in VERBS and kv_ in VERBS and cv == kv_,
2668
+ f"caption_verb={cv!r} clip_verb={kv_!r} — must be the SAME token from {sorted(VERBS)}")
2669
+ # Rule 3.2. Two coarse buckets on purpose: a 1-5 scale invites a 3-vs-4 that
2670
+ # means nothing, and the only question the format asks is binary — is the
2671
+ # reaction the wrong size for the cause?
2672
+ fs, cs = decl["feeling_size"].lower(), decl["clip_size"].lower()
2673
+ check("intensity_mismatched",
2674
+ {fs, cs} == {"small", "large"},
2675
+ f"feeling_size={fs!r} clip_size={cs!r} — must be small/large and DIFFERENT "
2676
+ f"(matched sizes are a re-enactment, not a joke)")
2677
+ # Rule 3.3 + the read-order problem. If the only way you can describe the clip
2678
+ # is with the caption's own nouns, either you never saw it cold (you projected
2679
+ # the caption onto it) or the clip really does depict the caption's nouns,
2680
+ # which is a re-enactment. A correct pair shares NO vocabulary — the clip's
2681
+ # world and the caption's world are supposed to be different. This is why the
2682
+ # check bans overlap instead of requiring it: an earlier draft required the two
2683
+ # reads to share a word, and every correct build failed.
2684
+ if cap:
2685
+ bleed = sorted(content(decl["clip_reads_blind"]) & content(text_cap))
2686
+ check("clip_read_is_independent", not bleed,
2687
+ f"clip_reads_blind reuses the caption's words {bleed} — describe the clip, "
2688
+ f"not your plan for it")
2689
+ # Rule 10, context 3 — the source meme's own lore. A word that comes from the
2690
+ # meme's NAME comes from the meme's fandom, not from the audience's week, and
2691
+ # it caps reach at people who recognise the clip. It also catches the literal
2692
+ # case, where the caption names what the clip already is.
2693
+ if cap:
2694
+ offer_stem = OFFER.split(".")[0].lower() if OFFER else ""
2695
+ slug_words = content(decl["clip_slug"].replace("-", " ").replace("_", " "))
2696
+ slug_words.discard(stem_word(offer_stem))
2697
+ lore = sorted(slug_words & content(text_cap))
2698
+ check("caption_does_not_borrow_lore", not lore,
2699
+ f"caption reuses slug words {lore} — the clip is borrowed equipment, "
2700
+ f"not a shared reference (Rule 10)")
2701
+
1737
2702
  # --------------------------------------------- 8. one shot, no brand chrome
1738
2703
  check("single_shot", len(re.findall(r"<video\b", html)) == 1,
1739
2704
  f"{len(re.findall(r'<video', html))} video layer(s) — a meme recaption is ONE shot")