@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.
- package/.agents/skills/editor-capabilities/SKILL.md +16 -0
- package/.agents/skills/vidfarm/SKILL.md +28 -4
- package/.agents/skills/vidfarm/harnesses/README.md +1 -0
- package/.agents/skills/vidfarm/harnesses/explainer.HARNESS.md +11 -0
- package/.agents/skills/vidfarm/harnesses/product-demo.HARNESS.md +2 -0
- package/.agents/skills/vidfarm/harnesses/product-explainer.HARNESS.md +242 -0
- package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-harness.md +1 -1
- package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +2 -0
- package/.agents/skills/vidfarm/recipes/onboard-a-new-director.md +1 -0
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +21 -9
- package/.agents/skills/vidfarm/references/content-ideas.md +111 -0
- package/.agents/skills/vidfarm/references/editor-workflows.md +36 -3
- package/.agents/skills/vidfarm/references/onboarding.md +2 -0
- package/.agents/skills/vidfarm/references/primitives.md +67 -0
- package/.agents/skills/vidfarm-media/SKILL.md +50 -0
- package/SKILL.director.md +270 -16
- package/SKILL.md +3 -3
- package/crowdsourcing.md +49 -1
- package/dist/src/cli.js +461 -35
- package/dist/src/devcli/cost-mode.js +8 -0
- package/dist/src/devcli/experiments.js +257 -31
- package/dist/src/devcli/qa-check.js +74 -0
- package/dist/src/devcli/skill-docs.js +103 -0
- package/{experiment.md → experiments.md} +129 -31
- package/package.json +5 -2
|
@@ -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/
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 — ${
|
|
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
|
|
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/
|
|
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 ${
|
|
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
|
-
|
|
582
|
-
|
|
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 (
|
|
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 ${
|
|
817
|
+
`### Epoch ${epoch} — ${epoch === 1 ? (input.date ?? "<date>") : "<date>"}`,
|
|
590
818
|
"",
|
|
591
819
|
"| slot | video | variant | channel | posted |",
|
|
592
820
|
"|---|---|---|---|---|",
|
|
593
|
-
...
|
|
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
|
-
|
|
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.
|