@mevdragon/vidfarm-devcli 0.20.6 → 0.20.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +15 -1
  2. package/.agents/skills/vidfarm-director/SKILL.md +106 -0
  3. package/.agents/skills/vidfarm-director/recipes/find-and-fork-template.md +16 -0
  4. package/.agents/skills/vidfarm-director/recipes/local-edit-render-approve.md +13 -0
  5. package/.agents/skills/vidfarm-director/recipes/onboard-a-new-director.md +13 -0
  6. package/.agents/skills/vidfarm-director/recipes/retheme-template.md +17 -0
  7. package/.agents/skills/vidfarm-director/references/assets-and-sourcing.md +111 -0
  8. package/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +243 -0
  9. package/.agents/skills/vidfarm-director/references/core-workflows.md +273 -0
  10. package/.agents/skills/vidfarm-director/references/editor-workflows.md +365 -0
  11. package/.agents/skills/vidfarm-director/references/onboarding.md +28 -0
  12. package/.agents/skills/vidfarm-director/references/primitives.md +265 -0
  13. package/SKILL.director.md +476 -260
  14. package/dist/src/cli.js +362 -96
  15. package/dist/src/devcli/clips.js +15 -10
  16. package/dist/src/devcli/doctor.js +2 -2
  17. package/dist/src/devcli/local-backend.js +46 -2
  18. package/dist/src/services/clip-curation/cost.js +5 -1
  19. package/dist/src/services/clip-curation/gemini.js +10 -0
  20. package/package.json +30 -10
  21. package/SKILL.platform.md +0 -432
  22. package/demo/README.md +0 -28
  23. package/demo/dist/app.css +0 -1
  24. package/demo/dist/app.js +0 -1850
  25. package/demo/dist/chunks/chunk-DXB73IDG.js +0 -1
  26. package/demo/dist/chunks/chunk-S7OWAJDS.js +0 -36
  27. package/demo/dist/chunks/chunk-VTIBZ6AN.js +0 -1
  28. package/demo/dist/chunks/dist-ADSJKBVE.js +0 -332
  29. package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +0 -1
  30. package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +0 -425
  31. package/demo/dist/chunks/lib-XAQ37YOE.js +0 -1
  32. package/demo/dist/chunks/src-TJ2QYA4U.js +0 -207
  33. package/demo/dist/favicon.ico +0 -0
  34. package/demo/dist/icons/timeline/audio.svg +0 -7
  35. package/demo/dist/icons/timeline/captions.svg +0 -5
  36. package/demo/dist/icons/timeline/composition.svg +0 -12
  37. package/demo/dist/icons/timeline/image.svg +0 -18
  38. package/demo/dist/icons/timeline/music.svg +0 -10
  39. package/demo/dist/icons/timeline/text.svg +0 -3
  40. package/demo/dist/index.html +0 -15
  41. package/dist/src/account-pages-legacy.js +0 -9396
  42. package/dist/src/account-pages.js +0 -61
  43. package/dist/src/app.js +0 -21603
  44. package/dist/src/composition-runtime.js +0 -1053
  45. package/dist/src/config.js +0 -217
  46. package/dist/src/context.js +0 -447
  47. package/dist/src/dev-app-legacy.js +0 -739
  48. package/dist/src/dev-app.js +0 -6
  49. package/dist/src/devcli/migrate-local.js +0 -140
  50. package/dist/src/devcli/sync.js +0 -368
  51. package/dist/src/domain.js +0 -5
  52. package/dist/src/editor-chat-history.js +0 -82
  53. package/dist/src/editor-chat.js +0 -828
  54. package/dist/src/editor-dark-theme.js +0 -1128
  55. package/dist/src/frontend/debug.js +0 -71
  56. package/dist/src/frontend/discover-client.js +0 -130
  57. package/dist/src/frontend/discover-store.js +0 -23
  58. package/dist/src/frontend/file-directory.js +0 -1018
  59. package/dist/src/frontend/flockposter-cache-store.js +0 -124
  60. package/dist/src/frontend/homepage-client.js +0 -446
  61. package/dist/src/frontend/homepage-shared.js +0 -201
  62. package/dist/src/frontend/homepage-store.js +0 -66
  63. package/dist/src/frontend/homepage-view.js +0 -705
  64. package/dist/src/frontend/page-runtime-client.js +0 -132
  65. package/dist/src/frontend/page-runtime-store.js +0 -9
  66. package/dist/src/frontend/sentry.js +0 -42
  67. package/dist/src/frontend/template-editor-chat.js +0 -4181
  68. package/dist/src/help-page.js +0 -346
  69. package/dist/src/homepage.js +0 -1458
  70. package/dist/src/index.js +0 -16
  71. package/dist/src/instrument.js +0 -30
  72. package/dist/src/landing-page.js +0 -384
  73. package/dist/src/page-runtime.js +0 -2
  74. package/dist/src/page-shell.js +0 -1452
  75. package/dist/src/primitive-context.js +0 -416
  76. package/dist/src/primitive-registry.js +0 -3940
  77. package/dist/src/primitive-sdk.js +0 -4
  78. package/dist/src/primitives/hyperframes-media.js +0 -108
  79. package/dist/src/react-page-shell.js +0 -35
  80. package/dist/src/ready-post-schedule-component.js +0 -1540
  81. package/dist/src/registry.js +0 -296
  82. package/dist/src/reskin/agency-page.js +0 -299
  83. package/dist/src/reskin/calendar-page.js +0 -568
  84. package/dist/src/reskin/chat-page.js +0 -942
  85. package/dist/src/reskin/discover-page.js +0 -1788
  86. package/dist/src/reskin/document.js +0 -1587
  87. package/dist/src/reskin/help-page.js +0 -357
  88. package/dist/src/reskin/index-page.js +0 -62
  89. package/dist/src/reskin/inpaint-clipper-page.js +0 -890
  90. package/dist/src/reskin/inpaint-page.js +0 -2554
  91. package/dist/src/reskin/inpaint-video-page.js +0 -1339
  92. package/dist/src/reskin/job-runs-page.js +0 -477
  93. package/dist/src/reskin/library-page.js +0 -1634
  94. package/dist/src/reskin/login-page.js +0 -262
  95. package/dist/src/reskin/portfolio-page.js +0 -687
  96. package/dist/src/reskin/pricing-page.js +0 -390
  97. package/dist/src/reskin/settings-page.js +0 -732
  98. package/dist/src/reskin/theme.js +0 -711
  99. package/dist/src/runtime.js +0 -35
  100. package/dist/src/services/api-call-history.js +0 -249
  101. package/dist/src/services/auth.js +0 -152
  102. package/dist/src/services/billing-pricing.js +0 -39
  103. package/dist/src/services/billing.js +0 -241
  104. package/dist/src/services/cast.js +0 -127
  105. package/dist/src/services/chat-threads.js +0 -92
  106. package/dist/src/services/clip-records.js +0 -250
  107. package/dist/src/services/clip-search.js +0 -77
  108. package/dist/src/services/clip-vectors.js +0 -125
  109. package/dist/src/services/composition-sanitize.js +0 -124
  110. package/dist/src/services/composition-watch.js +0 -79
  111. package/dist/src/services/elevenlabs.js +0 -222
  112. package/dist/src/services/file-directory.js +0 -117
  113. package/dist/src/services/fork-access.js +0 -93
  114. package/dist/src/services/fork-manifest.js +0 -42
  115. package/dist/src/services/ghostcut.js +0 -179
  116. package/dist/src/services/hyperframes.js +0 -3654
  117. package/dist/src/services/job-capacity.js +0 -14
  118. package/dist/src/services/job-logs.js +0 -197
  119. package/dist/src/services/jobs.js +0 -136
  120. package/dist/src/services/local-dynamo.js +0 -0
  121. package/dist/src/services/media-processing.js +0 -766
  122. package/dist/src/services/primitive-media-lambda.js +0 -280
  123. package/dist/src/services/providers.js +0 -2748
  124. package/dist/src/services/rate-limits.js +0 -262
  125. package/dist/src/services/scene-annotations.js +0 -32
  126. package/dist/src/services/serverless-auth.js +0 -382
  127. package/dist/src/services/serverless-jobs.js +0 -1084
  128. package/dist/src/services/serverless-provider-keys.js +0 -409
  129. package/dist/src/services/serverless-records.js +0 -1515
  130. package/dist/src/services/serverless-template-configs.js +0 -75
  131. package/dist/src/services/storage.js +0 -461
  132. package/dist/src/services/swipe-customize.js +0 -437
  133. package/dist/src/services/template-certification.js +0 -413
  134. package/dist/src/services/template-loader.js +0 -99
  135. package/dist/src/services/template-runtime-bundles.js +0 -217
  136. package/dist/src/services/template-sources.js +0 -1017
  137. package/dist/src/services/upstream.js +0 -248
  138. package/dist/src/services/video-normalization.js +0 -2
  139. package/dist/src/services/webhooks.js +0 -62
  140. package/dist/src/template-editor-pages.js +0 -2576
  141. package/dist/src/template-editor-shell.js +0 -2893
  142. package/dist/src/template-sdk.js +0 -4
  143. package/dist/src/worker.js +0 -17
  144. package/public/assets/discover-client-app.js +0 -1
  145. package/public/assets/file-directory-app.js +0 -3
  146. package/public/assets/homepage-app.js +0 -54
  147. package/public/assets/homepage-client-app.js +0 -80
  148. package/public/assets/page-runtime-client-app.js +0 -94
  149. package/public/assets/placeholders/scene-placeholder.png +0 -0
  150. package/src/assets/SELLING_AWARENESS_STAGES.md +0 -579
  151. package/src/assets/SELLING_WITH_HOOKS.md +0 -377
  152. package/src/assets/SELLING_WITH_VSLS.md +0 -606
  153. package/src/assets/favicon.ico +0 -0
  154. package/src/assets/logo-vidfarm.png +0 -0
package/dist/src/cli.js CHANGED
@@ -22,6 +22,7 @@ import { renderCompositionStills } from "./devcli/stills.js";
22
22
  import { runDoctorCommand } from "./devcli/doctor.js";
23
23
  import { runSkillsCommand } from "./devcli/skills.js";
24
24
  import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
25
+ import { resolveLocalDataDir, localBackendAvailable, LocalModeUnavailableError } from "./devcli/local-backend.js";
25
26
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
26
27
  // `serve` command boots the FULL editor locally (single origin, disk-backed
27
28
  // records + storage) so power users edit compositions on disk while a browser
@@ -60,16 +61,16 @@ Local editor loop:
60
61
  serve [template_id] [opts] Boot the FULL editor locally (single origin, disk-backed).
61
62
  Records + storage run on disk; render runs in-process on
62
63
  this box for FREE (no cloud, no wallet charge).
63
- /discover and /library still list the CLOUD catalog —
64
- opening a cloud template seeds it onto local disk, and
65
- the editor's Render button can hand off to the cloud
66
- renderer (needs the cloud --api-key). Only the editor
67
- and its data are local.
64
+ With a VALID cloud API key, /discover and /library can
65
+ mirror the CLOUD catalog, opening a cloud template seeds
66
+ it onto local disk, and the editor can hand a render off
67
+ to the cloud renderer. Without a valid key, serve falls
68
+ back to local browse mode and opens /tools/clipper.
68
69
  With a template_id or --fork, pulls that composition
69
70
  (or the template's default/shared decomposition) from
70
71
  the cloud host onto local disk to edit offline.
71
72
  --port <n> Server port (default: 3000)
72
- --dir <path> Local data dir (default: ./.vidfarm-local)
73
+ --dir <path> Local data dir (default: ~/.vidfarm/data or VIDFARM_HOME/data)
73
74
  --key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
74
75
  --fork <id> Pull + open a specific cloud fork
75
76
  --host <url> Cloud host to pull from (default: ${DEFAULT_HOST})
@@ -84,17 +85,27 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
84
85
  discover [query...] List/search TEMPLATES (best for an offer) → GET /discover/feed
85
86
  --query <text> Keyword search (e.g. "weight loss app")
86
87
  --limit <n> Max results
88
+ public-raws [query...] Browse PUBLIC RAWS (feed / categories) → GET /api/v1/public-raws
89
+ --query <text> Keyword/vector query over title + metadata
90
+ --category <key> Filter one public-raw category (e.g. emotion:confused)
91
+ --type <raw_type> Filter one raw family (greenscreen, b_roll, ...)
92
+ --freshness-days <n> Only raws newer than N days
93
+ --bookmark <raw_id> Save that public raw into /raws/public/* → POST /api/v1/public-raws/:rawId/bookmark
94
+ --limit <n> Max results
87
95
  videos [query...] List/search source INSPIRATIONS → GET /api/v1/videos
88
96
  --query <text> Keyword search over the catalog
89
97
  --mine Only your own uploaded inspirations
90
98
  --limit <n> Max results
91
99
  inspiration-add <url|file> Ingest a TikTok/YT/IG/X URL — or upload a → POST /discover/templates
92
100
  local MP4/MOV/WebM file — as a private (files: presign + PUT via
93
- template. --title (uploads; defaults to the /discover/templates/upload)
94
- template id), --tagline, --notes optional.
95
- --raw (uploads) marks it as your own raw
96
- project footage the editor won't auto-
97
- prompt Decompose on it.
101
+ template. A TikTok PHOTO/slideshow URL is /discover/templates/upload)
102
+ ingested as a slideshow (ordered images + its
103
+ audio) into an editable image timeline.
104
+ --title (uploads; defaults to the template
105
+ id), --tagline, --notes optional. --raw
106
+ (uploads) marks it as your own raw project
107
+ footage — the editor won't auto-prompt
108
+ Decompose on it.
98
109
  inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
99
110
  inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
100
111
 
@@ -103,7 +114,9 @@ Make a video in one command (orchestrates ingest → decompose → fork):
103
114
  the URL, AI-decompose/recreate its scenes,
104
115
  and open an editable fork. --prompt "...and
105
116
  make it about X" steers the recreation.
106
- --title, --no-fork optional.
117
+ --title, --no-fork optional. A TikTok photo/
118
+ slideshow URL skips decompose (already an
119
+ editable image timeline) and forks directly.
107
120
  create "<prompt>" Spin up a NEW video from a text prompt: AI- → generate + ingest + decompose + fork
108
121
  generate a base video, wrap it into an
109
122
  editable template, decompose + fork. --duration
@@ -260,9 +273,9 @@ Generate AI media and drop it on the timeline (for local coding agents):
260
273
  --replace <layer_key> Swap the media into this existing scene/layer instead
261
274
  --kind <video|image|audio> Media kind (inferred from the URL if omitted)
262
275
  --folder <name> (raw) Save the import into /raws/<name> (also the tracer)
263
- --clip [--prompt "..."] [--provider gemini|openai|openrouter]
276
+ --clip [--prompt "..."] [--provider gemini|openai|openrouter|nvidia]
264
277
  (raw) AI-clip the video into matching moments instead of
265
- importing the whole thing (BYOK)
278
+ importing the whole thing (BYOK; nvidia = lite clipping key)
266
279
 
267
280
  Raws (the third library — mine long-form video into a reusable raws store):
268
281
  raws scan <video-path> Hunt short clips out of a long video. LOCAL-FIRST: local ffmpeg +
@@ -281,9 +294,14 @@ Raws (the third library — mine long-form video into a reusable raws store):
281
294
  raws preset list|run <name>|save <name> --from-query '<...>' → /raws/presets
282
295
  raws export <ids...> --to <dir> Copy raw mp4s + metadata out
283
296
  (run 'vidfarm raws' for the full raws help)
284
- clipper <video-url|file> Import a source into raws (the /tools/clipper twin). POST /raws/clip-range
285
- DEFAULT = the WHOLE video; --start/--end trims an (or /raws/scan whole)
297
+ clipper <video-url|file> Import a source into raws (the /tools/clipper twin). LOCAL-FIRST by default;
298
+ DEFAULT = the WHOLE video; --start/--end trims an add --cloud for vidfarm.cc
286
299
  EXACT subrange and saves one raw.
300
+ Local URL imports resolve direct media from the page itself
301
+ first (JSON-LD / og:video / player metadata), so you can
302
+ browse manually and paste a source page URL without RapidAPI.
303
+ For bulk ingestion, cloud catalog access, and reliable scale,
304
+ sign up for vidfarm.cc.
287
305
  --start <t> --end <t> In/out point — seconds (12.5) or HH:MM:SS:NNNN. Omit
288
306
  BOTH to import the whole video (entirety); pass BOTH for
289
307
  a subrange (one without the other is an error).
@@ -312,7 +330,7 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
312
330
  takes an ElevenLabs voice_id (see: vidfarm voices).
313
331
  --own-key (--cloud) use your own ElevenLabs / BYOK key instead of the wallet
314
332
  --list-voices List ElevenLabs voices and exit (same as: vidfarm voices)
315
- music "<prompt>" Prompt → music track (ElevenLabs). CLOUD-ONLY (no local engine)
333
+ music "<prompt>" Prompt → music track (ElevenLabs). CLOUD/BYOK (no free local engine)
316
334
  --length <sec> | --length-ms <ms> Duration (default 30s, max 5 min)
317
335
  --out <file> Output mp3 path (default music-<id>.mp3)
318
336
  --own-key Use your own ElevenLabs key instead of the platform key + wallet
@@ -543,6 +561,12 @@ const GREEN = "\x1b[32m";
543
561
  const RED = "\x1b[31m";
544
562
  const RESET = "\x1b[0m";
545
563
  void main().catch(async (error) => {
564
+ // Deliberate, expected user-facing conditions (e.g. local mode in the
565
+ // published cloud-only CLI) print just the message — no stack, no crash report.
566
+ if (error instanceof LocalModeUnavailableError) {
567
+ console.error(error.message);
568
+ process.exit(1);
569
+ }
546
570
  console.error(error instanceof Error ? error.stack ?? error.message : String(error));
547
571
  // Report only unexpected crashes (bugs), never the deliberate user-facing
548
572
  // `throw new Error(...)` above; no-op unless a devcli DSN is configured.
@@ -565,6 +589,8 @@ async function main() {
565
589
  // editor locally. The old cross-origin dev-serve/`?dev=` flow is gone.
566
590
  case "serve":
567
591
  case "edit":
592
+ if (!localBackendAvailable())
593
+ throw new LocalModeUnavailableError();
568
594
  await runServeCommand(rest);
569
595
  return;
570
596
  case "publish":
@@ -577,6 +603,9 @@ async function main() {
577
603
  case "discover":
578
604
  await runDiscoverCommand(rest);
579
605
  return;
606
+ case "public-raws":
607
+ await runPublicRawsCommand(rest);
608
+ return;
580
609
  case "videos":
581
610
  await runVideosCommand(rest);
582
611
  return;
@@ -729,11 +758,15 @@ async function main() {
729
758
  await runDirectoryCommand(rest);
730
759
  return;
731
760
  case "migrate-local": {
761
+ if (!localBackendAvailable())
762
+ throw new LocalModeUnavailableError();
732
763
  const { runMigrateLocalCommand } = await import("./devcli/migrate-local.js");
733
764
  await runMigrateLocalCommand(rest);
734
765
  return;
735
766
  }
736
767
  case "sync": {
768
+ if (!localBackendAvailable())
769
+ throw new LocalModeUnavailableError();
737
770
  const { runSyncCommand } = await import("./devcli/sync.js");
738
771
  await runSyncCommand(rest);
739
772
  return;
@@ -857,6 +890,30 @@ async function apiRequest(input) {
857
890
  }
858
891
  return { status: res.status, ok: res.ok, json, text };
859
892
  }
893
+ async function detectCloudAuth(host, apiKey) {
894
+ const key = apiKey?.trim() || "";
895
+ if (!key) {
896
+ return { usable: false, reason: "missing", detail: "VIDFARM_API_KEY not set." };
897
+ }
898
+ try {
899
+ const res = await fetch(new URL("/api/v1/user/me", host), {
900
+ headers: { "vidfarm-api-key": key, accept: "application/json" }
901
+ });
902
+ if (res.ok)
903
+ return { usable: true, reason: "ok", detail: "authenticated" };
904
+ if (res.status === 401 || res.status === 403) {
905
+ return { usable: false, reason: "invalid", detail: `cloud auth rejected (${res.status})` };
906
+ }
907
+ return { usable: false, reason: "unreachable", detail: `cloud auth probe returned HTTP ${res.status}` };
908
+ }
909
+ catch (error) {
910
+ return {
911
+ usable: false,
912
+ reason: "unreachable",
913
+ detail: `cloud auth probe failed: ${error instanceof Error ? error.message : String(error)}`
914
+ };
915
+ }
916
+ }
860
917
  // Named commands should fail loudly (unlike the read-tolerant edit fetch).
861
918
  function assertApiOk(result, action) {
862
919
  if (result.ok)
@@ -907,7 +964,10 @@ function resolveTarget(values) {
907
964
  const envTarget = (process.env.VIDFARM_TARGET ?? "").trim().toLowerCase();
908
965
  if (envTarget === "cloud" || envTarget === "both" || envTarget === "local")
909
966
  return envTarget;
910
- return "local";
967
+ // Local-first by default — but only when the in-process backend is present
968
+ // (a full source checkout). The published cloud-only CLI has no local backend,
969
+ // so it defaults to cloud instead of failing on every bare command.
970
+ return localBackendAvailable() ? "local" : "cloud";
911
971
  }
912
972
  function commonContext(values) {
913
973
  return {
@@ -964,6 +1024,9 @@ function editorFrontendUrl(host, templateId, forkId) {
964
1024
  function discoverFrontendUrl(host) {
965
1025
  return `${host}/discover`;
966
1026
  }
1027
+ function publicRawsFrontendUrl(host) {
1028
+ return `${host}/discover/raws/feed`;
1029
+ }
967
1030
  // The devcli twin of the studio editor's "Create New" → /tools/image hand-off:
968
1031
  // a deep link that opens the Tools image editor seeded with a source image so
969
1032
  // the user can author a replacement. Mirrors buildInpaintEditUrl in
@@ -1029,7 +1092,9 @@ async function runServeCommand(argv) {
1029
1092
  if (!Number.isFinite(port) || port <= 0) {
1030
1093
  throw new Error(`Invalid --port: ${parsed.values.port}`);
1031
1094
  }
1032
- const dataDir = path.resolve(process.cwd(), parsed.values.dir ?? ".vidfarm-local");
1095
+ const dataDir = parsed.values.dir
1096
+ ? path.resolve(process.cwd(), parsed.values.dir)
1097
+ : resolveLocalDataDir();
1033
1098
  mkdirSync(dataDir, { recursive: true });
1034
1099
  // Capture the ambient cloud key BEFORE we overwrite VIDFARM_API_KEY with the
1035
1100
  // local bootstrap key, so `--fork`/template seeding can still authenticate to
@@ -1043,6 +1108,10 @@ async function runServeCommand(argv) {
1043
1108
  const host = trimTrailingSlash(parsed.values.host);
1044
1109
  const templateId = parsed.positionals[0];
1045
1110
  const forkFlag = parsed.values.fork;
1111
+ const requestedCloud = !parsed.values["no-cloud"];
1112
+ const cloudAuth = requestedCloud
1113
+ ? await detectCloudAuth(host, cloudApiKey)
1114
+ : { usable: false, reason: "missing", detail: "--no-cloud set" };
1046
1115
  // Env MUST be set before importing runtime — config.ts reads it at module
1047
1116
  // load. Force the fully-local drivers.
1048
1117
  process.env.RECORDS_DRIVER = "local";
@@ -1077,7 +1146,7 @@ async function runServeCommand(argv) {
1077
1146
  // listing the upstream (cloud) catalog, templates seed onto disk on demand,
1078
1147
  // and the editor's Render button can hand off to the cloud renderer. The
1079
1148
  // upstream key is the AMBIENT cloud key (never the local bootstrap key).
1080
- const cloudEnabled = !parsed.values["no-cloud"];
1149
+ const cloudEnabled = requestedCloud && cloudAuth.usable;
1081
1150
  process.env.VIDFARM_UPSTREAM_HOST = cloudEnabled ? host : "";
1082
1151
  process.env.VIDFARM_UPSTREAM_API_KEY = cloudEnabled ? (cloudApiKey ?? "") : "";
1083
1152
  const { startRuntime } = await import("./runtime.js");
@@ -1088,7 +1157,7 @@ async function runServeCommand(argv) {
1088
1157
  let openTemplateId = templateId;
1089
1158
  let openForkId = forkFlag;
1090
1159
  if ((templateId || forkFlag) && !cloudEnabled) {
1091
- console.warn("[vidfarm] serve: --no-cloud set skipping cloud seed of the requested template/fork.");
1160
+ console.warn(`[vidfarm] serve: cloud seed unavailable (${requestedCloud ? cloudAuth.detail : "--no-cloud set"}) — starting local-only.`);
1092
1161
  }
1093
1162
  else if (templateId || forkFlag) {
1094
1163
  try {
@@ -1109,11 +1178,22 @@ async function runServeCommand(argv) {
1109
1178
  }
1110
1179
  }
1111
1180
  const base = `http://localhost:${port}`;
1181
+ const browseMode = !cloudEnabled && !openTemplateId && !openForkId;
1112
1182
  const editorPath = openTemplateId
1113
1183
  ? `/editor/${encodeURIComponent(openTemplateId)}${openForkId ? `/fork/${encodeURIComponent(openForkId)}` : ""}`
1114
- : "/discover";
1184
+ : browseMode
1185
+ ? "/tools/clipper"
1186
+ : "/discover";
1115
1187
  const openUrl = `${base}/auto-login?api_key=${encodeURIComponent(apiKey)}&redirect=${encodeURIComponent(editorPath)}`;
1116
- printServeBanner({ base, dataDir, openUrl, editorPath, cloud: cloudEnabled ? { host, hasKey: Boolean(cloudApiKey) } : null });
1188
+ printServeBanner({
1189
+ base,
1190
+ dataDir,
1191
+ openUrl,
1192
+ editorPath,
1193
+ browseMode,
1194
+ cloud: cloudEnabled ? { host, hasKey: true, detail: cloudAuth.detail } : null,
1195
+ cloudDisabledReason: requestedCloud && !cloudEnabled ? cloudAuth.detail : null
1196
+ });
1117
1197
  const shouldOpen = parsed.values.open && !parsed.values["no-open"];
1118
1198
  if (shouldOpen)
1119
1199
  openInBrowser(openUrl);
@@ -1147,14 +1227,21 @@ function printServeBanner(input) {
1147
1227
  console.log(` data dir ${input.dataDir}`);
1148
1228
  console.log(` render ${DIM}local (in-process HyperFrames render — free, no cloud charge)${RESET}`);
1149
1229
  if (input.cloud) {
1150
- 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}`);
1230
+ console.log(` cloud ${input.cloud.host} ${DIM}(/discover + /library list the cloud catalog; templates seed locally on open; Render can hand off to the cloud renderer)${RESET}`);
1151
1231
  }
1152
1232
  else {
1153
- console.log(` cloud ${DIM}off (--no-cloud) — local data only${RESET}`);
1233
+ console.log(` cloud ${DIM}off${input.cloudDisabledReason ? ` — ${input.cloudDisabledReason}` : " (--no-cloud)"}${RESET}`);
1234
+ }
1235
+ if (input.browseMode) {
1236
+ console.log(` mode ${DIM}browse — local clipper opens instead of the cloud catalog${RESET}`);
1154
1237
  }
1155
1238
  console.log("");
1156
1239
  console.log(` ${DIM}Agents: edit composition files under ${input.dataDir}/storage — the`);
1157
1240
  console.log(` browser live-reloads on save. Multiple forks can be edited at once.${RESET}`);
1241
+ if (input.browseMode) {
1242
+ console.log(` ${DIM}Browse externally yourself, then paste/select source pages in /tools/clipper.`);
1243
+ console.log(` For bulk ingestion, curated fresh data, and cloud rendering at scale, use vidfarm.cc.${RESET}`);
1244
+ }
1158
1245
  console.log("");
1159
1246
  console.log(` ${BOLD}Open (pre-authed):${RESET}`);
1160
1247
  console.log(` ${FRONTEND}${input.openUrl}${RESET}`);
@@ -1360,7 +1447,12 @@ async function fetchCompositionFiles(input) {
1360
1447
  // "how to edit like this" companion to the video-context viral DNA (whose
1361
1448
  // `emotional_punch` captures the joke/vibe/intonation and, being decomposed
1362
1449
  // over the transcript too, the trending sound driving the emotion).
1363
- const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json", "scene-annotations.json", "editor-harness.json"];
1450
+ // replication-harness.json carries the TECHNICAL REPLICATION ANALYSIS (the
1451
+ // 3-paintbrush cheap vs best-quality build plans — which beats are raw_clip
1452
+ // vs hyperframes vs reusable_asset vs ai_gen, with reusable-asset guidance
1453
+ // disciplined by viral DNA and a free-tier open-tools note, all under
1454
+ // `.harness`) so an agent can rebuild the template WITHOUT burning AI credits.
1455
+ const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json", "scene-annotations.json", "editor-harness.json", "replication-harness.json"];
1364
1456
  for (const filename of files) {
1365
1457
  const target = path.join(input.dir, filename);
1366
1458
  if (existsSync(target) && !input.refetch) {
@@ -1408,6 +1500,7 @@ function readJsonStringList(value, max = 8) {
1408
1500
  function writeLocalAgentHarnessFiles(dir) {
1409
1501
  const videoContext = readJsonFile(path.join(dir, "video-context.json"));
1410
1502
  const editorHarness = readJsonFile(path.join(dir, "editor-harness.json"));
1503
+ const replicationHarness = readJsonFile(path.join(dir, "replication-harness.json"));
1411
1504
  const sceneAnnotations = readJsonFile(path.join(dir, "scene-annotations.json"));
1412
1505
  const cast = readJsonFile(path.join(dir, "cast.json"));
1413
1506
  const viralDna = videoContext && readJsonString(videoContext.status) === "ready" && videoContext.viral_dna && typeof videoContext.viral_dna === "object"
@@ -1416,6 +1509,9 @@ function writeLocalAgentHarnessFiles(dir) {
1416
1509
  const harness = editorHarness && readJsonString(editorHarness.status) === "ready" && editorHarness.harness && typeof editorHarness.harness === "object"
1417
1510
  ? editorHarness.harness
1418
1511
  : null;
1512
+ const replication = replicationHarness && readJsonString(replicationHarness.status) === "ready" && replicationHarness.harness && typeof replicationHarness.harness === "object"
1513
+ ? replicationHarness.harness
1514
+ : null;
1419
1515
  const annotations = sceneAnnotations && readJsonString(sceneAnnotations.status) === "done" && Array.isArray(sceneAnnotations.annotations)
1420
1516
  ? sceneAnnotations.annotations
1421
1517
  : [];
@@ -1431,6 +1527,7 @@ function writeLocalAgentHarnessFiles(dir) {
1431
1527
  composition_context: viralDna,
1432
1528
  static_vs_pivot: viralDna?.static_vs_pivot ?? null,
1433
1529
  editor_harness: harness,
1530
+ replication_harness: replication,
1434
1531
  scenes: Array.isArray(videoContext?.scenes) ? videoContext?.scenes : [],
1435
1532
  scene_annotations: annotations,
1436
1533
  cast: castMembers,
@@ -1472,6 +1569,29 @@ function writeLocalAgentHarnessFiles(dir) {
1472
1569
  return slug ? `${slug}: ${item}` : item;
1473
1570
  }))
1474
1571
  .slice(0, 8);
1572
+ // Replication harness (3-paintbrush cheap vs best-quality plans). Surface the
1573
+ // RECOMMENDED plan's per-beat brush assignment concretely so a local agent can
1574
+ // execute it without opening the raw JSON.
1575
+ const recommendedStrategy = readJsonString(replication?.recommended_strategy) || "cheap_efficient";
1576
+ const cheapPlan = replication?.cheap && typeof replication.cheap === "object" ? replication.cheap : null;
1577
+ const qualityPlan = replication?.quality && typeof replication.quality === "object" ? replication.quality : null;
1578
+ const recommendedPlan = recommendedStrategy === "best_quality" ? qualityPlan : cheapPlan;
1579
+ const motionStyle = replication?.motion_style && typeof replication.motion_style === "object" ? replication.motion_style : null;
1580
+ const planBeats = (plan) => {
1581
+ const scenes = plan && Array.isArray(plan.scenes) ? plan.scenes : [];
1582
+ return scenes.slice(0, 8).map((s) => {
1583
+ const role = readJsonString(s.role) || "beat";
1584
+ const ts = readJsonString(s.timestamp);
1585
+ const method = readJsonString(s.method) || "?";
1586
+ const credits = readJsonString(s.est_credits);
1587
+ const technique = readJsonString(s.technique);
1588
+ return `${ts ? `${ts} ` : ""}${role} → ${method}${credits ? ` (${credits} credits)` : ""}${technique ? `: ${technique}` : ""}`;
1589
+ });
1590
+ };
1591
+ const recommendedBeats = planBeats(recommendedPlan);
1592
+ const hyperframesCandidates = readJsonStringList(motionStyle?.hyperframes_candidates, 6);
1593
+ const assetOpportunities = readJsonStringList(replication?.asset_opportunities, 6);
1594
+ const dnaGuardrails = readJsonStringList(replication?.viral_dna_guardrails, 6);
1475
1595
  const agentMd = `# Vidfarm Local Harness
1476
1596
 
1477
1597
  Read \`.harness/context.json\` before making substantial edits. This fork was pulled from Vidfarm with decompose context; do not freestyle the rewrite from \`composition.html\` alone.
@@ -1479,6 +1599,7 @@ Read \`.harness/context.json\` before making substantial edits. This fork was pu
1479
1599
  Files to use:
1480
1600
  - \`video-context.json\`: transcript, scene descriptions, and viral DNA.
1481
1601
  - \`editor-harness.json\`: the technical editing brief under \`harness\` (pace, typography, audio, scene roles).
1602
+ - \`replication-harness.json\`: the technical replication analysis under \`harness\` — the 3-paintbrush cheap vs best-quality build plans (which beats are raw_clip / hyperframes / reusable_asset / ai_gen), reusable-asset guidance, and a free-tier note. See "Replication plan" below.
1482
1603
  - \`scene-annotations.json\`: per-scene replacement and regeneration DNA.
1483
1604
  - \`.harness/context.json\`: merged agent-facing snapshot generated by \`vidfarm pull\`.
1484
1605
  - \`.harness/agent-guide.md\`: Vidfarm-specific local scripting guidance generated by \`vidfarm pull\`.
@@ -1493,6 +1614,16 @@ Rules:
1493
1614
  - For replacement graphics, screenshots, or still-like scenes, prefer AI image generation plus Ken Burns before paying for AI video unless static_vs_pivot says motion footage is load-bearing.
1494
1615
  - If narration must be customized, default to premium ElevenLabs first, then the user's own ElevenLabs path, then BYOK OpenAI/Gemini/OpenRouter. If captions or scenes were timed to the old VO, retime them to the new narration.
1495
1616
 
1617
+ The three paintbrushes (Vidfarm is thrift-first — do NOT spend AI credits on every scene):
1618
+ - Paint each REPLACE beat with one of three brushes, cheapest first: (1) RAW CLIPS remixed from existing footage (\`vidfarm raws search\` the local/library first, then \`vidfarm raws scan <url|file>\` to hunt new ones — free local compute); (2) HTML/JS HYPERFRAMES — animate text/images/logos/stickers/charts with CSS or JS adapters (anime.js/GSAP/Lottie/Three) for titles, kinetic captions, data beats, brand cards; (3) PURE AI GENERATION (\`vidfarm generate image|video\`) — most expensive, AI video especially, last resort for beats no clip or hyperframe can cover.
1619
+ - A background video + a foreground video (greenscreen / picture-in-picture) covers most "video meme" formats with zero generation.
1620
+ - Reusable asset library (logos, stickers, reactions, b-roll, a-roll, brand media kit): have an opinion on when/where to reuse — but anchor every asset choice to the viral DNA + harness above. Reskinning past a load-bearing beat kills what made the format work. AI-generate a reusable element ONCE, then reuse it; don't regenerate per-scene.
1621
+
1622
+ Two replication harnesses — pick one and name it to the user (default A). When \`replication-harness.json\` is present it holds a CONCRETE, per-beat version of both plans (see "Replication plan" below) — prefer its beat assignments over freestyling; this prose is the fallback when it's absent:
1623
+ - (A) CHEAP & EFFICIENT (default = \`replication-harness.json\`.harness.cheap): recaption text; background+foreground video memes; animate HTML/image elements with hyperframes; reuse library media; AI-generate a reusable element once then reuse; greenscreen; raw-clip long-form and remix; lean on the memes/reactions/b-roll/a-roll library + brand media kit; only if genuinely needed, AI image then (last) AI video/voice/music.
1624
+ - (B) BEST QUALITY (\`.harness.quality\`): AI video generation by default; storyboard with AI IMAGE first (cheap stills lock composition/subject), generate motion from those references; then adversarially grade the render with a coding agent (Claude Code/Codex) against this harness and iterate.
1625
+ - This harness is open source: an everyday AI agent can follow it to recreate a template with NO Vidfarm wallet — clipping, hyperframes, and local \`vidfarm serve\` render all run on free compute. Keep the plan self-contained; don't gate the cheap path behind paid primitives. \`replication-harness.json\`.harness.free_tier_note names the open path for THIS template.
1626
+
1496
1627
  Current format snapshot:
1497
1628
  - Trend tagline: ${readJsonString(viralDna?.trend_tagline) || "unknown"}
1498
1629
  - Hook: ${readJsonString(viralDna?.hook) || "unknown"}
@@ -1513,6 +1644,26 @@ Static vs pivot:
1513
1644
  - Captions: ${readJsonString(captionsPivot?.overall) || "unknown"}${readJsonString(captionsPivot?.animation_dependency) ? ` — ${readJsonString(captionsPivot?.animation_dependency)}` : ""}
1514
1645
  - Visual rebuild: ${readJsonString(visualRebuild?.default_strategy) || "unknown"}${readJsonString(visualRebuild?.reason) ? ` — ${readJsonString(visualRebuild?.reason)}` : ""}
1515
1646
 
1647
+ Replication plan (from replication-harness.json — the recommended path for THIS template):
1648
+ - Recommended strategy: ${recommendedStrategy}${readJsonString(replication?.recommendation_reason) ? ` — ${readJsonString(replication?.recommendation_reason)}` : ""}
1649
+ - Summary: ${readJsonString(replication?.summary) || "unknown (run `vidfarm decompose`)"}
1650
+ - Paintbrush rationale: ${readJsonString(replication?.paintbrush_rationale) || "unknown"}
1651
+ - Cheap plan mix: ${readJsonString(cheapPlan?.method_mix) || readJsonString(cheapPlan?.one_liner) || "unknown"}
1652
+ - Best-quality mix: ${readJsonString(qualityPlan?.method_mix) || readJsonString(qualityPlan?.one_liner) || "unknown"}
1653
+ - Free-tier path: ${readJsonString(replication?.free_tier_note) || "unknown"}
1654
+
1655
+ Recommended per-beat brush plan (${recommendedStrategy}):
1656
+ ${recommendedBeats.length ? recommendedBeats.map((item) => `- ${item}`).join("\n") : "- none recorded (run `vidfarm decompose`)"}
1657
+
1658
+ Do these in HTML/hyperframes (NOT AI video — save credits):
1659
+ ${hyperframesCandidates.length ? hyperframesCandidates.map((item) => `- ${item}`).join("\n") : "- all on-screen text + graphic overlays"}
1660
+
1661
+ Reusable-asset opportunities (logos/stickers/b-roll — generate once, reuse):
1662
+ ${assetOpportunities.length ? assetOpportunities.map((item) => `- ${item}`).join("\n") : "- none recorded"}
1663
+
1664
+ Replication viral-DNA guardrails (never break these when swapping/reusing):
1665
+ ${dnaGuardrails.length ? dnaGuardrails.map((item) => `- ${item}`).join("\n") : "- none recorded"}
1666
+
1516
1667
  Priority preserve beats:
1517
1668
  ${preserve.length ? preserve.map((item) => `- ${item}`).join("\n") : "- none recorded"}
1518
1669
 
@@ -2004,6 +2155,50 @@ async function runDiscoverCommand(argv) {
2004
2155
  assertApiOk(result, "discover");
2005
2156
  emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
2006
2157
  }
2158
+ async function runPublicRawsCommand(argv) {
2159
+ const parsed = parseArgs({
2160
+ args: argv,
2161
+ allowPositionals: true,
2162
+ options: {
2163
+ ...commonOptions(),
2164
+ limit: { type: "string" },
2165
+ query: { type: "string" },
2166
+ category: { type: "string" },
2167
+ type: { type: "string" },
2168
+ "freshness-days": { type: "string" },
2169
+ bookmark: { type: "string" }
2170
+ }
2171
+ });
2172
+ const ctx = commonContext(parsed.values);
2173
+ const bookmark = parsed.values.bookmark?.trim();
2174
+ if (bookmark) {
2175
+ const result = await apiRequest({
2176
+ method: "POST",
2177
+ host: ctx.host,
2178
+ path: `/api/v1/public-raws/${encodeURIComponent(bookmark)}/bookmark`,
2179
+ auth: ctx.auth
2180
+ });
2181
+ assertApiOk(result, "public-raws bookmark");
2182
+ emitResult(result, ctx.json, [["Drive ", `${ctx.host}/library/files?route=raws/public`]]);
2183
+ return;
2184
+ }
2185
+ const query = (parsed.values.query ?? parsed.positionals.join(" ").trim()) || undefined;
2186
+ const result = await apiRequest({
2187
+ method: "GET",
2188
+ host: ctx.host,
2189
+ path: "/api/v1/public-raws",
2190
+ auth: ctx.auth,
2191
+ query: {
2192
+ limit: parsed.values.limit,
2193
+ q: query,
2194
+ category: parsed.values.category,
2195
+ raw_type: parsed.values.type,
2196
+ freshness_days: parsed.values["freshness-days"]
2197
+ }
2198
+ });
2199
+ assertApiOk(result, "public-raws");
2200
+ emitResult(result, ctx.json, [["Browse ", publicRawsFrontendUrl(ctx.host)]]);
2201
+ }
2007
2202
  // `videos` lists source INSPIRATIONS (the raw viral videos), with the same
2008
2203
  // keyword search and an optional --mine for the caller's own uploads.
2009
2204
  async function runVideosCommand(argv) {
@@ -2232,12 +2427,24 @@ async function runReplicateCommand(argv) {
2232
2427
  if (!templateId)
2233
2428
  throw new Error("replicate: no template was minted from the source video.");
2234
2429
  // 3. Decompose (AI scene analysis + recreation), steered by --prompt.
2235
- if (!ctx.json)
2236
- process.stdout.write(`${DIM}Analyzing + recreating scenes (can take a minute)… ${RESET}`);
2237
- const decomposed = await decomposeInspiration(ctx, inspirationId, userPrompt);
2238
- if (!ctx.json)
2239
- console.log(`${GREEN}done${RESET}`);
2240
- const defaultForkId = decomposed.json?.default_fork_id;
2430
+ // Slideshows (TikTok photo/carousel posts) have no source video to slice —
2431
+ // they're already an editable image timeline so skip decompose and fork the
2432
+ // ready slideshow template directly.
2433
+ const isSlideshow = String(ready.media_kind ?? "") === "slideshow";
2434
+ let defaultForkId;
2435
+ if (isSlideshow) {
2436
+ const slideCount = Array.isArray(ready.slide_image_urls) ? ready.slide_image_urls.length : 0;
2437
+ if (!ctx.json)
2438
+ console.log(`${DIM}Slideshow (${slideCount} slides) — already an editable image timeline, skipping decompose.${RESET}`);
2439
+ }
2440
+ else {
2441
+ if (!ctx.json)
2442
+ process.stdout.write(`${DIM}Analyzing + recreating scenes (can take a minute)… ${RESET}`);
2443
+ const decomposed = await decomposeInspiration(ctx, inspirationId, userPrompt);
2444
+ if (!ctx.json)
2445
+ console.log(`${GREEN}done${RESET}`);
2446
+ defaultForkId = decomposed.json?.default_fork_id;
2447
+ }
2241
2448
  // 4. Fork a personal editable copy (unless --no-fork opens the shared base).
2242
2449
  let forkId = defaultForkId;
2243
2450
  let openTemplate = templateId;
@@ -3436,8 +3643,8 @@ function speechKeyHint(kind) {
3436
3643
  : "OPENAI_API_KEY, GEMINI_API_KEY, or OPENROUTER_API_KEY";
3437
3644
  return `No local AI key found. Set ${preference} (or pass --gemini-key/--openai-key/--openrouter-key), or run with --cloud to use your saved Vidfarm provider keys.`;
3438
3645
  }
3439
- async function downloadUrlToFile(url, dest) {
3440
- const res = await fetch(url);
3646
+ async function downloadUrlToFile(url, dest, headers) {
3647
+ const res = await fetch(url, headers && Object.keys(headers).length ? { headers } : undefined);
3441
3648
  if (!res.ok || !res.body)
3442
3649
  throw new Error(`download failed (${res.status}) for ${url}`);
3443
3650
  mkdirSync(path.dirname(dest), { recursive: true });
@@ -3515,7 +3722,7 @@ async function runTtsCommand(argv) {
3515
3722
  // (speechKeyHint), speak through the local Kokoro engine so agents
3516
3723
  // always get audio out of the box.
3517
3724
  if (!json)
3518
- console.log(`${DIM}No provider key found — using the local Kokoro engine; pass --engine cloud + a key for BYOK voices. (${speechKeyHint("tts")})${RESET}`);
3725
+ console.log(`${DIM}No provider key found — using the local Kokoro engine. For better voices or higher-throughput runs, bring your own audio key or use vidfarm cloud. (${speechKeyHint("tts")})${RESET}`);
3519
3726
  await runLocalKokoroTts({ text, voice, style, out: parsed.values.out, format: explicitFormat, json, fallback: true });
3520
3727
  return;
3521
3728
  }
@@ -3758,7 +3965,7 @@ async function runMusicCommand(argv) {
3758
3965
  if (parsed.values["own-key"])
3759
3966
  payload.use_wallet_credits = false;
3760
3967
  if (!ctx.json && !parsed.values["own-key"]) {
3761
- console.log(`${DIM}Composing with ElevenLabs on the platform key (wallet-billed). Add --own-key to use your own ElevenLabs key.${RESET}`);
3968
+ console.log(`${DIM}Music generation runs through Vidfarm cloud on ElevenLabs (wallet-billed). Add --own-key to use your own ElevenLabs key; if you need a completely free local path, keep a human in the loop and skip AI music generation.${RESET}`);
3762
3969
  }
3763
3970
  const tracer = parsed.values.tracer ?? `devcli-music-${Date.now().toString(36)}`;
3764
3971
  const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/music/generate", auth: ctx.auth, body: { tracer, payload } });
@@ -3889,7 +4096,7 @@ async function runSttCommand(argv) {
3889
4096
  // (speechKeyHint), transcribe with the local whisper.cpp engine (word
3890
4097
  // timestamps, no speaker labels).
3891
4098
  if (!json)
3892
- console.log(`${DIM}No provider key found — using the local whisper.cpp engine; pass --engine cloud + a key for BYOK transcription (gemini adds speaker labels). (${speechKeyHint("stt")})${RESET}`);
4099
+ console.log(`${DIM}No provider key found — using the local whisper.cpp engine. For better diarization or heavier workloads, bring your own audio key or use vidfarm cloud. (${speechKeyHint("stt")})${RESET}`);
3893
4100
  await runLocalWhisperStt({ target, out: parsed.values.out, json, language, fallback: true });
3894
4101
  return;
3895
4102
  }
@@ -4484,6 +4691,62 @@ function parseClipTime(value, label) {
4484
4691
  throw new Error(`--${label} "${raw}" isn't a valid number of seconds.`);
4485
4692
  return n;
4486
4693
  }
4694
+ function authHeadersForSourceUrl(sourceUrl, ctx) {
4695
+ try {
4696
+ const source = new URL(sourceUrl);
4697
+ const host = new URL(ctx.host);
4698
+ return source.origin === host.origin ? buildAuthHeaders(ctx.auth) : undefined;
4699
+ }
4700
+ catch {
4701
+ return undefined;
4702
+ }
4703
+ }
4704
+ async function uploadTemporaryFileToSpace(ctx, absPath, space) {
4705
+ const form = new FormData();
4706
+ form.append("file", new Blob([readFileSync(absPath)]), path.basename(absPath));
4707
+ if (space === "local") {
4708
+ const { withLocalBackend } = await import("./devcli/local-backend.js");
4709
+ const backend = await withLocalBackend({ home: ctx.home, apiKey: ctx.auth.apiKey });
4710
+ const res = await backend.app.request("/api/v1/user/me/temporary-files/upload", {
4711
+ method: "POST",
4712
+ headers: buildAuthHeaders({ apiKey: ctx.auth.apiKey || backend.apiKey, shareToken: ctx.auth.shareToken }),
4713
+ body: form
4714
+ });
4715
+ const text = await res.text();
4716
+ let json = null;
4717
+ try {
4718
+ json = text ? JSON.parse(text) : null;
4719
+ }
4720
+ catch {
4721
+ json = null;
4722
+ }
4723
+ if (!res.ok)
4724
+ throw new Error(`Upload failed (HTTP ${res.status}): ${(json && json.error) || text.slice(0, 200)}`);
4725
+ const tempFileId = json?.file?.id ?? json?.id ?? json?.file_id;
4726
+ if (!tempFileId)
4727
+ throw new Error("Upload did not return a temporary file id.");
4728
+ return String(tempFileId);
4729
+ }
4730
+ const res = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), {
4731
+ method: "POST",
4732
+ headers: buildAuthHeaders(ctx.auth),
4733
+ body: form
4734
+ });
4735
+ const text = await res.text();
4736
+ let json = null;
4737
+ try {
4738
+ json = text ? JSON.parse(text) : null;
4739
+ }
4740
+ catch {
4741
+ json = null;
4742
+ }
4743
+ if (!res.ok)
4744
+ throw new Error(`Upload failed (HTTP ${res.status}): ${(json && json.error) || text.slice(0, 200)}`);
4745
+ const tempFileId = json?.file?.id ?? json?.id ?? json?.file_id;
4746
+ if (!tempFileId)
4747
+ throw new Error("Upload did not return a temporary file id.");
4748
+ return String(tempFileId);
4749
+ }
4487
4750
  async function runClipperCommand(argv) {
4488
4751
  const parsed = parseArgs({
4489
4752
  args: argv,
@@ -4505,6 +4768,9 @@ async function runClipperCommand(argv) {
4505
4768
  throw new Error("clipper requires a source: `vidfarm clipper <video-url|file> [--start <t> --end <t>] [--tracer NAME] [--folder NAME] [--name NAME]`. Omit --start/--end to import the WHOLE video.");
4506
4769
  }
4507
4770
  const ctx = commonContext(parsed.values);
4771
+ if (ctx.target === "both")
4772
+ throw new Error("clipper only supports one backend at a time. Omit --cloud for the local-first path, or pass --cloud for vidfarm.cc.");
4773
+ const target = ctx.target === "cloud" ? "cloud" : "local";
4508
4774
  const rawStart = (parsed.values.start ?? parsed.values.in);
4509
4775
  const rawEnd = (parsed.values.end ?? parsed.values.out);
4510
4776
  const hasStart = rawStart !== undefined && String(rawStart).trim() !== "";
@@ -4527,71 +4793,69 @@ async function runClipperCommand(argv) {
4527
4793
  body.name = String(parsed.values.name);
4528
4794
  if (audioOnly)
4529
4795
  body.audio_only = true;
4530
- // Resolve the source into the body once: a URL rides as source_url; a local
4531
- // file is staged into the temp store and referenced by temp_file_id.
4532
- if (/^https?:\/\//i.test(source)) {
4533
- body.source_url = source;
4534
- }
4535
- else {
4536
- const abs = path.resolve(process.cwd(), source.replace(/^@/, ""));
4537
- if (!existsSync(abs))
4538
- throw new Error(`No such file or URL: ${source}`);
4539
- const form = new FormData();
4540
- form.append("file", new Blob([readFileSync(abs)]), path.basename(abs));
4541
- const up = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
4542
- const upText = await up.text();
4543
- let upJson = null;
4544
- try {
4545
- upJson = upText ? JSON.parse(upText) : null;
4796
+ // Local-first: local URL clipping hands the source page URL straight to the
4797
+ // local backend, which resolves direct media from page metadata/JSON-LD (and
4798
+ // only falls back to RapidAPI when configured). Cloud mode still hands the
4799
+ // URL to vidfarm.cc; local file paths are staged into the chosen backend.
4800
+ const sourceIsUrl = /^https?:\/\//i.test(source);
4801
+ let stagedDir = null;
4802
+ try {
4803
+ if (sourceIsUrl) {
4804
+ body.source_url = source;
4546
4805
  }
4547
- catch {
4548
- upJson = null;
4806
+ else {
4807
+ const abs = path.resolve(process.cwd(), source.replace(/^@/, ""));
4808
+ if (!existsSync(abs))
4809
+ throw new Error(`No such file or URL: ${source}`);
4810
+ body.temp_file_id = await uploadTemporaryFileToSpace(ctx, abs, target);
4811
+ if (!body.name)
4812
+ body.name = path.basename(abs);
4813
+ }
4814
+ if (wholeVideo) {
4815
+ // Entirety → import the whole source as ONE raw (no trim/re-encode), the same
4816
+ // import_only path as `upload-media --from raw` and the /tools/clipper page's
4817
+ // Whole-video button.
4818
+ body.import_only = true;
4819
+ const res = target === "local"
4820
+ ? await dispatch(ctx, { method: "POST", path: "/raws/scan", body }, "local")
4821
+ : await apiRequest({ method: "POST", host: ctx.host, path: "/raws/scan", auth: ctx.auth, body });
4822
+ assertApiOk(res, "clipper (whole-video import)");
4823
+ if (ctx.json) {
4824
+ printJson({ ok: true, mode: "whole", target, ...res.json });
4825
+ return;
4826
+ }
4827
+ const j = res.json;
4828
+ const dest = parsed.values.folder ? `/raws/${String(parsed.values.folder)}` : "your raws library";
4829
+ console.log(`${GREEN}Imported the whole video → ${dest} (${target}, scan ${j?.scan_id ?? "?"}, status ${j?.status ?? "?"}).${RESET}`);
4830
+ console.log(`${DIM}Grab an exact subrange instead with --start <t> --end <t>.${RESET}`);
4831
+ return;
4549
4832
  }
4550
- if (!up.ok)
4551
- throw new Error(`Upload failed (HTTP ${up.status}): ${(upJson && upJson.error) || upText.slice(0, 200)}`);
4552
- const tempFileId = upJson?.file?.id ?? upJson?.id ?? upJson?.file_id;
4553
- if (!tempFileId)
4554
- throw new Error("Upload did not return a temporary file id.");
4555
- body.temp_file_id = String(tempFileId);
4556
- if (!body.name)
4557
- body.name = path.basename(abs);
4558
- }
4559
- if (wholeVideo) {
4560
- // Entirety → import the whole source as ONE raw (no trim/re-encode), the same
4561
- // import_only path as `upload-media --from raw` and the /tools/clipper page's
4562
- // Whole-video button.
4563
- body.import_only = true;
4564
- const res = await apiRequest({ method: "POST", host: ctx.host, path: "/raws/scan", auth: ctx.auth, body });
4565
- assertApiOk(res, "clipper (whole-video import)");
4833
+ const startSec = parseClipTime(rawStart, "start");
4834
+ const endSec = parseClipTime(rawEnd, "end");
4835
+ if (endSec <= startSec)
4836
+ throw new Error(`--end (${endSec}s) must be greater than --start (${startSec}s).`);
4837
+ body.start_sec = Number(startSec.toFixed(3));
4838
+ body.end_sec = Number(endSec.toFixed(3));
4839
+ const res = target === "local"
4840
+ ? await dispatch(ctx, { method: "POST", path: "/raws/clip-range", body }, "local")
4841
+ : await apiRequest({ method: "POST", host: ctx.host, path: "/raws/clip-range", auth: ctx.auth, body });
4842
+ assertApiOk(res, "clipper");
4566
4843
  if (ctx.json) {
4567
- printJson({ ok: true, mode: "whole", ...res.json });
4844
+ printJson({ ok: true, mode: "range", target, ...res.json });
4568
4845
  return;
4569
4846
  }
4570
4847
  const j = res.json;
4571
- const dest = parsed.values.folder ? `/raws/${String(parsed.values.folder)}` : "your raws library";
4572
- console.log(`${GREEN}Imported the whole video → ${dest} (scan ${j?.scan_id ?? "?"}, status ${j?.status ?? "?"}).${RESET}`);
4573
- console.log(`${DIM}Grab an exact subrange instead with --start <t> --end <t>.${RESET}`);
4574
- return;
4848
+ const dest = j?.folder_path ? `/raws/${j.folder_path}` : "your raws library";
4849
+ const kindWord = (j?.media_kind === "audio" || audioOnly) ? "audio" : "clip";
4850
+ console.log(`${GREEN}Clipped ${kindWord} ${(j?.duration_sec ?? (endSec - startSec)).toFixed?.(2) ?? j?.duration_sec}s → ${dest} (${target}, clip ${j?.clip_id ?? "?"}).${RESET}`);
4851
+ if (j?.view_url)
4852
+ console.log(`${DIM}Preview: ${new URL(j.view_url, ctx.host).toString()}${RESET}`);
4853
+ console.log(`${DIM}Grab another range from the same source with the same --tracer/--folder.${RESET}`);
4575
4854
  }
4576
- const startSec = parseClipTime(rawStart, "start");
4577
- const endSec = parseClipTime(rawEnd, "end");
4578
- if (endSec <= startSec)
4579
- throw new Error(`--end (${endSec}s) must be greater than --start (${startSec}s).`);
4580
- body.start_sec = Number(startSec.toFixed(3));
4581
- body.end_sec = Number(endSec.toFixed(3));
4582
- const res = await apiRequest({ method: "POST", host: ctx.host, path: "/raws/clip-range", auth: ctx.auth, body });
4583
- assertApiOk(res, "clipper");
4584
- if (ctx.json) {
4585
- printJson({ ok: true, mode: "range", ...res.json });
4586
- return;
4855
+ finally {
4856
+ if (stagedDir)
4857
+ rmSync(stagedDir, { recursive: true, force: true });
4587
4858
  }
4588
- const j = res.json;
4589
- const dest = j?.folder_path ? `/raws/${j.folder_path}` : "your raws library";
4590
- const kindWord = (j?.media_kind === "audio" || audioOnly) ? "audio" : "clip";
4591
- console.log(`${GREEN}Clipped ${kindWord} ${(j?.duration_sec ?? (endSec - startSec)).toFixed?.(2) ?? j?.duration_sec}s → ${dest} (clip ${j?.clip_id ?? "?"}).${RESET}`);
4592
- if (j?.view_url)
4593
- console.log(`${DIM}Preview: ${new URL(j.view_url, ctx.host).toString()}${RESET}`);
4594
- console.log(`${DIM}Grab another range from the same source with the same --tracer/--folder.${RESET}`);
4595
4859
  }
4596
4860
  async function runPullCommand(argv) {
4597
4861
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), dir: { type: "string" }, refetch: { type: "boolean", default: false } } });
@@ -4611,6 +4875,7 @@ async function runPullCommand(argv) {
4611
4875
  const hasCast = existsSync(path.join(dir, "cast.json"));
4612
4876
  const hasAnnotations = existsSync(path.join(dir, "scene-annotations.json"));
4613
4877
  const hasHarness = existsSync(path.join(dir, "editor-harness.json"));
4878
+ const hasReplication = existsSync(path.join(dir, "replication-harness.json"));
4614
4879
  if (ctx.json) {
4615
4880
  printJson({
4616
4881
  ok: true,
@@ -4620,6 +4885,7 @@ async function runPullCommand(argv) {
4620
4885
  has_cast: hasCast,
4621
4886
  has_scene_annotations: hasAnnotations,
4622
4887
  has_editor_harness: hasHarness,
4888
+ has_replication_harness: hasReplication,
4623
4889
  harness_context_path: harnessFiles.contextPath,
4624
4890
  harness_instructions_path: harnessFiles.instructionsPath,
4625
4891
  ...info
@@ -4634,7 +4900,7 @@ async function runPullCommand(argv) {
4634
4900
  const flags = [layer.is_timeline_proxy ? "proxy" : null, layer.is_full_canvas ? "full-canvas" : null].filter(Boolean).join(",");
4635
4901
  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}`);
4636
4902
  }
4637
- console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}, ${hasAnnotations ? "scene-annotations.json ✓" : "scene-annotations.json ✗"}, ${hasHarness ? "editor-harness.json ✓ (style brief under .harness)" : "editor-harness.json ✗"}.${RESET}`);
4903
+ console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}, ${hasAnnotations ? "scene-annotations.json ✓" : "scene-annotations.json ✗"}, ${hasHarness ? "editor-harness.json ✓ (style brief under .harness)" : "editor-harness.json ✗"}, ${hasReplication ? "replication-harness.json ✓ (3-paintbrush build plan under .harness)" : "replication-harness.json ✗"}.${RESET}`);
4638
4904
  console.log(`${DIM}Agent brief: ${path.relative(dir, harnessFiles.instructionsPath) || path.basename(harnessFiles.instructionsPath)}; merged context: ${path.relative(dir, harnessFiles.contextPath)}.${RESET}`);
4639
4905
  console.log(`${DIM}Next: vidfarm generate video --prompt "..." --aspect-ratio ${info.aspect_ratio ?? "9:16"} --place ${dir} --at <gap start>|--replace <layer_key>${RESET}`);
4640
4906
  }
@@ -4848,7 +5114,7 @@ async function runAddProviderKeyCommand(argv) {
4848
5114
  const provider = parsed.positionals[0];
4849
5115
  const secret = parsed.positionals[1];
4850
5116
  if (!provider || !secret)
4851
- throw new Error("add-provider-key requires <provider> <secret> (provider: openai|gemini|openrouter|perplexity).");
5117
+ throw new Error("add-provider-key requires <provider> <secret> (provider: openai|gemini|openrouter|perplexity|nvidia|elevenlabs).");
4852
5118
  const ctx = commonContext(parsed.values);
4853
5119
  const result = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/user/me/provider-keys", auth: ctx.auth, body: { provider, secret, label: parsed.values.label } });
4854
5120
  assertApiOk(result, "add-provider-key");