@mevdragon/vidfarm-devcli 0.18.1 → 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 (42) 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 +2 -0
  4. package/SKILL.director.md +174 -16
  5. package/SKILL.platform.md +7 -3
  6. package/demo/dist/app.css +1 -1
  7. package/demo/dist/app.js +77 -75
  8. package/dist/src/app.js +1513 -145
  9. package/dist/src/cli.js +1365 -105
  10. package/dist/src/config.js +7 -0
  11. package/dist/src/devcli/clips.js +3 -0
  12. package/dist/src/devcli/composition-edit.js +396 -8
  13. package/dist/src/devcli/local-backend.js +123 -0
  14. package/dist/src/devcli/migrate-local.js +140 -0
  15. package/dist/src/devcli/sync.js +311 -0
  16. package/dist/src/devcli/timeline-edit.js +208 -1
  17. package/dist/src/editor-chat.js +33 -17
  18. package/dist/src/frontend/file-directory.js +271 -20
  19. package/dist/src/frontend/homepage-view.js +3 -3
  20. package/dist/src/frontend/template-editor-chat.js +6 -3
  21. package/dist/src/page-shell.js +1 -1
  22. package/dist/src/primitive-registry.js +409 -4
  23. package/dist/src/reskin/chat-page.js +103 -15
  24. package/dist/src/reskin/discover-page.js +247 -10
  25. package/dist/src/reskin/document.js +147 -10
  26. package/dist/src/reskin/inpaint-clipper-page.js +649 -0
  27. package/dist/src/reskin/inpaint-page.js +2459 -452
  28. package/dist/src/reskin/inpaint-video-page.js +1339 -0
  29. package/dist/src/reskin/library-page.js +324 -82
  30. package/dist/src/reskin/theme.js +36 -11
  31. package/dist/src/services/billing.js +4 -0
  32. package/dist/src/services/clip-curation/hunt.js +2 -0
  33. package/dist/src/services/clip-records.js +28 -0
  34. package/dist/src/services/file-directory.js +6 -3
  35. package/dist/src/services/hyperframes.js +283 -3
  36. package/dist/src/services/serverless-records.js +43 -0
  37. package/dist/src/services/storage.js +24 -2
  38. package/package.json +1 -1
  39. package/public/assets/file-directory-app.js +2 -2
  40. package/public/assets/homepage-client-app.js +1 -1
  41. package/public/assets/page-runtime-client-app.js +2 -2
  42. package/public/assets/placeholders/scene-placeholder.png +0 -0
package/dist/src/cli.js CHANGED
@@ -8,11 +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 { runKeyframesCommand, runMoveCommand, runRestackCommand, runRippleCommand, runTrimCommand } from "./devcli/timeline-edit.js";
15
+ import { runDuplicateCommand, runKeyframesCommand, runMoveCommand, runRestackCommand, runRetimeCommand, runRippleCommand, runSetCompositionCommand, runSetIdentityCommand, runSetStyleCommand, runSetTextCommand, runSetVisualCommand, runSplitCommand, runTrimCommand } from "./devcli/timeline-edit.js";
16
16
  import { MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES, loadSpeechAudioLocally, localGenerateSpeech, localTranscribeSpeech, resolveLocalSpeechAuth } from "./devcli/speech.js";
17
17
  import { buildSrtFromSegments, inferSpeechProviderForVoice } from "./services/speech.js";
18
18
  import { formatCompositionLintIssues, lintCompositionHtml } from "./services/composition-lint.js";
@@ -132,10 +132,45 @@ Generate AI media and drop it on the timeline (for local coding agents):
132
132
  --no-wait Return the job id instead of polling to the URL
133
133
  --place <dir> After generating, place it into that composition
134
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
135
165
  place <dir|composition.html> Insert media into a local composition
136
- (fill a gap or replace a scene) same clip markup
137
- the browser editor makes; serve live-morphs it.
138
- --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
139
174
  LOCAL FILE PATH is imported without bloating S3 — a
140
175
  serve working copy copies it onto the box's own disk
141
176
  store (free, local render only); any other dir
@@ -143,13 +178,27 @@ Generate AI media and drop it on the timeline (for local coding agents):
143
178
  --base-url <url> Serve box origin that hosts /storage (default
144
179
  http://localhost:3000) — set if you ran serve --port.
145
180
  --folder <path> Temp-store folder for uploaded local files (default: temp)
146
- --kind <video|image> Media kind (inferred from the URL/path if omitted)
147
- --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.
148
193
  --replace <layer_key> Replace this scene/layer (copies its timing+geometry)
149
194
  --duration <n> Timeline length in seconds
150
195
  --track <n> Timeline layer index (auto if omitted)
151
196
  --x --y --width --height Geometry as % (default full canvas 0/0/100/100)
152
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
153
202
  --ken-burns <preset> Animate a still image (slow pan/zoom): zoom-in|
154
203
  zoom-out|pan-left|pan-right|pan-up|pan-down|
155
204
  zoom-in-left|zoom-in-right
@@ -165,9 +214,50 @@ Generate AI media and drop it on the timeline (for local coding agents):
165
214
  fade|fade-black|fade-white|flash|smoke|blur|
166
215
  slide-*|wipe-*|zoom-in|zoom-out|circle-close
167
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
168
240
  remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
169
241
  original vs decomposed (caption-free),
170
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)
171
261
 
172
262
  Raws (the third library — mine long-form video into a reusable raws store):
173
263
  raws scan <video-path> Hunt short clips out of a long video. LOCAL-FIRST: local ffmpeg +
@@ -179,12 +269,23 @@ Raws (the third library — mine long-form video into a reusable raws store):
179
269
  --no-text Prefer scenes WITHOUT captions/on-screen text (selection only)
180
270
  --cloud [--url <url>] BACKUP: run on the deployed pipeline → POST /raws/scan (+ poll)
181
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>
182
273
  --dry-run Show the estimated cost and stop (over $1 needs --yes/confirm)
183
274
  raws list List the local raws library → GET /raws
184
275
  raws search "<text>" Hybrid structured + semantic search → POST /raws/search
185
276
  raws preset list|run <name>|save <name> --from-query '<...>' → /raws/presets
186
277
  raws export <ids...> --to <dir> Copy raw mp4s + metadata out
187
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)
188
289
 
189
290
  Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit backup:
190
291
  tts "<text>" Text → narration audio file. LOCAL: your OPENAI/GEMINI/
@@ -258,7 +359,10 @@ Fine timeline control (trackpad-level verbs on a pulled/served composition — l
258
359
  'serve' host it renders in-process for FREE.
259
360
  render-status <forkId> <renderId> Poll one render job → GET .../compositions/:forkId/renders/:renderId
260
361
  visibility <forkId> <private|public> Set fork visibility → PATCH .../compositions/:forkId/visibility
261
- 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
262
366
  share-link <forkId> Mint a share-token URL for a fork → POST .../compositions/:forkId/share-links
263
367
 
264
368
  Local media engines & toolchain (all local, free, no account — no cloud key needed):
@@ -346,11 +450,17 @@ Files (multi-step flows the devcli handles for you):
346
450
  directory search <query> Ranked search across the whole tree → POST /api/v1/user/me/directory/search
347
451
  --path <scope> Scope to a subtree · --mode auto|semantic|substring|path · --limit <n>
348
452
  --content-type <a,b> Filter the /raws branch by shot-KIND tag (talking_head, b_roll, …)
349
- (alias: dir)
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)
350
459
  put-file / get-file / files / annotate-file are the My Files (persistent) set;
351
460
  upload is the throwaway temp store for dropping media into a composition.
352
- Annotate assets you'll want back — esp. character refs (character_sprite_card.png
353
- + 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.
354
464
  Local file paths: to avoid uploading assets to Vidfarm S3 at all, reference
355
465
  them straight from disk with 'place --src ./clip.mp4' on a 'serve' box (copied
356
466
  to that box's local disk store, free local render). When you DO need a durable
@@ -461,6 +571,18 @@ async function main() {
461
571
  case "generate":
462
572
  await runGenerateCommand(rest);
463
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;
464
586
  case "tts":
465
587
  await runTtsCommand(rest);
466
588
  return;
@@ -471,6 +593,14 @@ async function main() {
471
593
  case "place":
472
594
  await runPlaceCommand(rest);
473
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;
474
604
  case "decompose":
475
605
  await runDecomposeCommand(rest);
476
606
  return;
@@ -556,10 +686,24 @@ async function main() {
556
686
  case "dir":
557
687
  await runDirectoryCommand(rest);
558
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
+ }
559
699
  case "raws":
560
700
  case "clips":
561
701
  await runClipsCommand(rest);
562
702
  return;
703
+ case "clipper":
704
+ case "clip-range":
705
+ await runClipperCommand(rest);
706
+ return;
563
707
  case "captions":
564
708
  await runCaptionsCommand(rest);
565
709
  return;
@@ -584,6 +728,31 @@ async function main() {
584
728
  case "zindex":
585
729
  await runRestackCommand(rest);
586
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;
587
756
  // Local media engines + toolchain (bundled HyperFrames local commands —
588
757
  // free, no account — plus vidfarm-native lint/stills/doctor/skills).
589
758
  case "remove-background":
@@ -665,9 +834,33 @@ function commonOptions() {
665
834
  host: { type: "string", default: DEFAULT_HOST },
666
835
  "api-key": { type: "string" },
667
836
  share: { type: "string" },
668
- 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" }
669
846
  };
670
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
+ }
671
864
  function commonContext(values) {
672
865
  return {
673
866
  host: trimTrailingSlash(String(values.host ?? DEFAULT_HOST)),
@@ -675,9 +868,27 @@ function commonContext(values) {
675
868
  apiKey: values["api-key"] ?? process.env.VIDFARM_API_KEY,
676
869
  shareToken: values.share
677
870
  },
678
- json: Boolean(values.json)
871
+ json: Boolean(values.json),
872
+ target: resolveTarget(values),
873
+ home: values.home
679
874
  };
680
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
+ }
681
892
  function printJson(value) {
682
893
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
683
894
  }
@@ -705,6 +916,32 @@ function editorFrontendUrl(host, templateId, forkId) {
705
916
  function discoverFrontendUrl(host) {
706
917
  return `${host}/discover`;
707
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
+ }
708
945
  // Small arg helper: split "k=v" pairs for --query.
709
946
  function collectKeyValues(entries) {
710
947
  const out = {};
@@ -1068,7 +1305,11 @@ async function fetchCompositionFiles(input) {
1068
1305
  // scene-annotations.json carries the detected content format + per-scene
1069
1306
  // literal + viral DNA an agent needs to REPLACE a scene from the clip library
1070
1307
  // or RE-CREATE it from scratch (recreation_prompt + recreation_notes).
1071
- 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"];
1072
1313
  for (const filename of files) {
1073
1314
  const target = path.join(input.dir, filename);
1074
1315
  if (existsSync(target) && !input.refetch) {
@@ -2052,7 +2293,11 @@ async function resolveReferenceUrls(ctx, refs) {
2052
2293
  return out;
2053
2294
  }
2054
2295
  function inferMediaKindFromUrl(url) {
2055
- 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";
2056
2301
  }
2057
2302
  // Comma-separated shot-KIND filter (talking_head, b_roll, product_shot, …) →
2058
2303
  // the content_type array the /raws branch of the search endpoints honors.
@@ -2149,12 +2394,55 @@ async function uploadLocalToTempStore(ctx, absPath, folder) {
2149
2394
  throw new Error("local media upload returned no durable URL.");
2150
2395
  return url;
2151
2396
  }
2152
- // Turn a --src value (http(s) URL or local file path) into a fetchable media
2153
- // 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.
2154
2434
  async function resolvePlaceableMediaSrc(ctx, rawSrc, opts) {
2155
2435
  const value = rawSrc.trim();
2156
2436
  if (/^https?:\/\//i.test(value))
2157
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
+ }
2158
2446
  const abs = path.resolve(process.cwd(), value.startsWith("@") ? value.slice(1) : value);
2159
2447
  if (!existsSync(abs)) {
2160
2448
  throw new Error(`place --src "${rawSrc}" is neither an http(s) URL nor an existing local file.`);
@@ -2183,12 +2471,16 @@ function placeMediaOnDisk(input) {
2183
2471
  width: input.width,
2184
2472
  height: input.height,
2185
2473
  objectFit: input.objectFit,
2474
+ objectPosition: input.objectPosition,
2186
2475
  kenBurns: input.kenBurns,
2187
2476
  kenBurnsIntensity: input.kenBurnsIntensity,
2188
2477
  transition: input.transition,
2189
2478
  transitionDuration: input.transitionDuration,
2190
2479
  transitionOut: input.transitionOut,
2191
2480
  transitionOutDuration: input.transitionOutDuration,
2481
+ volume: input.volume,
2482
+ muted: input.muted,
2483
+ playbackStart: input.playbackStart,
2192
2484
  slug: input.slug
2193
2485
  };
2194
2486
  const result = input.replace
@@ -2315,6 +2607,404 @@ async function runGenerateCommand(argv) {
2315
2607
  console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind ${mediaType} --at <time>|--replace <layer_key>${RESET}`);
2316
2608
  }
2317
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
+ }
2318
3008
  // ── Speech: tts / stt ────────────────────────────────────────────────────────
2319
3009
  // LOCAL-FIRST like `raws scan`: both commands default to running on the
2320
3010
  // user's OWN AI key (GEMINI_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY or
@@ -2896,14 +3586,24 @@ async function runPlaceCommand(argv) {
2896
3586
  width: { type: "string" },
2897
3587
  height: { type: "string" },
2898
3588
  "object-fit": { type: "string" },
3589
+ "object-position": { type: "string" },
2899
3590
  "ken-burns": { type: "string" },
2900
3591
  "ken-burns-intensity": { type: "string" },
2901
3592
  transition: { type: "string" },
2902
3593
  "transition-duration": { type: "string" },
2903
3594
  "transition-out": { type: "string" },
2904
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" },
2905
3602
  "layer-key": { type: "string" },
2906
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 },
2907
3607
  // Local file support: where a serve box serves /storage from, and which
2908
3608
  // temp folder cloud uploads land under.
2909
3609
  "base-url": { type: "string" },
@@ -2925,8 +3625,9 @@ async function runPlaceCommand(argv) {
2925
3625
  folder: parsed.values.folder
2926
3626
  });
2927
3627
  const src = resolvedSrc.src;
2928
- const kind = parsed.values.kind === "video" || parsed.values.kind === "image"
2929
- ? parsed.values.kind
3628
+ const explicitKind = parsed.values.kind;
3629
+ const kind = explicitKind === "video" || explicitKind === "image" || explicitKind === "audio"
3630
+ ? explicitKind
2930
3631
  : inferMediaKindFromUrl(rawSrc);
2931
3632
  const num = (v) => (v !== undefined ? Number(v) : undefined);
2932
3633
  const placement = placeMediaOnDisk({
@@ -2942,17 +3643,24 @@ async function runPlaceCommand(argv) {
2942
3643
  width: num(parsed.values.width),
2943
3644
  height: num(parsed.values.height),
2944
3645
  objectFit: parsed.values["object-fit"],
3646
+ objectPosition: parsed.values["object-position"],
2945
3647
  kenBurns: parsed.values["ken-burns"],
2946
3648
  kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
2947
3649
  transition: parsed.values.transition,
2948
3650
  transitionDuration: num(parsed.values["transition-duration"]),
2949
3651
  transitionOut: parsed.values["transition-out"],
2950
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"]),
2951
3656
  layerKey: parsed.values["layer-key"],
2952
3657
  slug: parsed.values.slug
2953
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;
2954
3662
  if (ctx.json) {
2955
- 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 } : {}) });
2956
3664
  }
2957
3665
  else {
2958
3666
  if (resolvedSrc.origin === "local-serve") {
@@ -2963,9 +3671,377 @@ async function runPlaceCommand(argv) {
2963
3671
  }
2964
3672
  console.log(`${GREEN}Placed ${kind} as ${placement.layerKey}${parsed.values.replace ? ` (replaced ${parsed.values.replace})` : ""} in ${htmlPath}.${RESET}`);
2965
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
+ }
2966
3677
  console.log(`${DIM}Publish with: vidfarm publish --dir ${path.dirname(htmlPath)} --fork <forkId> (or it live-morphs under \`serve\`).${RESET}`);
2967
3678
  }
2968
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
+ }
2969
4045
  async function runPullCommand(argv) {
2970
4046
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), dir: { type: "string" }, refetch: { type: "boolean", default: false } } });
2971
4047
  const forkId = parsed.positionals[0];
@@ -2982,8 +4058,9 @@ async function runPullCommand(argv) {
2982
4058
  const hasContext = existsSync(path.join(dir, "video-context.json"));
2983
4059
  const hasCast = existsSync(path.join(dir, "cast.json"));
2984
4060
  const hasAnnotations = existsSync(path.join(dir, "scene-annotations.json"));
4061
+ const hasHarness = existsSync(path.join(dir, "editor-harness.json"));
2985
4062
  if (ctx.json) {
2986
- 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 });
2987
4064
  return;
2988
4065
  }
2989
4066
  console.log(`${GREEN}Pulled ${forkId} → ${dir}${RESET}`);
@@ -2994,7 +4071,7 @@ async function runPullCommand(argv) {
2994
4071
  const flags = [layer.is_timeline_proxy ? "proxy" : null, layer.is_full_canvas ? "full-canvas" : null].filter(Boolean).join(",");
2995
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}`);
2996
4073
  }
2997
- 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}`);
2998
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}`);
2999
4076
  }
3000
4077
  async function runVisibilityCommand(argv) {
@@ -3010,17 +4087,20 @@ async function runVisibilityCommand(argv) {
3010
4087
  emitResult(result, ctx.json, templateId ? [["Open editor ", editorFrontendUrl(ctx.host, templateId, forkId)]] : undefined);
3011
4088
  }
3012
4089
  async function runCloneCommand(argv) {
3013
- 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" } } });
3014
4091
  const forkId = parsed.positionals[0];
3015
4092
  if (!forkId)
3016
4093
  throw new Error("clone requires a fork id.");
4094
+ const from = parsed.values.from === "default" ? "default" : "current";
3017
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.
3018
4098
  const result = await apiRequest({
3019
4099
  method: "POST",
3020
4100
  host: ctx.host,
3021
- path: `/api/v1/compositions/${encodeURIComponent(forkId)}/clone`,
4101
+ path: `/api/v1/compositions/${encodeURIComponent(forkId)}/fork`,
3022
4102
  auth: ctx.auth,
3023
- body: { parent_version: parsed.values.version, title: parsed.values.title }
4103
+ body: { from, parent_version: parsed.values.version, title: parsed.values.title }
3024
4104
  });
3025
4105
  assertApiOk(result, "clone");
3026
4106
  const newForkId = result.json?.fork_id;
@@ -3277,36 +4357,45 @@ async function runFilesCommand(argv) {
3277
4357
  const ctx = commonContext(parsed.values);
3278
4358
  const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
3279
4359
  const contentTypes = parseContentTypeFlag(parsed.values["content-type"]);
3280
- if (parsed.values.search) {
3281
- // Meaning-first lookup: keyword + vector search over every file's
3282
- // name/folder/notes (BYOK embeddings; keyword-only without a saved key).
3283
- // content_type filters the /raws branch by shot-KIND tag (server-honored).
3284
- const result = await apiRequest({
3285
- method: "POST",
3286
- host: ctx.host,
3287
- path: "/api/v1/user/me/attachments/search",
3288
- auth: ctx.auth,
3289
- body: { query: String(parsed.values.search), ...(folderFilter ? { folder_path: folderFilter } : {}), ...(contentTypes.length ? { content_type: contentTypes } : {}) }
3290
- });
3291
- assertApiOk(result, "files --search");
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
+ }
3292
4386
  emitResult(result, ctx.json);
3293
- return;
3294
- }
3295
- const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
3296
- assertApiOk(result, "files");
3297
- if (folderFilter && result.json && Array.isArray(result.json.attachments)) {
3298
- result.json = {
3299
- ...result.json,
3300
- attachments: result.json.attachments.filter((file) => (file?.folderPath ?? "") === folderFilter)
3301
- };
3302
4387
  }
3303
- emitResult(result, ctx.json);
3304
4388
  }
3305
4389
  // ── directory (unified virtual file tree) ────────────────────────────────────
3306
- // Browse + search the three file roots (/files, /temp, /raws) as one tree via
3307
- // the cloud REST directory endpoints. Same cloud auth (--api-key/VIDFARM_API_KEY,
3308
- // --host, --json) as the rest of the wrapper commands.
3309
- const DIRECTORY_HELP = `vidfarm directory browse the unified file tree (/files · /temp · /raws)
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
+
3310
4399
 
3311
4400
  directory ls [path] List folders + files under a path (default "/") → GET /api/v1/user/me/directory
3312
4401
  --limit <n> --offset <n> Paginate (server returns next_offset when more)
@@ -3320,6 +4409,20 @@ const DIRECTORY_HELP = `vidfarm directory — browse the unified file tree (/fil
3320
4409
  reaction, interview, establishing, lifestyle, text_graphic)
3321
4410
  --limit <n> --json
3322
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
+
3323
4426
  Aliases: directory | dir · Auth: --api-key <key> or VIDFARM_API_KEY, --host <url>
3324
4427
  Move a raw between folders with: vidfarm api PATCH /raws/<clipId> --data '{"folder_path":"…"}'`;
3325
4428
  async function runDirectoryCommand(argv) {
@@ -3332,6 +4435,13 @@ async function runDirectoryCommand(argv) {
3332
4435
  case "search":
3333
4436
  case "find":
3334
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);
3335
4445
  case undefined:
3336
4446
  case "help":
3337
4447
  case "--help":
@@ -3352,23 +4462,27 @@ async function runDirectoryLs(argv) {
3352
4462
  });
3353
4463
  const ctx = commonContext(parsed.values);
3354
4464
  const dirPath = parsed.positionals[0] ?? "/";
3355
- const result = await apiRequest({
3356
- method: "GET",
3357
- host: ctx.host,
3358
- path: "/api/v1/user/me/directory",
3359
- auth: ctx.auth,
3360
- query: {
3361
- path: dirPath,
3362
- limit: parsed.values.limit,
3363
- offset: parsed.values.offset
3364
- }
3365
- });
3366
- assertApiOk(result, "directory ls");
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);
3367
4481
  if (ctx.json) {
3368
- printJson(result.json ?? result.text);
4482
+ printJson(merged);
3369
4483
  return;
3370
4484
  }
3371
- printDirectoryListing(result.json, dirPath);
4485
+ printDirectoryListing(merged, dirPath, spaces.length > 1 ? "both" : spaces[0]);
3372
4486
  }
3373
4487
  async function runDirectorySearch(argv) {
3374
4488
  const parsed = parseArgs({
@@ -3379,7 +4493,7 @@ async function runDirectorySearch(argv) {
3379
4493
  const ctx = commonContext(parsed.values);
3380
4494
  const query = parsed.positionals.join(" ").trim();
3381
4495
  if (!query) {
3382
- throw new Error('directory search requires a query: vidfarm directory search "<query>" [--path /raws] [--mode auto|semantic|substring|path] [--content-type talking_head,b_roll] [--limit N]');
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]');
3383
4497
  }
3384
4498
  const mode = parsed.values.mode;
3385
4499
  if (mode && !["auto", "semantic", "substring", "path"].includes(mode)) {
@@ -3387,25 +4501,152 @@ async function runDirectorySearch(argv) {
3387
4501
  }
3388
4502
  const contentTypes = parseContentTypeFlag(parsed.values["content-type"]);
3389
4503
  const limit = parsed.values.limit != null ? Number(parsed.values.limit) : undefined;
3390
- const result = await apiRequest({
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 });
4517
+ }
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, {
3391
4542
  method: "POST",
3392
- host: ctx.host,
3393
- path: "/api/v1/user/me/directory/search",
3394
- auth: ctx.auth,
4543
+ path: "/api/v1/user/me/directory/rename",
3395
4544
  body: {
3396
- query,
3397
- ...(parsed.values.path ? { path: String(parsed.values.path) } : {}),
3398
- ...(mode ? { mode } : {}),
3399
- ...(contentTypes.length ? { content_type: contentTypes } : {}),
3400
- ...(limit != null && Number.isFinite(limit) ? { limit } : {})
4545
+ path: targetPath,
4546
+ new_name: newName,
4547
+ ...(fileId ? { file_id: fileId } : {})
3401
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" } }
3402
4566
  });
3403
- assertApiOk(result, "directory search");
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})`);
3404
4582
  if (ctx.json) {
3405
4583
  printJson(result.json ?? result.text);
3406
4584
  return;
3407
4585
  }
3408
- printDirectorySearch(result.json, query);
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
+ };
3409
4650
  }
3410
4651
  // Compact "12.3s · 4.2 MB · video/mp4" line for a directory file item.
3411
4652
  function directoryFileMeta(item) {
@@ -3418,21 +4659,31 @@ function directoryFileMeta(item) {
3418
4659
  parts.push(item.contentType);
3419
4660
  return parts.join(" · ");
3420
4661
  }
3421
- function printDirectoryListing(data, requestedPath) {
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) {
3422
4673
  const listedPath = data && typeof data.path === "string" ? data.path : requestedPath;
3423
4674
  const root = data?.root ? ` ${DIM}[${data.root}]${RESET}` : "";
3424
- console.log(`${BOLD}${listedPath}${RESET}${root}`);
4675
+ console.log(`${BOLD}${listedPath}${RESET}${root}${spaceHeaderLabel(space)}`);
3425
4676
  const folders = Array.isArray(data?.folders) ? data.folders : [];
3426
4677
  const files = Array.isArray(data?.files) ? data.files : [];
3427
4678
  if (folders.length === 0 && files.length === 0) {
3428
4679
  console.log(` ${DIM}(empty)${RESET}`);
3429
4680
  }
3430
4681
  for (const f of folders) {
3431
- console.log(` ${DIM}dir ${RESET} ${f?.name ?? ""}/ ${DIM}${f?.path ?? ""}${RESET}`);
4682
+ console.log(` ${DIM}dir ${RESET} ${originBadge(f)}${f?.name ?? ""}/ ${DIM}${f?.path ?? ""}${RESET}`);
3432
4683
  }
3433
4684
  for (const f of files) {
3434
4685
  const meta = directoryFileMeta(f);
3435
- console.log(` file ${f?.name ?? ""}${meta ? ` ${DIM}${meta}${RESET}` : ""} ${DIM}${f?.path ?? ""}${RESET}`);
4686
+ console.log(` file ${originBadge(f)}${f?.name ?? ""}${meta ? ` ${DIM}${meta}${RESET}` : ""} ${DIM}${f?.path ?? ""}${RESET}`);
3436
4687
  if (f?.viewUrl)
3437
4688
  console.log(` ${FRONTEND}${f.viewUrl}${RESET}`);
3438
4689
  }
@@ -3440,12 +4691,12 @@ function printDirectoryListing(data, requestedPath) {
3440
4691
  const more = data?.next_offset != null ? ` (more: --offset ${data.next_offset})` : "";
3441
4692
  console.log(`\n ${total} item${total === 1 ? "" : "s"} (${folders.length} folder${folders.length === 1 ? "" : "s"}, ${files.length} file${files.length === 1 ? "" : "s"})${more}`);
3442
4693
  }
3443
- function printDirectorySearch(data, query) {
4694
+ function printDirectorySearch(data, query, space) {
3444
4695
  const results = Array.isArray(data?.results) ? data.results : [];
3445
4696
  const scope = data?.scope ? ` in ${data.scope}` : "";
3446
4697
  const modeLabel = data?.mode ? ` · mode=${data.mode}` : "";
3447
4698
  const semantic = data?.semantic === true ? " · semantic" : data?.semantic === false ? " · keyword-only" : "";
3448
- console.log(`${BOLD}"${query}"${RESET}${scope}${modeLabel}${semantic} → ${results.length} result${results.length === 1 ? "" : "s"}\n`);
4699
+ console.log(`${BOLD}"${query}"${RESET}${scope}${modeLabel}${semantic}${spaceHeaderLabel(space)} → ${results.length} result${results.length === 1 ? "" : "s"}\n`);
3449
4700
  if (results.length === 0) {
3450
4701
  console.log(` ${DIM}(no matches)${RESET}`);
3451
4702
  return;
@@ -3453,7 +4704,7 @@ function printDirectorySearch(data, query) {
3453
4704
  results.forEach((r, i) => {
3454
4705
  const score = typeof r?.score === "number" ? ` ${DIM}score=${r.score.toFixed(3)}${RESET}` : "";
3455
4706
  const kind = r?.kind === "folder" ? "dir " : "file";
3456
- console.log(` ${String(i + 1).padStart(2)}. ${kind} ${r?.path ?? r?.name ?? ""}${score}`);
4707
+ console.log(` ${String(i + 1).padStart(2)}. ${kind} ${originBadge(r)}${r?.path ?? r?.name ?? ""}${score}`);
3457
4708
  const meta = directoryFileMeta(r);
3458
4709
  if (meta)
3459
4710
  console.log(` ${DIM}${meta}${RESET}`);
@@ -3575,28 +4826,37 @@ async function runPutFileCommand(argv) {
3575
4826
  if (!fileName)
3576
4827
  throw new Error("put-file could not determine a file name. Pass --as <name>.");
3577
4828
  const contentType = guessContentType(fileName);
3578
- const form = new FormData();
3579
- form.append("file", new Blob([new Uint8Array(buffer)], contentType ? { type: contentType } : undefined), fileName);
3580
- if (parsed.values.folder)
3581
- form.append("folder_path", String(parsed.values.folder));
3582
- if (parsed.values.notes)
3583
- form.append("notes", String(parsed.values.notes));
3584
- const res = await fetch(new URL("/api/v1/user/me/attachments/upload", ctx.host), {
3585
- method: "POST",
3586
- headers: buildAuthHeaders(ctx.auth),
3587
- body: form
3588
- });
3589
- const text = await res.text();
3590
- let json = null;
3591
- try {
3592
- json = text ? JSON.parse(text) : null;
3593
- }
3594
- catch {
3595
- 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]]);
3596
4859
  }
3597
- const result = { status: res.status, ok: res.ok, json, text };
3598
- assertApiOk(result, "put-file");
3599
- emitResult(result, ctx.json, [["My Files URL", result.json?.attachment?.viewUrl]]);
3600
4860
  }
3601
4861
  // ── Local media engines + toolchain ─────────────────────────────────────────
3602
4862
  // Wrappers over the bundled hyperframes LOCAL commands (free, no account, no