@officexapp/vidfarm-devcli 0.21.38 → 0.21.42

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.
@@ -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>`)."
@@ -3,7 +3,7 @@
3
3
  // An ad campaign is an evolution, not a delivery: rounds of videos that vary ONE
4
4
  // composition param (structured) or vary everything (creative), posted across
5
5
  // however many channels the director holds, then read back against one north-star
6
- // metric. The method lives at `https://vidfarm.cc/experiment.md`; the ledger lives
6
+ // metric. The method lives at `https://vidfarm.cc/experiments.md`; the ledger lives
7
7
  // at the work root as one markdown file, `EXPERIMENTS_DIARY.md`.
8
8
  //
9
9
  // This module owns ONLY the parts nothing else in the CLI owns:
@@ -25,6 +25,27 @@ export const EXPERIMENT_MODES = ["creative", "structured"];
25
25
  /** The four core metrics, in funnel order. Any of them can be the north star. */
26
26
  export const CORE_METRICS = ["views", "comments", "clicks", "buys"];
27
27
  export const DEFAULT_METRIC = "comments";
28
+ /** The starting format menu, offered during planning — NOT discovered at build
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
34
+ * persuasion and the footage only has to hold attention, which is what makes
35
+ * them fast, cheap and repeatable. All seven are sourceable for ~$0 from
36
+ * `vidfarm public-raws --categories`. */
37
+ export const EASY_FORMATS = [
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" },
42
+ { key: "loop-background", label: "loop background footage", note: "cheapest of all; the copy is the entire video" },
43
+ { key: "satisfying", label: "satisfying footage", note: "strong retention, weak topical fit" },
44
+ { key: "lifestyle", label: "lifestyle footage", note: "identity and status angles" },
45
+ { key: "pov-quote", label: "POV quote aesthetic", note: "pure copywriting; a natural fit for hook tests" }
46
+ ];
47
+ /** What to take when the director has no opinion — never stall on this choice. */
48
+ export const DEFAULT_FORMAT = EASY_FORMATS[0].label;
28
49
  /** A variant only counts as tested once it has this many posts behind it. One
29
50
  * post is noise in short form, so nothing is promoted off a single result. */
30
51
  export const MIN_POSTS_PER_VARIANT = 2;
@@ -73,6 +94,26 @@ function parseMetricNumber(raw) {
73
94
  const n = Number.parseFloat(t.replace(/[^0-9.eE+-]/g, ""));
74
95
  return Number.isFinite(n) ? n : undefined;
75
96
  }
97
+ /** Posting frequency written next to a channel. Accepts the shapes a director
98
+ * actually types: "tiktok_a x2", "tiktok_a ×2", "yt_a 1/day", "li_a 3/week",
99
+ * "fb_a 2/month", and a bare "ig_a" (= once per epoch). "paused" or a 0 rate
100
+ * contributes no capacity but stays listed. */
101
+ const CHANNEL_RATE_RE = /^(.*?)[\s(]*(?:[x×]\s*(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s*\/\s*(day|epoch|wk|week|mo|month)|(paused|off))\)?\s*$/i;
102
+ export function parseChannelPlan(entry) {
103
+ const raw = entry.trim();
104
+ const m = CHANNEL_RATE_RE.exec(raw);
105
+ if (!m || (!m[2] && !m[3] && !m[5]))
106
+ return { id: raw, perEpoch: 1, raw };
107
+ const id = (m[1] ?? "").trim() || raw;
108
+ if (m[5])
109
+ return { id, perEpoch: 0, raw };
110
+ if (m[2])
111
+ return { id, perEpoch: Number.parseFloat(m[2]), raw };
112
+ const n = Number.parseFloat(m[3] ?? "1");
113
+ const unit = (m[4] ?? "day").toLowerCase();
114
+ const perEpoch = unit.startsWith("w") ? n / 7 : unit.startsWith("m") ? n / 30 : n;
115
+ return { id, perEpoch, raw };
116
+ }
76
117
  function isMode(value) {
77
118
  return EXPERIMENT_MODES.includes(value);
78
119
  }
@@ -84,7 +125,7 @@ function isMode(value) {
84
125
  export function parseDiary(source) {
85
126
  const warnings = [];
86
127
  const lines = source.split(/\r?\n/);
87
- const setup = { secondary: [], channels: [], extra: {} };
128
+ const setup = { secondary: [], channels: [], channelPlans: [], extra: {} };
88
129
  const rounds = [];
89
130
  let inSetup = false;
90
131
  let round = null;
@@ -214,14 +255,14 @@ export function parseDiary(source) {
214
255
  const video = (get("video", "id", "clip") ?? "").trim();
215
256
  const metrics = {};
216
257
  tableHeader.forEach((name, idx) => {
217
- if (!name || name === "video" || name === "id" || name === "clip" || name === "note" || name === "notes")
258
+ if (!name || name === "video" || name === "id" || name === "clip" || name === "note" || name === "notes" || name === "channel" || name === "account")
218
259
  return;
219
260
  const value = parseMetricNumber(row[idx] ?? "");
220
261
  if (value !== undefined)
221
262
  metrics[name] = value;
222
263
  });
223
264
  if (video) {
224
- results.rows.push({ video, metrics, note: get("note", "notes"), line: lineNo });
265
+ results.rows.push({ video, metrics, channel: get("channel", "account")?.trim() || undefined, note: get("note", "notes"), line: lineNo });
225
266
  }
226
267
  else {
227
268
  warnings.push({ message: "Result row with no video id — skipped.", line: lineNo });
@@ -255,9 +296,11 @@ export function parseDiary(source) {
255
296
  }
256
297
  else if (key === "channels" || key.startsWith("channels")) {
257
298
  const inner = /\(([^)]*)\)/.exec(value);
258
- setup.channels = splitList(value.replace(/\([^)]*\)/g, ""));
259
- if (inner && setup.channels.length === 0)
260
- setup.channels = splitList(inner[1] ?? "");
299
+ // Strip only a LEADING "(capacity N/epoch)" label — a per-channel rate
300
+ // in parentheses further along is data, not a label.
301
+ const entries = splitList(value.replace(/^\s*\([^)]*\)\s*/, ""));
302
+ setup.channelPlans = (entries.length ? entries : splitList(inner?.[1] ?? "")).map(parseChannelPlan);
303
+ setup.channels = setup.channelPlans.map((p) => p.id);
261
304
  }
262
305
  else if (key === "mode") {
263
306
  const normalized = value.toLowerCase();
@@ -268,6 +311,8 @@ export function parseDiary(source) {
268
311
  warnings.push({ message: `Unknown mode "${value}" — expected creative or structured.`, line: lineNo });
269
312
  }
270
313
  }
314
+ else if (key === "format" || key === "video_format")
315
+ setup.format = value;
271
316
  else if (key === "product")
272
317
  setup.product = value;
273
318
  else if (key.startsWith("baseline"))
@@ -299,6 +344,8 @@ export function parseDiary(source) {
299
344
  round.variable = v;
300
345
  else if (k === "constants")
301
346
  round.constants = splitList(v);
347
+ else if (k === "format")
348
+ round.format = v;
302
349
  else
303
350
  round.extra[k] = v;
304
351
  }
@@ -353,7 +400,7 @@ export function readDiary(dir) {
353
400
  exists: false,
354
401
  path: EXPERIMENTS_FILENAME,
355
402
  absPath,
356
- manifest: { setup: { secondary: [], channels: [], extra: {} }, rounds: [], warnings: [] }
403
+ manifest: { setup: { secondary: [], channels: [], channelPlans: [], extra: {} }, rounds: [], warnings: [] }
357
404
  };
358
405
  }
359
406
  return { exists: true, path: EXPERIMENTS_FILENAME, absPath, manifest: parseDiary(readFileSync(absPath, "utf8")) };
@@ -364,7 +411,11 @@ export function readDiary(dir) {
364
411
  /** Videos per epoch = channels held. One channel carries about one test post a
365
412
  * day before it reads as spam. */
366
413
  export function capacityOf(setup) {
367
- return setup.channels.length;
414
+ if (setup.channelPlans.length === 0)
415
+ return setup.channels.length;
416
+ const total = setup.channelPlans.reduce((sum, p) => sum + (Number.isFinite(p.perEpoch) ? p.perEpoch : 0), 0);
417
+ // Round to 2dp so "3/week" style fractions don't print as 0.4285714285714286.
418
+ return Math.round(total * 100) / 100;
368
419
  }
369
420
  export function epochsNeeded(videos, capacity) {
370
421
  if (!Number.isFinite(videos) || videos <= 0)
@@ -373,6 +424,36 @@ export function epochsNeeded(videos, capacity) {
373
424
  return Number.POSITIVE_INFINITY;
374
425
  return Math.ceil(videos / capacity);
375
426
  }
427
+ /** Deal N video slots out across channels, epoch by epoch, respecting each
428
+ * channel's posting frequency.
429
+ *
430
+ * Fractional rates (a channel that can only take 3 posts a week) are handled by
431
+ * carrying credit forward: the channel accrues `perEpoch` each epoch and takes a
432
+ * slot whenever its credit reaches 1. That keeps a 3/week channel on a real
433
+ * every-other-day cadence instead of either over-posting it daily or dropping it.
434
+ * Deterministic — same inputs, same schedule. */
435
+ export function allocateSlots(plans, videos) {
436
+ const active = plans.filter((p) => Number.isFinite(p.perEpoch) && p.perEpoch > 0);
437
+ if (active.length === 0 || videos <= 0)
438
+ return [];
439
+ const credit = new Map(active.map((p) => [p.id, 0]));
440
+ const out = [];
441
+ // Bound the loop: even the slowest channel mix can't need more epochs than
442
+ // videos / smallest-rate, and the +2 covers the first accrual.
443
+ const slowest = Math.min(...active.map((p) => p.perEpoch));
444
+ const maxEpochs = Math.ceil(videos / slowest) + 2;
445
+ for (let epoch = 1; epoch <= maxEpochs && out.length < videos; epoch++) {
446
+ for (const plan of active) {
447
+ let c = (credit.get(plan.id) ?? 0) + plan.perEpoch;
448
+ while (c >= 1 && out.length < videos) {
449
+ out.push({ epoch, channel: plan.id });
450
+ c -= 1;
451
+ }
452
+ credit.set(plan.id, c);
453
+ }
454
+ }
455
+ return out;
456
+ }
376
457
  export function median(values) {
377
458
  const sorted = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
378
459
  if (sorted.length === 0)
@@ -387,6 +468,8 @@ export function analyzeRound(round, setup, opts) {
387
468
  const slotRows = round.epochs.flatMap((e) => e.rows);
388
469
  const variantOf = new Map();
389
470
  const postsOf = new Map();
471
+ // A video can occupy more than one slot — that IS the cross-account retest.
472
+ const channelsOf = new Map();
390
473
  for (const row of slotRows) {
391
474
  if (!row.video)
392
475
  continue;
@@ -394,6 +477,11 @@ export function analyzeRound(round, setup, opts) {
394
477
  variantOf.set(row.video, row.variant);
395
478
  if (row.posted)
396
479
  postsOf.set(row.video, (postsOf.get(row.video) ?? 0) + 1);
480
+ if (row.channel) {
481
+ const set = channelsOf.get(row.video) ?? new Set();
482
+ set.add(row.channel);
483
+ channelsOf.set(row.video, set);
484
+ }
397
485
  }
398
486
  const resultRows = round.results.flatMap((block) => block.rows.map((row) => ({ row, block })));
399
487
  const scored = [];
@@ -404,15 +492,59 @@ export function analyzeRound(round, setup, opts) {
404
492
  scored.push({
405
493
  video: row.video,
406
494
  value,
495
+ channel: row.channel ?? (channelsOf.get(row.video)?.size === 1 ? Array.from(channelsOf.get(row.video) ?? [])[0] : undefined),
407
496
  variant: variantOf.get(row.video),
408
497
  posts: postsOf.get(row.video) ?? 0,
409
498
  age: block.age,
410
499
  source: block.source
411
500
  });
412
501
  }
502
+ // A RETEST is the same video MEASURED on two accounts — not a video whose slot
503
+ // was planned for one account and whose result came back from another (that is
504
+ // a plan change, and counting it would clear the confound warning for free).
505
+ // So: prefer distinct channels across RESULT rows; fall back to distinct
506
+ // channels across slots that were actually marked posted.
507
+ const measuredOn = new Map();
508
+ for (const s of scored) {
509
+ if (!s.channel)
510
+ continue;
511
+ const set = measuredOn.get(s.video) ?? new Set();
512
+ set.add(s.channel);
513
+ measuredOn.set(s.video, set);
514
+ }
515
+ for (const row of slotRows) {
516
+ if (!row.video || !row.channel || !row.posted)
517
+ continue;
518
+ if (resultRows.some(({ row: r }) => r.video === row.video && r.channel))
519
+ continue;
520
+ const set = measuredOn.get(row.video) ?? new Set();
521
+ set.add(row.channel);
522
+ measuredOn.set(row.video, set);
523
+ }
413
524
  const med = median(scored.map((s) => s.value));
414
525
  for (const s of scored)
415
526
  s.ratio = med && med > 0 ? s.value / med : undefined;
527
+ // Normalize per ACCOUNT. Account health moves numbers by multiples, so a video
528
+ // is only fairly judged against its own channel's median — and a channel needs
529
+ // at least two results before it has one.
530
+ const byChannel = new Map();
531
+ for (const s of scored) {
532
+ if (!s.channel)
533
+ continue;
534
+ byChannel.set(s.channel, [...(byChannel.get(s.channel) ?? []), s.value]);
535
+ }
536
+ const channelMedians = {};
537
+ for (const [channel, values] of byChannel) {
538
+ if (values.length < 2)
539
+ continue;
540
+ const m = median(values);
541
+ if (m !== undefined && m > 0)
542
+ channelMedians[channel] = m;
543
+ }
544
+ for (const s of scored) {
545
+ const m = s.channel ? channelMedians[s.channel] : undefined;
546
+ s.channelRatio = m ? s.value / m : undefined;
547
+ }
416
548
  const ranked = [...scored].sort((a, b) => b.value - a.value);
417
549
  const reported = new Set(resultRows.map(({ row }) => row.video));
418
550
  return {
@@ -428,7 +560,11 @@ export function analyzeRound(round, setup, opts) {
428
560
  awaiting: slotRows.filter((r) => r.posted && r.video && !reported.has(r.video)).map((r) => r.video),
429
561
  scored: ranked,
430
562
  median: med,
431
- outliers: med && med > 0 ? ranked.filter((s) => (s.ratio ?? 0) >= outlierRatio) : [],
563
+ channelMedians,
564
+ retested: Array.from(measuredOn.entries()).filter(([, set]) => set.size > 1).map(([video]) => video),
565
+ // Prefer the account-normalized ratio when the channel has a baseline; fall
566
+ // back to the round median otherwise.
567
+ outliers: med && med > 0 ? ranked.filter((s) => (s.channelRatio ?? s.ratio ?? 0) >= outlierRatio) : [],
432
568
  weakest: ranked[ranked.length - 1],
433
569
  readContexts: Array.from(new Set(round.results.map((b) => `${b.source ?? "?"}@${b.age ?? "?"}`)))
434
570
  };
@@ -445,6 +581,14 @@ export function lintDiary(manifest, analyses) {
445
581
  fix: `Add "- North-star metric: ${DEFAULT_METRIC}" (comments is the richest early-stage intel).`
446
582
  });
447
583
  }
584
+ if (!setup.format) {
585
+ findings.push({
586
+ level: "warn",
587
+ code: "no-format",
588
+ message: "No starting video format in Setup — the format is a PLANNING decision, not something to discover while editing.",
589
+ fix: `Pick one in the interview and write it down: ${EASY_FORMATS.map((f) => f.label).join(" · ")}. No opinion? Take "${DEFAULT_FORMAT}" and move — don't stall.`
590
+ });
591
+ }
448
592
  if (capacity === 0) {
449
593
  findings.push({
450
594
  level: "warn",
@@ -483,6 +627,17 @@ export function lintDiary(manifest, analyses) {
483
627
  if (!round.variable) {
484
628
  findings.push({ ...at, level: "error", code: "no-variable", message: `Round ${at.round} is structured but names no variable.`, fix: "Add \"- Variable: <param>\"." });
485
629
  }
630
+ // Format is a constant in most rounds. If the round neither pins one nor
631
+ // inherits one from Setup, its "constants" were never actually constant.
632
+ if (!round.format && !setup.format && !/format/i.test(round.variable ?? "")) {
633
+ findings.push({
634
+ ...at,
635
+ level: "warn",
636
+ code: "round-no-format",
637
+ message: `Round ${at.round} names no video format, so every video in it can drift to a different one.`,
638
+ fix: `Add "- Format: <choice>" to the round (or to Setup, which it inherits). Default: "${DEFAULT_FORMAT}".`
639
+ });
640
+ }
486
641
  if (round.constants.length === 0) {
487
642
  findings.push({
488
643
  ...at,
@@ -515,6 +670,22 @@ export function lintDiary(manifest, analyses) {
515
670
  fix: "Re-read the whole round at one age, or compare only within a single results block."
516
671
  });
517
672
  }
673
+ // Account health is the biggest confounder in the method: it moves numbers by
674
+ // multiples, often more than the variable under test. A round whose videos each
675
+ // sat on a different account has measured the accounts as much as the videos.
676
+ const channelsUsed = new Set(analysis.scored.map((s) => s.channel).filter(Boolean));
677
+ if (channelsUsed.size > 1 && analysis.retested.length === 0 && analysis.scored.length > 1) {
678
+ const normalized = Object.keys(analysis.channelMedians).length;
679
+ findings.push({
680
+ ...at,
681
+ level: normalized > 0 ? "warn" : "error",
682
+ code: "account-health-confound",
683
+ message: `Round ${at.round} ranks ${analysis.scored.length} videos across ${channelsUsed.size} accounts with no variant retested on a second account — account health can outweigh the variable you are testing.`,
684
+ fix: normalized > 0
685
+ ? "Per-account medians exist for some channels — judge by those, not the round median. Then retest the leader on a different account to confirm the ordering holds."
686
+ : "Re-post the apparent winner on a DIFFERENT account (deduped) and the apparent loser on the winner's account. If the ordering survives the swap, the effect is real; if it flips, you measured account health."
687
+ });
688
+ }
518
689
  for (const outlier of analysis.outliers) {
519
690
  if (outlier.posts < MIN_POSTS_PER_VARIANT) {
520
691
  findings.push({
@@ -529,16 +700,50 @@ export function lintDiary(manifest, analyses) {
529
700
  if (capacity > 0 && analysis.epochsRun > 0) {
530
701
  const latest = round.epochs[round.epochs.length - 1];
531
702
  const used = latest?.rows.length ?? 0;
532
- if (used > 0 && used < capacity) {
703
+ // Only whole slots are postable, and fractional capacity ("3/week") leaves a
704
+ // remainder that isn't a usable slot — don't report 0.43 free.
705
+ const free = Math.floor(capacity - used);
706
+ if (used > 0 && free >= 1) {
533
707
  findings.push({
534
708
  ...at,
535
709
  level: "info",
536
710
  code: "unspent-capacity",
537
- message: `Latest epoch of round ${at.round} uses ${used} of ${capacity} slots — ${capacity - used} free.`,
711
+ message: `Latest epoch of round ${at.round} uses ${used} of ${capacity} slots — ${free} free.`,
538
712
  fix: "Fill them: more samples of this variable, a parallel round on another param (formats or hooks are the usual next), or a one-off theory logged as a one-off."
539
713
  });
540
714
  }
541
715
  }
716
+ // A channel scheduled beyond its own stated frequency is how an account gets
717
+ // throttled or flagged — and a throttled account poisons every number on it.
718
+ for (const epoch of round.epochs) {
719
+ const counts = new Map();
720
+ for (const row of epoch.rows) {
721
+ if (!row.channel)
722
+ continue;
723
+ counts.set(row.channel, (counts.get(row.channel) ?? 0) + 1);
724
+ }
725
+ for (const [channel, used] of counts) {
726
+ const plan = setup.channelPlans.find((p) => p.id === channel);
727
+ if (!plan)
728
+ continue;
729
+ const allowed = Math.max(1, Math.ceil(plan.perEpoch));
730
+ if (plan.perEpoch === 0) {
731
+ findings.push({ ...at, level: "warn", code: "paused-channel-scheduled", message: `Round ${at.round}, epoch ${epoch.label ?? "?"}: ${channel} is marked paused but has ${used} slot(s).`, fix: "Un-pause it in Setup, or move those slots to an active channel." });
732
+ }
733
+ else if (used > allowed) {
734
+ // A bare channel name carries no explicit rate — say "the default" rather
735
+ // than echoing the id back as if it were a frequency.
736
+ const stated = plan.raw.trim() === plan.id ? "once per epoch (the default)" : `${plan.raw.trim()} (~${allowed}/epoch)`;
737
+ findings.push({
738
+ ...at,
739
+ level: "warn",
740
+ code: "channel-overposted",
741
+ message: `Round ${at.round}, epoch ${epoch.label ?? "?"}: ${channel} has ${used} slots but its stated frequency is ${stated}.`,
742
+ fix: "Spread them over more epochs, or raise the channel's frequency in Setup if it really can take that volume."
743
+ });
744
+ }
745
+ }
746
+ }
542
747
  if (analysis.awaiting.length > 0) {
543
748
  findings.push({ ...at, level: "info", code: "awaiting-results", message: `Round ${at.round}: ${analysis.awaiting.length} posted video(s) have no results logged (${analysis.awaiting.join(", ")}).`, fix: "Log them: `vidfarm experiment log <video> --views N --comments N --source <where> --age 48h`." });
544
749
  }
@@ -552,22 +757,34 @@ export function lintDiary(manifest, analyses) {
552
757
  return findings;
553
758
  }
554
759
  export function renderDiaryScaffold(input) {
555
- const channels = input.channels ?? [];
760
+ const plans = input.channelPlans?.length
761
+ ? input.channelPlans
762
+ : (input.channels ?? []).map((id) => parseChannelPlan(id));
763
+ const channelList = plans.map((p) => p.raw.trim()).join(", ");
764
+ const capacity = plans.length
765
+ ? Math.round(plans.reduce((sum, p) => sum + p.perEpoch, 0) * 100) / 100
766
+ : 0;
556
767
  const metric = input.metric ?? DEFAULT_METRIC;
557
768
  const secondary = input.secondary?.length ? input.secondary : CORE_METRICS.filter((m) => m !== metric).slice(0, 2);
558
769
  return [
559
770
  `# Experiments Diary — ${input.product ?? "<product>"}`,
560
771
  "",
561
- `Method: https://vidfarm.cc/experiment.md · started ${input.date ?? ""}`.trim(),
772
+ `Method: https://vidfarm.cc/experiments.md · started ${input.date ?? ""}`.trim(),
562
773
  "",
563
774
  "## Setup",
564
775
  `- North-star metric: ${metric} (secondary: ${secondary.join(", ")})`,
565
- `- Channels (capacity ${channels.length || "?"}/epoch): ${channels.length ? channels.join(", ") : "<run `vidfarm channels` and list them>"}`,
776
+ `- Channels (capacity ${capacity || "?"}/epoch): ${channelList || "<run `vidfarm channels` and list them>"}`,
777
+ " <!-- per-channel frequency: \"name x2\" = twice an epoch · \"name 3/week\" · \"name paused\" · bare name = once -->",
566
778
  `- Mode: ${input.mode ?? "creative"}`,
779
+ `- Format: ${input.format ?? DEFAULT_FORMAT}`,
567
780
  `- Editors: ${input.editors ?? "agent"}`,
568
781
  `- Baseline checkpoint: ${input.baseline ?? "kinetic captions over b-roll, no VO, ~20s"}`,
569
782
  `- Analytics source: ${input.source ?? "<flockposter | manual | email-channel | gigworkers>"}`,
570
783
  "",
784
+ "",
785
+ "Format menu (copywriting-led — the words do the work; all ~$0 via `vidfarm public-raws --categories`):",
786
+ ...EASY_FORMATS.map((f) => `- ${f.label} — ${f.note}`),
787
+ "",
571
788
  "Append only. Never rewrite history here — correct it with a later entry.",
572
789
  ""
573
790
  ].join("\n");
@@ -578,22 +795,30 @@ export function renderRoundScaffold(input) {
578
795
  const slots = input.slots?.length
579
796
  ? input.slots
580
797
  : Array.from({ length: videos }, (_, i) => `v${String(i + 1).padStart(3, "0")}`);
581
- const channels = input.channels ?? [];
582
- const perEpoch = input.capacity > 0 ? input.capacity : slots.length;
798
+ // Deal the slots out by each channel's OWN posting frequency, not round-robin —
799
+ // a 2/day channel earns two slots an epoch and a 3/week channel skips epochs.
800
+ const plans = input.channelPlans?.length
801
+ ? input.channelPlans
802
+ : (input.channels ?? []).map((id) => ({ id, perEpoch: 1, raw: id }));
803
+ const allocation = allocateSlots(plans, slots.length);
804
+ const byEpoch = new Map();
805
+ slots.forEach((raw, i) => {
806
+ const [video, variant] = raw.split("|").map((s) => s.trim());
807
+ // No channel plan at all → still emit the row, with the channel left blank.
808
+ const slot = allocation[i];
809
+ const epoch = slot?.epoch ?? Math.floor(i / Math.max(1, input.capacity || slots.length)) + 1;
810
+ const list = byEpoch.get(epoch) ?? [];
811
+ list.push({ video: video ?? "", variant, channel: slot?.channel ?? "" });
812
+ byEpoch.set(epoch, list);
813
+ });
583
814
  const epochBlocks = [];
584
- for (let e = 0; e < Math.max(1, Number.isFinite(epochs) ? epochs : 1); e++) {
585
- const slice = slots.slice(e * perEpoch, (e + 1) * perEpoch);
586
- if (slice.length === 0)
587
- break;
815
+ for (const [epoch, rows] of Array.from(byEpoch.entries()).sort((a, b) => a[0] - b[0])) {
588
816
  epochBlocks.push([
589
- `### Epoch ${e + 1} — ${e === 0 ? (input.date ?? "<date>") : "<date>"}`,
817
+ `### Epoch ${epoch} — ${epoch === 1 ? (input.date ?? "<date>") : "<date>"}`,
590
818
  "",
591
819
  "| slot | video | variant | channel | posted |",
592
820
  "|---|---|---|---|---|",
593
- ...slice.map((raw, i) => {
594
- const [video, variant] = raw.split("|").map((s) => s.trim());
595
- return `| ${i + 1} | ${video ?? ""} | ${variant ?? ""} | ${channels[i % Math.max(1, channels.length)] ?? ""} | |`;
596
- }),
821
+ ...rows.map((r, i) => `| ${i + 1} | ${r.video} | ${r.variant ?? ""} | ${r.channel} | |`),
597
822
  ""
598
823
  ].join("\n"));
599
824
  }
@@ -601,8 +826,9 @@ export function renderRoundScaffold(input) {
601
826
  `## Round ${input.number}${input.title ? ` — ${input.title}` : input.variable ? ` — ${input.variable}` : ""}`,
602
827
  `- Mode: ${input.mode ?? "creative"}`,
603
828
  ...(input.variable ? [`- Variable: ${input.variable}`] : []),
829
+ `- Format: ${input.format ?? DEFAULT_FORMAT}`,
604
830
  `- Constants: ${input.constants?.length ? input.constants.join(", ") : "—"}`,
605
- `- Videos: ${videos} · Capacity ${input.capacity || "?"}/epoch → ${Number.isFinite(epochs) ? epochs : "?"} epoch(s)`,
831
+ `- Videos: ${videos} · Capacity ${input.capacity || "?"} slots/epoch → ${Number.isFinite(epochs) ? epochs : "?"} epoch(s)`,
606
832
  `- Editors: ${input.editors ?? "agent"}`,
607
833
  `- Justification: ${input.justification ?? "<why this variable is worth the capacity before the others>"}`,
608
834
  `- Win condition: ${input.win ?? `any variant at >=${DEFAULT_OUTLIER_RATIO}x median`}`,
@@ -638,16 +864,16 @@ export function appendResult(source, manifest, roundIndex, input) {
638
864
  const header = manifest.rounds[roundIndex]?.results.find((b) => b === match);
639
865
  const known = header ? Object.keys(match.rows[0]?.metrics ?? {}) : columns;
640
866
  const ordered = known.length ? known : columns;
641
- const row = `| ${input.video} | ${ordered.map((c) => (input.metrics[c] !== undefined ? String(input.metrics[c]) : "")).join(" | ")} | ${input.note ?? ""} |`;
867
+ const row = `| ${input.video} | ${input.channel ?? ""} | ${ordered.map((c) => (input.metrics[c] !== undefined ? String(input.metrics[c]) : "")).join(" | ")} | ${input.note ?? ""} |`;
642
868
  return { text: spliceLines(source, match.endLine, row), block: "existing" };
643
869
  }
644
870
  const ordered = columns.length ? columns : [...CORE_METRICS];
645
871
  const block = [
646
872
  `### Results — read ${input.date}${input.source ? `, source: ${input.source}` : ""}${input.age ? `, age: ${input.age}` : ""}`,
647
873
  "",
648
- `| video | ${ordered.join(" | ")} | note |`,
649
- `|---|${ordered.map(() => "---|").join("")}---|`,
650
- `| ${input.video} | ${ordered.map((c) => (input.metrics[c] !== undefined ? String(input.metrics[c]) : "")).join(" | ")} | ${input.note ?? ""} |`
874
+ `| video | channel | ${ordered.join(" | ")} | note |`,
875
+ `|---|---|${ordered.map(() => "---|").join("")}---|`,
876
+ `| ${input.video} | ${input.channel ?? ""} | ${ordered.map((c) => (input.metrics[c] !== undefined ? String(input.metrics[c]) : "")).join(" | ")} | ${input.note ?? ""} |`
651
877
  ].join("\n");
652
878
  return { text: spliceLines(source, round.endLine, `\n${block}`), block: "new" };
653
879
  }
@@ -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.