@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.
- package/.agents/skills/editor-capabilities/SKILL.md +166 -0
- package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
- package/.agents/skills/vidfarm-media/SKILL.md +2 -0
- package/SKILL.director.md +174 -16
- package/SKILL.platform.md +7 -3
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +77 -75
- package/dist/src/app.js +1513 -145
- package/dist/src/cli.js +1365 -105
- package/dist/src/config.js +7 -0
- package/dist/src/devcli/clips.js +3 -0
- package/dist/src/devcli/composition-edit.js +396 -8
- package/dist/src/devcli/local-backend.js +123 -0
- package/dist/src/devcli/migrate-local.js +140 -0
- package/dist/src/devcli/sync.js +311 -0
- package/dist/src/devcli/timeline-edit.js +208 -1
- package/dist/src/editor-chat.js +33 -17
- package/dist/src/frontend/file-directory.js +271 -20
- package/dist/src/frontend/homepage-view.js +3 -3
- package/dist/src/frontend/template-editor-chat.js +6 -3
- package/dist/src/page-shell.js +1 -1
- package/dist/src/primitive-registry.js +409 -4
- package/dist/src/reskin/chat-page.js +103 -15
- package/dist/src/reskin/discover-page.js +247 -10
- package/dist/src/reskin/document.js +147 -10
- package/dist/src/reskin/inpaint-clipper-page.js +649 -0
- package/dist/src/reskin/inpaint-page.js +2459 -452
- package/dist/src/reskin/inpaint-video-page.js +1339 -0
- package/dist/src/reskin/library-page.js +324 -82
- package/dist/src/reskin/theme.js +36 -11
- package/dist/src/services/billing.js +4 -0
- package/dist/src/services/clip-curation/hunt.js +2 -0
- package/dist/src/services/clip-records.js +28 -0
- package/dist/src/services/file-directory.js +6 -3
- package/dist/src/services/hyperframes.js +283 -3
- package/dist/src/services/serverless-records.js +43 -0
- package/dist/src/services/storage.js +24 -2
- package/package.json +1 -1
- package/public/assets/file-directory-app.js +2 -2
- package/public/assets/homepage-client-app.js +1 -1
- package/public/assets/page-runtime-client-app.js +2 -2
- 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
|
|
137
|
-
the browser editor makes;
|
|
138
|
-
|
|
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>
|
|
147
|
-
|
|
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>
|
|
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
|
-
|
|
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.
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2153
|
-
//
|
|
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
|
|
2929
|
-
|
|
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)}/
|
|
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
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
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
|
|
3307
|
-
//
|
|
3308
|
-
//
|
|
3309
|
-
|
|
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
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
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(
|
|
4482
|
+
printJson(merged);
|
|
3369
4483
|
return;
|
|
3370
4484
|
}
|
|
3371
|
-
printDirectoryListing(
|
|
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
|
|
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
|
-
|
|
3393
|
-
path: "/api/v1/user/me/directory/search",
|
|
3394
|
-
auth: ctx.auth,
|
|
4543
|
+
path: "/api/v1/user/me/directory/rename",
|
|
3395
4544
|
body: {
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
form.append("
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
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
|