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