@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 CHANGED
@@ -85,13 +85,17 @@ Directors don't edit templates directly. They fork.
85
85
 
86
86
  ### Add your own source (inspiration)
87
87
 
88
- To bring a new viral video into the catalog as a **private** template you own, ingest its social URL:
88
+ To bring a new viral video into the catalog as a **private** template you own, ingest its social URL — or upload a video file directly:
89
89
 
90
90
  - `POST /discover/templates { source_url, tagline?, notes? }` — accepts TikTok / YouTube / Instagram / Twitter-X URLs only. The video download runs async; the response is the inspiration record (`202`, `status: "processing"`). The card finalizes into a private `template_...` the next time `GET /discover/feed` is polled (the feed poll doubles as the completion check).
91
+ - **Upload a file** (three steps, same finalize behavior as the URL flow):
92
+ 1. `POST /discover/templates/upload/presign { file_name, content_type?, size_bytes? }` — video files only (MP4/MOV/WebM), 200 MB cap. Returns `transport: "presigned"` with a `{ upload: { method, url, headers } }` S3 PUT target, or `transport: "server"` (local-storage boxes) pointing at the multipart fallback below. Both include the `storage_key` to finalize with.
93
+ 2. Send the bytes: `PUT` them to the presigned URL, or `POST /discover/templates/upload` (multipart, field `file`) when transport is `server`.
94
+ 3. `POST /discover/templates { upload: { storage_key, file_name }, title?, tagline?, notes? }` — queues an async `video_ingest` job (durable mirror + duration probe + thumbnail) and returns the inspiration record (`202`). Finalizes into a private template exactly like the URL flow. `title` names the /discover card; untitled templates display as their `template_...` id (the file name is only shown while processing, never persisted as the title).
91
95
  - `DELETE /discover/templates/:entryId` — remove a private inspiration/template you own (accepts either the `inspiration_...` or minted `template_...` id).
92
96
  - `POST /api/v1/inspirations/:inspirationId/decompose { user_prompt? }` — AI-decompose an inspiration's downloaded video into scenes (requires a saved provider key; same 120s source cap as auto-decompose).
93
97
 
94
- devcli: `vidfarm inspiration-add <url>`, `vidfarm inspiration-rm <id>`, `vidfarm inspiration-decompose <id>`, `vidfarm discover [query]` to browse/search templates, and `vidfarm videos [query] [--mine]` to browse/search source inspirations.
98
+ devcli: `vidfarm inspiration-add <url|file.mp4>` (a local file path runs the presign→PUT→finalize flow for you), `vidfarm inspiration-rm <id>`, `vidfarm inspiration-decompose <id>`, `vidfarm discover [query]` to browse/search templates, and `vidfarm videos [query] [--mine]` to browse/search source inspirations. On the web, the Discover page's **Add Template** modal accepts either a URL or a file upload.
95
99
 
96
100
  ## Fork a template
97
101
 
@@ -121,7 +125,7 @@ The editor loads `composition.html` and `composition.json` from the fork's worki
121
125
  The editor is a full timeline surface:
122
126
 
123
127
  - **Timeline** — multi-track, drag/trim/split clips, group/ungroup layers
124
- - **Inspector panel** — per-layer property editor (text, color, font, position, size, media source, playback start, volume)
128
+ - **Inspector panel** — per-layer property editor (text, color, font, position, size, media source, playback start, volume). Image layers also expose an **Animate (Ken Burns)** dropdown — a slow pan/zoom over the clip's full duration (see below).
125
129
  - **Canvas frame overlay** — 8-handle resize + WYSIWYG positioning
126
130
  - **Chat panel** — AI assistant that can programmatically edit the composition (`add_layer`, `remove_layer`, `set_layer_timing/visual/style/media/text`, `group_layers`, `generate_layer`, `replace_composition_html`, ...). The assistant uses the caller's saved provider keys. **`generate_layer`** is the one-shot "generate AI media and place it": it submits the `/videos/generate` or `/images/generate` primitive job, drops a placeholder clip into the target slot (fill a blank gap, replace a scene, or overlay), and auto-swaps in the finished media when the job settles — no manual polling. In-flight generations surface in `editor_context.pending_generations`.
127
131
  - **Auto-save** — every commit writes composition.html + composition.json to the working state. No explicit save button.
@@ -165,9 +169,9 @@ Response:
165
169
  { "ok": true, "mode": "smart", "scene_count": 6, "caption_count": 4, "duration_seconds": 12.4, "ghostcut_pending": true, "ghostcut_task_id": "..." }
166
170
  ```
167
171
 
168
- If `ghostcut_pending: true`, poll `POST /api/v1/compositions/:forkId/ghostcut-poll` until status transitions to `done` or `failed`.
172
+ If `ghostcut_pending: true`, poll `POST /api/v1/compositions/:forkId/remove-video-captions-poll` (legacy alias: `/ghostcut-poll`) until status transitions to `done` or `failed`.
169
173
 
170
- Read-only state (does **not** advance the job or bill): `GET /api/v1/compositions/:forkId/ghostcut`. Returns `{ status, task_id, original_source_url, mirrored_url, submitted_at_ms, completed_at_ms, failure_reason }`. This is how you read the composition's **two video sources**: `original_source_url` is the **original video** (raw upload, captions intact) and `mirrored_url` is the **decomposed video** (the processed, caption-free copy the timeline renders from). Use this when you want both URLs without triggering another poll — the AI copilot uses it to pick between the original video and the decomposed video before feeding a video into a primitive route (image extract, dedupe, trim, ai video edit, etc.). Prefer the **decomposed video** (`mirrored_url`) when `status === "done"` and the downstream call should be caption-free; prefer the **original video** (`original_source_url`) when the user explicitly wants the raw upload or when `status !== "done"`.
174
+ Read-only state (does **not** advance the job or bill): `GET /api/v1/compositions/:forkId/remove-video-captions` (legacy alias: `/ghostcut`). Returns `{ status, task_id, original_source_url, mirrored_url, submitted_at_ms, completed_at_ms, failure_reason }`. This is how you read the composition's **two video sources**: `original_source_url` is the **original video** (raw upload, captions intact) and `mirrored_url` is the **decomposed video** (the processed, caption-free copy the timeline renders from). Use this when you want both URLs without triggering another poll — the AI copilot uses it to pick between the original video and the decomposed video before feeding a video into a primitive route (image extract, dedupe, trim, ai video edit, etc.). Prefer the **decomposed video** (`mirrored_url`) when `status === "done"` and the downstream call should be caption-free; prefer the **original video** (`original_source_url`) when the user explicitly wants the raw upload or when `status !== "done"`.
171
175
 
172
176
  ## Video context (transcript + scene descriptions)
173
177
 
@@ -234,6 +238,35 @@ Both surfaces can generate a NEW AI video/image (optionally conditioned on refer
234
238
  ```
235
239
  `--ref @localfile` (or a bare path) auto-uploads the image and uses its durable URL as a reference. `generate` prints the media URL (drop `--place` to just get the URL); `place` can be run separately on any media URL (`vidfarm place ./work --src <url> --at <time>|--replace <layer_key>`). Placement produces the exact same clip markup the browser editor makes, so a human can finish it in the Trackpad Editor.
236
240
 
241
+ ### Ken Burns — animate still images (slow pan/zoom)
242
+
243
+ Still images can carry a **Ken Burns effect**: a slow pan or zoom that runs across the clip's full duration, in the editor preview and both render paths identically. This is what makes a slideshow of stills feel like motion footage. It is a first-class layer property — the image layer's `<img>` gets `data-kenburns="<preset>"`; there is no separate keyframe authoring.
244
+
245
+ **Presets:** `zoom-in`, `zoom-out`, `pan-left`, `pan-right`, `pan-up`, `pan-down`, `zoom-in-left`, `zoom-in-right`. Optional **intensity** `0.04`–`0.5` (default `0.18`; ~`0.1` subtle, ~`0.3` dramatic) controls how far it travels/zooms.
246
+
247
+ When the director says **"animate the images"**, **"make the photos move"**, **"add Ken Burns"**, or similar, apply a preset to **every** still-image layer — vary presets across adjacent clips (zoom-in, then pan-left, then zoom-out, …) so consecutive stills don't repeat the same motion. Zoom presets suit centered subjects (products, faces); pan presets suit wide scenery or tall screenshots. Only images support it — never set it on video, text, or audio.
248
+
249
+ Three surfaces:
250
+ - **Editor web copilot** — `editor_action` with `action_type=set_layer_media`, `ken_burns=<preset>` (`none` removes it), optional `ken_burns_intensity`. One call per image layer. You can also seed it on `add_layer` (kind=image) or `generate_layer` (media_type=image) so a freshly placed still arrives already animated.
251
+ - **Editor UI (human)** — the Inspector's **Animate (Ken Burns)** dropdown on any selected image.
252
+ - **Desktop agents (devcli)** — `vidfarm place ./work --src <img> --kind image --ken-burns zoom-in [--ken-burns-intensity 0.3]`, or fused with generation: `vidfarm generate image --prompt "…" --place ./work --ken-burns pan-left`.
253
+
254
+ ### Local file paths as media (skip the S3 upload)
255
+
256
+ `vidfarm place --src` (and `approve --video/--media`) accept a **local file path**, not just a URL — so a power user bulk-building compositions from a folder of clips on their own machine never has to upload every asset to Vidfarm storage:
257
+
258
+ - **On a `vidfarm serve` box (the free bulk workflow)** — `place`'s target composition lives under `<data-dir>/storage/compositions/forks/<forkId>/working/`, so a local `--src` file is **copied straight onto that box's own disk store** (`.vidfarm-local/storage/users/local-media/…`) and referenced by the box's own `http://localhost:3000/storage/…` URL. **Nothing touches S3.** The in-process local renderer fetches it over localhost for free. This is the intended path for bulk-generating many videos from local assets without bloating your durable library.
259
+ ```bash
260
+ # serve box running on :3000 — reference clips straight off disk, render locally, free
261
+ vidfarm place ./.vidfarm-local/storage/compositions/forks/<forkId>/working \
262
+ --src ~/footage/hook-042.mp4 --at 0
263
+ vidfarm render <forkId> # local, $0.00
264
+ ```
265
+ If you ran `serve --port <N>`, pass `--base-url http://localhost:<N>` so the reference URL points at the right origin. A localhost `/storage` URL only resolves against the serve box that holds the file, so these compositions render/preview **locally**; to render in the cloud or share them, upload the media instead (below).
266
+ - **Anywhere else (e.g. a `vidfarm pull` dir headed for cloud render/publish)** — the cloud renderer can't reach your disk, so a local `--src` file is **uploaded to the ephemeral temp store** first, namescoped under a throwaway `temp/` folder by default (override with `--folder`). Use this when the output must be a durable, shareable URL.
267
+
268
+ **`/temp` folder convention.** When you *do* need to upload throwaway media to Vidfarm (cloud render, `approve`, one-off URLs), keep it under a dedicated `temp/` folder — `vidfarm upload clip.mp4 --folder temp`, `vidfarm place … --folder temp`, `vidfarm approve … --folder temp` — so scratch assets stay quarantined in one place you can periodically purge, instead of cluttering your persistent My Files library or the temp-store root.
269
+
237
270
  ## Render (publish to MP4)
238
271
 
239
272
  Rendering publishes the fork's current working state to an MP4 using HyperFrames (cloud Lambda fan-out on the deployed host, or the free in-process renderer on a local `vidfarm serve` box). In the Trackpad Editor this is the **Render** button. The REST route is `/render`; the older `/export` path is kept as a deprecated alias for already-published devcli clients (treat "Render", "publish", `/render`, and `/export` as the same operation). The devcli exposes it as `vidfarm render <forkId> [--wait]`.
@@ -245,7 +278,7 @@ Content-Type: application/json
245
278
  { "title": "optional", "version": null, "tracer": "optional-trace-id", "html": "optional HTML to save to working state before rendering" }
246
279
  ```
247
280
 
248
- Returns 202 with `{ ok, renderId, status, progress, outputUrl, cost, title, version, ... }` where `version` is the snapshot version created for this publish. Failure modes: `402` if the fork owner is not on a paid plan, `409` while GhostCut subtitle removal is still `pending` (retry after `/ghostcut-poll` reports done/failed), `412` if the provider-key preflight fails. Poll:
281
+ Returns 202 with `{ ok, renderId, status, progress, outputUrl, cost, title, version, ... }` where `version` is the snapshot version created for this publish. Failure modes: `402` if the fork owner is not on a paid plan, `409` while GhostCut subtitle removal is still `pending` (retry after `/remove-video-captions-poll` reports done/failed), `412` if the provider-key preflight fails. Poll:
249
282
 
250
283
  ```
251
284
  GET /api/v1/compositions/:forkId/renders/:renderId
@@ -351,7 +384,7 @@ There is no single price for a video. The **approach** the director picks sets t
351
384
 
352
385
  | Approach | Typical cost | How |
353
386
  |---|---|---|
354
- | **Reuse + render locally** | **free** | Fork an already-decomposed template, swap captions / images / video with existing MP4s (from **My Files**, the director's **local computer**, or a **web search**), and render **locally** via `vidfarm serve` (native in-process HyperFrames render — free and unguarded, no cloud). |
387
+ | **Reuse + render locally** | **free** | Fork an already-decomposed template, swap captions / images / video with existing MP4s (from **My Files**, the director's **local computer** — reference files straight off disk with `place --src ./file`, no upload — or a **web search**), and render **locally** via `vidfarm serve` (native in-process HyperFrames render — free and unguarded, no cloud). |
355
388
  | **Reuse + cloud render** | **~$0.001 – $0.03** | Same reuse, but render on the cloud renderer (`POST /compositions/:forkId/render`, ~$0.01–$0.10 depending on length/res). Cheap **image** generation/edits fit in this band too. |
356
389
  | **AI-generate some scenes** | **~$1** | Replace a few scenes with AI-generated video clips for high specificity/customization (see the "Generate AI media" section). |
357
390
  | **Heavy AI generation** | **$10+** | Many/long AI video clips, custom characters, fully bespoke scenes. |
@@ -411,15 +444,15 @@ Send a stable `tracer` on export so retries are traceable and filterable in job
411
444
  |---|---|---|
412
445
  | `vidfarm discover [query]` | `GET /discover/feed[?q=]` | browse/search templates |
413
446
  | `vidfarm videos [query] [--mine]` | `GET /api/v1/videos[?q=&mine=]` | browse/search source inspirations |
414
- | `vidfarm inspiration-add <url>` | `POST /discover/templates` | add your own source |
447
+ | `vidfarm inspiration-add <url\|file>` | `POST /discover/templates` (files: `POST /discover/templates/upload/presign` + PUT first) | add your own source (URL or local video upload) |
415
448
  | `vidfarm inspiration-rm <id>` | `DELETE /discover/templates/:id` | remove a private one |
416
449
  | `vidfarm inspiration-decompose <id>` | `POST /api/v1/inspirations/:id/decompose` | AI-decompose an inspiration |
417
450
  | `vidfarm fork <template_id>` | `POST /api/v1/compositions` | fork a template |
418
451
  | `vidfarm pull <forkId> [--dir <p>]` | `GET .../compositions/:forkId/{composition.html,json,video-context.json,cast.json}` | sync a fork to disk + print gaps/scene keys |
419
452
  | `vidfarm generate <image\|video> --prompt "…"` | `POST /api/v1/primitives/{images,videos}/generate` (polls job) | generate AI media → finished URL |
420
- | `vidfarm place <dir> --src <url> [--at\|--replace]` | (edits local composition.html) | drop media into a gap / over a scene |
453
+ | `vidfarm place <dir> --src <url\|file> [--at\|--replace]` | (edits local composition.html; local files → serve disk store or temp upload) | drop media (URL **or local file**) into a gap / over a scene |
421
454
  | `vidfarm decompose <forkId>` | `POST .../compositions/:forkId/auto-decompose` | split source into scenes |
422
- | `vidfarm ghostcut <forkId>` | `GET .../compositions/:forkId/ghostcut` | subtitle-removal status |
455
+ | `vidfarm remove-video-captions <forkId>` (alias: `ghostcut`) | `GET .../compositions/:forkId/remove-video-captions` | subtitle-removal status |
423
456
  | `vidfarm snapshot <forkId>` | `POST .../compositions/:forkId/versions` | save composition fork version |
424
457
  | `vidfarm versions <forkId>` | `GET .../compositions/:forkId/versions` | list versions |
425
458
  | `vidfarm render <forkId> [--wait]` | `POST .../compositions/:forkId/render` | render video to MP4 |
@@ -427,14 +460,14 @@ Send a stable `tracer` on export so retries are traceable and filterable in job
427
460
  | `vidfarm visibility <forkId> <private\|public>` | `PATCH .../compositions/:forkId/visibility` | set visibility |
428
461
  | `vidfarm clone <forkId>` | `POST .../compositions/:forkId/clone` | clone a fork |
429
462
  | `vidfarm share-link <forkId>` | `POST .../compositions/:forkId/share-links` | mint a share URL |
430
- | `vidfarm approve --video <url> --caption "…"` | `POST /api/v1/approved/posts` | approve post (prints `share_url`) |
463
+ | `vidfarm approve --video <url\|file> --caption "…"` | `POST /api/v1/approved/posts` | approve post (local `--video`/`--media` auto-upload to `temp/`; prints `share_url`) |
431
464
  | `vidfarm posts` / `vidfarm post <id>` | `GET /api/v1/approved/posts[/:id]` | browse approved posts |
432
465
  | `vidfarm schedule <postId> --at <iso> --to <dest>` | `POST /api/v1/approved/posts/:postId/schedules` | schedule a post |
433
466
  | `vidfarm schedules <postId>` | `GET /api/v1/approved/posts/:postId/schedules` | browse scheduled posts |
434
467
  | `vidfarm login <email>` / `vidfarm verify <email> <code>` | `POST /api/v1/user/request-otp` · `verify-otp` | get an API key |
435
468
  | `vidfarm whoami` | `GET /api/v1/user/me` | who am I |
436
469
  | `vidfarm provider-keys` / `vidfarm add-provider-key <p> <secret>` | `GET`·`POST /api/v1/user/me/provider-keys` | manage AI keys |
437
- | `vidfarm upload <file> [--folder <path>]` | `POST /api/v1/user/me/temporary-files/upload` | upload → durable URL (ephemeral) |
470
+ | `vidfarm upload <file> [--folder <path>]` | `POST /api/v1/user/me/temporary-files/upload` | upload → durable URL (ephemeral; prefer `--folder temp` for scratch) |
438
471
  | `vidfarm download <url> [dest]` | (streams any URL to disk) | download media |
439
472
  | `vidfarm files [--folder <path>]` | `GET /api/v1/user/me/attachments` | list My Files assets + folders |
440
473
  | `vidfarm get-file <id> [dest] [--print]` | (resolve id → view_url, then stream/print) | read one My Files asset |
@@ -451,7 +484,7 @@ Each user has a persistent **My Files** filesystem — their own uploaded videos
451
484
  - **List** — `GET /api/v1/user/me/attachments` → `{ attachments: [{ id, fileName, contentType, sizeBytes, folderPath, viewUrl, createdAt }], folders: [string] }`. The `viewUrl` is a durable URL you can drop into a composition layer or pass into a primitive route. devcli: `vidfarm files [--folder <path>]`.
452
485
  - **Read one** — resolve it from the list and stream/read its `viewUrl`. devcli: `vidfarm get-file <id> [dest]` (add `--print` to print text contents of md/txt/csv/json inline). For images/video/audio/pdf, reference the `viewUrl` as media — you can't read their bytes as text.
453
486
  - **Write into My Files** — `POST /api/v1/user/me/attachments/upload` (multipart `file` + optional `folder_path`) persists a file into the library. devcli: `vidfarm put-file <localFile> --folder <path>`, or pipe/inline text without a temp file: `echo "…" | vidfarm put-file --stdin --as About.md --folder acme` / `vidfarm put-file --content "…" --as About.md`. This is how an agent saves Getting Started context (About.md, Interview.md, awareness-levels.md, persuasive-angles.md, ad-hooks.md) or drops a logo/product demo into a namescoped folder. In the web editor copilot the same capability is the **`browse_files` write action** (`action=write` with `file_name` + `content` + optional `folder_path`), which accepts text files (md/txt/csv/json/srt/vtt).
454
- - **`vidfarm upload` is different** — it targets the **ephemeral** temporary-files store (`POST /api/v1/user/me/temporary-files/upload`) for a throwaway durable URL to drop straight into a composition or approved post. Use `put-file` when you want the asset to persist in the user's My Files library; use `upload` for one-shot media you just need a URL for.
487
+ - **`vidfarm upload` is different** — it targets the **ephemeral** temporary-files store (`POST /api/v1/user/me/temporary-files/upload`) for a throwaway durable URL to drop straight into a composition or approved post. Use `put-file` when you want the asset to persist in the user's My Files library; use `upload` for one-shot media you just need a URL for. Namescope scratch uploads under a **`temp/` folder** (`vidfarm upload clip.mp4 --folder temp`) so they stay in one purgeable place. Better still, if you're on a `vidfarm serve` box, skip the upload entirely and reference the file off disk with `place --src ./clip.mp4` (see "Local file paths as media").
455
488
 
456
489
  **Assume My Files is multi-offer.** A user often runs more than one product, offer, brand, or region, and namescopes assets into folders accordingly — by product (`acme-skincare/`, `zensleep/`), by offer/campaign (`summer-sale/`), by region (`us/`, `eu/`), by asset type (`logos/`, `ugc-clips/`), or any arbitrary scheme. There is **no fixed layout** — read the `folders` tree first and reason about how this user organized things. Before pulling assets for a task, infer which folder(s) match the product/offer/region the current composition is about (match folder names to the composition title, video context, and what the user said), then scope reads to that folder so you never mix one brand's logo/product-shot/music into another brand's video. If the target offer is ambiguous, ask which product/offer/region (or which folder) this work is for rather than guessing across offers.
457
490
 
@@ -468,7 +501,9 @@ npx -y @mevdragon/vidfarm-devcli serve <template_id>
468
501
  # → auto-provisions a local user and opens the editor, pre-authed
469
502
  ```
470
503
 
471
- Everything runs locally (`RECORDS_DRIVER=local`, `STORAGE_DRIVER=local`) no AWS touched **except render**, which submits to the deployed cloud renderer when `VIDFARM_JOB_STATE_MACHINE_ARN` + `HYPERFRAMES_RENDER_*` are set (copy them from `.env.staging`); otherwise the editor's render button returns a clear `render_unavailable` message. Media referenced by the composition stays on its cloud URLs.
504
+ The **editor and its data** run locally (`RECORDS_DRIVER=local`, `STORAGE_DRIVER=local`) and rendering happens **in-process on this box for free**. Everything catalog-shaped still mirrors the cloud host ("cloud passthrough"): `/discover`, `/api/v1/videos`, and `/library` list the **cloud** catalog and the cloud account's approved posts next to anything local, opening a cloud template **seeds** its composition onto disk on demand, Add Template ingests into the cloud account, and approved-post actions (schedules, archive, delete) on cloud posts proxy through. Media a seeded composition already references stays on its cloud URLs — but you can also drop your **own local files** onto the timeline without any upload: `vidfarm place --src ./clip.mp4` copies the file into this box's disk store and references it by a `localhost/storage` URL (see "Local file paths as media" above), so the free in-process renderer plays it back with zero S3 involvement. Pass `--no-cloud` for a fully-offline box.
505
+
506
+ **Rendering from a serve box** — the editor's **Render** button becomes a popover with two options when a cloud `--api-key` is configured: **Render Local (Free)** (in-process HyperFrames render, no charge) and **Render in Cloud** (hands the render to the cloud renderer, billed to the cloud account's wallet). Over REST, pass `render_target: "cloud"` in the `POST /render` body; the local box resolves (or clones, once) a publishable cloud fork, remembers the mapping in the fork's `upstream-link.json`, and `GET /renders/:renderId` transparently proxies the cloud job status.
472
507
 
473
508
  How the loop works:
474
509
  - The composition lives on disk at `<data-dir>/storage/compositions/forks/<forkId>/working/composition.html` (default `<data-dir>` is `./.vidfarm-local`). Point your agent at that file.
@@ -477,7 +512,7 @@ How the loop works:
477
512
 
478
513
  Seeding from the cloud: `serve <template_id>` pulls the template's **default fork** (which may be another user's public decomposition); `serve --fork <id>` pulls a specific fork. Only the composition + records are localized — media stays on cloud URLs. Re-serving keeps local edits unless you pass `--refetch`.
479
514
 
480
- Flags: `--port` (default 3000), `--dir` (default `./.vidfarm-local`), `--key` (bootstrap/browser key, or `VIDFARM_API_KEY`), `--fork <id>`, `--host` (cloud host to pull from, default `https://vidfarm.cc`), `--api-key` (cloud key for the pull), `--refetch`, `--no-open`. `vidfarm <template_id>` is an alias for `serve <template_id>`.
515
+ Flags: `--port` (default 3000), `--dir` (default `./.vidfarm-local`), `--key` (bootstrap/browser key, or `VIDFARM_API_KEY`), `--fork <id>`, `--host` (cloud host to mirror + pull from, default `https://vidfarm.cc`), `--api-key` (cloud key for pulls, `/library`, and cloud render — defaults to `VIDFARM_API_KEY`), `--refetch`, `--no-cloud` (fully offline: no cloud catalog, seeding, or cloud render), `--no-open`. `vidfarm <template_id>` is an alias for `serve <template_id>`.
481
516
 
482
517
  To *analyze* the source media locally (videos, transcript, recurring cast), read the `video-context.json` / `cast.json` routes, or pull a media URL with `vidfarm download <url>`.
483
518
 
@@ -543,6 +578,25 @@ curl -X POST "$VIDFARM_BASE/api/v1/primitives/images/remove-background" \
543
578
  -d '{"source_image_url": "https://cdn.example.com/photo.jpg"}'
544
579
  ```
545
580
 
581
+ ## Primitive: video_remove_captions
582
+
583
+ Remove burned-in captions/subtitles/on-screen text from any video URL (GhostCut-powered — the same pipeline auto-decompose uses for its caption-free mirror, exposed as a standalone reusable primitive). Result is a durable caption-free MP4 stored at a Vidfarm URL.
584
+
585
+ - `POST /api/v1/primitives/videos/remove-captions` (alias: `POST /api/v1/primitives/remove-video-captions`)
586
+ - Body: `{ "tracer": "...", "payload": { "source_video_url": "https://..." }, "webhook_url"?: "..." }`
587
+ - Response: standard primitive job (`202 { job_id }`). The job probes the source, submits to GhostCut, polls to completion server-side (typically a few minutes), and mirrors the result. Poll `GET /api/v1/primitives/jobs/:jobId` until `status: "succeeded"`, then read `primary_file_url` / `video.file_url` (`captionsRemovedVideoUrl` in the output).
588
+ - Billing: `~$0.10 per 30 seconds` of source video (per-chunk, 1-chunk minimum; `ghostcut_subtitle_removal` cost center), plus the small probe cost.
589
+ - For the CURRENT fork's decompose flow, don't re-run this primitive — the caption-free mirror already exists via `GET /api/v1/compositions/:forkId/remove-video-captions`.
590
+
591
+ Example:
592
+
593
+ ```bash
594
+ curl -X POST "$VIDFARM_BASE/api/v1/primitives/videos/remove-captions" \
595
+ -H "vidfarm-api-key: $VIDFARM_API_KEY" \
596
+ -H "content-type: application/json" \
597
+ -d '{"tracer": "demo-remove-captions", "payload": {"source_video_url": "https://cdn.example.com/clip.mp4"}}'
598
+ ```
599
+
546
600
  ## Primitive: media_dedupe
547
601
 
548
602
  Apply subtle camouflage transforms (zoom, tilt, rotate, saturation, playback speed, contrast, brightness, hue rotate, blur, horizontal flip, tint overlay) to any source **image or video** URL. Useful when reposting existing media and you want small perceptual differences to reduce duplicate-detection.
@@ -635,7 +689,7 @@ Analyze a source video and identify where a product can be organically placed so
635
689
 
636
690
  - `POST /api/v1/primitives/brainstorm/product_placement`
637
691
  - Body: `{ "tracer": "...", "payload": { "source_video_url": "...", "offer_description": "...", "count"?: 8, "provider"?: "gemini" }, "webhook_url"?: "..." }`
638
- - `source_video_url` (required, URL) — the exact durable/public video to analyze. Aliases accepted: `video_url`, `source_url`, `url`. For a fork's composition, call `GET /api/v1/compositions/:forkId/ghostcut` first and pass the correct URL — the **original video** (`original_source_url`) or the **decomposed video** (`mirrored_url`).
692
+ - `source_video_url` (required, URL) — the exact durable/public video to analyze. Aliases accepted: `video_url`, `source_url`, `url`. For a fork's composition, call `GET /api/v1/compositions/:forkId/remove-video-captions` first and pass the correct URL — the **original video** (`original_source_url`) or the **decomposed video** (`mirrored_url`).
639
693
  - `offer_description` (required) — the product/offer to place. Aliases: `offer`, `description`, `details`.
640
694
  - `count` (optional, 3–30, default 8) — number of opportunities; only send when the user asks for a specific number.
641
695
  - Response: standard primitive job. Poll `GET /api/v1/primitives/jobs/:jobId` to completion, then read `result.json` (also surfaced on the job output as `opportunities[]`), where each entry has `timestamp`, `scene`, `placement_type`, `placement_idea`, and `why_it_works`.
package/SKILL.platform.md CHANGED
@@ -82,6 +82,8 @@ Additional prefixes:
82
82
  - `submissions/<videoId>/...` — raw source video uploads
83
83
  - `template-assets/<slug>/...` — template media
84
84
  - `user-attachments/<customerId>/...` — chat panel uploads (only ownership-keyed area remaining)
85
+ - `users/<customerId>/temporary/<folder>/<fileId>/<name>` — ephemeral temp-store uploads (`POST /me/temporary-files/upload`, 90-day TTL). The devcli namescopes throwaway media under a `temp/` folder here so scratch assets stay quarantined and purgeable rather than polluting the durable library.
86
+ - `users/local-media/<uuid>/<name>` — **local-only**, never on S3. On a `vidfarm serve` box (`STORAGE_DRIVER=local`), `vidfarm place --src ./file` copies a local media file into the box's disk store under this key and references it by the box's own `http://localhost:<port>/storage/<key>` URL, so power users can bulk-build compositions from local assets and render in-process for free without uploading anything to S3. These localhost URLs only resolve against the serve box that holds the file (local render/preview only). See `importLocalMediaToServeStore` / `resolvePlaceableMediaSrc` in `src/cli.ts`.
85
87
 
86
88
  Old ownership-keyed composition paths (`compositions/users/<customerId>/forks/...`) were migrated away by `scripts/migrate-compositions-to-ownership-agnostic.ts`. Old `decomposition.json` was renamed to `composition.json` by `scripts/migrate-decomposition-to-composition.ts`. Forks written before the manifest rollout won't have `manifest.json` until their next mutation — reads must tolerate 404.
87
89
 
@@ -176,7 +178,7 @@ The same runtime is used inside the Trackpad Editor iframe AND when a published
176
178
  - Bidirectionally syncs timeline drag → HTML `data-start`/`data-duration` mutations, and inspector edits → text/style changes
177
179
  - Persists every commit back to the API as PUT `composition.html` and PATCH `composition.json`
178
180
  - Exposes an **Editor Action API** so the Chat panel can invoke 13 typed actions (`add_layer`, `remove_layer`, `set_layer_start`, `set_layer_duration`, `set_layer_style`, `group_layers`, `ungroup_layers`, `replace_composition_html`, etc.)
179
- - Runs the composition-runtime script inside the preview iframe to keep timeline scrub in sync with the current time
181
+ - Runs the composition-runtime script inside the preview iframe to keep timeline scrub in sync with the current time. That runtime also scrubs **Ken Burns** image animations (CSS pan/zoom on `[data-kenburns]` `<img>` layers) to the playhead, so the preview matches the render frame-for-frame. Presets and keyframes live in `src/hyperframes/composition.ts` (`KEN_BURNS_CSS`), mirrored into the editor bundle and the serve-time HTML rewrite.
180
182
 
181
183
  The editor is a single monolithic React component (~9k lines). It builds cleanly to a static bundle deployed to CloudFront under `/editor/`.
182
184
 
@@ -221,9 +223,11 @@ GhostCut is an external AI subtitle-removal service. Vidfarm proxies to it via `
221
223
  - `pollStatus(taskId)` — check status (`pending` | `processing` | `queued` | `done` | `failed`)
222
224
  - `mirrorGhostcutVideoToStorage()` — copy the clean output back to Vidfarm S3
223
225
 
224
- State is persisted per fork at `compositions/forks/<forkId>/working/ghostcut.json`. The client polls `POST /api/v1/compositions/:forkId/ghostcut-poll` every few seconds during smart decompose.
226
+ State is persisted per fork at `compositions/forks/<forkId>/working/ghostcut.json`. The client polls `POST /api/v1/compositions/:forkId/remove-video-captions-poll` (legacy alias: `/ghostcut-poll`) every few seconds during smart decompose.
225
227
 
226
- Read-only companion: `GET /api/v1/compositions/:forkId/ghostcut` returns `{ status, task_id, original_source_url, mirrored_url, submitted_at_ms, completed_at_ms, failure_reason }` without touching GhostCut. Non-billing. Exposed for two reasons: (1) the editor chat harness / AI copilot uses it to look up both video URLs on demand so downstream primitive calls can pick raw-upload vs caption-free-mirror without hardcoding either in chat context; (2) it's `http_request`-allowlisted under `COMPOSITIONS_PREFIX` in `src/app.ts`, which is why the editor chat system prompt (`src/editor-chat.ts`) directs the model to call it before feeding a video into a primitive route. The AI is instructed **not** to call `POST /ghostcut-poll` unless the user explicitly asks to advance subtitle removal.
228
+ Read-only companion: `GET /api/v1/compositions/:forkId/remove-video-captions` (legacy alias: `/ghostcut`) returns `{ status, task_id, original_source_url, mirrored_url, submitted_at_ms, completed_at_ms, failure_reason }` without touching GhostCut. Non-billing. Exposed for two reasons: (1) the editor chat harness / AI copilot uses it to look up both video URLs on demand so downstream primitive calls can pick raw-upload vs caption-free-mirror without hardcoding either in chat context; (2) it's `http_request`-allowlisted under `COMPOSITIONS_PREFIX` in `src/app.ts`, which is why the editor chat system prompt (`src/editor-chat.ts`) directs the model to call it before feeding a video into a primitive route. The AI is instructed **not** to call `POST /remove-video-captions-poll` unless the user explicitly asks to advance subtitle removal.
229
+
230
+ Standalone primitive: `POST /api/v1/primitives/videos/remove-captions` (alias: `POST /api/v1/primitives/remove-video-captions`; id `primitive:video_remove_captions` in `src/primitive-registry.ts`) exposes the same GhostCut pipeline for ANY video URL, decoupled from forks and decompose. The job probes the source (duration prices the task), submits, polls GhostCut to completion inside the job runner (12-minute budget, vs the fork routes where the client drives completion), mirrors `captions-removed.mp4` into primitive storage, and bills the `ghostcut_subtitle_removal` cost center per 30s chunk.
227
231
 
228
232
  Configuration: `GHOSTCUT_KEY` and `GHOSTCUT_SECRET` env vars per stage. Pass-through cost: `GHOSTCUT_USD_PER_30S` (default 0.10).
229
233
 
@@ -378,13 +382,15 @@ vidfarm-devcli serve <template_id>
378
382
  vidfarm-devcli serve --fork <forkId> --host https://vidfarm.cc --api-key <key>
379
383
  ```
380
384
 
381
- Flags: `--port` (3000), `--dir` (`./.vidfarm-local`), `--key` (local bootstrap/browser key, or `VIDFARM_API_KEY`), `--fork`, `--host`, `--api-key` (cloud pull key), `--refetch`, `--no-open`. `vidfarm <template_id>` aliases `serve <template_id>`.
385
+ Flags: `--port` (3000), `--dir` (`./.vidfarm-local`), `--key` (local bootstrap/browser key, or `VIDFARM_API_KEY`), `--fork`, `--host`, `--api-key` (cloud key for pulls, /library, and cloud render), `--refetch`, `--no-cloud`, `--no-open`. `vidfarm <template_id>` aliases `serve <template_id>`.
386
+
387
+ **Cloud passthrough** (`src/services/upstream.ts`; on unless `--no-cloud`): only the editor's data is local — the box mirrors the upstream host (`VIDFARM_UPSTREAM_HOST` + `VIDFARM_UPSTREAM_API_KEY`, set by serve from `--host`/`--api-key`). `/discover/feed` and `GET /api/v1/videos` merge the upstream catalog behind local entries (dedup by id); `/library` and `GET /api/v1/approved/posts` merge the cloud account's approved posts, and `:postId` actions on posts that don't resolve locally proxy through (`maybeProxyUnknownReadyPostToUpstream`); `POST /discover/templates` (Add Template) proxies so ingest lands in the cloud account — its `/upload/presign` sibling proxies too, so file uploads PUT straight to cloud S3 (the multipart fallback `/discover/templates/upload` never proxies: the proxy is text-only; a locally-stored upload key is the one Add Template body handled locally); `/editor/:templateId` seeds an unknown template/fork from upstream on first open (`seedFromUpstream` — same path as boot seeding, cloud fork id reused locally). The editor boot gains `localRender` + `cloudRenderAvailable`, which turn the Render button into a popover: **Render Local (Free)** vs **Render in Cloud**. `render_target: "cloud"` on `POST /render` resolves (or clones, once) a publishable upstream fork (mapping persisted in the fork's working `upstream-link.json`), starts the render upstream with the submitted html, writes a `cloud-render-<renderId>.json` marker, and `GET /renders/:renderId` proxies status through that marker. serve force-clears `VIDFARM_JOB_STATE_MACHINE_ARN` so local renders never silently route to a cloud state machine picked up from a dev-shell `.env`.
382
388
 
383
- **Architecture** (fully local; render is the only cloud dependency):
389
+ **Architecture** (fully local editor; cloud passthrough is read/proxy-only):
384
390
  - **Records/auth** — `src/services/local-dynamo.ts` is a drop-in `.send()` shim for the DynamoDB DocumentClient used by `serverless-records.ts` + `serverless-auth.ts`, servicing Get/Put/Delete/Query/Scan/Update against JSON files under `<data-dir>/dynamo/<table>/`. Selected by `config.RECORDS_DRIVER` (`z.enum(["local","dynamo"]).default("dynamo")` — deployed envs are unaffected). The 25 call sites are unchanged; only the client construction switches.
385
- - **Storage** — `StorageService` already writes to disk when `STORAGE_DRIVER=local` / `AWS_S3_BUCKET=""` (files under `<data-dir>/storage/`). Composition public URLs become `${PUBLIC_BASE_URL}/storage/<key>`.
391
+ - **Storage** — `StorageService` already writes to disk when `STORAGE_DRIVER=local` / `AWS_S3_BUCKET=""` (files under `<data-dir>/storage/`). Composition public URLs become `${PUBLIC_BASE_URL}/storage/<key>`. This is also what makes **local file paths as media** free: `vidfarm place --src ./file` against a serve working copy copies the file to `<data-dir>/storage/users/local-media/<uuid>/<name>` and references the box's own `/storage/<key>` URL — no S3, the in-process renderer fetches it over localhost (`resolvePlaceableMediaSrc` in `src/cli.ts` detects the serve tree structurally). Off a serve box, `place`/`approve` fall back to uploading a local file to the ephemeral temp store under a `temp/` folder.
386
392
  - **Auth** — no seeding: `VIDFARM_API_KEY` + the existing `tryBootstrapFromEnv` auto-provisions a paid customer; `serve` opens `/auto-login?api_key=…&redirect=/editor/…` to plant the session cookie so the browser lands pre-authed.
387
393
  - **Live push** — `src/services/composition-watch.ts` (`fs.watch` on the forks dir, only when `STORAGE_DRIVER=local`) coalesces bursts (80ms) and broadcasts `{ forkId, file }` to `GET /api/v1/dev/events` (Hono `streamSSE`, cookie-authed). The editor opens an `EventSource` there gated on the boot flag `liveReload`, and on a `reload` for its fork refetches `composition.html` and runs `applyExternalCompositionHtml` → `morphLiveCompositionDom` (keyed on `data-hf-id`; media buffers + playback survive). The composition write routes call `compositionWatch.suppressFork(forkId)` so the editor's own `PUT` doesn't echo.
388
394
  - **Cloud-seed** — `serve` pulls the composition for `--fork` (or the template's default fork via `resolveForkId`, which follows the cloud `/editor` redirect and grabs even another user's public decomposition), then materializes a local template + fork record and writes the composition to the working key so `/editor/:templateId?fork=:id` resolves offline. Media stays on cloud URLs.
389
- - **Render** — cloud only (no local job runner; hyperframes render is a Lambda fan-out). `publishCompositionToRenderer` returns `render_unavailable` (501) when `VIDFARM_JOB_STATE_MACHINE_ARN` is unset; wire the `HYPERFRAMES_RENDER_*` + state-machine env from `.env.staging` to submit to the deployed renderer.
395
+ - **Render** — in-process on this box (free, unguarded `runPrimitiveJobInProcess` + headless-Chromium HyperFrames). Cloud rendering is the explicit `render_target: "cloud"` upstream handoff described above; the ambient `VIDFARM_JOB_STATE_MACHINE_ARN` is force-cleared by serve.
390
396
  - Lambdas — `infra/lambda/*.ts`