@officexapp/vidfarm-devcli 0.21.52 → 0.21.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -1144,6 +1144,9 @@ Marketplace (paid, cloud-only — the bazaar never renders locally):
1144
1144
  --file-id <id> Move a FILE; omit to move the folder (same root, e.g. /raws/demos → /raws/archive)
1145
1145
  directory copy <path> [<to-folder>] Duplicate a file/folder (shared S3 obj) → POST /api/v1/user/me/directory/copy
1146
1146
  --file-id <id> --as <name> Copy a FILE; omit --file-id to copy the folder; --as renames the copy
1147
+ directory note <path> Read/write the VECTOR NOTE of a file or FOLDER → GET|PUT /api/v1/user/me/directory/note
1148
+ --file-id <id> Annotate a FILE; omit to annotate the FOLDER at <path>
1149
+ --set "<text>" What the thing IS, in plain words — embedded, so search finds it by meaning
1147
1150
  directory save-url <url> Save a durable media URL INTO My Files at a folder → POST /api/v1/user/me/attachments/from-url
1148
1151
  --folder <path> Destination folder under /files (e.g. inpaints, promos)
1149
1152
  --as <name> Name the saved file · --notes <text> vector-embedded notes
@@ -1161,7 +1164,10 @@ Marketplace (paid, cloud-only — the bazaar never renders locally):
1161
1164
  vidfarm gigs proofs --status pending · vidfarm gigs approve PRF_01H…
1162
1165
  (see: vidfarm gigs help)
1163
1166
  shared <sub> <link> USE a link someone shared with you — NO account, NO API key
1164
- info · ls · search · mkdir · put · get e.g. vidfarm shared put <link> clip.mp4 --subfolder batch-01
1167
+ info · ls --tree · search · grab · note · mkdir · put · get
1168
+ grab is the one to reach for: it finds footage by MEANING and downloads it in one step.
1169
+ e.g. vidfarm shared grab <link> "founder talking head, kitchen" --out ./assets
1170
+ vidfarm shared put <link> cut-v2.mp4 --subfolder batch-01 --note "Final cut, 9:16"
1165
1171
  (the gigworker/agent side of directory share; see: vidfarm shared help)
1166
1172
  put-file / get-file / files / annotate-file are the My Files (persistent) set;
1167
1173
  upload is the throwaway temp store for dropping media into a composition.
@@ -2741,7 +2747,7 @@ Rules:
2741
2747
  - When swapping visuals, match both the literal scene DNA and the narrative purpose of the beat.
2742
2748
  - 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.
2743
2749
  - 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.
2744
- - 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 an imported family (Montserrat default / TikTok Sans / Abel / Source Code Pro / Yesteryear) 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).
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 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).
2745
2751
  - 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.
2746
2752
  - 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\`.
2747
2753
  - 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).
@@ -11104,6 +11110,17 @@ const DIRECTORY_HELP = `vidfarm directory — browse the unified file tree (/fil
11104
11110
  e.g. vidfarm directory copy /raws/demos /raws/archive
11105
11111
  vidfarm directory copy /files/brand/logo.png /files/inbox --file-id att_123 --as logo-copy.png
11106
11112
 
11113
+ directory note <path> Read the VECTOR NOTE of a file or FOLDER → GET /api/v1/user/me/directory/note
11114
+ --file-id <id> Annotate a FILE; omit to annotate the FOLDER at <path>
11115
+ --set "<text>" Write the note ("" clears it). It is embedded, so the
11116
+ thing is findable by MEANING — a file name is not.
11117
+ --json
11118
+ e.g. vidfarm directory note /raws/AboutOffer/BRoll --set "Kitchen b-roll, no faces, 9:16"
11119
+ vidfarm directory note /files/brand/logo.png --file-id att_123
11120
+ Notes live on /files entries, /raws entries and FOLDERS in any writable root.
11121
+ Uploads self-describe when the account has an AI key saved (skipped over 100 MB);
11122
+ with no key the note is yours to type, and search falls back to keyword-only.
11123
+
11107
11124
  directory save-url <url> Save a durable media URL INTO My Files at a folder → POST /api/v1/user/me/attachments/from-url
11108
11125
  --folder <path> Destination folder under /files (e.g. inpaints, promos)
11109
11126
  --as <name> Name the saved file (else derived from the URL)
@@ -11155,6 +11172,10 @@ async function runDirectoryCommand(argv) {
11155
11172
  case "copy":
11156
11173
  case "cp":
11157
11174
  return runDirectoryCopy(rest);
11175
+ case "note":
11176
+ case "notes":
11177
+ case "annotate":
11178
+ return runDirectoryNote(rest);
11158
11179
  case "save-url":
11159
11180
  case "from-url":
11160
11181
  case "import-url":
@@ -11247,6 +11268,55 @@ async function runDirectorySearch(argv) {
11247
11268
  }
11248
11269
  printDirectorySearch(merged, query, spaces.length > 1 ? "both" : spaces[0]);
11249
11270
  }
11271
+ // Read or write the VECTOR NOTE of a file or folder. The note is the plain-text
11272
+ // answer to "what is this?", embedded so search finds it from any phrasing —
11273
+ // which is the difference between a folder of IMG_4821.mp4 and a usable library.
11274
+ async function runDirectoryNote(argv) {
11275
+ const parsed = parseArgs({
11276
+ args: argv,
11277
+ allowPositionals: true,
11278
+ options: { ...commonOptions(), "file-id": { type: "string" }, set: { type: "string" }, note: { type: "string" } }
11279
+ });
11280
+ const ctx = commonContext(parsed.values);
11281
+ const targetPath = parsed.positionals[0];
11282
+ if (!targetPath) {
11283
+ throw new Error('directory note requires a path: vidfarm directory note /raws/BRoll [--file-id <id>] [--set "what this is"]');
11284
+ }
11285
+ const fileId = parsed.values["file-id"];
11286
+ const next = parsed.values.set ?? parsed.values.note;
11287
+ const space = targetSpaces(ctx.target)[0];
11288
+ if (next == null) {
11289
+ const result = await dispatch(ctx, { method: "GET", path: "/api/v1/user/me/directory/note", query: { path: targetPath, id: fileId } }, space);
11290
+ assertApiOk(result, "directory note");
11291
+ if (ctx.json) {
11292
+ printJson(result.json ?? result.text);
11293
+ return;
11294
+ }
11295
+ const note = result.json?.note;
11296
+ console.log(`${BOLD}${targetPath}${RESET}`);
11297
+ console.log(note ? ` ${note}` : ` ${DIM}(no vector note yet)${RESET}`);
11298
+ if (!note)
11299
+ console.log(` ${DIM}Write one: vidfarm directory note ${targetPath}${fileId ? ` --file-id ${fileId}` : ""} --set "what this is"${RESET}`);
11300
+ return;
11301
+ }
11302
+ const result = await dispatch(ctx, {
11303
+ method: "PUT",
11304
+ path: "/api/v1/user/me/directory/note",
11305
+ body: { path: targetPath, ...(fileId ? { id: fileId } : {}), note: next }
11306
+ }, space);
11307
+ assertApiOk(result, "directory note");
11308
+ if (ctx.json) {
11309
+ printJson(result.json ?? result.text);
11310
+ return;
11311
+ }
11312
+ const saved = result.json?.note;
11313
+ console.log(`${GREEN}✓${RESET} ${saved ? "note saved on" : "note cleared on"} ${BOLD}${targetPath}${RESET}`);
11314
+ if (saved) {
11315
+ console.log(result.json?.embedded
11316
+ ? ` ${DIM}searchable by meaning${RESET}`
11317
+ : ` ${DIM}saved, but keyword-search only — add a gemini/openai key to embed it${RESET}`);
11318
+ }
11319
+ }
11250
11320
  async function runDirectoryRename(argv) {
11251
11321
  const parsed = parseArgs({
11252
11322
  args: argv,
@@ -11568,6 +11638,20 @@ function mergeSearch(parts, limit) {
11568
11638
  };
11569
11639
  }
11570
11640
  // Compact "12.3s · 4.2 MB · video/mp4" line for a directory file item.
11641
+ // ── vector notes in list/search output ───────────────────────────────────────
11642
+ // The note is what a human (or an agent) recognises the item by, so it prints
11643
+ // under the name; the mark makes a MISSING note visible as a gap to fill.
11644
+ function noteLine(item) {
11645
+ const note = typeof item?.note === "string" ? item.note.replace(/\s+/g, " ").trim() : "";
11646
+ if (!note)
11647
+ return "";
11648
+ return note.length > 110 ? `${note.slice(0, 109)}…` : note;
11649
+ }
11650
+ function noteMark(item) {
11651
+ if (item?.note === undefined)
11652
+ return ""; // backend carries no note field
11653
+ return item.note ? `${GREEN}✓${RESET} ` : `${DIM}✎${RESET} `;
11654
+ }
11571
11655
  function directoryFileMeta(item) {
11572
11656
  const parts = [];
11573
11657
  if (typeof item?.durationSec === "number" && item.durationSec > 0)
@@ -11598,11 +11682,17 @@ function printDirectoryListing(data, requestedPath, space) {
11598
11682
  console.log(` ${DIM}(empty)${RESET}`);
11599
11683
  }
11600
11684
  for (const f of folders) {
11601
- console.log(` ${DIM}dir ${RESET} ${originBadge(f)}${f?.name ?? ""}/ ${DIM}${f?.path ?? ""}${RESET}`);
11685
+ console.log(` ${DIM}dir ${RESET} ${noteMark(f)}${originBadge(f)}${f?.name ?? ""}/ ${DIM}${f?.path ?? ""}${RESET}`);
11686
+ const note = noteLine(f);
11687
+ if (note)
11688
+ console.log(` ${DIM}${note}${RESET}`);
11602
11689
  }
11603
11690
  for (const f of files) {
11604
11691
  const meta = directoryFileMeta(f);
11605
- console.log(` file ${originBadge(f)}${f?.name ?? ""}${meta ? ` ${DIM}${meta}${RESET}` : ""} ${DIM}${f?.path ?? ""}${RESET}`);
11692
+ console.log(` file ${noteMark(f)}${originBadge(f)}${f?.name ?? ""}${meta ? ` ${DIM}${meta}${RESET}` : ""} ${DIM}${f?.path ?? ""}${RESET}`);
11693
+ const note = noteLine(f);
11694
+ if (note)
11695
+ console.log(` ${DIM}${note}${RESET}`);
11606
11696
  if (f?.viewUrl)
11607
11697
  console.log(` ${FRONTEND}${f.viewUrl}${RESET}`);
11608
11698
  }
@@ -11623,7 +11713,10 @@ function printDirectorySearch(data, query, space) {
11623
11713
  results.forEach((r, i) => {
11624
11714
  const score = typeof r?.score === "number" ? ` ${DIM}score=${r.score.toFixed(3)}${RESET}` : "";
11625
11715
  const kind = r?.kind === "folder" ? "dir " : "file";
11626
- console.log(` ${String(i + 1).padStart(2)}. ${kind} ${originBadge(r)}${r?.path ?? r?.name ?? ""}${score}`);
11716
+ console.log(` ${String(i + 1).padStart(2)}. ${kind} ${noteMark(r)}${originBadge(r)}${r?.path ?? r?.name ?? ""}${score}`);
11717
+ const note = noteLine(r);
11718
+ if (note)
11719
+ console.log(` ${DIM}${note}${RESET}`);
11627
11720
  const meta = directoryFileMeta(r);
11628
11721
  if (meta)
11629
11722
  console.log(` ${DIM}${meta}${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;
@@ -305,7 +305,7 @@ async function cmdMachines(auth, values) {
305
305
  }));
306
306
  out(Boolean(values.json), { machines }, () => {
307
307
  if (!machines.length) {
308
- console.log(`${DIM}No vending machines on this key yet. A paid vidfarm account gets two; open ${RESET}https://vidfarm.cc/marketplace/buyer${DIM} once to create them.${RESET}`);
308
+ console.log(`${DIM}No vending machines on this key yet. A paid vidfarm account gets two; open ${RESET}https://vidfarm.cc/marketplace${DIM} once to create them.${RESET}`);
309
309
  return;
310
310
  }
311
311
  for (const machine of machines) {
@@ -461,7 +461,7 @@ async function cmdRingBell(auth, values) {
461
461
  destination = hasInviteToken(registered.invite_url) ? registered.invite_url : "";
462
462
  }
463
463
  if (!destination)
464
- throw new Error("That machine has no invite link yet — open it once on vidfarm.cc/marketplace/buyer.");
464
+ throw new Error("That machine has no invite link yet — open it once on vidfarm.cc/marketplace.");
465
465
  const result = await dp(auth, `/feeds/${VIDFARM_FEED_ID}/notifications`, {
466
466
  method: "POST",
467
467
  body: {
@@ -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.`
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