@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
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";
@@ -168,22 +169,22 @@ Generate AI media and drop it on the timeline (for local coding agents):
168
169
  original vs decomposed (caption-free),
169
170
  non-billing (alias: ghostcut)
170
171
 
171
- Clip hunting (the third library — mine long-form video into a reusable clip store):
172
- clips scan <video-path> Hunt short clips out of a long video. LOCAL-FIRST: local ffmpeg +
172
+ Raws (the third library — mine long-form video into a reusable raws store):
173
+ raws scan <video-path> Hunt short clips out of a long video. LOCAL-FIRST: local ffmpeg +
173
174
  Detect → tag/transcribe → embed → persist your local claude/codex CLI
174
175
  to ~/.vidfarm/clips.db. (keys are fallback)
175
176
  --range "MM:SS-MM:SS" Only hunt these source windows (repeatable; big cost saver)
176
177
  --duration <sec> Target clip length as a SOFT band (10→5-20s, 30→20-40s)
177
178
  --aspect 9:16|16:9|4:3|1:1 Crop clips (also: vertical/horizontal/square) [--crop-focus <f>]
178
179
  --no-text Prefer scenes WITHOUT captions/on-screen text (selection only)
179
- --cloud [--url <url>] BACKUP: run on the deployed pipeline → POST /clips/scan (+ poll)
180
+ --cloud [--url <url>] BACKUP: run on the deployed pipeline → POST /raws/scan (+ poll)
180
181
  (uploads to your temp folder — 30-day TTL — bills AWS compute only)
181
182
  --dry-run Show the estimated cost and stop (over $1 needs --yes/confirm)
182
- clips list List the local clip library → GET /clips
183
- clips search "<text>" Hybrid structured + semantic search → POST /clips/search
184
- clips preset list|run <name>|save <name> --from-query '<...>' → /clips/presets
185
- clips export <ids...> --to <dir> Copy clip mp4s + metadata out
186
- (run 'vidfarm clips' for the full clips help)
183
+ raws list List the local raws library → GET /raws
184
+ raws search "<text>" Hybrid structured + semantic search → POST /raws/search
185
+ raws preset list|run <name>|save <name> --from-query '<...>' → /raws/presets
186
+ raws export <ids...> --to <dir> Copy raw mp4s + metadata out
187
+ (run 'vidfarm raws' for the full raws help)
187
188
 
188
189
  Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit backup:
189
190
  tts "<text>" Text → narration audio file. LOCAL: your OPENAI/GEMINI/
@@ -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,11 @@ 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;
559
+ case "raws":
531
560
  case "clips":
532
561
  await runClipsCommand(rest);
533
562
  return;
@@ -537,6 +566,24 @@ async function main() {
537
566
  case "transitions":
538
567
  await runTransitionsCommand(rest);
539
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;
540
587
  // Local media engines + toolchain (bundled HyperFrames local commands —
541
588
  // free, no account — plus vidfarm-native lint/stills/doctor/skills).
542
589
  case "remove-background":
@@ -653,7 +700,7 @@ function emitResult(result, json, frontend) {
653
700
  // Frontend URL builders — these are the pages a director opens in a browser.
654
701
  function editorFrontendUrl(host, templateId, forkId) {
655
702
  const base = `${host}/editor/${encodeURIComponent(templateId)}`;
656
- return forkId ? `${base}?fork=${encodeURIComponent(forkId)}` : base;
703
+ return forkId ? `${base}/fork/${encodeURIComponent(forkId)}` : base;
657
704
  }
658
705
  function discoverFrontendUrl(host) {
659
706
  return `${host}/discover`;
@@ -722,7 +769,7 @@ async function runServeCommand(argv) {
722
769
  // render_target="cloud" upstream handoff (the editor's "Render in Cloud").
723
770
  process.env.VIDFARM_JOB_STATE_MACHINE_ARN = "";
724
771
  // Same trap for clip hunts: a dev-shell .env carrying the staging clip-scan
725
- // ARN would route this box's /clips/scan to the cloud Step Functions. serve
772
+ // ARN would route this box's /raws/scan to the cloud Step Functions. serve
726
773
  // hunts are LOCAL-FIRST (local agent CLI / BYOK keys); the upstream cloud is
727
774
  // the explicit backup inside the scan route itself.
728
775
  process.env.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN = "";
@@ -778,7 +825,7 @@ async function runServeCommand(argv) {
778
825
  }
779
826
  const base = `http://localhost:${port}`;
780
827
  const editorPath = openTemplateId
781
- ? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `?fork=${encodeURIComponent(openForkId)}` : ""}`
828
+ ? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `/fork/${encodeURIComponent(openForkId)}` : ""}`
782
829
  : "/discover";
783
830
  const openUrl = `${base}/auto-login?api_key=${encodeURIComponent(apiKey)}&redirect=${encodeURIComponent(editorPath)}`;
784
831
  printServeBanner({ base, dataDir, openUrl, editorPath, cloud: cloudEnabled ? { host, hasKey: Boolean(cloudApiKey) } : null });
@@ -981,9 +1028,9 @@ function printPublishBanner(input) {
981
1028
  console.log("");
982
1029
  }
983
1030
  async function resolveForkId(input) {
984
- // /editor/<template_id> issues a 302 → /editor/<template_id>?fork=<id>
1031
+ // /editor/<template_id> issues a 302 → /editor/<template_id>/fork/<id>
985
1032
  // whenever the caller (or the template's default fork) has one. Follow the
986
- // 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.
987
1034
  const url = `${input.host}/editor/${encodeURIComponent(input.templateId)}`;
988
1035
  const res = await fetch(url, {
989
1036
  method: "GET",
@@ -995,7 +1042,7 @@ async function resolveForkId(input) {
995
1042
  if (location) {
996
1043
  try {
997
1044
  const target = new URL(location, input.host);
998
- const fork = target.searchParams.get("fork");
1045
+ const fork = parseForkFromEditorPath(target.pathname);
999
1046
  if (fork)
1000
1047
  return fork;
1001
1048
  }
@@ -1006,6 +1053,12 @@ async function resolveForkId(input) {
1006
1053
  }
1007
1054
  return null;
1008
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
+ }
1009
1062
  async function fetchCompositionFiles(input) {
1010
1063
  // video-context.json carries the source video's audio transcript and
1011
1064
  // per-scene visual descriptions so desktop agents (Claude Code, Codex)
@@ -2001,6 +2054,13 @@ async function resolveReferenceUrls(ctx, refs) {
2001
2054
  function inferMediaKindFromUrl(url) {
2002
2055
  return /\.(mp4|mov|webm|m4v|mkv)(\?|#|$)/i.test(url) ? "video" : "image";
2003
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
+ }
2004
2064
  // Resolve a composition.html on disk from a file path or a directory holding one.
2005
2065
  function resolveCompositionHtmlPath(target) {
2006
2066
  const abs = path.resolve(process.cwd(), target);
@@ -2256,7 +2316,7 @@ async function runGenerateCommand(argv) {
2256
2316
  }
2257
2317
  }
2258
2318
  // ── Speech: tts / stt ────────────────────────────────────────────────────────
2259
- // LOCAL-FIRST like `clips scan`: both commands default to running on the
2319
+ // LOCAL-FIRST like `raws scan`: both commands default to running on the
2260
2320
  // user's OWN AI key (GEMINI_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY or
2261
2321
  // the --*-key flags) via src/services/speech.ts — local ffmpeg demux, direct
2262
2322
  // provider call, no cloud job, no wallet. The REST primitive routes
@@ -3213,18 +3273,20 @@ async function runDownloadCommand(argv) {
3213
3273
  // This is the same filesystem the /editor AI agent browses via the browse_files
3214
3274
  // tool, so an agent CLI (Claude Code / Codex) can find assets the same way.
3215
3275
  async function runFilesCommand(argv) {
3216
- 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" } } });
3217
3277
  const ctx = commonContext(parsed.values);
3218
3278
  const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
3279
+ const contentTypes = parseContentTypeFlag(parsed.values["content-type"]);
3219
3280
  if (parsed.values.search) {
3220
3281
  // Meaning-first lookup: keyword + vector search over every file's
3221
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).
3222
3284
  const result = await apiRequest({
3223
3285
  method: "POST",
3224
3286
  host: ctx.host,
3225
3287
  path: "/api/v1/user/me/attachments/search",
3226
3288
  auth: ctx.auth,
3227
- 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 } : {}) }
3228
3290
  });
3229
3291
  assertApiOk(result, "files --search");
3230
3292
  emitResult(result, ctx.json);
@@ -3240,6 +3302,165 @@ async function runFilesCommand(argv) {
3240
3302
  }
3241
3303
  emitResult(result, ctx.json);
3242
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
+ }
3243
3464
  // Attach metadata notes to one My Files entry (by id, or file name as a
3244
3465
  // fallback). Notes describe what the file IS and when to use it; the server
3245
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: [],