@mevdragon/vidfarm-devcli 0.17.0 → 0.18.1

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 (55) hide show
  1. package/.agents/skills/vidfarm-media/SKILL.md +43 -6
  2. package/SKILL.director.md +37 -23
  3. package/SKILL.platform.md +6 -6
  4. package/demo/dist/app.js +69 -69
  5. package/demo/dist/favicon.ico +0 -0
  6. package/dist/src/app.js +1832 -213
  7. package/dist/src/cli.js +238 -17
  8. package/dist/src/devcli/clip-store.js +29 -2
  9. package/dist/src/devcli/clips.js +116 -73
  10. package/dist/src/devcli/composition-edit.js +262 -0
  11. package/dist/src/devcli/timeline-edit.js +283 -0
  12. package/dist/src/editor-chat.js +29 -6
  13. package/dist/src/frontend/discover-client.js +130 -0
  14. package/dist/src/frontend/discover-store.js +23 -0
  15. package/dist/src/frontend/file-directory.js +744 -0
  16. package/dist/src/frontend/template-editor-chat.js +22 -19
  17. package/dist/src/landing-page.js +24 -7
  18. package/dist/src/page-shell.js +25 -1
  19. package/dist/src/reskin/agency-page.js +214 -170
  20. package/dist/src/reskin/calendar-page.js +503 -187
  21. package/dist/src/reskin/chat-page.js +739 -299
  22. package/dist/src/reskin/discover-page.js +1450 -279
  23. package/dist/src/reskin/document.js +1392 -16
  24. package/dist/src/reskin/help-page.js +139 -100
  25. package/dist/src/reskin/index-page.js +1 -1
  26. package/dist/src/reskin/inpaint-page.js +547 -0
  27. package/dist/src/reskin/job-runs-page.js +355 -127
  28. package/dist/src/reskin/library-page.js +1188 -317
  29. package/dist/src/reskin/login-page.js +124 -87
  30. package/dist/src/reskin/pricing-page.js +197 -166
  31. package/dist/src/reskin/settings-page.js +566 -187
  32. package/dist/src/reskin/theme.js +434 -17
  33. package/dist/src/services/clip-curation/gemini.js +5 -0
  34. package/dist/src/services/clip-curation/hunt.js +79 -1
  35. package/dist/src/services/clip-curation/index.js +2 -1
  36. package/dist/src/services/clip-curation/local-agent.js +4 -3
  37. package/dist/src/services/clip-curation/media-select.js +85 -0
  38. package/dist/src/services/clip-curation/query.js +5 -1
  39. package/dist/src/services/clip-curation/refine.js +50 -20
  40. package/dist/src/services/clip-curation/scan.js +10 -3
  41. package/dist/src/services/clip-curation/taxonomy.js +3 -1
  42. package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
  43. package/dist/src/services/clip-records.js +14 -1
  44. package/dist/src/services/clip-search.js +43 -13
  45. package/dist/src/services/file-directory.js +114 -0
  46. package/dist/src/services/storage.js +24 -1
  47. package/dist/src/services/upstream.js +5 -5
  48. package/dist/src/template-editor-shell.js +16 -2
  49. package/package.json +1 -1
  50. package/public/assets/discover-client-app.js +1 -0
  51. package/public/assets/file-directory-app.js +2 -0
  52. package/public/assets/homepage-client-app.js +12 -12
  53. package/public/assets/page-runtime-client-app.js +24 -24
  54. package/src/assets/favicon.ico +0 -0
  55. package/src/assets/logo-vidfarm.png +0 -0
@@ -12,11 +12,11 @@ import { homedir } from "node:os";
12
12
  import { parseArgs } from "node:util";
13
13
  import { createInterface } from "node:readline/promises";
14
14
  import { createIdV7 } from "../lib/ids.js";
15
- import { buildEffectiveGuidance, ClipModelClient, detectLocalAgent, detectScenes, estimateScanCostFromScenes, fitScenesToDurationBand, formatCostEstimate, hasFfmpeg, LocalAgentClipClient, normalizeAspect, normalizeCropFocus, normalizeWindows, parseClipHuntPrompt, parseTimeRanges, probeVideo, resolveDurationBand, scanVideo, searchClips } from "../services/clip-curation/index.js";
15
+ import { buildEffectiveGuidance, ClipModelClient, DEFAULT_CLIP_ASPECT, DEFAULT_CLIP_DURATION_BAND, detectLocalAgent, detectScenes, effectiveHuntDurationSec, estimateScanCostFromScenes, fitScenesToDurationBand, formatCostEstimate, hasFfmpeg, LocalAgentClipClient, normalizeAspect, normalizeCropFocus, normalizeWindows, parseClipHuntPrompt, parseTimeRanges, probeVideo, resolveDurationBand, resolveTargetClipCount, scanVideo, searchClips } from "../services/clip-curation/index.js";
16
16
  import { ClipStore } from "./clip-store.js";
17
- export const CLIPS_HELP = `vidfarm clips — build & search a local clip library (local ffmpeg + local agent by default)
17
+ export const CLIPS_HELP = `vidfarm raws — build & search a local raws library (local ffmpeg + local agent by default)
18
18
 
19
- clips scan <video-path> Mine a long-form video into tagged, searchable clips
19
+ raws scan <video-path> Mine a long-form video into tagged, searchable raws
20
20
  --provider <p> agent | gemini | openai | openrouter
21
21
  Default: agent when a local claude/codex CLI is installed
22
22
  (your subscription, no API key), else gemini.
@@ -27,8 +27,8 @@ export const CLIPS_HELP = `vidfarm clips — build & search a local clip library
27
27
  Inline hints are honored too: "between 12:30 and 15:45",
28
28
  "30 sec clips", "vertical", "no captions".
29
29
  --range "MM:SS-MM:SS" Only hunt inside this source range (repeatable / comma-separated)
30
- --duration <sec> Target clip length as a SOFT band (10→5-20s, 30→20-40s, …)
31
- --aspect <a> Crop clips to 9:16 | 16:9 | 4:3 | 1:1 (or vertical/horizontal/square)
30
+ --duration <sec> Target raw length as a SOFT band (10→5-20s, 30→20-40s, …)
31
+ --aspect <a> Crop raws to 9:16 | 16:9 | 4:3 | 1:1 (or vertical/horizontal/square)
32
32
  --crop-focus <f> center | top | bottom | left | right (default: center)
33
33
  --no-text Prefer scenes WITHOUT burned-in captions/on-screen text
34
34
  (scene selection — NEVER GhostCut on the long-form source)
@@ -52,25 +52,30 @@ export const CLIPS_HELP = `vidfarm clips — build & search a local clip library
52
52
  --embed-provider gemini|openai --embed-key <key> Embeddings for agent/openrouter scans
53
53
  --home <dir> Library dir (default: ~/.vidfarm or VIDFARM_HOME)
54
54
 
55
- clips list List clips in the library
56
- --source <id> Only clips from one source video
55
+ raws list List raws in the library
56
+ --source <id> Only raws from one source video
57
+ --content-type <a,b> Only raws whose shot-KIND tag matches (comma-separated,
58
+ exact): talking_head, b_roll, product_shot, screen_recording,
59
+ demo, reaction, interview, establishing, lifestyle, text_graphic
60
+ --offset <n> Skip the first N (paginate with --limit)
57
61
  --limit <n> | --json
58
62
 
59
- clips search "<natural language>" Hybrid structured + semantic search
63
+ raws search "<natural language>" Hybrid structured + semantic search
64
+ --content-type <a,b> Post-filter hits by shot-KIND tag (see \`raws list\`)
60
65
  --limit <n> | --json | --gemini-key <key>
61
66
 
62
- clips match "<text>" Top library clips to REPLACE a scene (vector search)
67
+ raws match "<text>" Top library raws to REPLACE a scene (vector search)
63
68
  --criteria '<json>' Match a structured SplicingCriteria instead of text
64
69
  --scenes <annotations.json> Batch: match every scene of a decomposed video
65
70
  (array of scene annotations, or {annotations|scenes:[…]})
66
71
  --limit <n> | --json
67
72
 
68
- clips preset list List built-in + saved presets
69
- clips preset run <name> Run a preset query [--limit <n>] [--json]
70
- clips preset save <name> --from-query '<json | natural language>'
73
+ raws preset list List built-in + saved presets
74
+ raws preset run <name> Run a preset query [--limit <n>] [--json]
75
+ raws preset save <name> --from-query '<json | natural language>'
71
76
 
72
- clips sources List scanned source videos
73
- clips export <clip-ids...> --to <dir> Copy clip mp4s (+ metadata.json) out
77
+ raws sources List scanned source videos
78
+ raws export <raw-ids...> --to <dir> Copy raw mp4s (+ metadata.json) out
74
79
  `;
75
80
  export async function runClipsCommand(argv) {
76
81
  const sub = argv[0];
@@ -96,7 +101,7 @@ export async function runClipsCommand(argv) {
96
101
  case "-h":
97
102
  return runOverview();
98
103
  default:
99
- console.error(`Unknown clips subcommand: ${sub}\n`);
104
+ console.error(`Unknown raws subcommand: ${sub}\n`);
100
105
  console.log(CLIPS_HELP);
101
106
  process.exitCode = 1;
102
107
  }
@@ -106,8 +111,8 @@ function runOverview() {
106
111
  const store = new ClipStore();
107
112
  const stats = store.stats();
108
113
  store.close();
109
- console.log(`vidfarm clips — library at ${resolveHome()}`);
110
- console.log(` ${stats.clips} clips · ${stats.sources} source videos · ${stats.presets} presets\n`);
114
+ console.log(`vidfarm raws — library at ${resolveHome()}`);
115
+ console.log(` ${stats.clips} raws · ${stats.sources} source videos · ${stats.presets} presets\n`);
111
116
  console.log(CLIPS_HELP);
112
117
  }
113
118
  // ── clips scan ──────────────────────────────────────────────────────────────
@@ -125,6 +130,8 @@ async function runScan(argv) {
125
130
  aspect: { type: "string" },
126
131
  "crop-focus": { type: "string" },
127
132
  "no-text": { type: "boolean" },
133
+ "allow-text": { type: "boolean" },
134
+ "max-clips": { type: "string" },
128
135
  "no-refine": { type: "boolean" },
129
136
  cloud: { type: "boolean" },
130
137
  url: { type: "string" },
@@ -153,16 +160,22 @@ async function runScan(argv) {
153
160
  const rawWindows = values.range?.length
154
161
  ? parseTimeRanges(values.range)
155
162
  : parsedPrompt.windows;
156
- const durationBand = values.duration
163
+ // Explicit flags / prompt hints win; otherwise fall back to the same
164
+ // unspecified-prompt product defaults (5–15s clips, vertical 9:16, avoid text,
165
+ // ~10 clips / 10 min). Opt out with `--aspect original` / `--allow-text`.
166
+ const explicitBand = values.duration
157
167
  ? resolveDurationBand(Number(values.duration))
158
168
  : parsedPrompt.target_duration_sec != null
159
169
  ? resolveDurationBand(parsedPrompt.target_duration_sec)
160
170
  : null;
161
- const aspect = values.aspect
162
- ? (normalizeAspect(values.aspect) ?? (() => { throw new Error(`--aspect must be 9:16 | 16:9 | 4:3 | 1:1 (or vertical/horizontal/square), got "${values.aspect}"`); })())
171
+ const durationBand = explicitBand ?? { ...DEFAULT_CLIP_DURATION_BAND };
172
+ const explicitAspect = values.aspect
173
+ ? (normalizeAspect(values.aspect) ?? (() => { throw new Error(`--aspect must be 9:16 | 16:9 | 4:3 | 1:1 | original (or vertical/horizontal/square), got "${values.aspect}"`); })())
163
174
  : parsedPrompt.aspect;
175
+ const aspect = explicitAspect ?? DEFAULT_CLIP_ASPECT;
164
176
  const cropFocus = normalizeCropFocus(values["crop-focus"]);
165
- const avoidText = Boolean(values["no-text"]) || parsedPrompt.avoid_text;
177
+ const avoidText = values["allow-text"] ? false : (Boolean(values["no-text"]) || parsedPrompt.avoid_text || true);
178
+ const maxClips = values["max-clips"] ? Math.max(1, Math.round(Number(values["max-clips"]))) : parsedPrompt.max_clips ?? undefined;
166
179
  const guidance = buildEffectiveGuidance({ contentPrompt: guidancePromptRaw, avoidText }) || undefined;
167
180
  // ── BACKUP path: run the hunt on the deployed pipeline (--cloud) ──────────
168
181
  if (values.cloud) {
@@ -181,17 +194,18 @@ async function runScan(argv) {
181
194
  durationBand,
182
195
  aspect,
183
196
  cropFocus,
184
- avoidText
197
+ avoidText,
198
+ maxClips
185
199
  });
186
200
  }
187
201
  const videoArg = positionals[0];
188
202
  if (!videoArg)
189
- throw new Error("Usage: vidfarm clips scan <video-path> [--range MM:SS-MM:SS] [--duration 30] [--aspect 9:16] [--dry-run]");
203
+ throw new Error("Usage: vidfarm raws scan <video-path> [--range MM:SS-MM:SS] [--duration 30] [--aspect 9:16] [--dry-run]");
190
204
  const videoPath = path.resolve(process.cwd(), videoArg);
191
205
  if (!existsSync(videoPath))
192
206
  throw new Error(`No such video: ${videoPath}`);
193
207
  if (!(await hasFfmpeg())) {
194
- throw new Error("ffmpeg is required for `clips scan` but was not found (bundled ffmpeg-static missing and none on PATH).");
208
+ throw new Error("ffmpeg is required for `raws scan` but was not found (bundled ffmpeg-static missing and none on PATH).");
195
209
  }
196
210
  // ── Model resolution: LOCAL-FIRST ─────────────────────────────────────────
197
211
  // 1. --provider agent (or no --provider + a local claude/codex CLI installed)
@@ -217,24 +231,27 @@ async function runScan(argv) {
217
231
  const embedding = provider === "agent"
218
232
  ? resolveAgentEmbeddingConfig(values)
219
233
  : apiKey ? resolveEmbeddingConfig(provider, apiKey, values) : null;
220
- console.log(`[clips] probing ${path.basename(videoPath)} …`);
234
+ console.log(`[raws] probing ${path.basename(videoPath)} …`);
221
235
  const probe = await probeVideo(videoPath);
222
- console.log(`[clips] ${fmtDuration(probe.duration_sec)} · ${probe.width ?? "?"}x${probe.height ?? "?"} · ${probe.codec ?? "?"}`);
236
+ console.log(`[raws] ${fmtDuration(probe.duration_sec)} · ${probe.width ?? "?"}x${probe.height ?? "?"} · ${probe.codec ?? "?"}`);
223
237
  const windows = normalizeWindows(rawWindows, probe.duration_sec);
224
238
  if (rawWindows.length && !windows.length) {
225
239
  throw new Error("--range produced no usable window inside the video's duration.");
226
240
  }
241
+ // Density/uniqueness target: ~10 clips / 10 min unless --max-clips / "N clips".
242
+ const targetClipCount = resolveTargetClipCount(effectiveHuntDurationSec(probe.duration_sec, windows), { max_clips: maxClips });
227
243
  if (windows.length) {
228
- console.log(`[clips] hunting only ${windows.map((w) => `${fmtClock(w.start_sec)}–${fmtClock(w.end_sec)}`).join(", ")} (${fmtDuration(windows.reduce((a, w) => a + w.end_sec - w.start_sec, 0))} of ${fmtDuration(probe.duration_sec)}).`);
244
+ console.log(`[raws] hunting only ${windows.map((w) => `${fmtClock(w.start_sec)}–${fmtClock(w.end_sec)}`).join(", ")} (${fmtDuration(windows.reduce((a, w) => a + w.end_sec - w.start_sec, 0))} of ${fmtDuration(probe.duration_sec)}).`);
229
245
  }
230
246
  if (durationBand) {
231
- console.log(`[clips] target clip length ~${durationBand.target_sec}s (soft range ${durationBand.min_sec}–${durationBand.max_sec}s).`);
247
+ console.log(`[raws] target clip length ~${durationBand.target_sec}s (soft range ${durationBand.min_sec}–${durationBand.max_sec}s).`);
232
248
  }
233
249
  if (aspect && aspect !== "original")
234
- console.log(`[clips] cropping clips to ${aspect} (${cropFocus}).`);
250
+ console.log(`[raws] cropping clips to ${aspect} (${cropFocus}).`);
235
251
  if (avoidText)
236
- console.log(`[clips] preferring scenes without on-screen text/captions (selection only — GhostCut is never run on the source).`);
237
- console.log(`[clips] detecting scenes …`);
252
+ console.log(`[raws] preferring scenes without on-screen text/captions (selection only — GhostCut is never run on the source).`);
253
+ console.log(`[raws] aiming for ~${targetClipCount} distinct raw${targetClipCount === 1 ? "" : "s"}${maxClips ? " (--max-clips)" : " (~10 / 10 min)"}.`);
254
+ console.log(`[raws] detecting scenes …`);
238
255
  const detected = await detectScenes(videoPath, { durationSec: probe.duration_sec, windows, ...sceneOptions });
239
256
  const scenes = fitScenesToDurationBand(detected, durationBand);
240
257
  if (scenes.length === 0)
@@ -247,13 +264,13 @@ async function runScan(argv) {
247
264
  embedding,
248
265
  tier
249
266
  });
250
- console.log(`[clips] evaluating scenes with your local ${client.tagModelId} subscription (no API cost).`);
267
+ console.log(`[raws] evaluating scenes with your local ${client.tagModelId} subscription (no API cost).`);
251
268
  }
252
269
  else if (apiKey) {
253
270
  client = new ClipModelClient({ provider, apiKey, tier, embedding });
254
271
  }
255
272
  if (client && !client.canEmbed) {
256
- console.warn(`[clips] no embedding provider for "${provider}" — clips will be structured-searchable only.`);
273
+ console.warn(`[raws] no embedding provider for "${provider}" — raws will be structured-searchable only.`);
257
274
  console.warn(` Add --embed-provider gemini|openai --embed-key <key> for semantic search.`);
258
275
  }
259
276
  // Cost estimate: local-agent scans ride the user's subscription, so only the
@@ -279,7 +296,7 @@ async function runScan(argv) {
279
296
  console.log(` Guidance: "${guidance.replace(/\n/g, " · ")}"`);
280
297
  console.log("");
281
298
  if (values["dry-run"]) {
282
- console.log("[clips] --dry-run: not scanning. Re-run without --dry-run to build the library.");
299
+ console.log("[raws] --dry-run: not scanning. Re-run without --dry-run to build the library.");
283
300
  return;
284
301
  }
285
302
  // Actually scanning now — an evaluator is required.
@@ -293,7 +310,7 @@ async function runScan(argv) {
293
310
  if (estimate && estimate.usd > 1 && !values.yes) {
294
311
  const ok = await confirm(`This scan is estimated at $${estimate.usd.toFixed(2)}. Proceed? [y/N] `);
295
312
  if (!ok) {
296
- console.log("[clips] aborted.");
313
+ console.log("[raws] aborted.");
297
314
  return;
298
315
  }
299
316
  }
@@ -301,7 +318,7 @@ async function runScan(argv) {
301
318
  if (activeClient.embeddingModelId) {
302
319
  const existing = store.getLibraryEmbeddingModel();
303
320
  if (existing && existing !== activeClient.embeddingModelId) {
304
- console.warn(`[clips] WARNING: library was built with "${existing}" embeddings but this scan uses "${activeClient.embeddingModelId}". Semantic search across them will be unreliable.`);
321
+ console.warn(`[raws] WARNING: library was built with "${existing}" embeddings but this scan uses "${activeClient.embeddingModelId}". Semantic search across them will be unreliable.`);
305
322
  }
306
323
  else {
307
324
  store.rememberEmbeddingModel(activeClient.embeddingModelId);
@@ -319,7 +336,7 @@ async function runScan(argv) {
319
336
  reencodeClips: true,
320
337
  crop: aspect && aspect !== "original" ? { aspect, focus: cropFocus } : undefined
321
338
  };
322
- console.log(`[clips] scanning ${scenes.length} scenes (${provider}/${activeClient.tagModelId}, ${concurrency}x) …`);
339
+ console.log(`[raws] scanning ${scenes.length} scenes (${provider}/${activeClient.tagModelId}, ${concurrency}x) …`);
323
340
  let errors = 0;
324
341
  const started = timestamp();
325
342
  const result = await scanVideo({
@@ -329,14 +346,15 @@ async function runScan(argv) {
329
346
  probe,
330
347
  windows,
331
348
  durationBand,
349
+ targetClipCount,
332
350
  sourceFilename: path.basename(videoPath),
333
351
  concurrency,
334
352
  onRefine: (r) => {
335
353
  if (r.refined) {
336
- console.log(`[clips] guided segmentation: ${scenes.length} → ${r.kept} clips (dropped ${r.dropped}, merged ${r.merged}).`);
354
+ console.log(`[raws] guided segmentation: ${scenes.length} → ${r.kept} raws (dropped ${r.dropped}, merged ${r.merged}).`);
337
355
  }
338
356
  else if (r.note) {
339
- console.log(`[clips] guided segmentation skipped: ${r.note}.`);
357
+ console.log(`[raws] guided segmentation skipped: ${r.note}.`);
340
358
  }
341
359
  },
342
360
  onClip: (clip, i, total) => {
@@ -365,10 +383,10 @@ async function runScan(argv) {
365
383
  /* ignore */
366
384
  }
367
385
  store.close();
368
- console.log(`\n[clips] done in ${elapsed(started)}: ${result.clips.length} clips${errors ? `, ${errors} scene(s) failed` : ""}.`);
369
- console.log(` clips → ${store.paths.clipsDir}`);
370
- console.log(` db → ${store.paths.db}`);
371
- console.log(` try: vidfarm clips search "…" | vidfarm clips preset run "funny reaction gifs"`);
386
+ console.log(`\n[raws] done in ${elapsed(started)}: ${result.clips.length} raws${errors ? `, ${errors} scene(s) failed` : ""}.`);
387
+ console.log(` raws → ${store.paths.clipsDir}`);
388
+ console.log(` db → ${store.paths.db}`);
389
+ console.log(` try: vidfarm raws search "…" | vidfarm raws preset run "funny reaction gifs"`);
372
390
  }
373
391
  // ── clips scan --cloud (explicit backup: the deployed pipeline) ─────────────
374
392
  // Uploads the local file into the user's temp folder (30-day TTL, auto-removed)
@@ -384,13 +402,13 @@ async function runScanCloud(input) {
384
402
  let fileName;
385
403
  if (!input.sourceUrl) {
386
404
  if (!input.videoArg)
387
- throw new Error("Usage: vidfarm clips scan --cloud <video-path> | --cloud --url <video-url>");
405
+ throw new Error("Usage: vidfarm raws scan --cloud <video-path> | --cloud --url <video-url>");
388
406
  const videoPath = path.resolve(process.cwd(), input.videoArg);
389
407
  if (!existsSync(videoPath))
390
408
  throw new Error(`No such video: ${videoPath}`);
391
409
  fileName = path.basename(videoPath);
392
410
  const sizeBytes = statSync(videoPath).size;
393
- console.log(`[clips] uploading ${fileName} (${(sizeBytes / (1024 * 1024)).toFixed(1)} MB) to your temp folder (auto-deletes in 30 days) …`);
411
+ console.log(`[raws] uploading ${fileName} (${(sizeBytes / (1024 * 1024)).toFixed(1)} MB) to your temp folder (auto-deletes in 30 days) …`);
394
412
  const presignRes = await fetch(`${baseUrl}/api/v1/user/me/temporary-files/presign`, {
395
413
  method: "POST",
396
414
  headers,
@@ -428,8 +446,8 @@ async function runScanCloud(input) {
428
446
  }
429
447
  tempFileId = presign.file_id;
430
448
  }
431
- console.log(`[clips] starting cloud hunt …`);
432
- const scanRes = await fetch(`${baseUrl}/clips/scan`, {
449
+ console.log(`[raws] starting cloud hunt …`);
450
+ const scanRes = await fetch(`${baseUrl}/raws/scan`, {
433
451
  method: "POST",
434
452
  headers,
435
453
  body: JSON.stringify({
@@ -442,7 +460,10 @@ async function runScanCloud(input) {
442
460
  ...(input.durationBand ? { duration_band: input.durationBand } : {}),
443
461
  ...(input.aspect ? { aspect: input.aspect } : {}),
444
462
  crop_focus: input.cropFocus,
445
- ...(input.avoidText ? { avoid_text: true } : {})
463
+ // Send explicitly (incl. false) so --allow-text can override the
464
+ // server-side avoid-text default.
465
+ avoid_text: input.avoidText,
466
+ ...(input.maxClips ? { max_clips: input.maxClips } : {})
446
467
  }
447
468
  })
448
469
  });
@@ -450,28 +471,28 @@ async function runScanCloud(input) {
450
471
  if (!scanRes.ok || !scan.scan_id) {
451
472
  throw new Error(`Cloud scan failed to start (${scanRes.status}): ${scan.error ?? "unexpected response"}`);
452
473
  }
453
- console.log(`[clips] scan ${scan.scan_id} ${scan.status ?? "queued"}${scan.tracer ? ` (tracer ${scan.tracer})` : ""}.`);
474
+ console.log(`[raws] scan ${scan.scan_id} ${scan.status ?? "queued"}${scan.tracer ? ` (tracer ${scan.tracer})` : ""}.`);
454
475
  if (scan.compute_estimate?.estimated_charge_usd != null) {
455
- console.log(`[clips] estimated AWS compute charge ~$${scan.compute_estimate.estimated_charge_usd.toFixed(4)} (AI runs on your saved provider keys).`);
476
+ console.log(`[raws] estimated AWS compute charge ~$${scan.compute_estimate.estimated_charge_usd.toFixed(4)} (AI runs on your saved provider keys).`);
456
477
  }
457
478
  // Async by design — poll until the pipeline finishes.
458
479
  const started = timestamp();
459
480
  for (;;) {
460
481
  await new Promise((r) => setTimeout(r, 5000));
461
- const pollRes = await fetch(`${baseUrl}/clips/scan/${encodeURIComponent(scan.scan_id)}`, { headers });
482
+ const pollRes = await fetch(`${baseUrl}/raws/scan/${encodeURIComponent(scan.scan_id)}`, { headers });
462
483
  if (!pollRes.ok)
463
484
  continue;
464
485
  const poll = await pollRes.json();
465
486
  const status = poll.source?.status ?? poll.scan?.status ?? "running";
466
487
  if (status === "complete") {
467
- console.log(`\n[clips] cloud hunt done in ${elapsed(started)}: ${poll.source?.clip_count ?? 0} clips.`);
468
- console.log(` browse → ${baseUrl}/clips`);
488
+ console.log(`\n[raws] cloud hunt done in ${elapsed(started)}: ${poll.source?.clip_count ?? 0} raws.`);
489
+ console.log(` browse → ${baseUrl}/library/raws`);
469
490
  return;
470
491
  }
471
492
  if (status === "failed") {
472
493
  throw new Error(`Cloud hunt failed: ${poll.scan?.error ?? "unknown error"}`);
473
494
  }
474
- process.stdout.write(`\r[clips] ${status} … ${elapsed(started)} `);
495
+ process.stdout.write(`\r[raws] ${status} … ${elapsed(started)} `);
475
496
  }
476
497
  }
477
498
  /**
@@ -510,26 +531,33 @@ async function runList(argv) {
510
531
  args: argv,
511
532
  options: {
512
533
  source: { type: "string" },
534
+ "content-type": { type: "string" },
535
+ offset: { type: "string" },
513
536
  limit: { type: "string" },
514
537
  json: { type: "boolean" },
515
538
  home: { type: "string" }
516
539
  }
517
540
  });
518
541
  const store = new ClipStore(values.home);
519
- const clips = store.listClips({ sourceVideoId: values.source, limit: clampInt(values.limit, 50, 1, 5000) });
542
+ const clips = store.listClips({
543
+ sourceVideoId: values.source,
544
+ contentTypes: parseContentTypes(values["content-type"]),
545
+ offset: values.offset ? clampInt(values.offset, 0, 0, 1_000_000) : undefined,
546
+ limit: clampInt(values.limit, 50, 1, 5000)
547
+ });
520
548
  store.close();
521
549
  if (values.json) {
522
550
  console.log(JSON.stringify(clips, null, 2));
523
551
  return;
524
552
  }
525
553
  if (clips.length === 0) {
526
- console.log("No clips yet. Run: vidfarm clips scan <video-path>");
554
+ console.log("No raws yet. Run: vidfarm raws scan <video-path>");
527
555
  return;
528
556
  }
529
557
  for (const clip of clips) {
530
558
  console.log(`${clip.clip_id.slice(0, 14)} ${fmtRange(clip)} ${clip.source_filename} ${summarizeTags(clip)}`);
531
559
  }
532
- console.log(`\n${clips.length} clips.`);
560
+ console.log(`\n${clips.length} raws.`);
533
561
  }
534
562
  // ── clips search ────────────────────────────────────────────────────────────
535
563
  async function runSearch(argv) {
@@ -538,6 +566,7 @@ async function runSearch(argv) {
538
566
  allowPositionals: true,
539
567
  options: {
540
568
  provider: { type: "string" },
569
+ "content-type": { type: "string" },
541
570
  limit: { type: "string" },
542
571
  json: { type: "boolean" },
543
572
  "gemini-key": { type: "string" },
@@ -548,12 +577,13 @@ async function runSearch(argv) {
548
577
  });
549
578
  const query = positionals.join(" ").trim();
550
579
  if (!query)
551
- throw new Error('Usage: vidfarm clips search "<natural language>"');
580
+ throw new Error('Usage: vidfarm raws search "<natural language>"');
581
+ const contentTypes = parseContentTypes(values["content-type"]);
552
582
  const limit = clampInt(values.limit, 10, 1, 200);
553
583
  const store = new ClipStore(values.home);
554
584
  if (store.stats().clips === 0) {
555
585
  store.close();
556
- console.log("Library is empty — scan a video first: vidfarm clips scan <video-path>");
586
+ console.log("Library is empty — scan a video first: vidfarm raws scan <video-path>");
557
587
  return;
558
588
  }
559
589
  const provider = normalizeProvider(values.provider);
@@ -566,7 +596,7 @@ async function runSearch(argv) {
566
596
  if (client.canEmbed) {
567
597
  const libModel = store.getLibraryEmbeddingModel();
568
598
  if (libModel && client.embeddingModelId && libModel !== client.embeddingModelId) {
569
- console.warn(`[clips] note: library uses "${libModel}" embeddings but you're searching with "${client.embeddingModelId}" — semantic re-rank may be off; pass --provider to match.`);
599
+ console.warn(`[raws] note: library uses "${libModel}" embeddings but you're searching with "${client.embeddingModelId}" — semantic re-rank may be off; pass --provider to match.`);
570
600
  }
571
601
  queryEmbedding = await client.embedQuery(criteria.semantic_text ?? query);
572
602
  }
@@ -575,14 +605,20 @@ async function runSearch(argv) {
575
605
  // Local-first: no key, but a local agent CLI can still turn the natural-
576
606
  // language query into a structured filter (vector re-rank needs a key).
577
607
  const agent = new LocalAgentClipClient({});
578
- console.log(`[clips] structuring query with your local ${agent.tagModelId} …`);
608
+ console.log(`[raws] structuring query with your local ${agent.tagModelId} …`);
579
609
  criteria = await agent.naturalLanguageToCriteria(query);
580
610
  }
581
611
  else {
582
- console.warn(`[clips] no ${provider} key or local agent CLI — structured/keyword search only.`);
612
+ console.warn(`[raws] no ${provider} key or local agent CLI — structured/keyword search only.`);
583
613
  }
584
614
  const t0 = timestamp();
585
- const hits = store.search({ criteria, queryEmbedding, limit });
615
+ // Over-fetch when a content-type filter is on so the post-filter still fills
616
+ // the requested limit (the tag lives in tags_json, not the search index).
617
+ const searchLimit = contentTypes.length ? Math.min(1000, limit * 5) : limit;
618
+ let hits = store.search({ criteria, queryEmbedding, limit: searchLimit });
619
+ if (contentTypes.length) {
620
+ hits = hits.filter((h) => (h.clip.tags.content_type ?? []).some((t) => contentTypes.includes(String(t).toLowerCase()))).slice(0, limit);
621
+ }
586
622
  const ms = elapsedMs(t0);
587
623
  store.close();
588
624
  if (values.json) {
@@ -627,7 +663,7 @@ async function runMatch(argv) {
627
663
  const store = new ClipStore(values.home);
628
664
  if (store.stats().clips === 0) {
629
665
  store.close();
630
- console.log("Library is empty — scan a video first: vidfarm clips scan <video-path>");
666
+ console.log("Library is empty — scan a video first: vidfarm raws scan <video-path>");
631
667
  return;
632
668
  }
633
669
  // Embed the match query in the SAME space the library was built with.
@@ -636,7 +672,7 @@ async function runMatch(argv) {
636
672
  const embedKey = resolveProviderKey(embedProvider, values, { optional: true });
637
673
  const client = embedKey ? new ClipModelClient({ provider: embedProvider, apiKey: embedKey }) : null;
638
674
  if (!client)
639
- console.warn(`[clips] no ${embedProvider} key — structured match only (set the key for vector re-rank).`);
675
+ console.warn(`[raws] no ${embedProvider} key — structured match only (set the key for vector re-rank).`);
640
676
  if (values.scenes) {
641
677
  const filePath = path.resolve(process.cwd(), values.scenes);
642
678
  if (!existsSync(filePath))
@@ -670,7 +706,7 @@ async function runMatch(argv) {
670
706
  console.log(JSON.stringify(output, null, 2));
671
707
  return;
672
708
  }
673
- console.log(`Matched ${scenes.length} scenes against ${all.length} clips:`);
709
+ console.log(`Matched ${scenes.length} scenes against ${all.length} raws:`);
674
710
  for (const o of output) {
675
711
  const rep = o.replaceability ? ` [replaceability: ${o.replaceability}]` : "";
676
712
  const dur = typeof o.duration === "number" ? ` (${o.duration.toFixed(1)}s)` : "";
@@ -699,7 +735,7 @@ async function runMatch(argv) {
699
735
  text = positionals.join(" ").trim();
700
736
  if (!text) {
701
737
  store.close();
702
- throw new Error('Usage: vidfarm clips match "<text>" | --criteria \'<json>\' | --scenes <annotations.json>');
738
+ throw new Error('Usage: vidfarm raws match "<text>" | --criteria \'<json>\' | --scenes <annotations.json>');
703
739
  }
704
740
  criteria = { semantic_text: text };
705
741
  }
@@ -724,7 +760,7 @@ async function runPreset(argv) {
724
760
  return runPresetRun(rest);
725
761
  if (action === "save")
726
762
  return runPresetSave(rest);
727
- throw new Error("Usage: vidfarm clips preset <list|run <name>|save <name> --from-query '<...>'>");
763
+ throw new Error("Usage: vidfarm raws preset <list|run <name>|save <name> --from-query '<...>'>");
728
764
  }
729
765
  async function runPresetList(argv) {
730
766
  const { values } = parseArgs({ args: argv, options: { json: { type: "boolean" }, home: { type: "string" } } });
@@ -756,12 +792,12 @@ async function runPresetRun(argv) {
756
792
  });
757
793
  const name = positionals.join(" ").trim();
758
794
  if (!name)
759
- throw new Error("Usage: vidfarm clips preset run <name>");
795
+ throw new Error("Usage: vidfarm raws preset run <name>");
760
796
  const store = new ClipStore(values.home);
761
797
  const preset = store.findPreset(name);
762
798
  if (!preset) {
763
799
  store.close();
764
- throw new Error(`No preset "${name}". Try: vidfarm clips preset list`);
800
+ throw new Error(`No preset "${name}". Try: vidfarm raws preset list`);
765
801
  }
766
802
  const limit = clampInt(values.limit, 10, 1, 200);
767
803
  // Presets carry semantic_text; embed it if we have a key for the re-rank.
@@ -800,7 +836,7 @@ async function runPresetSave(argv) {
800
836
  const name = positionals.join(" ").trim();
801
837
  const fromQuery = values["from-query"];
802
838
  if (!name || !fromQuery) {
803
- throw new Error("Usage: vidfarm clips preset save <name> --from-query '<json | natural language>'");
839
+ throw new Error("Usage: vidfarm raws preset save <name> --from-query '<json | natural language>'");
804
840
  }
805
841
  let criteria;
806
842
  const asJson = tryParseJson(fromQuery);
@@ -857,7 +893,7 @@ async function runExport(argv) {
857
893
  const ids = positionals;
858
894
  const to = values.to;
859
895
  if (ids.length === 0 || !to) {
860
- throw new Error("Usage: vidfarm clips export <clip-ids...> --to <dir>");
896
+ throw new Error("Usage: vidfarm raws export <raw-ids...> --to <dir>");
861
897
  }
862
898
  const outDir = path.resolve(process.cwd(), to);
863
899
  mkdirSync(outDir, { recursive: true });
@@ -885,7 +921,7 @@ async function runExport(argv) {
885
921
  store.close();
886
922
  const { writeFileSync } = await import("node:fs");
887
923
  writeFileSync(path.join(outDir, "clips.metadata.json"), JSON.stringify(exported, null, 2));
888
- console.log(`\nExported ${exported.length} clips + clips.metadata.json → ${outDir}`);
924
+ console.log(`\nExported ${exported.length} raws + clips.metadata.json → ${outDir}`);
889
925
  }
890
926
  // ── helpers ─────────────────────────────────────────────────────────────────
891
927
  function normalizeProvider(value) {
@@ -895,6 +931,13 @@ function normalizeProvider(value) {
895
931
  return value;
896
932
  throw new Error(`--provider must be gemini | openai | openrouter (got "${value}")`);
897
933
  }
934
+ // Parse a comma-separated --content-type flag into lowercase shot-KIND tags for
935
+ // the tags.content_type filter (talking_head, b_roll, product_shot, …).
936
+ function parseContentTypes(value) {
937
+ if (!value)
938
+ return [];
939
+ return value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
940
+ }
898
941
  function normalizeTier(value) {
899
942
  if (value === "flash")
900
943
  return "flash";