@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
@@ -0,0 +1,396 @@
1
+ // QA_REGIME.md — a per-style quality contract for a template family.
2
+ //
3
+ // WHY IT EXISTS: `vidfarm qa`'s built-in rules are UNIVERSAL (no HTML slop, the
4
+ // font regime, the thumbnail frame) — they are the same for every video anyone
5
+ // makes, so they can live in code. A regime is the opposite: it is what makes
6
+ // THIS director's THIS format good, and it changes per account, per offer, per
7
+ // campaign. That can't be hard-coded, so it lives next to the work as Markdown
8
+ // the director owns, edits, and versions.
9
+ //
10
+ // It matters most in SCRIPTING MODE. One video gets human eyes on every frame;
11
+ // fifty variants generated in a loop do not. The regime is what a bulk run is
12
+ // graded against — the thing that keeps variant #37 as good as variant #1.
13
+ //
14
+ // THE FORMAT is a plain Markdown doc with two machine-readable affordances:
15
+ //
16
+ // 1. Optional YAML-ish front matter with a `checks:` block. These are the
17
+ // assertions the CLI can settle DETERMINISTICALLY from the composition DOM
18
+ // (duration, aspect, hook word budget, banned phrases, …). No AI, no
19
+ // network, instant.
20
+ // 2. Any `- [ ]` checkbox line anywhere in the body becomes a REVIEW ITEM:
21
+ // a question the CLI hands back for the agent (or the human) to answer.
22
+ // "Is the withheld answer one the viewer can't supply themselves?" is a
23
+ // judgment call; pretending a linter can settle it would be a lie.
24
+ //
25
+ // Everything else is prose the agent reads for context. That split is the whole
26
+ // design: the CLI is honest about which half it can enforce, and it never
27
+ // silently "passes" a video on the strength of the half it can't.
28
+ //
29
+ // COMPOSABLE BY CONSTRUCTION: regimes stack (`--regime a --regime b`), resolve
30
+ // from a built-in name OR any path the user points at, and a directory's own
31
+ // QA_REGIME.md is picked up automatically. Nothing here is a gate — same
32
+ // feedback-not-a-gate contract as the rest of `vidfarm qa`.
33
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
34
+ import path from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ const BUILTIN_SUFFIX = ".QA_REGIME.md";
37
+ // ── Locating the built-in regimes ────────────────────────────────────────────
38
+ // They ship inside the skill pack (.agents/skills/vidfarm/regimes/) rather than
39
+ // as TS string constants, so a director can read, diff, and copy them as normal
40
+ // files — the file IS the documentation.
41
+ function builtinDir() {
42
+ let dir = path.dirname(fileURLToPath(import.meta.url));
43
+ for (let i = 0; i < 6; i += 1) {
44
+ const candidate = path.join(dir, ".agents", "skills", "vidfarm", "regimes");
45
+ if (existsSync(candidate))
46
+ return candidate;
47
+ const parent = path.dirname(dir);
48
+ if (parent === dir)
49
+ break;
50
+ dir = parent;
51
+ }
52
+ return null;
53
+ }
54
+ export function listBuiltinRegimes() {
55
+ const dir = builtinDir();
56
+ if (!dir)
57
+ return [];
58
+ return readdirSync(dir)
59
+ .filter((file) => file.endsWith(BUILTIN_SUFFIX))
60
+ .sort()
61
+ .map((file) => {
62
+ const full = path.join(dir, file);
63
+ const parsed = parseRegime(readFileSync(full, "utf8"), full);
64
+ return { name: file.slice(0, -BUILTIN_SUFFIX.length), path: full, video_type: parsed.video_type, summary: parsed.summary };
65
+ });
66
+ }
67
+ /** Resolve `hooks` (built-in) or `./my/QA_REGIME.md` (a path) to a file. */
68
+ export function resolveRegimePath(ref) {
69
+ const direct = path.resolve(ref);
70
+ if (existsSync(direct) && !direct.endsWith(path.sep))
71
+ return direct;
72
+ const dir = builtinDir();
73
+ if (dir) {
74
+ const candidate = path.join(dir, `${ref}${BUILTIN_SUFFIX}`);
75
+ if (existsSync(candidate))
76
+ return candidate;
77
+ }
78
+ const names = listBuiltinRegimes().map((entry) => entry.name);
79
+ throw new Error(`No QA regime "${ref}". Pass a file path, or one of the built-ins: ${names.join(", ") || "(none bundled)"}. ` +
80
+ `Scaffold your own with \`vidfarm regime init <name> --out ./work/QA_REGIME.md\`.`);
81
+ }
82
+ /** A directory's own regime, if it has one. Case-tolerant on the filename. */
83
+ export function discoverRegime(dir) {
84
+ for (const name of ["QA_REGIME.md", "qa_regime.md", "QA-REGIME.md"]) {
85
+ const candidate = path.join(dir, name);
86
+ if (existsSync(candidate))
87
+ return candidate;
88
+ }
89
+ return null;
90
+ }
91
+ /**
92
+ * A deliberately tiny front-matter reader: `key: value`, and one level of
93
+ * nesting under `checks:` with `- item` lists. We do NOT pull in a YAML parser —
94
+ * a regime that needs anchors and multi-document streams has stopped being a
95
+ * checklist a director maintains by hand.
96
+ */
97
+ function parseFrontMatter(raw) {
98
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
99
+ if (!match)
100
+ return { front: {}, body: raw };
101
+ const front = {};
102
+ let currentList = null;
103
+ let listKey = "";
104
+ let inChecks = false;
105
+ for (const line of match[1].split(/\r?\n/)) {
106
+ if (!line.trim() || line.trim().startsWith("#"))
107
+ continue;
108
+ const listItem = line.match(/^\s*-\s+(.*)$/);
109
+ if (listItem && currentList) {
110
+ currentList.push(listItem[1].trim().replace(/^["']|["']$/g, ""));
111
+ continue;
112
+ }
113
+ const pair = line.match(/^(\s*)([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
114
+ if (!pair)
115
+ continue;
116
+ const indented = pair[1].length > 0;
117
+ const key = pair[2];
118
+ const value = pair[3].trim();
119
+ if (currentList && !listItem) {
120
+ front[listKey] = currentList;
121
+ currentList = null;
122
+ }
123
+ if (key === "checks" && !value) {
124
+ inChecks = true;
125
+ continue;
126
+ }
127
+ if (!indented && key !== "checks")
128
+ inChecks = false;
129
+ const scopedKey = inChecks && indented ? `checks.${key}` : key;
130
+ if (!value) {
131
+ currentList = [];
132
+ listKey = scopedKey;
133
+ continue;
134
+ }
135
+ if (value.startsWith("[")) {
136
+ front[scopedKey] = value
137
+ .replace(/^\[|\]$/g, "")
138
+ .split(",")
139
+ .map((entry) => entry.trim().replace(/^["']|["']$/g, ""))
140
+ .filter(Boolean);
141
+ continue;
142
+ }
143
+ front[scopedKey] = value.replace(/^["']|["']$/g, "");
144
+ }
145
+ if (currentList)
146
+ front[listKey] = currentList;
147
+ return { front, body: raw.slice(match[0].length) };
148
+ }
149
+ const KNOWN_CHECKS = new Set([
150
+ "duration_sec", "aspect", "first_frame_visual", "first_frame_text", "hook_words_max",
151
+ "text_by_sec", "audio", "captions", "font_regime", "safe_zone", "forbid_text",
152
+ "require_text", "max_text_cards", "max_simultaneous_text", "scenes", "max_scene_sec"
153
+ ]);
154
+ export function parseRegime(raw, source) {
155
+ const { front, body } = parseFrontMatter(raw);
156
+ const checks = {};
157
+ const unknown = [];
158
+ for (const [key, value] of Object.entries(front)) {
159
+ if (!key.startsWith("checks."))
160
+ continue;
161
+ const bare = key.slice("checks.".length);
162
+ if (KNOWN_CHECKS.has(bare))
163
+ checks[bare] = value;
164
+ else
165
+ unknown.push(bare);
166
+ }
167
+ const reviewItems = [];
168
+ let section = "Checklist";
169
+ for (const line of body.split(/\r?\n/)) {
170
+ const heading = line.match(/^#{1,6}\s+(.*)$/);
171
+ if (heading) {
172
+ section = heading[1].replace(/[*_`]/g, "").trim();
173
+ continue;
174
+ }
175
+ const bold = line.match(/^\*\*(.+?)\*\*\s*$/);
176
+ if (bold) {
177
+ section = bold[1].trim();
178
+ continue;
179
+ }
180
+ const box = line.match(/^\s*[-*]\s+\[( |x|X)\]\s+(.*)$/);
181
+ if (box)
182
+ reviewItems.push({ section, text: box[2].trim() });
183
+ }
184
+ const headline = body.split(/\r?\n/).find((line) => line.trim() && !line.startsWith("#") && !line.startsWith(">"));
185
+ const name = String(front.name ?? path.basename(source).replace(/\.md$/i, "").replace(/\.QA_REGIME$/i, ""));
186
+ return {
187
+ name,
188
+ source,
189
+ video_type: front.video_type ? String(front.video_type) : null,
190
+ summary: headline ? headline.replace(/[*_`]/g, "").trim().slice(0, 160) : null,
191
+ checks,
192
+ review_items: reviewItems,
193
+ unknown_check_keys: unknown
194
+ };
195
+ }
196
+ // ── Evaluating the deterministic half ────────────────────────────────────────
197
+ function asList(value) {
198
+ if (!value)
199
+ return [];
200
+ return Array.isArray(value) ? value : [value];
201
+ }
202
+ /** `8-34` | `<=34` | `>=8` | `30` (exact-ish, ±0.5s). */
203
+ function rangeSatisfied(spec, actual) {
204
+ const trimmed = spec.trim();
205
+ const between = trimmed.match(/^(-?[\d.]+)\s*-\s*(-?[\d.]+)$/);
206
+ if (between)
207
+ return actual >= Number(between[1]) - 0.001 && actual <= Number(between[2]) + 0.001;
208
+ const cmp = trimmed.match(/^(<=|>=|<|>)\s*(-?[\d.]+)$/);
209
+ if (cmp) {
210
+ const bound = Number(cmp[2]);
211
+ if (cmp[1] === "<=")
212
+ return actual <= bound + 0.001;
213
+ if (cmp[1] === ">=")
214
+ return actual >= bound - 0.001;
215
+ if (cmp[1] === "<")
216
+ return actual < bound;
217
+ return actual > bound;
218
+ }
219
+ const exact = Number(trimmed);
220
+ return Number.isFinite(exact) ? Math.abs(actual - exact) <= 0.5 : true;
221
+ }
222
+ function isRequired(value) {
223
+ return String(value ?? "").trim().toLowerCase() === "required";
224
+ }
225
+ function isForbidden(value) {
226
+ return String(value ?? "").trim().toLowerCase() === "forbidden";
227
+ }
228
+ export function evaluateRegime(parsed, facts) {
229
+ const checks = [];
230
+ const findings = [];
231
+ const where = `QA_REGIME "${parsed.name}"`;
232
+ const settle = (key, expected, actual, ok, fix) => {
233
+ checks.push({ key, expected, actual, ok });
234
+ if (!ok) {
235
+ findings.push({
236
+ rule: `regime:${key}`,
237
+ severity: "error",
238
+ message: `${parsed.name}: ${key} expects ${expected} — this composition is ${actual}.`,
239
+ where,
240
+ fix
241
+ });
242
+ }
243
+ };
244
+ const c = parsed.checks;
245
+ if (c.duration_sec !== undefined && facts.duration_sec !== null) {
246
+ const spec = String(c.duration_sec);
247
+ settle("duration_sec", spec, `${facts.duration_sec.toFixed(1)}s`, rangeSatisfied(spec, facts.duration_sec), "Retime the timeline (`vidfarm retime` / `vidfarm ripple`) or set the composition duration to land inside the regime's window.");
248
+ }
249
+ if (c.aspect !== undefined && facts.canvas.aspect) {
250
+ const allowed = String(c.aspect).split(/[|,]/).map((entry) => entry.trim());
251
+ settle("aspect", allowed.join(" or "), facts.canvas.aspect, allowed.includes(facts.canvas.aspect), "Resize the canvas (editor `set_composition canvas_width/canvas_height`) — a re-crop, not a re-render of the source.");
252
+ }
253
+ if (isRequired(c.first_frame_visual)) {
254
+ settle("first_frame_visual", "something on screen at t=0", facts.first_frame_visual ? "covered" : "black", facts.first_frame_visual, "Pull the opening clip to start:0 (`vidfarm retime <dir> --layer <key> --start 0`). Frame 0 is the thumbnail.");
255
+ }
256
+ if (c.first_frame_text !== undefined) {
257
+ const has = Boolean(facts.first_frame_text);
258
+ if (isRequired(c.first_frame_text)) {
259
+ settle("first_frame_text", "hook words up at t=0", has ? `"${facts.first_frame_text}"` : "no text at t=0", has, "Start the hook caption at 0 so the thumbnail states the promise before a word is spoken (muted autoplay is the default).");
260
+ }
261
+ else if (isForbidden(c.first_frame_text)) {
262
+ settle("first_frame_text", "no text at t=0", has ? `"${facts.first_frame_text}"` : "clean", !has, "Delay the first text layer past 0 — this regime wants the opening frame to carry the image alone.");
263
+ }
264
+ }
265
+ if (c.hook_words_max !== undefined) {
266
+ const budget = Number(c.hook_words_max);
267
+ const hook = facts.first_frame_text ?? facts.text_runs[0]?.text ?? "";
268
+ const words = hook ? hook.trim().split(/\s+/).length : 0;
269
+ if (Number.isFinite(budget) && hook) {
270
+ settle("hook_words_max", `≤${budget} words in the opening line`, `${words} ("${hook.slice(0, 48)}")`, words <= budget, "Cut the opening line to the shortest complete clause that still parses cold. Chunk 1 is read before any audio.");
271
+ }
272
+ }
273
+ if (c.text_by_sec !== undefined) {
274
+ const by = Number(c.text_by_sec);
275
+ const at = facts.first_text_at_sec;
276
+ if (Number.isFinite(by)) {
277
+ settle("text_by_sec", `text on screen by ${by}s`, at === null ? "no text at all" : `${at.toFixed(2)}s`, at !== null && at <= by + 0.001, "Move the first caption earlier. A muted viewer decides before the audio hook lands.");
278
+ }
279
+ }
280
+ if (c.audio !== undefined) {
281
+ if (isRequired(c.audio)) {
282
+ settle("audio", "at least one audio layer", `${facts.audio_layers}`, facts.audio_layers > 0, "Add narration or a bed (`vidfarm tts` free local voice, or `vidfarm media search --type bgm`).");
283
+ }
284
+ else if (isForbidden(c.audio)) {
285
+ settle("audio", "no audio layers", `${facts.audio_layers}`, facts.audio_layers === 0, "Remove the audio layers — this regime is for silent/overlay output.");
286
+ }
287
+ }
288
+ if (isRequired(c.captions)) {
289
+ settle("captions", "a caption run", `${facts.caption_layers} caption layer(s)`, facts.caption_layers > 0, "Generate captions (`vidfarm captions generate`) — three of the four charges reach a muted viewer through text.");
290
+ }
291
+ if (isRequired(c.font_regime)) {
292
+ settle("font_regime", "imported display fonts only", facts.off_regime_fonts.length ? facts.off_regime_fonts.join(", ") : "in regime", facts.off_regime_fonts.length === 0, "Coerce the off-regime families to Montserrat/TikTok Sans (`vidfarm set-style --font-family Montserrat`).");
293
+ }
294
+ if (isRequired(c.safe_zone) && facts.canvas.height && facts.canvas.width && facts.canvas.height > facts.canvas.width) {
295
+ // Position lives in the DOM style, so this reuses the built-in rule's intent
296
+ // rather than re-deriving it — the built-in check reports the offenders.
297
+ checks.push({ key: "safe_zone", expected: "8%–85% band (portrait)", actual: "see caption-safe-zone findings", ok: true });
298
+ }
299
+ for (const phrase of asList(c.forbid_text)) {
300
+ const hit = phrase && new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i").test(facts.all_text);
301
+ settle("forbid_text", `no "${phrase}" on screen`, hit ? `found "${phrase}"` : "absent", !hit, "Rewrite the line. The regime bans this phrasing for a reason recorded in the doc — read the section it sits under.");
302
+ }
303
+ for (const phrase of asList(c.require_text)) {
304
+ const hit = phrase && new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i").test(facts.all_text);
305
+ settle("require_text", `"${phrase}" appears on screen`, hit ? "present" : "missing", Boolean(hit), "Add the required line as a timed caption — this is usually a compliance or positioning beat the regime treats as mandatory.");
306
+ }
307
+ if (c.max_text_cards !== undefined) {
308
+ const max = Number(c.max_text_cards);
309
+ if (Number.isFinite(max)) {
310
+ settle("max_text_cards", `≤${max} standalone text cards`, `${facts.card_runs.length}`, facts.card_runs.length <= max, "Merge or drop cards. Standalone cards compete with the footage; captions carry the words instead.");
311
+ }
312
+ }
313
+ if (c.max_simultaneous_text !== undefined) {
314
+ const max = Number(c.max_simultaneous_text);
315
+ if (Number.isFinite(max)) {
316
+ settle("max_simultaneous_text", `≤${max} text runs at once`, `${facts.max_simultaneous_text}`, facts.max_simultaneous_text <= max, "Stagger the overlapping text layers. Two things to read at once means neither gets read.");
317
+ }
318
+ }
319
+ if (c.scenes !== undefined) {
320
+ const spec = String(c.scenes);
321
+ settle("scenes", `${spec} visual clips`, `${facts.visual_clips.length}`, rangeSatisfied(spec, facts.visual_clips.length), "Split or merge scenes to hit the regime's pacing shape (`vidfarm split` / `vidfarm retime`).");
322
+ }
323
+ if (c.max_scene_sec !== undefined) {
324
+ const max = Number(c.max_scene_sec);
325
+ const longest = facts.visual_clips.reduce((acc, clip) => Math.max(acc, clip.duration), 0);
326
+ if (Number.isFinite(max) && facts.visual_clips.length) {
327
+ settle("max_scene_sec", `no clip longer than ${max}s`, `longest is ${longest.toFixed(1)}s`, longest <= max + 0.001, "Cut the long clip or push a cutaway over it — a static hold past this length is where retention drops.");
328
+ }
329
+ }
330
+ for (const key of parsed.unknown_check_keys) {
331
+ findings.push({
332
+ rule: "regime:unknown-check",
333
+ severity: "warn",
334
+ message: `${parsed.name}: unknown check "${key}" — ignored (the CLI can't settle it).`,
335
+ where,
336
+ fix: `Known checks: ${[...KNOWN_CHECKS].join(", ")}. Anything else belongs in the prose/checklist half, where the agent judges it.`
337
+ });
338
+ }
339
+ return {
340
+ name: parsed.name,
341
+ source: parsed.source,
342
+ video_type: parsed.video_type,
343
+ checks,
344
+ review_items: parsed.review_items,
345
+ findings
346
+ };
347
+ }
348
+ export function loadAndEvaluateRegime(ref, facts) {
349
+ const file = resolveRegimePath(ref);
350
+ return evaluateRegime(parseRegime(readFileSync(file, "utf8"), file), facts);
351
+ }
352
+ /**
353
+ * Fold regime results into a QaReport. Regime failures are the DIRECTOR's own
354
+ * rules, so they land in the same errors list as the built-ins — but the verdict
355
+ * stays advisory: `vidfarm qa` still exits 0 unless the caller asked for
356
+ * --strict, exactly as it does for slop.
357
+ */
358
+ export function mergeRegimeIntoReport(report, evaluations) {
359
+ const findings = evaluations.flatMap((evaluation) => evaluation.findings);
360
+ const errors = [...report.errors, ...findings.filter((finding) => finding.severity === "error")];
361
+ const warnings = [...report.warnings, ...findings.filter((finding) => finding.severity === "warn")];
362
+ return {
363
+ ...report,
364
+ ok: errors.length === 0,
365
+ verdict: errors.length ? "slop" : warnings.length ? "warnings" : "clean",
366
+ errors,
367
+ warnings,
368
+ regimes: evaluations
369
+ };
370
+ }
371
+ /** Human-readable regime block for the CLI (findings are printed by qa itself). */
372
+ export function formatRegimeReport(evaluation, colors) {
373
+ const { green, red, dim, reset } = colors;
374
+ const lines = [];
375
+ const failed = evaluation.checks.filter((check) => !check.ok).length;
376
+ lines.push(`${dim}regime${reset} ${evaluation.name}${evaluation.video_type ? ` ${dim}(${evaluation.video_type})${reset}` : ""} ` +
377
+ `${dim}— ${evaluation.checks.length - failed}/${evaluation.checks.length} machine checks passed, ` +
378
+ `${evaluation.review_items.length} item(s) need your judgment${reset}`);
379
+ for (const check of evaluation.checks) {
380
+ const mark = check.ok ? `${green}✓${reset}` : `${red}✗${reset}`;
381
+ lines.push(` ${mark} ${check.key} ${dim}expects ${check.expected} — got ${check.actual}${reset}`);
382
+ }
383
+ if (evaluation.review_items.length) {
384
+ lines.push(` ${dim}Review items — answer each one honestly; the CLI cannot settle these:${reset}`);
385
+ let section = "";
386
+ for (const item of evaluation.review_items) {
387
+ if (item.section !== section) {
388
+ section = item.section;
389
+ lines.push(` ${dim}${section}${reset}`);
390
+ }
391
+ lines.push(` ${dim}[ ]${reset} ${item.text}`);
392
+ }
393
+ }
394
+ return lines.join("\n");
395
+ }
396
+ //# sourceMappingURL=qa-regime.js.map