@officexapp/vidfarm-devcli 0.21.28 → 0.21.30

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.
Files changed (30) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +26 -0
  2. package/.agents/skills/vidfarm/SKILL.md +53 -2
  3. package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-regime.md +65 -0
  4. package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +78 -7
  5. package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +2 -2
  6. package/.agents/skills/vidfarm/recipes/retheme-template.md +1 -1
  7. package/.agents/skills/vidfarm/references/automation-and-local-dev.md +66 -5
  8. package/.agents/skills/vidfarm/references/editor-workflows.md +94 -1
  9. package/.agents/skills/vidfarm/references/hooks-and-virality.md +237 -0
  10. package/.agents/skills/vidfarm/references/onboarding.md +1 -1
  11. package/.agents/skills/vidfarm/regimes/README.md +77 -0
  12. package/.agents/skills/vidfarm/regimes/explainer.QA_REGIME.md +82 -0
  13. package/.agents/skills/vidfarm/regimes/hooks.QA_REGIME.md +117 -0
  14. package/.agents/skills/vidfarm/regimes/product-demo.QA_REGIME.md +92 -0
  15. package/.agents/skills/vidfarm/regimes/short-form.QA_REGIME.md +163 -0
  16. package/.agents/skills/vidfarm/regimes/ugc-testimonial.QA_REGIME.md +82 -0
  17. package/SKILL.director.md +599 -19
  18. package/SKILL.md +18 -2
  19. package/demo/dist/app.js +103 -103
  20. package/dist/src/cli.js +925 -18
  21. package/dist/src/devcli/doctor.js +13 -0
  22. package/dist/src/devcli/handoff.js +162 -0
  23. package/dist/src/devcli/hyperframes-cli.js +12 -0
  24. package/dist/src/devcli/interaction-mode.js +154 -0
  25. package/dist/src/devcli/qa-check.js +173 -0
  26. package/dist/src/devcli/qa-regime.js +396 -0
  27. package/dist/src/devcli/sticker-pack.js +396 -0
  28. package/dist/src/devcli/storyboard.js +243 -0
  29. package/dist/src/devcli/studio-brand.js +196 -0
  30. package/package.json +8 -1
@@ -15,6 +15,7 @@ import { parseArgs } from "node:util";
15
15
  import { detectLocalAgent } from "../services/clip-curation/index.js";
16
16
  import { hasFfmpeg, resolveFfmpeg, resolveFfprobe } from "../services/clip-curation/ffmpeg.js";
17
17
  import { resolveHyperframesCli } from "./hyperframes-cli.js";
18
+ import { applyVidfarmStudioBrand } from "./studio-brand.js";
18
19
  import { resolveSkillsRoot } from "./skills.js";
19
20
  import { readStoredAuth } from "./auth-store.js";
20
21
  import { scanLocalServers, reapProcesses } from "./process-scan.js";
@@ -138,6 +139,18 @@ export async function runDoctorCommand(argv) {
138
139
  add("hyperframes", hyperframesCli ? "ok" : "warn", hyperframesCli
139
140
  ? `${hyperframesCli} (Vidfarm's whitelabel render/animation engine — reach it via \`vidfarm hf …\`)`
140
141
  : "not installed — Vidfarm's render/animation engine (open-source whitelabel). Install for native local render/tts/stt/matting: `npm i -g hyperframes` (else falls back to slow `npx -y hyperframes`)");
142
+ // 3b. Whitelabel the LOCAL studio shell (the `hyperframes preview` editor UI),
143
+ // which otherwise renders the upstream "HeyGen · HyperFrames" header logo and
144
+ // tab title. `doctor` REPAIRS it rather than just reporting: the patch is
145
+ // idempotent and a fresh `npm install` restores the pristine shell.
146
+ if (hyperframesCli) {
147
+ const brand = applyVidfarmStudioBrand();
148
+ add("studio branding", brand === "unavailable" ? "warn" : "ok", brand === "patched"
149
+ ? "local studio shell re-branded as VidFarm (upstream logo/title replaced)"
150
+ : brand === "already-branded"
151
+ ? "local studio shell already whitelabeled as VidFarm"
152
+ : "could not patch the local studio shell — `hyperframes preview` will show upstream branding (cosmetic only)");
153
+ }
141
154
  // 4. Chrome for the in-process render (stills / serve local render).
142
155
  const chrome = detectChromeForRender();
143
156
  add("chrome", chrome.found ? "ok" : "warn", chrome.found ? chrome.detail : `${chrome.detail} — local render/stills will try to download one on first run`);
@@ -0,0 +1,162 @@
1
+ // Interactive-mode HAND-OFF briefs — the copy-paste packets an agent gives the
2
+ // human when the pair (cost mode × interaction mode) says "a human step here
3
+ // beats spending, and beats going without".
4
+ //
5
+ // Two hand-offs cover almost every real case:
6
+ //
7
+ // 1. IMAGES (`buildImageHandoff`) — the agent writes the prompt, the user runs
8
+ // it in a FREE frontier web generator (meta.ai / ChatGPT / Gemini / a
9
+ // Hugging Face Space) and drops the file back. Defaults to a STICKER PACK:
10
+ // one prompt → one greenscreen sheet holding every graphic → `vidfarm
11
+ // sticker-pack` splits it into many transparent stickers locally. One round
12
+ // trip for a whole cast is both the cheapest thing for the user's time and
13
+ // the most token-efficient thing for the agent.
14
+ //
15
+ // 2. RAW CLIPS (`buildRawsHandoff`) — the agent hands over search keywords and
16
+ // where to search, the user downloads a few clips with a free online
17
+ // downloader and points the agent at the folder. This is the LAST rung of
18
+ // the sourcing ladder: an agent should first try its own browser control,
19
+ // then vidfarm's cloud scan, and only ask the human when neither is
20
+ // available (fully-local, keyless devcli) or when human taste matters.
21
+ //
22
+ // Both builders return plain text meant to be shown verbatim to the user, plus a
23
+ // structured form for `--json` so an agent can render it its own way. No network,
24
+ // no backend imports — this module is pure string assembly.
25
+ import { pickPlateColor } from "./sticker-pack.js";
26
+ const DEFAULT_STYLE = "simple flat vector illustration, minimal detail, 2-3 flat colors, no shadows, no text";
27
+ /** Pick a sensible grid for N items (roughly square, wider than tall). */
28
+ function gridFor(count) {
29
+ if (count <= 1)
30
+ return "1x1";
31
+ const cols = Math.ceil(Math.sqrt(count));
32
+ const rows = Math.ceil(count / cols);
33
+ return `${cols}x${rows}`;
34
+ }
35
+ /**
36
+ * Build the free-image-generation hand-off. Sticker-pack shaped by default: ONE
37
+ * image containing every item on a flat chroma plate, generously spaced, which
38
+ * `vidfarm sticker-pack` then splits into individually-trimmed transparent
39
+ * stickers. The spacing/no-touching instruction is load-bearing — items that
40
+ * touch on the sheet segment as a single sticker.
41
+ */
42
+ export function buildImageHandoff(input) {
43
+ // Plate choice matters MORE here than anywhere else: the user is about to
44
+ // spend their own effort generating this sheet, and a green plate under green
45
+ // art means the stickers come back with holes. Pick the plate off the subject
46
+ // unless the caller pinned one.
47
+ const picked = pickPlateColor([input.theme, input.items.join(" "), input.style ?? ""].join(" "));
48
+ const keyColor = (input.keyColor ?? picked.keyColor).toUpperCase();
49
+ const keyColorNote = input.keyColor
50
+ ? null
51
+ : picked.moved
52
+ ? `Plate moved off green to ${picked.preset} (${picked.keyColor}) — ${picked.reason}. A green plate would key holes through green artwork.`
53
+ : picked.reason;
54
+ const style = input.style?.trim() || DEFAULT_STYLE;
55
+ const items = input.items.map((s) => s.trim()).filter(Boolean);
56
+ const pack = input.pack ?? items.length > 1;
57
+ const outDir = input.outDir ?? "./stickers";
58
+ const grid = input.grid ?? gridFor(items.length || 6);
59
+ const prompt = pack
60
+ ? [
61
+ `A sticker sheet of ${items.length || "several"} separate objects — ${style} — on a solid pure ${keyColor} background`,
62
+ `(flat, evenly lit, no gradient, no shadow, no text, no labels, no frames or dividing lines).`,
63
+ `Theme: ${input.theme}.`,
64
+ items.length
65
+ ? `Arranged in a ${grid} grid with generous even spacing: ${items.map((it, i) => `(${i + 1}) ${it}`).join(", ")}.`
66
+ : `Arranged in a ${grid} grid with generous even spacing.`,
67
+ `CRITICAL: every object must be fully separated from the others by a clear margin of plain ${keyColor} background —`,
68
+ `nothing touching, overlapping or connected, and nothing touching the image edge.`,
69
+ `One consistent art style, line weight and palette across all objects. Front-facing, centered in its own cell.`,
70
+ `Square image, high resolution.`
71
+ ].join(" ")
72
+ : [
73
+ `${input.theme} — ${style} — isolated on a solid pure ${keyColor} background`,
74
+ `(flat, evenly lit, no gradient, no shadow cast on the background, no text).`,
75
+ `Center the subject with generous empty margin on all sides, crisp clean edges, single subject.`,
76
+ `Square image, high resolution.`
77
+ ].join(" ");
78
+ const steps = pack
79
+ ? [
80
+ "Open a FREE image generator you're already signed into (list below).",
81
+ "Paste the prompt below and generate. If any two objects come out touching, re-generate asking for wider spacing — touching objects get cut out as one sticker.",
82
+ "Download the image (PNG preferred) and tell me the file path — or drop it in this project folder.",
83
+ `I'll split it into individual transparent stickers locally for $0: \`vidfarm sticker-pack <sheet> --out-dir ${outDir}\`.`
84
+ ]
85
+ : [
86
+ "Open a FREE image generator you're already signed into (list below).",
87
+ "Paste the prompt below and generate.",
88
+ "Download the image (PNG preferred) and tell me the file path.",
89
+ "I'll key the background out and trim it to a snug transparent sticker locally for $0: `vidfarm cutout <file>`."
90
+ ];
91
+ const keyFlag = keyColor === "#00FF00" ? "" : ` --key-color "${keyColor}"`;
92
+ const followUp = pack
93
+ ? `vidfarm sticker-pack <downloaded-sheet.png>${items.length ? ` --items "${items.join(",")}"` : ""}${keyFlag} --out-dir ${outDir}`
94
+ : `vidfarm cutout <downloaded.png>${keyFlag}`;
95
+ return {
96
+ prompt,
97
+ steps,
98
+ followUp,
99
+ keyColor,
100
+ keyColorNote,
101
+ tools: [
102
+ "https://meta.ai (free, generous)",
103
+ "ChatGPT free tier (image generation included)",
104
+ "Google Gemini free tier",
105
+ "a free image Space on https://huggingface.co/spaces (search \"flux\" or \"sdxl\")"
106
+ ]
107
+ };
108
+ }
109
+ /**
110
+ * Build the manual raw-clip sourcing hand-off. Deliberately does NOT name one
111
+ * downloader site: those rot, get blocked, and vary by region — so the brief
112
+ * hands over a SEARCH for a downloader instead, which stays correct forever.
113
+ */
114
+ export function buildRawsHandoff(input) {
115
+ const keywords = input.keywords.map((k) => k.trim()).filter(Boolean);
116
+ if (!keywords.length)
117
+ throw new Error("A raws hand-off needs at least one search keyword.");
118
+ const platforms = (input.platforms?.length ? input.platforms : ["tiktok", "youtube"]).map((p) => p.trim().toLowerCase());
119
+ const count = input.count ?? Math.max(3, keywords.length * 2);
120
+ const outDir = input.outDir ?? "./downloads";
121
+ const searchLines = keywords.map((k) => ` • "${k}"`).join("\n");
122
+ const prompt = [
123
+ `Search these keywords on ${platforms.join(" / ")} and download ${count} clips you like:`,
124
+ searchLines,
125
+ "",
126
+ `To download: search Google for a downloader — https://google.com/search?q=${encodeURIComponent(`${platforms[0]} video downloader`)} — and use whichever one works today.`
127
+ ].join("\n");
128
+ return {
129
+ prompt,
130
+ steps: [
131
+ `Search ${platforms.join(" and ")} for the keywords below.`,
132
+ input.purpose
133
+ ? `Pick ${count} clips that fit: ${input.purpose}. Favor clean, well-lit footage with no burned-in captions or watermarks over the subject.`
134
+ : `Pick ${count} clips with clean, well-lit footage and no burned-in captions or watermarks over the subject.`,
135
+ `Download them with any free online downloader (google "${platforms[0]} video downloader" — the working one changes often) into ${outDir}.`,
136
+ `Tell me the folder and I'll import each one into your raws library: \`vidfarm clipper ${outDir}/<file>.mp4\` (whole clip), or \`vidfarm raws scan ${outDir}/<file>.mp4\` to mine short clips out of a long one.`
137
+ ],
138
+ followUp: `vidfarm clipper ${outDir}/<file>.mp4 # per download; or: vidfarm raws scan ${outDir}/<file>.mp4`,
139
+ tools: [
140
+ `https://google.com/search?q=${encodeURIComponent(`${platforms[0]} video downloader`)}`,
141
+ ...platforms.map((p) => `https://google.com/search?q=${encodeURIComponent(`${p} ${keywords[0]}`)}`)
142
+ ]
143
+ };
144
+ }
145
+ /** Render a brief as the block of text an agent shows the user verbatim. */
146
+ export function formatHandoff(title, brief, opts = {}) {
147
+ const lines = [];
148
+ lines.push(title);
149
+ lines.push("");
150
+ brief.steps.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
151
+ lines.push("");
152
+ lines.push(`${opts.promptLabel ?? "─── copy from here ───"}`);
153
+ lines.push(brief.prompt);
154
+ lines.push("──────────────────────");
155
+ lines.push("");
156
+ lines.push("Free tools:");
157
+ brief.tools.forEach((t) => lines.push(` • ${t}`));
158
+ lines.push("");
159
+ lines.push(`When the file(s) come back: ${brief.followUp}`);
160
+ return lines.join("\n");
161
+ }
162
+ //# sourceMappingURL=handoff.js.map
@@ -49,6 +49,18 @@ export async function runHyperframesCommand(subcommand, args, opts = {}) {
49
49
  "Local engines and vidfarm BYOK keys are the only auth.");
50
50
  }
51
51
  const cli = resolveHyperframesCli();
52
+ // The local studio SPA shell ships upstream branding (a "HeyGen · HyperFrames"
53
+ // header logo and a "HyperFrames Studio" tab title) that `hyperframes preview`
54
+ // serves straight off disk — the one Vidfarm surface the web editor's own
55
+ // rebrand never reaches. Whitelabel the shell on EVERY hyperframes spawn, not
56
+ // just preview: the patch is a cheap idempotent file check, and doing it here
57
+ // means a director who later runs `hyperframes preview` by hand still gets the
58
+ // VidFarm studio. Best-effort — branding never fails a render.
59
+ try {
60
+ const { applyVidfarmStudioBrand } = await import("./studio-brand.js");
61
+ applyVidfarmStudioBrand();
62
+ }
63
+ catch { /* cosmetic only */ }
52
64
  // Fresh empty config dir per invocation — guarantees no ~/.heygen credential
53
65
  // (or any other persisted account state) leaks into the child process.
54
66
  const emptyConfigDir = mkdtempSync(path.join(os.tmpdir(), "vidfarm-hf-noauth-"));
@@ -0,0 +1,154 @@
1
+ // Interaction mode — is the human IN THE LOOP, or is the agent running solo?
2
+ //
3
+ // This is a SECOND, INDEPENDENT axis from cost mode (cost-mode.ts). Cost mode
4
+ // answers "how much money may I spend?"; interaction mode answers "how much of
5
+ // the user's own time and hands may I use?". Every cost mode can run either
6
+ // way — interactive minimize, autonomous rich-ai, interactive pure-videogen —
7
+ // and the pair is what actually determines the plan.
8
+ //
9
+ // - interactive : the user is willing to do some manual work in exchange for
10
+ // better output. The agent hands them copy-paste briefs at
11
+ // fixed checkpoints — an image prompt to run in a free web
12
+ // generator, a keyword list to source raw clips with — and
13
+ // waits for the files to come back. Quality goes UP, because
14
+ // the frontier web models (free tiers) beat what an API-key
15
+ // budget usually buys, and a human eye picks better clips than
16
+ // any keyword scan. In `minimize` this is the difference
17
+ // between "no custom art at all" and "a full sticker pack for
18
+ // $0", so ALWAYS offer it there.
19
+ // - autonomous : the agent finishes end-to-end with zero user steps. It
20
+ // spends (within cost mode) or does without. Faster and
21
+ // hands-off; typically a quality floor below interactive.
22
+ //
23
+ // Like cost mode, this is a durable preference the human picks ONCE (`vidfarm
24
+ // mode <interactive|autonomous>`) and every relevant command respects. Unset
25
+ // means "nobody has asked yet" — the agent should ask before it either spends
26
+ // money or silently downgrades quality.
27
+ //
28
+ // Backend-free (Node built-ins only) so it ships in the public cloud-only CLI.
29
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
30
+ import path from "node:path";
31
+ import { resolveDevcliHome } from "./auth-store.js";
32
+ export const INTERACTION_MODES = ["interactive", "autonomous"];
33
+ /** What we assume when nothing is set. Autonomous is the safe assumption for a
34
+ * running pipeline (never block on a human who isn't there), but `isSet:false`
35
+ * is the signal for an agent to ASK — interactive usually wins on quality. */
36
+ export const DEFAULT_INTERACTION_MODE = "autonomous";
37
+ function interactionModeFilePath(home) {
38
+ return path.join(resolveDevcliHome(home), "interaction-mode.json");
39
+ }
40
+ /** Normalize loose spellings ("manual", "hands-on", "auto", "solo"). */
41
+ export function normalizeInteractionMode(raw) {
42
+ const v = (raw ?? "").trim().toLowerCase().replace(/[\s_]+/g, "-");
43
+ if (!v)
44
+ return null;
45
+ if (["interactive", "manual", "hands-on", "handson", "collab", "collaborative", "human", "human-in-the-loop", "hitl", "assisted", "guided", "with-me"].includes(v)) {
46
+ return "interactive";
47
+ }
48
+ if (["autonomous", "auto", "automatic", "solo", "hands-off", "handsoff", "unattended", "headless", "agentic", "full-auto"].includes(v)) {
49
+ return "autonomous";
50
+ }
51
+ return null;
52
+ }
53
+ export function readStoredInteractionMode(home) {
54
+ try {
55
+ const parsed = JSON.parse(readFileSync(interactionModeFilePath(home), "utf8"));
56
+ const mode = normalizeInteractionMode(parsed?.mode);
57
+ if (mode)
58
+ return { mode, note: parsed.note ?? null, savedAt: String(parsed.savedAt ?? "") };
59
+ return null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /** Persist the interaction mode. Returns the file path written. */
66
+ export function writeStoredInteractionMode(mode, savedAt, note, home) {
67
+ const dir = resolveDevcliHome(home);
68
+ mkdirSync(dir, { recursive: true });
69
+ const file = interactionModeFilePath(home);
70
+ const payload = { mode, note: note ?? null, savedAt };
71
+ writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
72
+ return file;
73
+ }
74
+ export function clearStoredInteractionMode(home) {
75
+ const file = interactionModeFilePath(home);
76
+ if (!existsSync(file))
77
+ return false;
78
+ rmSync(file, { force: true });
79
+ return true;
80
+ }
81
+ /**
82
+ * Resolve the effective interaction mode. Precedence:
83
+ * --interaction-mode/--interactive/--autonomous → VIDFARM_INTERACTION_MODE →
84
+ * stored preference → default (autonomous, isSet:false).
85
+ */
86
+ export function resolveInteractionMode(values) {
87
+ if (values.interactive && !values.autonomous)
88
+ return { mode: "interactive", isSet: true, source: "flag" };
89
+ if (values.autonomous && !values.interactive)
90
+ return { mode: "autonomous", isSet: true, source: "flag" };
91
+ const flag = normalizeInteractionMode(values["interaction-mode"]);
92
+ if (flag)
93
+ return { mode: flag, isSet: true, source: "flag" };
94
+ const env = normalizeInteractionMode(process.env.VIDFARM_INTERACTION_MODE);
95
+ if (env)
96
+ return { mode: env, isSet: true, source: "env" };
97
+ const stored = readStoredInteractionMode(values.home);
98
+ if (stored)
99
+ return { mode: stored.mode, isSet: true, source: "stored" };
100
+ return { mode: DEFAULT_INTERACTION_MODE, isSet: false, source: "default" };
101
+ }
102
+ export const INTERACTION_MODE_BLURB = {
103
+ interactive: "Interactive — you're in the loop, and the video gets better for it. At fixed checkpoints the agent " +
104
+ "hands you a copy-paste brief instead of spending: an IMAGE PROMPT to run in a free web generator " +
105
+ "(meta.ai / ChatGPT / Gemini / a Hugging Face Space) and drop the PNG back, or a KEYWORD LIST to " +
106
+ "search TikTok/YouTube and download raw clips with a free online downloader. Frontier web models on " +
107
+ "their free tiers usually beat what an API budget buys, and a human eye picks better clips than any " +
108
+ "keyword scan — so quality goes UP, especially in cost mode `minimize` where the alternative is no " +
109
+ "custom art at all. Manual image work defaults to STICKER PACKS: one prompt → one greenscreen sheet " +
110
+ "→ `vidfarm sticker-pack` splits it into many transparent stickers.",
111
+ autonomous: "Autonomous — the agent finishes end-to-end with zero steps from you. No prompts to paste, no files " +
112
+ "to fetch: it sources clips itself (browser control, the free public raws catalog, cloud scan) and " +
113
+ "generates art within the cost mode's budget, or does without. Fastest and fully hands-off; the " +
114
+ "quality ceiling is usually a notch below interactive, and in `minimize` it means custom art simply " +
115
+ "doesn't happen."
116
+ };
117
+ /** One short human line summarizing the active interaction mode. */
118
+ export function interactionModeSummaryLine(resolved) {
119
+ const setNote = resolved.isSet ? `set via ${resolved.source}` : "NOT set — assuming autonomous";
120
+ return `interaction mode: ${resolved.mode} (${setNote})`;
121
+ }
122
+ /** The "explain it simply" block an agent should relay to the user. */
123
+ export function interactionModeExplainer() {
124
+ return [
125
+ "Do you want to be in the loop, or should the agent finish on its own?",
126
+ " • interactive — you do a little manual work, the video comes out better. At checkpoints you get",
127
+ " a copy-paste IMAGE PROMPT for a free web generator (and drop the PNG back), or a",
128
+ " KEYWORD LIST to hand-source raw clips. Manual images default to sticker packs.",
129
+ " • autonomous — zero steps from you: the agent sources and generates everything itself, within",
130
+ " the cost mode's budget (or goes without).",
131
+ "This is INDEPENDENT of cost mode — any cost mode can run either way:",
132
+ " minimize + interactive → $0 videos WITH custom art and hand-picked footage (the sweet spot).",
133
+ " minimize + autonomous → $0 videos from stock + existing raws only; no custom art.",
134
+ " hybrid/rich-ai + interactive → paid generation for the hard shots, your hands on the hero art.",
135
+ " rich-ai/pure-videogen + autonomous → fully hands-off, budget does the work.",
136
+ "Quality usually goes UP in interactive mode — most of all in `minimize`, where the free frontier",
137
+ "web models replace an AI budget you're not spending.",
138
+ "Set it: vidfarm mode <interactive|autonomous> (cost is separate: vidfarm cost-mode <…>)"
139
+ ].join("\n");
140
+ }
141
+ /**
142
+ * The line an agent should print (and act on) when it is about to do something
143
+ * that interactive mode would do better — generate art, or source raw clips.
144
+ * Returns null when the mode is set and the agent should just proceed.
145
+ */
146
+ export function interactiveOfferLine(input) {
147
+ if (input.interaction.isSet)
148
+ return null;
149
+ return (`[mode] No interaction preference set — about to ${input.about} autonomously. ` +
150
+ "Ask the user whether they'd rather work INTERACTIVELY (they paste a prompt into a free image " +
151
+ "generator / hand-source clips — usually better output, and the only way to get custom art in " +
152
+ "`minimize`) and save it: vidfarm mode <interactive|autonomous>.");
153
+ }
154
+ //# sourceMappingURL=interaction-mode.js.map
@@ -5,6 +5,10 @@
5
5
  // capsules, benefit chip rows, and frosted cards floating over footage — the
6
6
  // single loudest tell that a video was machine-made.
7
7
  //
8
+ // It also judges ONE frame on its own terms: t=0. That frame is the poster every
9
+ // feed and share sheet freezes on, so a black open or a fade-up from black is a
10
+ // real defect even though it's a single frame of ~30 in the first second.
11
+ //
8
12
  // DESIGN CONTRACT (read before adding a rule):
9
13
  // * BLOCKLIST, never allowlist. We name specific, well-known web-furniture
10
14
  // patterns. Anything we haven't named is legal. A composition that does
@@ -50,6 +54,27 @@ const SLOP_CLASS_PREFIXES = [
50
54
  const ACTION_COPY = /\b(sign\s?up|get\s?started|learn\s?more|book\s+a\s+(call|demo)|start\s+(your\s+)?free\s+trial|free\s+trial|try\s+(it\s+)?free|click\s+here|subscribe\s+now|shop\s+now|order\s+now|contact\s+us|join\s+now|download\s+now|register\s+now|read\s+more|see\s+plans|view\s+pricing)\b/i;
51
55
  // The stock trust-badge phrases that show up in benefit chip rows.
52
56
  const BENEFIT_COPY = /(no\s+credit\s+card|money[-\s]?back|cancel\s+any\s?time|24\/7|free\s+shipping|verified|guarantee|no\s+commitment|risk[-\s]?free)/i;
57
+ // Entrance transitions that leave frame 0 as a flat solid — the worst possible
58
+ // thumbnail, because the frame carries no information at all.
59
+ const BLANKING_INTROS = ["fade-black", "fade-white", "flash", "smoke"];
60
+ function numAttrOf(node, name) {
61
+ const raw = node?.getAttribute?.(name);
62
+ const value = Number.parseFloat(String(raw ?? ""));
63
+ return Number.isFinite(value) ? value : NaN;
64
+ }
65
+ /** Does this clip paint pixels? Mirrors gapsFromClips in composition-edit.ts. */
66
+ function isVisualClip(node) {
67
+ const kind = String(node?.getAttribute?.("data-layer-kind") ?? "").toLowerCase();
68
+ return node?.getAttribute?.("data-vf-timeline-proxy") === "video" || ["video", "image", "html", "shape"].includes(kind);
69
+ }
70
+ /** Is the clip on screen at t (seconds)? Layers with no duration are ignored. */
71
+ function coversTime(node, t) {
72
+ const start = numAttrOf(node, "data-start");
73
+ const duration = numAttrOf(node, "data-duration");
74
+ if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0)
75
+ return false;
76
+ return start <= t + 0.001 && start + duration > t + 0.001;
77
+ }
53
78
  function textOf(node) {
54
79
  return String(node?.textContent ?? "").replace(/\s+/g, " ").trim();
55
80
  }
@@ -390,6 +415,62 @@ export function qaCompositionHtml(html) {
390
415
  }
391
416
  }
392
417
  }
418
+ // ── Rule: the first frame IS the thumbnail ─────────────────────────────────
419
+ // Frame 0 is one frame of ~30 in the first second, but every feed, share
420
+ // sheet, and player poster freezes on it — so it is seen by everyone who
421
+ // scrolls past and never presses play. A black open, a fade-up from black, or
422
+ // an empty frame waiting for the hook line throws away the only frame that
423
+ // has to sell the video. Judged at t=0 exactly, since that is the frame the
424
+ // poster is grabbed from.
425
+ const visualClips = layers.filter(isVisualClip);
426
+ const openingVisuals = visualClips.filter((node) => coversTime(node, 0));
427
+ if (visualClips.length && !openingVisuals.length) {
428
+ const earliest = visualClips
429
+ .map((node) => numAttrOf(node, "data-start"))
430
+ .filter((value) => Number.isFinite(value))
431
+ .sort((a, b) => a - b)[0];
432
+ push({
433
+ rule: "thumbnail-blank-open",
434
+ severity: "error",
435
+ message: Number.isFinite(earliest)
436
+ ? `Nothing is on screen at t=0 — the first visual starts at ${earliest.toFixed(2)}s, so the thumbnail is a black frame.`
437
+ : "Nothing is on screen at t=0 — the thumbnail is a black frame.",
438
+ where: "timeline t=0",
439
+ fix: "Pull the opening clip back to start:0 (`vidfarm retime <dir> --layer <key> --start 0`), or place a deliberate hook frame there. The first frame is the poster every scroller judges the video by."
440
+ });
441
+ }
442
+ for (const node of openingVisuals) {
443
+ const intro = String(node.getAttribute?.("data-transition") ?? "").trim().toLowerCase();
444
+ if (!intro || intro === "none")
445
+ continue;
446
+ const blanking = BLANKING_INTROS.includes(intro);
447
+ push({
448
+ rule: "thumbnail-fade-in",
449
+ severity: blanking ? "error" : "warn",
450
+ message: blanking
451
+ ? `The opening clip has a "${intro}" entrance — frame 0 is a flat ${intro === "fade-white" ? "white" : intro === "flash" ? "blown-out" : "black"} frame, so that becomes the thumbnail.`
452
+ : `The opening clip has a "${intro}" entrance — frame 0 is caught mid-animation (offset, blurred, or scaled), which is what the thumbnail freezes on.`,
453
+ where: label(node, "opening clip"),
454
+ fix: `Drop the entrance on the FIRST clip — it's an intro, not a junction (\`vidfarm transitions set <dir> --layer <key> --in none\`). Junction transitions between later clips are unaffected. If you truly want to open from black, add a held hook frame before it so the poster still shows something.`
455
+ });
456
+ }
457
+ // The strongest thumbnails carry the hook in words. Only a nudge: opening on a
458
+ // clean face or product shot is a legitimate call.
459
+ if (textLayers.length && !textLayers.some((node) => coversTime(node, 0))) {
460
+ const earliest = textLayers
461
+ .map((node) => numAttrOf(node, "data-start"))
462
+ .filter((value) => Number.isFinite(value))
463
+ .sort((a, b) => a - b)[0];
464
+ push({
465
+ rule: "thumbnail-no-hook-text",
466
+ severity: "warn",
467
+ message: Number.isFinite(earliest)
468
+ ? `No on-screen text at t=0 (the first text lands at ${earliest.toFixed(2)}s) — the thumbnail carries no hook words.`
469
+ : "No on-screen text at t=0 — the thumbnail carries no hook words.",
470
+ where: "timeline t=0",
471
+ fix: "Start the hook line at 0 so the poster frame states the promise. Ignore this if the video deliberately opens on a clean face/product shot."
472
+ });
473
+ }
393
474
  const errors = findings.filter((f) => f.severity === "error");
394
475
  const warnings = findings.filter((f) => f.severity === "warn");
395
476
  return {
@@ -404,6 +485,98 @@ export function qaCompositionHtml(html) {
404
485
  }
405
486
  };
406
487
  }
488
+ function aspectLabel(width, height) {
489
+ if (!width || !height)
490
+ return null;
491
+ const gcd = (a, b) => (b ? gcd(b, a % b) : a);
492
+ const divisor = gcd(width, height) || 1;
493
+ return `${width / divisor}:${height / divisor}`;
494
+ }
495
+ /**
496
+ * Derive the facts a QA_REGIME.md can assert against. Pure, tolerant of
497
+ * malformed input (returns empty facts rather than throwing) — same contract as
498
+ * qaCompositionHtml, because a regime run must never break a render either.
499
+ */
500
+ export function extractCompositionFacts(html) {
501
+ const empty = {
502
+ duration_sec: null,
503
+ canvas: { width: null, height: null, aspect: null },
504
+ visual_clips: [],
505
+ text_runs: [],
506
+ card_runs: [],
507
+ audio_layers: 0,
508
+ caption_layers: 0,
509
+ first_frame_visual: false,
510
+ first_frame_text: null,
511
+ first_text_at_sec: null,
512
+ max_simultaneous_text: 0,
513
+ off_regime_fonts: [],
514
+ all_text: ""
515
+ };
516
+ let root = null;
517
+ try {
518
+ const { document } = parseHTML(html);
519
+ root = document.querySelector("[data-composition-id]") ?? document.body;
520
+ }
521
+ catch {
522
+ return empty;
523
+ }
524
+ if (!root)
525
+ return empty;
526
+ const layers = Array.from(root.querySelectorAll("[data-start]"));
527
+ const width = Number(root.getAttribute?.("data-width") ?? 0) || null;
528
+ const height = Number(root.getAttribute?.("data-height") ?? 0) || null;
529
+ const duration = Number.parseFloat(String(root.getAttribute?.("data-duration") ?? ""));
530
+ const visualClips = layers.filter(isVisualClip).map((node) => ({
531
+ key: String(node.getAttribute?.("data-hf-id") || node.getAttribute?.("id") || ""),
532
+ kind: String(node.getAttribute?.("data-layer-kind") ?? "video").toLowerCase(),
533
+ start: numAttrOf(node, "data-start") || 0,
534
+ duration: numAttrOf(node, "data-duration") || 0
535
+ }));
536
+ const textRuns = [];
537
+ for (const node of Array.from(root.querySelectorAll('[data-layer-kind="caption"], [data-layer-kind="text"]'))) {
538
+ const text = textOf(node);
539
+ if (!text)
540
+ continue;
541
+ const style = styleString(node);
542
+ const cssFamily = (style.match(/(?:^|;)\s*font-family\s*:\s*([^;]+)/) ?? [])[1] ?? "";
543
+ const family = primaryFamily(String(node.getAttribute?.("data-font-family") ?? "") || cssFamily);
544
+ textRuns.push({
545
+ key: String(node.getAttribute?.("data-hf-id") || node.getAttribute?.("id") || ""),
546
+ kind: String(node.getAttribute?.("data-layer-kind") ?? "text").toLowerCase(),
547
+ text,
548
+ start: numAttrOf(node, "data-start") || 0,
549
+ duration: numAttrOf(node, "data-duration") || 0,
550
+ font: family || null
551
+ });
552
+ }
553
+ // Max text runs sharing the screen — swept at every run's start, which is the
554
+ // only instant the count can rise.
555
+ let maxSimultaneous = 0;
556
+ for (const probe of textRuns) {
557
+ const at = probe.start;
558
+ const overlapping = textRuns.filter((run) => run.start <= at + 0.001 && run.start + run.duration > at + 0.001).length;
559
+ if (overlapping > maxSimultaneous)
560
+ maxSimultaneous = overlapping;
561
+ }
562
+ const openingText = textRuns.filter((run) => run.start <= 0.001 && run.start + run.duration > 0.001);
563
+ const sortedStarts = textRuns.map((run) => run.start).sort((a, b) => a - b);
564
+ return {
565
+ duration_sec: Number.isFinite(duration) ? duration : null,
566
+ canvas: { width, height, aspect: aspectLabel(width, height) },
567
+ visual_clips: visualClips,
568
+ text_runs: textRuns,
569
+ card_runs: textRuns.filter((run) => run.kind === "text"),
570
+ audio_layers: layers.filter((node) => String(node.getAttribute?.("data-layer-kind") ?? "").toLowerCase() === "audio").length,
571
+ caption_layers: textRuns.filter((run) => run.kind === "caption").length,
572
+ first_frame_visual: visualClips.some((clip) => clip.start <= 0.001 && clip.start + clip.duration > 0.001),
573
+ first_frame_text: openingText.length ? openingText.map((run) => run.text).join(" ").trim() : null,
574
+ first_text_at_sec: sortedStarts.length ? sortedStarts[0] : null,
575
+ max_simultaneous_text: maxSimultaneous,
576
+ off_regime_fonts: [...new Set(textRuns.map((run) => run.font).filter((font) => Boolean(font) && !FONT_REGIME.includes(font)))],
577
+ all_text: textRuns.map((run) => run.text).join(" \n ")
578
+ };
579
+ }
407
580
  /** Human-readable report body (no trailing summary line — the CLI adds that). */
408
581
  export function formatQaReport(report, colors) {
409
582
  const { red, yellow, dim, reset } = colors;