@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/.agents/skills/vidfarm/SKILL.md +6 -2
- package/.agents/skills/vidfarm/harnesses/hooks.HARNESS.md +10 -3
- package/.agents/skills/vidfarm/harnesses/product-explainer.HARNESS.md +1 -1
- package/.agents/skills/vidfarm/harnesses/short-form.HARNESS.md +2 -0
- package/.agents/skills/vidfarm/references/assets-and-sourcing.md +44 -4
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +1 -1
- package/.agents/skills/vidfarm/references/content-ideas.md +72 -1
- package/.agents/skills/vidfarm/references/core-workflows.md +17 -2
- package/.agents/skills/vidfarm/references/editor-workflows.md +39 -9
- package/.agents/skills/vidfarm/references/reviewing-renders.md +1 -0
- package/SKILL.director.md +180 -19
- package/SKILL.md +9 -2
- package/clipper.md +22 -0
- package/dist/src/cli.js +98 -5
- package/dist/src/devcli/composition-edit.js +36 -3
- package/dist/src/devcli/marketplace-gigs.js +2 -2
- package/dist/src/devcli/qa-check.js +7 -3
- package/dist/src/devcli/shared-folder.js +333 -44
- package/dist/src/devcli/skill-docs.js +2 -2
- package/experimental/meme-recaption.md +1756 -0
- package/experimental/sticker-slideshow-tips.md +1486 -0
- package/experimental/ugc-reaction-greenscreen.md +1963 -0
- package/experimental/wall-text-pov-ugc.md +2036 -0
- package/marketplace.md +82 -8
- package/package.json +6 -1
- package/public/assets/file-directory-app.js +33 -33
- package/public/assets/homepage-client-app.js +14 -14
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 ·
|
|
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
|
|
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
|
|
395
|
-
// which means coercing it to the bold default is strictly an
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
-
|
|
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}",
|
|
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
|
|
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
|