@mevdragon/vidfarm-devcli 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/SKILL.director.md +123 -41
- package/SKILL.platform.md +36 -35
- package/dist/src/cli.js +850 -97
- package/package.json +1 -6
- package/dist/src/dev-serve.js +0 -340
package/dist/src/cli.js
CHANGED
|
@@ -7,10 +7,12 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { Readable } from "node:stream";
|
|
9
9
|
import { pipeline } from "node:stream/promises";
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./services/composition-edit.js";
|
|
11
|
+
// vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
|
|
12
|
+
// `serve` command boots the FULL editor locally (single origin, disk-backed
|
|
13
|
+
// records + storage) so power users edit compositions on disk while a browser
|
|
14
|
+
// editor and a coding agent share one source of truth; render stays on the
|
|
15
|
+
// cloud. The remaining commands are thin 1:1 wrappers over the REST API.
|
|
14
16
|
const DEFAULT_HOST = "https://vidfarm.cc";
|
|
15
17
|
const DEFAULT_PORT = 4321;
|
|
16
18
|
// Agent skill files the `update-skill` command can install from the live host
|
|
@@ -38,23 +40,77 @@ Usage:
|
|
|
38
40
|
vidfarm --help
|
|
39
41
|
|
|
40
42
|
Local editor loop:
|
|
41
|
-
|
|
43
|
+
serve [template_id] [opts] Boot the FULL editor locally (single origin, disk-backed).
|
|
44
|
+
Records + storage run on disk; render runs in-process on
|
|
45
|
+
this box for FREE (no cloud, no wallet charge).
|
|
46
|
+
With a template_id or --fork, pulls that composition
|
|
47
|
+
(or the template's default/shared decomposition) from
|
|
48
|
+
the cloud host onto local disk to edit offline.
|
|
49
|
+
--port <n> Server port (default: 3000)
|
|
50
|
+
--dir <path> Local data dir (default: ./.vidfarm-local)
|
|
51
|
+
--key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
|
|
52
|
+
--fork <id> Pull + open a specific cloud fork
|
|
53
|
+
--host <url> Cloud host to pull from (default: ${DEFAULT_HOST})
|
|
54
|
+
--api-key <key> Cloud key for the pull (default: VIDFARM_API_KEY)
|
|
55
|
+
--refetch Overwrite local composition with the cloud copy
|
|
56
|
+
--no-open Don't auto-open the browser
|
|
57
|
+
<template_id> [opts] Alias for 'serve <template_id>' (boot the local editor)
|
|
42
58
|
publish [template_id] Push local composition.html/json to your fork → PUT/PATCH composition + POST versions
|
|
43
|
-
dev-serve Serve an existing local dir only (no fetch)
|
|
44
59
|
|
|
45
60
|
Discover & inspiration (browse the viral-video catalog, add your own source):
|
|
46
|
-
discover
|
|
61
|
+
discover [query...] List/search TEMPLATES (best for an offer) → GET /discover/feed
|
|
62
|
+
--query <text> Keyword search (e.g. "weight loss app")
|
|
63
|
+
--limit <n> Max results
|
|
64
|
+
videos [query...] List/search source INSPIRATIONS → GET /api/v1/videos
|
|
65
|
+
--query <text> Keyword search over the catalog
|
|
66
|
+
--mine Only your own uploaded inspirations
|
|
67
|
+
--limit <n> Max results
|
|
47
68
|
inspiration-add <url> Ingest a TikTok/YT/IG/X video as a template → POST /discover/templates
|
|
48
69
|
inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
|
|
49
70
|
inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
|
|
50
71
|
|
|
51
72
|
Composition lifecycle (fork → decompose → snapshot → render):
|
|
52
73
|
fork <template_id> Fork a template into an editable composition → POST /api/v1/compositions
|
|
74
|
+
pull <forkId> Sync composition.html/json + video-context → GET .../compositions/:forkId/{composition.html,...}
|
|
75
|
+
+ cast.json to disk, then print timeline gaps
|
|
76
|
+
(blank space) and scene/layer keys to target.
|
|
77
|
+
--dir <path> Local dir (default: .vidfarm/<forkId>)
|
|
78
|
+
--refetch Overwrite local copies with the cloud version
|
|
53
79
|
decompose <forkId> Split the fork's source into scenes (smart) → POST .../compositions/:forkId/auto-decompose
|
|
54
|
-
|
|
80
|
+
|
|
81
|
+
Generate AI media and drop it on the timeline (for local coding agents):
|
|
82
|
+
generate <image|video> Generate AI media, poll to the finished URL → POST /api/v1/primitives/{images,videos}/generate
|
|
83
|
+
--prompt <text> What to generate (required)
|
|
84
|
+
--ref <url|@file> Reference image (repeatable): input_references
|
|
85
|
+
for video, prompt_attachments for image. @file
|
|
86
|
+
auto-uploads a local image and uses its URL.
|
|
87
|
+
--aspect-ratio <r> e.g. 9:16 (match the composition canvas)
|
|
88
|
+
--duration <n> Video only: seconds of footage (e.g. 4)
|
|
89
|
+
--resolution <r> Video only: 480p|720p|1080p|1K|2K|4K
|
|
90
|
+
--audio Video only: generate an audio track
|
|
91
|
+
--provider <p> openai|gemini|openrouter (omit to auto-pick)
|
|
92
|
+
--model <m> Model override (omit for provider defaults)
|
|
93
|
+
--no-wait Return the job id instead of polling to the URL
|
|
94
|
+
--place <dir> After generating, place it into that composition
|
|
95
|
+
--at <time> | --replace <layer_key> Placement (fill gap vs replace scene)
|
|
96
|
+
place <dir|composition.html> Insert a media URL into a local composition
|
|
97
|
+
(fill a gap or replace a scene) — same clip markup
|
|
98
|
+
the browser editor makes; serve live-morphs it.
|
|
99
|
+
--src <url> Direct media URL to place (required)
|
|
100
|
+
--kind <video|image> Media kind (inferred from the URL if omitted)
|
|
101
|
+
--at <time> Start it at this time (seconds / mm:ss) — fill a gap
|
|
102
|
+
--replace <layer_key> Replace this scene/layer (copies its timing+geometry)
|
|
103
|
+
--duration <n> Timeline length in seconds
|
|
104
|
+
--track <n> Timeline layer index (auto if omitted)
|
|
105
|
+
--x --y --width --height Geometry as % (default full canvas 0/0/100/100)
|
|
106
|
+
--object-fit <fit> cover|contain|fill|none|scale-down (default cover)
|
|
107
|
+
ghostcut <forkId> Read the two video sources: original vs → GET .../compositions/:forkId/ghostcut
|
|
108
|
+
decomposed (caption-free), non-billing
|
|
55
109
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
56
110
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
57
|
-
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/
|
|
111
|
+
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
|
|
112
|
+
Cloud render costs ~$0.01-$0.10; against a local
|
|
113
|
+
'serve' host it renders in-process for FREE.
|
|
58
114
|
render-status <forkId> <renderId> Poll one render job → GET .../compositions/:forkId/renders/:renderId
|
|
59
115
|
visibility <forkId> <private|public> Set fork visibility → PATCH .../compositions/:forkId/visibility
|
|
60
116
|
clone <forkId> Clone a fork as a new starting point → POST .../compositions/:forkId/clone
|
|
@@ -82,8 +138,30 @@ Agent skill (install the latest director skill so your AI agent can act):
|
|
|
82
138
|
--print Print the skill to stdout instead of writing a file
|
|
83
139
|
|
|
84
140
|
Files (multi-step flows the devcli handles for you):
|
|
85
|
-
upload <file> Upload
|
|
141
|
+
upload <file> Upload to the EPHEMERAL temp store, print URL → POST /api/v1/user/me/temporary-files/upload
|
|
142
|
+
--folder <path> Namescope the upload under a product/offer folder
|
|
143
|
+
put-file [file] Write a file INTO My Files (persistent lib) → POST /api/v1/user/me/attachments/upload
|
|
144
|
+
--folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
|
|
145
|
+
--as <name> Name the saved file (required for --content/--stdin)
|
|
146
|
+
--content <text> Inline text to save (needs --as); or pipe stdin with --stdin
|
|
147
|
+
e.g. vidfarm put-file About.md --folder acme-skincare
|
|
148
|
+
echo "..." | vidfarm put-file --stdin --as About.md --folder acme
|
|
86
149
|
download <url> [dest] Stream any Vidfarm/media URL to disk
|
|
150
|
+
files List My Files assets + folders → GET /api/v1/user/me/attachments
|
|
151
|
+
--folder <path> Only files under this folder (e.g. a product/offer)
|
|
152
|
+
get-file <id> [dest] Resolve a My Files id to its URL and download it
|
|
153
|
+
--print Print text contents (md/txt/csv/json) instead of saving
|
|
154
|
+
put-file / get-file / files are the My Files (persistent) read+write set;
|
|
155
|
+
upload is the throwaway temp store for dropping media into a composition.
|
|
156
|
+
|
|
157
|
+
Cost spectrum (default to the cheapest approach that works; see SKILL.director.md):
|
|
158
|
+
free Reuse a decomposed template, swap captions/images/existing MP4s
|
|
159
|
+
(My Files / your computer / web search), render on a local 'serve' box.
|
|
160
|
+
~$0.001-$0.03 Same reuse, but cloud 'render' (~$0.01-$0.10). Image gen is cheap too.
|
|
161
|
+
~$1 AI-generate a few scenes for specificity (generate video ...).
|
|
162
|
+
$10+ Heavy AI generation (many/long AI clips, custom characters).
|
|
163
|
+
Notes: image gen is cheap (use freely); AI VIDEO gen is expensive (ask the user
|
|
164
|
+
first). Decompose is a one-time ~$0.10 — or skip it by forking a decomposed template.
|
|
87
165
|
|
|
88
166
|
Escape hatch — call ANY route directly:
|
|
89
167
|
api <METHOD> <path> Raw REST call with auth + pretty errors
|
|
@@ -99,11 +177,12 @@ Common options (any command):
|
|
|
99
177
|
--share <token> Share token for a shared fork (public read)
|
|
100
178
|
--json Print only the raw JSON response (agent-friendly)
|
|
101
179
|
|
|
102
|
-
|
|
103
|
-
--port <n> Local
|
|
104
|
-
--dir <path> Local
|
|
105
|
-
--
|
|
106
|
-
--
|
|
180
|
+
Serve-mode options:
|
|
181
|
+
--port <n> Local server port (default: 3000)
|
|
182
|
+
--dir <path> Local data dir for disk records + storage (default: ./.vidfarm-local)
|
|
183
|
+
--key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
|
|
184
|
+
--fork <id> Open a specific fork (multiple forks editable at once)
|
|
185
|
+
--no-open Don't auto-open the browser
|
|
107
186
|
|
|
108
187
|
Publish-mode options:
|
|
109
188
|
--message <text> Message attached to the published version snapshot
|
|
@@ -122,13 +201,11 @@ async function main() {
|
|
|
122
201
|
const command = argv[0];
|
|
123
202
|
const rest = argv.slice(1);
|
|
124
203
|
switch (command) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
204
|
+
// `serve` (and the bare `<template_id>` / legacy `edit` alias) boot the full
|
|
205
|
+
// editor locally. The old cross-origin dev-serve/`?dev=` flow is gone.
|
|
206
|
+
case "serve":
|
|
130
207
|
case "edit":
|
|
131
|
-
await
|
|
208
|
+
await runServeCommand(rest);
|
|
132
209
|
return;
|
|
133
210
|
case "publish":
|
|
134
211
|
await runPublishCommand(rest);
|
|
@@ -140,6 +217,9 @@ async function main() {
|
|
|
140
217
|
case "discover":
|
|
141
218
|
await runDiscoverCommand(rest);
|
|
142
219
|
return;
|
|
220
|
+
case "videos":
|
|
221
|
+
await runVideosCommand(rest);
|
|
222
|
+
return;
|
|
143
223
|
case "inspiration-add":
|
|
144
224
|
await runInspirationAddCommand(rest);
|
|
145
225
|
return;
|
|
@@ -152,6 +232,15 @@ async function main() {
|
|
|
152
232
|
case "fork":
|
|
153
233
|
await runForkCommand(rest);
|
|
154
234
|
return;
|
|
235
|
+
case "pull":
|
|
236
|
+
await runPullCommand(rest);
|
|
237
|
+
return;
|
|
238
|
+
case "generate":
|
|
239
|
+
await runGenerateCommand(rest);
|
|
240
|
+
return;
|
|
241
|
+
case "place":
|
|
242
|
+
await runPlaceCommand(rest);
|
|
243
|
+
return;
|
|
155
244
|
case "decompose":
|
|
156
245
|
await runDecomposeCommand(rest);
|
|
157
246
|
return;
|
|
@@ -219,10 +308,20 @@ async function main() {
|
|
|
219
308
|
case "download":
|
|
220
309
|
await runDownloadCommand(rest);
|
|
221
310
|
return;
|
|
311
|
+
case "files":
|
|
312
|
+
await runFilesCommand(rest);
|
|
313
|
+
return;
|
|
314
|
+
case "get-file":
|
|
315
|
+
await runGetFileCommand(rest);
|
|
316
|
+
return;
|
|
317
|
+
case "put-file":
|
|
318
|
+
case "save-file":
|
|
319
|
+
await runPutFileCommand(rest);
|
|
320
|
+
return;
|
|
222
321
|
default:
|
|
223
322
|
if (!command.startsWith("-")) {
|
|
224
|
-
// Positional template id → default
|
|
225
|
-
await
|
|
323
|
+
// Positional template id → default to booting the local editor.
|
|
324
|
+
await runServeCommand(argv);
|
|
226
325
|
return;
|
|
227
326
|
}
|
|
228
327
|
throw new Error(`Unknown option before command: ${command}\n\n${HELP}`);
|
|
@@ -337,61 +436,205 @@ function collectKeyValues(entries) {
|
|
|
337
436
|
}
|
|
338
437
|
return out;
|
|
339
438
|
}
|
|
340
|
-
|
|
439
|
+
// `vidfarm serve` — boot the FULL Vidfarm backend locally (single origin), on
|
|
440
|
+
// disk-backed records + storage, so power users edit compositions locally while
|
|
441
|
+
// a browser editor and a coding agent share one source of truth. Render stays
|
|
442
|
+
// on the cloud (see Phase 4). This replaces the cross-origin `?dev=` shim.
|
|
443
|
+
async function runServeCommand(argv) {
|
|
341
444
|
const parsed = parseArgs({
|
|
342
445
|
args: argv,
|
|
343
446
|
allowPositionals: true,
|
|
344
447
|
options: {
|
|
345
|
-
port: { type: "string", default:
|
|
448
|
+
port: { type: "string", default: "3000" },
|
|
346
449
|
dir: { type: "string" },
|
|
347
|
-
|
|
450
|
+
key: { type: "string" },
|
|
348
451
|
fork: { type: "string" },
|
|
452
|
+
host: { type: "string", default: DEFAULT_HOST },
|
|
349
453
|
"api-key": { type: "string" },
|
|
350
454
|
share: { type: "string" },
|
|
351
|
-
refetch: { type: "boolean", default: false }
|
|
455
|
+
refetch: { type: "boolean", default: false },
|
|
456
|
+
open: { type: "boolean", default: true },
|
|
457
|
+
"no-open": { type: "boolean", default: false }
|
|
352
458
|
}
|
|
353
459
|
});
|
|
354
|
-
const templateId = parsed.positionals[0];
|
|
355
|
-
if (!templateId) {
|
|
356
|
-
throw new Error(`edit requires a template id.\n\n${HELP}`);
|
|
357
|
-
}
|
|
358
460
|
const port = Number(parsed.values.port);
|
|
359
461
|
if (!Number.isFinite(port) || port <= 0) {
|
|
360
462
|
throw new Error(`Invalid --port: ${parsed.values.port}`);
|
|
361
463
|
}
|
|
464
|
+
const dataDir = path.resolve(process.cwd(), parsed.values.dir ?? ".vidfarm-local");
|
|
465
|
+
mkdirSync(dataDir, { recursive: true });
|
|
466
|
+
// Capture the ambient cloud key BEFORE we overwrite VIDFARM_API_KEY with the
|
|
467
|
+
// local bootstrap key, so `--fork`/template seeding can still authenticate to
|
|
468
|
+
// the cloud host.
|
|
469
|
+
const envKey = process.env.VIDFARM_API_KEY;
|
|
470
|
+
// A stable local bootstrap key: first --key, else VIDFARM_API_KEY, else a
|
|
471
|
+
// fixed dev key. The same value auto-provisions the paid customer (via
|
|
472
|
+
// tryBootstrapFromEnv) and pre-auths the browser through /auto-login.
|
|
473
|
+
const apiKey = parsed.values.key ?? envKey ?? "vidfarm-local-dev-key";
|
|
474
|
+
const cloudApiKey = parsed.values["api-key"] ?? envKey;
|
|
362
475
|
const host = trimTrailingSlash(parsed.values.host);
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
476
|
+
const templateId = parsed.positionals[0];
|
|
477
|
+
const forkFlag = parsed.values.fork;
|
|
478
|
+
// Env MUST be set before importing runtime — config.ts reads it at module
|
|
479
|
+
// load. Force the fully-local drivers; keep any cloud creds already present
|
|
480
|
+
// in the environment so render passthrough still works.
|
|
481
|
+
process.env.RECORDS_DRIVER = "local";
|
|
482
|
+
process.env.STORAGE_DRIVER = "local";
|
|
483
|
+
process.env.AWS_S3_BUCKET = "";
|
|
484
|
+
process.env.VIDFARM_DATA_DIR = dataDir;
|
|
485
|
+
process.env.VIDFARM_API_KEY = apiKey;
|
|
486
|
+
process.env.PORT = String(port);
|
|
487
|
+
if (!process.env.NODE_ENV)
|
|
488
|
+
process.env.NODE_ENV = "development";
|
|
489
|
+
const { startRuntime } = await import("./runtime.js");
|
|
490
|
+
await startRuntime();
|
|
491
|
+
// If a template/fork was requested, pull its composition from the cloud host
|
|
492
|
+
// onto local disk and materialize the matching local records so the editor
|
|
493
|
+
// resolves it. Best-effort: a failure just drops us to /discover.
|
|
494
|
+
let openTemplateId = templateId;
|
|
495
|
+
let openForkId = forkFlag;
|
|
496
|
+
if (templateId || forkFlag) {
|
|
497
|
+
try {
|
|
498
|
+
const seeded = await seedForkFromCloud({
|
|
499
|
+
host,
|
|
500
|
+
templateId,
|
|
501
|
+
forkId: forkFlag,
|
|
502
|
+
cloudApiKey,
|
|
503
|
+
shareToken: parsed.values.share,
|
|
504
|
+
bootstrapKey: apiKey,
|
|
505
|
+
refetch: parsed.values.refetch
|
|
506
|
+
});
|
|
507
|
+
if (seeded) {
|
|
508
|
+
openTemplateId = seeded.templateId;
|
|
509
|
+
openForkId = seeded.forkId;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
console.warn(`[vidfarm] serve: cloud seed failed (${error instanceof Error ? error.message : String(error)}); starting empty.`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const base = `http://localhost:${port}`;
|
|
517
|
+
const editorPath = openTemplateId
|
|
518
|
+
? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `?fork=${encodeURIComponent(openForkId)}` : ""}`
|
|
519
|
+
: "/discover";
|
|
520
|
+
const openUrl = `${base}/auto-login?api_key=${encodeURIComponent(apiKey)}&redirect=${encodeURIComponent(editorPath)}`;
|
|
521
|
+
printServeBanner({ base, dataDir, openUrl, editorPath });
|
|
522
|
+
const shouldOpen = parsed.values.open && !parsed.values["no-open"];
|
|
523
|
+
if (shouldOpen)
|
|
524
|
+
openInBrowser(openUrl);
|
|
525
|
+
// startRuntime() keeps the process alive via its HTTP server; nothing else to do.
|
|
526
|
+
}
|
|
527
|
+
// Pull a cloud fork's composition onto local disk and create the local
|
|
528
|
+
// template + fork records so the editor resolves it. The fork is either an
|
|
529
|
+
// explicit --fork or the template's default fork (which may be another user's
|
|
530
|
+
// public decomposition). Media stays on cloud URLs — only the composition +
|
|
531
|
+
// records are localized. Runs in-process after startRuntime(), so it shares the
|
|
532
|
+
// server's disk-backed records/storage singletons.
|
|
533
|
+
async function seedForkFromCloud(input) {
|
|
534
|
+
const { serverlessRecords } = await import("./services/serverless-records.js");
|
|
535
|
+
const { StorageService } = await import("./services/storage.js");
|
|
536
|
+
const { ServerlessAuthService } = await import("./services/serverless-auth.js");
|
|
537
|
+
const storage = new StorageService();
|
|
538
|
+
const auth = new ServerlessAuthService();
|
|
539
|
+
// Ensure the local bootstrap customer exists and grab its id to own the seed.
|
|
540
|
+
const customer = await auth.authenticate(input.bootstrapKey);
|
|
541
|
+
const authHeaders = buildAuthHeaders({ apiKey: input.cloudApiKey, shareToken: input.shareToken });
|
|
542
|
+
// 1. Resolve the source fork: explicit --fork wins, else the template's
|
|
543
|
+
// default fork (grabs the shared decomposition even if another user owns it).
|
|
544
|
+
let cloudForkId = input.forkId ?? null;
|
|
545
|
+
if (!cloudForkId && input.templateId) {
|
|
546
|
+
cloudForkId = await resolveForkId({ host: input.host, templateId: input.templateId, apiKey: input.cloudApiKey, shareToken: input.shareToken });
|
|
547
|
+
}
|
|
548
|
+
if (!cloudForkId) {
|
|
549
|
+
console.warn("[vidfarm] serve: no --fork and no default fork resolvable from cloud; starting empty.");
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
// 2. Read the serialized fork for its template id + title.
|
|
553
|
+
const forkMetaRes = await fetch(`${input.host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}`, { headers: authHeaders });
|
|
554
|
+
const forkMeta = forkMetaRes.ok ? (await forkMetaRes.json().catch(() => null)) : null;
|
|
555
|
+
const templateId = input.templateId ?? (typeof forkMeta?.template_id === "string" ? forkMeta.template_id : null);
|
|
556
|
+
if (!templateId || !templateId.startsWith("template_")) {
|
|
557
|
+
console.warn(`[vidfarm] serve: could not determine a template id for fork ${cloudForkId}; starting empty.`);
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
const title = (typeof forkMeta?.title === "string" && forkMeta.title) || `Local · ${templateId}`;
|
|
561
|
+
// 3. Materialize the local template record (the editor 404s without it).
|
|
562
|
+
const existingTemplate = await serverlessRecords.getTemplate(templateId);
|
|
563
|
+
if (!existingTemplate) {
|
|
564
|
+
await serverlessRecords.createTemplate({
|
|
565
|
+
id: templateId,
|
|
566
|
+
inspirationId: `local:${templateId}`,
|
|
567
|
+
customerId: customer.id,
|
|
568
|
+
originalUrl: typeof forkMeta?.original_url === "string" ? forkMeta.original_url : "",
|
|
569
|
+
sourceHost: "local",
|
|
570
|
+
title,
|
|
571
|
+
visibility: "public"
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
// 4. Materialize the local fork (reuse the cloud id for stable URLs).
|
|
575
|
+
const localForkId = cloudForkId;
|
|
576
|
+
const existingFork = await serverlessRecords.getCompositionFork(localForkId);
|
|
577
|
+
if (!existingFork) {
|
|
578
|
+
await serverlessRecords.createCompositionFork({
|
|
579
|
+
id: localForkId,
|
|
580
|
+
customerId: customer.id,
|
|
581
|
+
templateId,
|
|
582
|
+
title,
|
|
583
|
+
visibility: "private"
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
// 5. Pull composition.html/json onto disk (skip if present unless --refetch,
|
|
587
|
+
// so local edits aren't clobbered on re-serve).
|
|
588
|
+
const htmlKey = storage.compositionForkWorkingKey(localForkId, "composition.html");
|
|
589
|
+
const jsonKey = storage.compositionForkWorkingKey(localForkId, "composition.json");
|
|
590
|
+
const hasLocalHtml = Boolean(await storage.readText(htmlKey));
|
|
591
|
+
if (!hasLocalHtml || input.refetch) {
|
|
592
|
+
const htmlRes = await fetch(`${input.host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}/composition.html`, { headers: authHeaders });
|
|
593
|
+
if (!htmlRes.ok) {
|
|
594
|
+
console.warn(`[vidfarm] serve: could not fetch composition.html for ${cloudForkId} (${htmlRes.status}); starting empty.`);
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
await storage.putText(htmlKey, await htmlRes.text(), "text/html; charset=utf-8");
|
|
598
|
+
const jsonRes = await fetch(`${input.host}/api/v1/compositions/${encodeURIComponent(cloudForkId)}/composition.json`, { headers: authHeaders });
|
|
599
|
+
if (jsonRes.ok)
|
|
600
|
+
await storage.putBuffer(jsonKey, Buffer.from(await jsonRes.text(), "utf8"), "application/json");
|
|
601
|
+
console.log(`[vidfarm] seeded fork ${localForkId} from ${input.host}`);
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
console.log(`[vidfarm] fork ${localForkId} already local (use --refetch to overwrite)`);
|
|
605
|
+
}
|
|
606
|
+
// 6. Point the template's default fork at the local copy so a bare
|
|
607
|
+
// /editor/:templateId resolves it too.
|
|
608
|
+
await serverlessRecords.stampDefaultForkIfUnset({ templateId, forkId: localForkId });
|
|
609
|
+
return { templateId, forkId: localForkId };
|
|
610
|
+
}
|
|
611
|
+
function printServeBanner(input) {
|
|
612
|
+
const line = `${DIM}${"─".repeat(74)}${RESET}`;
|
|
613
|
+
console.log("");
|
|
614
|
+
console.log(line);
|
|
615
|
+
console.log(`${BOLD}${GREEN} Vidfarm local server${RESET} ${DIM}(fully local — records + storage on disk)${RESET}`);
|
|
616
|
+
console.log(line);
|
|
617
|
+
console.log(` server ${input.base}`);
|
|
618
|
+
console.log(` data dir ${input.dataDir}`);
|
|
619
|
+
console.log(` render ${DIM}cloud (submitted to the deployed renderer)${RESET}`);
|
|
620
|
+
console.log("");
|
|
621
|
+
console.log(` ${DIM}Agents: edit composition files under ${input.dataDir}/storage — the`);
|
|
622
|
+
console.log(` browser live-reloads on save. Multiple forks can be edited at once.${RESET}`);
|
|
623
|
+
console.log("");
|
|
624
|
+
console.log(` ${BOLD}Open (pre-authed):${RESET}`);
|
|
625
|
+
console.log(` ${FRONTEND}${input.openUrl}${RESET}`);
|
|
626
|
+
console.log(line);
|
|
627
|
+
console.log("");
|
|
628
|
+
}
|
|
629
|
+
function openInBrowser(url) {
|
|
630
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
631
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
384
632
|
try {
|
|
385
|
-
|
|
633
|
+
spawnSync(opener, args, { stdio: "ignore" });
|
|
386
634
|
}
|
|
387
|
-
catch
|
|
388
|
-
|
|
635
|
+
catch {
|
|
636
|
+
// Non-fatal: the banner already printed the URL to open manually.
|
|
389
637
|
}
|
|
390
|
-
const devSource = `http://localhost:${port}`;
|
|
391
|
-
const editorUrl = `${host}/editor/${encodeURIComponent(templateId)}?fork=${encodeURIComponent(forkId)}&dev=${encodeURIComponent(devSource)}`;
|
|
392
|
-
printEditorBanner({ templateId, forkId, dir: rootDir, port, editorUrl });
|
|
393
|
-
const { runDevServeCommand } = await import("./dev-serve.js");
|
|
394
|
-
await runDevServeCommand(["--dir", rootDir, "--port", String(port)]);
|
|
395
638
|
}
|
|
396
639
|
async function runPublishCommand(argv) {
|
|
397
640
|
const parsed = parseArgs({
|
|
@@ -564,7 +807,9 @@ async function fetchCompositionFiles(input) {
|
|
|
564
807
|
// video-context.json carries the source video's audio transcript and
|
|
565
808
|
// per-scene visual descriptions so desktop agents (Claude Code, Codex)
|
|
566
809
|
// can ground edits in what the video actually says and shows.
|
|
567
|
-
|
|
810
|
+
// cast.json carries the identified recurring people/characters plus their
|
|
811
|
+
// reference-still URLs so agents can reuse "the same person" in new gens.
|
|
812
|
+
const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json"];
|
|
568
813
|
for (const filename of files) {
|
|
569
814
|
const target = path.join(input.dir, filename);
|
|
570
815
|
if (existsSync(target) && !input.refetch) {
|
|
@@ -588,10 +833,10 @@ async function fetchVideoAssets(input) {
|
|
|
588
833
|
console.log("[vidfarm] no source video URL found in composition — skipping video assets");
|
|
589
834
|
return;
|
|
590
835
|
}
|
|
591
|
-
// "decomposed video
|
|
592
|
-
//
|
|
593
|
-
// decompose ran against, before any processing. They
|
|
594
|
-
// was never
|
|
836
|
+
// "decomposed video" = the source the composition currently renders from
|
|
837
|
+
// (caption-free after subtitle removal, if applicable). "original video" =
|
|
838
|
+
// the raw source the decompose ran against, before any processing. They
|
|
839
|
+
// coincide when the fork was never subtitle-removed.
|
|
595
840
|
const compositionUrl = refs.compositionSourceUrl ?? refs.originalSourceUrl;
|
|
596
841
|
const originalUrl = refs.originalSourceUrl ?? refs.compositionSourceUrl;
|
|
597
842
|
const sameSource = compositionUrl === originalUrl;
|
|
@@ -630,19 +875,20 @@ async function fetchVideoAssets(input) {
|
|
|
630
875
|
: new Map();
|
|
631
876
|
const manifest = {
|
|
632
877
|
generated_by: "vidfarm-devcli",
|
|
633
|
-
note: "Local media references for coding agents. `original.mp4` is the
|
|
634
|
-
"`source.mp4` is the video the
|
|
635
|
-
"
|
|
636
|
-
"
|
|
878
|
+
note: "Local media references for coding agents. Two video sources: `original.mp4` is the " +
|
|
879
|
+
"ORIGINAL video (raw source, captions intact); `source.mp4` is the DECOMPOSED video, the " +
|
|
880
|
+
"processed caption-free copy the composition timeline renders from (differs from the original " +
|
|
881
|
+
"only when the fork was decomposed with subtitle removal). Scene entries map timeline segments " +
|
|
882
|
+
"to a still frame you can open as an image. Run `--refetch` to refresh.",
|
|
637
883
|
videos: {
|
|
638
884
|
decomposed: {
|
|
639
|
-
role: "video the composition timeline renders from
|
|
885
|
+
role: "decomposed video: processed caption-free source the composition timeline renders from",
|
|
640
886
|
url: compositionUrl,
|
|
641
887
|
path: decomposed ? decomposedName : null,
|
|
642
888
|
bytes: decomposed?.bytes ?? null
|
|
643
889
|
},
|
|
644
890
|
original: {
|
|
645
|
-
role: "raw
|
|
891
|
+
role: "original video: raw source before any processing",
|
|
646
892
|
url: originalUrl,
|
|
647
893
|
path: original ? original.path : null,
|
|
648
894
|
bytes: original?.bytes ?? null,
|
|
@@ -801,6 +1047,39 @@ function safeSize(target) {
|
|
|
801
1047
|
return 0;
|
|
802
1048
|
}
|
|
803
1049
|
}
|
|
1050
|
+
// Guess a content type from a file name so My Files stores text docs as text/*
|
|
1051
|
+
// (a bare multipart Blob otherwise defaults to application/octet-stream, which
|
|
1052
|
+
// makes browsers download .md/.txt instead of showing them). Mirrors the common
|
|
1053
|
+
// cases of the server's inferAttachmentContentType; returns "" when unknown so
|
|
1054
|
+
// the server can fall back to its own inference.
|
|
1055
|
+
function guessContentType(fileName) {
|
|
1056
|
+
const dot = fileName.lastIndexOf(".");
|
|
1057
|
+
const ext = dot >= 0 ? fileName.slice(dot).toLowerCase() : "";
|
|
1058
|
+
switch (ext) {
|
|
1059
|
+
case ".md": return "text/markdown; charset=utf-8";
|
|
1060
|
+
case ".txt": return "text/plain; charset=utf-8";
|
|
1061
|
+
case ".csv": return "text/csv; charset=utf-8";
|
|
1062
|
+
case ".json": return "application/json";
|
|
1063
|
+
case ".srt":
|
|
1064
|
+
case ".vtt": return "text/plain; charset=utf-8";
|
|
1065
|
+
case ".png": return "image/png";
|
|
1066
|
+
case ".jpg":
|
|
1067
|
+
case ".jpeg": return "image/jpeg";
|
|
1068
|
+
case ".gif": return "image/gif";
|
|
1069
|
+
case ".webp": return "image/webp";
|
|
1070
|
+
case ".svg": return "image/svg+xml";
|
|
1071
|
+
case ".mp4":
|
|
1072
|
+
case ".m4v": return "video/mp4";
|
|
1073
|
+
case ".mov": return "video/quicktime";
|
|
1074
|
+
case ".webm": return "video/webm";
|
|
1075
|
+
case ".mp3": return "audio/mpeg";
|
|
1076
|
+
case ".wav": return "audio/wav";
|
|
1077
|
+
case ".m4a": return "audio/mp4";
|
|
1078
|
+
case ".aac": return "audio/aac";
|
|
1079
|
+
case ".pdf": return "application/pdf";
|
|
1080
|
+
default: return "";
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
804
1083
|
function formatBytes(bytes) {
|
|
805
1084
|
if (bytes >= 1024 * 1024)
|
|
806
1085
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
@@ -818,29 +1097,6 @@ function buildAuthHeaders(input) {
|
|
|
818
1097
|
headers["vidfarm-share-token"] = input.shareToken;
|
|
819
1098
|
return headers;
|
|
820
1099
|
}
|
|
821
|
-
function printEditorBanner(input) {
|
|
822
|
-
const bold = "\x1b[1m";
|
|
823
|
-
const green = "\x1b[32m";
|
|
824
|
-
const cyan = "\x1b[36m";
|
|
825
|
-
const dim = "\x1b[2m";
|
|
826
|
-
const reset = "\x1b[0m";
|
|
827
|
-
const line = `${dim}${"─".repeat(74)}${reset}`;
|
|
828
|
-
console.log("");
|
|
829
|
-
console.log(line);
|
|
830
|
-
console.log(`${bold}${green} Vidfarm local editor${reset}`);
|
|
831
|
-
console.log(line);
|
|
832
|
-
console.log(` template ${input.templateId}`);
|
|
833
|
-
console.log(` fork ${input.forkId}`);
|
|
834
|
-
console.log(` local dir ${input.dir}`);
|
|
835
|
-
console.log(` dev-serve http://localhost:${input.port}`);
|
|
836
|
-
console.log("");
|
|
837
|
-
console.log(` ${dim}Agent media: original.mp4, source.mp4, frames/*.png, sources.json${reset}`);
|
|
838
|
-
console.log("");
|
|
839
|
-
console.log(` ${bold}Open in browser:${reset}`);
|
|
840
|
-
console.log(` ${cyan}${input.editorUrl}${reset}`);
|
|
841
|
-
console.log(line);
|
|
842
|
-
console.log("");
|
|
843
|
-
}
|
|
844
1100
|
function trimTrailingSlash(value) {
|
|
845
1101
|
return value.replace(/\/+$/, "");
|
|
846
1102
|
}
|
|
@@ -884,13 +1140,33 @@ async function runApiCommand(argv) {
|
|
|
884
1140
|
process.exitCode = 1;
|
|
885
1141
|
}
|
|
886
1142
|
// ── Discover & inspiration ──────────────────────────────────────────────────
|
|
1143
|
+
// `discover` lists TEMPLATES; `--query` keyword-searches the catalog so an agent
|
|
1144
|
+
// CLI (Claude Code / Codex) can answer "which templates suit my <offer>?". Each
|
|
1145
|
+
// result carries decompose-derived promotions/keywords/summary to reason over.
|
|
887
1146
|
async function runDiscoverCommand(argv) {
|
|
888
|
-
const parsed = parseArgs({ args: argv, allowPositionals:
|
|
1147
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), limit: { type: "string" }, query: { type: "string" } } });
|
|
889
1148
|
const ctx = commonContext(parsed.values);
|
|
890
|
-
const
|
|
1149
|
+
const query = (parsed.values.query ?? parsed.positionals.join(" ").trim()) || undefined;
|
|
1150
|
+
const result = await apiRequest({ method: "GET", host: ctx.host, path: "/discover/feed", auth: ctx.auth, query: { limit: parsed.values.limit, q: query } });
|
|
891
1151
|
assertApiOk(result, "discover");
|
|
892
1152
|
emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
|
|
893
1153
|
}
|
|
1154
|
+
// `videos` lists source INSPIRATIONS (the raw viral videos), with the same
|
|
1155
|
+
// keyword search and an optional --mine for the caller's own uploads.
|
|
1156
|
+
async function runVideosCommand(argv) {
|
|
1157
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), limit: { type: "string" }, query: { type: "string" }, mine: { type: "boolean", default: false } } });
|
|
1158
|
+
const ctx = commonContext(parsed.values);
|
|
1159
|
+
const query = (parsed.values.query ?? parsed.positionals.join(" ").trim()) || undefined;
|
|
1160
|
+
const result = await apiRequest({
|
|
1161
|
+
method: "GET",
|
|
1162
|
+
host: ctx.host,
|
|
1163
|
+
path: "/api/v1/videos",
|
|
1164
|
+
auth: ctx.auth,
|
|
1165
|
+
query: { limit: parsed.values.limit, q: query, mine: parsed.values.mine ? "true" : undefined }
|
|
1166
|
+
});
|
|
1167
|
+
assertApiOk(result, "videos");
|
|
1168
|
+
emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
|
|
1169
|
+
}
|
|
894
1170
|
async function runInspirationAddCommand(argv) {
|
|
895
1171
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), tagline: { type: "string" }, notes: { type: "string" } } });
|
|
896
1172
|
const sourceUrl = parsed.positionals[0];
|
|
@@ -931,6 +1207,12 @@ async function runInspirationDecomposeCommand(argv) {
|
|
|
931
1207
|
body: { user_prompt: parsed.values.prompt ?? null }
|
|
932
1208
|
});
|
|
933
1209
|
assertApiOk(result, "inspiration-decompose");
|
|
1210
|
+
if (!ctx.json && result.json?.default_fork_id) {
|
|
1211
|
+
const forkId = result.json.default_fork_id;
|
|
1212
|
+
console.log(result.json.became_default_fork
|
|
1213
|
+
? `${DIM}This decompose set the template default fork (${forkId}) — everyone now forks off it.${RESET}`
|
|
1214
|
+
: `${DIM}Template already has a default fork (${forkId}); left it unchanged.${RESET}`);
|
|
1215
|
+
}
|
|
934
1216
|
emitResult(result, ctx.json);
|
|
935
1217
|
}
|
|
936
1218
|
// ── Composition lifecycle ───────────────────────────────────────────────────
|
|
@@ -1014,7 +1296,7 @@ async function runRenderCommand(argv) {
|
|
|
1014
1296
|
const result = await apiRequest({
|
|
1015
1297
|
method: "POST",
|
|
1016
1298
|
host: ctx.host,
|
|
1017
|
-
path: `/api/v1/compositions/${encodeURIComponent(forkId)}/
|
|
1299
|
+
path: `/api/v1/compositions/${encodeURIComponent(forkId)}/render`,
|
|
1018
1300
|
auth: ctx.auth,
|
|
1019
1301
|
body: { title: parsed.values.title, tracer: parsed.values.tracer }
|
|
1020
1302
|
});
|
|
@@ -1058,6 +1340,351 @@ async function runRenderStatusCommand(argv) {
|
|
|
1058
1340
|
assertApiOk(result, "render-status");
|
|
1059
1341
|
emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.outputUrl]]);
|
|
1060
1342
|
}
|
|
1343
|
+
// ── AI generation + timeline placement (for local coding agents) ────────────
|
|
1344
|
+
// `generate` submits an AI image/video primitive job and (by default) polls it
|
|
1345
|
+
// to the finished media URL — the async poll the raw `api` command can't do for
|
|
1346
|
+
// you. `place` drops any media URL into a local composition.html (fill a gap or
|
|
1347
|
+
// replace a scene), producing the SAME clip markup the browser editor makes, so
|
|
1348
|
+
// `serve` live-morphs it and `publish` pushes it. Together they let a Claude
|
|
1349
|
+
// Code / Codex agent do end-to-end "generate → insert" without hand-editing HTML.
|
|
1350
|
+
// Parse "5", "5.5", "mm:ss", or "hh:mm:ss" into seconds.
|
|
1351
|
+
function parseTimeToSeconds(raw) {
|
|
1352
|
+
const trimmed = raw.trim();
|
|
1353
|
+
if (/^\d+(\.\d+)?$/.test(trimmed))
|
|
1354
|
+
return Number(trimmed);
|
|
1355
|
+
const parts = trimmed.split(":").map((p) => Number(p));
|
|
1356
|
+
if (parts.some((n) => !Number.isFinite(n)))
|
|
1357
|
+
throw new Error(`Invalid time: ${raw} (use seconds, mm:ss, or hh:mm:ss).`);
|
|
1358
|
+
return parts.reduce((acc, n) => acc * 60 + n, 0);
|
|
1359
|
+
}
|
|
1360
|
+
// Pull the finished media URL out of a settled primitive job (GET /me/jobs/:id).
|
|
1361
|
+
// Mirrors the server's extractOutputUrlFromJob.
|
|
1362
|
+
function resolveJobMediaUrl(job) {
|
|
1363
|
+
if (!job || typeof job !== "object")
|
|
1364
|
+
return null;
|
|
1365
|
+
const readStr = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
|
|
1366
|
+
const result = (job.result && typeof job.result === "object") ? job.result : {};
|
|
1367
|
+
const output = (result.output && typeof result.output === "object") ? result.output : result;
|
|
1368
|
+
const direct = readStr(output.primary_file_url)
|
|
1369
|
+
?? readStr(output.video?.file_url)
|
|
1370
|
+
?? readStr(output.image?.file_url)
|
|
1371
|
+
?? readStr(output.render?.output_url);
|
|
1372
|
+
if (direct)
|
|
1373
|
+
return direct;
|
|
1374
|
+
if (Array.isArray(output.files)) {
|
|
1375
|
+
for (const f of output.files) {
|
|
1376
|
+
const u = readStr(typeof f === "string" ? f : (f?.file_url ?? f?.url));
|
|
1377
|
+
if (u)
|
|
1378
|
+
return u;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (Array.isArray(job.artifacts)) {
|
|
1382
|
+
for (const a of job.artifacts) {
|
|
1383
|
+
const u = readStr(a?.public_url);
|
|
1384
|
+
if (u)
|
|
1385
|
+
return u;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1390
|
+
// Poll a primitive job until it settles (or times out). Returns the final job.
|
|
1391
|
+
async function pollPrimitiveJob(ctx, jobId) {
|
|
1392
|
+
const MAX_WAIT_MS = 8 * 60 * 1000;
|
|
1393
|
+
const startedAt = Date.now();
|
|
1394
|
+
let last = null;
|
|
1395
|
+
for (;;) {
|
|
1396
|
+
await sleep(5000);
|
|
1397
|
+
const res = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/user/me/jobs/${encodeURIComponent(jobId)}`, auth: ctx.auth });
|
|
1398
|
+
if (res.ok && res.json) {
|
|
1399
|
+
last = res.json;
|
|
1400
|
+
const status = String(last.status ?? "");
|
|
1401
|
+
if (!ctx.json)
|
|
1402
|
+
console.log(`${DIM} ${status}${typeof last.progress === "number" ? ` ${Math.round(last.progress * 100)}%` : ""}${RESET}`);
|
|
1403
|
+
if (status === "succeeded" || status === "failed" || status === "cancelled")
|
|
1404
|
+
return last;
|
|
1405
|
+
if (resolveJobMediaUrl(last))
|
|
1406
|
+
return last;
|
|
1407
|
+
}
|
|
1408
|
+
if (Date.now() - startedAt > MAX_WAIT_MS)
|
|
1409
|
+
return last ?? { status: "timeout" };
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
// Upload a local file to the ephemeral temp store, returning its durable URL.
|
|
1413
|
+
// Used to turn `--ref @local.png` into a direct asset URL for reference images.
|
|
1414
|
+
async function uploadTempFileForRef(ctx, absPath) {
|
|
1415
|
+
if (!existsSync(absPath))
|
|
1416
|
+
throw new Error(`No such reference file: ${absPath}`);
|
|
1417
|
+
const form = new FormData();
|
|
1418
|
+
form.append("file", new Blob([readFileSync(absPath)]), path.basename(absPath));
|
|
1419
|
+
const res = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
|
|
1420
|
+
const text = await res.text();
|
|
1421
|
+
let json = null;
|
|
1422
|
+
try {
|
|
1423
|
+
json = text ? JSON.parse(text) : null;
|
|
1424
|
+
}
|
|
1425
|
+
catch {
|
|
1426
|
+
json = null;
|
|
1427
|
+
}
|
|
1428
|
+
if (!res.ok)
|
|
1429
|
+
throw new Error(`reference upload failed (${res.status}): ${text.slice(0, 200)}`);
|
|
1430
|
+
const url = json?.file?.view_url ?? json?.file?.s3_url;
|
|
1431
|
+
if (!url)
|
|
1432
|
+
throw new Error("reference upload returned no durable URL.");
|
|
1433
|
+
return url;
|
|
1434
|
+
}
|
|
1435
|
+
// Resolve --ref values: pass http(s) URLs through; upload @local files.
|
|
1436
|
+
async function resolveReferenceUrls(ctx, refs) {
|
|
1437
|
+
const out = [];
|
|
1438
|
+
for (const raw of refs ?? []) {
|
|
1439
|
+
const value = raw.trim();
|
|
1440
|
+
if (!value)
|
|
1441
|
+
continue;
|
|
1442
|
+
if (value.startsWith("@")) {
|
|
1443
|
+
out.push(await uploadTempFileForRef(ctx, path.resolve(process.cwd(), value.slice(1))));
|
|
1444
|
+
}
|
|
1445
|
+
else if (/^https?:\/\//i.test(value)) {
|
|
1446
|
+
out.push(value);
|
|
1447
|
+
}
|
|
1448
|
+
else {
|
|
1449
|
+
// Bare path with no @ — treat as a local file too, for convenience.
|
|
1450
|
+
out.push(await uploadTempFileForRef(ctx, path.resolve(process.cwd(), value)));
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
return out;
|
|
1454
|
+
}
|
|
1455
|
+
function inferMediaKindFromUrl(url) {
|
|
1456
|
+
return /\.(mp4|mov|webm|m4v|mkv)(\?|#|$)/i.test(url) ? "video" : "image";
|
|
1457
|
+
}
|
|
1458
|
+
// Resolve a composition.html on disk from a file path or a directory holding one.
|
|
1459
|
+
function resolveCompositionHtmlPath(target) {
|
|
1460
|
+
const abs = path.resolve(process.cwd(), target);
|
|
1461
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
|
1462
|
+
const inside = path.join(abs, "composition.html");
|
|
1463
|
+
if (!existsSync(inside))
|
|
1464
|
+
throw new Error(`No composition.html inside ${abs}. Run \`vidfarm pull <forkId> --dir ${target}\` first.`);
|
|
1465
|
+
return inside;
|
|
1466
|
+
}
|
|
1467
|
+
if (!existsSync(abs))
|
|
1468
|
+
throw new Error(`No such composition file or dir: ${abs}.`);
|
|
1469
|
+
return abs;
|
|
1470
|
+
}
|
|
1471
|
+
// Apply an insert/replace of a media URL onto a composition.html on disk.
|
|
1472
|
+
function placeMediaOnDisk(input) {
|
|
1473
|
+
const html = readFileSync(input.htmlPath, "utf8");
|
|
1474
|
+
const opts = {
|
|
1475
|
+
kind: input.kind,
|
|
1476
|
+
src: input.src,
|
|
1477
|
+
layerKey: input.layerKey,
|
|
1478
|
+
start: input.at,
|
|
1479
|
+
duration: input.duration,
|
|
1480
|
+
track: input.track,
|
|
1481
|
+
x: input.x,
|
|
1482
|
+
y: input.y,
|
|
1483
|
+
width: input.width,
|
|
1484
|
+
height: input.height,
|
|
1485
|
+
objectFit: input.objectFit,
|
|
1486
|
+
slug: input.slug
|
|
1487
|
+
};
|
|
1488
|
+
const result = input.replace
|
|
1489
|
+
? replaceLayerWithMedia(html, input.replace, opts)
|
|
1490
|
+
: insertMediaLayer(html, opts);
|
|
1491
|
+
writeFileSync(input.htmlPath, result.html);
|
|
1492
|
+
return { layerKey: result.layerKey, gaps: computeCompositionGaps(result.html) };
|
|
1493
|
+
}
|
|
1494
|
+
async function runGenerateCommand(argv) {
|
|
1495
|
+
const parsed = parseArgs({
|
|
1496
|
+
args: argv,
|
|
1497
|
+
allowPositionals: true,
|
|
1498
|
+
options: {
|
|
1499
|
+
...commonOptions(),
|
|
1500
|
+
prompt: { type: "string" },
|
|
1501
|
+
ref: { type: "string", multiple: true },
|
|
1502
|
+
"aspect-ratio": { type: "string" },
|
|
1503
|
+
duration: { type: "string" },
|
|
1504
|
+
provider: { type: "string" },
|
|
1505
|
+
model: { type: "string" },
|
|
1506
|
+
resolution: { type: "string" },
|
|
1507
|
+
audio: { type: "boolean", default: false },
|
|
1508
|
+
"no-wait": { type: "boolean", default: false },
|
|
1509
|
+
tracer: { type: "string" },
|
|
1510
|
+
// Fused placement onto a local composition.html.
|
|
1511
|
+
place: { type: "string" },
|
|
1512
|
+
at: { type: "string" },
|
|
1513
|
+
replace: { type: "string" },
|
|
1514
|
+
track: { type: "string" },
|
|
1515
|
+
"layer-key": { type: "string" }
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
const mediaType = parsed.positionals[0];
|
|
1519
|
+
if (mediaType !== "image" && mediaType !== "video") {
|
|
1520
|
+
throw new Error("generate requires a media type: `vidfarm generate image ...` or `vidfarm generate video ...`.");
|
|
1521
|
+
}
|
|
1522
|
+
const prompt = parsed.values.prompt;
|
|
1523
|
+
if (!prompt || !prompt.trim())
|
|
1524
|
+
throw new Error("generate requires --prompt \"...\".");
|
|
1525
|
+
const ctx = commonContext(parsed.values);
|
|
1526
|
+
const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
|
|
1527
|
+
const payload = { prompt };
|
|
1528
|
+
if (parsed.values["aspect-ratio"])
|
|
1529
|
+
payload.aspect_ratio = parsed.values["aspect-ratio"];
|
|
1530
|
+
if (parsed.values.provider)
|
|
1531
|
+
payload.provider = parsed.values.provider;
|
|
1532
|
+
if (parsed.values.model)
|
|
1533
|
+
payload.model = parsed.values.model;
|
|
1534
|
+
if (mediaType === "video") {
|
|
1535
|
+
if (parsed.values.duration)
|
|
1536
|
+
payload.duration = Math.round(Number(parsed.values.duration));
|
|
1537
|
+
if (parsed.values.resolution)
|
|
1538
|
+
payload.resolution = parsed.values.resolution;
|
|
1539
|
+
if (parsed.values.audio)
|
|
1540
|
+
payload.generate_audio = true;
|
|
1541
|
+
if (refs.length)
|
|
1542
|
+
payload.input_references = refs.slice(0, 8);
|
|
1543
|
+
}
|
|
1544
|
+
else if (refs.length) {
|
|
1545
|
+
payload.prompt_attachments = refs.slice(0, 16);
|
|
1546
|
+
}
|
|
1547
|
+
const tracer = parsed.values.tracer ?? `devcli-gen-${Date.now().toString(36)}`;
|
|
1548
|
+
const route = mediaType === "video" ? "/api/v1/primitives/videos/generate" : "/api/v1/primitives/images/generate";
|
|
1549
|
+
const submit = await apiRequest({ method: "POST", host: ctx.host, path: route, auth: ctx.auth, body: { tracer, payload } });
|
|
1550
|
+
assertApiOk(submit, "generate");
|
|
1551
|
+
const jobId = submit.json?.job_id;
|
|
1552
|
+
const wait = !parsed.values["no-wait"];
|
|
1553
|
+
if (!wait || !jobId) {
|
|
1554
|
+
if (!ctx.json && jobId)
|
|
1555
|
+
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}`);
|
|
1556
|
+
emitResult(submit, ctx.json);
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
if (!ctx.json)
|
|
1560
|
+
console.log(`${DIM}Generating ${mediaType} (${jobId})… polling every 5s.${RESET}`);
|
|
1561
|
+
const job = await pollPrimitiveJob(ctx, jobId);
|
|
1562
|
+
const mediaUrl = resolveJobMediaUrl(job);
|
|
1563
|
+
const status = String(job?.status ?? "");
|
|
1564
|
+
if (!mediaUrl) {
|
|
1565
|
+
if (ctx.json) {
|
|
1566
|
+
printJson({ ok: false, job_id: jobId, status, job });
|
|
1567
|
+
}
|
|
1568
|
+
else {
|
|
1569
|
+
console.log(`${RED}Generation ${status || "did not finish"} — no media URL.${RESET}`);
|
|
1570
|
+
printJson(job);
|
|
1571
|
+
}
|
|
1572
|
+
process.exitCode = 1;
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
// Optional fused placement onto a local composition.html.
|
|
1576
|
+
let placement = null;
|
|
1577
|
+
if (parsed.values.place) {
|
|
1578
|
+
const htmlPath = resolveCompositionHtmlPath(String(parsed.values.place));
|
|
1579
|
+
placement = placeMediaOnDisk({
|
|
1580
|
+
htmlPath,
|
|
1581
|
+
src: mediaUrl,
|
|
1582
|
+
kind: mediaType,
|
|
1583
|
+
at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
|
|
1584
|
+
replace: parsed.values.replace,
|
|
1585
|
+
track: parsed.values.track ? Number(parsed.values.track) : undefined,
|
|
1586
|
+
layerKey: parsed.values["layer-key"]
|
|
1587
|
+
});
|
|
1588
|
+
if (!ctx.json)
|
|
1589
|
+
console.log(`${GREEN}Placed as ${placement.layerKey} in ${htmlPath}. Run \`vidfarm publish --dir ${path.dirname(htmlPath)}\` (or it live-morphs under \`serve\`).${RESET}`);
|
|
1590
|
+
}
|
|
1591
|
+
if (ctx.json) {
|
|
1592
|
+
printJson({ ok: true, job_id: jobId, media_url: mediaUrl, media_type: mediaType, placement });
|
|
1593
|
+
}
|
|
1594
|
+
else {
|
|
1595
|
+
console.log(`${GREEN}${mediaType} ready:${RESET} ${mediaUrl}`);
|
|
1596
|
+
if (!placement)
|
|
1597
|
+
console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind ${mediaType} --at <time>|--replace <layer_key>${RESET}`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
async function runPlaceCommand(argv) {
|
|
1601
|
+
const parsed = parseArgs({
|
|
1602
|
+
args: argv,
|
|
1603
|
+
allowPositionals: true,
|
|
1604
|
+
options: {
|
|
1605
|
+
...commonOptions(),
|
|
1606
|
+
src: { type: "string" },
|
|
1607
|
+
kind: { type: "string" },
|
|
1608
|
+
at: { type: "string" },
|
|
1609
|
+
duration: { type: "string" },
|
|
1610
|
+
replace: { type: "string" },
|
|
1611
|
+
track: { type: "string" },
|
|
1612
|
+
x: { type: "string" },
|
|
1613
|
+
y: { type: "string" },
|
|
1614
|
+
width: { type: "string" },
|
|
1615
|
+
height: { type: "string" },
|
|
1616
|
+
"object-fit": { type: "string" },
|
|
1617
|
+
"layer-key": { type: "string" },
|
|
1618
|
+
slug: { type: "string" }
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
const target = parsed.positionals[0];
|
|
1622
|
+
if (!target)
|
|
1623
|
+
throw new Error("place requires a composition path: `vidfarm place <dir-or-composition.html> --src <url> ...`.");
|
|
1624
|
+
const src = parsed.values.src;
|
|
1625
|
+
if (!src || !/^https?:\/\//i.test(src))
|
|
1626
|
+
throw new Error("place requires --src <direct media URL>.");
|
|
1627
|
+
const ctx = commonContext(parsed.values);
|
|
1628
|
+
const htmlPath = resolveCompositionHtmlPath(target);
|
|
1629
|
+
const kind = parsed.values.kind === "video" || parsed.values.kind === "image"
|
|
1630
|
+
? parsed.values.kind
|
|
1631
|
+
: inferMediaKindFromUrl(src);
|
|
1632
|
+
const num = (v) => (v !== undefined ? Number(v) : undefined);
|
|
1633
|
+
const placement = placeMediaOnDisk({
|
|
1634
|
+
htmlPath,
|
|
1635
|
+
src,
|
|
1636
|
+
kind,
|
|
1637
|
+
at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
|
|
1638
|
+
duration: num(parsed.values.duration),
|
|
1639
|
+
replace: parsed.values.replace,
|
|
1640
|
+
track: num(parsed.values.track),
|
|
1641
|
+
x: num(parsed.values.x),
|
|
1642
|
+
y: num(parsed.values.y),
|
|
1643
|
+
width: num(parsed.values.width),
|
|
1644
|
+
height: num(parsed.values.height),
|
|
1645
|
+
objectFit: parsed.values["object-fit"],
|
|
1646
|
+
layerKey: parsed.values["layer-key"],
|
|
1647
|
+
slug: parsed.values.slug
|
|
1648
|
+
});
|
|
1649
|
+
if (ctx.json) {
|
|
1650
|
+
printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, timeline_gaps: placement.gaps });
|
|
1651
|
+
}
|
|
1652
|
+
else {
|
|
1653
|
+
console.log(`${GREEN}Placed ${kind} as ${placement.layerKey}${parsed.values.replace ? ` (replaced ${parsed.values.replace})` : ""} in ${htmlPath}.${RESET}`);
|
|
1654
|
+
console.log(`${DIM}Remaining timeline gaps: ${placement.gaps.length ? placement.gaps.map((g) => `${g.start}-${g.end}s`).join(", ") : "none"}.${RESET}`);
|
|
1655
|
+
console.log(`${DIM}Publish with: vidfarm publish --dir ${path.dirname(htmlPath)} --fork <forkId> (or it live-morphs under \`serve\`).${RESET}`);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
async function runPullCommand(argv) {
|
|
1659
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), dir: { type: "string" }, refetch: { type: "boolean", default: false } } });
|
|
1660
|
+
const forkId = parsed.positionals[0];
|
|
1661
|
+
if (!forkId)
|
|
1662
|
+
throw new Error("pull requires a fork id: `vidfarm pull <forkId> [--dir <path>]`.");
|
|
1663
|
+
const ctx = commonContext(parsed.values);
|
|
1664
|
+
const dir = path.resolve(process.cwd(), parsed.values.dir ?? path.join(".vidfarm", forkId));
|
|
1665
|
+
mkdirSync(dir, { recursive: true });
|
|
1666
|
+
await fetchCompositionFiles({ host: ctx.host, forkId, dir, apiKey: ctx.auth.apiKey, shareToken: ctx.auth.shareToken, refetch: Boolean(parsed.values.refetch) });
|
|
1667
|
+
const htmlPath = path.join(dir, "composition.html");
|
|
1668
|
+
if (!existsSync(htmlPath))
|
|
1669
|
+
throw new Error(`pull did not fetch composition.html for ${forkId} (check credentials / fork id).`);
|
|
1670
|
+
const info = inspectComposition(readFileSync(htmlPath, "utf8"));
|
|
1671
|
+
const hasContext = existsSync(path.join(dir, "video-context.json"));
|
|
1672
|
+
const hasCast = existsSync(path.join(dir, "cast.json"));
|
|
1673
|
+
if (ctx.json) {
|
|
1674
|
+
printJson({ ok: true, fork_id: forkId, dir, has_video_context: hasContext, has_cast: hasCast, ...info });
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
console.log(`${GREEN}Pulled ${forkId} → ${dir}${RESET}`);
|
|
1678
|
+
console.log(`${DIM}Canvas ${info.width ?? "?"}x${info.height ?? "?"} (${info.aspect_ratio ?? "?"}), ${info.duration_seconds}s, ${info.layers.length} layers.${RESET}`);
|
|
1679
|
+
console.log(`${BOLD}Timeline gaps (blank space to fill):${RESET} ${info.gaps.length ? info.gaps.map((g) => `${g.start}-${g.end}s (${g.duration}s)`).join(", ") : "none"}`);
|
|
1680
|
+
console.log(`${BOLD}Scenes / layers (keys to replace):${RESET}`);
|
|
1681
|
+
for (const layer of info.layers) {
|
|
1682
|
+
const flags = [layer.is_timeline_proxy ? "proxy" : null, layer.is_full_canvas ? "full-canvas" : null].filter(Boolean).join(",");
|
|
1683
|
+
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}`);
|
|
1684
|
+
}
|
|
1685
|
+
console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}.${RESET}`);
|
|
1686
|
+
console.log(`${DIM}Next: vidfarm generate video --prompt "..." --aspect-ratio ${info.aspect_ratio ?? "9:16"} --place ${dir} --at <gap start>|--replace <layer_key>${RESET}`);
|
|
1687
|
+
}
|
|
1061
1688
|
async function runVisibilityCommand(argv) {
|
|
1062
1689
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
|
|
1063
1690
|
const forkId = parsed.positionals[0];
|
|
@@ -1316,6 +1943,132 @@ async function runDownloadCommand(argv) {
|
|
|
1316
1943
|
console.log(`${GREEN}Downloaded ${formatBytes(bytes)} → ${dest}${RESET}`);
|
|
1317
1944
|
}
|
|
1318
1945
|
}
|
|
1946
|
+
// Browse the user's My Files library — the persistent per-user asset store
|
|
1947
|
+
// (videos, images, audio, docs) organized into virtual folders. `files` lists
|
|
1948
|
+
// them (optionally scoped to a folder); `get-file` resolves one attachment id to
|
|
1949
|
+
// its durable URL and either streams the bytes to disk or prints text contents.
|
|
1950
|
+
// This is the same filesystem the /editor AI agent browses via the browse_files
|
|
1951
|
+
// tool, so an agent CLI (Claude Code / Codex) can find assets the same way.
|
|
1952
|
+
async function runFilesCommand(argv) {
|
|
1953
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" } } });
|
|
1954
|
+
const ctx = commonContext(parsed.values);
|
|
1955
|
+
const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
|
|
1956
|
+
assertApiOk(result, "files");
|
|
1957
|
+
const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
|
|
1958
|
+
if (folderFilter && result.json && Array.isArray(result.json.attachments)) {
|
|
1959
|
+
result.json = {
|
|
1960
|
+
...result.json,
|
|
1961
|
+
attachments: result.json.attachments.filter((file) => (file?.folderPath ?? "") === folderFilter)
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
emitResult(result, ctx.json);
|
|
1965
|
+
}
|
|
1966
|
+
async function runGetFileCommand(argv) {
|
|
1967
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), print: { type: "boolean", default: false } } });
|
|
1968
|
+
const fileId = parsed.positionals[0];
|
|
1969
|
+
if (!fileId)
|
|
1970
|
+
throw new Error("get-file requires a file id (run `files` to list ids).");
|
|
1971
|
+
const ctx = commonContext(parsed.values);
|
|
1972
|
+
const list = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
|
|
1973
|
+
assertApiOk(list, "get-file");
|
|
1974
|
+
const attachments = Array.isArray(list.json?.attachments) ? list.json.attachments : [];
|
|
1975
|
+
const match = attachments.find((file) => file?.id === fileId) ?? attachments.find((file) => file?.fileName === fileId);
|
|
1976
|
+
if (!match)
|
|
1977
|
+
throw new Error(`No file with id or name "${fileId}" in My Files. Run \`files\` to list ids.`);
|
|
1978
|
+
const viewUrl = match.viewUrl;
|
|
1979
|
+
if (!viewUrl)
|
|
1980
|
+
throw new Error(`File "${match.fileName}" has no resolvable URL.`);
|
|
1981
|
+
const absoluteUrl = new URL(viewUrl, ctx.host).toString();
|
|
1982
|
+
const res = await fetch(absoluteUrl, { headers: buildAuthHeaders(ctx.auth) });
|
|
1983
|
+
if (!res.ok || !res.body)
|
|
1984
|
+
throw new Error(`get-file failed: ${res.status} ${res.statusText} (${absoluteUrl}).`);
|
|
1985
|
+
if (parsed.values.print) {
|
|
1986
|
+
const text = await res.text();
|
|
1987
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
const dest = parsed.positionals[1]
|
|
1991
|
+
? path.resolve(process.cwd(), parsed.positionals[1])
|
|
1992
|
+
: path.resolve(process.cwd(), match.fileName || path.basename(new URL(absoluteUrl).pathname) || "download.bin");
|
|
1993
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
|
|
1994
|
+
const bytes = safeSize(dest);
|
|
1995
|
+
if (ctx.json) {
|
|
1996
|
+
printJson({ ok: true, file_id: match.id, file_name: match.fileName, url: absoluteUrl, path: dest, bytes });
|
|
1997
|
+
}
|
|
1998
|
+
else {
|
|
1999
|
+
console.log(`${GREEN}Downloaded ${formatBytes(bytes)} → ${dest}${RESET}`);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
// Write a file INTO My Files — the persistent per-user library (`/me/attachments`),
|
|
2003
|
+
// NOT the ephemeral temporary-files store that `upload` targets. This is what an
|
|
2004
|
+
// agent uses to save durable context docs during Getting Started (About.md,
|
|
2005
|
+
// Interview.md, awareness-levels.md, persuasive-angles.md, ad-hooks.md) or to drop
|
|
2006
|
+
// brand assets under /brand-assets, product demos under /product-demos, etc.
|
|
2007
|
+
// Content can come from a local file (positional), inline --content, or piped stdin.
|
|
2008
|
+
// It is the write counterpart to `files`/`get-file` and the same store the /editor
|
|
2009
|
+
// AI copilot writes to via the browse_files write action.
|
|
2010
|
+
async function runPutFileCommand(argv) {
|
|
2011
|
+
const parsed = parseArgs({
|
|
2012
|
+
args: argv,
|
|
2013
|
+
allowPositionals: true,
|
|
2014
|
+
options: {
|
|
2015
|
+
...commonOptions(),
|
|
2016
|
+
folder: { type: "string" },
|
|
2017
|
+
as: { type: "string" },
|
|
2018
|
+
content: { type: "string" },
|
|
2019
|
+
stdin: { type: "boolean", default: false }
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
const ctx = commonContext(parsed.values);
|
|
2023
|
+
const localPath = parsed.positionals[0];
|
|
2024
|
+
const asName = parsed.values.as ? String(parsed.values.as) : undefined;
|
|
2025
|
+
let buffer;
|
|
2026
|
+
let fileName = asName;
|
|
2027
|
+
if (parsed.values.stdin) {
|
|
2028
|
+
if (!fileName)
|
|
2029
|
+
throw new Error("put-file --stdin requires --as <name> to name the file in My Files.");
|
|
2030
|
+
buffer = readFileSync(0); // fd 0 = stdin (piped content)
|
|
2031
|
+
}
|
|
2032
|
+
else if (parsed.values.content != null) {
|
|
2033
|
+
if (!fileName)
|
|
2034
|
+
throw new Error("put-file --content requires --as <name> to name the file in My Files.");
|
|
2035
|
+
buffer = Buffer.from(String(parsed.values.content), "utf8");
|
|
2036
|
+
}
|
|
2037
|
+
else if (localPath) {
|
|
2038
|
+
const abs = path.resolve(process.cwd(), localPath);
|
|
2039
|
+
if (!existsSync(abs))
|
|
2040
|
+
throw new Error(`No such file: ${abs}`);
|
|
2041
|
+
buffer = readFileSync(abs);
|
|
2042
|
+
if (!fileName)
|
|
2043
|
+
fileName = path.basename(abs);
|
|
2044
|
+
}
|
|
2045
|
+
else {
|
|
2046
|
+
throw new Error("put-file requires a local file path, or --content/--stdin with --as <name>.");
|
|
2047
|
+
}
|
|
2048
|
+
if (!fileName)
|
|
2049
|
+
throw new Error("put-file could not determine a file name. Pass --as <name>.");
|
|
2050
|
+
const contentType = guessContentType(fileName);
|
|
2051
|
+
const form = new FormData();
|
|
2052
|
+
form.append("file", new Blob([new Uint8Array(buffer)], contentType ? { type: contentType } : undefined), fileName);
|
|
2053
|
+
if (parsed.values.folder)
|
|
2054
|
+
form.append("folder_path", String(parsed.values.folder));
|
|
2055
|
+
const res = await fetch(new URL("/api/v1/user/me/attachments/upload", ctx.host), {
|
|
2056
|
+
method: "POST",
|
|
2057
|
+
headers: buildAuthHeaders(ctx.auth),
|
|
2058
|
+
body: form
|
|
2059
|
+
});
|
|
2060
|
+
const text = await res.text();
|
|
2061
|
+
let json = null;
|
|
2062
|
+
try {
|
|
2063
|
+
json = text ? JSON.parse(text) : null;
|
|
2064
|
+
}
|
|
2065
|
+
catch {
|
|
2066
|
+
json = null;
|
|
2067
|
+
}
|
|
2068
|
+
const result = { status: res.status, ok: res.ok, json, text };
|
|
2069
|
+
assertApiOk(result, "put-file");
|
|
2070
|
+
emitResult(result, ctx.json, [["My Files URL", result.json?.attachment?.viewUrl]]);
|
|
2071
|
+
}
|
|
1319
2072
|
// ── Agent skill ───────────────────────────────────────────────────────────────
|
|
1320
2073
|
// Install the latest director skill onto disk as a Claude Code / agent skill so
|
|
1321
2074
|
// the user's AI agent can read SKILL.director.md and act. We pull the freshest
|