@mevdragon/vidfarm-devcli 0.18.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 (53) hide show
  1. package/.agents/skills/vidfarm-media/SKILL.md +41 -4
  2. package/SKILL.director.md +17 -3
  3. package/SKILL.platform.md +3 -3
  4. package/demo/dist/app.js +69 -69
  5. package/demo/dist/favicon.ico +0 -0
  6. package/dist/src/app.js +1550 -187
  7. package/dist/src/cli.js +227 -7
  8. package/dist/src/devcli/clip-store.js +29 -2
  9. package/dist/src/devcli/clips.js +52 -9
  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 +1 -1
  20. package/dist/src/reskin/calendar-page.js +2 -1
  21. package/dist/src/reskin/chat-page.js +319 -72
  22. package/dist/src/reskin/discover-page.js +486 -31
  23. package/dist/src/reskin/document.js +1171 -384
  24. package/dist/src/reskin/help-page.js +1 -0
  25. package/dist/src/reskin/inpaint-page.js +10 -4
  26. package/dist/src/reskin/library-page.js +918 -220
  27. package/dist/src/reskin/login-page.js +6 -6
  28. package/dist/src/reskin/pricing-page.js +2 -0
  29. package/dist/src/reskin/settings-page.js +55 -10
  30. package/dist/src/reskin/theme.js +337 -17
  31. package/dist/src/services/clip-curation/gemini.js +5 -0
  32. package/dist/src/services/clip-curation/hunt.js +79 -1
  33. package/dist/src/services/clip-curation/index.js +2 -1
  34. package/dist/src/services/clip-curation/local-agent.js +4 -3
  35. package/dist/src/services/clip-curation/media-select.js +85 -0
  36. package/dist/src/services/clip-curation/query.js +5 -1
  37. package/dist/src/services/clip-curation/refine.js +50 -20
  38. package/dist/src/services/clip-curation/scan.js +10 -3
  39. package/dist/src/services/clip-curation/taxonomy.js +3 -1
  40. package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
  41. package/dist/src/services/clip-records.js +14 -1
  42. package/dist/src/services/clip-search.js +43 -13
  43. package/dist/src/services/file-directory.js +114 -0
  44. package/dist/src/services/storage.js +24 -1
  45. package/dist/src/services/upstream.js +5 -5
  46. package/dist/src/template-editor-shell.js +16 -2
  47. package/package.json +1 -1
  48. package/public/assets/discover-client-app.js +1 -0
  49. package/public/assets/file-directory-app.js +2 -0
  50. package/public/assets/homepage-client-app.js +12 -12
  51. package/public/assets/page-runtime-client-app.js +24 -24
  52. package/src/assets/favicon.ico +0 -0
  53. package/src/assets/logo-vidfarm.png +0 -0
package/dist/src/cli.js CHANGED
@@ -12,6 +12,7 @@ import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLa
12
12
  import { runCaptionsCommand } from "./devcli/captions.js";
13
13
  import { runClipsCommand } from "./devcli/clips.js";
14
14
  import { runTransitionsCommand } from "./devcli/transitions.js";
15
+ import { runKeyframesCommand, runMoveCommand, runRestackCommand, runRippleCommand, runTrimCommand } from "./devcli/timeline-edit.js";
15
16
  import { MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES, loadSpeechAudioLocally, localGenerateSpeech, localTranscribeSpeech, resolveLocalSpeechAuth } from "./devcli/speech.js";
16
17
  import { buildSrtFromSegments, inferSpeechProviderForVoice } from "./services/speech.js";
17
18
  import { formatCompositionLintIssues, lintCompositionHtml } from "./services/composition-lint.js";
@@ -236,6 +237,20 @@ Scene transitions (between clips on a pulled/served composition — local file w
236
237
  transitions list <dir> Show transitions on every scene clip [--json]
237
238
  transitions clear <dir> Remove all transitions
238
239
  (run 'vidfarm transitions' for presets & all flags)
240
+
241
+ Fine timeline control (trackpad-level verbs on a pulled/served composition — local file write):
242
+ keyframes <dir> --layer <key> --preset <name>|--keyframes '<json>' Script-free CSS
243
+ @keyframes animation on ONE layer (opacity/translate/
244
+ scale/rotate); previews AND renders. --easing --duration
245
+ move <dir> --layer <key> [--layer …] Shift layers by --delta-start (sec) and/or
246
+ --delta-track (lanes); group-aware. (alias: nudge)
247
+ ripple <dir> --at <sec> --delta <sec> Insert (+) or close (-) time at --at and
248
+ shift every downstream clip so the rest stays in sync
249
+ trim <dir> --layer <key> --edge start|end --to <sec> Move one edge to a time
250
+ (left trim couples the media in-point on video/audio)
251
+ restack <dir> --layer <key> --order front|back|forward|backward|--track <n> Change
252
+ stacking (== track index; higher = on top). (alias: zindex)
253
+ (run 'vidfarm keyframes|move|ripple|trim|restack help' for all flags)
239
254
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
240
255
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
241
256
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
@@ -319,10 +334,19 @@ Files (multi-step flows the devcli handles for you):
319
334
  --folder <path> Only files under this folder (e.g. a product/offer)
320
335
  --search <query> Find files by MEANING (keyword + vector over → POST /api/v1/user/me/attachments/search
321
336
  name/folder/notes) instead of listing everything
337
+ --content-type <a,b> With --search: filter the /raws branch by shot-KIND tag
338
+ (talking_head,b_roll,product_shot,screen_recording,demo,
339
+ reaction,interview,establishing,lifestyle,text_graphic)
322
340
  get-file <id> [dest] Resolve a My Files id to its URL and download it
323
341
  --print Print text contents (md/txt/csv/json) instead of saving
324
342
  annotate-file <id|name> Set metadata notes on one My Files entry → PATCH /api/v1/user/me/attachments/:id
325
343
  --notes <text> The notes to attach (empty string clears); embedded for vector search
344
+ directory ls [path] Browse the unified file tree (/files·/temp·/raws) → GET /api/v1/user/me/directory
345
+ --limit <n> --offset <n> Paginate (folders + files under a path; default "/")
346
+ directory search <query> Ranked search across the whole tree → POST /api/v1/user/me/directory/search
347
+ --path <scope> Scope to a subtree · --mode auto|semantic|substring|path · --limit <n>
348
+ --content-type <a,b> Filter the /raws branch by shot-KIND tag (talking_head, b_roll, …)
349
+ (alias: dir)
326
350
  put-file / get-file / files / annotate-file are the My Files (persistent) set;
327
351
  upload is the throwaway temp store for dropping media into a composition.
328
352
  Annotate assets you'll want back — esp. character refs (character_sprite_card.png
@@ -528,6 +552,10 @@ async function main() {
528
552
  case "annotate-file":
529
553
  await runAnnotateFileCommand(rest);
530
554
  return;
555
+ case "directory":
556
+ case "dir":
557
+ await runDirectoryCommand(rest);
558
+ return;
531
559
  case "raws":
532
560
  case "clips":
533
561
  await runClipsCommand(rest);
@@ -538,6 +566,24 @@ async function main() {
538
566
  case "transitions":
539
567
  await runTransitionsCommand(rest);
540
568
  return;
569
+ // Fine timeline control — local-file twins of the editor's trackpad verbs.
570
+ case "keyframes":
571
+ await runKeyframesCommand(rest);
572
+ return;
573
+ case "move":
574
+ case "nudge":
575
+ await runMoveCommand(rest);
576
+ return;
577
+ case "ripple":
578
+ await runRippleCommand(rest);
579
+ return;
580
+ case "trim":
581
+ await runTrimCommand(rest);
582
+ return;
583
+ case "restack":
584
+ case "zindex":
585
+ await runRestackCommand(rest);
586
+ return;
541
587
  // Local media engines + toolchain (bundled HyperFrames local commands —
542
588
  // free, no account — plus vidfarm-native lint/stills/doctor/skills).
543
589
  case "remove-background":
@@ -654,7 +700,7 @@ function emitResult(result, json, frontend) {
654
700
  // Frontend URL builders — these are the pages a director opens in a browser.
655
701
  function editorFrontendUrl(host, templateId, forkId) {
656
702
  const base = `${host}/editor/${encodeURIComponent(templateId)}`;
657
- return forkId ? `${base}?fork=${encodeURIComponent(forkId)}` : base;
703
+ return forkId ? `${base}/fork/${encodeURIComponent(forkId)}` : base;
658
704
  }
659
705
  function discoverFrontendUrl(host) {
660
706
  return `${host}/discover`;
@@ -779,7 +825,7 @@ async function runServeCommand(argv) {
779
825
  }
780
826
  const base = `http://localhost:${port}`;
781
827
  const editorPath = openTemplateId
782
- ? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `?fork=${encodeURIComponent(openForkId)}` : ""}`
828
+ ? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `/fork/${encodeURIComponent(openForkId)}` : ""}`
783
829
  : "/discover";
784
830
  const openUrl = `${base}/auto-login?api_key=${encodeURIComponent(apiKey)}&redirect=${encodeURIComponent(editorPath)}`;
785
831
  printServeBanner({ base, dataDir, openUrl, editorPath, cloud: cloudEnabled ? { host, hasKey: Boolean(cloudApiKey) } : null });
@@ -982,9 +1028,9 @@ function printPublishBanner(input) {
982
1028
  console.log("");
983
1029
  }
984
1030
  async function resolveForkId(input) {
985
- // /editor/<template_id> issues a 302 → /editor/<template_id>?fork=<id>
1031
+ // /editor/<template_id> issues a 302 → /editor/<template_id>/fork/<id>
986
1032
  // whenever the caller (or the template's default fork) has one. Follow the
987
- // redirect once by hand so we can read the fork id off the query string.
1033
+ // redirect once by hand so we can read the fork id off the path.
988
1034
  const url = `${input.host}/editor/${encodeURIComponent(input.templateId)}`;
989
1035
  const res = await fetch(url, {
990
1036
  method: "GET",
@@ -996,7 +1042,7 @@ async function resolveForkId(input) {
996
1042
  if (location) {
997
1043
  try {
998
1044
  const target = new URL(location, input.host);
999
- const fork = target.searchParams.get("fork");
1045
+ const fork = parseForkFromEditorPath(target.pathname);
1000
1046
  if (fork)
1001
1047
  return fork;
1002
1048
  }
@@ -1007,6 +1053,12 @@ async function resolveForkId(input) {
1007
1053
  }
1008
1054
  return null;
1009
1055
  }
1056
+ // Parse the fork id out of a canonical editor path
1057
+ // /editor/<template_id>/fork/<fork_id> → "<fork_id>" (else null).
1058
+ function parseForkFromEditorPath(pathname) {
1059
+ const match = pathname.match(/^\/editor\/[^/]+\/fork\/([^/]+)\/?$/);
1060
+ return match ? decodeURIComponent(match[1]) : null;
1061
+ }
1010
1062
  async function fetchCompositionFiles(input) {
1011
1063
  // video-context.json carries the source video's audio transcript and
1012
1064
  // per-scene visual descriptions so desktop agents (Claude Code, Codex)
@@ -2002,6 +2054,13 @@ async function resolveReferenceUrls(ctx, refs) {
2002
2054
  function inferMediaKindFromUrl(url) {
2003
2055
  return /\.(mp4|mov|webm|m4v|mkv)(\?|#|$)/i.test(url) ? "video" : "image";
2004
2056
  }
2057
+ // Comma-separated shot-KIND filter (talking_head, b_roll, product_shot, …) →
2058
+ // the content_type array the /raws branch of the search endpoints honors.
2059
+ function parseContentTypeFlag(value) {
2060
+ if (typeof value !== "string" || !value.trim())
2061
+ return [];
2062
+ return value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
2063
+ }
2005
2064
  // Resolve a composition.html on disk from a file path or a directory holding one.
2006
2065
  function resolveCompositionHtmlPath(target) {
2007
2066
  const abs = path.resolve(process.cwd(), target);
@@ -3214,18 +3273,20 @@ async function runDownloadCommand(argv) {
3214
3273
  // This is the same filesystem the /editor AI agent browses via the browse_files
3215
3274
  // tool, so an agent CLI (Claude Code / Codex) can find assets the same way.
3216
3275
  async function runFilesCommand(argv) {
3217
- const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" }, search: { type: "string" } } });
3276
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" }, search: { type: "string" }, "content-type": { type: "string" } } });
3218
3277
  const ctx = commonContext(parsed.values);
3219
3278
  const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
3279
+ const contentTypes = parseContentTypeFlag(parsed.values["content-type"]);
3220
3280
  if (parsed.values.search) {
3221
3281
  // Meaning-first lookup: keyword + vector search over every file's
3222
3282
  // name/folder/notes (BYOK embeddings; keyword-only without a saved key).
3283
+ // content_type filters the /raws branch by shot-KIND tag (server-honored).
3223
3284
  const result = await apiRequest({
3224
3285
  method: "POST",
3225
3286
  host: ctx.host,
3226
3287
  path: "/api/v1/user/me/attachments/search",
3227
3288
  auth: ctx.auth,
3228
- body: { query: String(parsed.values.search), ...(folderFilter ? { folder_path: folderFilter } : {}) }
3289
+ body: { query: String(parsed.values.search), ...(folderFilter ? { folder_path: folderFilter } : {}), ...(contentTypes.length ? { content_type: contentTypes } : {}) }
3229
3290
  });
3230
3291
  assertApiOk(result, "files --search");
3231
3292
  emitResult(result, ctx.json);
@@ -3241,6 +3302,165 @@ async function runFilesCommand(argv) {
3241
3302
  }
3242
3303
  emitResult(result, ctx.json);
3243
3304
  }
3305
+ // ── directory (unified virtual file tree) ────────────────────────────────────
3306
+ // Browse + search the three file roots (/files, /temp, /raws) as one tree via
3307
+ // the cloud REST directory endpoints. Same cloud auth (--api-key/VIDFARM_API_KEY,
3308
+ // --host, --json) as the rest of the wrapper commands.
3309
+ const DIRECTORY_HELP = `vidfarm directory — browse the unified file tree (/files · /temp · /raws)
3310
+
3311
+ directory ls [path] List folders + files under a path (default "/") → GET /api/v1/user/me/directory
3312
+ --limit <n> --offset <n> Paginate (server returns next_offset when more)
3313
+ --json Raw JSON response
3314
+
3315
+ directory search <query> Ranked search across the tree → POST /api/v1/user/me/directory/search
3316
+ --path <scope> Scope the search to a subtree (e.g. /raws/demos)
3317
+ --mode auto|semantic|substring|path Signal(s) to use (default: auto)
3318
+ --content-type <a,b> Filter the /raws branch by shot-KIND tag (comma-separated:
3319
+ talking_head, b_roll, product_shot, screen_recording, demo,
3320
+ reaction, interview, establishing, lifestyle, text_graphic)
3321
+ --limit <n> --json
3322
+
3323
+ Aliases: directory | dir · Auth: --api-key <key> or VIDFARM_API_KEY, --host <url>
3324
+ Move a raw between folders with: vidfarm api PATCH /raws/<clipId> --data '{"folder_path":"…"}'`;
3325
+ async function runDirectoryCommand(argv) {
3326
+ const sub = argv[0];
3327
+ const rest = argv.slice(1);
3328
+ switch (sub) {
3329
+ case "ls":
3330
+ case "list":
3331
+ return runDirectoryLs(rest);
3332
+ case "search":
3333
+ case "find":
3334
+ return runDirectorySearch(rest);
3335
+ case undefined:
3336
+ case "help":
3337
+ case "--help":
3338
+ case "-h":
3339
+ console.log(DIRECTORY_HELP);
3340
+ return;
3341
+ default:
3342
+ console.error(`Unknown directory subcommand: ${sub}\n`);
3343
+ console.log(DIRECTORY_HELP);
3344
+ process.exitCode = 1;
3345
+ }
3346
+ }
3347
+ async function runDirectoryLs(argv) {
3348
+ const parsed = parseArgs({
3349
+ args: argv,
3350
+ allowPositionals: true,
3351
+ options: { ...commonOptions(), limit: { type: "string" }, offset: { type: "string" } }
3352
+ });
3353
+ const ctx = commonContext(parsed.values);
3354
+ const dirPath = parsed.positionals[0] ?? "/";
3355
+ const result = await apiRequest({
3356
+ method: "GET",
3357
+ host: ctx.host,
3358
+ path: "/api/v1/user/me/directory",
3359
+ auth: ctx.auth,
3360
+ query: {
3361
+ path: dirPath,
3362
+ limit: parsed.values.limit,
3363
+ offset: parsed.values.offset
3364
+ }
3365
+ });
3366
+ assertApiOk(result, "directory ls");
3367
+ if (ctx.json) {
3368
+ printJson(result.json ?? result.text);
3369
+ return;
3370
+ }
3371
+ printDirectoryListing(result.json, dirPath);
3372
+ }
3373
+ async function runDirectorySearch(argv) {
3374
+ const parsed = parseArgs({
3375
+ args: argv,
3376
+ allowPositionals: true,
3377
+ options: { ...commonOptions(), path: { type: "string" }, mode: { type: "string" }, "content-type": { type: "string" }, limit: { type: "string" } }
3378
+ });
3379
+ const ctx = commonContext(parsed.values);
3380
+ const query = parsed.positionals.join(" ").trim();
3381
+ if (!query) {
3382
+ throw new Error('directory search requires a query: vidfarm directory search "<query>" [--path /raws] [--mode auto|semantic|substring|path] [--content-type talking_head,b_roll] [--limit N]');
3383
+ }
3384
+ const mode = parsed.values.mode;
3385
+ if (mode && !["auto", "semantic", "substring", "path"].includes(mode)) {
3386
+ throw new Error(`--mode must be auto | semantic | substring | path (got "${mode}")`);
3387
+ }
3388
+ const contentTypes = parseContentTypeFlag(parsed.values["content-type"]);
3389
+ const limit = parsed.values.limit != null ? Number(parsed.values.limit) : undefined;
3390
+ const result = await apiRequest({
3391
+ method: "POST",
3392
+ host: ctx.host,
3393
+ path: "/api/v1/user/me/directory/search",
3394
+ auth: ctx.auth,
3395
+ body: {
3396
+ query,
3397
+ ...(parsed.values.path ? { path: String(parsed.values.path) } : {}),
3398
+ ...(mode ? { mode } : {}),
3399
+ ...(contentTypes.length ? { content_type: contentTypes } : {}),
3400
+ ...(limit != null && Number.isFinite(limit) ? { limit } : {})
3401
+ }
3402
+ });
3403
+ assertApiOk(result, "directory search");
3404
+ if (ctx.json) {
3405
+ printJson(result.json ?? result.text);
3406
+ return;
3407
+ }
3408
+ printDirectorySearch(result.json, query);
3409
+ }
3410
+ // Compact "12.3s · 4.2 MB · video/mp4" line for a directory file item.
3411
+ function directoryFileMeta(item) {
3412
+ const parts = [];
3413
+ if (typeof item?.durationSec === "number" && item.durationSec > 0)
3414
+ parts.push(`${item.durationSec.toFixed(1)}s`);
3415
+ if (typeof item?.sizeBytes === "number" && item.sizeBytes > 0)
3416
+ parts.push(formatBytes(item.sizeBytes));
3417
+ if (typeof item?.contentType === "string" && item.contentType)
3418
+ parts.push(item.contentType);
3419
+ return parts.join(" · ");
3420
+ }
3421
+ function printDirectoryListing(data, requestedPath) {
3422
+ const listedPath = data && typeof data.path === "string" ? data.path : requestedPath;
3423
+ const root = data?.root ? ` ${DIM}[${data.root}]${RESET}` : "";
3424
+ console.log(`${BOLD}${listedPath}${RESET}${root}`);
3425
+ const folders = Array.isArray(data?.folders) ? data.folders : [];
3426
+ const files = Array.isArray(data?.files) ? data.files : [];
3427
+ if (folders.length === 0 && files.length === 0) {
3428
+ console.log(` ${DIM}(empty)${RESET}`);
3429
+ }
3430
+ for (const f of folders) {
3431
+ console.log(` ${DIM}dir ${RESET} ${f?.name ?? ""}/ ${DIM}${f?.path ?? ""}${RESET}`);
3432
+ }
3433
+ for (const f of files) {
3434
+ const meta = directoryFileMeta(f);
3435
+ console.log(` file ${f?.name ?? ""}${meta ? ` ${DIM}${meta}${RESET}` : ""} ${DIM}${f?.path ?? ""}${RESET}`);
3436
+ if (f?.viewUrl)
3437
+ console.log(` ${FRONTEND}${f.viewUrl}${RESET}`);
3438
+ }
3439
+ const total = folders.length + files.length;
3440
+ const more = data?.next_offset != null ? ` (more: --offset ${data.next_offset})` : "";
3441
+ console.log(`\n ${total} item${total === 1 ? "" : "s"} (${folders.length} folder${folders.length === 1 ? "" : "s"}, ${files.length} file${files.length === 1 ? "" : "s"})${more}`);
3442
+ }
3443
+ function printDirectorySearch(data, query) {
3444
+ const results = Array.isArray(data?.results) ? data.results : [];
3445
+ const scope = data?.scope ? ` in ${data.scope}` : "";
3446
+ const modeLabel = data?.mode ? ` · mode=${data.mode}` : "";
3447
+ const semantic = data?.semantic === true ? " · semantic" : data?.semantic === false ? " · keyword-only" : "";
3448
+ console.log(`${BOLD}"${query}"${RESET}${scope}${modeLabel}${semantic} → ${results.length} result${results.length === 1 ? "" : "s"}\n`);
3449
+ if (results.length === 0) {
3450
+ console.log(` ${DIM}(no matches)${RESET}`);
3451
+ return;
3452
+ }
3453
+ results.forEach((r, i) => {
3454
+ const score = typeof r?.score === "number" ? ` ${DIM}score=${r.score.toFixed(3)}${RESET}` : "";
3455
+ const kind = r?.kind === "folder" ? "dir " : "file";
3456
+ console.log(` ${String(i + 1).padStart(2)}. ${kind} ${r?.path ?? r?.name ?? ""}${score}`);
3457
+ const meta = directoryFileMeta(r);
3458
+ if (meta)
3459
+ console.log(` ${DIM}${meta}${RESET}`);
3460
+ if (r?.viewUrl)
3461
+ console.log(` ${FRONTEND}${r.viewUrl}${RESET}`);
3462
+ });
3463
+ }
3244
3464
  // Attach metadata notes to one My Files entry (by id, or file name as a
3245
3465
  // fallback). Notes describe what the file IS and when to use it; the server
3246
3466
  // vector-embeds them so `files --search` finds the asset by meaning later —
@@ -130,7 +130,12 @@ export class ClipStore {
130
130
  this.attachEmbeddings([clip]);
131
131
  return clip;
132
132
  }
133
- /** All clips (metadata only), newest first, optionally scoped to a source video. */
133
+ /**
134
+ * All clips (metadata only), newest first, optionally scoped to a source
135
+ * video. `contentTypes` filters by the shot-KIND tag (tags.content_type,
136
+ * exact match, case-insensitive); when present the limit/offset are applied
137
+ * AFTER the JS filter (the tag lives in tags_json, not a column).
138
+ */
134
139
  listClips(opts = {}) {
135
140
  let sql = "SELECT * FROM clips";
136
141
  const args = [];
@@ -139,9 +144,30 @@ export class ClipStore {
139
144
  args.push(opts.sourceVideoId);
140
145
  }
141
146
  sql += " ORDER BY created_at DESC";
142
- if (opts.limit) {
147
+ const wanted = (opts.contentTypes ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean);
148
+ if (wanted.length) {
149
+ // Content-type filter can't ride SQL (tag is in JSON), so filter every
150
+ // matching row in JS, then window with offset/limit.
151
+ let clips = this.db.prepare(sql).all(...args)
152
+ .map(rowToClip)
153
+ .filter((c) => (c.tags.content_type ?? []).some((t) => wanted.includes(String(t).toLowerCase())));
154
+ if (opts.offset)
155
+ clips = clips.slice(Math.max(0, Math.floor(opts.offset)));
156
+ if (opts.limit)
157
+ clips = clips.slice(0, opts.limit);
158
+ return clips;
159
+ }
160
+ if (opts.limit != null) {
143
161
  sql += " LIMIT ?";
144
162
  args.push(opts.limit);
163
+ if (opts.offset) {
164
+ sql += " OFFSET ?";
165
+ args.push(Math.max(0, Math.floor(opts.offset)));
166
+ }
167
+ }
168
+ else if (opts.offset) {
169
+ sql += " LIMIT -1 OFFSET ?";
170
+ args.push(Math.max(0, Math.floor(opts.offset)));
145
171
  }
146
172
  return this.db.prepare(sql).all(...args).map(rowToClip);
147
173
  }
@@ -313,6 +339,7 @@ function rowToClip(row) {
313
339
  }
314
340
  function emptyTags() {
315
341
  return {
342
+ content_type: [],
316
343
  subject: [],
317
344
  action: [],
318
345
  emotion: [],
@@ -12,7 +12,7 @@ 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
17
  export const CLIPS_HELP = `vidfarm raws — build & search a local raws library (local ffmpeg + local agent by default)
18
18
 
@@ -54,9 +54,14 @@ export const CLIPS_HELP = `vidfarm raws — build & search a local raws library
54
54
 
55
55
  raws list List raws in the library
56
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
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
67
  raws match "<text>" Top library raws to REPLACE a scene (vector search)
@@ -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,7 +194,8 @@ 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];
@@ -224,6 +238,8 @@ async function runScan(argv) {
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
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
  }
@@ -234,6 +250,7 @@ async function runScan(argv) {
234
250
  console.log(`[raws] cropping clips to ${aspect} (${cropFocus}).`);
235
251
  if (avoidText)
236
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)"}.`);
237
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);
@@ -329,6 +346,7 @@ 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) => {
@@ -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
  });
@@ -510,13 +531,20 @@ 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));
@@ -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" },
@@ -549,6 +578,7 @@ async function runSearch(argv) {
549
578
  const query = positionals.join(" ").trim();
550
579
  if (!query)
551
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) {
@@ -582,7 +612,13 @@ async function runSearch(argv) {
582
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) {
@@ -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";