@officexapp/vidfarm-devcli 0.21.45 → 0.21.46

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 (37) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +2 -0
  2. package/.agents/skills/vidfarm/SKILL.md +60 -7
  3. package/.agents/skills/vidfarm/harnesses/explainer.HARNESS.md +1 -1
  4. package/.agents/skills/vidfarm/harnesses/product-demo.HARNESS.md +2 -0
  5. package/.agents/skills/vidfarm/harnesses/short-form.HARNESS.md +1 -0
  6. package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +1 -1
  7. package/.agents/skills/vidfarm/references/agent-included-imagegen.md +75 -0
  8. package/.agents/skills/vidfarm/references/assets-and-sourcing.md +56 -2
  9. package/.agents/skills/vidfarm/references/automation-and-local-dev.md +13 -6
  10. package/.agents/skills/vidfarm/references/browser-harness.md +93 -0
  11. package/.agents/skills/vidfarm/references/editor-workflows.md +22 -0
  12. package/SKILL.director.md +322 -16
  13. package/SKILL.md +44 -3
  14. package/crowdsourcing.md +373 -2
  15. package/dist/src/cli.js +207 -4
  16. package/dist/src/devcli/agent-imagegen.js +181 -0
  17. package/dist/src/devcli/browser-harness.js +384 -0
  18. package/dist/src/devcli/clip-store.js +41 -3
  19. package/dist/src/devcli/cost-mode.js +23 -3
  20. package/dist/src/devcli/doctor.js +52 -3
  21. package/dist/src/devcli/hyperframes-cli.js +11 -1
  22. package/dist/src/devcli/local-render.js +4 -7
  23. package/dist/src/devcli/marketplace-gigs.js +623 -0
  24. package/dist/src/devcli/qa-check.js +89 -1
  25. package/dist/src/devcli/shared-folder.js +387 -0
  26. package/dist/src/devcli/stills.js +4 -8
  27. package/dist/src/lib/ffprobe-path.js +64 -0
  28. package/dist/src/lib/render-media-prep.js +2 -11
  29. package/dist/src/services/clip-curation/ffmpeg.js +4 -15
  30. package/dist/src/services/clip-curation/local-agent.js +6 -2
  31. package/package.json +8 -153
  32. package/public/assets/file-directory-app.js +34 -34
  33. package/public/serve-shells/library-files.html +5 -1
  34. package/public/serve-shells/library-raws.html +10 -1
  35. package/public/serve-shells/tools-clipper.html +5 -1
  36. package/public/serve-shells/tools-image.html +5 -1
  37. package/public/serve-shells/tools-video.html +5 -1
@@ -0,0 +1,384 @@
1
+ // `vidfarm browser` — wire the agent's OWN Chrome up as a FREE substitute for
2
+ // the paid cloud search/download primitives.
3
+ //
4
+ // WHY THIS EXISTS. The sourcing ladder in SKILL.md already starts at "your own
5
+ // browser control, if you have it" — but nothing in the devcli ever made that
6
+ // rung real, so every agent fell straight through to `video-search` /
7
+ // `image-search` / `news-search` / `download-video`, all of which are paid
8
+ // plans only. A free-tier director therefore got a 402 for the single most
9
+ // common ask in the product ("find me a clip of X").
10
+ //
11
+ // browser-harness (https://github.com/browser-use/browser-harness) closes that
12
+ // gap: it attaches an LLM to the user's already-running Chrome over one CDP
13
+ // websocket, so the agent can drive Google Images / Google Videos / Google News
14
+ // itself. The session is the user's own logged-in browser, on the user's own
15
+ // IP, at $0. That is a genuine substitute for the paid primitives when the mode
16
+ // is `minimize`, and a cost saver in `hybrid`.
17
+ //
18
+ // This module does NOT reimplement browser-harness. It does three things:
19
+ // 1. detect / install it (`setup`), so "can easily set it up" is one command;
20
+ // 2. report it in `vidfarm doctor` as an optional free capability;
21
+ // 3. mint the exact browse RECIPE for a given sourcing job (`browse`), in the
22
+ // same house pattern as `vidfarm consult` and `vidfarm handoff` — the
23
+ // devcli writes the brief, the agent already in this terminal executes it.
24
+ //
25
+ // Nothing here is billed and nothing here needs a Vidfarm account.
26
+ import { spawnSync } from "node:child_process";
27
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import path from "node:path";
30
+ import { parseArgs } from "node:util";
31
+ import { resolveSkillsRoot } from "./skills.js";
32
+ const GREEN = "\x1b[32m";
33
+ const YELLOW = "\x1b[33m";
34
+ const DIM = "\x1b[2m";
35
+ const BOLD = "\x1b[1m";
36
+ const RESET = "\x1b[0m";
37
+ export const BROWSER_HARNESS_REPO = "https://github.com/browser-use/browser-harness";
38
+ /** Where `uv tool install` drops console scripts, per platform. browser-harness
39
+ * lands on PATH for most users, but a fresh `uv` install in the same shell has
40
+ * not re-hashed PATH yet — so check the well-known bin dirs too rather than
41
+ * telling a user who just installed it that it isn't there. */
42
+ function candidateBins() {
43
+ const home = homedir();
44
+ const exe = process.platform === "win32" ? "browser-harness.exe" : "browser-harness";
45
+ const dirs = process.platform === "win32"
46
+ ? [path.join(home, ".local", "bin"), path.join(home, "AppData", "Roaming", "uv", "tools", "browser-harness", "Scripts")]
47
+ : [path.join(home, ".local", "bin"), "/usr/local/bin", "/opt/homebrew/bin"];
48
+ return dirs.map((dir) => path.join(dir, exe));
49
+ }
50
+ function which(command) {
51
+ const probe = process.platform === "win32" ? "where" : "which";
52
+ const res = spawnSync(probe, [command], { encoding: "utf8" });
53
+ if (res.status !== 0)
54
+ return null;
55
+ const first = String(res.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).find(Boolean);
56
+ return first || null;
57
+ }
58
+ export function detectBrowserHarness() {
59
+ const uv = which("uv");
60
+ let bin = which("browser-harness");
61
+ if (!bin)
62
+ bin = candidateBins().find((candidate) => existsSync(candidate)) ?? null;
63
+ let version = null;
64
+ if (bin) {
65
+ const res = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 20_000 });
66
+ const out = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim();
67
+ version = out ? out.split(/\r?\n/)[0].trim() : null;
68
+ }
69
+ return { bin, version, uv };
70
+ }
71
+ /** One line for `vidfarm doctor`. Never a failure — this is an OPTIONAL free
72
+ * capability, and a box without it is a perfectly healthy box. */
73
+ export function browserHarnessDoctorCheck() {
74
+ const found = detectBrowserHarness();
75
+ if (found.bin) {
76
+ return {
77
+ level: "ok",
78
+ detail: `${found.bin}${found.version ? ` (${found.version})` : ""} — free browser sourcing available; \`vidfarm browse videos "<shot>"\` for the recipe`
79
+ };
80
+ }
81
+ return {
82
+ level: "warn",
83
+ detail: found.uv
84
+ ? "not installed — the FREE substitute for the paid video-search/image-search/news-search/download-video primitives. Install: `vidfarm browser setup`"
85
+ : "not installed, and `uv` is missing too — run `vidfarm browser setup` (it prints the one-line uv install first)"
86
+ };
87
+ }
88
+ const RECIPES = {
89
+ videos: {
90
+ replaces: "vidfarm video-search (paid, $0.0003/call)",
91
+ udm: "7",
92
+ tbm: "vid",
93
+ extract: "every off-Google result link with its title and host — YouTube, TikTok, Pexels, Pixabay, Mixkit, archive.org, news sites",
94
+ next: [
95
+ "vidfarm raws scan \"<result url>\" --clips 8 # mine short raws out of the best hit",
96
+ "vidfarm clipper \"<result url>\" --start <t> --end <t> # one surgical cut instead"
97
+ ]
98
+ },
99
+ images: {
100
+ replaces: "vidfarm image-search (paid, $0.0003/call)",
101
+ udm: "2",
102
+ tbm: "isch",
103
+ extract: "the SOURCE PAGE url behind each thumbnail (Google lazy-loads full-res, so the thumbnail src is a data: URI and not what you want) — click a result, then read the full-size image url off the detail panel",
104
+ next: [
105
+ "vidfarm put-file ./downloaded.png --folder brand-assets --notes \"<what it is, so files --search finds it later>\""
106
+ ]
107
+ },
108
+ news: {
109
+ replaces: "vidfarm news-search (paid, $0.0003/call)",
110
+ udm: "12",
111
+ tbm: "nws",
112
+ extract: "headline, publisher, timestamp and article url for each story — this is the STORY stage, not the visuals stage",
113
+ next: [
114
+ "# then a SECOND pass for the visuals — never one query for both:",
115
+ "vidfarm browse videos \"<the thing the story is about>\""
116
+ ]
117
+ },
118
+ page: {
119
+ replaces: "vidfarm download-video / download-audio (paid, wallet-billed resolver)",
120
+ udm: null,
121
+ tbm: null,
122
+ extract: "the media element itself — `document.querySelectorAll('video source, video')` for the src, or CDP `Network.getResponseBody` on the media request; a logged-in session sees what an anonymous resolver cannot",
123
+ next: [
124
+ "vidfarm put-file ./saved.mp4 --folder raws-inbox # bring the local file in for $0",
125
+ "vidfarm clipper ./saved.mp4 --start <t> --end <t> # or clip it straight away"
126
+ ]
127
+ }
128
+ };
129
+ function googleUrl(mode, query, legacy) {
130
+ const recipe = RECIPES[mode];
131
+ const q = encodeURIComponent(query);
132
+ if (!recipe.udm && !recipe.tbm)
133
+ return query.startsWith("http") ? query : `https://www.google.com/search?q=${q}`;
134
+ const surface = legacy ? `tbm=${recipe.tbm}` : `udm=${recipe.udm}`;
135
+ return `https://www.google.com/search?q=${q}&${surface}`;
136
+ }
137
+ /** The heredoc an agent runs. Deliberately short: browser-harness's own SKILL.md
138
+ * teaches the AX-tree-first workflow, and we do not want to fight it with a
139
+ * brittle selector script that Google will break next month. */
140
+ function browseScript(mode, query, legacy, limit) {
141
+ const url = googleUrl(mode, query, legacy);
142
+ if (mode === "page") {
143
+ return [
144
+ "browser-harness <<'PY'",
145
+ `new_tab(${JSON.stringify(query)})`,
146
+ "wait_for_load()",
147
+ "print(page_info())",
148
+ "print(js(\"JSON.stringify([...document.querySelectorAll('video, video source')].map(n => n.currentSrc || n.src).filter(Boolean))\"))",
149
+ "PY"
150
+ ].join("\n");
151
+ }
152
+ return [
153
+ "browser-harness <<'PY'",
154
+ `new_tab(${JSON.stringify(url)})`,
155
+ "wait_for_load()",
156
+ "# Result links, deduped, Google's own chrome stripped out.",
157
+ "print(js(\"\"\"JSON.stringify(",
158
+ " [...document.querySelectorAll('a[href^=\\\"http\\\"]')]",
159
+ " .map(a => ({ url: a.href, title: (a.innerText || '').trim().split('\\\\n')[0] }))",
160
+ " .filter(r => !/(^|\\\\.)google\\\\.com$/.test(new URL(r.url).hostname) && r.title)",
161
+ " .filter((r, i, all) => all.findIndex(o => o.url === r.url) === i)",
162
+ ` .slice(0, ${limit})`,
163
+ ")\"\"\"))",
164
+ "PY"
165
+ ].join("\n");
166
+ }
167
+ function renderBrowseRecipe(mode, query, legacy, limit, installed) {
168
+ const recipe = RECIPES[mode];
169
+ const lines = [];
170
+ lines.push(`${BOLD}Free browser sourcing — ${mode}${RESET} ${DIM}(replaces ${recipe.replaces})${RESET}`);
171
+ lines.push("");
172
+ if (!installed) {
173
+ lines.push(`${YELLOW}browser-harness is not installed yet.${RESET} Run ${BOLD}vidfarm browser setup${RESET} first — one command, no account, no key.`);
174
+ lines.push("");
175
+ }
176
+ lines.push(`${BOLD}1. Open it${RESET}`);
177
+ lines.push("");
178
+ lines.push(browseScript(mode, query, legacy, limit));
179
+ lines.push("");
180
+ lines.push(`${BOLD}2. Pull off the page${RESET}`);
181
+ lines.push(` ${recipe.extract}`);
182
+ lines.push("");
183
+ lines.push(`${BOLD}3. Feed it forward${RESET}`);
184
+ for (const step of recipe.next)
185
+ lines.push(` ${step}`);
186
+ lines.push("");
187
+ lines.push(`${DIM}Prefer the accessibility tree over screenshots — cdp("Accessibility.getFullAXTree"). If a`);
188
+ lines.push(`selector above comes back empty, Google changed its markup: read the AX tree and adapt,`);
189
+ lines.push(`don't retry the same query. Legacy surface param: rerun with --legacy (tbm=… instead of udm=…).${RESET}`);
190
+ lines.push("");
191
+ lines.push(`${DIM}A public result is not a licensed asset. Keep the source URL and the licence page for`);
192
+ lines.push(`anything that reaches a client render.${RESET}`);
193
+ return lines.join("\n");
194
+ }
195
+ // ── setup ───────────────────────────────────────────────────────────────────
196
+ function runSetup(opts) {
197
+ const before = detectBrowserHarness();
198
+ console.log(`${BOLD}vidfarm browser setup${RESET} — free browser sourcing via browser-harness`);
199
+ console.log(`${DIM}${BROWSER_HARNESS_REPO}${RESET}`);
200
+ console.log("");
201
+ if (!before.uv) {
202
+ console.log(`${YELLOW}Step 0 — install uv (the Python tool installer browser-harness ships through):${RESET}`);
203
+ console.log(process.platform === "win32"
204
+ ? ' powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"'
205
+ : " curl -LsSf https://astral.sh/uv/install.sh | sh");
206
+ console.log("");
207
+ console.log("Then re-run `vidfarm browser setup`.");
208
+ return 1;
209
+ }
210
+ const install = ["tool", "install", "--python", "3.12", "--upgrade", "--force", "browser-harness"];
211
+ console.log(`${BOLD}Step 1 — install/upgrade browser-harness${RESET}`);
212
+ console.log(` uv ${install.join(" ")}`);
213
+ if (opts.dryRun) {
214
+ console.log(`${DIM} (--dry-run: not executed)${RESET}`);
215
+ }
216
+ else {
217
+ const res = spawnSync(before.uv, install, { stdio: "inherit" });
218
+ if (res.status !== 0) {
219
+ console.error(`${YELLOW}uv tool install failed (exit ${res.status}). Run the command above by hand to see why.${RESET}`);
220
+ return 1;
221
+ }
222
+ }
223
+ console.log("");
224
+ const found = opts.dryRun ? before : detectBrowserHarness();
225
+ const bin = found.bin ?? "browser-harness";
226
+ // Step 2 — register browser-harness's OWN skill next to the vidfarm pack, so
227
+ // the agent reading .agents/skills finds the browser workflow without a
228
+ // network fetch. `browser-harness skill` prints the canonical body; we never
229
+ // author or paraphrase it here.
230
+ const skillRoot = opts.skillDir ? path.resolve(process.cwd(), opts.skillDir) : resolveSkillsRoot();
231
+ const skillDir = path.join(skillRoot, ".agents", "skills", "browser-harness");
232
+ const skillFile = path.join(skillDir, "SKILL.md");
233
+ console.log(`${BOLD}Step 2 — register the browser-harness skill${RESET}`);
234
+ console.log(` ${skillFile}`);
235
+ if (opts.dryRun) {
236
+ console.log(`${DIM} (--dry-run: not written)${RESET}`);
237
+ }
238
+ else {
239
+ const res = spawnSync(bin, ["skill"], { encoding: "utf8", timeout: 60_000 });
240
+ const body = String(res.stdout ?? "").trim();
241
+ if (res.status === 0 && body) {
242
+ mkdirSync(skillDir, { recursive: true });
243
+ writeFileSync(skillFile, `${body}\n`, "utf8");
244
+ console.log(` ${GREEN}written${RESET}`);
245
+ }
246
+ else {
247
+ console.log(` ${YELLOW}\`${bin} skill\` produced nothing — register it by hand: \`${bin} skill > ${skillFile}\`${RESET}`);
248
+ }
249
+ }
250
+ console.log("");
251
+ // Step 3 — the one manual step. Chrome will not expose CDP until the user
252
+ // ticks the box, and no amount of tooling can do it for them.
253
+ console.log(`${BOLD}Step 3 — let Chrome be driven (one-time, the user must do this)${RESET}`);
254
+ console.log(" 1. Open Chrome → chrome://inspect/#remote-debugging");
255
+ console.log(' 2. Tick "Allow remote debugging for this browser instance"');
256
+ console.log(` 3. macOS only, if a permission sheet appears: ${bin} mac-approve`);
257
+ console.log("");
258
+ console.log(`${BOLD}Step 4 — verify${RESET}`);
259
+ console.log(` ${bin} <<'PY'`);
260
+ console.log(" print(page_info())");
261
+ console.log(" PY");
262
+ console.log(` ${DIM}(if it fails: ${bin} --doctor)${RESET}`);
263
+ console.log("");
264
+ console.log(`${GREEN}Then this is free:${RESET}`);
265
+ console.log(' vidfarm browse videos "warehouse worker packing orders POV 4K" # was: video-search, paid');
266
+ console.log(' vidfarm browse images "manila street market wide shot" # was: image-search, paid');
267
+ console.log(' vidfarm browse news "AI startup funding announced" # was: news-search, paid');
268
+ console.log(' vidfarm browse page "https://…" # was: download-video, paid');
269
+ console.log("");
270
+ console.log(`${DIM}Recordings are OFF by default and stay off unless the user asks — they capture page`);
271
+ console.log(`content to disk. \`${bin} recordings\` shows the current preference.${RESET}`);
272
+ return 0;
273
+ }
274
+ function printStatus() {
275
+ const found = detectBrowserHarness();
276
+ console.log(`${BOLD}browser-harness${RESET} ${DIM}— free substitute for the paid search/download primitives${RESET}`);
277
+ console.log("");
278
+ console.log(` uv ${found.uv ? `${GREEN}${found.uv}${RESET}` : `${YELLOW}not found${RESET}`}`);
279
+ console.log(` browser-harness ${found.bin ? `${GREEN}${found.bin}${RESET}${found.version ? ` ${DIM}(${found.version})${RESET}` : ""}` : `${YELLOW}not installed${RESET}`}`);
280
+ console.log("");
281
+ if (!found.bin) {
282
+ console.log(`Install it with ${BOLD}vidfarm browser setup${RESET} — no account, no API key, ~1 minute.`);
283
+ console.log(`${DIM}It replaces video-search / image-search / news-search / download-video, all of which are`);
284
+ console.log(`paid plans only. In cost mode \`minimize\` this is the difference between sourcing footage`);
285
+ console.log(`and telling the user you can't.${RESET}`);
286
+ return 0;
287
+ }
288
+ console.log(`Connection check: ${BOLD}${found.bin} --doctor${RESET}`);
289
+ console.log(`Browse recipes: ${BOLD}vidfarm browse <videos|images|news|page> "<query>"${RESET}`);
290
+ return 0;
291
+ }
292
+ export async function runBrowserHarnessCommand(argv) {
293
+ const sub = (argv[0] ?? "status").toLowerCase();
294
+ const rest = argv.slice(1);
295
+ if (sub === "setup" || sub === "install") {
296
+ const parsed = parseArgs({
297
+ args: rest,
298
+ allowPositionals: false,
299
+ options: { "dry-run": { type: "boolean", default: false }, dir: { type: "string" } }
300
+ });
301
+ const code = runSetup({ dryRun: Boolean(parsed.values["dry-run"]), skillDir: parsed.values.dir });
302
+ if (code !== 0)
303
+ process.exitCode = code;
304
+ return;
305
+ }
306
+ if (sub === "doctor") {
307
+ const found = detectBrowserHarness();
308
+ if (!found.bin) {
309
+ printStatus();
310
+ return;
311
+ }
312
+ const res = spawnSync(found.bin, ["--doctor"], { stdio: "inherit" });
313
+ if (res.status !== 0)
314
+ process.exitCode = res.status ?? 1;
315
+ return;
316
+ }
317
+ if (sub === "status" || sub === "" || sub === "--help" || sub === "-h") {
318
+ printStatus();
319
+ return;
320
+ }
321
+ // `vidfarm browser videos "…"` is the same thing as `vidfarm browse videos "…"`.
322
+ if (sub === "videos" || sub === "images" || sub === "news" || sub === "page") {
323
+ await runBrowseCommand(argv);
324
+ return;
325
+ }
326
+ throw new Error(`Unknown browser subcommand "${sub}". Use: status | setup | doctor, or \`vidfarm browse <videos|images|news|page> "<query>"\`.`);
327
+ }
328
+ export async function runBrowseCommand(argv) {
329
+ const parsed = parseArgs({
330
+ args: argv,
331
+ allowPositionals: true,
332
+ options: {
333
+ limit: { type: "string", default: "40" },
334
+ legacy: { type: "boolean", default: false },
335
+ // Pipe the script straight into browser-harness instead of printing it.
336
+ // Off by default: the recipe is meant to be ADAPTED (Google's markup
337
+ // moves), and an agent that reads it first gets better results than one
338
+ // that runs it blind.
339
+ run: { type: "boolean", default: false },
340
+ json: { type: "boolean", default: false }
341
+ }
342
+ });
343
+ const positionals = parsed.positionals ?? [];
344
+ const mode = String(positionals[0] ?? "").toLowerCase();
345
+ const query = positionals.slice(1).join(" ").trim();
346
+ if (!["videos", "images", "news", "page"].includes(mode) || !query) {
347
+ throw new Error('Usage: vidfarm browse <videos|images|news|page> "<query or url>" [--limit 40] [--legacy] [--run]\n' +
348
+ ' videos → replaces the paid video-search · images → image-search · news → news-search · page → download-video');
349
+ }
350
+ const limit = Math.max(1, Math.min(200, Number(parsed.values.limit) || 40));
351
+ const legacy = Boolean(parsed.values.legacy);
352
+ const found = detectBrowserHarness();
353
+ if (parsed.values.json) {
354
+ console.log(JSON.stringify({
355
+ mode,
356
+ query,
357
+ installed: Boolean(found.bin),
358
+ bin: found.bin,
359
+ replaces: RECIPES[mode].replaces,
360
+ url: googleUrl(mode, query, legacy),
361
+ script: browseScript(mode, query, legacy, limit),
362
+ extract: RECIPES[mode].extract,
363
+ next: RECIPES[mode].next
364
+ }, null, 2));
365
+ return;
366
+ }
367
+ if (parsed.values.run) {
368
+ if (!found.bin) {
369
+ throw new Error("--run needs browser-harness. Install it with `vidfarm browser setup`, or drop --run to print the recipe.");
370
+ }
371
+ // Feed the PY body (not the wrapper) to the binary on stdin, exactly as the
372
+ // heredoc would.
373
+ const script = browseScript(mode, query, legacy, limit)
374
+ .split("\n")
375
+ .slice(1, -1)
376
+ .join("\n");
377
+ const res = spawnSync(found.bin, [], { input: `${script}\n`, stdio: ["pipe", "inherit", "inherit"] });
378
+ if (res.status !== 0)
379
+ process.exitCode = res.status ?? 1;
380
+ return;
381
+ }
382
+ console.log(renderBrowseRecipe(mode, query, legacy, limit, Boolean(found.bin)));
383
+ }
384
+ //# sourceMappingURL=browser-harness.js.map
@@ -6,8 +6,7 @@
6
6
  // ~/.vidfarm/clips/<id>.mp4 clip files
7
7
  // ~/.vidfarm/thumbs/<id>.jpg thumbnails
8
8
  // Override the base dir with VIDFARM_HOME.
9
- import Database from "better-sqlite3";
10
- import * as sqliteVec from "sqlite-vec";
9
+ import { createRequire } from "node:module";
11
10
  import { existsSync, mkdirSync, rmSync } from "node:fs";
12
11
  import path from "node:path";
13
12
  import { homedir } from "node:os";
@@ -27,6 +26,44 @@ export function resolveClipStorePaths(baseOverride) {
27
26
  workDir: path.join(base, "scan-tmp")
28
27
  };
29
28
  }
29
+ const requireFrom = createRequire(import.meta.url);
30
+ let cachedSqlite;
31
+ /**
32
+ * A missing OPTIONAL native module is an expected environment condition, not a
33
+ * bug — cli.ts prints the message without a stack and skips the crash report,
34
+ * the same treatment LocalModeUnavailableError gets.
35
+ */
36
+ export class NativeModuleUnavailableError extends Error {
37
+ constructor(message) {
38
+ super(message);
39
+ this.name = "NativeModuleUnavailableError";
40
+ }
41
+ }
42
+ /**
43
+ * Load the optional native SQLite stack, or throw a message that names the
44
+ * actual fix. Called from the ClipStore constructor, never at import time.
45
+ */
46
+ function loadSqlite() {
47
+ if (cachedSqlite)
48
+ return cachedSqlite;
49
+ try {
50
+ const mod = requireFrom("better-sqlite3");
51
+ const ctor = (typeof mod === "function" ? mod : mod.default);
52
+ const sqliteVec = requireFrom("sqlite-vec");
53
+ return (cachedSqlite = { Database: ctor, sqliteVec });
54
+ }
55
+ catch (error) {
56
+ const detail = error instanceof Error ? error.message.split("\n")[0] : String(error);
57
+ throw new NativeModuleUnavailableError("The local clip library needs the optional native module `better-sqlite3`, which is not built on this machine.\n" +
58
+ " Everything else in vidfarm works without it — only `vidfarm clips` does not.\n" +
59
+ " To enable it, install a C++ toolchain and reinstall:\n" +
60
+ " Windows : npm i -g windows-build-tools (or install \"Desktop development with C++\" from the Visual Studio Installer)\n" +
61
+ " macOS : xcode-select --install\n" +
62
+ " Linux : apt install build-essential python3\n" +
63
+ " Then: npm i -g @officexapp/vidfarm-devcli\n" +
64
+ ` Underlying load error: ${detail}`);
65
+ }
66
+ }
30
67
  export class ClipStore {
31
68
  paths;
32
69
  db;
@@ -36,7 +73,8 @@ export class ClipStore {
36
73
  mkdirSync(this.paths.base, { recursive: true });
37
74
  mkdirSync(this.paths.clipsDir, { recursive: true });
38
75
  mkdirSync(this.paths.thumbsDir, { recursive: true });
39
- this.db = new Database(this.paths.db);
76
+ const { Database: DatabaseCtor, sqliteVec } = loadSqlite();
77
+ this.db = new DatabaseCtor(this.paths.db);
40
78
  this.db.pragma("journal_mode = WAL");
41
79
  sqliteVec.load(this.db);
42
80
  this.dim = this.initSchema();
@@ -7,7 +7,11 @@
7
7
  // their AI agent up front), then every billed command respects it:
8
8
  // - minimize : ~$0 per video. Never burn AI credits by surprise. Prefer free
9
9
  // local engines; refuse billed cloud/AI ops unless the user
10
- // re-confirms (--yes).
10
+ // re-confirms (--yes). NOTE this does not mean "no AI at all":
11
+ // if the AGENT driving the terminal has image generation included
12
+ // in its own subscription (Antigravity / Gemini CLI, Codex /
13
+ // ChatGPT — Claude Code does not), it should generate the graphic
14
+ // itself for $0 rather than go without. See agent-imagegen.ts.
11
15
  // - hybrid : roughly $0.01–$1 per video. Free where it's free, spend on AI
12
16
  // only where it clearly wins. (default recommendation.) Billed
13
17
  // ops run but print a cost line.
@@ -134,9 +138,15 @@ export const COST_MODE_BLURB = {
134
138
  'For any ICON, STICKER, illustration, 3D prop or Lottie, use vidfarm iconscout "<meaning>" ' +
135
139
  "--free before even considering AI image generation: free IconScout assets cost $0 (a credit " +
136
140
  "line is the only price) and arrive as clean transparent vectors. " +
141
+ "$0 does NOT mean no AI: if YOUR OWN agent subscription already includes image generation " +
142
+ "(Google Antigravity / Gemini CLI, OpenAI Codex / ChatGPT — Claude Code does not, Anthropic " +
143
+ 'ships no image model), generate the graphic yourself — `vidfarm agent-image "<subject>"` ' +
144
+ "prints the prompt, the plate rules and the import command. It is free in minimize. " +
137
145
  "Billed cloud/AI generation is refused unless you re-confirm it (--yes).",
138
146
  hybrid: "Hybrid (recommended) — roughly $0.01–$1 per video. Free where it's free, spend AI " +
139
147
  "credits only where they clearly win (a hero shot, a voice you can't fake locally). " +
148
+ 'If your own agent subscription includes image generation, use it first (`vidfarm agent-image "<subject>"`) — ' +
149
+ "it costs nothing extra and leaves the budget for the shots only Vidfarm can make. " +
140
150
  'Never GENERATE an icon, sticker, illustration or 3D prop — vidfarm iconscout "<meaning>" ' +
141
151
  "buys a designer's finished asset for a fraction of one AI attempt, with no prompt loop. " +
142
152
  "Billed ops run but each prints its cost so nothing is a surprise. Charges land on " +
@@ -171,6 +181,10 @@ export function costModeExplainer() {
171
181
  " frame-by-frame scene generation. Most cinematic, most expensive.",
172
182
  "Any spend is billed to YOUR own AI provider keys (BYOK) — add them with",
173
183
  " `vidfarm add-provider-key <provider> <key>` or at vidfarm.cc/settings/developer.",
184
+ "Even minimize can make custom art: if the AI agent you are talking to has image generation",
185
+ " included in its own subscription (Google Antigravity / Gemini CLI, OpenAI Codex / ChatGPT —",
186
+ " Claude Code does not), it generates the graphic itself for $0 extra. Ask it to run",
187
+ ' `vidfarm agent-image "<subject>" --items "a,b,c"`.',
174
188
  "Tip: before paying to generate music/SFX/images/video, try the free stock catalog —",
175
189
  ' vidfarm media search "<meaning>" --type bgm|sfx|image|vector|icon|video.',
176
190
  " For icons, STICKERS, illustrations, 3D props and Lottie, IconScout beats AI generation on price",
@@ -200,14 +214,20 @@ export class CostModeBlockedError extends Error {
200
214
  * nudge once so the default posture really is "ask before spending."
201
215
  */
202
216
  export function assertBilledAllowed(input) {
203
- const { resolved, yes, json, label, estimate, freeAlternative } = input;
217
+ const { resolved, yes, json, label, estimate, freeAlternative, agentAlternative } = input;
204
218
  const log = input.log ?? ((line) => console.error(line));
205
219
  const cost = estimate ? ` (${estimate})` : "";
206
220
  if (resolved.mode === "minimize" && !yes) {
221
+ const agentAlt = agentAlternative ? `\nDo it yourself for $0: ${agentAlternative}` : "";
207
222
  const alt = freeAlternative ? `\nFree alternative: ${freeAlternative}` : "";
208
223
  throw new CostModeBlockedError(`Blocked by cost mode "minimize": ${label}${cost} spends billed AI credits.\n` +
209
224
  `Confirm the spend with the user, then re-run with --yes (or switch modes: ` +
210
- `vidfarm cost-mode hybrid).${alt}`);
225
+ `vidfarm cost-mode hybrid).${agentAlt}${alt}`);
226
+ }
227
+ // Not blocked, but still worth saying: a capability the agent already pays for
228
+ // beats one Vidfarm bills for, in every mode below rich-ai.
229
+ if (!json && agentAlternative && (resolved.mode === "hybrid" || !resolved.isSet)) {
230
+ log(`[cost] Cheaper first: ${agentAlternative}`);
211
231
  }
212
232
  if (json)
213
233
  return;
@@ -8,6 +8,7 @@
8
8
  // unconditionally at session start.
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { existsSync, readdirSync } from "node:fs";
11
+ import { createRequire } from "node:module";
11
12
  import net from "node:net";
12
13
  import { homedir } from "node:os";
13
14
  import path from "node:path";
@@ -17,6 +18,7 @@ import { hasFfmpeg, resolveFfmpeg, resolveFfprobe } from "../services/clip-curat
17
18
  import { resolveHyperframesCli } from "./hyperframes-cli.js";
18
19
  import { applyVidfarmStudioBrand } from "./studio-brand.js";
19
20
  import { resolveSkillsRoot } from "./skills.js";
21
+ import { browserHarnessDoctorCheck } from "./browser-harness.js";
20
22
  import { readStoredAuth } from "./auth-store.js";
21
23
  import { scanLocalServers, reapProcesses } from "./process-scan.js";
22
24
  const GREEN = "\x1b[32m";
@@ -47,6 +49,24 @@ function isPortInUse(port) {
47
49
  server.listen(port, "127.0.0.1");
48
50
  });
49
51
  }
52
+ // Windows installs Chrome/Edge under one of three roots depending on whether it
53
+ // was a per-machine or per-user install. Without these the check ALWAYS reported
54
+ // "no Chrome" on Windows, and agents went off installing a browser the user
55
+ // already had. Edge is included because it is Chromium and always present.
56
+ function windowsChromeCandidates() {
57
+ const roots = [
58
+ process.env["PROGRAMFILES"],
59
+ process.env["PROGRAMFILES(X86)"],
60
+ process.env.LOCALAPPDATA
61
+ ].filter((value) => Boolean(value?.trim()));
62
+ const relative = [
63
+ ["Google", "Chrome", "Application", "chrome.exe"],
64
+ ["Google", "Chrome Beta", "Application", "chrome.exe"],
65
+ ["Chromium", "Application", "chrome.exe"],
66
+ ["Microsoft", "Edge", "Application", "msedge.exe"]
67
+ ];
68
+ return roots.flatMap((root) => relative.map((parts) => path.join(root, ...parts)));
69
+ }
50
70
  // Best-effort Chrome availability for the in-process HyperFrames render:
51
71
  // explicit env overrides, the puppeteer download cache, or a system Chrome.
52
72
  function detectChromeForRender() {
@@ -66,7 +86,9 @@ function detectChromeForRender() {
66
86
  }
67
87
  const systemCandidates = process.platform === "darwin"
68
88
  ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"]
69
- : ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
89
+ : process.platform === "win32"
90
+ ? windowsChromeCandidates()
91
+ : ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
70
92
  for (const candidate of systemCandidates) {
71
93
  if (existsSync(candidate))
72
94
  return { found: true, detail: candidate };
@@ -154,6 +176,11 @@ export async function runDoctorCommand(argv) {
154
176
  // 4. Chrome for the in-process render (stills / serve local render).
155
177
  const chrome = detectChromeForRender();
156
178
  add("chrome", chrome.found ? "ok" : "warn", chrome.found ? chrome.detail : `${chrome.detail} — local render/stills will try to download one on first run`);
179
+ // 4b. browser-harness — OPTIONAL, and the only free substitute for the paid
180
+ // search/download primitives. Reported even when absent because a free-plan
181
+ // user who doesn't know it exists gets a 402 instead of footage.
182
+ const browserHarness = browserHarnessDoctorCheck();
183
+ add("browser control", browserHarness.level, browserHarness.detail);
157
184
  // 5. Vidfarm cloud auth — --api-key / env, else the persisted `vidfarm login`
158
185
  // (auth-store), which is how most users authenticate. Without the store
159
186
  // lookup a logged-in user got a false "VIDFARM_API_KEY not set" warning.
@@ -175,9 +202,31 @@ export async function runDoctorCommand(argv) {
175
202
  add("provider keys", presentKeys.length > 0 ? "ok" : "warn", presentKeys.length > 0
176
203
  ? presentKeys.join(", ")
177
204
  : "none of OPENAI/GEMINI/OPENROUTER/ELEVENLABS_API_KEY set — BYOK speech/clip tagging unavailable (keyless local engines still work)");
178
- // 7. Local agent CLI (claude / codex) for clips scan etc.
205
+ // 7. Local agent CLI (claude / codex) for clips scan etc. On Windows the CLI
206
+ // is a .cmd shim and the scan passes a multi-line prompt as an argv, which a
207
+ // cmd.exe command line cannot carry — so report the limit rather than
208
+ // claiming a path that fails at use time.
179
209
  const agent = detectLocalAgent();
180
- add("agent CLI", agent ? "ok" : "warn", agent ? `${agent.kind} (${agent.bin})` : "no claude/codex CLI on PATH — `clips scan` falls back to provider keys");
210
+ if (agent && process.platform === "win32") {
211
+ add("agent CLI", "warn", `${agent.kind} found (${agent.bin}) but agent-driven \`clips scan\` is not supported on Windows yet — use provider keys (OPENAI/GEMINI_API_KEY)`);
212
+ }
213
+ else {
214
+ add("agent CLI", agent ? "ok" : "warn", agent ? `${agent.kind} (${agent.bin})` : "no claude/codex CLI on PATH — `clips scan` falls back to provider keys");
215
+ }
216
+ // 7b. The local clip library rides on the OPTIONAL native module
217
+ // better-sqlite3. It is optional precisely because a Windows box with no C++
218
+ // toolchain cannot build it — report that here instead of letting
219
+ // `vidfarm clips` be the thing that discovers it.
220
+ try {
221
+ createRequire(import.meta.url)("better-sqlite3");
222
+ add("clip library", "ok", "better-sqlite3 native module loaded (`vidfarm clips` available)");
223
+ }
224
+ catch {
225
+ add("clip library", "warn", "optional native module better-sqlite3 is not built — only `vidfarm clips` is affected; everything else works. " +
226
+ (process.platform === "win32"
227
+ ? "To enable it, install \"Desktop development with C++\" from the Visual Studio Installer, then reinstall the devcli."
228
+ : "To enable it, install a C++ toolchain (macOS: xcode-select --install; Linux: apt install build-essential python3), then reinstall the devcli."));
229
+ }
181
230
  // 8. Poisoned-env traps.
182
231
  if (process.env.AWS_PROFILE?.trim()) {
183
232
  add("env AWS_PROFILE", "warn", `AWS_PROFILE=${process.env.AWS_PROFILE} is set — it can hijack AWS SDK auth; run \`env -u AWS_PROFILE\` or unset it`);
@@ -74,11 +74,21 @@ export async function runHyperframesCommand(subcommand, args, opts = {}) {
74
74
  const [bin, baseArgs] = cli
75
75
  ? [process.execPath, [cli]]
76
76
  : ["npx", ["-y", "hyperframes"]];
77
+ // On Windows `npx` is `npx.cmd`, and Node >= 20.12 refuses to spawn a
78
+ // .cmd/.bat without a shell (it throws EINVAL, which surfaced here as the
79
+ // misleading "npx unavailable" error below). Route the FALLBACK through a
80
+ // shell there. The primary path spawns process.execPath directly and needs
81
+ // no shell on any platform, so this never touches the normal case.
82
+ const useShell = !cli && process.platform === "win32";
83
+ // A shell re-parses the command line, so quote anything with whitespace
84
+ // (project paths under "C:\Users\Some Name\..." are the common case).
85
+ const quote = (value) => (useShell && /[\s"&|<>^]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value);
77
86
  const capture = opts.stdio === "capture";
78
87
  return new Promise((resolvePromise, rejectPromise) => {
79
- const child = spawn(bin, [...baseArgs, subcommand, ...args], {
88
+ const child = spawn(bin, [...baseArgs, subcommand, ...args].map(quote), {
80
89
  cwd: opts.cwd,
81
90
  env,
91
+ shell: useShell,
82
92
  stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit"
83
93
  });
84
94
  let stdout = "";
@@ -19,6 +19,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
19
19
  import os from "node:os";
20
20
  import path from "node:path";
21
21
  import { prepareProjectMediaForRender } from "../lib/render-media-prep.js";
22
+ import { resolveBundledFfprobe } from "../lib/ffprobe-path.js";
22
23
  import { runHyperframesCommand } from "./hyperframes-cli.js";
23
24
  import { normalizeTikTokCaptionLayout } from "./composition-edit.js";
24
25
  // Same invariant as the backend's forceEvenCompositionDimensions: libx264
@@ -53,13 +54,9 @@ async function producerFfmpegEnv() {
53
54
  catch { /* PATH fallback */ }
54
55
  }
55
56
  if (!process.env.HYPERFRAMES_FFPROBE_PATH?.trim()) {
56
- try {
57
- const mod = (await import("ffprobe-static"));
58
- const resolved = (mod.path ?? mod.default?.path);
59
- if (typeof resolved === "string" && resolved && existsSync(resolved))
60
- env.HYPERFRAMES_FFPROBE_PATH = resolved;
61
- }
62
- catch { /* PATH fallback */ }
57
+ const resolved = resolveBundledFfprobe();
58
+ if (resolved)
59
+ env.HYPERFRAMES_FFPROBE_PATH = resolved;
63
60
  }
64
61
  return env;
65
62
  }