@officexapp/vidfarm-devcli 0.21.53 → 0.21.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -925,9 +925,18 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
925
925
  "create me a harness" / "update the harness for
926
926
  this format" / "give me the harness for this
927
927
  template_id" all land here
928
- harness list [--json] Bundled starting points: short-form,
929
- hooks, ugc-testimonial, explainer,
930
- product-demo, product-explainer
928
+ harness list [--json] Two shelves. BASES to copy and edit:
929
+ short-form, hooks, ugc-testimonial,
930
+ explainer, product-demo,
931
+ product-explainer. FORMAT harnesses —
932
+ complete contracts, read by name, same
933
+ shelf as vidfarm.cc/experimental:
934
+ meme-recaption, wall-text-pov-ugc,
935
+ ugc-reaction-greenscreen,
936
+ sticker-slideshow-tips,
937
+ animated-sticker-story,
938
+ google-news-to-video,
939
+ unique-product-explainers
931
940
  harness show <name|path> Print one [--dna <strand>] to print just
932
941
  one strand (viral_dna, visual_dna, …)
933
942
  harness init <name> [--out <p>] Copy one next to your work, then EDIT it
@@ -2747,7 +2756,7 @@ Rules:
2747
2756
  - When swapping visuals, match both the literal scene DNA and the narrative purpose of the beat.
2748
2757
  - For replacement graphics, screenshots, or still-like scenes, prefer AI image generation plus Ken Burns before paying for AI video unless static_vs_pivot says motion footage is load-bearing.
2749
2758
  - If narration must be customized, default to premium ElevenLabs first, then the user's own ElevenLabs path, then BYOK OpenAI/Gemini/OpenRouter. If captions or scenes were timed to the old VO, retime them to the new narration.
2750
- - NO HTML SLOP. You are editing HTML, but the output is a social video, not a web page. THE TEST IS THE NATIVE-EDITOR TEST: could you have made this element with the tools inside TikTok's own editor? That toolset is a font, a color, a stroke/outline, a soft shadow, a tight text box, alignment, opacity, rotation, animation presets — plus stickers, emoji, drawn marks and clips. It has NO padded capsule, NO border, NO gradient fill, NO blur panel, NO card. If you reached past it, cut it. Never author landing-page furniture: CTA "buttons" (a filled/gradient rounded capsule with action copy like "Sign Up for a Free Trial →"), benefit chip/badge rows ("✓ No Credit Card Needed"), bordered/shadowed/frosted cards holding a headline + URL, gradient text fills, feature grids, bulleted lists, or web-default fonts (Inter/Roboto/Arial/system-ui). AND NOT A SINGLE PILL EITHER: one lonely rounded, padded, filled capsule around a static stat or label — "10 hrs / week", "STEP 2", "EP.01", "+40%" — is a web badge, and being the only one on screen does not make it native. The ONLY legitimate capsule in a video is the active-word spotlight/karaoke caption highlight, because it moves with the spoken word. Emphasize a stat the way the editor would: bigger, heavier, ALL-CAPS, an accent color, a hand-drawn circle or underline, or its own beat on screen. Rule of thumb on anything holding words: border-radius over ~8px PLUS a background fill PLUS padding = a badge; drop the fill or drop the radius until the band hugs the glyphs. None of this appears in a real TikTok, and nothing in a video is clickable — say it as timed text on the footage instead. Arrows, scribble/underline marks, italics, ALL-CAPS, single-word color pops, emoji, transparent cut-out stickers, and mock social UI (iMessage bubbles, comment cards) are all fine. Captions use one of the FIVE imported families and nothing else (Montserrat default / TikTok Sans / Abel / Source Code Pro / Yesteryear - the full regime, with a rendered specimen of each, is at https://vidfarm.cc/fonts) at weight 700-900, ~36-64px on a 1080-wide frame, inside the 8%-85% safe zone, with exactly one of four backgrounds: outline, plain, an active-word spotlight/karaoke pill, or a tight-hugging solid band (radius <=8px, no border/shadow/gradient/blur).
2759
+ - NO HTML SLOP. You are editing HTML, but the output is a social video, not a web page. THE TEST IS THE NATIVE-EDITOR TEST: could you have made this element with the tools inside TikTok's own editor? That toolset is a font, a color, a stroke/outline, a soft shadow, a tight text box, alignment, opacity, rotation, animation presets — plus stickers, emoji, drawn marks and clips. It has NO padded capsule, NO border, NO gradient fill, NO blur panel, NO card. If you reached past it, cut it. Never author landing-page furniture: CTA "buttons" (a filled/gradient rounded capsule with action copy like "Sign Up for a Free Trial →"), benefit chip/badge rows ("✓ No Credit Card Needed"), bordered/shadowed/frosted cards holding a headline + URL, gradient text fills, feature grids, bulleted lists, or web-default fonts (Inter/Roboto/Arial/system-ui). AND NOT A SINGLE PILL EITHER: one lonely rounded, padded, filled capsule around a static stat or label — "10 hrs / week", "STEP 2", "EP.01", "+40%" — is a web badge, and being the only one on screen does not make it native. The ONLY legitimate capsule in a video is the active-word spotlight/karaoke caption highlight, because it moves with the spoken word. Emphasize a stat the way the editor would: bigger, heavier, ALL-CAPS, an accent color, a hand-drawn circle or underline, or its own beat on screen. Rule of thumb on anything holding words: border-radius over ~8px PLUS a background fill PLUS padding = a badge; drop the fill or drop the radius until the band hugs the glyphs. None of this appears in a real TikTok, and nothing in a video is clickable — say it as timed text on the footage instead. Arrows, scribble/underline marks, italics, ALL-CAPS, single-word color pops, emoji, transparent cut-out stickers, and mock social UI (iMessage bubbles, comment cards) are all fine. Captions use one of the FIVE imported families by default (Montserrat default / TikTok Sans / Abel / Source Code Pro / Yesteryear - the full regime, with a rendered specimen of each, is at https://vidfarm.cc/fonts). A custom family is allowed only if the composition DECLARES it (@font-face or a Google Fonts @import); an undeclared family silently falls back to a web-default sans and local renders coerce it to Montserrat. Every family has its own standalone reference card at https://vidfarm.cc/assets/fonts/caption-font-<family>.png (backgrounds: caption-bg-outline|plain|spotlight|highlight-solid.png) - after styling, pull a still and COMPARE it against the card for the family you picked, because a font that failed to load looks fine on its own. Weight 700-900, ~36-64px on a 1080-wide frame, inside the 8%-85% safe zone, with exactly one of four backgrounds: outline, plain, an active-word spotlight/karaoke pill, or a tight-hugging solid band (radius <=8px, no border/shadow/gradient/blur).
2751
2760
  - NO LAYOUT TEMPLATES — JUDGE THE WHOLE FRAME, NOT JUST THE ELEMENT. Every rule above judges one element, and a frame can pass element-by-element and still be a web page. The archetype is the MODAL: the backdrop dimmed and blurred out of focus, and floating on top of it a rounded bordered box holding a big headline, a smaller support line, and a fat CTA button. THE STACK IS THE TELL, NOT THE BOX — delete the border, the fill and the capsule, keep headline then subheadline then CTA centred in a well with even margins, and it STILL reads as a landing page, because a viewer recognizes the SHAPE before reading a single word. Banned at frame level: a modal/dialog staged on top of a backdrop that has been dimmed, blurred, greyed or scaled back (nothing in a video pops "above" the video); the hero triplet and its cousins (title + kicker + logo lockup, question + answer + URL); a full-frame dark wash used to stage a floating block (a legibility band on ONE caption is legal, a page-wide wash to stage a panel is not; likewise a blurred backdrop is fine alone — a blurred fill behind a 16:9 clip in a 9:16 frame is a real technique — but blur PLUS dimming is modal staging); nav strip / hero / three-up feature row / testimonial block / footer fine print; a blurred website screenshot used as the background plate (if the backdrop is a web page, the frame is a screen recording of a web page — show the real product UI full-bleed and in focus, or don't show it); a centred content column with even gutters and document margins. THE FIX IS ALWAYS TO UNSTACK IT INTO TIME: the headline is the hook at start:0, the support line lands on the next cut, the CTA is SPOKEN or a bare caption on the final frame. You lose nothing — a viewer reads one line at a time anyway — and you gain the pacing that makes it look shot rather than designed. Self-check before you place any text group: am I arranging words relative to EACH OTHER, or relative to the PICTURE? Relative to each other is a layout, which is web. Two on-screen text runs at once is the ceiling. Verify on real pixels: \`vidfarm stills . --at <t>\` — if the still could be a screenshot of a website, rebuild the beat. \`vidfarm qa\` catches only the mechanical half (layout-template, modal-scrim); the frame-level judgement is yours.
2752
2761
  - STRUCTURE BEFORE POLISH — THE FOUR CHARGES, WRITTEN BEFORE YOU TOUCH THE TIMELINE. Most agent-made videos fail on structure, not polish, because the timeline is the fun part so it gets built first and the words get retrofitted. Invert it: (1) HOOK — write the opening line as text first: a complete clause (subject + verb), no jargon, naming a SITUATION ("I've quit six businesses") not a label ("anonymity"); it goes on screen at start:0, because caption chunk 1 is read before any audio and muted autoplay is the default. Banned openings: throat-clearing ("so I was thinking", "here's the thing"), a logo, a title card, a fade from black, context before the claim. (2) LOOP — one open question by 0:10, said ON SCREEN, closing INSIDE this video (state the timestamp it closes at; if you can't, there is no loop), and the withheld answer must be one the viewer CANNOT supply themselves — a formally-correct loop with a guessable answer passes every mechanical check and dies in the field. (3) PAYOFF — shown, not summarized, ≥5 uninterrupted seconds, landing BEFORE the final beat; the payoff is not the CTA. (4) BAIT — one ask in the final beat and in the post caption; a keyword comment ask ("comment CLIPPER and I'll send the breakdown") is standard and allowed, but never "follow for part two", ragebait, or an earnings/health claim traded for the reply. Then build the timeline. Re-theming a decomposed template: viral_dna already names the source's hook/retention/payoff — rebuild each charge for the new subject, never flatten the loop into a product statement. Full craft harness: the vidfarm skill's references/hooks-and-virality.md. Checkable form: \`vidfarm harness show hooks\`.
2753
2762
  - ORIENT THE COLD VIEWER IN THE FIRST 3 SECONDS — THE VIEWER HAS NO CONTEXT AND DID NOT CHOOSE THIS VIDEO. Distinct from the hook: the hook makes them WANT to watch, orientation makes the watching POSSIBLE. A stranger mid-scroll must be able to answer three things by ~3s — what am I looking at (the CATEGORY noun), who is it for, and why is this on my screen (the situation). The failure is not a bad first frame, it is a good video that BEGINS AT BEAT TWO, and the author cannot see it because the author already knows what the thing is. Signatures, each a rebuild not a polish: a pronoun with no referent ("it just works", "this changes everything", "here's how they do it"); starting at step three (the process already running, the dashboard already full); a metaphor whose subject only lands at 6s; insider vocabulary, a product's own feature name, or an ACRONYM in the first line; a detail crop that reads as texture until you know the whole. Instead, the opening beat is BOTH channels at once: an EASY IMAGE (one large subject, already moving, legible at a glance and at thumbnail scale — a relevant die-cut sticker names the category before a word is read) AND an EASY LINE (first spoken sentence one clause, <=12 words, everyday words, concrete noun + verb, no subordinate clause, brand name said once plainly, and the CATEGORY named: "X is a language app that…"). Give the SITUATION, not the label — "the end of the month, and your receipts are in a shoebox" orients, "expense automation" does not. THIS IS NOT AN INTRO AND COSTS NO EXTRA SECONDS: it replaces the wind-up sentence, it never precedes it, and it never licenses a logo, a title card or a fade from black. Test it on the render, not the script: play the first 3 seconds ONLY to somebody with no context and stop — they should say what kind of thing it is and roughly who it is for. "Something about audio" is a fail. Fullest form: \`vidfarm harness show product-explainer\` (Rule 0).
@@ -12344,13 +12353,28 @@ async function runHarnessCommand(argv) {
12344
12353
  });
12345
12354
  const json = Boolean(parsed.values.json);
12346
12355
  if (!sub || sub === "list") {
12347
- const builtins = listBuiltinHarnesses();
12348
- if (json)
12349
- return printJson({ harnesses: builtins.map(({ name, path: file, video_type }) => ({ name, path: file, video_type })) });
12350
- console.log(`${DIM}Built-in harnesses copy one next to your work, then edit it:${RESET}`);
12351
- for (const entry of builtins) {
12356
+ const all = listBuiltinHarnesses();
12357
+ if (json) {
12358
+ return printJson({
12359
+ harnesses: all.map(({ name, path: file, video_type, origin, url }) => ({ name, path: file, video_type, origin, url })),
12360
+ index_url: "https://vidfarm.cc/experimental"
12361
+ });
12362
+ }
12363
+ const bases = all.filter((entry) => entry.origin === "builtin");
12364
+ const experimental = all.filter((entry) => entry.origin === "experimental");
12365
+ console.log(`${DIM}Base harnesses — copy one next to your work, then edit it:${RESET}`);
12366
+ for (const entry of bases) {
12352
12367
  console.log(` ${GREEN}${entry.name}${RESET} ${DIM}${entry.video_type ?? ""}${RESET}`);
12353
12368
  }
12369
+ if (experimental.length) {
12370
+ // The same shelf as https://vidfarm.cc/experimental, shipped in the package
12371
+ // so an agent reads a full format contract by name without a fetch.
12372
+ console.log(`\n${DIM}Format harnesses — complete contracts for ONE format. Read one before you build it:${RESET}`);
12373
+ for (const entry of experimental) {
12374
+ console.log(` ${GREEN}${entry.name}${RESET} ${DIM}${(entry.video_type ?? "").slice(0, 120)}${RESET}`);
12375
+ }
12376
+ console.log(`${DIM}vidfarm harness show <name> (also live at https://vidfarm.cc/experimental)${RESET}`);
12377
+ }
12354
12378
  console.log(`\n${DIM}vidfarm harness init <name> --out ./work/${HARNESS_FILENAME}${RESET}`);
12355
12379
  console.log(`${DIM}vidfarm harness derive <forkId> (a decomposed template → a harness)${RESET}`);
12356
12380
  console.log(`${DIM}vidfarm qa ./work --harness <name|path> (repeatable — harnesses stack)${RESET}`);
@@ -391,11 +391,42 @@ const CAPTION_DEFAULT_FRAME = { x: 10, y: 70, width: 80, height: 14 };
391
391
  export const TIKTOK_CAPTION_SAFE_ZONE = { top: 8, bottom: 85 }; // % of canvas height
392
392
  // The composition font regime — mirrors COMPOSITION_FONT_IMPORT's family list in
393
393
  // services/studio-project-adapter.ts. A caption/text layer whose primary family
394
- // is outside this set isn't even imported (so it silently falls back at render),
395
- // which means coercing it to the bold default is strictly an improvement.
394
+ // is outside this set is normally not imported (so it silently falls back at
395
+ // render), which means coercing it to the bold default is strictly an
396
+ // improvement. The regime is a strong default, not a ban: a family the
397
+ // composition declares for itself (see compositionDeclaresFont) is left alone.
396
398
  const CAPTION_FONT_REGIME = ["tiktok sans", "montserrat", "abel", "source code pro", "yesteryear"];
397
399
  const CAPTION_REGIME_FALLBACK_FONT = "Montserrat";
398
400
  const CAPTION_FONT_FALLBACK_CHAIN = "'Montserrat', 'TikTok Sans', Abel, sans-serif";
401
+ /**
402
+ * A CUSTOM family is allowed — as long as the composition actually ships it.
403
+ * The regime exists because an unimported family silently falls back to a
404
+ * web-default sans at render; it is not a ban on typography. So: if the
405
+ * composition declares the family itself (an `@font-face` for it, or a Google
406
+ * Fonts `@import`/`<link>` naming it), leave the layer alone. Only a family
407
+ * with no declaration anywhere gets coerced, because that one really is broken.
408
+ */
409
+ export function compositionDeclaresFont(html, family) {
410
+ const name = family.trim().toLowerCase();
411
+ if (!name)
412
+ return false;
413
+ const hay = html.toLowerCase();
414
+ // @font-face { font-family: "Brand Sans" }
415
+ const faceRe = /@font-face\s*{[^}]*}/g;
416
+ for (const block of hay.match(faceRe) ?? []) {
417
+ const declared = block.match(/font-family\s*:\s*['"]?([^;'"}]+)/);
418
+ if (declared && declared[1].trim() === name)
419
+ return true;
420
+ }
421
+ // Google Fonts URL: family=Brand+Sans / family=Brand%20Sans
422
+ const urlName = name.replace(/\s+/g, "");
423
+ for (const m of hay.matchAll(/fonts\.googleapis\.com\/css2\?([^"')\s]+)/g)) {
424
+ const params = m[1].replace(/\+/g, "").replace(/%20/g, "");
425
+ if (params.includes(`family=${urlName}`))
426
+ return true;
427
+ }
428
+ return false;
429
+ }
399
430
  function setStylePercent(node, prop, value) {
400
431
  if (node?.style)
401
432
  node.style[prop] = `${Number(value.toFixed(2))}%`;
@@ -450,7 +481,9 @@ export function normalizeTikTokCaptionLayout(html) {
450
481
  }
451
482
  // Font: coerce off-regime primary family to the bold default.
452
483
  const primary = String(node.getAttribute?.("data-font-family") || "").trim();
453
- if (primary && !CAPTION_FONT_REGIME.includes(primary.toLowerCase())) {
484
+ if (primary &&
485
+ !CAPTION_FONT_REGIME.includes(primary.toLowerCase()) &&
486
+ !compositionDeclaresFont(html, primary)) {
454
487
  node.setAttribute?.("data-font-family", CAPTION_REGIME_FALLBACK_FONT);
455
488
  if (node.style)
456
489
  node.style.fontFamily = CAPTION_FONT_FALLBACK_CHAIN;
@@ -59,10 +59,10 @@ const DISCOVERY_NAMES = ["HARNESS.md", "harness.md", "QA_REGIME.md", "qa_regime.
59
59
  // They ship inside the skill pack (.agents/skills/vidfarm/harnesses/) rather
60
60
  // than as TS string constants, so a director can read, diff, and copy them as
61
61
  // normal files — the file IS the documentation.
62
- function builtinDir() {
62
+ function packDir(...segments) {
63
63
  let dir = path.dirname(fileURLToPath(import.meta.url));
64
64
  for (let i = 0; i < 6; i += 1) {
65
- const candidate = path.join(dir, ".agents", "skills", "vidfarm", "harnesses");
65
+ const candidate = path.join(dir, ...segments);
66
66
  if (existsSync(candidate))
67
67
  return candidate;
68
68
  const parent = path.dirname(dir);
@@ -72,32 +72,77 @@ function builtinDir() {
72
72
  }
73
73
  return null;
74
74
  }
75
- export function listBuiltinHarnesses() {
76
- const dir = builtinDir();
75
+ function builtinDir() {
76
+ return packDir(".agents", "skills", "vidfarm", "harnesses");
77
+ }
78
+ // The EXPERIMENTAL harnesses — full format contracts under live testing, served
79
+ // at https://vidfarm.cc/experimental/<slug>.md. They ship inside the npm package
80
+ // too (`experimental/**/*.md` in package.json `files`), so an agent that already
81
+ // has the CLI reaches them by NAME, offline, with no fetch: `vidfarm harness show
82
+ // meme-recaption`, `vidfarm qa ./work --harness wall-text-pov-ugc`. The web index
83
+ // stays the source of truth for what exists; this is the same shelf, local.
84
+ function experimentalDir() {
85
+ return packDir("experimental");
86
+ }
87
+ function readEntry(full, name, origin) {
88
+ const parsed = parseHarness(readFileSync(full, "utf8"), full);
89
+ return {
90
+ name,
91
+ path: full,
92
+ video_type: parsed.video_type,
93
+ summary: parsed.summary,
94
+ origin,
95
+ ...(origin === "experimental" ? { url: `https://vidfarm.cc/experimental/${name}.md` } : {})
96
+ };
97
+ }
98
+ export function listExperimentalHarnesses() {
99
+ const dir = experimentalDir();
77
100
  if (!dir)
78
101
  return [];
79
102
  return readdirSync(dir)
80
- .filter((file) => file.endsWith(BUILTIN_SUFFIX))
103
+ .filter((file) => file.endsWith(".md"))
81
104
  .sort()
82
- .map((file) => {
83
- const full = path.join(dir, file);
84
- const parsed = parseHarness(readFileSync(full, "utf8"), full);
85
- return { name: file.slice(0, -BUILTIN_SUFFIX.length), path: full, video_type: parsed.video_type, summary: parsed.summary };
86
- });
105
+ .map((file) => readEntry(path.join(dir, file), file.slice(0, -".md".length), "experimental"));
87
106
  }
88
- /** Resolve `hooks` (built-in) or `./my/HARNESS.md` (a path) to a file. */
107
+ /** Both shelves, built-ins first. `vidfarm harness list` prints exactly this. */
108
+ export function listBuiltinHarnesses() {
109
+ const dir = builtinDir();
110
+ const builtins = !dir
111
+ ? []
112
+ : readdirSync(dir)
113
+ .filter((file) => file.endsWith(BUILTIN_SUFFIX))
114
+ .sort()
115
+ .map((file) => readEntry(path.join(dir, file), file.slice(0, -BUILTIN_SUFFIX.length), "builtin"));
116
+ return [...builtins, ...listExperimentalHarnesses()];
117
+ }
118
+ /**
119
+ * Resolve `hooks` (built-in), `meme-recaption` (experimental), or
120
+ * `./my/HARNESS.md` (a path) to a file. Underscore spellings resolve too, so the
121
+ * URL slug and the CLI name never disagree — the same near-miss the web route
122
+ * forgives with its aliases.
123
+ */
89
124
  export function resolveHarnessPath(ref) {
90
125
  const direct = path.resolve(ref);
91
126
  if (existsSync(direct) && !direct.endsWith(path.sep))
92
127
  return direct;
93
- const dir = builtinDir();
94
- if (dir) {
95
- const candidate = path.join(dir, `${ref}${BUILTIN_SUFFIX}`);
128
+ const slug = ref.trim().toLowerCase().replace(/\.md$/, "").replace(/_/g, "-");
129
+ const builtins = builtinDir();
130
+ if (builtins) {
131
+ for (const name of [ref, slug]) {
132
+ const candidate = path.join(builtins, `${name}${BUILTIN_SUFFIX}`);
133
+ if (existsSync(candidate))
134
+ return candidate;
135
+ }
136
+ }
137
+ const experimental = experimentalDir();
138
+ if (experimental) {
139
+ const candidate = path.join(experimental, `${slug}.md`);
96
140
  if (existsSync(candidate))
97
141
  return candidate;
98
142
  }
99
143
  const names = listBuiltinHarnesses().map((entry) => entry.name);
100
- throw new Error(`No harness "${ref}". Pass a file path, or one of the built-ins: ${names.join(", ") || "(none bundled)"}. ` +
144
+ throw new Error(`No harness "${ref}". Pass a file path, or one of the bundled names: ${names.join(", ") || "(none bundled)"}. ` +
145
+ `The live index is https://vidfarm.cc/experimental. ` +
101
146
  `Scaffold your own with \`vidfarm harness init <name> --out ./work/HARNESS.md\`, ` +
102
147
  `or derive one from a decomposed template with \`vidfarm harness derive <forkId>\`.`);
103
148
  }
@@ -28,6 +28,7 @@ import { createHash } from "node:crypto";
28
28
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
29
29
  import path from "node:path";
30
30
  import { parseHTML } from "linkedom";
31
+ import { compositionDeclaresFont } from "./composition-edit.js";
31
32
  // ── Revision governor ────────────────────────────────────────────────────────
32
33
  //
33
34
  // `vidfarm qa` is feedback, not a gate — which is exactly what makes it a loop
@@ -646,16 +647,19 @@ export function qaCompositionHtml(html) {
646
647
  const style = styleString(node);
647
648
  const cssMatch = style.match(/(?:^|;)\s*font-family\s*:\s*([^;]+)/);
648
649
  const family = primaryFamily(attr || (cssMatch ? cssMatch[1] : ""));
649
- if (family && !FONT_REGIME.includes(family)) {
650
+ // A custom family is allowed when the composition SHIPS it — an @font-face
651
+ // or a Google Fonts import naming it. The defect this rule exists for is a
652
+ // family that no render can load, so a declared one is not a finding.
653
+ if (family && !FONT_REGIME.includes(family) && !compositionDeclaresFont(html, family)) {
650
654
  const isWebDefault = WEB_DEFAULT_FONTS.includes(family) || family === "sans-serif" || family === "serif";
651
655
  push({
652
656
  rule: "font-regime",
653
657
  severity: isWebDefault ? "error" : "warn",
654
658
  message: isWebDefault
655
659
  ? `Text layer in "${family}" — a website body font. This alone makes a frame read as a screenshot of a web page.`
656
- : `Text layer in "${family}", outside the composition's imported font regime — it will silently fall back at render.`,
660
+ : `Text layer in "${family}", which this composition never imports — it will silently fall back to a web-default sans at render.`,
657
661
  where: label(node, "text layer"),
658
- fix: `Use an imported display family: Montserrat (default), TikTok Sans, Abel, Source Code Pro, or Yesteryear — e.g. \`vidfarm set-style <dir> --layer <key> --font-family Montserrat\`. Local renders auto-coerce this, but the editor preview will not match until you fix it. The five families, each rendered as a real caption: https://vidfarm.cc/fonts`
662
+ fix: `Use a regime family Montserrat (default), TikTok Sans, Abel, Source Code Pro, Yesteryear — e.g. \`vidfarm set-style <dir> --layer <key> --font-family Montserrat\`; each has its own reference card, e.g. https://vidfarm.cc/assets/fonts/caption-font-montserrat.png (all five: https://vidfarm.cc/fonts). Keeping "${family}" is allowed, but then you must IMPORT it in the composition (@font-face or a Google Fonts @import) — otherwise local renders coerce it to Montserrat.`
659
663
  });
660
664
  }
661
665
  // Weight: the TikTok caption look is heavy. Light weights are a legitimate
@@ -117,7 +117,8 @@ export const PACK_TOPICS = [
117
117
  blurb: "The 5-stage ladder — what the viewer knows, what the video must do, and what it may ask for" },
118
118
  { topic: "problem-angles", aliases: ["angle", "lenses", "problem-angle"], doc: "references/content-ideas.md", heading: "The problem angles",
119
119
  blurb: "44 angles on the problem — hold the frame, change the angle when a topic is \"already covered\"" },
120
- { topic: "meme-recaption", aliases: ["meme", "recaption", "meme-caption", "meme_recaption"], doc: "references/editor-workflows.md", heading: "Writing a meme recaption",
120
+ { topic: "meme-recaption", // `meme_recaption` needs no alias: resolvePackTopic folds `_` to `-` first.
121
+ aliases: ["meme", "recaption", "meme-caption"], doc: "references/editor-workflows.md", heading: "Writing a meme recaption",
121
122
  blurb: "Recaption a meme at a pain or a win the niche knows — the cold-viewer test. Building one from scratch? the full format is vidfarm.cc/experimental/meme-recaption.md" },
122
123
  { topic: "product-explainer", aliases: ["product-explainers"], doc: "harnesses/product-explainer.HARNESS.md",
123
124
  blurb: "The product-explainer harness — the bundled base for explaining what a product does" },