@mevdragon/vidfarm-devcli 0.18.0 → 0.19.0

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 (68) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +166 -0
  2. package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
  3. package/.agents/skills/vidfarm-media/SKILL.md +43 -4
  4. package/SKILL.director.md +190 -18
  5. package/SKILL.platform.md +8 -4
  6. package/demo/dist/app.css +1 -1
  7. package/demo/dist/app.js +77 -75
  8. package/demo/dist/favicon.ico +0 -0
  9. package/dist/src/app.js +3031 -300
  10. package/dist/src/cli.js +1549 -69
  11. package/dist/src/config.js +7 -0
  12. package/dist/src/devcli/clip-store.js +29 -2
  13. package/dist/src/devcli/clips.js +55 -9
  14. package/dist/src/devcli/composition-edit.js +658 -8
  15. package/dist/src/devcli/local-backend.js +123 -0
  16. package/dist/src/devcli/migrate-local.js +140 -0
  17. package/dist/src/devcli/sync.js +311 -0
  18. package/dist/src/devcli/timeline-edit.js +490 -0
  19. package/dist/src/editor-chat.js +56 -17
  20. package/dist/src/frontend/discover-client.js +130 -0
  21. package/dist/src/frontend/discover-store.js +23 -0
  22. package/dist/src/frontend/file-directory.js +995 -0
  23. package/dist/src/frontend/homepage-view.js +3 -3
  24. package/dist/src/frontend/template-editor-chat.js +28 -22
  25. package/dist/src/landing-page.js +24 -7
  26. package/dist/src/page-shell.js +26 -2
  27. package/dist/src/primitive-registry.js +409 -4
  28. package/dist/src/reskin/agency-page.js +1 -1
  29. package/dist/src/reskin/calendar-page.js +2 -1
  30. package/dist/src/reskin/chat-page.js +420 -85
  31. package/dist/src/reskin/discover-page.js +731 -39
  32. package/dist/src/reskin/document.js +1311 -387
  33. package/dist/src/reskin/help-page.js +1 -0
  34. package/dist/src/reskin/inpaint-clipper-page.js +649 -0
  35. package/dist/src/reskin/inpaint-page.js +2459 -446
  36. package/dist/src/reskin/inpaint-video-page.js +1339 -0
  37. package/dist/src/reskin/library-page.js +1168 -228
  38. package/dist/src/reskin/login-page.js +6 -6
  39. package/dist/src/reskin/pricing-page.js +2 -0
  40. package/dist/src/reskin/settings-page.js +55 -10
  41. package/dist/src/reskin/theme.js +365 -20
  42. package/dist/src/services/billing.js +4 -0
  43. package/dist/src/services/clip-curation/gemini.js +5 -0
  44. package/dist/src/services/clip-curation/hunt.js +81 -1
  45. package/dist/src/services/clip-curation/index.js +2 -1
  46. package/dist/src/services/clip-curation/local-agent.js +4 -3
  47. package/dist/src/services/clip-curation/media-select.js +85 -0
  48. package/dist/src/services/clip-curation/query.js +5 -1
  49. package/dist/src/services/clip-curation/refine.js +50 -20
  50. package/dist/src/services/clip-curation/scan.js +10 -3
  51. package/dist/src/services/clip-curation/taxonomy.js +3 -1
  52. package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
  53. package/dist/src/services/clip-records.js +42 -1
  54. package/dist/src/services/clip-search.js +43 -13
  55. package/dist/src/services/file-directory.js +117 -0
  56. package/dist/src/services/hyperframes.js +283 -3
  57. package/dist/src/services/serverless-records.js +43 -0
  58. package/dist/src/services/storage.js +47 -2
  59. package/dist/src/services/upstream.js +5 -5
  60. package/dist/src/template-editor-shell.js +16 -2
  61. package/package.json +1 -1
  62. package/public/assets/discover-client-app.js +1 -0
  63. package/public/assets/file-directory-app.js +2 -0
  64. package/public/assets/homepage-client-app.js +12 -12
  65. package/public/assets/page-runtime-client-app.js +24 -24
  66. package/public/assets/placeholders/scene-placeholder.png +0 -0
  67. package/src/assets/favicon.ico +0 -0
  68. package/src/assets/logo-vidfarm.png +0 -0
package/dist/src/cli.js CHANGED
@@ -8,10 +8,11 @@ import { parseArgs } from "node:util";
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { Readable } from "node:stream";
10
10
  import { pipeline } from "node:stream/promises";
11
- import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./devcli/composition-edit.js";
11
+ import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia, setLayerMedia } from "./devcli/composition-edit.js";
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 { runDuplicateCommand, runKeyframesCommand, runMoveCommand, runRestackCommand, runRetimeCommand, runRippleCommand, runSetCompositionCommand, runSetIdentityCommand, runSetStyleCommand, runSetTextCommand, runSetVisualCommand, runSplitCommand, 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";
@@ -131,10 +132,45 @@ Generate AI media and drop it on the timeline (for local coding agents):
131
132
  --no-wait Return the job id instead of polling to the URL
132
133
  --place <dir> After generating, place it into that composition
133
134
  --at <time> | --replace <layer_key> Placement (fill gap vs replace scene)
135
+ inpaint <image|url> Masked image EDIT — replace ONLY the painted region → POST /api/v1/primitives/images/inpaint
136
+ --mask <png|@file|url> Alpha-mask PNG: TRANSPARENT pixels = the editable region (required)
137
+ --prompt <text> Whole-image edit instruction (what to put in the mask)
138
+ --region "<label>=<p>" Per-region prompt (repeatable) — mirrors the web's colored masks
139
+ --ref <url|@file> Reference image (repeatable) → reference_attachments
140
+ --aspect-ratio <r> e.g. 9:16 (omit for auto)
141
+ --provider <p> openai|gemini|openrouter (omit to auto-pick)
142
+ --out <file> Download the finished image to this path
143
+ --no-wait Return the job id instead of polling to the URL
144
+ --place <dir> --at <time>|--replace <layer_key> Fuse onto a composition
145
+ create-overlay "<subject>" Vox-style transparent OVERLAY — AI image on a forced
146
+ green screen, chroma-keyed out in one job → transparent PNG
147
+ → POST /api/v1/primitives/images/create-overlay
148
+ --key-color <c> Background color to force + key (default #00FF00)
149
+ --aspect-ratio <r> e.g. 1:1 (match how you'll place it)
150
+ --ref <url|@file> Reference image (repeatable) → prompt_attachments
151
+ --tolerance <0..1> Key radius (default 0.3); --softness <0..1> edge feather (0.1)
152
+ --no-despill Skip edge color-fringe suppression
153
+ --provider <p> --model <m> Image provider/model overrides (omit to auto-pick)
154
+ --out <file> Download the transparent PNG to this path
155
+ --no-wait Return the job id instead of polling to the URL
156
+ --place <dir> --at <time>|--replace <layer_key> Fuse onto a composition
157
+ remove-background-greenscreen <image|url> Cheap chroma-key removal of a FLAT
158
+ solid background → transparent PNG (cloud primitive;
159
+ differs from remove-background = local ONNX matting)
160
+ → POST /api/v1/primitives/images/remove-background-greenscreen
161
+ --key-color <c> Background color to key out (default #00FF00; e.g. #FFFFFF)
162
+ --tolerance <0..1> Key radius (default 0.3); --softness <0..1> edge feather (0.1)
163
+ --no-despill Skip edge color-fringe suppression
164
+ --out <file> Download the transparent PNG to this path
134
165
  place <dir|composition.html> Insert media into a local composition
135
- (fill a gap or replace a scene) same clip markup
136
- the browser editor makes; serve live-morphs it.
137
- --src <url|file> Media to place (required). A URL is used as-is; a
166
+ (fill a gap, replace a scene, or overlay an AUDIO
167
+ track) — same clip markup the browser editor makes;
168
+ serve live-morphs it.
169
+ --src <url|/root/path|file> Media to place (required). A URL is used as-is;
170
+ a MY FILES PATH (/files/… · /raws/… · /temp/… ·
171
+ /projects/… · /approved/…) is resolved to its view URL
172
+ via the directory API (the CLI twin of the
173
+ /library/files picker; add --cloud for vidfarm.cc); a
138
174
  LOCAL FILE PATH is imported without bloating S3 — a
139
175
  serve working copy copies it onto the box's own disk
140
176
  store (free, local render only); any other dir
@@ -142,13 +178,27 @@ Generate AI media and drop it on the timeline (for local coding agents):
142
178
  --base-url <url> Serve box origin that hosts /storage (default
143
179
  http://localhost:3000) — set if you ran serve --port.
144
180
  --folder <path> Temp-store folder for uploaded local files (default: temp)
145
- --kind <video|image> Media kind (inferred from the URL/path if omitted)
146
- --at <time> Start it at this time (seconds / mm:ss) — fill a gap
181
+ --kind <video|image|audio> Media kind (inferred from the URL/path if omitted).
182
+ kind=audio lays a geometry-free <audio> track: multiple
183
+ audio tracks mix together at render, each at its own
184
+ --volume, so overlay narration (~1.0) + a low music bed
185
+ (--volume 0.15) + SFX as separate "place --kind audio"
186
+ calls. Split a template's baked music+narration into 2
187
+ tracks this way when recreating it.
188
+ --volume <0..2> Audio/video track level (default 1). Music bed ~0.1-0.2.
189
+ --muted Silence this track (or pass --volume 0)
190
+ --playback-start <s> Media in-point for video/audio (skip into the file)
191
+ --at <time> Start it at this time (seconds / mm:ss) — fill a gap.
192
+ For audio, duration defaults to the rest of the timeline.
147
193
  --replace <layer_key> Replace this scene/layer (copies its timing+geometry)
148
194
  --duration <n> Timeline length in seconds
149
195
  --track <n> Timeline layer index (auto if omitted)
150
196
  --x --y --width --height Geometry as % (default full canvas 0/0/100/100)
151
197
  --object-fit <fit> cover|contain|fill|none|scale-down (default cover)
198
+ --object-position <pos> Cover-crop focal point for aspect-mismatched media:
199
+ a keyword (left|right|top|bottom|"top left"|...) or
200
+ a percentage pair ("30% 50%") — keeps the subject in
201
+ frame when a 16:9 clip fills a 9:16 canvas
152
202
  --ken-burns <preset> Animate a still image (slow pan/zoom): zoom-in|
153
203
  zoom-out|pan-left|pan-right|pan-up|pan-down|
154
204
  zoom-in-left|zoom-in-right
@@ -164,9 +214,50 @@ Generate AI media and drop it on the timeline (for local coding agents):
164
214
  fade|fade-black|fade-white|flash|smoke|blur|
165
215
  slide-*|wipe-*|zoom-in|zoom-out|circle-close
166
216
  --transition-out-duration <s> Exit length in seconds 0.1-2.5 (default 0.5)
217
+ set-media <dir|composition.html> Edit a media layer IN PLACE (the set_layer_media
218
+ twin). Unlike 'place --replace' (delete + re-insert,
219
+ new key), this keeps the SAME node/key/geometry/timing —
220
+ it just mutates the chosen attributes. (alias: replace-media)
221
+ --layer <key|slug> Media layer to edit (required)
222
+ --src <url|/root/path|file> Swap the media source. Accepts a URL, a My Files
223
+ path (/raws/… · /files/… — resolved via the directory
224
+ API, add --cloud for vidfarm.cc), or a local file
225
+ (imported like 'place --src').
226
+ --playback-start <s> Media in-point (skip into the source file)
227
+ --source-out <s> Media out-point (source seconds) → sets the timeline
228
+ duration to (source_out − in-point), exactly like the
229
+ web in/out segment picker. e.g. seconds 3-8 of a clip:
230
+ --playback-start 3 --source-out 8
231
+ --duration <s> Set the timeline duration directly (alt to --source-out)
232
+ --volume <0..2> Track level --muted / --no-muted Silence toggle
233
+ --object-fit <fit> cover|contain|fill|none|scale-down
234
+ --object-position <pos> Cover-crop focal point (keyword or "30% 50%")
235
+ --ken-burns <preset|none> Still-image pan/zoom (see place); none clears
236
+ --ken-burns-intensity <n> Travel/zoom strength 0.04-0.5
237
+ --transition <preset|none> [--transition-duration <s>] Entrance transition
238
+ --transition-out <preset|none> [--transition-out-duration <s>] Exit transition
239
+ --json
167
240
  remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
168
241
  original vs decomposed (caption-free),
169
242
  non-billing (alias: ghostcut)
243
+ upload-media <file|url|id> The /editor "Upload media" button, from the CLI.
244
+ Bring media in from one of three sources, then drop it
245
+ onto a composition (alias: add-media).
246
+ --from computer|files|raw computer = a LOCAL FILE; files = a My Files id/name,
247
+ a directory path (/raws/… · /files/…), or a public URL;
248
+ raw = import a VIDEO URL into the raws library. Inferred
249
+ (computer vs files) when omitted.
250
+ --into <dir|composition.html> Place the media onto this composition (computer/
251
+ files). Without it, the resolved media URL is just printed.
252
+ --at <time> Timeline start (seconds / mm:ss) — fill a gap
253
+ --in <s> Source in-point (n-of-N segment start; --playback-start alias)
254
+ --duration <s> Clip length in seconds (the "n" of the segment)
255
+ --replace <layer_key> Swap the media into this existing scene/layer instead
256
+ --kind <video|image|audio> Media kind (inferred from the URL if omitted)
257
+ --folder <name> (raw) Save the import into /raws/<name> (also the tracer)
258
+ --clip [--prompt "..."] [--provider gemini|openai|openrouter]
259
+ (raw) AI-clip the video into matching moments instead of
260
+ importing the whole thing (BYOK)
170
261
 
171
262
  Raws (the third library — mine long-form video into a reusable raws store):
172
263
  raws scan <video-path> Hunt short clips out of a long video. LOCAL-FIRST: local ffmpeg +
@@ -178,12 +269,23 @@ Raws (the third library — mine long-form video into a reusable raws store):
178
269
  --no-text Prefer scenes WITHOUT captions/on-screen text (selection only)
179
270
  --cloud [--url <url>] BACKUP: run on the deployed pipeline → POST /raws/scan (+ poll)
180
271
  (uploads to your temp folder — 30-day TTL — bills AWS compute only)
272
+ --folder <name> (with --cloud) Save the mined clips into /raws/<name>
181
273
  --dry-run Show the estimated cost and stop (over $1 needs --yes/confirm)
182
274
  raws list List the local raws library → GET /raws
183
275
  raws search "<text>" Hybrid structured + semantic search → POST /raws/search
184
276
  raws preset list|run <name>|save <name> --from-query '<...>' → /raws/presets
185
277
  raws export <ids...> --to <dir> Copy raw mp4s + metadata out
186
278
  (run 'vidfarm raws' for the full raws help)
279
+ clipper <video-url|file> Import a source into raws (the /tools/clipper twin). → POST /raws/clip-range
280
+ DEFAULT = the WHOLE video; --start/--end trims an (or /raws/scan whole)
281
+ EXACT subrange and saves one raw.
282
+ --start <t> --end <t> In/out point — seconds (12.5) or HH:MM:SS:NNNN. Omit
283
+ BOTH to import the whole video (entirety); pass BOTH for
284
+ a subrange (one without the other is an error).
285
+ --tracer <name> Group this session's clips (also the default folder)
286
+ --folder <name> Save the clip into /raws/<name>
287
+ --name <text> Human name for the clip
288
+ (multi-clip: re-run with a new range + same --tracer)
187
289
 
188
290
  Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit backup:
189
291
  tts "<text>" Text → narration audio file. LOCAL: your OPENAI/GEMINI/
@@ -236,6 +338,20 @@ Scene transitions (between clips on a pulled/served composition — local file w
236
338
  transitions list <dir> Show transitions on every scene clip [--json]
237
339
  transitions clear <dir> Remove all transitions
238
340
  (run 'vidfarm transitions' for presets & all flags)
341
+
342
+ Fine timeline control (trackpad-level verbs on a pulled/served composition — local file write):
343
+ keyframes <dir> --layer <key> --preset <name>|--keyframes '<json>' Script-free CSS
344
+ @keyframes animation on ONE layer (opacity/translate/
345
+ scale/rotate); previews AND renders. --easing --duration
346
+ move <dir> --layer <key> [--layer …] Shift layers by --delta-start (sec) and/or
347
+ --delta-track (lanes); group-aware. (alias: nudge)
348
+ ripple <dir> --at <sec> --delta <sec> Insert (+) or close (-) time at --at and
349
+ shift every downstream clip so the rest stays in sync
350
+ trim <dir> --layer <key> --edge start|end --to <sec> Move one edge to a time
351
+ (left trim couples the media in-point on video/audio)
352
+ restack <dir> --layer <key> --order front|back|forward|backward|--track <n> Change
353
+ stacking (== track index; higher = on top). (alias: zindex)
354
+ (run 'vidfarm keyframes|move|ripple|trim|restack help' for all flags)
239
355
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
240
356
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
241
357
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
@@ -243,7 +359,10 @@ Scene transitions (between clips on a pulled/served composition — local file w
243
359
  'serve' host it renders in-process for FREE.
244
360
  render-status <forkId> <renderId> Poll one render job → GET .../compositions/:forkId/renders/:renderId
245
361
  visibility <forkId> <private|public> Set fork visibility → PATCH .../compositions/:forkId/visibility
246
- clone <forkId> Clone a fork as a new starting point → POST .../compositions/:forkId/clone
362
+ clone <forkId> [--from current|default] [--version N]
363
+ New fork: --from current (default) branches this
364
+ working copy; --from default starts fresh from the
365
+ template's original default → POST .../compositions/:forkId/fork
247
366
  share-link <forkId> Mint a share-token URL for a fork → POST .../compositions/:forkId/share-links
248
367
 
249
368
  Local media engines & toolchain (all local, free, no account — no cloud key needed):
@@ -319,14 +438,29 @@ Files (multi-step flows the devcli handles for you):
319
438
  --folder <path> Only files under this folder (e.g. a product/offer)
320
439
  --search <query> Find files by MEANING (keyword + vector over → POST /api/v1/user/me/attachments/search
321
440
  name/folder/notes) instead of listing everything
441
+ --content-type <a,b> With --search: filter the /raws branch by shot-KIND tag
442
+ (talking_head,b_roll,product_shot,screen_recording,demo,
443
+ reaction,interview,establishing,lifestyle,text_graphic)
322
444
  get-file <id> [dest] Resolve a My Files id to its URL and download it
323
445
  --print Print text contents (md/txt/csv/json) instead of saving
324
446
  annotate-file <id|name> Set metadata notes on one My Files entry → PATCH /api/v1/user/me/attachments/:id
325
447
  --notes <text> The notes to attach (empty string clears); embedded for vector search
448
+ directory ls [path] Browse the unified file tree (/files·/temp·/raws) → GET /api/v1/user/me/directory
449
+ --limit <n> --offset <n> Paginate (folders + files under a path; default "/")
450
+ directory search <query> Ranked search across the whole tree → POST /api/v1/user/me/directory/search
451
+ --path <scope> Scope to a subtree · --mode auto|semantic|substring|path · --limit <n>
452
+ --content-type <a,b> Filter the /raws branch by shot-KIND tag (talking_head, b_roll, …)
453
+ directory rename <path> <new-name> Rename a /files or /temp file/folder → POST /api/v1/user/me/directory/rename
454
+ --file-id <id> Rename a FILE (display name); omit to rename the folder at <path>
455
+ directory save-url <url> Save a durable media URL INTO My Files at a folder → POST /api/v1/user/me/attachments/from-url
456
+ --folder <path> Destination folder under /files (e.g. inpaints, promos)
457
+ --as <name> Name the saved file · --notes <text> vector-embedded notes
458
+ (alias: dir · save-url|from-url|import-url — CLI twin of the "Save to Files" picker)
326
459
  put-file / get-file / files / annotate-file are the My Files (persistent) set;
327
460
  upload is the throwaway temp store for dropping media into a composition.
328
- Annotate assets you'll want back — esp. character refs (character_sprite_card.png
329
- + about_character.md) — so 'files --search "the mascot"' finds them later.
461
+ Annotate assets you'll want back — esp. recurring characters, kept under
462
+ /files/characters/<slug>/ as a trio (<character_id>.json e.g. character_zara.json
463
+ + character_sprite_card.png + character_about.md) — 'files --search "the mascot"' finds them.
330
464
  Local file paths: to avoid uploading assets to Vidfarm S3 at all, reference
331
465
  them straight from disk with 'place --src ./clip.mp4' on a 'serve' box (copied
332
466
  to that box's local disk store, free local render). When you DO need a durable
@@ -437,6 +571,18 @@ async function main() {
437
571
  case "generate":
438
572
  await runGenerateCommand(rest);
439
573
  return;
574
+ case "inpaint":
575
+ await runInpaintCommand(rest);
576
+ return;
577
+ case "create-overlay":
578
+ case "overlay":
579
+ await runCreateOverlayCommand(rest);
580
+ return;
581
+ case "remove-background-greenscreen":
582
+ case "rmbg-green":
583
+ case "greenscreen":
584
+ await runRemoveBackgroundGreenscreenCommand(rest);
585
+ return;
440
586
  case "tts":
441
587
  await runTtsCommand(rest);
442
588
  return;
@@ -447,6 +593,14 @@ async function main() {
447
593
  case "place":
448
594
  await runPlaceCommand(rest);
449
595
  return;
596
+ case "set-media":
597
+ case "replace-media":
598
+ await runSetMediaCommand(rest);
599
+ return;
600
+ case "upload-media":
601
+ case "add-media":
602
+ await runUploadMediaCommand(rest);
603
+ return;
450
604
  case "decompose":
451
605
  await runDecomposeCommand(rest);
452
606
  return;
@@ -528,16 +682,77 @@ async function main() {
528
682
  case "annotate-file":
529
683
  await runAnnotateFileCommand(rest);
530
684
  return;
685
+ case "directory":
686
+ case "dir":
687
+ await runDirectoryCommand(rest);
688
+ return;
689
+ case "migrate-local": {
690
+ const { runMigrateLocalCommand } = await import("./devcli/migrate-local.js");
691
+ await runMigrateLocalCommand(rest);
692
+ return;
693
+ }
694
+ case "sync": {
695
+ const { runSyncCommand } = await import("./devcli/sync.js");
696
+ await runSyncCommand(rest);
697
+ return;
698
+ }
531
699
  case "raws":
532
700
  case "clips":
533
701
  await runClipsCommand(rest);
534
702
  return;
703
+ case "clipper":
704
+ case "clip-range":
705
+ await runClipperCommand(rest);
706
+ return;
535
707
  case "captions":
536
708
  await runCaptionsCommand(rest);
537
709
  return;
538
710
  case "transitions":
539
711
  await runTransitionsCommand(rest);
540
712
  return;
713
+ // Fine timeline control — local-file twins of the editor's trackpad verbs.
714
+ case "keyframes":
715
+ await runKeyframesCommand(rest);
716
+ return;
717
+ case "move":
718
+ case "nudge":
719
+ await runMoveCommand(rest);
720
+ return;
721
+ case "ripple":
722
+ await runRippleCommand(rest);
723
+ return;
724
+ case "trim":
725
+ await runTrimCommand(rest);
726
+ return;
727
+ case "restack":
728
+ case "zindex":
729
+ await runRestackCommand(rest);
730
+ return;
731
+ // Named layer-edit verbs — devcli twins of the web editor_action set.
732
+ case "set-style":
733
+ await runSetStyleCommand(rest);
734
+ return;
735
+ case "set-visual":
736
+ await runSetVisualCommand(rest);
737
+ return;
738
+ case "set-text":
739
+ await runSetTextCommand(rest);
740
+ return;
741
+ case "set-identity":
742
+ await runSetIdentityCommand(rest);
743
+ return;
744
+ case "duplicate":
745
+ await runDuplicateCommand(rest);
746
+ return;
747
+ case "split":
748
+ await runSplitCommand(rest);
749
+ return;
750
+ case "retime":
751
+ await runRetimeCommand(rest);
752
+ return;
753
+ case "set-composition":
754
+ await runSetCompositionCommand(rest);
755
+ return;
541
756
  // Local media engines + toolchain (bundled HyperFrames local commands —
542
757
  // free, no account — plus vidfarm-native lint/stills/doctor/skills).
543
758
  case "remove-background":
@@ -619,9 +834,33 @@ function commonOptions() {
619
834
  host: { type: "string", default: DEFAULT_HOST },
620
835
  "api-key": { type: "string" },
621
836
  share: { type: "string" },
622
- json: { type: "boolean", default: false }
837
+ json: { type: "boolean", default: false },
838
+ // Dual local/cloud targeting (honored by directory/files/clips/sync). The
839
+ // devcli is LOCAL-FIRST: the file directory + raws library default to the
840
+ // on-disk backend `vidfarm serve` also reads. `--cloud` targets vidfarm.cc;
841
+ // `--both` queries and merges the two, labeling each item [local]/[cloud].
842
+ local: { type: "boolean", default: false },
843
+ cloud: { type: "boolean", default: false },
844
+ both: { type: "boolean", default: false },
845
+ home: { type: "string" }
623
846
  };
624
847
  }
848
+ /** Resolve which backend a dual-target command should hit. Local-first: the
849
+ * default is local unless --cloud/--both is given (or VIDFARM_TARGET overrides). */
850
+ function resolveTarget(values) {
851
+ if (values.both)
852
+ return "both";
853
+ if (values.cloud && values.local)
854
+ return "both";
855
+ if (values.cloud)
856
+ return "cloud";
857
+ if (values.local)
858
+ return "local";
859
+ const envTarget = (process.env.VIDFARM_TARGET ?? "").trim().toLowerCase();
860
+ if (envTarget === "cloud" || envTarget === "both" || envTarget === "local")
861
+ return envTarget;
862
+ return "local";
863
+ }
625
864
  function commonContext(values) {
626
865
  return {
627
866
  host: trimTrailingSlash(String(values.host ?? DEFAULT_HOST)),
@@ -629,9 +868,27 @@ function commonContext(values) {
629
868
  apiKey: values["api-key"] ?? process.env.VIDFARM_API_KEY,
630
869
  shareToken: values.share
631
870
  },
632
- json: Boolean(values.json)
871
+ json: Boolean(values.json),
872
+ target: resolveTarget(values),
873
+ home: values.home
633
874
  };
634
875
  }
876
+ /**
877
+ * One dispatch seam for dual local/cloud REST calls. `local` runs the request
878
+ * against the in-process app (disk backend); `cloud` fetches vidfarm.cc. Every
879
+ * dual-target command routes through here so it never branches on transport.
880
+ */
881
+ async function dispatch(ctx, req, space) {
882
+ if (space === "local") {
883
+ const { localApiRequest } = await import("./devcli/local-backend.js");
884
+ return localApiRequest({ ...req, auth: ctx.auth, home: ctx.home });
885
+ }
886
+ return apiRequest({ ...req, host: ctx.host, auth: ctx.auth });
887
+ }
888
+ /** The concrete spaces a target expands to, in display order. */
889
+ function targetSpaces(target) {
890
+ return target === "both" ? ["local", "cloud"] : [target];
891
+ }
635
892
  function printJson(value) {
636
893
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
637
894
  }
@@ -654,11 +911,37 @@ function emitResult(result, json, frontend) {
654
911
  // Frontend URL builders — these are the pages a director opens in a browser.
655
912
  function editorFrontendUrl(host, templateId, forkId) {
656
913
  const base = `${host}/editor/${encodeURIComponent(templateId)}`;
657
- return forkId ? `${base}?fork=${encodeURIComponent(forkId)}` : base;
914
+ return forkId ? `${base}/fork/${encodeURIComponent(forkId)}` : base;
658
915
  }
659
916
  function discoverFrontendUrl(host) {
660
917
  return `${host}/discover`;
661
918
  }
919
+ // The devcli twin of the studio editor's "Create New" → /tools/image hand-off:
920
+ // a deep link that opens the Tools image editor seeded with a source image so
921
+ // the user can author a replacement. Mirrors buildInpaintEditUrl in
922
+ // demo/src/HyperframesStudioEditor.tsx (the /tools/image page reads
923
+ // source_image_url / source_image_name / tracer from the query string).
924
+ function inpaintFrontendUrl(host, sourceImageUrl, sourceImageName) {
925
+ const params = new URLSearchParams();
926
+ if (sourceImageUrl) {
927
+ params.set("source_image_url", sourceImageUrl);
928
+ if (sourceImageName)
929
+ params.set("source_image_name", sourceImageName);
930
+ }
931
+ const query = params.toString();
932
+ return query ? `${host}/tools/image?${query}` : `${host}/tools/image`;
933
+ }
934
+ // Basename of a media URL (query/hash stripped), for a human-readable label.
935
+ function fileNameFromMediaUrl(src) {
936
+ if (!src)
937
+ return undefined;
938
+ try {
939
+ return path.basename(new URL(src).pathname) || undefined;
940
+ }
941
+ catch {
942
+ return path.basename(src.split(/[?#]/)[0]) || undefined;
943
+ }
944
+ }
662
945
  // Small arg helper: split "k=v" pairs for --query.
663
946
  function collectKeyValues(entries) {
664
947
  const out = {};
@@ -779,7 +1062,7 @@ async function runServeCommand(argv) {
779
1062
  }
780
1063
  const base = `http://localhost:${port}`;
781
1064
  const editorPath = openTemplateId
782
- ? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `?fork=${encodeURIComponent(openForkId)}` : ""}`
1065
+ ? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `/fork/${encodeURIComponent(openForkId)}` : ""}`
783
1066
  : "/discover";
784
1067
  const openUrl = `${base}/auto-login?api_key=${encodeURIComponent(apiKey)}&redirect=${encodeURIComponent(editorPath)}`;
785
1068
  printServeBanner({ base, dataDir, openUrl, editorPath, cloud: cloudEnabled ? { host, hasKey: Boolean(cloudApiKey) } : null });
@@ -982,9 +1265,9 @@ function printPublishBanner(input) {
982
1265
  console.log("");
983
1266
  }
984
1267
  async function resolveForkId(input) {
985
- // /editor/<template_id> issues a 302 → /editor/<template_id>?fork=<id>
1268
+ // /editor/<template_id> issues a 302 → /editor/<template_id>/fork/<id>
986
1269
  // 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.
1270
+ // redirect once by hand so we can read the fork id off the path.
988
1271
  const url = `${input.host}/editor/${encodeURIComponent(input.templateId)}`;
989
1272
  const res = await fetch(url, {
990
1273
  method: "GET",
@@ -996,7 +1279,7 @@ async function resolveForkId(input) {
996
1279
  if (location) {
997
1280
  try {
998
1281
  const target = new URL(location, input.host);
999
- const fork = target.searchParams.get("fork");
1282
+ const fork = parseForkFromEditorPath(target.pathname);
1000
1283
  if (fork)
1001
1284
  return fork;
1002
1285
  }
@@ -1007,6 +1290,12 @@ async function resolveForkId(input) {
1007
1290
  }
1008
1291
  return null;
1009
1292
  }
1293
+ // Parse the fork id out of a canonical editor path
1294
+ // /editor/<template_id>/fork/<fork_id> → "<fork_id>" (else null).
1295
+ function parseForkFromEditorPath(pathname) {
1296
+ const match = pathname.match(/^\/editor\/[^/]+\/fork\/([^/]+)\/?$/);
1297
+ return match ? decodeURIComponent(match[1]) : null;
1298
+ }
1010
1299
  async function fetchCompositionFiles(input) {
1011
1300
  // video-context.json carries the source video's audio transcript and
1012
1301
  // per-scene visual descriptions so desktop agents (Claude Code, Codex)
@@ -1016,7 +1305,11 @@ async function fetchCompositionFiles(input) {
1016
1305
  // scene-annotations.json carries the detected content format + per-scene
1017
1306
  // literal + viral DNA an agent needs to REPLACE a scene from the clip library
1018
1307
  // or RE-CREATE it from scratch (recreation_prompt + recreation_notes).
1019
- const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json", "scene-annotations.json"];
1308
+ // editor-harness.json carries the technical EDITING DIRECTION (pace,
1309
+ // typography, b-roll, transitions, audio, scene roles under `.harness`) so an
1310
+ // agent can one-shot a clean, on-style edit — the "how to edit like this"
1311
+ // companion to the video-context viral DNA.
1312
+ const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json", "scene-annotations.json", "editor-harness.json"];
1020
1313
  for (const filename of files) {
1021
1314
  const target = path.join(input.dir, filename);
1022
1315
  if (existsSync(target) && !input.refetch) {
@@ -2000,7 +2293,18 @@ async function resolveReferenceUrls(ctx, refs) {
2000
2293
  return out;
2001
2294
  }
2002
2295
  function inferMediaKindFromUrl(url) {
2003
- return /\.(mp4|mov|webm|m4v|mkv)(\?|#|$)/i.test(url) ? "video" : "image";
2296
+ if (/\.(mp4|mov|webm|m4v|mkv)(\?|#|$)/i.test(url))
2297
+ return "video";
2298
+ if (/\.(mp3|wav|m4a|aac|ogg|oga|flac)(\?|#|$)/i.test(url))
2299
+ return "audio";
2300
+ return "image";
2301
+ }
2302
+ // Comma-separated shot-KIND filter (talking_head, b_roll, product_shot, …) →
2303
+ // the content_type array the /raws branch of the search endpoints honors.
2304
+ function parseContentTypeFlag(value) {
2305
+ if (typeof value !== "string" || !value.trim())
2306
+ return [];
2307
+ return value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
2004
2308
  }
2005
2309
  // Resolve a composition.html on disk from a file path or a directory holding one.
2006
2310
  function resolveCompositionHtmlPath(target) {
@@ -2090,12 +2394,55 @@ async function uploadLocalToTempStore(ctx, absPath, folder) {
2090
2394
  throw new Error("local media upload returned no durable URL.");
2091
2395
  return url;
2092
2396
  }
2093
- // Turn a --src value (http(s) URL or local file path) into a fetchable media
2094
- // URL, importing/uploading local files as described above.
2397
+ // A value like "/raws/demos/hero.mp4" or "/files/logos/logo.png" is a My Files
2398
+ // directory path (one of the five unified roots), NOT a URL or a local disk
2399
+ // path — resolve it through the directory API. (Prefixes are inlined, not a
2400
+ // module const, because `void main()` runs before this line's module scope, so a
2401
+ // top-level const here would be in its temporal dead zone during dispatch.)
2402
+ function looksLikeMyFilesPath(value) {
2403
+ const v = value.trim().toLowerCase();
2404
+ return ["/files/", "/temp/", "/raws/", "/projects/", "/approved/"].some((prefix) => v.startsWith(prefix));
2405
+ }
2406
+ // Resolve a My Files path to its durable view URL by listing the parent folder
2407
+ // via the unified directory API — the CLI equivalent of picking a file in the
2408
+ // web /library/files selector. Honors --local/--cloud/--both (first match wins).
2409
+ async function resolveMyFilesPathToUrl(ctx, value) {
2410
+ const canonical = value.trim().replace(/\/+$/, "");
2411
+ const lastSlash = canonical.lastIndexOf("/");
2412
+ const parent = lastSlash > 0 ? canonical.slice(0, lastSlash) : "/";
2413
+ const basename = canonical.slice(lastSlash + 1);
2414
+ for (const space of targetSpaces(ctx.target)) {
2415
+ let res;
2416
+ try {
2417
+ res = await dispatch(ctx, { method: "GET", path: "/api/v1/user/me/directory", query: { path: parent } }, space);
2418
+ }
2419
+ catch {
2420
+ continue;
2421
+ }
2422
+ if (!res.ok || !res.json)
2423
+ continue;
2424
+ const files = Array.isArray(res.json.files) ? res.json.files : [];
2425
+ const match = files.find((f) => String(f?.path ?? "").toLowerCase() === canonical.toLowerCase()) ??
2426
+ files.find((f) => String(f?.name ?? "") === basename);
2427
+ if (match?.viewUrl)
2428
+ return { url: String(match.viewUrl), name: String(match.name ?? basename) };
2429
+ }
2430
+ return null;
2431
+ }
2432
+ // Turn a --src value (http(s) URL, My Files path, or local file path) into a
2433
+ // fetchable media URL, importing/uploading local files as described above.
2095
2434
  async function resolvePlaceableMediaSrc(ctx, rawSrc, opts) {
2096
2435
  const value = rawSrc.trim();
2097
2436
  if (/^https?:\/\//i.test(value))
2098
2437
  return { src: value, origin: "url", detail: null };
2438
+ if (looksLikeMyFilesPath(value)) {
2439
+ const resolved = await resolveMyFilesPathToUrl(ctx, value);
2440
+ if (!resolved) {
2441
+ const parent = value.replace(/\/[^/]*$/, "") || "/";
2442
+ throw new Error(`--src "${rawSrc}" looks like a My Files path but no file was found there. Browse it with \`vidfarm directory ls ${parent}\` (add --cloud if it lives on vidfarm.cc), or pass the file's view URL.`);
2443
+ }
2444
+ return { src: resolved.url, origin: "url", detail: `myfiles:${value}` };
2445
+ }
2099
2446
  const abs = path.resolve(process.cwd(), value.startsWith("@") ? value.slice(1) : value);
2100
2447
  if (!existsSync(abs)) {
2101
2448
  throw new Error(`place --src "${rawSrc}" is neither an http(s) URL nor an existing local file.`);
@@ -2124,12 +2471,16 @@ function placeMediaOnDisk(input) {
2124
2471
  width: input.width,
2125
2472
  height: input.height,
2126
2473
  objectFit: input.objectFit,
2474
+ objectPosition: input.objectPosition,
2127
2475
  kenBurns: input.kenBurns,
2128
2476
  kenBurnsIntensity: input.kenBurnsIntensity,
2129
2477
  transition: input.transition,
2130
2478
  transitionDuration: input.transitionDuration,
2131
2479
  transitionOut: input.transitionOut,
2132
2480
  transitionOutDuration: input.transitionOutDuration,
2481
+ volume: input.volume,
2482
+ muted: input.muted,
2483
+ playbackStart: input.playbackStart,
2133
2484
  slug: input.slug
2134
2485
  };
2135
2486
  const result = input.replace
@@ -2256,6 +2607,404 @@ async function runGenerateCommand(argv) {
2256
2607
  console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind ${mediaType} --at <time>|--replace <layer_key>${RESET}`);
2257
2608
  }
2258
2609
  }
2610
+ // ── Inpaint: masked image edit ───────────────────────────────────────────────
2611
+ // The devcli twin of the web /tools/image page. The browser page rasterizes painted
2612
+ // strokes into an alpha mask (transparent = editable), composes one provider
2613
+ // instruction from a whole-image prompt + per-color region prompts via
2614
+ // buildInpaintInstruction, and POSTs to /api/v1/primitives/images/inpaint. This
2615
+ // command mirrors that exactly for scripted / agent use: it takes a source image
2616
+ // + an alpha-mask PNG + prompt(s), rebuilds the SAME INPAINTING SYSTEM WRAPPER,
2617
+ // submits the same payload shape, and polls to the finished URL — like `generate`
2618
+ // this is a cloud primitive job (apiRequest to ctx.host), not a local engine.
2619
+ // Parse repeatable --region "<label>=<prompt>" (also accepts "label: prompt").
2620
+ // Mirrors the web page's per-color mask prompts; the label is a text tag only.
2621
+ function parseInpaintRegions(values) {
2622
+ const out = [];
2623
+ for (const raw of values ?? []) {
2624
+ const v = String(raw);
2625
+ const eq = v.indexOf("=");
2626
+ const colon = v.indexOf(":");
2627
+ const sep = eq === -1 ? colon : colon === -1 ? eq : Math.min(eq, colon);
2628
+ if (sep === -1) {
2629
+ const prompt = v.trim();
2630
+ if (prompt)
2631
+ out.push({ label: `region ${out.length + 1}`, prompt });
2632
+ continue;
2633
+ }
2634
+ const label = v.slice(0, sep).trim() || `region ${out.length + 1}`;
2635
+ const prompt = v.slice(sep + 1).trim();
2636
+ if (prompt)
2637
+ out.push({ label, prompt });
2638
+ }
2639
+ return out;
2640
+ }
2641
+ // Port of buildInpaintInstruction() from src/reskin/inpaint-page.ts — keep the
2642
+ // wrapper text in sync so CLI and web edits behave identically at the provider.
2643
+ function buildCliInpaintInstruction(globalPrompt, regions, refCount) {
2644
+ const g = globalPrompt.trim();
2645
+ const rs = regions
2646
+ .map((r) => ({ label: r.label.toUpperCase(), prompt: r.prompt.trim() }))
2647
+ .filter((r) => r.prompt);
2648
+ if (!g && !rs.length)
2649
+ return "";
2650
+ const refNotes = refCount > 0
2651
+ ? "Reference image scope: " + Array.from({ length: refCount }, (_unused, i) => "reference image " + (i + 1) + " applies to the whole image instruction").join("; ") + ". Use each reference only for its stated scope and do not copy unrelated reference details into unmasked areas."
2652
+ : "";
2653
+ return [
2654
+ "INPAINTING SYSTEM WRAPPER:",
2655
+ "You are editing an existing image with an alpha mask. Treat transparent mask pixels as the only editable region. Everything outside the editable mask must remain the same image: preserve identity, layout, pose, camera angle, framing, background, lighting, colors, textures, text, logos, and all unmasked details.",
2656
+ "Do not redraw, reinterpret, beautify, crop, zoom, rotate, expand, stylize, or otherwise alter unmasked content. Do not render the UI mask colors; they are labels only.",
2657
+ "The final image must keep the original dimensions and aspect ratio unless the user explicitly asks for a dimension change.",
2658
+ g ? "Whole image prompt: " + g + ". Apply this only as guidance for the masked edit and overall consistency; it does not permit changing unmasked content." : "",
2659
+ rs.map((r) => "Masked region " + r.label + ": replace only that marked area with " + r.prompt + ". Match the surrounding perspective, lighting, grain, shadows, and edge blending.").join(" "),
2660
+ refNotes
2661
+ ].filter(Boolean).join(" ");
2662
+ }
2663
+ // Resolve a single source/mask arg to a durable URL: http(s) passes through,
2664
+ // @file / bare path uploads to the ephemeral temp store. Same rules as --ref.
2665
+ async function resolveSingleMediaUrl(ctx, value) {
2666
+ const [url] = await resolveReferenceUrls(ctx, [value]);
2667
+ if (!url)
2668
+ throw new Error(`Could not resolve media: ${value}`);
2669
+ return url;
2670
+ }
2671
+ async function runInpaintCommand(argv) {
2672
+ const parsed = parseArgs({
2673
+ args: argv,
2674
+ allowPositionals: true,
2675
+ options: {
2676
+ ...commonOptions(),
2677
+ source: { type: "string" },
2678
+ mask: { type: "string" },
2679
+ prompt: { type: "string" },
2680
+ instruction: { type: "string" },
2681
+ region: { type: "string", multiple: true },
2682
+ ref: { type: "string", multiple: true },
2683
+ "aspect-ratio": { type: "string" },
2684
+ provider: { type: "string" },
2685
+ out: { type: "string" },
2686
+ "no-wait": { type: "boolean", default: false },
2687
+ tracer: { type: "string" },
2688
+ // Fused placement onto a local composition.html (same as `generate`).
2689
+ place: { type: "string" },
2690
+ at: { type: "string" },
2691
+ replace: { type: "string" },
2692
+ track: { type: "string" },
2693
+ "ken-burns": { type: "string" },
2694
+ "ken-burns-intensity": { type: "string" },
2695
+ transition: { type: "string" },
2696
+ "transition-duration": { type: "string" },
2697
+ "transition-out": { type: "string" },
2698
+ "transition-out-duration": { type: "string" },
2699
+ "layer-key": { type: "string" }
2700
+ }
2701
+ });
2702
+ const ctx = commonContext(parsed.values);
2703
+ const sourceArg = parsed.values.source ?? parsed.positionals[0];
2704
+ if (!sourceArg)
2705
+ throw new Error("inpaint requires a source image: `vidfarm inpaint <image|url> --mask <mask.png> --prompt \"...\"`.");
2706
+ const maskArg = parsed.values.mask;
2707
+ if (!maskArg)
2708
+ throw new Error("inpaint requires --mask <alpha-png|url>: TRANSPARENT pixels mark the ONLY editable region.");
2709
+ const globalPrompt = (parsed.values.instruction ?? parsed.values.prompt ?? "").trim();
2710
+ const regions = parseInpaintRegions(parsed.values.region);
2711
+ if (!globalPrompt && !regions.length) {
2712
+ throw new Error("inpaint requires --prompt \"what to put in the masked area\" (or one or more --region \"label=prompt\").");
2713
+ }
2714
+ // Upload local source/mask/ref files to durable URLs (URLs pass through).
2715
+ const [sourceUrl, maskUrl] = await Promise.all([
2716
+ resolveSingleMediaUrl(ctx, sourceArg),
2717
+ resolveSingleMediaUrl(ctx, maskArg)
2718
+ ]);
2719
+ const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
2720
+ const instruction = buildCliInpaintInstruction(globalPrompt, regions, refs.length);
2721
+ const payload = {
2722
+ source_image_url: sourceUrl,
2723
+ mask_url: maskUrl,
2724
+ instruction,
2725
+ reference_attachments: refs,
2726
+ size: "auto",
2727
+ output_format: "png"
2728
+ };
2729
+ if (parsed.values.provider)
2730
+ payload.provider = parsed.values.provider;
2731
+ if (parsed.values["aspect-ratio"])
2732
+ payload.aspect_ratio = parsed.values["aspect-ratio"];
2733
+ const tracer = parsed.values.tracer ?? `devcli-inpaint-${Date.now().toString(36)}`;
2734
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/images/inpaint", auth: ctx.auth, body: { tracer, payload } });
2735
+ assertApiOk(submit, "inpaint");
2736
+ const jobId = submit.json?.job_id;
2737
+ const wait = !parsed.values["no-wait"];
2738
+ if (!wait || !jobId) {
2739
+ if (!ctx.json && jobId)
2740
+ console.log(`${DIM}Queued ${jobId} (tracer ${tracer}). Poll: vidfarm api GET /api/v1/user/me/jobs/${jobId} — or drop --no-wait to auto-poll.${RESET}`);
2741
+ emitResult(submit, ctx.json);
2742
+ return;
2743
+ }
2744
+ if (!ctx.json)
2745
+ console.log(`${DIM}Editing image (${jobId})… polling every 5s.${RESET}`);
2746
+ const job = await pollPrimitiveJob(ctx, jobId);
2747
+ const mediaUrl = resolveJobMediaUrl(job);
2748
+ const status = String(job?.status ?? "");
2749
+ if (!mediaUrl) {
2750
+ if (ctx.json) {
2751
+ printJson({ ok: false, job_id: jobId, status, job });
2752
+ }
2753
+ else {
2754
+ console.log(`${RED}Inpaint ${status || "did not finish"} — no media URL.${RESET}`);
2755
+ printJson(job);
2756
+ }
2757
+ process.exitCode = 1;
2758
+ return;
2759
+ }
2760
+ let outPath = null;
2761
+ if (parsed.values.out) {
2762
+ outPath = path.resolve(process.cwd(), String(parsed.values.out));
2763
+ await downloadUrlToFile(mediaUrl, outPath);
2764
+ }
2765
+ // Optional fused placement onto a local composition.html (image kind).
2766
+ let placement = null;
2767
+ if (parsed.values.place) {
2768
+ const htmlPath = resolveCompositionHtmlPath(String(parsed.values.place));
2769
+ placement = placeMediaOnDisk({
2770
+ htmlPath,
2771
+ src: mediaUrl,
2772
+ kind: "image",
2773
+ at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
2774
+ replace: parsed.values.replace,
2775
+ track: parsed.values.track ? Number(parsed.values.track) : undefined,
2776
+ kenBurns: parsed.values["ken-burns"],
2777
+ kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
2778
+ transition: parsed.values.transition,
2779
+ transitionDuration: parsed.values["transition-duration"] ? Number(parsed.values["transition-duration"]) : undefined,
2780
+ transitionOut: parsed.values["transition-out"],
2781
+ transitionOutDuration: parsed.values["transition-out-duration"] ? Number(parsed.values["transition-out-duration"]) : undefined,
2782
+ layerKey: parsed.values["layer-key"]
2783
+ });
2784
+ if (!ctx.json)
2785
+ console.log(`${GREEN}Placed as ${placement.layerKey} in ${htmlPath}. Run \`vidfarm publish --dir ${path.dirname(htmlPath)}\` (or it live-morphs under \`serve\`).${RESET}`);
2786
+ }
2787
+ if (ctx.json) {
2788
+ printJson({ ok: true, job_id: jobId, media_url: mediaUrl, out: outPath, placement });
2789
+ }
2790
+ else {
2791
+ console.log(`${GREEN}Image edit ready:${RESET} ${mediaUrl}`);
2792
+ if (outPath)
2793
+ console.log(`${DIM}Saved to ${outPath}${RESET}`);
2794
+ if (!placement)
2795
+ console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind image --at <time>|--replace <layer_key>${RESET}`);
2796
+ }
2797
+ }
2798
+ // ── Create media overlay (Vox-style) ─────────────────────────────────────────
2799
+ // The devcli twin of the media_overlay primitive: generate an AI image on a
2800
+ // forced flat key-color background (BYOK image keys) then chroma-key it out to a
2801
+ // transparent PNG in ONE cloud job. Like `generate`/`inpaint` this is a cloud
2802
+ // primitive (apiRequest to ctx.host), and it shares the fused --place placement.
2803
+ async function runCreateOverlayCommand(argv) {
2804
+ const parsed = parseArgs({
2805
+ args: argv,
2806
+ allowPositionals: true,
2807
+ options: {
2808
+ ...commonOptions(),
2809
+ prompt: { type: "string" },
2810
+ ref: { type: "string", multiple: true },
2811
+ "aspect-ratio": { type: "string" },
2812
+ "image-size": { type: "string" },
2813
+ "key-color": { type: "string" },
2814
+ tolerance: { type: "string" },
2815
+ softness: { type: "string" },
2816
+ "no-despill": { type: "boolean", default: false },
2817
+ "output-format": { type: "string" },
2818
+ provider: { type: "string" },
2819
+ model: { type: "string" },
2820
+ out: { type: "string" },
2821
+ "no-wait": { type: "boolean", default: false },
2822
+ tracer: { type: "string" },
2823
+ // Fused placement onto a local composition.html (same as `generate`).
2824
+ place: { type: "string" },
2825
+ at: { type: "string" },
2826
+ replace: { type: "string" },
2827
+ track: { type: "string" },
2828
+ "ken-burns": { type: "string" },
2829
+ "ken-burns-intensity": { type: "string" },
2830
+ transition: { type: "string" },
2831
+ "transition-duration": { type: "string" },
2832
+ "transition-out": { type: "string" },
2833
+ "transition-out-duration": { type: "string" },
2834
+ "layer-key": { type: "string" }
2835
+ }
2836
+ });
2837
+ const ctx = commonContext(parsed.values);
2838
+ const prompt = (parsed.values.prompt ?? parsed.positionals.join(" ")).trim();
2839
+ if (!prompt) {
2840
+ throw new Error("create-overlay requires a subject prompt: `vidfarm create-overlay \"a cartoon rocket ship\" [--key-color #00FF00] [--out overlay.png]`. Describe ONLY the subject — the flat key-color background is added for you.");
2841
+ }
2842
+ const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
2843
+ const payload = { prompt };
2844
+ if (refs.length)
2845
+ payload.prompt_attachments = refs.slice(0, 16);
2846
+ if (parsed.values["aspect-ratio"])
2847
+ payload.aspect_ratio = parsed.values["aspect-ratio"];
2848
+ if (parsed.values["image-size"])
2849
+ payload.image_size = parsed.values["image-size"];
2850
+ if (parsed.values["key-color"])
2851
+ payload.key_color = parsed.values["key-color"];
2852
+ if (parsed.values.tolerance)
2853
+ payload.tolerance = Number(parsed.values.tolerance);
2854
+ if (parsed.values.softness)
2855
+ payload.softness = Number(parsed.values.softness);
2856
+ if (parsed.values["no-despill"])
2857
+ payload.despill = false;
2858
+ if (parsed.values["output-format"])
2859
+ payload.output_format = parsed.values["output-format"];
2860
+ if (parsed.values.provider)
2861
+ payload.provider = parsed.values.provider;
2862
+ if (parsed.values.model)
2863
+ payload.model = parsed.values.model;
2864
+ const tracer = parsed.values.tracer ?? `devcli-overlay-${Date.now().toString(36)}`;
2865
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/images/create-overlay", auth: ctx.auth, body: { tracer, payload } });
2866
+ assertApiOk(submit, "create-overlay");
2867
+ const jobId = submit.json?.job_id;
2868
+ const wait = !parsed.values["no-wait"];
2869
+ if (!wait || !jobId) {
2870
+ if (!ctx.json && jobId)
2871
+ console.log(`${DIM}Queued ${jobId} (tracer ${tracer}). Poll: vidfarm api GET /api/v1/user/me/jobs/${jobId} — or drop --no-wait to auto-poll.${RESET}`);
2872
+ emitResult(submit, ctx.json);
2873
+ return;
2874
+ }
2875
+ if (!ctx.json)
2876
+ console.log(`${DIM}Creating transparent overlay (${jobId})… polling every 5s.${RESET}`);
2877
+ const job = await pollPrimitiveJob(ctx, jobId);
2878
+ const mediaUrl = resolveJobMediaUrl(job);
2879
+ const status = String(job?.status ?? "");
2880
+ if (!mediaUrl) {
2881
+ if (ctx.json) {
2882
+ printJson({ ok: false, job_id: jobId, status, job });
2883
+ }
2884
+ else {
2885
+ console.log(`${RED}Overlay ${status || "did not finish"} — no media URL.${RESET}`);
2886
+ printJson(job);
2887
+ }
2888
+ process.exitCode = 1;
2889
+ return;
2890
+ }
2891
+ let outPath = null;
2892
+ if (parsed.values.out) {
2893
+ outPath = path.resolve(process.cwd(), String(parsed.values.out));
2894
+ await downloadUrlToFile(mediaUrl, outPath);
2895
+ }
2896
+ // Optional fused placement onto a local composition.html (image kind).
2897
+ let placement = null;
2898
+ if (parsed.values.place) {
2899
+ const htmlPath = resolveCompositionHtmlPath(String(parsed.values.place));
2900
+ placement = placeMediaOnDisk({
2901
+ htmlPath,
2902
+ src: mediaUrl,
2903
+ kind: "image",
2904
+ at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
2905
+ replace: parsed.values.replace,
2906
+ track: parsed.values.track ? Number(parsed.values.track) : undefined,
2907
+ kenBurns: parsed.values["ken-burns"],
2908
+ kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
2909
+ transition: parsed.values.transition,
2910
+ transitionDuration: parsed.values["transition-duration"] ? Number(parsed.values["transition-duration"]) : undefined,
2911
+ transitionOut: parsed.values["transition-out"],
2912
+ transitionOutDuration: parsed.values["transition-out-duration"] ? Number(parsed.values["transition-out-duration"]) : undefined,
2913
+ layerKey: parsed.values["layer-key"]
2914
+ });
2915
+ if (!ctx.json)
2916
+ console.log(`${GREEN}Placed as ${placement.layerKey} in ${htmlPath}. Run \`vidfarm publish --dir ${path.dirname(htmlPath)}\` (or it live-morphs under \`serve\`).${RESET}`);
2917
+ }
2918
+ if (ctx.json) {
2919
+ printJson({ ok: true, job_id: jobId, media_url: mediaUrl, out: outPath, placement, greenscreen_source_url: job?.result?.greenscreen_source_url ?? null });
2920
+ }
2921
+ else {
2922
+ console.log(`${GREEN}Transparent overlay ready:${RESET} ${mediaUrl}`);
2923
+ if (outPath)
2924
+ console.log(`${DIM}Saved to ${outPath}${RESET}`);
2925
+ if (!placement)
2926
+ console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind image --at <time>|--replace <layer_key>${RESET}`);
2927
+ }
2928
+ }
2929
+ // ── Green-screen (chroma-key) background removal ──────────────────────────────
2930
+ // The devcli twin of the image_remove_background_greenscreen primitive: cheap
2931
+ // local chroma key on a flat, solid background. A cloud primitive job (distinct
2932
+ // from `vidfarm remove-background`, which is the free LOCAL ONNX matting engine).
2933
+ async function runRemoveBackgroundGreenscreenCommand(argv) {
2934
+ const parsed = parseArgs({
2935
+ args: argv,
2936
+ allowPositionals: true,
2937
+ options: {
2938
+ ...commonOptions(),
2939
+ source: { type: "string" },
2940
+ "key-color": { type: "string" },
2941
+ tolerance: { type: "string" },
2942
+ softness: { type: "string" },
2943
+ "no-despill": { type: "boolean", default: false },
2944
+ "output-format": { type: "string" },
2945
+ out: { type: "string" },
2946
+ "no-wait": { type: "boolean", default: false },
2947
+ tracer: { type: "string" }
2948
+ }
2949
+ });
2950
+ const ctx = commonContext(parsed.values);
2951
+ const sourceArg = parsed.values.source ?? parsed.positionals[0];
2952
+ if (!sourceArg) {
2953
+ throw new Error("remove-background-greenscreen requires a source image: `vidfarm remove-background-greenscreen <image|url> [--key-color #00FF00] [--out cutout.png]`.");
2954
+ }
2955
+ const sourceUrl = await resolveSingleMediaUrl(ctx, sourceArg);
2956
+ const payload = { source_image_url: sourceUrl };
2957
+ if (parsed.values["key-color"])
2958
+ payload.key_color = parsed.values["key-color"];
2959
+ if (parsed.values.tolerance)
2960
+ payload.tolerance = Number(parsed.values.tolerance);
2961
+ if (parsed.values.softness)
2962
+ payload.softness = Number(parsed.values.softness);
2963
+ if (parsed.values["no-despill"])
2964
+ payload.despill = false;
2965
+ if (parsed.values["output-format"])
2966
+ payload.output_format = parsed.values["output-format"];
2967
+ const tracer = parsed.values.tracer ?? `devcli-gsbg-${Date.now().toString(36)}`;
2968
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/images/remove-background-greenscreen", auth: ctx.auth, body: { tracer, payload } });
2969
+ assertApiOk(submit, "remove-background-greenscreen");
2970
+ const jobId = submit.json?.job_id;
2971
+ const wait = !parsed.values["no-wait"];
2972
+ if (!wait || !jobId) {
2973
+ if (!ctx.json && jobId)
2974
+ console.log(`${DIM}Queued ${jobId} (tracer ${tracer}). Poll: vidfarm api GET /api/v1/user/me/jobs/${jobId} — or drop --no-wait to auto-poll.${RESET}`);
2975
+ emitResult(submit, ctx.json);
2976
+ return;
2977
+ }
2978
+ if (!ctx.json)
2979
+ console.log(`${DIM}Keying out background (${jobId})… polling every 5s.${RESET}`);
2980
+ const job = await pollPrimitiveJob(ctx, jobId);
2981
+ const mediaUrl = resolveJobMediaUrl(job);
2982
+ const status = String(job?.status ?? "");
2983
+ if (!mediaUrl) {
2984
+ if (ctx.json) {
2985
+ printJson({ ok: false, job_id: jobId, status, job });
2986
+ }
2987
+ else {
2988
+ console.log(`${RED}Chroma key ${status || "did not finish"} — no media URL.${RESET}`);
2989
+ printJson(job);
2990
+ }
2991
+ process.exitCode = 1;
2992
+ return;
2993
+ }
2994
+ let outPath = null;
2995
+ if (parsed.values.out) {
2996
+ outPath = path.resolve(process.cwd(), String(parsed.values.out));
2997
+ await downloadUrlToFile(mediaUrl, outPath);
2998
+ }
2999
+ if (ctx.json) {
3000
+ printJson({ ok: true, job_id: jobId, media_url: mediaUrl, out: outPath, keyed_fraction: job?.result?.keyed_fraction ?? null });
3001
+ }
3002
+ else {
3003
+ console.log(`${GREEN}Transparent cut-out ready:${RESET} ${mediaUrl}`);
3004
+ if (outPath)
3005
+ console.log(`${DIM}Saved to ${outPath}${RESET}`);
3006
+ }
3007
+ }
2259
3008
  // ── Speech: tts / stt ────────────────────────────────────────────────────────
2260
3009
  // LOCAL-FIRST like `raws scan`: both commands default to running on the
2261
3010
  // user's OWN AI key (GEMINI_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY or
@@ -2837,14 +3586,24 @@ async function runPlaceCommand(argv) {
2837
3586
  width: { type: "string" },
2838
3587
  height: { type: "string" },
2839
3588
  "object-fit": { type: "string" },
3589
+ "object-position": { type: "string" },
2840
3590
  "ken-burns": { type: "string" },
2841
3591
  "ken-burns-intensity": { type: "string" },
2842
3592
  transition: { type: "string" },
2843
3593
  "transition-duration": { type: "string" },
2844
3594
  "transition-out": { type: "string" },
2845
3595
  "transition-out-duration": { type: "string" },
3596
+ // Audio (and video) level: --volume 0-2 (default 1), --muted to silence.
3597
+ // These make `place --kind audio` a first-class way to overlay a
3598
+ // narration / low-volume music bed / SFX as its own mixed track.
3599
+ volume: { type: "string" },
3600
+ muted: { type: "boolean", default: false },
3601
+ "playback-start": { type: "string" },
2846
3602
  "layer-key": { type: "string" },
2847
3603
  slug: { type: "string" },
3604
+ // Print the /tools/image "Create New" deep-link for the placed image (the
3605
+ // devcli twin of the web editor's Create New button).
3606
+ "print-inpaint-url": { type: "boolean", default: false },
2848
3607
  // Local file support: where a serve box serves /storage from, and which
2849
3608
  // temp folder cloud uploads land under.
2850
3609
  "base-url": { type: "string" },
@@ -2866,8 +3625,9 @@ async function runPlaceCommand(argv) {
2866
3625
  folder: parsed.values.folder
2867
3626
  });
2868
3627
  const src = resolvedSrc.src;
2869
- const kind = parsed.values.kind === "video" || parsed.values.kind === "image"
2870
- ? parsed.values.kind
3628
+ const explicitKind = parsed.values.kind;
3629
+ const kind = explicitKind === "video" || explicitKind === "image" || explicitKind === "audio"
3630
+ ? explicitKind
2871
3631
  : inferMediaKindFromUrl(rawSrc);
2872
3632
  const num = (v) => (v !== undefined ? Number(v) : undefined);
2873
3633
  const placement = placeMediaOnDisk({
@@ -2883,17 +3643,24 @@ async function runPlaceCommand(argv) {
2883
3643
  width: num(parsed.values.width),
2884
3644
  height: num(parsed.values.height),
2885
3645
  objectFit: parsed.values["object-fit"],
3646
+ objectPosition: parsed.values["object-position"],
2886
3647
  kenBurns: parsed.values["ken-burns"],
2887
3648
  kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
2888
3649
  transition: parsed.values.transition,
2889
3650
  transitionDuration: num(parsed.values["transition-duration"]),
2890
3651
  transitionOut: parsed.values["transition-out"],
2891
3652
  transitionOutDuration: num(parsed.values["transition-out-duration"]),
3653
+ volume: num(parsed.values.volume),
3654
+ muted: Boolean(parsed.values.muted),
3655
+ playbackStart: num(parsed.values["playback-start"]),
2892
3656
  layerKey: parsed.values["layer-key"],
2893
3657
  slug: parsed.values.slug
2894
3658
  });
3659
+ // The /tools/image "Create New" hand-off only makes sense for images (masked
3660
+ // inpaint is image-only), seeded with the freshly-placed src so the user can remix it.
3661
+ const inpaintUrl = kind === "image" ? inpaintFrontendUrl(ctx.host, src, fileNameFromMediaUrl(src)) : null;
2895
3662
  if (ctx.json) {
2896
- printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, src, media_origin: resolvedSrc.origin, timeline_gaps: placement.gaps });
3663
+ printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, src, media_origin: resolvedSrc.origin, timeline_gaps: placement.gaps, ...(inpaintUrl ? { inpaint_url: inpaintUrl } : {}) });
2897
3664
  }
2898
3665
  else {
2899
3666
  if (resolvedSrc.origin === "local-serve") {
@@ -2904,9 +3671,377 @@ async function runPlaceCommand(argv) {
2904
3671
  }
2905
3672
  console.log(`${GREEN}Placed ${kind} as ${placement.layerKey}${parsed.values.replace ? ` (replaced ${parsed.values.replace})` : ""} in ${htmlPath}.${RESET}`);
2906
3673
  console.log(`${DIM}Remaining timeline gaps: ${placement.gaps.length ? placement.gaps.map((g) => `${g.start}-${g.end}s`).join(", ") : "none"}.${RESET}`);
3674
+ if (inpaintUrl && parsed.values["print-inpaint-url"]) {
3675
+ console.log(`${DIM}Create New (remix this image in Inpaint): ${inpaintUrl}${RESET}`);
3676
+ }
2907
3677
  console.log(`${DIM}Publish with: vidfarm publish --dir ${path.dirname(htmlPath)} --fork <forkId> (or it live-morphs under \`serve\`).${RESET}`);
2908
3678
  }
2909
3679
  }
3680
+ // The devcli twin of the web editor's set_layer_media (and the inspector's
3681
+ // "Replace media" + segment). Edits a media layer IN PLACE — a --src swap keeps
3682
+ // the SAME node/key/geometry/timing (unlike `place --replace`, which deletes and
3683
+ // re-inserts with a fresh key). --src accepts a URL, a My Files path
3684
+ // (/raws/… · /files/… · …), or a local file (imported like `place --src`).
3685
+ async function runSetMediaCommand(argv) {
3686
+ const parsed = parseArgs({
3687
+ args: argv,
3688
+ allowPositionals: true,
3689
+ options: {
3690
+ ...commonOptions(),
3691
+ layer: { type: "string" },
3692
+ src: { type: "string" },
3693
+ volume: { type: "string" },
3694
+ muted: { type: "boolean" },
3695
+ "no-muted": { type: "boolean" },
3696
+ "playback-start": { type: "string" },
3697
+ "source-out": { type: "string" },
3698
+ duration: { type: "string" },
3699
+ "object-fit": { type: "string" },
3700
+ "object-position": { type: "string" },
3701
+ "ken-burns": { type: "string" },
3702
+ "ken-burns-intensity": { type: "string" },
3703
+ transition: { type: "string" },
3704
+ "transition-duration": { type: "string" },
3705
+ "transition-out": { type: "string" },
3706
+ "transition-out-duration": { type: "string" },
3707
+ "base-url": { type: "string" },
3708
+ folder: { type: "string" }
3709
+ }
3710
+ });
3711
+ const target = parsed.positionals[0];
3712
+ if (!target)
3713
+ throw new Error("set-media requires a composition path: `vidfarm set-media <dir-or-composition.html> --layer <key> [--src <url|/raws/…|file>] ...`.");
3714
+ const layer = parsed.values.layer?.trim();
3715
+ if (!layer)
3716
+ throw new Error("set-media requires --layer <layerKey|slug>.");
3717
+ const ctx = commonContext(parsed.values);
3718
+ const htmlPath = resolveCompositionHtmlPath(target);
3719
+ let resolvedSrc;
3720
+ let srcOrigin = null;
3721
+ if (parsed.values.src !== undefined) {
3722
+ const baseUrl = parsed.values["base-url"] ?? "http://localhost:3000";
3723
+ const r = await resolvePlaceableMediaSrc(ctx, String(parsed.values.src), {
3724
+ htmlPath,
3725
+ baseUrl,
3726
+ folder: parsed.values.folder
3727
+ });
3728
+ resolvedSrc = r.src;
3729
+ srcOrigin = r.origin;
3730
+ }
3731
+ const muted = parsed.values.muted ? true : (parsed.values["no-muted"] ? false : undefined);
3732
+ const num = (v) => (v !== undefined ? Number(v) : undefined);
3733
+ const result = setLayerMedia(readFileSync(htmlPath, "utf8"), layer, {
3734
+ src: resolvedSrc,
3735
+ volume: num(parsed.values.volume),
3736
+ muted,
3737
+ playbackStart: num(parsed.values["playback-start"]),
3738
+ sourceOut: num(parsed.values["source-out"]),
3739
+ duration: num(parsed.values.duration),
3740
+ objectFit: parsed.values["object-fit"],
3741
+ objectPosition: parsed.values["object-position"],
3742
+ kenBurns: parsed.values["ken-burns"],
3743
+ kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
3744
+ transition: parsed.values.transition,
3745
+ transitionDuration: num(parsed.values["transition-duration"]),
3746
+ transitionOut: parsed.values["transition-out"],
3747
+ transitionOutDuration: num(parsed.values["transition-out-duration"])
3748
+ });
3749
+ writeFileSync(htmlPath, result.html);
3750
+ const gaps = computeCompositionGaps(result.html);
3751
+ if (ctx.json) {
3752
+ printJson({ ok: true, layer_key: result.layerKey, kind: result.kind, changed: result.changed, path: htmlPath, ...(resolvedSrc ? { src: resolvedSrc, media_origin: srcOrigin } : {}), timeline_gaps: gaps });
3753
+ return;
3754
+ }
3755
+ if (srcOrigin === "local-serve")
3756
+ console.log(`${DIM}Local file → serve disk store; renders on this serve box only.${RESET}`);
3757
+ else if (srcOrigin === "cloud-temp")
3758
+ console.log(`${DIM}Local file → ephemeral temp store on ${ctx.host}.${RESET}`);
3759
+ console.log(`${GREEN}Edited ${result.kind} layer ${result.layerKey} in place (${result.changed.join(" ")}).${RESET}`);
3760
+ console.log(`${DIM}Remaining timeline gaps: ${gaps.length ? gaps.map((g) => `${g.start}-${g.end}s`).join(", ") : "none"}.${RESET}`);
3761
+ console.log(`${DIM}Publish with: vidfarm publish --dir ${path.dirname(htmlPath)} --fork <forkId> (or it live-morphs under \`serve\`).${RESET}`);
3762
+ }
3763
+ // The devcli twin of the /editor "Upload media" button: bring media in via one
3764
+ // of three sources and (for computer/files) drop it onto a composition, honoring
3765
+ // a chosen segment (--in / --duration). "raw" imports a video into the Raws
3766
+ // library (optionally AI-clipped) under a named --folder.
3767
+ async function runUploadMediaCommand(argv) {
3768
+ const parsed = parseArgs({
3769
+ args: argv,
3770
+ allowPositionals: true,
3771
+ options: {
3772
+ ...commonOptions(),
3773
+ from: { type: "string" },
3774
+ into: { type: "string" },
3775
+ at: { type: "string" },
3776
+ // The source in-point (n-of-N segment start). --in is the friendly alias.
3777
+ in: { type: "string" },
3778
+ "playback-start": { type: "string" },
3779
+ duration: { type: "string" },
3780
+ kind: { type: "string" },
3781
+ track: { type: "string" },
3782
+ replace: { type: "string" },
3783
+ "base-url": { type: "string" },
3784
+ // raw mode: destination Raws folder + AI-clip controls.
3785
+ folder: { type: "string" },
3786
+ clip: { type: "boolean", default: false },
3787
+ prompt: { type: "string" },
3788
+ provider: { type: "string" }
3789
+ }
3790
+ });
3791
+ const source = parsed.positionals[0];
3792
+ if (!source) {
3793
+ throw new Error("upload-media requires a source: `vidfarm upload-media <file|url|id> --from computer|files|raw [--into <composition>] [--at N --in N --duration N] [--folder NAME --clip]`.");
3794
+ }
3795
+ const ctx = commonContext(parsed.values);
3796
+ const explicitFrom = parsed.values.from?.trim();
3797
+ const localCandidate = path.resolve(process.cwd(), source.replace(/^@/, ""));
3798
+ const from = explicitFrom ?? (existsSync(localCandidate) ? "computer" : "files");
3799
+ if (from !== "computer" && from !== "files" && from !== "raw") {
3800
+ throw new Error(`--from must be computer | files | raw (got "${from}").`);
3801
+ }
3802
+ // ── Clip from Raw: import a video URL into the raws library (whole or AI-clipped). ──
3803
+ if (from === "raw") {
3804
+ if (!/^https?:\/\//i.test(source))
3805
+ throw new Error("upload-media --from raw needs a video URL (TikTok / YouTube / Instagram / X / direct link).");
3806
+ const clip = Boolean(parsed.values.clip);
3807
+ const folderName = parsed.values.folder?.trim();
3808
+ const body = { source_url: source };
3809
+ if (clip) {
3810
+ body.prompt = parsed.values.prompt?.trim() ?? "";
3811
+ if (parsed.values.provider)
3812
+ body.provider = parsed.values.provider;
3813
+ }
3814
+ else {
3815
+ body.import_only = true;
3816
+ }
3817
+ if (folderName) {
3818
+ body.folder_path = folderName;
3819
+ body.tracer = folderName;
3820
+ }
3821
+ const res = await apiRequest({ method: "POST", host: ctx.host, path: "/raws/scan", auth: ctx.auth, body });
3822
+ assertApiOk(res, "raws import");
3823
+ const dest = folderName ? `/raws/${folderName}` : "your raws library";
3824
+ if (ctx.json) {
3825
+ printJson({ ok: true, ...res.json });
3826
+ return;
3827
+ }
3828
+ console.log(`${GREEN}${clip ? "Started clip scan" : "Imported video"} → ${dest} (scan ${res.json?.scan_id ?? "?"}, status ${res.json?.status ?? "?"}).${RESET}`);
3829
+ if (clip)
3830
+ console.log(`${DIM}AI clipping runs on your saved key; new clips appear in the Raws library shortly.${RESET}`);
3831
+ return;
3832
+ }
3833
+ // ── Upload from Computer / Attach from Files → a placeable media URL. ──
3834
+ const into = parsed.values.into;
3835
+ let mediaUrl;
3836
+ let mediaName;
3837
+ if (from === "files") {
3838
+ if (/^https?:\/\//i.test(source)) {
3839
+ mediaUrl = source;
3840
+ mediaName = fileNameFromMediaUrl(source);
3841
+ }
3842
+ else if (looksLikeMyFilesPath(source)) {
3843
+ // A unified directory path (/raws/… · /temp/… · /projects/… · /approved/… ·
3844
+ // /files/…) — the CLI twin of picking a file in the /library/files selector.
3845
+ const resolved = await resolveMyFilesPathToUrl(ctx, source);
3846
+ if (!resolved)
3847
+ throw new Error(`No My Files item at path "${source}". Browse with \`vidfarm directory ls ${source.replace(/\/[^/]*$/, "") || "/"}\` (add --cloud for vidfarm.cc), or pass an id/name/URL.`);
3848
+ mediaUrl = resolved.url;
3849
+ mediaName = resolved.name;
3850
+ }
3851
+ else {
3852
+ const list = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
3853
+ assertApiOk(list, "My Files list");
3854
+ const items = (list.json?.attachments ?? list.json?.files ?? []);
3855
+ const match = items.find((a) => a.id === source || a.attachmentId === source || a.attachment_id === source) ??
3856
+ items.find((a) => (a.fileName ?? a.file_name ?? a.name) === source);
3857
+ if (!match)
3858
+ throw new Error(`No My Files item with id or name "${source}". Run \`vidfarm files\` to list, or pass a public URL.`);
3859
+ mediaUrl = String(match.viewUrl ?? match.view_url ?? match.publicUrl ?? match.url ?? "");
3860
+ mediaName = (match.fileName ?? match.file_name ?? match.name);
3861
+ if (!mediaUrl)
3862
+ throw new Error("That My Files item has no resolvable URL.");
3863
+ }
3864
+ }
3865
+ else {
3866
+ // computer: resolve a local file → serve-disk store (when --into is a serve
3867
+ // box's fork) or the ephemeral temp store, exactly like `place --src <file>`.
3868
+ const baseUrl = parsed.values["base-url"] ?? "http://localhost:3000";
3869
+ const htmlPath = into ? resolveCompositionHtmlPath(into) : path.resolve(process.cwd(), "composition.html");
3870
+ const resolved = await resolvePlaceableMediaSrc(ctx, source, { htmlPath, baseUrl, folder: "uploads" });
3871
+ mediaUrl = resolved.src;
3872
+ mediaName = path.basename(localCandidate);
3873
+ }
3874
+ // No target composition → just report the URL (place it later with `place`).
3875
+ if (!into) {
3876
+ if (ctx.json) {
3877
+ printJson({ ok: true, from, src: mediaUrl, name: mediaName ?? null });
3878
+ return;
3879
+ }
3880
+ console.log(`${GREEN}Resolved media: ${mediaUrl}${RESET}`);
3881
+ console.log(`${DIM}Place it with: vidfarm place <composition> --src "${mediaUrl}" [--at N --playback-start N --duration N]${RESET}`);
3882
+ return;
3883
+ }
3884
+ const htmlPath = resolveCompositionHtmlPath(into);
3885
+ const explicitKind = parsed.values.kind;
3886
+ const kind = explicitKind === "video" || explicitKind === "image" || explicitKind === "audio" ? explicitKind : inferMediaKindFromUrl(mediaUrl);
3887
+ const num = (v) => (v !== undefined ? Number(v) : undefined);
3888
+ const inPoint = parsed.values.in ?? parsed.values["playback-start"];
3889
+ const placement = placeMediaOnDisk({
3890
+ htmlPath,
3891
+ src: mediaUrl,
3892
+ kind,
3893
+ at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
3894
+ duration: num(parsed.values.duration),
3895
+ replace: parsed.values.replace,
3896
+ track: num(parsed.values.track),
3897
+ playbackStart: inPoint !== undefined ? Number(inPoint) : undefined
3898
+ });
3899
+ if (ctx.json) {
3900
+ printJson({ ok: true, from, layer_key: placement.layerKey, path: htmlPath, src: mediaUrl, timeline_gaps: placement.gaps });
3901
+ return;
3902
+ }
3903
+ console.log(`${GREEN}Added ${kind} as ${placement.layerKey}${parsed.values.replace ? ` (replaced ${parsed.values.replace})` : ""} in ${htmlPath}.${RESET}`);
3904
+ console.log(`${DIM}Remaining timeline gaps: ${placement.gaps.length ? placement.gaps.map((g) => `${g.start}-${g.end}s`).join(", ") : "none"}.${RESET}`);
3905
+ console.log(`${DIM}Publish with: vidfarm publish --dir ${path.dirname(htmlPath)} --fork <forkId> (or it live-morphs under \`serve\`).${RESET}`);
3906
+ }
3907
+ // The devcli twin of the web /tools/clipper page: cut an EXACT subrange out of a
3908
+ // source video (URL or local file) straight into the raws library. Trims just the
3909
+ // [--start, --end] window via POST /raws/clip-range — reusable, multi-clip (run it
3910
+ // again with a new range for another clip under the same --tracer/--folder).
3911
+ // Times accept seconds (12.5) or HH:MM:SS:NNNN / MM:SS / SS[:NNNN] timecodes.
3912
+ function parseClipTime(value, label) {
3913
+ const raw = (value ?? "").trim();
3914
+ if (!raw)
3915
+ throw new Error(`clipper needs --${label} (seconds or HH:MM:SS:NNNN).`);
3916
+ if (raw.includes(":")) {
3917
+ const parts = raw.split(":").map((p) => p.trim());
3918
+ let h = 0, m = 0, s = 0, frac = 0;
3919
+ if (parts.length === 4) {
3920
+ [h, m, s] = parts.slice(0, 3).map(Number);
3921
+ frac = Number(parts[3]) / Math.pow(10, parts[3].length || 4);
3922
+ }
3923
+ else if (parts.length === 3) {
3924
+ [m, s] = parts.slice(0, 2).map(Number);
3925
+ frac = Number(parts[2]) / Math.pow(10, parts[2].length || 4);
3926
+ }
3927
+ else if (parts.length === 2) {
3928
+ s = Number(parts[0]);
3929
+ frac = Number(parts[1]) / Math.pow(10, parts[1].length || 4);
3930
+ }
3931
+ else {
3932
+ s = Number(parts[0]);
3933
+ }
3934
+ const total = h * 3600 + m * 60 + s + frac;
3935
+ if (!Number.isFinite(total))
3936
+ throw new Error(`--${label} "${raw}" isn't a valid timecode.`);
3937
+ return total;
3938
+ }
3939
+ const n = Number(raw);
3940
+ if (!Number.isFinite(n))
3941
+ throw new Error(`--${label} "${raw}" isn't a valid number of seconds.`);
3942
+ return n;
3943
+ }
3944
+ async function runClipperCommand(argv) {
3945
+ const parsed = parseArgs({
3946
+ args: argv,
3947
+ allowPositionals: true,
3948
+ options: {
3949
+ ...commonOptions(),
3950
+ start: { type: "string" },
3951
+ in: { type: "string" },
3952
+ end: { type: "string" },
3953
+ out: { type: "string" },
3954
+ tracer: { type: "string" },
3955
+ folder: { type: "string" },
3956
+ name: { type: "string" }
3957
+ }
3958
+ });
3959
+ const source = parsed.positionals[0];
3960
+ if (!source) {
3961
+ throw new Error("clipper requires a source: `vidfarm clipper <video-url|file> [--start <t> --end <t>] [--tracer NAME] [--folder NAME] [--name NAME]`. Omit --start/--end to import the WHOLE video.");
3962
+ }
3963
+ const ctx = commonContext(parsed.values);
3964
+ const rawStart = (parsed.values.start ?? parsed.values.in);
3965
+ const rawEnd = (parsed.values.end ?? parsed.values.out);
3966
+ const hasStart = rawStart !== undefined && String(rawStart).trim() !== "";
3967
+ const hasEnd = rawEnd !== undefined && String(rawEnd).trim() !== "";
3968
+ // Default = ENTIRETY: no range → import the whole source. A range trims exactly.
3969
+ const wholeVideo = !hasStart && !hasEnd;
3970
+ if (!wholeVideo && (!hasStart || !hasEnd)) {
3971
+ throw new Error("clipper needs BOTH --start and --end for an exact subrange, or NEITHER to import the whole video.");
3972
+ }
3973
+ const body = {};
3974
+ if (parsed.values.tracer)
3975
+ body.tracer = String(parsed.values.tracer);
3976
+ if (parsed.values.folder)
3977
+ body.folder_path = String(parsed.values.folder);
3978
+ if (parsed.values.name)
3979
+ body.name = String(parsed.values.name);
3980
+ // Resolve the source into the body once: a URL rides as source_url; a local
3981
+ // file is staged into the temp store and referenced by temp_file_id.
3982
+ if (/^https?:\/\//i.test(source)) {
3983
+ body.source_url = source;
3984
+ }
3985
+ else {
3986
+ const abs = path.resolve(process.cwd(), source.replace(/^@/, ""));
3987
+ if (!existsSync(abs))
3988
+ throw new Error(`No such file or URL: ${source}`);
3989
+ const form = new FormData();
3990
+ form.append("file", new Blob([readFileSync(abs)]), path.basename(abs));
3991
+ const up = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
3992
+ const upText = await up.text();
3993
+ let upJson = null;
3994
+ try {
3995
+ upJson = upText ? JSON.parse(upText) : null;
3996
+ }
3997
+ catch {
3998
+ upJson = null;
3999
+ }
4000
+ if (!up.ok)
4001
+ throw new Error(`Upload failed (HTTP ${up.status}): ${(upJson && upJson.error) || upText.slice(0, 200)}`);
4002
+ const tempFileId = upJson?.file?.id ?? upJson?.id ?? upJson?.file_id;
4003
+ if (!tempFileId)
4004
+ throw new Error("Upload did not return a temporary file id.");
4005
+ body.temp_file_id = String(tempFileId);
4006
+ if (!body.name)
4007
+ body.name = path.basename(abs);
4008
+ }
4009
+ if (wholeVideo) {
4010
+ // Entirety → import the whole source as ONE raw (no trim/re-encode), the same
4011
+ // import_only path as `upload-media --from raw` and the /tools/clipper page's
4012
+ // Whole-video button.
4013
+ body.import_only = true;
4014
+ const res = await apiRequest({ method: "POST", host: ctx.host, path: "/raws/scan", auth: ctx.auth, body });
4015
+ assertApiOk(res, "clipper (whole-video import)");
4016
+ if (ctx.json) {
4017
+ printJson({ ok: true, mode: "whole", ...res.json });
4018
+ return;
4019
+ }
4020
+ const j = res.json;
4021
+ const dest = parsed.values.folder ? `/raws/${String(parsed.values.folder)}` : "your raws library";
4022
+ console.log(`${GREEN}Imported the whole video → ${dest} (scan ${j?.scan_id ?? "?"}, status ${j?.status ?? "?"}).${RESET}`);
4023
+ console.log(`${DIM}Grab an exact subrange instead with --start <t> --end <t>.${RESET}`);
4024
+ return;
4025
+ }
4026
+ const startSec = parseClipTime(rawStart, "start");
4027
+ const endSec = parseClipTime(rawEnd, "end");
4028
+ if (endSec <= startSec)
4029
+ throw new Error(`--end (${endSec}s) must be greater than --start (${startSec}s).`);
4030
+ body.start_sec = Number(startSec.toFixed(3));
4031
+ body.end_sec = Number(endSec.toFixed(3));
4032
+ const res = await apiRequest({ method: "POST", host: ctx.host, path: "/raws/clip-range", auth: ctx.auth, body });
4033
+ assertApiOk(res, "clipper");
4034
+ if (ctx.json) {
4035
+ printJson({ ok: true, mode: "range", ...res.json });
4036
+ return;
4037
+ }
4038
+ const j = res.json;
4039
+ const dest = j?.folder_path ? `/raws/${j.folder_path}` : "your raws library";
4040
+ console.log(`${GREEN}Clipped ${(j?.duration_sec ?? (endSec - startSec)).toFixed?.(2) ?? j?.duration_sec}s → ${dest} (clip ${j?.clip_id ?? "?"}).${RESET}`);
4041
+ if (j?.view_url)
4042
+ console.log(`${DIM}Preview: ${new URL(j.view_url, ctx.host).toString()}${RESET}`);
4043
+ console.log(`${DIM}Grab another range from the same source with the same --tracer/--folder.${RESET}`);
4044
+ }
2910
4045
  async function runPullCommand(argv) {
2911
4046
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), dir: { type: "string" }, refetch: { type: "boolean", default: false } } });
2912
4047
  const forkId = parsed.positionals[0];
@@ -2923,8 +4058,9 @@ async function runPullCommand(argv) {
2923
4058
  const hasContext = existsSync(path.join(dir, "video-context.json"));
2924
4059
  const hasCast = existsSync(path.join(dir, "cast.json"));
2925
4060
  const hasAnnotations = existsSync(path.join(dir, "scene-annotations.json"));
4061
+ const hasHarness = existsSync(path.join(dir, "editor-harness.json"));
2926
4062
  if (ctx.json) {
2927
- printJson({ ok: true, fork_id: forkId, dir, has_video_context: hasContext, has_cast: hasCast, has_scene_annotations: hasAnnotations, ...info });
4063
+ printJson({ ok: true, fork_id: forkId, dir, has_video_context: hasContext, has_cast: hasCast, has_scene_annotations: hasAnnotations, has_editor_harness: hasHarness, ...info });
2928
4064
  return;
2929
4065
  }
2930
4066
  console.log(`${GREEN}Pulled ${forkId} → ${dir}${RESET}`);
@@ -2935,7 +4071,7 @@ async function runPullCommand(argv) {
2935
4071
  const flags = [layer.is_timeline_proxy ? "proxy" : null, layer.is_full_canvas ? "full-canvas" : null].filter(Boolean).join(",");
2936
4072
  console.log(` ${layer.key} ${DIM}${layer.kind ?? "?"} ${layer.start}-${(layer.start + layer.duration).toFixed(2)}s track${layer.track}${flags ? ` [${flags}]` : ""}${layer.slug ? ` slug=${layer.slug}` : ""}${RESET}`);
2937
4073
  }
2938
- console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}, ${hasAnnotations ? "scene-annotations.json ✓" : "scene-annotations.json ✗"}.${RESET}`);
4074
+ console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}, ${hasAnnotations ? "scene-annotations.json ✓" : "scene-annotations.json ✗"}, ${hasHarness ? "editor-harness.json ✓ (style brief under .harness)" : "editor-harness.json ✗"}.${RESET}`);
2939
4075
  console.log(`${DIM}Next: vidfarm generate video --prompt "..." --aspect-ratio ${info.aspect_ratio ?? "9:16"} --place ${dir} --at <gap start>|--replace <layer_key>${RESET}`);
2940
4076
  }
2941
4077
  async function runVisibilityCommand(argv) {
@@ -2951,17 +4087,20 @@ async function runVisibilityCommand(argv) {
2951
4087
  emitResult(result, ctx.json, templateId ? [["Open editor ", editorFrontendUrl(ctx.host, templateId, forkId)]] : undefined);
2952
4088
  }
2953
4089
  async function runCloneCommand(argv) {
2954
- const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), version: { type: "string" }, title: { type: "string" } } });
4090
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), version: { type: "string" }, title: { type: "string" }, from: { type: "string" } } });
2955
4091
  const forkId = parsed.positionals[0];
2956
4092
  if (!forkId)
2957
4093
  throw new Error("clone requires a fork id.");
4094
+ const from = parsed.values.from === "default" ? "default" : "current";
2958
4095
  const ctx = commonContext(parsed.values);
4096
+ // Unified fork endpoint: from=current branches this working copy (default),
4097
+ // from=default starts fresh from the template's original default composition.
2959
4098
  const result = await apiRequest({
2960
4099
  method: "POST",
2961
4100
  host: ctx.host,
2962
- path: `/api/v1/compositions/${encodeURIComponent(forkId)}/clone`,
4101
+ path: `/api/v1/compositions/${encodeURIComponent(forkId)}/fork`,
2963
4102
  auth: ctx.auth,
2964
- body: { parent_version: parsed.values.version, title: parsed.values.title }
4103
+ body: { from, parent_version: parsed.values.version, title: parsed.values.title }
2965
4104
  });
2966
4105
  assertApiOk(result, "clone");
2967
4106
  const newForkId = result.json?.fork_id;
@@ -3214,32 +4353,364 @@ async function runDownloadCommand(argv) {
3214
4353
  // This is the same filesystem the /editor AI agent browses via the browse_files
3215
4354
  // tool, so an agent CLI (Claude Code / Codex) can find assets the same way.
3216
4355
  async function runFilesCommand(argv) {
3217
- const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" }, search: { type: "string" } } });
4356
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" }, search: { type: "string" }, "content-type": { type: "string" } } });
3218
4357
  const ctx = commonContext(parsed.values);
3219
4358
  const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
3220
- if (parsed.values.search) {
3221
- // Meaning-first lookup: keyword + vector search over every file's
3222
- // name/folder/notes (BYOK embeddings; keyword-only without a saved key).
3223
- const result = await apiRequest({
3224
- method: "POST",
3225
- host: ctx.host,
3226
- path: "/api/v1/user/me/attachments/search",
3227
- auth: ctx.auth,
3228
- body: { query: String(parsed.values.search), ...(folderFilter ? { folder_path: folderFilter } : {}) }
3229
- });
3230
- assertApiOk(result, "files --search");
4359
+ const contentTypes = parseContentTypeFlag(parsed.values["content-type"]);
4360
+ const spaces = targetSpaces(ctx.target);
4361
+ const multi = spaces.length > 1;
4362
+ for (const space of spaces) {
4363
+ if (multi && !ctx.json)
4364
+ console.log(`${BOLD}── ${space} ──${RESET}`);
4365
+ if (parsed.values.search) {
4366
+ // Meaning-first lookup: keyword + vector search over every file's
4367
+ // name/folder/notes (BYOK embeddings; keyword-only without a saved key).
4368
+ // content_type filters the /raws branch by shot-KIND tag (server-honored).
4369
+ const result = await dispatch(ctx, {
4370
+ method: "POST",
4371
+ path: "/api/v1/user/me/attachments/search",
4372
+ body: { query: String(parsed.values.search), ...(folderFilter ? { folder_path: folderFilter } : {}), ...(contentTypes.length ? { content_type: contentTypes } : {}) }
4373
+ }, space);
4374
+ assertApiOk(result, `files --search (${space})`);
4375
+ emitResult(result, ctx.json);
4376
+ continue;
4377
+ }
4378
+ const result = await dispatch(ctx, { method: "GET", path: "/api/v1/user/me/attachments" }, space);
4379
+ assertApiOk(result, `files (${space})`);
4380
+ if (folderFilter && result.json && Array.isArray(result.json.attachments)) {
4381
+ result.json = {
4382
+ ...result.json,
4383
+ attachments: result.json.attachments.filter((file) => (file?.folderPath ?? "") === folderFilter)
4384
+ };
4385
+ }
3231
4386
  emitResult(result, ctx.json);
4387
+ }
4388
+ }
4389
+ // ── directory (unified virtual file tree) ────────────────────────────────────
4390
+ // Browse + search the file roots (/files · /temp · /raws · /projects · /approved)
4391
+ // as one tree. LOCAL-FIRST: by default this reads the on-disk backend that
4392
+ // `vidfarm serve` also serves (so a `clips scan` is instantly browsable); pass
4393
+ // --cloud to read vidfarm.cc, or --both to merge the two ([local]/[cloud] tags).
4394
+ const DIRECTORY_HELP = `vidfarm directory — browse the unified file tree (/files · /temp · /raws · /projects · /approved)
4395
+
4396
+ Target: --local (default) · --cloud (vidfarm.cc) · --both (merge, tagged [local]/[cloud])
4397
+ --home <dir> or VIDFARM_HOME (local backend home; default ~/.vidfarm)
4398
+
4399
+
4400
+ directory ls [path] List folders + files under a path (default "/") → GET /api/v1/user/me/directory
4401
+ --limit <n> --offset <n> Paginate (server returns next_offset when more)
4402
+ --json Raw JSON response
4403
+
4404
+ directory search <query> Ranked search across the tree → POST /api/v1/user/me/directory/search
4405
+ --path <scope> Scope the search to a subtree (e.g. /raws/demos)
4406
+ --mode auto|semantic|substring|path Signal(s) to use (default: auto)
4407
+ --content-type <a,b> Filter the /raws branch by shot-KIND tag (comma-separated:
4408
+ talking_head, b_roll, product_shot, screen_recording, demo,
4409
+ reaction, interview, establishing, lifestyle, text_graphic)
4410
+ --limit <n> --json
4411
+
4412
+ directory rename <path> <new-name> Rename a file or folder in /files or /temp → POST /api/v1/user/me/directory/rename
4413
+ --file-id <id> Rename a FILE (its display name); omit to rename the folder at <path>
4414
+ --json Raw JSON response
4415
+ e.g. vidfarm directory rename /files/characters/zara zara-fox
4416
+ vidfarm directory rename /files/characters/zara/notes.md character_about.md --file-id att_123
4417
+
4418
+ directory save-url <url> Save a durable media URL INTO My Files at a folder → POST /api/v1/user/me/attachments/from-url
4419
+ --folder <path> Destination folder under /files (e.g. inpaints, promos)
4420
+ --as <name> Name the saved file (else derived from the URL)
4421
+ --notes <text> Metadata notes (what it is / when to use it) — vector-embedded
4422
+ --json Raw JSON response
4423
+ e.g. vidfarm directory save-url "https://…/inpaint-out.png" --folder inpaints --as hero.png
4424
+ (server fetches the URL — handles cross-origin S3; the CLI twin of the pop-panel "Save to Files")
4425
+
4426
+ Aliases: directory | dir · Auth: --api-key <key> or VIDFARM_API_KEY, --host <url>
4427
+ Move a raw between folders with: vidfarm api PATCH /raws/<clipId> --data '{"folder_path":"…"}'`;
4428
+ async function runDirectoryCommand(argv) {
4429
+ const sub = argv[0];
4430
+ const rest = argv.slice(1);
4431
+ switch (sub) {
4432
+ case "ls":
4433
+ case "list":
4434
+ return runDirectoryLs(rest);
4435
+ case "search":
4436
+ case "find":
4437
+ return runDirectorySearch(rest);
4438
+ case "rename":
4439
+ case "mv":
4440
+ return runDirectoryRename(rest);
4441
+ case "save-url":
4442
+ case "from-url":
4443
+ case "import-url":
4444
+ return runDirectorySaveUrl(rest);
4445
+ case undefined:
4446
+ case "help":
4447
+ case "--help":
4448
+ case "-h":
4449
+ console.log(DIRECTORY_HELP);
4450
+ return;
4451
+ default:
4452
+ console.error(`Unknown directory subcommand: ${sub}\n`);
4453
+ console.log(DIRECTORY_HELP);
4454
+ process.exitCode = 1;
4455
+ }
4456
+ }
4457
+ async function runDirectoryLs(argv) {
4458
+ const parsed = parseArgs({
4459
+ args: argv,
4460
+ allowPositionals: true,
4461
+ options: { ...commonOptions(), limit: { type: "string" }, offset: { type: "string" } }
4462
+ });
4463
+ const ctx = commonContext(parsed.values);
4464
+ const dirPath = parsed.positionals[0] ?? "/";
4465
+ const spaces = targetSpaces(ctx.target);
4466
+ const parts = [];
4467
+ for (const space of spaces) {
4468
+ const result = await dispatch(ctx, {
4469
+ method: "GET",
4470
+ path: "/api/v1/user/me/directory",
4471
+ query: {
4472
+ path: dirPath,
4473
+ limit: parsed.values.limit,
4474
+ offset: parsed.values.offset
4475
+ }
4476
+ }, space);
4477
+ assertApiOk(result, `directory ls (${space})`);
4478
+ parts.push({ space, data: result.json });
4479
+ }
4480
+ const merged = mergeListings(parts);
4481
+ if (ctx.json) {
4482
+ printJson(merged);
3232
4483
  return;
3233
4484
  }
3234
- const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
3235
- assertApiOk(result, "files");
3236
- if (folderFilter && result.json && Array.isArray(result.json.attachments)) {
3237
- result.json = {
3238
- ...result.json,
3239
- attachments: result.json.attachments.filter((file) => (file?.folderPath ?? "") === folderFilter)
3240
- };
4485
+ printDirectoryListing(merged, dirPath, spaces.length > 1 ? "both" : spaces[0]);
4486
+ }
4487
+ async function runDirectorySearch(argv) {
4488
+ const parsed = parseArgs({
4489
+ args: argv,
4490
+ allowPositionals: true,
4491
+ options: { ...commonOptions(), path: { type: "string" }, mode: { type: "string" }, "content-type": { type: "string" }, limit: { type: "string" } }
4492
+ });
4493
+ const ctx = commonContext(parsed.values);
4494
+ const query = parsed.positionals.join(" ").trim();
4495
+ if (!query) {
4496
+ 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] [--cloud|--both]');
4497
+ }
4498
+ const mode = parsed.values.mode;
4499
+ if (mode && !["auto", "semantic", "substring", "path"].includes(mode)) {
4500
+ throw new Error(`--mode must be auto | semantic | substring | path (got "${mode}")`);
4501
+ }
4502
+ const contentTypes = parseContentTypeFlag(parsed.values["content-type"]);
4503
+ const limit = parsed.values.limit != null ? Number(parsed.values.limit) : undefined;
4504
+ const spaces = targetSpaces(ctx.target);
4505
+ const body = {
4506
+ query,
4507
+ ...(parsed.values.path ? { path: String(parsed.values.path) } : {}),
4508
+ ...(mode ? { mode } : {}),
4509
+ ...(contentTypes.length ? { content_type: contentTypes } : {}),
4510
+ ...(limit != null && Number.isFinite(limit) ? { limit } : {})
4511
+ };
4512
+ const parts = [];
4513
+ for (const space of spaces) {
4514
+ const result = await dispatch(ctx, { method: "POST", path: "/api/v1/user/me/directory/search", body }, space);
4515
+ assertApiOk(result, `directory search (${space})`);
4516
+ parts.push({ space, data: result.json });
3241
4517
  }
3242
- emitResult(result, ctx.json);
4518
+ const merged = mergeSearch(parts, limit);
4519
+ if (ctx.json) {
4520
+ printJson(merged);
4521
+ return;
4522
+ }
4523
+ printDirectorySearch(merged, query, spaces.length > 1 ? "both" : spaces[0]);
4524
+ }
4525
+ async function runDirectoryRename(argv) {
4526
+ const parsed = parseArgs({
4527
+ args: argv,
4528
+ allowPositionals: true,
4529
+ options: { ...commonOptions(), "file-id": { type: "string" } }
4530
+ });
4531
+ const ctx = commonContext(parsed.values);
4532
+ const targetPath = parsed.positionals[0];
4533
+ const newName = parsed.positionals.slice(1).join(" ").trim();
4534
+ if (!targetPath || !newName) {
4535
+ throw new Error('directory rename requires a path and a new name: vidfarm directory rename <path> <new-name> [--file-id <id>]');
4536
+ }
4537
+ const fileId = parsed.values["file-id"];
4538
+ // Rename is a mutation — apply it in exactly one space (the first the target
4539
+ // resolves to; local unless --cloud). "both" doesn't fan a rename out.
4540
+ const space = targetSpaces(ctx.target)[0];
4541
+ const result = await dispatch(ctx, {
4542
+ method: "POST",
4543
+ path: "/api/v1/user/me/directory/rename",
4544
+ body: {
4545
+ path: targetPath,
4546
+ new_name: newName,
4547
+ ...(fileId ? { file_id: fileId } : {})
4548
+ }
4549
+ }, space);
4550
+ assertApiOk(result, "directory rename");
4551
+ if (ctx.json) {
4552
+ printJson(result.json ?? result.text);
4553
+ return;
4554
+ }
4555
+ const newPath = result.json?.path ?? "";
4556
+ console.log(`${GREEN}✓${RESET} Renamed to ${BOLD}${newName}${RESET} ${DIM}[${space}]${RESET}${newPath ? ` ${DIM}${newPath}${RESET}` : ""}`);
4557
+ }
4558
+ // Save a durable media URL (e.g. a finished job's output) INTO My Files at a
4559
+ // folder — the CLI twin of the pop-panel "Save to Files" picker. Server fetches
4560
+ // the URL (handles cross-origin S3), so no local download round-trip needed.
4561
+ async function runDirectorySaveUrl(argv) {
4562
+ const parsed = parseArgs({
4563
+ args: argv,
4564
+ allowPositionals: true,
4565
+ options: { ...commonOptions(), folder: { type: "string" }, as: { type: "string" }, notes: { type: "string" } }
4566
+ });
4567
+ const ctx = commonContext(parsed.values);
4568
+ const sourceUrl = parsed.positionals[0];
4569
+ if (!sourceUrl) {
4570
+ throw new Error('directory save-url requires a URL: vidfarm directory save-url <url> [--folder promos] [--as name.png] [--notes "…"]');
4571
+ }
4572
+ const body = {
4573
+ source_url: sourceUrl,
4574
+ ...(parsed.values.folder ? { folder_path: String(parsed.values.folder) } : {}),
4575
+ ...(parsed.values.as ? { file_name: String(parsed.values.as) } : {}),
4576
+ ...(parsed.values.notes ? { notes: String(parsed.values.notes) } : {})
4577
+ };
4578
+ // A mutation — apply in exactly one space (first the target resolves to).
4579
+ const space = targetSpaces(ctx.target)[0];
4580
+ const result = await dispatch(ctx, { method: "POST", path: "/api/v1/user/me/attachments/from-url", body }, space);
4581
+ assertApiOk(result, `directory save-url (${space})`);
4582
+ if (ctx.json) {
4583
+ printJson(result.json ?? result.text);
4584
+ return;
4585
+ }
4586
+ const saved = result.json?.attachment;
4587
+ emitResult(result, false, [[`My Files [${space}]`, saved?.viewUrl]]);
4588
+ }
4589
+ // ── Dual-target merge helpers ───────────────────────────────────────────────
4590
+ // Local is queried first, so on a same-path collision the local (working) copy
4591
+ // wins the de-dupe and the cloud duplicate is dropped.
4592
+ function mergeListings(parts) {
4593
+ const multi = parts.length > 1;
4594
+ const folders = [];
4595
+ const files = [];
4596
+ const seen = new Set();
4597
+ for (const { space, data } of parts) {
4598
+ for (const f of Array.isArray(data?.folders) ? data.folders : []) {
4599
+ const key = "d:" + (f?.path ?? f?.name ?? "");
4600
+ if (seen.has(key))
4601
+ continue;
4602
+ seen.add(key);
4603
+ folders.push(multi ? { ...f, origin: space } : f);
4604
+ }
4605
+ for (const f of Array.isArray(data?.files) ? data.files : []) {
4606
+ const key = "f:" + (f?.path ?? f?.id ?? f?.name ?? "");
4607
+ if (seen.has(key))
4608
+ continue;
4609
+ seen.add(key);
4610
+ files.push(multi ? { ...f, origin: space } : f);
4611
+ }
4612
+ }
4613
+ if (multi)
4614
+ folders.sort((a, b) => String(a?.name ?? "").localeCompare(String(b?.name ?? "")));
4615
+ const base = parts[0]?.data ?? {};
4616
+ return {
4617
+ path: base.path,
4618
+ root: base.root,
4619
+ folders,
4620
+ files,
4621
+ // Merged listings can't page coherently across two backends; single-space
4622
+ // keeps the backend's own paging cursor.
4623
+ next_offset: multi ? null : base.next_offset ?? null
4624
+ };
4625
+ }
4626
+ function mergeSearch(parts, limit) {
4627
+ const multi = parts.length > 1;
4628
+ const results = [];
4629
+ const seen = new Set();
4630
+ for (const { space, data } of parts) {
4631
+ for (const r of Array.isArray(data?.results) ? data.results : []) {
4632
+ const key = (r?.path ?? r?.name ?? "") + "|" + (r?.kind ?? "");
4633
+ if (seen.has(key))
4634
+ continue;
4635
+ seen.add(key);
4636
+ results.push(multi ? { ...r, origin: space } : r);
4637
+ }
4638
+ }
4639
+ if (multi) {
4640
+ results.sort((a, b) => (Number(b?.score) || 0) - (Number(a?.score) || 0));
4641
+ }
4642
+ const capped = multi && limit != null && Number.isFinite(limit) ? results.slice(0, limit) : results;
4643
+ const base = parts[0]?.data ?? {};
4644
+ return {
4645
+ results: capped,
4646
+ scope: base.scope,
4647
+ mode: base.mode,
4648
+ semantic: parts.some((p) => p?.data?.semantic === true)
4649
+ };
4650
+ }
4651
+ // Compact "12.3s · 4.2 MB · video/mp4" line for a directory file item.
4652
+ function directoryFileMeta(item) {
4653
+ const parts = [];
4654
+ if (typeof item?.durationSec === "number" && item.durationSec > 0)
4655
+ parts.push(`${item.durationSec.toFixed(1)}s`);
4656
+ if (typeof item?.sizeBytes === "number" && item.sizeBytes > 0)
4657
+ parts.push(formatBytes(item.sizeBytes));
4658
+ if (typeof item?.contentType === "string" && item.contentType)
4659
+ parts.push(item.contentType);
4660
+ return parts.join(" · ");
4661
+ }
4662
+ // A dim `[local]`/`[cloud]` tag for an item that carries an origin (set only on
4663
+ // --both merges, so single-space listings stay clean).
4664
+ function originBadge(item) {
4665
+ return item?.origin ? `${DIM}[${item.origin}]${RESET} ` : "";
4666
+ }
4667
+ function spaceHeaderLabel(space) {
4668
+ if (!space)
4669
+ return "";
4670
+ return ` ${DIM}·${RESET} ${BOLD}${space}${RESET}`;
4671
+ }
4672
+ function printDirectoryListing(data, requestedPath, space) {
4673
+ const listedPath = data && typeof data.path === "string" ? data.path : requestedPath;
4674
+ const root = data?.root ? ` ${DIM}[${data.root}]${RESET}` : "";
4675
+ console.log(`${BOLD}${listedPath}${RESET}${root}${spaceHeaderLabel(space)}`);
4676
+ const folders = Array.isArray(data?.folders) ? data.folders : [];
4677
+ const files = Array.isArray(data?.files) ? data.files : [];
4678
+ if (folders.length === 0 && files.length === 0) {
4679
+ console.log(` ${DIM}(empty)${RESET}`);
4680
+ }
4681
+ for (const f of folders) {
4682
+ console.log(` ${DIM}dir ${RESET} ${originBadge(f)}${f?.name ?? ""}/ ${DIM}${f?.path ?? ""}${RESET}`);
4683
+ }
4684
+ for (const f of files) {
4685
+ const meta = directoryFileMeta(f);
4686
+ console.log(` file ${originBadge(f)}${f?.name ?? ""}${meta ? ` ${DIM}${meta}${RESET}` : ""} ${DIM}${f?.path ?? ""}${RESET}`);
4687
+ if (f?.viewUrl)
4688
+ console.log(` ${FRONTEND}${f.viewUrl}${RESET}`);
4689
+ }
4690
+ const total = folders.length + files.length;
4691
+ const more = data?.next_offset != null ? ` (more: --offset ${data.next_offset})` : "";
4692
+ console.log(`\n ${total} item${total === 1 ? "" : "s"} (${folders.length} folder${folders.length === 1 ? "" : "s"}, ${files.length} file${files.length === 1 ? "" : "s"})${more}`);
4693
+ }
4694
+ function printDirectorySearch(data, query, space) {
4695
+ const results = Array.isArray(data?.results) ? data.results : [];
4696
+ const scope = data?.scope ? ` in ${data.scope}` : "";
4697
+ const modeLabel = data?.mode ? ` · mode=${data.mode}` : "";
4698
+ const semantic = data?.semantic === true ? " · semantic" : data?.semantic === false ? " · keyword-only" : "";
4699
+ console.log(`${BOLD}"${query}"${RESET}${scope}${modeLabel}${semantic}${spaceHeaderLabel(space)} → ${results.length} result${results.length === 1 ? "" : "s"}\n`);
4700
+ if (results.length === 0) {
4701
+ console.log(` ${DIM}(no matches)${RESET}`);
4702
+ return;
4703
+ }
4704
+ results.forEach((r, i) => {
4705
+ const score = typeof r?.score === "number" ? ` ${DIM}score=${r.score.toFixed(3)}${RESET}` : "";
4706
+ const kind = r?.kind === "folder" ? "dir " : "file";
4707
+ console.log(` ${String(i + 1).padStart(2)}. ${kind} ${originBadge(r)}${r?.path ?? r?.name ?? ""}${score}`);
4708
+ const meta = directoryFileMeta(r);
4709
+ if (meta)
4710
+ console.log(` ${DIM}${meta}${RESET}`);
4711
+ if (r?.viewUrl)
4712
+ console.log(` ${FRONTEND}${r.viewUrl}${RESET}`);
4713
+ });
3243
4714
  }
3244
4715
  // Attach metadata notes to one My Files entry (by id, or file name as a
3245
4716
  // fallback). Notes describe what the file IS and when to use it; the server
@@ -3355,28 +4826,37 @@ async function runPutFileCommand(argv) {
3355
4826
  if (!fileName)
3356
4827
  throw new Error("put-file could not determine a file name. Pass --as <name>.");
3357
4828
  const contentType = guessContentType(fileName);
3358
- const form = new FormData();
3359
- form.append("file", new Blob([new Uint8Array(buffer)], contentType ? { type: contentType } : undefined), fileName);
3360
- if (parsed.values.folder)
3361
- form.append("folder_path", String(parsed.values.folder));
3362
- if (parsed.values.notes)
3363
- form.append("notes", String(parsed.values.notes));
3364
- const res = await fetch(new URL("/api/v1/user/me/attachments/upload", ctx.host), {
3365
- method: "POST",
3366
- headers: buildAuthHeaders(ctx.auth),
3367
- body: form
3368
- });
3369
- const text = await res.text();
3370
- let json = null;
3371
- try {
3372
- json = text ? JSON.parse(text) : null;
3373
- }
3374
- catch {
3375
- json = null;
4829
+ const uploadPath = "/api/v1/user/me/attachments/upload";
4830
+ for (const space of targetSpaces(ctx.target)) {
4831
+ // FormData is single-use (its stream is consumed), so build a fresh one per
4832
+ // space when writing to both.
4833
+ const form = new FormData();
4834
+ form.append("file", new Blob([new Uint8Array(buffer)], contentType ? { type: contentType } : undefined), fileName);
4835
+ if (parsed.values.folder)
4836
+ form.append("folder_path", String(parsed.values.folder));
4837
+ if (parsed.values.notes)
4838
+ form.append("notes", String(parsed.values.notes));
4839
+ let res;
4840
+ if (space === "local") {
4841
+ const { withLocalBackend } = await import("./devcli/local-backend.js");
4842
+ const backend = await withLocalBackend({ home: ctx.home, apiKey: ctx.auth.apiKey });
4843
+ res = await backend.app.request(uploadPath, { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
4844
+ }
4845
+ else {
4846
+ res = await fetch(new URL(uploadPath, ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
4847
+ }
4848
+ const text = await res.text();
4849
+ let json = null;
4850
+ try {
4851
+ json = text ? JSON.parse(text) : null;
4852
+ }
4853
+ catch {
4854
+ json = null;
4855
+ }
4856
+ const result = { status: res.status, ok: res.ok, json, text };
4857
+ assertApiOk(result, `put-file (${space})`);
4858
+ emitResult(result, ctx.json, [[`My Files [${space}]`, result.json?.attachment?.viewUrl]]);
3376
4859
  }
3377
- const result = { status: res.status, ok: res.ok, json, text };
3378
- assertApiOk(result, "put-file");
3379
- emitResult(result, ctx.json, [["My Files URL", result.json?.attachment?.viewUrl]]);
3380
4860
  }
3381
4861
  // ── Local media engines + toolchain ─────────────────────────────────────────
3382
4862
  // Wrappers over the bundled hyperframes LOCAL commands (free, no account, no