@officexapp/vidfarm-devcli 0.21.39 → 0.21.43

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 +16 -0
  2. package/.agents/skills/vidfarm/SKILL.md +31 -6
  3. package/.agents/skills/vidfarm/harnesses/README.md +1 -0
  4. package/.agents/skills/vidfarm/harnesses/explainer.HARNESS.md +11 -0
  5. package/.agents/skills/vidfarm/harnesses/product-demo.HARNESS.md +2 -0
  6. package/.agents/skills/vidfarm/harnesses/product-explainer.HARNESS.md +242 -0
  7. package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-harness.md +1 -1
  8. package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +2 -0
  9. package/.agents/skills/vidfarm/recipes/onboard-a-new-director.md +10 -8
  10. package/.agents/skills/vidfarm/references/automation-and-local-dev.md +21 -8
  11. package/.agents/skills/vidfarm/references/content-ideas.md +113 -0
  12. package/.agents/skills/vidfarm/references/editor-workflows.md +36 -3
  13. package/.agents/skills/vidfarm/references/onboarding.md +64 -1
  14. package/.agents/skills/vidfarm/references/primitives.md +67 -0
  15. package/.agents/skills/vidfarm-media/SKILL.md +50 -0
  16. package/SKILL.director.md +347 -27
  17. package/SKILL.md +6 -3
  18. package/crowdsourcing.md +49 -1
  19. package/dist/src/cli.js +579 -19
  20. package/dist/src/devcli/consult.js +389 -0
  21. package/dist/src/devcli/cost-mode.js +8 -0
  22. package/dist/src/devcli/experiments.js +10 -5
  23. package/dist/src/devcli/qa-check.js +74 -0
  24. package/dist/src/devcli/skill-docs.js +103 -0
  25. package/dist/src/services/brainstorm-prompts.js +132 -0
  26. package/experimental/unique-product-explainers.md +855 -0
  27. package/experiments.md +33 -2
  28. package/package.json +10 -1
  29. package/src/assets/SELLING_AWARENESS_STAGES.md +579 -0
  30. package/src/assets/SELLING_WITH_HOOKS.md +377 -0
@@ -0,0 +1,389 @@
1
+ // KEYLESS LOCAL CONSULTATION — the brainstorm chain with no AI key and no wallet.
2
+ //
3
+ // The cloud runs the consultation as five `brainstorm/*` primitives: Vidfarm
4
+ // sends a prompt to a provider on the director's saved key, or bills the wallet.
5
+ // That is a hard stop for a director who has neither.
6
+ //
7
+ // But an agent is ALREADY driving this terminal, and it is a frontier model. It
8
+ // does not need Vidfarm to broker a second one. So this module hands that agent
9
+ // the SAME prompt the cloud primitive would have sent — imported from
10
+ // services/brainstorm-prompts.ts, not re-written here, so the two paths cannot
11
+ // drift — and tells it to answer the prompt itself and save the artifact. Cost:
12
+ // $0. Network calls: none. Provider keys: none.
13
+ //
14
+ // The one real difference from the cloud path is the reference documents. The
15
+ // cloud appends SELLING_WITH_HOOKS.md / SELLING_AWARENESS_STAGES.md (34KB of
16
+ // lessons) to the prompt. Inlining those into every terminal print would burn
17
+ // ~10k tokens of the agent's context per step, so by default the brief POINTS at
18
+ // the skill pack's own tighter reference instead. `--refs` inlines the full
19
+ // documents for parity when the caller wants it.
20
+ //
21
+ // Pure string assembly plus one small offer-file lookup. No network, no backend.
22
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
23
+ import path from "node:path";
24
+ import { buildAnglesPrompt, buildAwarenessStagesPrompt, buildColdstartPrompt, buildHooksPrompt, buildProductPlacementPrompt } from "../services/brainstorm-prompts.js";
25
+ export const CONSULT_STEPS = ["coldstart", "awareness", "angles", "hooks", "placement"];
26
+ /** Aliases a director or an agent actually types. */
27
+ const STEP_ALIASES = {
28
+ coldstart: "coldstart",
29
+ "cold-start": "coldstart",
30
+ interview: "coldstart",
31
+ questions: "coldstart",
32
+ offer: "coldstart",
33
+ awareness: "awareness",
34
+ "awareness-stages": "awareness",
35
+ awareness_stages: "awareness",
36
+ stages: "awareness",
37
+ angles: "angles",
38
+ angle: "angles",
39
+ hooks: "hooks",
40
+ hook: "hooks",
41
+ placement: "placement",
42
+ "product-placement": "placement",
43
+ product_placement: "placement"
44
+ };
45
+ export function resolveConsultStep(raw) {
46
+ return STEP_ALIASES[raw.trim().toLowerCase().replace(/\s+/g, "-")] ?? null;
47
+ }
48
+ /** Where each step's answer belongs. These filenames are the ones the skill's
49
+ * onboarding flow already names, so a later step can read the earlier one. */
50
+ export const CONSULT_ARTIFACTS = {
51
+ coldstart: "OFFER.md",
52
+ awareness: "awareness-levels.md",
53
+ angles: "persuasive-angles.md",
54
+ hooks: "ad-hooks.md",
55
+ placement: "product-placement.md"
56
+ };
57
+ const STEP_ORDER = ["coldstart", "awareness", "angles", "hooks"];
58
+ /**
59
+ * The durable local answer file. OFFER.md is the polished product description;
60
+ * this is the raw carry-over — what the director said, which stage was chosen,
61
+ * what was ruled out — so a LATER session (a new terminal, a new agent, a month
62
+ * from now) starts warm instead of re-interviewing a director who already
63
+ * answered. It lives in the working folder next to the other artifacts.
64
+ */
65
+ export const CONSULT_CONTEXT_FILE = "CONTEXT.md";
66
+ /**
67
+ * The SHORT interview. The full cold-start prompt asks a model to invent 12-30
68
+ * questions; that is the right depth for a director who wants a real strategy
69
+ * session, and far too much for one who wants to make a video today. These six
70
+ * are fixed, not generated: they are the minimum set the later steps actually
71
+ * read back (awareness needs the offer + the psyche, angles and hooks quote the
72
+ * pain and desire verbatim). Fixed also means free, instant, and identical every
73
+ * run, which is what makes the answers worth saving.
74
+ */
75
+ export const SHORT_INTERVIEW = [
76
+ {
77
+ question: "In one line, what do you sell, and to whom?",
78
+ why_it_matters: "Every later step opens with this line. Without it there is no offer to consult on."
79
+ },
80
+ {
81
+ question: "What does it cost, and how does someone actually pay (appstore, Stripe, Shopify, invoice, sales call)?",
82
+ why_it_matters: "Price and checkout friction decide whether an ad can close or only warm up."
83
+ },
84
+ {
85
+ question: "Who is the single best customer you have had? Describe them like a person, not a segment.",
86
+ why_it_matters: "Angles are written to one person. A segment produces generic ads."
87
+ },
88
+ {
89
+ question: "What do they complain about, in their own words, before they find you?",
90
+ why_it_matters: "Hooks quote this back verbatim. Paraphrase kills it."
91
+ },
92
+ {
93
+ question: "What do they want instead — the dream outcome they would brag about?",
94
+ why_it_matters: "The payoff half of every hook, and the reason to keep watching."
95
+ },
96
+ {
97
+ question: "Do they already know this kind of product exists, or have they never heard of it?",
98
+ why_it_matters: "This is the awareness stage in plain language, and it decides which ad to make first."
99
+ }
100
+ ];
101
+ /** The short interview rendered as the brief's prompt block. Fixed text — there
102
+ * is no cloud counterpart, so nothing here can drift from a cloud prompt. */
103
+ export function buildShortInterviewPrompt() {
104
+ return [
105
+ "SHORT-FORM COLD START — a fixed six-question interview. Ask these exact questions. Do not generate your own set.",
106
+ "",
107
+ ...SHORT_INTERVIEW.map((q, i) => `${i + 1}. ${q.question}\n (why it matters: ${q.why_it_matters})`),
108
+ "",
109
+ "Say up front that every question is optional: the director can skip any of them, or stop the interview at any point, and you will work with what they gave.",
110
+ "Assemble the answers into OFFER.md in the director's own words, then save the same answers to CONTEXT.md for future sessions. Mark anything skipped as UNKNOWN."
111
+ ].join("\n");
112
+ }
113
+ /** The skill-pack reference each step should be graded against locally, in place
114
+ * of the big SELLING_*.md document the cloud prompt inlines. */
115
+ const STEP_REFERENCE = {
116
+ awareness: "references/onboarding.md",
117
+ angles: "references/onboarding.md",
118
+ hooks: "references/hooks-and-virality.md"
119
+ };
120
+ // Count bounds MIRROR the cloud primitive payload schemas on purpose. The local
121
+ // path has no schema to satisfy, but a director who tunes --count here and then
122
+ // moves the same chain to the cloud should not hit a 400 they never saw locally.
123
+ // `awareness` returns one markdown recommendation, so it has no count at all.
124
+ const COUNTS = {
125
+ coldstart: { default: 12, min: 7, max: 30 },
126
+ awareness: null,
127
+ angles: { default: 12, min: 1, max: 50 },
128
+ hooks: { default: 12, min: 1, max: 50 },
129
+ placement: { default: 8, min: 3, max: 30 }
130
+ };
131
+ export function consultCountRange(step) {
132
+ return COUNTS[step];
133
+ }
134
+ export function defaultConsultCount(step) {
135
+ return COUNTS[step]?.default ?? 0;
136
+ }
137
+ export function offerPathCandidates(dir, explicit) {
138
+ const fromCwd = path.resolve(explicit);
139
+ const fromDir = path.resolve(dir, explicit);
140
+ return fromCwd === fromDir ? [fromCwd] : [fromCwd, fromDir];
141
+ }
142
+ export function resolveOfferPath(dir, explicit) {
143
+ return offerPathCandidates(dir, explicit).find((candidate) => existsSync(candidate)) ?? null;
144
+ }
145
+ /**
146
+ * Does this --offer value read as a PATH the caller expected to exist, rather
147
+ * than the offer text itself? Load-bearing: without it, a mistyped path is
148
+ * silently consulted on as if the filename were the product description, and
149
+ * the director gets twelve hooks about "./OFFER_ACME.md".
150
+ */
151
+ export function looksLikeOfferPath(value) {
152
+ const trimmed = value.trim();
153
+ return !trimmed.includes("\n") && (/^[.~/]/.test(trimmed) || /\.(md|txt)$/i.test(trimmed));
154
+ }
155
+ /**
156
+ * Find the director's offer document. Prefers an explicit path, then OFFER.md,
157
+ * then a single OFFER_<NAME>.md — a director running several offers must name
158
+ * which one, because silently picking the first alphabetically would generate a
159
+ * whole angle set for the wrong product.
160
+ */
161
+ export function findOfferFile(dir, explicit) {
162
+ if (explicit) {
163
+ // Try cwd AND --dir: `--dir ./work --offer ./OFFER_ACME.md` names a file in
164
+ // the work directory, not next to the shell.
165
+ const resolved = resolveOfferPath(dir, explicit);
166
+ if (!resolved) {
167
+ return { error: `Offer file not found: ${offerPathCandidates(dir, explicit).join(" or ")}` };
168
+ }
169
+ return { path: resolved, text: readFileSync(resolved, "utf8") };
170
+ }
171
+ const root = path.resolve(dir);
172
+ const primary = path.join(root, "OFFER.md");
173
+ if (existsSync(primary)) {
174
+ return { path: primary, text: readFileSync(primary, "utf8") };
175
+ }
176
+ let named = [];
177
+ try {
178
+ named = readdirSync(root).filter((f) => /^OFFER_.+\.md$/i.test(f)).sort();
179
+ }
180
+ catch {
181
+ named = [];
182
+ }
183
+ if (named.length === 1) {
184
+ const only = path.join(root, named[0]);
185
+ return { path: only, text: readFileSync(only, "utf8") };
186
+ }
187
+ if (named.length > 1) {
188
+ return {
189
+ error: `Found ${named.length} offer files (${named.join(", ")}) — name the one you mean with --offer ./${named[0]}.`
190
+ };
191
+ }
192
+ return {
193
+ error: `No OFFER.md in ${root}. Run \`vidfarm consult coldstart\` first — it mints the interview that produces it — or pass --offer "<a paragraph about the offer>".`
194
+ };
195
+ }
196
+ function nextCommand(step) {
197
+ const i = STEP_ORDER.indexOf(step);
198
+ if (i === -1 || i === STEP_ORDER.length - 1) {
199
+ return null;
200
+ }
201
+ return `vidfarm consult ${STEP_ORDER[i + 1]}`;
202
+ }
203
+ /** The path the durable answer file takes, and whether it is already written. */
204
+ export function consultContextState(dir) {
205
+ const relative = path.join(dir === "." ? "" : dir, CONSULT_CONTEXT_FILE) || CONSULT_CONTEXT_FILE;
206
+ return { file: relative, exists: existsSync(path.resolve(dir, CONSULT_CONTEXT_FILE)) };
207
+ }
208
+ /** Build the keyless local brief for one step of the chain. */
209
+ export function buildConsultBrief(input) {
210
+ const dir = input.dir ?? ".";
211
+ const context = consultContextState(dir);
212
+ const short = Boolean(input.short) && input.step === "coldstart";
213
+ const artifact = path.join(dir === "." ? "" : dir, CONSULT_ARTIFACTS[input.step]) || CONSULT_ARTIFACTS[input.step];
214
+ const refs = input.references ?? {};
215
+ const count = input.count ?? defaultConsultCount(input.step);
216
+ const offer = (input.offer ?? "").trim();
217
+ const referenceFile = STEP_REFERENCE[input.step];
218
+ const inlined = (input.step === "hooks" && Boolean(refs.hooks)) ||
219
+ (input.step === "awareness" && Boolean(refs.awareness)) ||
220
+ (input.step === "angles" && Boolean(refs.hooks || refs.awareness));
221
+ const referenceHint = inlined || !referenceFile
222
+ ? null
223
+ : `The cloud prompt appends Vidfarm's full selling reference here. Read \`vidfarm skill show ${referenceFile}\` before you answer, and grade your own output against it. Pass --refs to inline the full document instead.`;
224
+ // The durable answer file is read BEFORE the step and written AFTER it. Both
225
+ // halves matter: reading stops a second session re-asking questions the
226
+ // director already answered, writing is what makes the next session cheap.
227
+ const contextRead = context.exists
228
+ ? `Read \`${context.file}\` first — it holds the answers this director already gave in earlier sessions. Treat it as fact, and never re-ask a question it already answers.`
229
+ : `No \`${context.file}\` yet. It is the durable answer file for this working folder; you create it in this step.`;
230
+ const contextWrite = `When you finish, write what you learned into \`${context.file}\` — the director's own words, the decisions made, and what was ruled out. Append under a dated heading; never overwrite an earlier session's answers. This file is why the next session does not start from zero.`;
231
+ const common = [
232
+ "YOU answer the prompt below — you are the AI in this loop. Do not call the Vidfarm API, do not look for a provider key, and do not tell the user they need one. This step is free and offline.",
233
+ contextRead,
234
+ ...(referenceHint ? [referenceHint] : []),
235
+ `Save your answer to \`${artifact}\`. Later steps read it back, so write the file even if the user only wanted to look at the output.`,
236
+ contextWrite
237
+ ];
238
+ const shared = { short, contextFile: context.file, contextExists: context.exists };
239
+ if (short) {
240
+ return {
241
+ ...shared,
242
+ step: "coldstart",
243
+ title: `Cold-start interview, SHORT FORM — ${SHORT_INTERVIEW.length} fixed questions`,
244
+ prompt: buildShortInterviewPrompt(),
245
+ steps: [
246
+ contextRead,
247
+ `Ask the human these ${SHORT_INTERVIEW.length} questions in two batches of three. Ask them as written. Do not add questions, do not invent answers, and do not skip ahead to advice.`,
248
+ `Keep their phrasing for the pain and the desire answers word for word: the angles and hooks steps quote them back, and a paraphrase is what makes an ad sound generic.`,
249
+ "TELL THEM UP FRONT that every question is optional: they can skip any one, or say stop and end the interview on the spot. Then work with whatever they gave — do not push, do not re-ask, and do not make finishing the set a condition of getting a video.",
250
+ `Write \`${artifact}\` from their answers, then write the same answers into \`${context.file}\` under a dated heading.`,
251
+ "Mark any question they skip as UNKNOWN in both files, and carry on. An invented customer psyche poisons every later step, so UNKNOWN is the honest answer — and a later step that needs it can ask then, when the director can see why it matters.",
252
+ "This is the short form. Tell them `vidfarm consult coldstart` runs the full interview when they want the deeper session."
253
+ ],
254
+ artifact,
255
+ next: nextCommand("coldstart"),
256
+ referenceHint: null
257
+ };
258
+ }
259
+ if (input.step === "coldstart") {
260
+ return {
261
+ ...shared,
262
+ step: "coldstart",
263
+ title: "Cold-start interview — the questions that produce OFFER.md",
264
+ prompt: buildColdstartPrompt(input.userMessage?.trim() || "The director asked for a consultation and has given no offer context yet.", count),
265
+ steps: [
266
+ ...common,
267
+ `This one is an INTERVIEW, not a report. Answer the prompt to get the question set, then ASK THE HUMAN those questions conversationally, a few at a time — never dump all ${count} at once, and never invent their answers.`,
268
+ `Assemble their real words into \`${artifact}\`: keep their phrasing for pain and desire, because the later angle and hook steps quote it back.`,
269
+ "Stop and wait for the human between batches. If they go quiet on a question, mark it UNKNOWN in the file rather than guessing — an invented customer psyche poisons every later step.",
270
+ "TELL THEM UP FRONT that every question is optional: they can skip any one, or say stop and end the interview at any point. Then work with what they gave. An interview that feels like a form is one the director abandons, and half the answers beats none.",
271
+ `If the director sounds impatient, or only wants to make one video today, offer the short form instead: \`vidfarm consult coldstart --short\` is ${SHORT_INTERVIEW.length} fixed questions and still feeds every later step.`
272
+ ],
273
+ artifact,
274
+ next: nextCommand("coldstart"),
275
+ referenceHint
276
+ };
277
+ }
278
+ if (input.step === "awareness") {
279
+ return {
280
+ ...shared,
281
+ step: "awareness",
282
+ title: "Awareness stages — which kind of ad to make first",
283
+ prompt: buildAwarenessStagesPrompt(offer, refs),
284
+ steps: [
285
+ ...common,
286
+ "Return markdown, not JSON. If the offer genuinely does not settle on one stage, say so and recommend testing ads for every stage rather than picking one to look decisive.",
287
+ "Carry the chosen stage forward: the angles step takes it as --problem-awareness / --solution-awareness."
288
+ ],
289
+ artifact,
290
+ next: nextCommand("awareness"),
291
+ referenceHint
292
+ };
293
+ }
294
+ if (input.step === "angles") {
295
+ return {
296
+ ...shared,
297
+ step: "angles",
298
+ title: "Persuasive angles — the strategic bets to test",
299
+ prompt: buildAnglesPrompt({
300
+ offer_description: offer,
301
+ problem_awareness: input.problemAwareness ?? "problem_aware",
302
+ solution_awareness: input.solutionAwareness ?? "solution_unaware",
303
+ count
304
+ }, refs),
305
+ steps: [
306
+ ...common,
307
+ "The prompt asks for strict JSON. Write the JSON answer into the markdown file under a fenced block, then a short human-readable list under it — the director reads the list, the next step reads the JSON.",
308
+ "Do not rank the set with the same reasoning that wrote it. If the director wants a ranking, grade against the reference and say what each angle risks."
309
+ ],
310
+ artifact,
311
+ next: nextCommand("angles"),
312
+ referenceHint
313
+ };
314
+ }
315
+ if (input.step === "hooks") {
316
+ return {
317
+ ...shared,
318
+ step: "hooks",
319
+ title: "Hooks — the openings to test",
320
+ prompt: buildHooksPrompt(offer, count, refs),
321
+ steps: [
322
+ ...common,
323
+ "The prompt asks for strict JSON. Write the JSON into the file under a fenced block, with a plain list under it.",
324
+ "Then GRADE the batch against the four charges before you show it: a hook is a complete clause naming a SITUATION, not a label; it must be unguessable; and it must be sayable in the first second. Cut or rewrite the ones that fail — do not hand over the raw generated list.",
325
+ "Never rank a generated batch with the reasoning that wrote it. The rubric catches defects; it does not pick winners."
326
+ ],
327
+ artifact,
328
+ next: nextCommand("hooks"),
329
+ referenceHint
330
+ };
331
+ }
332
+ const videoRef = input.videoRef?.trim();
333
+ return {
334
+ ...shared,
335
+ step: "placement",
336
+ title: "Product placement — native moments inside an existing video",
337
+ prompt: buildProductPlacementPrompt(offer, count),
338
+ steps: [
339
+ ...common,
340
+ videoRef
341
+ ? `The prompt says "the attached video" — that is \`${videoRef}\`. Watch it yourself before answering: read real frames (\`vidfarm stills ${videoRef} --sheet\` for a contact sheet) and the transcript. This step is worthless if you answer from the filename.`
342
+ : "The prompt says \"the attached video\" — you were given none. Ask the director for the video path or URL, then re-run with --video <path|url>. Do not answer from imagination.",
343
+ "Every opportunity must cite a moment you actually saw, with a timestamp. Drop any you cannot point at."
344
+ ],
345
+ artifact,
346
+ next: null,
347
+ referenceHint
348
+ };
349
+ }
350
+ /** Render a brief for a terminal. The prompt is delimited so an agent can lift
351
+ * it exactly, and so a human can copy it into a chat window instead. */
352
+ export function formatConsultBrief(brief, style = { bold: "", dim: "", reset: "" }) {
353
+ const { bold, dim, reset } = style;
354
+ const lines = [];
355
+ lines.push(`${bold}${brief.title}${reset}`);
356
+ lines.push(`${dim}Keyless + local: no provider key, no wallet, no network. You answer this.${reset}`);
357
+ lines.push("");
358
+ brief.steps.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
359
+ lines.push("");
360
+ lines.push(brief.short
361
+ ? `${dim}─── the short-form question set (fixed and local — ask these as written) ───${reset}`
362
+ : `${dim}─── the prompt (identical to the cloud brainstorm primitive) ───${reset}`);
363
+ lines.push(brief.prompt);
364
+ lines.push(`${dim}───────────────────────────────────────────────────────────────${reset}`);
365
+ lines.push("");
366
+ lines.push(`Save to: ${brief.artifact}`);
367
+ lines.push(`Context: ${brief.contextFile}${brief.contextExists ? " (exists — read it first)" : " (create it)"}`);
368
+ if (brief.next) {
369
+ lines.push(`Next: ${brief.next}`);
370
+ }
371
+ return lines.join("\n");
372
+ }
373
+ /** The whole chain plus which artifacts already exist, for a bare
374
+ * `vidfarm consult`. A director who says "give me a consultation" gets the map
375
+ * and the first command, not a wall of prompt. */
376
+ export function planConsultation(dir) {
377
+ const root = path.resolve(dir);
378
+ return STEP_ORDER.map((step) => {
379
+ const file = CONSULT_ARTIFACTS[step];
380
+ const offerDone = step === "coldstart" && !("error" in findOfferFile(root));
381
+ return {
382
+ step,
383
+ artifact: file,
384
+ done: step === "coldstart" ? offerDone : existsSync(path.join(root, file)),
385
+ command: `vidfarm consult ${step}`
386
+ };
387
+ });
388
+ }
389
+ //# sourceMappingURL=consult.js.map
@@ -131,9 +131,14 @@ export const COST_MODE_BLURB = {
131
131
  "bgm|sfx|image|vector|icon|video. Check the KEYLESS sources first: Openverse (CC/CC0 " +
132
132
  "music, SFX, images) and iconify (icons) need no account at all. A free Pixabay key adds " +
133
133
  "photos/vectors/stock-video and may already be saved (check vidfarm provider-keys). " +
134
+ 'For any ICON, STICKER, illustration, 3D prop or Lottie, use vidfarm iconscout "<meaning>" ' +
135
+ "--free before even considering AI image generation: free IconScout assets cost $0 (a credit " +
136
+ "line is the only price) and arrive as clean transparent vectors. " +
134
137
  "Billed cloud/AI generation is refused unless you re-confirm it (--yes).",
135
138
  hybrid: "Hybrid (recommended) — roughly $0.01–$1 per video. Free where it's free, spend AI " +
136
139
  "credits only where they clearly win (a hero shot, a voice you can't fake locally). " +
140
+ 'Never GENERATE an icon, sticker, illustration or 3D prop — vidfarm iconscout "<meaning>" ' +
141
+ "buys a designer's finished asset for a fraction of one AI attempt, with no prompt loop. " +
137
142
  "Billed ops run but each prints its cost so nothing is a surprise. Charges land on " +
138
143
  "your own AI provider keys (BYOK).",
139
144
  "rich-ai": "Rich AI — $1+ per video. AI video generation is spent on REUSABLE greenscreen raws " +
@@ -168,6 +173,9 @@ export function costModeExplainer() {
168
173
  " `vidfarm add-provider-key <provider> <key>` or at vidfarm.cc/settings/developer.",
169
174
  "Tip: before paying to generate music/SFX/images/video, try the free stock catalog —",
170
175
  ' vidfarm media search "<meaning>" --type bgm|sfx|image|vector|icon|video.',
176
+ " For icons, STICKERS, illustrations, 3D props and Lottie, IconScout beats AI generation on price",
177
+ ' and on quality in EVERY mode — vidfarm iconscout "<meaning>" --style sticker (search is free;',
178
+ " free assets cost $0 with a credit line, premium downloads are a few cents on the wallet).",
171
179
  " Check the keyless sources first — Openverse (CC/CC0 music, SFX, images) and iconify (icons)",
172
180
  " need no account. A free Pixabay key adds photos/vectors/stock-video (it may already be saved —",
173
181
  " check `vidfarm provider-keys` or vidfarm.cc/settings/developer; add via `vidfarm add-provider-key pixabay <key>`)."
@@ -26,17 +26,22 @@ export const EXPERIMENT_MODES = ["creative", "structured"];
26
26
  export const CORE_METRICS = ["views", "comments", "clicks", "buys"];
27
27
  export const DEFAULT_METRIC = "comments";
28
28
  /** The starting format menu, offered during planning — NOT discovered at build
29
- * time. Deliberately copywriting-led: in every one of these the words carry the
29
+ * time. **Read the script's grammatical person to pick one**: copy written in the
30
+ * first person ("I tried to…") wants the speaker's own world (POV b-roll, talking
31
+ * head, process); copy written in the third person ("when you try to…") wants a
32
+ * meme recaption — a greenscreen reaction raw over a background image. Either way
33
+ * the finish is the same: TikTok-native type, kinetic captions, free Kokoro VO. Deliberately copywriting-led: in every one of these the words carry the
30
34
  * persuasion and the footage only has to hold attention, which is what makes
31
35
  * them fast, cheap and repeatable. All seven are sourceable for ~$0 from
32
36
  * `vidfarm public-raws --categories`. */
33
37
  export const EASY_FORMATS = [
34
- { key: "b-roll", label: "kinetic captions over b-roll footage", note: "the workhorse start here" },
35
- { key: "talking-head", label: "talking head", note: "only if the user will film themselves; strongest trust signal" },
36
- { key: "process", label: "process footage", note: "high watch-through, needs no narration" },
38
+ { key: "b-roll", label: "kinetic captions over b-roll footage", note: "1st person — the workhorse, start here" },
39
+ { key: "talking-head", label: "talking head", note: "1st person — only if they'll film themselves; strongest trust signal" },
40
+ { key: "process", label: "process footage", note: "1st person — high watch-through, needs no narration" },
41
+ { key: "meme-recaption", label: "meme recaption", note: "3rd person (\"when you try to…\") — greenscreen raw over a background image, blur optional" },
37
42
  { key: "loop-background", label: "loop background footage", note: "cheapest of all; the copy is the entire video" },
38
43
  { key: "satisfying", label: "satisfying footage", note: "strong retention, weak topical fit" },
39
- { key: "lifestyle", label: "lifestyle footage", note: "best for identity and status angles" },
44
+ { key: "lifestyle", label: "lifestyle footage", note: "identity and status angles" },
40
45
  { key: "pov-quote", label: "POV quote aesthetic", note: "pure copywriting; a natural fit for hook tests" }
41
46
  ];
42
47
  /** What to take when the director has no opinion — never stall on this choice. */
@@ -24,7 +24,81 @@
24
24
  //
25
25
  // Pure DOM (linkedom) — no ffmpeg, no network, no Chrome. Local-only: this is a
26
26
  // devcli capability with no cloud/REST equivalent.
27
+ import { createHash } from "node:crypto";
28
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
29
+ import path from "node:path";
27
30
  import { parseHTML } from "linkedom";
31
+ // ── Revision governor ────────────────────────────────────────────────────────
32
+ //
33
+ // `vidfarm qa` is feedback, not a gate — which is exactly what makes it a loop
34
+ // hazard for an autonomous agent: qa lists findings → agent edits → qa lists
35
+ // findings → forever. Some findings are deliberate style choices that will never
36
+ // "clear", so the loop has no natural base case.
37
+ //
38
+ // This is that base case. The governor counts how many times the composition has
39
+ // CHANGED between qa passes (re-running qa on an untouched file is free), and at
40
+ // the limit qa stops handing out new work: it withholds the finding list and says
41
+ // stop. ONE revision is the default: the first pass names the real slop, the fix
42
+ // clears it, and a second round is nearly always an agent polishing its own taste
43
+ // rather than removing a defect. The human raises the ceiling with
44
+ // `--max-revisions <n>` when a video genuinely earns another round.
45
+ export const QA_STATE_FILENAME = ".vidfarm-qa-state.json";
46
+ /** Revisions of one composition before qa stops feeding the loop. */
47
+ export const DEFAULT_MAX_REVISIONS = 1;
48
+ function hashComposition(html) {
49
+ return createHash("sha256").update(html).digest("hex").slice(0, 16);
50
+ }
51
+ /**
52
+ * Record this qa pass and report where the composition stands against the
53
+ * revision limit. `max <= 0` disables the governor entirely.
54
+ *
55
+ * Deliberately forgiving: an unreadable or corrupt state file is treated as a
56
+ * fresh start, never an error — a QA helper must not be the thing that breaks a
57
+ * build.
58
+ */
59
+ export function recordQaPass(htmlPath, html, opts) {
60
+ const max = opts.max ?? DEFAULT_MAX_REVISIONS;
61
+ const workDir = path.dirname(htmlPath);
62
+ const statePath = path.join(workDir, QA_STATE_FILENAME);
63
+ const composition = path.basename(htmlPath);
64
+ const hash = hashComposition(html);
65
+ const disabled = !Number.isFinite(max) || max <= 0;
66
+ let previous = null;
67
+ if (!opts.reset && existsSync(statePath)) {
68
+ try {
69
+ const parsed = JSON.parse(readFileSync(statePath, "utf8"));
70
+ // A different composition in the same folder starts its own count.
71
+ if (parsed && parsed.composition === composition)
72
+ previous = parsed;
73
+ }
74
+ catch {
75
+ previous = null;
76
+ }
77
+ }
78
+ const changed = previous ? previous.hash !== hash : false;
79
+ const revisions = previous ? previous.revisions + (changed ? 1 : 0) : 0;
80
+ const next = {
81
+ composition,
82
+ hash,
83
+ revisions,
84
+ first_seen: previous?.first_seen ?? opts.now,
85
+ last_seen: opts.now
86
+ };
87
+ try {
88
+ writeFileSync(statePath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
89
+ }
90
+ catch {
91
+ // A read-only work dir just means no governor. Don't fail the check.
92
+ }
93
+ return {
94
+ revisions,
95
+ max,
96
+ limit_reached: !disabled && revisions >= max,
97
+ changed_since_last_run: changed,
98
+ state_path: statePath,
99
+ disabled
100
+ };
101
+ }
28
102
  /**
29
103
  * Build the "now go watch it" directive. `dir` is substituted into the commands
30
104
  * so the agent can paste them; the JSON default is the generic placeholder.
@@ -106,6 +106,109 @@ export function readPackDoc(ref, name = DEFAULT_PACK) {
106
106
  const doc = resolvePackDoc(ref, name);
107
107
  return { doc, contents: readFileSync(doc.abs, "utf8") };
108
108
  }
109
+ export const PACK_TOPICS = [
110
+ { topic: "content-ideas", aliases: ["ideas", "idea", "angles", "what-to-post", "content"], doc: "references/content-ideas.md",
111
+ blurb: "The 50-frame angle bank — answer \"what should I post?\" for a whole month" },
112
+ { topic: "meme-recaption", aliases: ["meme", "recaption", "meme-caption"], doc: "references/editor-workflows.md", heading: "Writing a meme recaption",
113
+ blurb: "Recaption a meme at a pain or a win the niche knows — the cold-viewer test" },
114
+ { topic: "product-explainer", aliases: ["product-explainers"], doc: "harnesses/product-explainer.HARNESS.md",
115
+ blurb: "The product-explainer harness — the bundled base for explaining what a product does" },
116
+ { topic: "product-demo", aliases: ["demo"], doc: "harnesses/product-demo.HARNESS.md",
117
+ blurb: "The product-demo harness — showing the product in use" },
118
+ { topic: "explainer-cutouts", aliases: ["cutout", "cutouts", "stickers", "sticker", "explainer"], doc: "recipes/cutout-graphics-for-explainers.md",
119
+ blurb: "Explainers built from transparent cutout stickers — house style, sheets, keying, dark stages" },
120
+ { topic: "hooks", aliases: ["hook", "virality", "viral", "charges"], doc: "references/hooks-and-virality.md",
121
+ blurb: "The four charges — hook, loop, payoff, bait — written BEFORE the timeline" },
122
+ { topic: "density", aliases: ["cut", "cutting", "too-long", "pacing"], doc: "references/hooks-and-virality.md", heading: "Density",
123
+ blurb: "Every second must earn its place — the deletion test and cut-on-sight list" },
124
+ { topic: "captions", aliases: ["caption", "subtitles", "safe-zone"], doc: "references/editor-workflows.md", heading: "TikTok-native caption standard",
125
+ blurb: "The caption standard — safe zone, negative space, font regime, the four valid backgrounds" },
126
+ { topic: "first-frame", aliases: ["thumbnail", "poster"], doc: "references/editor-workflows.md", heading: "The first frame is the thumbnail",
127
+ blurb: "t=0 is the thumbnail everywhere — compose it as a designed still" },
128
+ { topic: "no-slop", aliases: ["slop", "visual-standard", "html-slop"], doc: "references/editor-workflows.md", heading: "Social-native visual standard",
129
+ blurb: "No HTML slop — what a social-native frame may and may not contain" },
130
+ { topic: "blurred-plate", aliases: ["letterbox", "landscape-vertical", "bars"], doc: "references/editor-workflows.md", heading: "The blurred plate",
131
+ blurb: "Landscape footage fullscreen on a vertical canvas, without black bars" },
132
+ { topic: "ken-burns", aliases: ["pan-zoom", "still-motion"], doc: "references/editor-workflows.md", heading: "Ken Burns",
133
+ blurb: "Animate a still image instead of paying for AI video" },
134
+ { topic: "paintbrushes", aliases: ["three-paintbrushes", "replication"], doc: "references/editor-workflows.md", heading: "The three paintbrushes",
135
+ blurb: "Clip reuse vs. image gen vs. AI video — and the two replication harnesses" },
136
+ { topic: "avatar", aliases: ["talking-head", "spokesperson", "presenter"], doc: "references/primitives.md", heading: "Primitive: talking_avatar",
137
+ blurb: "\"Create an avatar\" = a talking head WITH audio, keyed off greenscreen" },
138
+ { topic: "dedupe", aliases: ["deduplicate", "repost", "variants"], doc: "references/primitives.md", heading: "Primitive: media_dedupe",
139
+ blurb: "Deduplicate an exported MP4 before posting it again or to another platform" },
140
+ { topic: "review", aliases: ["reviewing", "judge", "holistic", "qa-review"], doc: "references/reviewing-renders.md",
141
+ blurb: "Judge the WHOLE video before you report it done — never by one frame" },
142
+ { topic: "harness", aliases: ["harnesses", "regime"], doc: "harnesses/README.md",
143
+ blurb: "What a HARNESS.md is, its format, and the three phrasings that mean one" },
144
+ { topic: "bulk", aliases: ["batch", "volume", "scripting", "daily-posting"], doc: "recipes/bulk-scripting-with-a-harness.md",
145
+ blurb: "Produce N different videos in one run — scripting mode with a harness" },
146
+ { topic: "onboarding", aliases: ["consultation", "coldstart", "offer", "strategy"], doc: "references/onboarding.md",
147
+ blurb: "The cold-start interview and the brainstorm/* consultation chain" }
148
+ ];
149
+ export function listPackTopics() {
150
+ return [...PACK_TOPICS].sort((a, b) => (a.topic < b.topic ? -1 : 1));
151
+ }
152
+ /** Match a spoken topic name. Returns null (not an error) so callers can fall back to file lookup. */
153
+ export function resolvePackTopic(ref) {
154
+ const needle = ref.trim().toLowerCase().replace(/[\s_]+/g, "-").replace(/\.md$/, "");
155
+ return PACK_TOPICS.find((entry) => entry.topic === needle || entry.aliases.includes(needle)) ?? null;
156
+ }
157
+ /**
158
+ * Slice one Markdown section out of a doc: from the heading that contains
159
+ * `heading` down to the next heading of the same or higher level. Returns null
160
+ * when the heading is gone (the pack was edited) so the caller can fall back to
161
+ * the whole file rather than printing nothing.
162
+ */
163
+ export function extractSection(contents, heading) {
164
+ const needle = heading.trim().toLowerCase();
165
+ const lines = contents.split("\n");
166
+ const level = (line) => (line.match(/^(#{1,6})\s/)?.[1].length ?? 0);
167
+ const start = lines.findIndex((line) => level(line) > 0 && line.toLowerCase().includes(needle));
168
+ if (start < 0)
169
+ return null;
170
+ const depth = level(lines[start]);
171
+ let end = lines.length;
172
+ for (let i = start + 1; i < lines.length; i += 1) {
173
+ const at = level(lines[i]);
174
+ if (at > 0 && at <= depth) {
175
+ end = i;
176
+ break;
177
+ }
178
+ }
179
+ return { heading: lines[start].replace(/^#+\s*/, "").trim(), body: lines.slice(start, end).join("\n").trimEnd() };
180
+ }
181
+ /** A topic's text, already sliced to its section when it names one. */
182
+ export function readPackTopic(topic, name = DEFAULT_PACK) {
183
+ const { doc, contents } = readPackDoc(topic.doc, name);
184
+ if (!topic.heading)
185
+ return { doc, contents, whole: true };
186
+ const section = extractSection(contents, topic.heading);
187
+ if (!section)
188
+ return { doc, contents, whole: true };
189
+ return { doc, heading: section.heading, contents: section.body, whole: false };
190
+ }
191
+ export function loadIdeaBank(name = DEFAULT_PACK) {
192
+ const { contents } = readPackDoc("references/content-ideas.md", name);
193
+ const section = extractSection(contents, "The 50 frames");
194
+ const lines = (section?.body ?? contents).split("\n");
195
+ const frames = [];
196
+ const families = [];
197
+ let family = "";
198
+ for (const line of lines) {
199
+ const label = line.match(/^\*\*(.+?)\*\*\s*$/);
200
+ if (label) {
201
+ family = label[1].split(/\s+[—-]\s+/)[0].trim();
202
+ if (family && !families.includes(family))
203
+ families.push(family);
204
+ continue;
205
+ }
206
+ const bullet = line.match(/^-\s+(.+?)\s*$/);
207
+ if (bullet && family)
208
+ frames.push({ frame: bullet[1].trim(), family });
209
+ }
210
+ return { frames, families };
211
+ }
109
212
  /**
110
213
  * Grep the pack. This is the affordance that makes a local copy genuinely
111
214
  * better than the network one: "where does it say anything about greenscreen"