@mevdragon/vidfarm-devcli 0.6.0 → 0.7.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/SKILL.director.md +70 -16
- package/SKILL.platform.md +13 -7
- package/demo/dist/app.js +110 -60
- package/dist/src/app.js +579 -57
- package/dist/src/cli.js +304 -136
- package/dist/src/composition-runtime.js +26 -0
- package/dist/src/config.js +7 -0
- package/dist/src/devcli/composition-edit.js +12 -0
- package/dist/src/editor-chat.js +5 -4
- package/dist/src/frontend/homepage-client.js +61 -6
- package/dist/src/frontend/homepage-view.js +17 -4
- package/dist/src/homepage.js +46 -0
- package/dist/src/hyperframes/composition.js +87 -0
- package/dist/src/primitive-registry.js +261 -0
- package/dist/src/services/billing.js +1 -0
- package/dist/src/services/hyperframes.js +26 -1
- package/dist/src/services/storage.js +6 -0
- package/dist/src/services/upstream.js +248 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +18 -18
package/dist/src/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createWriteStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { copyFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { parseArgs } from "node:util";
|
|
7
8
|
import { spawnSync } from "node:child_process";
|
|
@@ -43,6 +44,11 @@ Local editor loop:
|
|
|
43
44
|
serve [template_id] [opts] Boot the FULL editor locally (single origin, disk-backed).
|
|
44
45
|
Records + storage run on disk; render runs in-process on
|
|
45
46
|
this box for FREE (no cloud, no wallet charge).
|
|
47
|
+
/discover and /library still list the CLOUD catalog —
|
|
48
|
+
opening a cloud template seeds it onto local disk, and
|
|
49
|
+
the editor's Render button can hand off to the cloud
|
|
50
|
+
renderer (needs the cloud --api-key). Only the editor
|
|
51
|
+
and its data are local.
|
|
46
52
|
With a template_id or --fork, pulls that composition
|
|
47
53
|
(or the template's default/shared decomposition) from
|
|
48
54
|
the cloud host onto local disk to edit offline.
|
|
@@ -51,8 +57,9 @@ Local editor loop:
|
|
|
51
57
|
--key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
|
|
52
58
|
--fork <id> Pull + open a specific cloud fork
|
|
53
59
|
--host <url> Cloud host to pull from (default: ${DEFAULT_HOST})
|
|
54
|
-
--api-key <key> Cloud key for
|
|
60
|
+
--api-key <key> Cloud key for pulls/library/cloud render (default: VIDFARM_API_KEY)
|
|
55
61
|
--refetch Overwrite local composition with the cloud copy
|
|
62
|
+
--no-cloud Fully local: no cloud catalog, seeding, or cloud render
|
|
56
63
|
--no-open Don't auto-open the browser
|
|
57
64
|
<template_id> [opts] Alias for 'serve <template_id>' (boot the local editor)
|
|
58
65
|
publish [template_id] Push local composition.html/json to your fork → PUT/PATCH composition + POST versions
|
|
@@ -65,7 +72,10 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
|
|
|
65
72
|
--query <text> Keyword search over the catalog
|
|
66
73
|
--mine Only your own uploaded inspirations
|
|
67
74
|
--limit <n> Max results
|
|
68
|
-
inspiration-add <url>
|
|
75
|
+
inspiration-add <url|file> Ingest a TikTok/YT/IG/X URL — or upload a → POST /discover/templates
|
|
76
|
+
local MP4/MOV/WebM file — as a private (files: presign + PUT via
|
|
77
|
+
template. --title (uploads; defaults to the /discover/templates/upload)
|
|
78
|
+
template id), --tagline, --notes optional.
|
|
69
79
|
inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
|
|
70
80
|
inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
|
|
71
81
|
|
|
@@ -93,19 +103,31 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
93
103
|
--no-wait Return the job id instead of polling to the URL
|
|
94
104
|
--place <dir> After generating, place it into that composition
|
|
95
105
|
--at <time> | --replace <layer_key> Placement (fill gap vs replace scene)
|
|
96
|
-
place <dir|composition.html> Insert
|
|
106
|
+
place <dir|composition.html> Insert media into a local composition
|
|
97
107
|
(fill a gap or replace a scene) — same clip markup
|
|
98
108
|
the browser editor makes; serve live-morphs it.
|
|
99
|
-
--src <url>
|
|
100
|
-
|
|
109
|
+
--src <url|file> Media to place (required). A URL is used as-is; a
|
|
110
|
+
LOCAL FILE PATH is imported without bloating S3 — a
|
|
111
|
+
serve working copy copies it onto the box's own disk
|
|
112
|
+
store (free, local render only); any other dir
|
|
113
|
+
uploads it to the ephemeral temp store (folder below).
|
|
114
|
+
--base-url <url> Serve box origin that hosts /storage (default
|
|
115
|
+
http://localhost:3000) — set if you ran serve --port.
|
|
116
|
+
--folder <path> Temp-store folder for uploaded local files (default: temp)
|
|
117
|
+
--kind <video|image> Media kind (inferred from the URL/path if omitted)
|
|
101
118
|
--at <time> Start it at this time (seconds / mm:ss) — fill a gap
|
|
102
119
|
--replace <layer_key> Replace this scene/layer (copies its timing+geometry)
|
|
103
120
|
--duration <n> Timeline length in seconds
|
|
104
121
|
--track <n> Timeline layer index (auto if omitted)
|
|
105
122
|
--x --y --width --height Geometry as % (default full canvas 0/0/100/100)
|
|
106
123
|
--object-fit <fit> cover|contain|fill|none|scale-down (default cover)
|
|
107
|
-
|
|
108
|
-
|
|
124
|
+
--ken-burns <preset> Animate a still image (slow pan/zoom): zoom-in|
|
|
125
|
+
zoom-out|pan-left|pan-right|pan-up|pan-down|
|
|
126
|
+
zoom-in-left|zoom-in-right
|
|
127
|
+
--ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
|
|
128
|
+
remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
|
|
129
|
+
original vs decomposed (caption-free),
|
|
130
|
+
non-billing (alias: ghostcut)
|
|
109
131
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
110
132
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
111
133
|
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
|
|
@@ -118,6 +140,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
118
140
|
|
|
119
141
|
Approve & schedule (publish a finished MP4 as a shareable post):
|
|
120
142
|
approve Approve media into a preview/share page → POST /api/v1/approved/posts
|
|
143
|
+
--video <url|file> Primary MP4 (URL, or a local file auto-uploaded to temp/)
|
|
144
|
+
--media <url|file> Extra media (repeatable; local files auto-upload to temp/)
|
|
145
|
+
--folder <path> Temp-store folder for uploaded local files (default: temp)
|
|
146
|
+
--caption <text> Post caption (required)
|
|
121
147
|
posts List your approved posts → GET /api/v1/approved/posts
|
|
122
148
|
post <postId> Read one approved post (prints share URL) → GET /api/v1/approved/posts/:postId
|
|
123
149
|
schedule <postId> Schedule an approved post to a channel → POST /api/v1/approved/posts/:postId/schedules
|
|
@@ -139,7 +165,9 @@ Agent skill (install the latest director skill so your AI agent can act):
|
|
|
139
165
|
|
|
140
166
|
Files (multi-step flows the devcli handles for you):
|
|
141
167
|
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
|
|
168
|
+
--folder <path> Namescope the upload under a folder. Prefer a
|
|
169
|
+
throwaway 'temp' folder (--folder temp) for scratch
|
|
170
|
+
media so it never clutters your durable library.
|
|
143
171
|
put-file [file] Write a file INTO My Files (persistent lib) → POST /api/v1/user/me/attachments/upload
|
|
144
172
|
--folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
|
|
145
173
|
--as <name> Name the saved file (required for --content/--stdin)
|
|
@@ -153,6 +181,10 @@ Files (multi-step flows the devcli handles for you):
|
|
|
153
181
|
--print Print text contents (md/txt/csv/json) instead of saving
|
|
154
182
|
put-file / get-file / files are the My Files (persistent) read+write set;
|
|
155
183
|
upload is the throwaway temp store for dropping media into a composition.
|
|
184
|
+
Local file paths: to avoid uploading assets to Vidfarm S3 at all, reference
|
|
185
|
+
them straight from disk with 'place --src ./clip.mp4' on a 'serve' box (copied
|
|
186
|
+
to that box's local disk store, free local render). When you DO need a durable
|
|
187
|
+
URL (cloud render/approve), keep it under a throwaway 'temp/' folder.
|
|
156
188
|
|
|
157
189
|
Cost spectrum (default to the cheapest approach that works; see SKILL.director.md):
|
|
158
190
|
free Reuse a decomposed template, swap captions/images/existing MP4s
|
|
@@ -244,8 +276,9 @@ async function main() {
|
|
|
244
276
|
case "decompose":
|
|
245
277
|
await runDecomposeCommand(rest);
|
|
246
278
|
return;
|
|
279
|
+
case "remove-video-captions":
|
|
247
280
|
case "ghostcut":
|
|
248
|
-
await
|
|
281
|
+
await runRemoveVideoCaptionsCommand(rest);
|
|
249
282
|
return;
|
|
250
283
|
case "versions":
|
|
251
284
|
await runVersionsCommand(rest);
|
|
@@ -454,7 +487,10 @@ async function runServeCommand(argv) {
|
|
|
454
487
|
share: { type: "string" },
|
|
455
488
|
refetch: { type: "boolean", default: false },
|
|
456
489
|
open: { type: "boolean", default: true },
|
|
457
|
-
"no-open": { type: "boolean", default: false }
|
|
490
|
+
"no-open": { type: "boolean", default: false },
|
|
491
|
+
// Disable the cloud passthrough entirely: /discover and /library list
|
|
492
|
+
// only local data and the editor offers no "Render in Cloud".
|
|
493
|
+
"no-cloud": { type: "boolean", default: false }
|
|
458
494
|
}
|
|
459
495
|
});
|
|
460
496
|
const port = Number(parsed.values.port);
|
|
@@ -476,16 +512,33 @@ async function runServeCommand(argv) {
|
|
|
476
512
|
const templateId = parsed.positionals[0];
|
|
477
513
|
const forkFlag = parsed.values.fork;
|
|
478
514
|
// Env MUST be set before importing runtime — config.ts reads it at module
|
|
479
|
-
// load. Force the fully-local drivers
|
|
480
|
-
// in the environment so render passthrough still works.
|
|
515
|
+
// load. Force the fully-local drivers.
|
|
481
516
|
process.env.RECORDS_DRIVER = "local";
|
|
482
517
|
process.env.STORAGE_DRIVER = "local";
|
|
483
518
|
process.env.AWS_S3_BUCKET = "";
|
|
519
|
+
// A dev-shell .env can carry the staging Step Functions ARN, which would
|
|
520
|
+
// silently route this box's renders to the cloud state machine. serve is
|
|
521
|
+
// local-render by definition — cloud rendering is the EXPLICIT
|
|
522
|
+
// render_target="cloud" upstream handoff (the editor's "Render in Cloud").
|
|
523
|
+
process.env.VIDFARM_JOB_STATE_MACHINE_ARN = "";
|
|
524
|
+
// Same trap as the state machine ARN: a dev-shell .env can carry the staging
|
|
525
|
+
// media-lambda URL, which would route probe/thumbnail/trim/etc. to a cloud
|
|
526
|
+
// Lambda that cannot fetch this box's localhost storage URLs ("fetch
|
|
527
|
+
// failed"). Media ops on a serve box always run in-process.
|
|
528
|
+
process.env.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_URL = "";
|
|
529
|
+
process.env.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_SECRET = "";
|
|
484
530
|
process.env.VIDFARM_DATA_DIR = dataDir;
|
|
485
531
|
process.env.VIDFARM_API_KEY = apiKey;
|
|
486
532
|
process.env.PORT = String(port);
|
|
487
533
|
if (!process.env.NODE_ENV)
|
|
488
534
|
process.env.NODE_ENV = "development";
|
|
535
|
+
// Cloud passthrough: only the editor is local — /discover and /library keep
|
|
536
|
+
// listing the upstream (cloud) catalog, templates seed onto disk on demand,
|
|
537
|
+
// and the editor's Render button can hand off to the cloud renderer. The
|
|
538
|
+
// upstream key is the AMBIENT cloud key (never the local bootstrap key).
|
|
539
|
+
const cloudEnabled = !parsed.values["no-cloud"];
|
|
540
|
+
process.env.VIDFARM_UPSTREAM_HOST = cloudEnabled ? host : "";
|
|
541
|
+
process.env.VIDFARM_UPSTREAM_API_KEY = cloudEnabled ? (cloudApiKey ?? "") : "";
|
|
489
542
|
const { startRuntime } = await import("./runtime.js");
|
|
490
543
|
await startRuntime();
|
|
491
544
|
// If a template/fork was requested, pull its composition from the cloud host
|
|
@@ -493,13 +546,14 @@ async function runServeCommand(argv) {
|
|
|
493
546
|
// resolves it. Best-effort: a failure just drops us to /discover.
|
|
494
547
|
let openTemplateId = templateId;
|
|
495
548
|
let openForkId = forkFlag;
|
|
496
|
-
if (templateId || forkFlag) {
|
|
549
|
+
if ((templateId || forkFlag) && !cloudEnabled) {
|
|
550
|
+
console.warn("[vidfarm] serve: --no-cloud set — skipping cloud seed of the requested template/fork.");
|
|
551
|
+
}
|
|
552
|
+
else if (templateId || forkFlag) {
|
|
497
553
|
try {
|
|
498
554
|
const seeded = await seedForkFromCloud({
|
|
499
|
-
host,
|
|
500
555
|
templateId,
|
|
501
556
|
forkId: forkFlag,
|
|
502
|
-
cloudApiKey,
|
|
503
557
|
shareToken: parsed.values.share,
|
|
504
558
|
bootstrapKey: apiKey,
|
|
505
559
|
refetch: parsed.values.refetch
|
|
@@ -518,105 +572,45 @@ async function runServeCommand(argv) {
|
|
|
518
572
|
? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `?fork=${encodeURIComponent(openForkId)}` : ""}`
|
|
519
573
|
: "/discover";
|
|
520
574
|
const openUrl = `${base}/auto-login?api_key=${encodeURIComponent(apiKey)}&redirect=${encodeURIComponent(editorPath)}`;
|
|
521
|
-
printServeBanner({ base, dataDir, openUrl, editorPath });
|
|
575
|
+
printServeBanner({ base, dataDir, openUrl, editorPath, cloud: cloudEnabled ? { host, hasKey: Boolean(cloudApiKey) } : null });
|
|
522
576
|
const shouldOpen = parsed.values.open && !parsed.values["no-open"];
|
|
523
577
|
if (shouldOpen)
|
|
524
578
|
openInBrowser(openUrl);
|
|
525
579
|
// startRuntime() keeps the process alive via its HTTP server; nothing else to do.
|
|
526
580
|
}
|
|
527
581
|
// Pull a cloud fork's composition onto local disk and create the local
|
|
528
|
-
// template + fork records so the editor resolves it.
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
// server's disk-backed records/storage singletons.
|
|
582
|
+
// template + fork records so the editor resolves it. Thin wrapper over
|
|
583
|
+
// services/upstream.ts seedFromUpstream (which reads VIDFARM_UPSTREAM_HOST/
|
|
584
|
+
// _API_KEY — set above from --host/--api-key). Runs in-process after
|
|
585
|
+
// startRuntime(), so it shares the server's disk-backed records/storage.
|
|
533
586
|
async function seedForkFromCloud(input) {
|
|
534
|
-
const { serverlessRecords } = await import("./services/serverless-records.js");
|
|
535
|
-
const { StorageService } = await import("./services/storage.js");
|
|
536
587
|
const { ServerlessAuthService } = await import("./services/serverless-auth.js");
|
|
537
|
-
const
|
|
538
|
-
const auth = new ServerlessAuthService();
|
|
588
|
+
const { seedFromUpstream } = await import("./services/upstream.js");
|
|
539
589
|
// Ensure the local bootstrap customer exists and grab its id to own the seed.
|
|
540
|
-
const customer = await
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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 };
|
|
590
|
+
const customer = await new ServerlessAuthService().authenticate(input.bootstrapKey);
|
|
591
|
+
return seedFromUpstream({
|
|
592
|
+
customerId: customer.id,
|
|
593
|
+
templateId: input.templateId,
|
|
594
|
+
forkId: input.forkId,
|
|
595
|
+
shareToken: input.shareToken,
|
|
596
|
+
refetch: input.refetch
|
|
597
|
+
});
|
|
610
598
|
}
|
|
611
599
|
function printServeBanner(input) {
|
|
612
600
|
const line = `${DIM}${"─".repeat(74)}${RESET}`;
|
|
613
601
|
console.log("");
|
|
614
602
|
console.log(line);
|
|
615
|
-
console.log(`${BOLD}${GREEN} Vidfarm local server${RESET} ${DIM}(
|
|
603
|
+
console.log(`${BOLD}${GREEN} Vidfarm local server${RESET} ${DIM}(local editor — records + storage on disk)${RESET}`);
|
|
616
604
|
console.log(line);
|
|
617
605
|
console.log(` server ${input.base}`);
|
|
618
606
|
console.log(` data dir ${input.dataDir}`);
|
|
619
607
|
console.log(` render ${DIM}local (in-process HyperFrames render — free, no cloud charge)${RESET}`);
|
|
608
|
+
if (input.cloud) {
|
|
609
|
+
console.log(` cloud ${input.cloud.host} ${DIM}(/discover + /library list the cloud catalog; templates seed locally on open${input.cloud.hasKey ? "; Render can hand off to the cloud renderer" : "; set VIDFARM_API_KEY for /library + cloud render"})${RESET}`);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
console.log(` cloud ${DIM}off (--no-cloud) — local data only${RESET}`);
|
|
613
|
+
}
|
|
620
614
|
console.log("");
|
|
621
615
|
console.log(` ${DIM}Agents: edit composition files under ${input.dataDir}/storage — the`);
|
|
622
616
|
console.log(` browser live-reloads on save. Multiple forks can be edited at once.${RESET}`);
|
|
@@ -1168,17 +1162,77 @@ async function runVideosCommand(argv) {
|
|
|
1168
1162
|
emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
|
|
1169
1163
|
}
|
|
1170
1164
|
async function runInspirationAddCommand(argv) {
|
|
1171
|
-
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), tagline: { type: "string" }, notes: { type: "string" } } });
|
|
1172
|
-
const
|
|
1173
|
-
if (!
|
|
1174
|
-
throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X).");
|
|
1165
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" }, tagline: { type: "string" }, notes: { type: "string" } } });
|
|
1166
|
+
const source = parsed.positionals[0];
|
|
1167
|
+
if (!source)
|
|
1168
|
+
throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X) or a local video file path.");
|
|
1175
1169
|
const ctx = commonContext(parsed.values);
|
|
1170
|
+
// Local file → upload flow: presign, PUT the bytes (straight to S3, or the
|
|
1171
|
+
// server multipart fallback on a local-storage box), then finalize with the
|
|
1172
|
+
// storage key. Anything that parses as http(s) keeps the classic URL ingest.
|
|
1173
|
+
const isUrl = /^https?:\/\//i.test(source);
|
|
1174
|
+
const localPath = isUrl ? null : path.resolve(process.cwd(), source);
|
|
1175
|
+
if (localPath) {
|
|
1176
|
+
if (!existsSync(localPath)) {
|
|
1177
|
+
throw new Error(`inspiration-add: "${source}" is not an http(s) URL and no such local file exists.`);
|
|
1178
|
+
}
|
|
1179
|
+
const fileName = path.basename(localPath);
|
|
1180
|
+
const buffer = readFileSync(localPath);
|
|
1181
|
+
const contentType = guessContentType(fileName);
|
|
1182
|
+
const presign = await apiRequest({
|
|
1183
|
+
method: "POST",
|
|
1184
|
+
host: ctx.host,
|
|
1185
|
+
path: "/discover/templates/upload/presign",
|
|
1186
|
+
auth: ctx.auth,
|
|
1187
|
+
body: { file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength }
|
|
1188
|
+
});
|
|
1189
|
+
assertApiOk(presign, "inspiration-add (presign)");
|
|
1190
|
+
let storageKey = presign.json?.storage_key;
|
|
1191
|
+
let uploadedName = presign.json?.file_name || fileName;
|
|
1192
|
+
if (presign.json?.transport === "presigned" && presign.json?.upload?.url) {
|
|
1193
|
+
const upload = presign.json.upload;
|
|
1194
|
+
const put = await fetch(upload.url, {
|
|
1195
|
+
method: upload.method || "PUT",
|
|
1196
|
+
headers: upload.headers || {},
|
|
1197
|
+
body: new Uint8Array(buffer)
|
|
1198
|
+
});
|
|
1199
|
+
if (!put.ok)
|
|
1200
|
+
throw new Error(`inspiration-add upload failed with HTTP ${put.status}.`);
|
|
1201
|
+
}
|
|
1202
|
+
else {
|
|
1203
|
+
const form = new FormData();
|
|
1204
|
+
form.append("file", new Blob([new Uint8Array(buffer)], { type: contentType }), fileName);
|
|
1205
|
+
const res = await fetch(new URL(presign.json?.upload?.url || "/discover/templates/upload", ctx.host), {
|
|
1206
|
+
method: "POST",
|
|
1207
|
+
headers: buildAuthHeaders(ctx.auth),
|
|
1208
|
+
body: form
|
|
1209
|
+
});
|
|
1210
|
+
const json = await res.json().catch(() => null);
|
|
1211
|
+
if (!res.ok || !json?.storage_key) {
|
|
1212
|
+
throw new Error(`inspiration-add upload failed with HTTP ${res.status}${json?.error ? `: ${json.error}` : ""}.`);
|
|
1213
|
+
}
|
|
1214
|
+
storageKey = json.storage_key;
|
|
1215
|
+
uploadedName = json.file_name || fileName;
|
|
1216
|
+
}
|
|
1217
|
+
if (!storageKey)
|
|
1218
|
+
throw new Error("inspiration-add upload did not return a storage key.");
|
|
1219
|
+
const result = await apiRequest({
|
|
1220
|
+
method: "POST",
|
|
1221
|
+
host: ctx.host,
|
|
1222
|
+
path: "/discover/templates",
|
|
1223
|
+
auth: ctx.auth,
|
|
1224
|
+
body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: parsed.values.title, tagline: parsed.values.tagline, notes: parsed.values.notes }
|
|
1225
|
+
});
|
|
1226
|
+
assertApiOk(result, "inspiration-add");
|
|
1227
|
+
emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1176
1230
|
const result = await apiRequest({
|
|
1177
1231
|
method: "POST",
|
|
1178
1232
|
host: ctx.host,
|
|
1179
1233
|
path: "/discover/templates",
|
|
1180
1234
|
auth: ctx.auth,
|
|
1181
|
-
body: { source_url:
|
|
1235
|
+
body: { source_url: source, tagline: parsed.values.tagline, notes: parsed.values.notes }
|
|
1182
1236
|
});
|
|
1183
1237
|
assertApiOk(result, "inspiration-add");
|
|
1184
1238
|
emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
|
|
@@ -1249,18 +1303,18 @@ async function runDecomposeCommand(argv) {
|
|
|
1249
1303
|
});
|
|
1250
1304
|
assertApiOk(result, "decompose");
|
|
1251
1305
|
if (!ctx.json && result.json?.ghostcut_pending) {
|
|
1252
|
-
console.log(`${DIM}
|
|
1306
|
+
console.log(`${DIM}Subtitle removal (GhostCut) is running in the background — poll \`vidfarm api POST /api/v1/compositions/${forkId}/remove-video-captions-poll\` or GET .../remove-video-captions until done.${RESET}`);
|
|
1253
1307
|
}
|
|
1254
1308
|
emitResult(result, ctx.json);
|
|
1255
1309
|
}
|
|
1256
|
-
async function
|
|
1310
|
+
async function runRemoveVideoCaptionsCommand(argv) {
|
|
1257
1311
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
|
|
1258
1312
|
const forkId = parsed.positionals[0];
|
|
1259
1313
|
if (!forkId)
|
|
1260
|
-
throw new Error("
|
|
1314
|
+
throw new Error("remove-video-captions requires a fork id.");
|
|
1261
1315
|
const ctx = commonContext(parsed.values);
|
|
1262
|
-
const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/
|
|
1263
|
-
assertApiOk(result, "
|
|
1316
|
+
const result = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/compositions/${encodeURIComponent(forkId)}/remove-video-captions`, auth: ctx.auth });
|
|
1317
|
+
assertApiOk(result, "remove-video-captions");
|
|
1264
1318
|
emitResult(result, ctx.json);
|
|
1265
1319
|
}
|
|
1266
1320
|
async function runVersionsCommand(argv) {
|
|
@@ -1412,25 +1466,7 @@ async function pollPrimitiveJob(ctx, jobId) {
|
|
|
1412
1466
|
// Upload a local file to the ephemeral temp store, returning its durable URL.
|
|
1413
1467
|
// Used to turn `--ref @local.png` into a direct asset URL for reference images.
|
|
1414
1468
|
async function uploadTempFileForRef(ctx, absPath) {
|
|
1415
|
-
|
|
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;
|
|
1469
|
+
return uploadLocalToTempStore(ctx, absPath);
|
|
1434
1470
|
}
|
|
1435
1471
|
// Resolve --ref values: pass http(s) URLs through; upload @local files.
|
|
1436
1472
|
async function resolveReferenceUrls(ctx, refs) {
|
|
@@ -1468,6 +1504,100 @@ function resolveCompositionHtmlPath(target) {
|
|
|
1468
1504
|
throw new Error(`No such composition file or dir: ${abs}.`);
|
|
1469
1505
|
return abs;
|
|
1470
1506
|
}
|
|
1507
|
+
// ── Local file paths as media (no S3 upload) ────────────────────────────────
|
|
1508
|
+
// Power users bulk-building compositions want to reference media that lives on
|
|
1509
|
+
// their own disk WITHOUT uploading every asset to Vidfarm storage. Two cases:
|
|
1510
|
+
//
|
|
1511
|
+
// 1. The composition is a `vidfarm serve` working copy (its path runs through
|
|
1512
|
+
// <data-dir>/storage/compositions/forks/<forkId>/working/). That box renders
|
|
1513
|
+
// IN-PROCESS for free and serves /storage/:key off local disk, so we just
|
|
1514
|
+
// copy the file into the same disk store and reference it by the box's own
|
|
1515
|
+
// localhost URL. Nothing ever touches S3.
|
|
1516
|
+
// 2. Any other composition dir (e.g. a `vidfarm pull` dir headed for cloud
|
|
1517
|
+
// render/publish). The cloud renderer can't reach the user's disk, so a
|
|
1518
|
+
// local file has to become a durable URL — we upload it to the EPHEMERAL
|
|
1519
|
+
// temp store, namescoped under a folder (default `temp/`) so it stays easy
|
|
1520
|
+
// to find and purge instead of bloating the durable library.
|
|
1521
|
+
//
|
|
1522
|
+
// A localhost /storage URL only resolves against the serve box that owns the
|
|
1523
|
+
// file, so case-1 compositions are for local render/preview; to render in the
|
|
1524
|
+
// cloud or share, upload the media (case 2) instead.
|
|
1525
|
+
// Read the durable URL out of a temporary-files upload response, tolerating both
|
|
1526
|
+
// the camelCase (viewUrl/s3Url) and snake_case (view_url/s3_url) shapes the API
|
|
1527
|
+
// has used across versions.
|
|
1528
|
+
function readUploadedFileUrl(json) {
|
|
1529
|
+
const file = json?.file ?? {};
|
|
1530
|
+
return file.viewUrl ?? file.view_url ?? file.s3Url ?? file.s3_url ?? null;
|
|
1531
|
+
}
|
|
1532
|
+
// If `htmlPath` sits inside a `vidfarm serve` disk store, return that store's
|
|
1533
|
+
// root (`<data-dir>/storage`); otherwise null. Detected structurally from the
|
|
1534
|
+
// canonical working-copy layout rather than any running server.
|
|
1535
|
+
function findServeStorageRoot(htmlPath) {
|
|
1536
|
+
const norm = path.resolve(htmlPath);
|
|
1537
|
+
const marker = `${path.sep}storage${path.sep}compositions${path.sep}forks${path.sep}`;
|
|
1538
|
+
const idx = norm.indexOf(marker);
|
|
1539
|
+
if (idx === -1)
|
|
1540
|
+
return null;
|
|
1541
|
+
return norm.slice(0, idx + `${path.sep}storage`.length);
|
|
1542
|
+
}
|
|
1543
|
+
// Copy a local media file into a serve box's disk store under a public-read key
|
|
1544
|
+
// and return that storage key. The key is stable-random so re-placing the same
|
|
1545
|
+
// file never collides.
|
|
1546
|
+
function importLocalMediaToServeStore(storageRoot, absFile) {
|
|
1547
|
+
const safeName = path.basename(absFile).replace(/[^\w.\-]+/g, "_") || "media.bin";
|
|
1548
|
+
const key = `users/local-media/${randomUUID()}/${safeName}`;
|
|
1549
|
+
const dest = path.join(storageRoot, ...key.split("/"));
|
|
1550
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
1551
|
+
copyFileSync(absFile, dest);
|
|
1552
|
+
return key;
|
|
1553
|
+
}
|
|
1554
|
+
function serveStorageUrl(baseUrl, key) {
|
|
1555
|
+
return `${trimTrailingSlash(baseUrl)}/storage/${encodeURIComponent(key)}`;
|
|
1556
|
+
}
|
|
1557
|
+
// Upload a local file to the EPHEMERAL temp store on ctx.host, optionally
|
|
1558
|
+
// namescoped under a folder, and return its durable URL.
|
|
1559
|
+
async function uploadLocalToTempStore(ctx, absPath, folder) {
|
|
1560
|
+
if (!existsSync(absPath))
|
|
1561
|
+
throw new Error(`No such local file: ${absPath}`);
|
|
1562
|
+
const form = new FormData();
|
|
1563
|
+
form.append("file", new Blob([readFileSync(absPath)]), path.basename(absPath));
|
|
1564
|
+
if (folder)
|
|
1565
|
+
form.append("folder_path", folder);
|
|
1566
|
+
const res = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
|
|
1567
|
+
const text = await res.text();
|
|
1568
|
+
let json = null;
|
|
1569
|
+
try {
|
|
1570
|
+
json = text ? JSON.parse(text) : null;
|
|
1571
|
+
}
|
|
1572
|
+
catch {
|
|
1573
|
+
json = null;
|
|
1574
|
+
}
|
|
1575
|
+
if (!res.ok)
|
|
1576
|
+
throw new Error(`local media upload failed (${res.status}): ${text.slice(0, 200)}`);
|
|
1577
|
+
const url = readUploadedFileUrl(json);
|
|
1578
|
+
if (!url)
|
|
1579
|
+
throw new Error("local media upload returned no durable URL.");
|
|
1580
|
+
return url;
|
|
1581
|
+
}
|
|
1582
|
+
// Turn a --src value (http(s) URL or local file path) into a fetchable media
|
|
1583
|
+
// URL, importing/uploading local files as described above.
|
|
1584
|
+
async function resolvePlaceableMediaSrc(ctx, rawSrc, opts) {
|
|
1585
|
+
const value = rawSrc.trim();
|
|
1586
|
+
if (/^https?:\/\//i.test(value))
|
|
1587
|
+
return { src: value, origin: "url", detail: null };
|
|
1588
|
+
const abs = path.resolve(process.cwd(), value.startsWith("@") ? value.slice(1) : value);
|
|
1589
|
+
if (!existsSync(abs)) {
|
|
1590
|
+
throw new Error(`place --src "${rawSrc}" is neither an http(s) URL nor an existing local file.`);
|
|
1591
|
+
}
|
|
1592
|
+
const storageRoot = findServeStorageRoot(opts.htmlPath);
|
|
1593
|
+
if (storageRoot) {
|
|
1594
|
+
const key = importLocalMediaToServeStore(storageRoot, abs);
|
|
1595
|
+
return { src: serveStorageUrl(opts.baseUrl, key), origin: "local-serve", detail: key };
|
|
1596
|
+
}
|
|
1597
|
+
const folder = opts.folder ?? "temp";
|
|
1598
|
+
const url = await uploadLocalToTempStore(ctx, abs, folder);
|
|
1599
|
+
return { src: url, origin: "cloud-temp", detail: folder };
|
|
1600
|
+
}
|
|
1471
1601
|
// Apply an insert/replace of a media URL onto a composition.html on disk.
|
|
1472
1602
|
function placeMediaOnDisk(input) {
|
|
1473
1603
|
const html = readFileSync(input.htmlPath, "utf8");
|
|
@@ -1483,6 +1613,8 @@ function placeMediaOnDisk(input) {
|
|
|
1483
1613
|
width: input.width,
|
|
1484
1614
|
height: input.height,
|
|
1485
1615
|
objectFit: input.objectFit,
|
|
1616
|
+
kenBurns: input.kenBurns,
|
|
1617
|
+
kenBurnsIntensity: input.kenBurnsIntensity,
|
|
1486
1618
|
slug: input.slug
|
|
1487
1619
|
};
|
|
1488
1620
|
const result = input.replace
|
|
@@ -1512,6 +1644,8 @@ async function runGenerateCommand(argv) {
|
|
|
1512
1644
|
at: { type: "string" },
|
|
1513
1645
|
replace: { type: "string" },
|
|
1514
1646
|
track: { type: "string" },
|
|
1647
|
+
"ken-burns": { type: "string" },
|
|
1648
|
+
"ken-burns-intensity": { type: "string" },
|
|
1515
1649
|
"layer-key": { type: "string" }
|
|
1516
1650
|
}
|
|
1517
1651
|
});
|
|
@@ -1583,6 +1717,8 @@ async function runGenerateCommand(argv) {
|
|
|
1583
1717
|
at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
|
|
1584
1718
|
replace: parsed.values.replace,
|
|
1585
1719
|
track: parsed.values.track ? Number(parsed.values.track) : undefined,
|
|
1720
|
+
kenBurns: parsed.values["ken-burns"],
|
|
1721
|
+
kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
|
|
1586
1722
|
layerKey: parsed.values["layer-key"]
|
|
1587
1723
|
});
|
|
1588
1724
|
if (!ctx.json)
|
|
@@ -1614,21 +1750,34 @@ async function runPlaceCommand(argv) {
|
|
|
1614
1750
|
width: { type: "string" },
|
|
1615
1751
|
height: { type: "string" },
|
|
1616
1752
|
"object-fit": { type: "string" },
|
|
1753
|
+
"ken-burns": { type: "string" },
|
|
1754
|
+
"ken-burns-intensity": { type: "string" },
|
|
1617
1755
|
"layer-key": { type: "string" },
|
|
1618
|
-
slug: { type: "string" }
|
|
1756
|
+
slug: { type: "string" },
|
|
1757
|
+
// Local file support: where a serve box serves /storage from, and which
|
|
1758
|
+
// temp folder cloud uploads land under.
|
|
1759
|
+
"base-url": { type: "string" },
|
|
1760
|
+
folder: { type: "string" }
|
|
1619
1761
|
}
|
|
1620
1762
|
});
|
|
1621
1763
|
const target = parsed.positionals[0];
|
|
1622
1764
|
if (!target)
|
|
1623
|
-
throw new Error("place requires a composition path: `vidfarm place <dir-or-composition.html> --src <url> ...`.");
|
|
1624
|
-
const
|
|
1625
|
-
if (!
|
|
1626
|
-
throw new Error("place requires --src <
|
|
1765
|
+
throw new Error("place requires a composition path: `vidfarm place <dir-or-composition.html> --src <url|file> ...`.");
|
|
1766
|
+
const rawSrc = parsed.values.src;
|
|
1767
|
+
if (!rawSrc)
|
|
1768
|
+
throw new Error("place requires --src <media URL or local file path>.");
|
|
1627
1769
|
const ctx = commonContext(parsed.values);
|
|
1628
1770
|
const htmlPath = resolveCompositionHtmlPath(target);
|
|
1771
|
+
const baseUrl = parsed.values["base-url"] ?? "http://localhost:3000";
|
|
1772
|
+
const resolvedSrc = await resolvePlaceableMediaSrc(ctx, rawSrc, {
|
|
1773
|
+
htmlPath,
|
|
1774
|
+
baseUrl,
|
|
1775
|
+
folder: parsed.values.folder
|
|
1776
|
+
});
|
|
1777
|
+
const src = resolvedSrc.src;
|
|
1629
1778
|
const kind = parsed.values.kind === "video" || parsed.values.kind === "image"
|
|
1630
1779
|
? parsed.values.kind
|
|
1631
|
-
: inferMediaKindFromUrl(
|
|
1780
|
+
: inferMediaKindFromUrl(rawSrc);
|
|
1632
1781
|
const num = (v) => (v !== undefined ? Number(v) : undefined);
|
|
1633
1782
|
const placement = placeMediaOnDisk({
|
|
1634
1783
|
htmlPath,
|
|
@@ -1643,13 +1792,21 @@ async function runPlaceCommand(argv) {
|
|
|
1643
1792
|
width: num(parsed.values.width),
|
|
1644
1793
|
height: num(parsed.values.height),
|
|
1645
1794
|
objectFit: parsed.values["object-fit"],
|
|
1795
|
+
kenBurns: parsed.values["ken-burns"],
|
|
1796
|
+
kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
|
|
1646
1797
|
layerKey: parsed.values["layer-key"],
|
|
1647
1798
|
slug: parsed.values.slug
|
|
1648
1799
|
});
|
|
1649
1800
|
if (ctx.json) {
|
|
1650
|
-
printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, timeline_gaps: placement.gaps });
|
|
1801
|
+
printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, src, media_origin: resolvedSrc.origin, timeline_gaps: placement.gaps });
|
|
1651
1802
|
}
|
|
1652
1803
|
else {
|
|
1804
|
+
if (resolvedSrc.origin === "local-serve") {
|
|
1805
|
+
console.log(`${DIM}Local file → serve disk store (${resolvedSrc.detail}); referenced as ${src}. No S3 upload. Renders on this serve box only.${RESET}`);
|
|
1806
|
+
}
|
|
1807
|
+
else if (resolvedSrc.origin === "cloud-temp") {
|
|
1808
|
+
console.log(`${DIM}Local file → uploaded to ephemeral temp store under "${resolvedSrc.detail}/" on ${ctx.host}; referenced as ${src}.${RESET}`);
|
|
1809
|
+
}
|
|
1653
1810
|
console.log(`${GREEN}Placed ${kind} as ${placement.layerKey}${parsed.values.replace ? ` (replaced ${parsed.values.replace})` : ""} in ${htmlPath}.${RESET}`);
|
|
1654
1811
|
console.log(`${DIM}Remaining timeline gaps: ${placement.gaps.length ? placement.gaps.map((g) => `${g.start}-${g.end}s`).join(", ") : "none"}.${RESET}`);
|
|
1655
1812
|
console.log(`${DIM}Publish with: vidfarm publish --dir ${path.dirname(htmlPath)} --fork <forkId> (or it live-morphs under \`serve\`).${RESET}`);
|
|
@@ -1743,6 +1900,7 @@ async function runApproveCommand(argv) {
|
|
|
1743
1900
|
caption: { type: "string" },
|
|
1744
1901
|
title: { type: "string" },
|
|
1745
1902
|
pinned: { type: "string" },
|
|
1903
|
+
folder: { type: "string" },
|
|
1746
1904
|
tracer: { type: "string" }
|
|
1747
1905
|
}
|
|
1748
1906
|
});
|
|
@@ -1750,11 +1908,21 @@ async function runApproveCommand(argv) {
|
|
|
1750
1908
|
const caption = parsed.values.caption;
|
|
1751
1909
|
if (!caption)
|
|
1752
1910
|
throw new Error("approve requires --caption <text>.");
|
|
1911
|
+
// An approved post is a shareable cloud page, so any media must be a durable
|
|
1912
|
+
// URL the page can load. Pass an http(s) URL through; a local file is uploaded
|
|
1913
|
+
// to the ephemeral temp store (default folder `temp/`) first.
|
|
1914
|
+
const uploadFolder = parsed.values.folder ?? "temp";
|
|
1915
|
+
const resolveApproveMedia = async (value) => {
|
|
1916
|
+
if (/^https?:\/\//i.test(value.trim()))
|
|
1917
|
+
return value.trim();
|
|
1918
|
+
const abs = path.resolve(process.cwd(), value.trim());
|
|
1919
|
+
return uploadLocalToTempStore(ctx, abs, uploadFolder);
|
|
1920
|
+
};
|
|
1753
1921
|
const media = [];
|
|
1754
1922
|
if (parsed.values.video)
|
|
1755
|
-
media.push({ url: parsed.values.video, kind: "video", role: "primary" });
|
|
1923
|
+
media.push({ url: await resolveApproveMedia(String(parsed.values.video)), kind: "video", role: "primary" });
|
|
1756
1924
|
for (const url of parsed.values.media ?? [])
|
|
1757
|
-
media.push({ url });
|
|
1925
|
+
media.push({ url: await resolveApproveMedia(String(url)) });
|
|
1758
1926
|
if (media.length === 0) {
|
|
1759
1927
|
console.warn(`${DIM}[vidfarm] no --video / --media given; approving a text-only post.${RESET}`);
|
|
1760
1928
|
}
|
|
@@ -1920,7 +2088,7 @@ async function runUploadCommand(argv) {
|
|
|
1920
2088
|
}
|
|
1921
2089
|
const result = { status: res.status, ok: res.ok, json, text };
|
|
1922
2090
|
assertApiOk(result, "upload");
|
|
1923
|
-
emitResult(result, ctx.json, [["Durable URL ", result.json
|
|
2091
|
+
emitResult(result, ctx.json, [["Durable URL ", readUploadedFileUrl(result.json)]]);
|
|
1924
2092
|
}
|
|
1925
2093
|
async function runDownloadCommand(argv) {
|
|
1926
2094
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
|