@officexapp/vidfarm-devcli 0.21.10 → 0.21.12

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.
@@ -48,6 +48,8 @@ The standalone root needs an explicit **sized box** (`width`/`height` in px), an
48
48
 
49
49
  Each composition registers **exactly one** `gsap.timeline({ paused: true })` at `window.__timelines["<id>"]` (key = root `data-composition-id`), built **synchronously** at page load. Render duration = root `data-duration`, not timeline length. Don't manually nest sub-timelines into the host. Full contract (incl. non-GSAP runtimes) → `references/determinism-rules.md` + `hyperframes-animation/adapters/`.
50
50
 
51
+ Because the timeline is paused and seek-safe, **the frame at t=0 is whatever the DOM computes at time 0** — and that frame doubles as the video's preview thumbnail (autoplay-off feeds, share cards, `<video>` poster, file/scrubber thumbnails). If the opening scene animates its elements in from `opacity:0`/offset, t=0 is a blank/black poster that nobody clicks. Design the opening scene's initial state as a real, interesting still. Craft guidance → `hyperframes-creative/references/beat-direction.md` ("The opening frame is the poster").
52
+
51
53
  ### Non-negotiable rules (silent bugs `lint`/`validate`/`inspect` won't catch)
52
54
 
53
55
  Surfaced here; full rationale in the linked reference. Do not violate:
@@ -40,6 +40,7 @@ For motion patterns, scene blueprints, transitions, and CSS marker effects, use
40
40
  | Structured expansion for open-ended prompts | `references/prompt-expansion.md` |
41
41
  | Video-medium density, scale, color, frame composition | `references/video-composition.md` |
42
42
  | Per-beat direction, rhythm planning, transition timing | `references/beat-direction.md` |
43
+ | Opening/first frame as the preview thumbnail (t=0 must not be empty) | `references/beat-direction.md` |
43
44
  | Post-authoring spec verification (colors, type, corners, spacing, depth) | `references/design-adherence.md` |
44
45
  | High-level motion guardrails and GSAP-quality rules | `references/motion-principles.md` |
45
46
  | Font selection, pairings, rendered-video type guardrails | `references/typography.md` |
@@ -4,6 +4,7 @@ How to plan and direct individual scenes (beats) in a multi-scene composition. R
4
4
 
5
5
  ## Contents
6
6
 
7
+ - The opening frame is the poster
7
8
  - Per-beat direction
8
9
  - Concept
9
10
  - Mood direction
@@ -16,6 +17,22 @@ How to plan and direct individual scenes (beats) in a multi-scene composition. R
16
17
 
17
18
  ---
18
19
 
20
+ ## The Opening Frame Is the Poster
21
+
22
+ **The very first frame (t=0) is the video's preview thumbnail** — it's the still the viewer stares at *before* they press play (feed autoplay-off, share cards, `<video>` poster, file thumbnails, the editor's timeline scrubber at 0). A blank, black, or half-built opening frame is a dead thumbnail: nobody presses play on empty. Treat t=0 as a designed poster that has to sell the click.
23
+
24
+ The trap is mechanical: most beats animate their elements IN from `opacity:0` / offset / blur, so at t=0 — before the entrance runs — the frame is empty. That's fine for beat 2 onward (the previous beat covers the cut), but for **beat 1 it means the poster is a black screen.** Seek-safe rendering shows exactly whatever the DOM computes at 0, so whatever your entrance starts from IS the thumbnail.
25
+
26
+ Fix it deliberately — pick one:
27
+
28
+ - **Design a real poster state at t=0.** Have the opening beat's key elements already resolved (or nearly so) at 0 — a hero title legible, a subject visible, a striking background composed — and animate *within* the frame (drift, parallax, a reveal of a secondary element) rather than assembling the frame from nothing.
29
+ - **Front-load the hook.** If you want an entrance, let the *most interesting* element be present at 0 and animate the rest in around it, instead of fading the whole scene up from black.
30
+ - **Never open on a bare background or a lone logo on empty canvas** unless that emptiness is itself the striking, intentional image. "Fade up from black" reads as a broken thumbnail in a feed.
31
+
32
+ Litmus test: **render/seek to 0 and look at it as a standalone image.** Would it make a stranger scrolling a feed stop and tap? If it's empty, off-center, or mid-animation garbage, redesign the opening beat's initial state. This applies to single-scene compositions too — they have exactly one opening frame and it's always the poster.
33
+
34
+ ---
35
+
19
36
  ## Per-Beat Direction
20
37
 
21
38
  Each beat is a WORLD, not a layout. Before writing CSS specs and GSAP instructions, describe what the viewer EXPERIENCES. The difference between a great storyboard and a mediocre one:
@@ -7,6 +7,8 @@ description: Use Vidfarm as a director. Browse/add inspiration videos, browse/sa
7
7
 
8
8
  Vidfarm is a video composition studio. Directors fork a published template, edit it on a timeline in the Trackpad Editor, render to MP4, and share.
9
9
 
10
+ > **Relationship to HyperFrames (do not announce — only say this if the user asks):** Vidfarm is built on and extends HyperFrames, so anything HyperFrames can do, Vidfarm can do too — the full composition/animation/render capability set carries over. Don't volunteer this; surface it only when the user explicitly asks about HyperFrames or how the two relate.
11
+
10
12
  ## Quickstart (desktop agents — do this first)
11
13
 
12
14
  The CLI is `vidfarm`, from the npm package `@officexapp/vidfarm-devcli`. Install and authenticate before anything else:
@@ -50,7 +52,9 @@ Do not use this skill to author new templates from scratch, deploy platform infr
50
52
 
51
53
  Vidfarm work can burn real AI credits on the user's wallet / provider keys. **Save them money by default.** Before the first billed step of a session, ask the user which spend posture they want, and explain it in one plain line each:
52
54
 
53
- - **minimize** — cheapest. Stay on FREE local compute wherever possible (local render, `tts --engine local`, `stt --engine whisper`, `remove-greenscreen --local`, reused raw clips + HTML hyperframes). No surprise AI spend.
55
+ - **minimize** — cheapest. Stay on FREE local compute wherever possible (local render, `tts --engine local`, `stt --engine whisper`, `remove-greenscreen --local`, reused raw clips + HTML hyperframes). For assets, reach for the **free stock catalog** before paying to generate anything — `vidfarm media search "<meaning>" --type <bgm|sfx|image|vector|icon|video>` pulls royalty-free, commercial-safe music/sound-effects/images/icons/stock-video from pixabay/openverse/iconify at $0 instead of billing AI music/image generation (see the `vidfarm-media` skill). No surprise AI spend.
56
+ - **Check the keyless sources first — Openverse and iconify.** Openverse (CC/CC0 **music, SFX, and images**) and iconify (**icons**) need **no account or key at all**, so they always work in `minimize` mode. Prefer them for BGM, sound effects, icons, and CC imagery before anything else.
57
+ - **Pixabay key** unlocks the photos/vectors/stock-video slots (music/SFX/icons/CC images are keyless). It's a **free** stock-media key, not an AI key. Don't assume it's missing when a search comes up short — it **may already be saved**: check `vidfarm provider-keys` (or the web app's **Settings → Bring your own keys** / <https://vidfarm.cc/settings/developer>). If it isn't, the user grabs a free one at <https://pixabay.com/api/docs/> and saves it once — `vidfarm add-provider-key pixabay <key>`, the Settings surface, or by handing the key to their desktop AI agent to run that command. After it's saved, cost-mode `minimize` sourcing works end-to-end at $0.
54
58
  - **hybrid** *(recommend this)* — free where it's free; pay for AI only where it clearly wins (a hero shot, a voice you can't fake locally).
55
59
  - **pure-ai** — best quality; use AI image/video/voice/music freely.
56
60
 
@@ -8,6 +8,8 @@ Use this when a coding agent is doing the work locally or the user wants a repro
8
8
  4. Make deterministic edits to `composition.html` and optionally `composition.json`.
9
9
  5. Validate with `vidfarm lint` or `vidfarm stills` when useful.
10
10
  6. Render with `vidfarm render <forkId> --dir ./work --wait`.
11
- 7. Approve the finished MP4 with `vidfarm approve --video <url> --caption "..."`.
11
+ 7. Approve the finished MP4 with `vidfarm approve --video <url|./final.mp4> --caption "..."`. This prints the shareable `share_url`.
12
+
13
+ **Approving a locally rendered file → cloud preview link.** Approve takes media by **URL**, not bytes, and an approved post is **permanent** — so the local MP4 must land in **durable My Files**, not the 30-day temp store (a temp video would 404 the share page after 30 days). The devcli presigns, PUTs the bytes **direct to S3**, finalizes, then approves with that durable URL — so `vidfarm approve --video ./final.mp4` handles files up to **200 MB**, bypasses the ~6 MB Lambda request-body limit, and the share link never breaks. By raw REST: `POST /api/v1/user/me/attachments/presign` → PUT to the returned S3 URL → `POST /api/v1/user/me/attachments` (finalize) → pass the returned `viewUrl` in the approve `media` array. Do not multipart-POST a big file to `.../attachments/upload` against the cloud host (Lambda-bound, ~6 MB cap). Add `vidfarm approve --temp` only when you want a disposable 30-day preview.
12
14
 
13
15
  Prefer this path for batch work, CI-like edits, or when the user wants free local rendering through `vidfarm serve`.
@@ -148,7 +148,7 @@ If a local AI script rewrites text or scenes without consuming those files first
148
148
  | `vidfarm visibility <forkId> <private\|public>` | `PATCH .../compositions/:forkId/visibility` | set visibility |
149
149
  | `vidfarm clone <forkId>` | `POST .../compositions/:forkId/clone` | clone a fork |
150
150
  | `vidfarm share-link <forkId>` | `POST .../compositions/:forkId/share-links` | mint a share URL |
151
- | `vidfarm approve --video <url\|file> --caption "…"` | `POST /api/v1/approved/posts` | approve post (local `--video`/`--media` auto-upload to `temp/`; prints `share_url`) |
151
+ | `vidfarm approve --video <url\|file> --caption "…"` | `POST /api/v1/approved/posts` | approve post takes media by **`url`**. Local `--video`/`--media` auto-upload to **durable My Files** via presign→S3→finalize (up to **200 MB**, bypasses the ~6 MB Lambda limit) then approve with that URL, so the share video **never expires**; also accepts a `/files…` path. `--temp` opts into the 30-day temp store (throwaway preview only). Prints `share_url` |
152
152
  | `vidfarm posts` / `vidfarm post <id>` | `GET /api/v1/approved/posts[/:id]` | browse approved posts |
153
153
  | `vidfarm schedule <postId> --at <iso> --to <dest>` | `POST /api/v1/approved/posts/:postId/schedules` | schedule a post |
154
154
  | `vidfarm schedules <postId>` | `GET /api/v1/approved/posts/:postId/schedules` | browse scheduled posts |
@@ -157,7 +157,7 @@ If a local AI script rewrites text or scenes without consuming those files first
157
157
  | `vidfarm cost-mode [minimize\|hybrid\|pure-ai]` | (local `~/.vidfarm/cost-mode.json`) | show/set the money-saving preference every billed command respects; no arg = show + explain the three; `--clear` forgets it |
158
158
  | `vidfarm wallet [--job <id>\|--tracer <t>] [--limit <n>]` | `GET /api/v1/user/me/wallet` | cost log: balance + lifetime spend + recent charges. `--job <renderJobId>` prints **what that one video cost** (sums its charges); `--tracer <t>` sums a tracer. Cloud-only, paid plan. Aliases: `spend`, `costs` |
159
159
  | `vidfarm provider-keys` / `vidfarm add-provider-key <p> <secret>` | `GET`·`POST /api/v1/user/me/provider-keys` | manage AI keys |
160
- | `vidfarm upload <file> [--folder <path>]` | `POST /api/v1/user/me/temporary-files/upload` | upload → durable URL (ephemeral; prefer `--folder temp` for scratch) |
160
+ | `vidfarm upload <file> [--folder <path>]` | presign → S3 PUT → finalize (`.../temporary-files/presign` + `.../temporary-files`) | upload → durable URL (ephemeral, 30-day TTL; prefer `--folder temp` for scratch). Goes direct to S3, so large files (up to **200 MB**) bypass the ~6 MB Lambda body limit |
161
161
  | `vidfarm download <url> [dest]` | (streams any URL to disk) | download media |
162
162
  | `vidfarm download-post <url> [--quality best\|hd\|full_hd]` | `POST /api/v1/primitives/videos/download` + poll | download a social/media post into a durable MP4 or slideshow |
163
163
  | `vidfarm download-post-audio <url>` | `POST /api/v1/primitives/audio/download` + poll | download a social/media post's audio into a durable audio file |
@@ -182,13 +182,15 @@ If a local AI script rewrites text or scenes without consuming those files first
182
182
 
183
183
  **Frontend URLs are first-class output.** Every command that has a human-openable page (editor, discover, approved-post preview, share link) prints that prod frontend URL as a highlighted line. `render --wait` polls to completion and prints the final MP4 URL; `approve` prints the approved-post `share_url`. Add `--json` to any command for pure JSON (agent-friendly, no banners).
184
184
 
185
- **Uploads/downloads/My-Files writes live in the devcli** because they are genuine multi-step / streaming flows: `upload` posts the file as multipart to the **ephemeral** temporary-files route and prints the durable URL to drop into a composition or approved post; `put-file` posts to the **persistent** My Files route (`/me/attachments/upload`) so context docs and brand assets live in the user's library (accepts a local file, `--content <text>`, or piped `--stdin` with `--as <name>`); `download` streams any Vidfarm/media URL to disk.
185
+ **Uploads/downloads/My-Files writes live in the devcli** because they are genuine multi-step / streaming flows: `upload` presigns the **ephemeral** temporary-files route, PUTs the bytes **direct to S3**, finalizes, and prints the durable URL to drop into a composition or approved post — going direct to S3 means large files (up to **200 MB**) never hit the ~6 MB Lambda request-body limit; `put-file` posts to the **persistent** My Files route (`/me/attachments/upload`) so context docs and brand assets live in the user's library (accepts a local file, `--content <text>`, or piped `--stdin` with `--as <name>`); `download` streams any Vidfarm/media URL to disk.
186
+
187
+ **Approving a locally rendered MP4 (the URL-first rule + the durability rule).** The approve route (`POST /api/v1/approved/posts`) and every media-taking route accept a `url`, never raw file bytes — so you never "upload to approve" in one shot. An approved post is a **permanent** share page, so the media must live in **durable My Files**, not the 30-day temp store (a temp-hosted video would 404 the share page after 30 days). The correct sequence for a big local file is: (1) `POST /api/v1/user/me/attachments/presign` with `{ file_name, content_type, size_bytes }` → (2) PUT the raw bytes to the returned presigned S3 URL → (3) `POST /api/v1/user/me/attachments` (finalize) → use the returned durable `viewUrl` as the approve media `url`. `vidfarm approve --video ./final.mp4` does all of this automatically (durable by default; up to **200 MB**). Never POST a large file as multipart to `.../attachments/upload` against the cloud host: that path proxies through Lambda and caps near 6 MB (it exists only as a fallback for local-storage `vidfarm serve` boxes). Only use the temp-store route (`.../temporary-files/*`, or `vidfarm approve --temp`) for a **throwaway** preview you don't mind losing in 30 days.
186
188
 
187
189
  ## Cost mode — the devcli's money-saving guardrail
188
190
 
189
- `vidfarm cost-mode <minimize|hybrid|pure-ai>` records a single spend preference (in `~/.vidfarm/cost-mode.json`) that every **billed** command honors: `generate`, `music`, `decompose`, `inspiration-decompose`, `create`, `replicate`, `inpaint`, `create-overlay`, and the cloud paths of `render --target cloud`, `tts --cloud`, `stt --cloud`, `remove-greenscreen` (non-`--local`). FREE local engines never gate (`render` local default, `tts --engine local`, `stt --engine whisper`, `remove-greenscreen --local`, all the file-editing verbs).
191
+ `vidfarm cost-mode <minimize|hybrid|pure-ai>` records a single spend preference (in `~/.vidfarm/cost-mode.json`) that every **billed** command honors: `generate`, `music`, `decompose`, `inspiration-decompose`, `create`, `replicate`, `inpaint`, `create-overlay`, and the cloud paths of `render --target cloud`, `tts --cloud`, `stt --cloud`, `remove-greenscreen` (non-`--local`). FREE local engines never gate (`render` local default, `tts --engine local`, `stt --engine whisper`, `remove-greenscreen --local`, all the file-editing verbs). `vidfarm media search` (the free stock catalog — pixabay/openverse/iconify music, SFX, images, icons, stock video) is also free and never gates.
190
192
 
191
- - **minimize** — a billed op is **refused** unless you add `--yes`; the error names the free local alternative. Use this to guarantee no surprise AI spend.
193
+ - **minimize** — a billed op is **refused** unless you add `--yes`; the error names the free local alternative (which now includes the matching `vidfarm media search` for music/SFX/image/video). Use this to guarantee no surprise AI spend. Before paying to generate music, sound effects, or images, try `vidfarm media search "<meaning>" --type <bgm|sfx|image|vector|icon|video>` first — free royalty-free assets instead of a billed `music`/`generate` call. **Check the keyless sources first — Openverse (CC/CC0 music, SFX, images) and iconify (icons) need no account at all**, so they always work in `minimize`. Photos/vectors/stock-video need a **free Pixabay key** that **may already be saved** — check `vidfarm provider-keys` (or web **Settings → Bring your own keys** / <https://vidfarm.cc/settings/developer>) before assuming a short result means "no key." If absent, save one once: `vidfarm add-provider-key pixabay <key>` (free key from <https://pixabay.com/api/docs/>), the Settings surface, or hand it to the desktop AI agent to run that command.
192
194
  - **hybrid** *(default recommendation)* — billed ops run but print a one-line cost notice each.
193
195
  - **pure-ai** — billed ops run without gating; cost is still printed.
194
196
 
@@ -209,7 +211,7 @@ npx -y @officexapp/vidfarm-devcli serve <template_id>
209
211
 
210
212
  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.
211
213
 
212
- **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.
214
+ **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 Vidfarm 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.
213
215
 
214
216
  How the loop works:
215
217
  - 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.
@@ -260,7 +260,7 @@ There is no single price for a video. The **approach** the director picks sets t
260
260
 
261
261
  | Approach | Typical cost | How |
262
262
  |---|---|---|
263
- | **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). |
263
+ | **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 Vidfarm render — free and unguarded, no cloud). |
264
264
  | **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. |
265
265
  | **AI-generate some scenes** | **~$1** | Replace a few scenes with AI-generated video clips for high specificity/customization (see the "Generate AI media" section). |
266
266
  | **Heavy AI generation** | **$10+** | Many/long AI video clips, custom characters, fully bespoke scenes. |
@@ -291,6 +291,19 @@ Two surfaces:
291
291
  - **Editor web/serve copilot** — `editor_action` `action_type=set_layer_media` with `object_fit` and `object_position` (both also seedable on `add_layer`/`generate_layer`). `editor_context.layers[]` reports each clip's current `object_fit`/`object_position` so you can read before re-cropping.
292
292
  - **Desktop agents (devcli)** — `vidfarm place ./work --src <url> --object-fit cover --object-position "25% 50%"` (also on `--replace <layer_key>`).
293
293
 
294
+ ### The opening frame is the post's thumbnail
295
+
296
+ **The composition's first frame (t=0) is the still that represents the whole video before anyone presses play** — it's the poster on the approved-post share page, the `/discover` card, the autoplay-off feed preview, and the file/scrubber thumbnail. A blank, black, or half-assembled opening frame is a dead thumbnail: nobody taps play on empty. Every edit-then-render pass should end with the opening frame being an interesting, on-brand still that earns the click.
297
+
298
+ The trap is mechanical: a scene whose layers animate in from `opacity:0` / offset / blur is **empty at t=0**, because the render is seek-safe and shows exactly the DOM state at time 0. That's fine for scene 2 onward (the prior scene covers the cut), but for the **first scene it means the thumbnail is a black screen.** Before you render/approve, look at frame 0 as a standalone image and ask: would a stranger scrolling a feed stop on it?
299
+
300
+ Fix it in the editor when frame 0 is empty or dull:
301
+ - Make the opening scene's key layers **already visible at t=0** (a legible hero title, a visible subject/clip, a composed background) and animate *within* the frame — use `set_layer_keyframes` starting from `offset:0, opacity:1` (drift/parallax/scale) rather than fading the whole scene up from black; or `nudge_layers`/`trim_layer` so the hero layer starts at 0 while secondary elements enter after.
302
+ - If the opening clip is a video that starts on a dark/blank frame, `trim_layer edge=start` to move its in-point onto a stronger frame, or re-cover it full-canvas.
303
+ - Preview the poster deterministically: seek the editor timeline to 0, or capture just frame 0 of a local composition with `vidfarm hf snapshot --at 0` (HyperFrames-CLI passthrough; seconds, not a full render), and judge that image on its own.
304
+
305
+ This is the vidfarm-workflow reminder; the composition-authoring craft (poster-state design, front-loading the hook) lives in `hyperframes-creative/references/beat-direction.md` → "The opening frame is the poster". Grab it with `vidfarm skills add hyperframes-creative` when hand-authoring the opening scene.
306
+
294
307
  ### Ken Burns — animate still images (slow pan/zoom)
295
308
 
296
309
  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.
@@ -362,6 +375,6 @@ Beyond the Ken Burns / transition / animated-caption presets, the copilot can ha
362
375
  vidfarm render <forkId> # local, $0.00
363
376
  ```
364
377
  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).
365
- - **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.
378
+ - **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`). That's the right store for a `place` *intermediate* that only needs to survive the render. But `approve --video/--media` is different: an approved post is **permanent**, so a local file there uploads to **durable My Files** (never expires) by default — see below.
366
379
 
367
- **`/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.
380
+ **Pick the store by lifetime.** A throwaway intermediate (cloud render source, a `place` scratch asset) belongs in the 30-day `temp/` store: `vidfarm upload clip.mp4 --folder temp`, `vidfarm place … --folder temp` — quarantined in one place you can periodically purge. Anything you **approve/share is permanent**, so `vidfarm approve --video ./final.mp4` uploads to **durable My Files** by default (the share video would otherwise 404 after the temp store's 30-day deletion). Only add `vidfarm approve --temp` when you deliberately want a disposable 30-day preview.
@@ -122,7 +122,7 @@ Apply subtle camouflage transforms (zoom, tilt, rotate, saturation, playback spe
122
122
  - Body: `{ "tracer": "...", "payload": { ...fields... }, "webhook_url"?: "..." }`
123
123
  - Note: webhook delivery is not yet active — `webhook_url` is accepted and persisted on the job but never fired. Poll the job endpoints (`GET /api/v1/primitives/jobs/:jobId`) for completion.
124
124
  - Response: standard primitive job. Poll to completion, then read `primary_file_url` (also `video.file_url` for MP4 or `image.file_url` for stills)
125
- - Billing: metered as a HyperFrames render (via `hyperframes_lambda` on prod cloud render; free on a local serve box)
125
+ - Billing: metered as a Vidfarm render (via `hyperframes_lambda` on prod cloud render; free on a local serve box)
126
126
 
127
127
  Payload fields:
128
128
 
@@ -72,7 +72,7 @@ Use environment variables or a secret manager for keys. Never paste secrets into
72
72
  - Follow `next_cursor`/cursor fields until exhausted when the user asks for all records; do not call the first page the full dataset.
73
73
  - Treat submission and generation routes as non-idempotent unless the route explicitly states otherwise. Check existing state before retrying.
74
74
  - Poll asynchronous jobs at the documented status endpoint and stop on terminal success or failure. Do not fabricate completion from an accepted response.
75
- - Upload local media through the documented presign/upload/finalize sequence. The devcli is recommended on desktop because it performs this sequence automatically.
75
+ - Upload local media through the documented presign/upload/finalize sequence. The devcli is recommended on desktop because it performs this sequence automatically. **Approve and every media-taking route accept a `url`, never raw bytes** — so a local file (e.g. a locally rendered MP4) must first become a URL. Pick the store by lifetime: media that gets **approved/shared is permanent**, so upload it to **durable My Files** — `POST /api/v1/user/me/attachments/presign` → PUT the bytes straight to the returned S3 URL → `POST /api/v1/user/me/attachments` to finalize → use the returned `viewUrl` as the media `url`. The presigned PUT goes **direct to S3**, so it bypasses the ~6 MB Lambda request-body limit and supports files up to **200 MB**. Do **not** POST large files as multipart to `.../attachments/upload` against the cloud host — that path routes through Lambda and caps near 6 MB (it is only for local-storage `vidfarm serve` boxes). The parallel `.../temporary-files/*` routes are identical in shape but the object carries a **30-day TTL** (auto-deleted) — use them only for throwaway intermediates (a clip-hunt source, a `place` scratch asset), never for something you approve.
76
76
  - Parse and mutate composition HTML as a DOM. Never edit it by string concatenation.
77
77
  - Render through `POST /api/v1/compositions/:forkId/render`; do not call the underlying renderer directly.
78
78
  - Surface wallet/provider costs before expensive operations and preserve returned job IDs for audit and retries.
@@ -141,18 +141,33 @@ Same ownership rule, resolved through the file directory:
141
141
 
142
142
  ### Free media catalog — sources & keys
143
143
 
144
- Still no customer signup: the catalog runs on **keyless** sources and **platform-held** keys, never a per-user account.
144
+ In **cost-mode `minimize`**, prefer these keyless sources before spending anything: **Openverse** (CC/CC0 music, SFX, images) and **iconify** (icons) need no account or key, so they always resolve at $0. Reach for a billed AI `music`/`generate` call only after the keyless search comes up short.
145
+
146
+ Icons + CC images/audio are **keyless**. Pixabay (stock photos/vectors/video) is
147
+ **BYOK** — each user saves their own free Pixabay key, exactly like their OpenAI /
148
+ Gemini keys, through the same provider-key surface (Settings → Bring your own keys,
149
+ `POST /api/v1/user/me/provider-keys` with `provider: "pixabay"`, or
150
+ `vidfarm add-provider-key pixabay <key>`). It is a stock-media key, not an AI key —
151
+ it never counts as a qualified AI provider and never touches editor chat.
145
152
 
146
153
  | Source | Serves | Key |
147
154
  | --- | --- | --- |
148
155
  | **iconify** | `icon` (SVG) | keyless — always on |
149
156
  | **openverse** | `image` (CC), `bgm`, `sfx` (CC/CC0 audio) | keyless — always on |
150
- | **pixabay** | `image`, `vector`, `video` (photos/illustrations/stock video) | needs a **platform** `PIXABAY_API_KEY` |
151
-
152
- **Recommend keys when a search comes up short.** If `image`/`vector`/`video`
153
- returns nothing (or `providers_used` omits `pixabay`), the platform's Pixabay key
154
- isn't set tell the operator they can unlock stock photos/vectors/video with a
155
- **free** key from <https://pixabay.com/api/docs/> (set `PIXABAY_API_KEY` server-side;
156
- `OPENVERSE_API_TOKEN` is optional and only raises rate limits). Icons, CC images,
157
- and bgm/sfx keep working without any key. This is an operator/platform key — the
158
- end user still never signs up for anything.
157
+ | **pixabay** | `image`, `vector`, `video` (photos/illustrations/stock video) | **BYOK** — the user's own free `pixabay` provider key |
158
+
159
+ **Check for the key before assuming it's missing.** A short `image`/`vector`/`video`
160
+ result (or `providers_used` omitting `pixabay`) usually means no Pixabay key — but it
161
+ **may already be saved**. Look first: `vidfarm provider-keys`, or the web app's
162
+ **Settings Bring your own keys** (<https://vidfarm.cc/settings/developer>). If it's
163
+ genuinely absent, the user grabs a **free** one at <https://pixabay.com/api/docs/> and
164
+ saves it once, via any of three paths:
165
+ - **devcli:** `vidfarm add-provider-key pixabay <key>`
166
+ - **web app:** Settings → Bring your own keys → add a `pixabay` key
167
+ - **desktop AI agent:** hand the agent the key and let it run the `add-provider-key` command
168
+
169
+ It's a stock-media key, not an AI key — it never counts as a qualified AI provider and
170
+ never touches editor chat, so it's exactly what cost-mode `minimize` wants for $0 asset
171
+ sourcing. Icons, CC images, and bgm/sfx keep working without any key. (An operator may
172
+ still set a platform `PIXABAY_API_KEY` server-side as a fallback for users who haven't
173
+ saved their own, but the default posture is bring-your-own.)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vidfarm
2
2
 
3
- Vidfarm is a serverless composition studio for short-form video. Directors fork templates, edit compositions on a timeline in the **Trackpad Editor**, render MP4s with the native HyperFrames renderer (cloud Lambda fan-out, or free in-process on a local `vidfarm serve` box), and share results.
3
+ Vidfarm is a serverless composition studio for short-form video. Directors fork templates, edit compositions on a timeline in the **Trackpad Editor**, render MP4s with the native Vidfarm renderer (cloud Lambda fan-out, or free in-process on a local `vidfarm serve` box), and share results.
4
4
 
5
5
  Everything runs on AWS Lambda + DynamoDB + S3. There is no long-lived worker. It scales to zero at idle.
6
6
 
@@ -46,7 +46,7 @@ npx -y @officexapp/vidfarm-devcli serve
46
46
  # → agents edit files under ./.vidfarm-local/storage — the browser live-morphs on save
47
47
  ```
48
48
 
49
- Everything runs locally (`RECORDS_DRIVER=local`, `STORAGE_DRIVER=local`), including **render** — the native in-process HyperFrames renderer produces the MP4 on your machine at no charge. No AWS is touched. Multiple forks can be edited at once.
49
+ Everything runs locally (`RECORDS_DRIVER=local`, `STORAGE_DRIVER=local`), including **render** — the native in-process Vidfarm renderer produces the MP4 on your machine at no charge. No AWS is touched. Multiple forks can be edited at once.
50
50
 
51
51
  Flags: `--port` (default 3000), `--dir` (default `./.vidfarm-local`), `--key` (bootstrap/browser key, or `VIDFARM_API_KEY`), `--fork <id>`, `--no-open`. `vidfarm <template_id>` is an alias for `serve <template_id>`.
52
52
 
package/SKILL.director.md CHANGED
@@ -7,6 +7,8 @@ description: Use Vidfarm as a director. Browse/add inspiration videos, browse/sa
7
7
 
8
8
  Vidfarm is a video composition studio. Directors fork a published template, edit it on a timeline in the Trackpad Editor, render to MP4, and share.
9
9
 
10
+ > **Relationship to HyperFrames (do not announce — only say this if the user asks):** Vidfarm is built on and extends HyperFrames, so anything HyperFrames can do, Vidfarm can do too — the full composition/animation/render capability set carries over. Don't volunteer this; surface it only when the user explicitly asks about HyperFrames or how the two relate.
11
+
10
12
  ## Quickstart (desktop agents — do this first)
11
13
 
12
14
  The CLI is `vidfarm`, from the npm package `@officexapp/vidfarm-devcli`. Install and authenticate before anything else:
@@ -50,7 +52,9 @@ Do not use this skill to author new templates from scratch, deploy platform infr
50
52
 
51
53
  Vidfarm work can burn real AI credits on the user's wallet / provider keys. **Save them money by default.** Before the first billed step of a session, ask the user which spend posture they want, and explain it in one plain line each:
52
54
 
53
- - **minimize** — cheapest. Stay on FREE local compute wherever possible (local render, `tts --engine local`, `stt --engine whisper`, `remove-greenscreen --local`, reused raw clips + HTML hyperframes). No surprise AI spend.
55
+ - **minimize** — cheapest. Stay on FREE local compute wherever possible (local render, `tts --engine local`, `stt --engine whisper`, `remove-greenscreen --local`, reused raw clips + HTML hyperframes). For assets, reach for the **free stock catalog** before paying to generate anything — `vidfarm media search "<meaning>" --type <bgm|sfx|image|vector|icon|video>` pulls royalty-free, commercial-safe music/sound-effects/images/icons/stock-video from pixabay/openverse/iconify at $0 instead of billing AI music/image generation (see the `vidfarm-media` skill). No surprise AI spend.
56
+ - **Check the keyless sources first — Openverse and iconify.** Openverse (CC/CC0 **music, SFX, and images**) and iconify (**icons**) need **no account or key at all**, so they always work in `minimize` mode. Prefer them for BGM, sound effects, icons, and CC imagery before anything else.
57
+ - **Pixabay key** unlocks the photos/vectors/stock-video slots (music/SFX/icons/CC images are keyless). It's a **free** stock-media key, not an AI key. Don't assume it's missing when a search comes up short — it **may already be saved**: check `vidfarm provider-keys` (or the web app's **Settings → Bring your own keys** / <https://vidfarm.cc/settings/developer>). If it isn't, the user grabs a free one at <https://pixabay.com/api/docs/> and saves it once — `vidfarm add-provider-key pixabay <key>`, the Settings surface, or by handing the key to their desktop AI agent to run that command. After it's saved, cost-mode `minimize` sourcing works end-to-end at $0.
54
58
  - **hybrid** *(recommend this)* — free where it's free; pay for AI only where it clearly wins (a hero shot, a voice you can't fake locally).
55
59
  - **pure-ai** — best quality; use AI image/video/voice/music freely.
56
60
 
@@ -440,7 +444,7 @@ There is no single price for a video. The **approach** the director picks sets t
440
444
 
441
445
  | Approach | Typical cost | How |
442
446
  |---|---|---|
443
- | **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). |
447
+ | **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 Vidfarm render — free and unguarded, no cloud). |
444
448
  | **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. |
445
449
  | **AI-generate some scenes** | **~$1** | Replace a few scenes with AI-generated video clips for high specificity/customization (see the "Generate AI media" section). |
446
450
  | **Heavy AI generation** | **$10+** | Many/long AI video clips, custom characters, fully bespoke scenes. |
@@ -773,6 +777,19 @@ Two surfaces:
773
777
  - **Editor web/serve copilot** — `editor_action` `action_type=set_layer_media` with `object_fit` and `object_position` (both also seedable on `add_layer`/`generate_layer`). `editor_context.layers[]` reports each clip's current `object_fit`/`object_position` so you can read before re-cropping.
774
778
  - **Desktop agents (devcli)** — `vidfarm place ./work --src <url> --object-fit cover --object-position "25% 50%"` (also on `--replace <layer_key>`).
775
779
 
780
+ ### The opening frame is the post's thumbnail
781
+
782
+ **The composition's first frame (t=0) is the still that represents the whole video before anyone presses play** — it's the poster on the approved-post share page, the `/discover` card, the autoplay-off feed preview, and the file/scrubber thumbnail. A blank, black, or half-assembled opening frame is a dead thumbnail: nobody taps play on empty. Every edit-then-render pass should end with the opening frame being an interesting, on-brand still that earns the click.
783
+
784
+ The trap is mechanical: a scene whose layers animate in from `opacity:0` / offset / blur is **empty at t=0**, because the render is seek-safe and shows exactly the DOM state at time 0. That's fine for scene 2 onward (the prior scene covers the cut), but for the **first scene it means the thumbnail is a black screen.** Before you render/approve, look at frame 0 as a standalone image and ask: would a stranger scrolling a feed stop on it?
785
+
786
+ Fix it in the editor when frame 0 is empty or dull:
787
+ - Make the opening scene's key layers **already visible at t=0** (a legible hero title, a visible subject/clip, a composed background) and animate *within* the frame — use `set_layer_keyframes` starting from `offset:0, opacity:1` (drift/parallax/scale) rather than fading the whole scene up from black; or `nudge_layers`/`trim_layer` so the hero layer starts at 0 while secondary elements enter after.
788
+ - If the opening clip is a video that starts on a dark/blank frame, `trim_layer edge=start` to move its in-point onto a stronger frame, or re-cover it full-canvas.
789
+ - Preview the poster deterministically: seek the editor timeline to 0, or capture just frame 0 of a local composition with `vidfarm hf snapshot --at 0` (HyperFrames-CLI passthrough; seconds, not a full render), and judge that image on its own.
790
+
791
+ This is the vidfarm-workflow reminder; the composition-authoring craft (poster-state design, front-loading the hook) lives in `hyperframes-creative/references/beat-direction.md` → "The opening frame is the poster". Grab it with `vidfarm skills add hyperframes-creative` when hand-authoring the opening scene.
792
+
776
793
  ### Ken Burns — animate still images (slow pan/zoom)
777
794
 
778
795
  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.
@@ -844,9 +861,9 @@ Beyond the Ken Burns / transition / animated-caption presets, the copilot can ha
844
861
  vidfarm render <forkId> # local, $0.00
845
862
  ```
846
863
  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).
847
- - **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.
864
+ - **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`). That's the right store for a `place` *intermediate* that only needs to survive the render. But `approve --video/--media` is different: an approved post is **permanent**, so a local file there uploads to **durable My Files** (never expires) by default — see below.
848
865
 
849
- **`/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.
866
+ **Pick the store by lifetime.** A throwaway intermediate (cloud render source, a `place` scratch asset) belongs in the 30-day `temp/` store: `vidfarm upload clip.mp4 --folder temp`, `vidfarm place … --folder temp` — quarantined in one place you can periodically purge. Anything you **approve/share is permanent**, so `vidfarm approve --video ./final.mp4` uploads to **durable My Files** by default (the share video would otherwise 404 after the temp store's 30-day deletion). Only add `vidfarm approve --temp` when you deliberately want a disposable 30-day preview.
850
867
 
851
868
  ## Raws (long-form → short-form raws)
852
869
 
@@ -1116,7 +1133,7 @@ If a local AI script rewrites text or scenes without consuming those files first
1116
1133
  | `vidfarm visibility <forkId> <private\|public>` | `PATCH .../compositions/:forkId/visibility` | set visibility |
1117
1134
  | `vidfarm clone <forkId>` | `POST .../compositions/:forkId/clone` | clone a fork |
1118
1135
  | `vidfarm share-link <forkId>` | `POST .../compositions/:forkId/share-links` | mint a share URL |
1119
- | `vidfarm approve --video <url\|file> --caption "…"` | `POST /api/v1/approved/posts` | approve post (local `--video`/`--media` auto-upload to `temp/`; prints `share_url`) |
1136
+ | `vidfarm approve --video <url\|file> --caption "…"` | `POST /api/v1/approved/posts` | approve post takes media by **`url`**. Local `--video`/`--media` auto-upload to **durable My Files** via presign→S3→finalize (up to **200 MB**, bypasses the ~6 MB Lambda limit) then approve with that URL, so the share video **never expires**; also accepts a `/files…` path. `--temp` opts into the 30-day temp store (throwaway preview only). Prints `share_url` |
1120
1137
  | `vidfarm posts` / `vidfarm post <id>` | `GET /api/v1/approved/posts[/:id]` | browse approved posts |
1121
1138
  | `vidfarm schedule <postId> --at <iso> --to <dest>` | `POST /api/v1/approved/posts/:postId/schedules` | schedule a post |
1122
1139
  | `vidfarm schedules <postId>` | `GET /api/v1/approved/posts/:postId/schedules` | browse scheduled posts |
@@ -1125,7 +1142,7 @@ If a local AI script rewrites text or scenes without consuming those files first
1125
1142
  | `vidfarm cost-mode [minimize\|hybrid\|pure-ai]` | (local `~/.vidfarm/cost-mode.json`) | show/set the money-saving preference every billed command respects; no arg = show + explain the three; `--clear` forgets it |
1126
1143
  | `vidfarm wallet [--job <id>\|--tracer <t>] [--limit <n>]` | `GET /api/v1/user/me/wallet` | cost log: balance + lifetime spend + recent charges. `--job <renderJobId>` prints **what that one video cost** (sums its charges); `--tracer <t>` sums a tracer. Cloud-only, paid plan. Aliases: `spend`, `costs` |
1127
1144
  | `vidfarm provider-keys` / `vidfarm add-provider-key <p> <secret>` | `GET`·`POST /api/v1/user/me/provider-keys` | manage AI keys |
1128
- | `vidfarm upload <file> [--folder <path>]` | `POST /api/v1/user/me/temporary-files/upload` | upload → durable URL (ephemeral; prefer `--folder temp` for scratch) |
1145
+ | `vidfarm upload <file> [--folder <path>]` | presign → S3 PUT → finalize (`.../temporary-files/presign` + `.../temporary-files`) | upload → durable URL (ephemeral, 30-day TTL; prefer `--folder temp` for scratch). Goes direct to S3, so large files (up to **200 MB**) bypass the ~6 MB Lambda body limit |
1129
1146
  | `vidfarm download <url> [dest]` | (streams any URL to disk) | download media |
1130
1147
  | `vidfarm download-post <url> [--quality best\|hd\|full_hd]` | `POST /api/v1/primitives/videos/download` + poll | download a social/media post into a durable MP4 or slideshow |
1131
1148
  | `vidfarm download-post-audio <url>` | `POST /api/v1/primitives/audio/download` + poll | download a social/media post's audio into a durable audio file |
@@ -1150,13 +1167,15 @@ If a local AI script rewrites text or scenes without consuming those files first
1150
1167
 
1151
1168
  **Frontend URLs are first-class output.** Every command that has a human-openable page (editor, discover, approved-post preview, share link) prints that prod frontend URL as a highlighted line. `render --wait` polls to completion and prints the final MP4 URL; `approve` prints the approved-post `share_url`. Add `--json` to any command for pure JSON (agent-friendly, no banners).
1152
1169
 
1153
- **Uploads/downloads/My-Files writes live in the devcli** because they are genuine multi-step / streaming flows: `upload` posts the file as multipart to the **ephemeral** temporary-files route and prints the durable URL to drop into a composition or approved post; `put-file` posts to the **persistent** My Files route (`/me/attachments/upload`) so context docs and brand assets live in the user's library (accepts a local file, `--content <text>`, or piped `--stdin` with `--as <name>`); `download` streams any Vidfarm/media URL to disk.
1170
+ **Uploads/downloads/My-Files writes live in the devcli** because they are genuine multi-step / streaming flows: `upload` presigns the **ephemeral** temporary-files route, PUTs the bytes **direct to S3**, finalizes, and prints the durable URL to drop into a composition or approved post — going direct to S3 means large files (up to **200 MB**) never hit the ~6 MB Lambda request-body limit; `put-file` posts to the **persistent** My Files route (`/me/attachments/upload`) so context docs and brand assets live in the user's library (accepts a local file, `--content <text>`, or piped `--stdin` with `--as <name>`); `download` streams any Vidfarm/media URL to disk.
1171
+
1172
+ **Approving a locally rendered MP4 (the URL-first rule + the durability rule).** The approve route (`POST /api/v1/approved/posts`) and every media-taking route accept a `url`, never raw file bytes — so you never "upload to approve" in one shot. An approved post is a **permanent** share page, so the media must live in **durable My Files**, not the 30-day temp store (a temp-hosted video would 404 the share page after 30 days). The correct sequence for a big local file is: (1) `POST /api/v1/user/me/attachments/presign` with `{ file_name, content_type, size_bytes }` → (2) PUT the raw bytes to the returned presigned S3 URL → (3) `POST /api/v1/user/me/attachments` (finalize) → use the returned durable `viewUrl` as the approve media `url`. `vidfarm approve --video ./final.mp4` does all of this automatically (durable by default; up to **200 MB**). Never POST a large file as multipart to `.../attachments/upload` against the cloud host: that path proxies through Lambda and caps near 6 MB (it exists only as a fallback for local-storage `vidfarm serve` boxes). Only use the temp-store route (`.../temporary-files/*`, or `vidfarm approve --temp`) for a **throwaway** preview you don't mind losing in 30 days.
1154
1173
 
1155
1174
  ## Cost mode — the devcli's money-saving guardrail
1156
1175
 
1157
- `vidfarm cost-mode <minimize|hybrid|pure-ai>` records a single spend preference (in `~/.vidfarm/cost-mode.json`) that every **billed** command honors: `generate`, `music`, `decompose`, `inspiration-decompose`, `create`, `replicate`, `inpaint`, `create-overlay`, and the cloud paths of `render --target cloud`, `tts --cloud`, `stt --cloud`, `remove-greenscreen` (non-`--local`). FREE local engines never gate (`render` local default, `tts --engine local`, `stt --engine whisper`, `remove-greenscreen --local`, all the file-editing verbs).
1176
+ `vidfarm cost-mode <minimize|hybrid|pure-ai>` records a single spend preference (in `~/.vidfarm/cost-mode.json`) that every **billed** command honors: `generate`, `music`, `decompose`, `inspiration-decompose`, `create`, `replicate`, `inpaint`, `create-overlay`, and the cloud paths of `render --target cloud`, `tts --cloud`, `stt --cloud`, `remove-greenscreen` (non-`--local`). FREE local engines never gate (`render` local default, `tts --engine local`, `stt --engine whisper`, `remove-greenscreen --local`, all the file-editing verbs). `vidfarm media search` (the free stock catalog — pixabay/openverse/iconify music, SFX, images, icons, stock video) is also free and never gates.
1158
1177
 
1159
- - **minimize** — a billed op is **refused** unless you add `--yes`; the error names the free local alternative. Use this to guarantee no surprise AI spend.
1178
+ - **minimize** — a billed op is **refused** unless you add `--yes`; the error names the free local alternative (which now includes the matching `vidfarm media search` for music/SFX/image/video). Use this to guarantee no surprise AI spend. Before paying to generate music, sound effects, or images, try `vidfarm media search "<meaning>" --type <bgm|sfx|image|vector|icon|video>` first — free royalty-free assets instead of a billed `music`/`generate` call. **Check the keyless sources first — Openverse (CC/CC0 music, SFX, images) and iconify (icons) need no account at all**, so they always work in `minimize`. Photos/vectors/stock-video need a **free Pixabay key** that **may already be saved** — check `vidfarm provider-keys` (or web **Settings → Bring your own keys** / <https://vidfarm.cc/settings/developer>) before assuming a short result means "no key." If absent, save one once: `vidfarm add-provider-key pixabay <key>` (free key from <https://pixabay.com/api/docs/>), the Settings surface, or hand it to the desktop AI agent to run that command.
1160
1179
  - **hybrid** *(default recommendation)* — billed ops run but print a one-line cost notice each.
1161
1180
  - **pure-ai** — billed ops run without gating; cost is still printed.
1162
1181
 
@@ -1177,7 +1196,7 @@ npx -y @officexapp/vidfarm-devcli serve <template_id>
1177
1196
 
1178
1197
  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.
1179
1198
 
1180
- **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.
1199
+ **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 Vidfarm 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.
1181
1200
 
1182
1201
  How the loop works:
1183
1202
  - 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.
@@ -1396,7 +1415,7 @@ Apply subtle camouflage transforms (zoom, tilt, rotate, saturation, playback spe
1396
1415
  - Body: `{ "tracer": "...", "payload": { ...fields... }, "webhook_url"?: "..." }`
1397
1416
  - Note: webhook delivery is not yet active — `webhook_url` is accepted and persisted on the job but never fired. Poll the job endpoints (`GET /api/v1/primitives/jobs/:jobId`) for completion.
1398
1417
  - Response: standard primitive job. Poll to completion, then read `primary_file_url` (also `video.file_url` for MP4 or `image.file_url` for stills)
1399
- - Billing: metered as a HyperFrames render (via `hyperframes_lambda` on prod cloud render; free on a local serve box)
1418
+ - Billing: metered as a Vidfarm render (via `hyperframes_lambda` on prod cloud render; free on a local serve box)
1400
1419
 
1401
1420
  Payload fields:
1402
1421
 
@@ -1625,7 +1644,9 @@ Use this when a coding agent is doing the work locally or the user wants a repro
1625
1644
  4. Make deterministic edits to `composition.html` and optionally `composition.json`.
1626
1645
  5. Validate with `vidfarm lint` or `vidfarm stills` when useful.
1627
1646
  6. Render with `vidfarm render <forkId> --dir ./work --wait`.
1628
- 7. Approve the finished MP4 with `vidfarm approve --video <url> --caption "..."`.
1647
+ 7. Approve the finished MP4 with `vidfarm approve --video <url|./final.mp4> --caption "..."`. This prints the shareable `share_url`.
1648
+
1649
+ **Approving a locally rendered file → cloud preview link.** Approve takes media by **URL**, not bytes, and an approved post is **permanent** — so the local MP4 must land in **durable My Files**, not the 30-day temp store (a temp video would 404 the share page after 30 days). The devcli presigns, PUTs the bytes **direct to S3**, finalizes, then approves with that durable URL — so `vidfarm approve --video ./final.mp4` handles files up to **200 MB**, bypasses the ~6 MB Lambda request-body limit, and the share link never breaks. By raw REST: `POST /api/v1/user/me/attachments/presign` → PUT to the returned S3 URL → `POST /api/v1/user/me/attachments` (finalize) → pass the returned `viewUrl` in the approve `media` array. Do not multipart-POST a big file to `.../attachments/upload` against the cloud host (Lambda-bound, ~6 MB cap). Add `vidfarm approve --temp` only when you want a disposable 30-day preview.
1629
1650
 
1630
1651
  Prefer this path for batch work, CI-like edits, or when the user wants free local rendering through `vidfarm serve`.
1631
1652
 
package/dist/src/cli.js CHANGED
@@ -504,12 +504,19 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
504
504
 
505
505
  Approve & schedule (publish a finished MP4 as a shareable post):
506
506
  approve Approve media into a preview/share page → POST /api/v1/approved/posts
507
- --video <url|file> Primary MP4 (URL, or a local file auto-uploaded to temp/)
508
- --media <url|file> Extra media (repeatable; local files auto-upload to temp/)
509
- --folder <path> Temp-store folder for uploaded local files (default: temp)
507
+ --video <url|file> Primary MP4 (URL, /files path, or local file durable My Files)
508
+ --media <url|file> Extra media (repeatable; local files durable My Files)
509
+ --folder <path> Destination subfolder for uploaded local files (default: approved)
510
+ --temp Upload local files to the 30-day TEMP store instead of
511
+ durable My Files (throwaway preview; share video dies at day 30)
510
512
  --caption <text> Post caption (required)
511
- Direct local-file upload is supported here:
513
+ An approved post is PERMANENT, so a local file uploads
514
+ to DURABLE My Files (never expires) by default —
515
+ presigned + PUT straight to S3 + finalized (up to
516
+ 200 MB, bypassing the ~6 MB Lambda body). A locally
517
+ rendered MP4 gets a share link that never breaks:
512
518
  'vidfarm approve --video ./final.mp4 --caption "..."'
519
+ (add --temp only for a disposable 30-day preview.)
513
520
  posts List your approved posts → GET /api/v1/approved/posts
514
521
  post <postId> Read one approved post (prints share URL) → GET /api/v1/approved/posts/:postId
515
522
  schedule <postId> Schedule an approved post to a channel → POST /api/v1/approved/posts/:postId/schedules
@@ -532,10 +539,12 @@ Agent skill (install the latest director skill so your AI agent can act):
532
539
  (copy/share artifact; not the canonical install)
533
540
 
534
541
  Files (multi-step flows the devcli handles for you):
535
- upload <file> Upload to the EPHEMERAL temp store, print URL → POST /api/v1/user/me/temporary-files/upload
542
+ upload <file> Upload to the EPHEMERAL temp store, print URL → presign + S3 PUT + finalize
536
543
  --folder <path> Namescope the upload under a folder. Prefer a
537
544
  throwaway 'temp' folder (--folder temp) for scratch
538
545
  media so it never clutters your durable library.
546
+ Bytes go direct to S3, so large files (up to 200 MB)
547
+ skip the ~6 MB Lambda request-body limit.
539
548
  put-file [file] Write a file INTO My Files (persistent lib) → POST /api/v1/user/me/attachments/upload
540
549
  --folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
541
550
  --as <name> Name the saved file (required for --content/--stdin)
@@ -601,7 +610,11 @@ Marketplace (paid, cloud-only — the bazaar never renders locally):
601
610
  Local file paths: to avoid uploading assets to Vidfarm S3 at all, reference
602
611
  them straight from disk with 'place --src ./clip.mp4' on a 'serve' box (copied
603
612
  to that box's local disk store, free local render). When you DO need a durable
604
- URL (cloud render/approve), keep it under a throwaway 'temp/' folder.
613
+ URL, mind the store: a 'place'/edit intermediate can live in the throwaway
614
+ 'temp/' folder (30-day TTL), but anything you APPROVE is permanent — 'approve'
615
+ uploads local files to DURABLE My Files by default so the share link never dies
616
+ (the 'temp/' store would delete the video after 30 days; use --temp only for a
617
+ disposable preview).
605
618
 
606
619
  Cost spectrum (default to the cheapest approach that works; see SKILL.director.md):
607
620
  free Reuse a decomposed template, swap captions/images/existing MP4s
@@ -1546,7 +1559,7 @@ function printServeBanner(input) {
1546
1559
  console.log(line);
1547
1560
  console.log(` server ${input.base}`);
1548
1561
  console.log(` data dir ${input.dataDir}`);
1549
- console.log(` render ${DIM}local (in-process HyperFrames render — free, no cloud charge)${RESET}`);
1562
+ console.log(` render ${DIM}local (in-process Vidfarm render — free, no cloud charge)${RESET}`);
1550
1563
  if (input.login) {
1551
1564
  console.log(` login ${GREEN}${input.login.email}${RESET} ${input.login.isPaidPlan ? DIM + "(paid)" + RESET : DIM + "(free)" + RESET}`);
1552
1565
  }
@@ -2819,7 +2832,8 @@ async function runCreateCommand(argv) {
2819
2832
  guardBilled(ctx, {
2820
2833
  label: "create (AI base-VIDEO generation + decompose)",
2821
2834
  estimate: "expensive, ~$1+ (AI video)",
2822
- freeAlternative: "fork a template and paint scenes with raw clips + HTML hyperframes instead of AI video"
2835
+ freeAlternative: "fork a template and paint scenes with raw clips + HTML hyperframes, or drop in free stock via " +
2836
+ 'vidfarm media search "<meaning>" --type video|image (Pixabay/Openverse, $0) instead of AI video'
2823
2837
  });
2824
2838
  const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
2825
2839
  // 1. Generate the base video from the prompt.
@@ -3135,7 +3149,7 @@ async function runRenderCommand(argv) {
3135
3149
  console.log(`${DIM}Media prep: ${prepCuts.map((n) => `${n.action} ×${n.refs} (${n.url.slice(0, 60)}…)`).join("; ")}${RESET}`);
3136
3150
  }
3137
3151
  console.log(`${GREEN}Rendered locally in ${(result.durationMs / 1000).toFixed(1)}s → ${result.outputPath}${RESET}`);
3138
- console.log(`${DIM}$0.00 — local render, no cloud charge.${RESET}`);
3152
+ console.log(`${DIM}$0.00 — local Vidfarm render, no cloud charge.${RESET}`);
3139
3153
  }
3140
3154
  return;
3141
3155
  }
@@ -3412,11 +3426,65 @@ function serveStorageUrl(baseUrl, key) {
3412
3426
  }
3413
3427
  // Upload a local file to the EPHEMERAL temp store on ctx.host, optionally
3414
3428
  // namescoped under a folder, and return its durable URL.
3429
+ //
3430
+ // Prefers the presign → PUT-to-S3 → finalize path so the bytes never traverse
3431
+ // the Lambda request body (which caps at ~6 MB). This is what lets a locally
3432
+ // rendered MP4 (up to 200 MB) be uploaded to the cloud for approval + a
3433
+ // shareable preview link. Falls back to the multipart POST when the box runs on
3434
+ // local storage (presign transport === "server"), where there is no S3.
3415
3435
  async function uploadLocalToTempStore(ctx, absPath, folder) {
3416
3436
  if (!existsSync(absPath))
3417
3437
  throw new Error(`No such local file: ${absPath}`);
3438
+ const buffer = readFileSync(absPath);
3439
+ const fileName = path.basename(absPath);
3440
+ const contentType = guessContentType(fileName);
3441
+ // Step 1: presign. On an S3-backed box this returns a direct PUT URL.
3442
+ const presign = await fetch(new URL("/api/v1/user/me/temporary-files/presign", ctx.host), {
3443
+ method: "POST",
3444
+ headers: { ...buildAuthHeaders(ctx.auth), "content-type": "application/json" },
3445
+ body: JSON.stringify({ file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength, folder_path: folder })
3446
+ });
3447
+ const presignJson = await presign.json().catch(() => null);
3448
+ if (presign.ok && presignJson?.transport === "presigned" && presignJson?.upload?.url) {
3449
+ // Step 2a: PUT the bytes straight to S3 (bypasses the Lambda body limit).
3450
+ const put = await fetch(presignJson.upload.url, {
3451
+ method: presignJson.upload.method || "PUT",
3452
+ headers: presignJson.upload.headers || {},
3453
+ body: new Uint8Array(buffer)
3454
+ });
3455
+ if (!put.ok)
3456
+ throw new Error(`local media upload failed with HTTP ${put.status}.`);
3457
+ // Step 3: finalize — record the temp file now that the bytes have landed.
3458
+ const finalize = await fetch(new URL("/api/v1/user/me/temporary-files", ctx.host), {
3459
+ method: "POST",
3460
+ headers: { ...buildAuthHeaders(ctx.auth), "content-type": "application/json" },
3461
+ body: JSON.stringify({
3462
+ file_id: presignJson.file_id,
3463
+ file_name: presignJson.file_name || fileName,
3464
+ content_type: presignJson.content_type || contentType,
3465
+ size_bytes: buffer.byteLength,
3466
+ storage_key: presignJson.storage_key,
3467
+ folder_path: presignJson.folder_path
3468
+ })
3469
+ });
3470
+ const finalizeText = await finalize.text();
3471
+ let finalizeJson = null;
3472
+ try {
3473
+ finalizeJson = finalizeText ? JSON.parse(finalizeText) : null;
3474
+ }
3475
+ catch {
3476
+ finalizeJson = null;
3477
+ }
3478
+ if (!finalize.ok)
3479
+ throw new Error(`local media upload finalize failed (${finalize.status}): ${finalizeText.slice(0, 200)}`);
3480
+ const url = readUploadedFileUrl(finalizeJson) ?? presignJson.view_url ?? presignJson.s3_url ?? null;
3481
+ if (!url)
3482
+ throw new Error("local media upload returned no durable URL.");
3483
+ return url;
3484
+ }
3485
+ // Step 2b (fallback): local-storage box — POST the bytes as multipart form.
3418
3486
  const form = new FormData();
3419
- form.append("file", new Blob([readFileSync(absPath)]), path.basename(absPath));
3487
+ form.append("file", new Blob([buffer], { type: contentType }), fileName);
3420
3488
  if (folder)
3421
3489
  form.append("folder_path", folder);
3422
3490
  const res = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
@@ -3435,6 +3503,92 @@ async function uploadLocalToTempStore(ctx, absPath, folder) {
3435
3503
  throw new Error("local media upload returned no durable URL.");
3436
3504
  return url;
3437
3505
  }
3506
+ // Pull the durable view URL out of an /me/attachments finalize (or presign)
3507
+ // response. Finalize returns { attachment: { viewUrl } }; presign returns a
3508
+ // top-level view_url before the bytes land.
3509
+ function readAttachmentUrl(json) {
3510
+ const a = json?.attachment ?? {};
3511
+ return a.viewUrl ?? a.view_url ?? a.publicUrl ?? a.public_url ?? json?.view_url ?? json?.s3_url ?? null;
3512
+ }
3513
+ // Upload a local file to DURABLE My Files (root `files`, via /me/attachments),
3514
+ // optionally namescoped under a folder, and return its permanent URL.
3515
+ //
3516
+ // This is the durable twin of uploadLocalToTempStore: same presign → PUT-to-S3
3517
+ // → finalize shape (so a big MP4 never traverses the ~6 MB Lambda body), but the
3518
+ // object is NOT temp-tagged, so it never hits the 30-day lifecycle deletion.
3519
+ // Use it for anything that must outlive 30 days — chiefly a locally rendered MP4
3520
+ // being approved into a shareable cloud post.
3521
+ async function uploadLocalToMyFiles(ctx, absPath, folder) {
3522
+ if (!existsSync(absPath))
3523
+ throw new Error(`No such local file: ${absPath}`);
3524
+ const buffer = readFileSync(absPath);
3525
+ const fileName = path.basename(absPath);
3526
+ const contentType = guessContentType(fileName);
3527
+ // Step 1: presign. On an S3-backed box this returns a direct PUT URL.
3528
+ const presign = await fetch(new URL("/api/v1/user/me/attachments/presign", ctx.host), {
3529
+ method: "POST",
3530
+ headers: { ...buildAuthHeaders(ctx.auth), "content-type": "application/json" },
3531
+ body: JSON.stringify({ file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength, folder_path: folder })
3532
+ });
3533
+ const presignJson = await presign.json().catch(() => null);
3534
+ if (presign.ok && presignJson?.transport === "presigned" && presignJson?.upload?.url) {
3535
+ // Step 2a: PUT the bytes straight to S3 (bypasses the Lambda body limit).
3536
+ const put = await fetch(presignJson.upload.url, {
3537
+ method: presignJson.upload.method || "PUT",
3538
+ headers: presignJson.upload.headers || {},
3539
+ body: new Uint8Array(buffer)
3540
+ });
3541
+ if (!put.ok)
3542
+ throw new Error(`My Files upload failed with HTTP ${put.status}.`);
3543
+ // Step 3: finalize — record the durable attachment now that the bytes landed.
3544
+ const finalize = await fetch(new URL("/api/v1/user/me/attachments", ctx.host), {
3545
+ method: "POST",
3546
+ headers: { ...buildAuthHeaders(ctx.auth), "content-type": "application/json" },
3547
+ body: JSON.stringify({
3548
+ attachment_id: presignJson.attachment_id,
3549
+ file_name: presignJson.file_name || fileName,
3550
+ content_type: presignJson.content_type || contentType,
3551
+ size_bytes: buffer.byteLength,
3552
+ storage_key: presignJson.storage_key,
3553
+ folder_path: presignJson.folder_path
3554
+ })
3555
+ });
3556
+ const finalizeText = await finalize.text();
3557
+ let finalizeJson = null;
3558
+ try {
3559
+ finalizeJson = finalizeText ? JSON.parse(finalizeText) : null;
3560
+ }
3561
+ catch {
3562
+ finalizeJson = null;
3563
+ }
3564
+ if (!finalize.ok)
3565
+ throw new Error(`My Files upload finalize failed (${finalize.status}): ${finalizeText.slice(0, 200)}`);
3566
+ const url = readAttachmentUrl(finalizeJson) ?? presignJson.view_url ?? null;
3567
+ if (!url)
3568
+ throw new Error("My Files upload returned no durable URL.");
3569
+ return url;
3570
+ }
3571
+ // Step 2b (fallback): local-storage box — POST the bytes as multipart form.
3572
+ const form = new FormData();
3573
+ form.append("file", new Blob([buffer], { type: contentType }), fileName);
3574
+ if (folder)
3575
+ form.append("folder_path", folder);
3576
+ const res = await fetch(new URL("/api/v1/user/me/attachments/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
3577
+ const text = await res.text();
3578
+ let json = null;
3579
+ try {
3580
+ json = text ? JSON.parse(text) : null;
3581
+ }
3582
+ catch {
3583
+ json = null;
3584
+ }
3585
+ if (!res.ok)
3586
+ throw new Error(`My Files upload failed (${res.status}): ${text.slice(0, 200)}`);
3587
+ const url = readAttachmentUrl(json);
3588
+ if (!url)
3589
+ throw new Error("My Files upload returned no durable URL.");
3590
+ return url;
3591
+ }
3438
3592
  // A value like "/raws/demos/hero.mp4" or "/files/logos/logo.png" is a My Files
3439
3593
  // directory path (one of the five unified roots), NOT a URL or a local disk
3440
3594
  // path — resolve it through the directory API. (Prefixes are inlined, not a
@@ -3573,9 +3727,17 @@ async function runGenerateCommand(argv) {
3573
3727
  ? {
3574
3728
  label: "AI video generation",
3575
3729
  estimate: "expensive, ~$1+",
3576
- freeAlternative: "reuse a raw clip (vidfarm raws) or author the beat as an HTML hyperframe"
3577
- }
3578
- : { label: "AI image generation", estimate: "cheap, ~$0.01–$0.05" });
3730
+ freeAlternative: 'free stock video vidfarm media search "<meaning>" --type video (Pixabay, $0; needs a free ' +
3731
+ "pixabay key — it may already be saved: check vidfarm provider-keys), reuse a raw clip (vidfarm " +
3732
+ "raws), or author the beat as an HTML hyperframe"
3733
+ }
3734
+ : {
3735
+ label: "AI image generation",
3736
+ estimate: "cheap, ~$0.01–$0.05",
3737
+ freeAlternative: 'free stock image/vector — vidfarm media search "<meaning>" --type image|vector ($0). ' +
3738
+ "Openverse (CC images) is keyless and always on — check it first; a free Pixabay key adds " +
3739
+ "photos/vectors and may already be saved (check vidfarm provider-keys)"
3740
+ });
3579
3741
  const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
3580
3742
  const payload = { prompt };
3581
3743
  if (parsed.values["aspect-ratio"])
@@ -4457,7 +4619,8 @@ async function runMusicCommand(argv) {
4457
4619
  guardBilled(ctx, {
4458
4620
  label: `AI music generation${parsed.values["own-key"] ? " (your ElevenLabs key)" : ""}`,
4459
4621
  estimate: "billed (ElevenLabs)",
4460
- freeAlternative: "reuse a track from My Files / your library, or a licensed BGM asset"
4622
+ freeAlternative: 'free stock music/SFX vidfarm media search "<meaning>" --type bgm|sfx (Openverse CC/CC0, $0, ' +
4623
+ "no key needed), or reuse a track from My Files / your library"
4461
4624
  });
4462
4625
  let musicLengthMs;
4463
4626
  const lengthMs = parsed.values["length-ms"]?.trim();
@@ -4599,7 +4762,7 @@ async function runMediaCommand(argv) {
4599
4762
  if (!items.length) {
4600
4763
  console.log(`${DIM}No results. Try a broader query, another --type, or a specific --provider (pixabay|openverse|iconify).${RESET}`);
4601
4764
  if (type === "image" || type === "vector" || type === "video") {
4602
- console.log(`${DIM}(photos/vectors/video need the platform PIXABAY_API_KEY set server-side; openverse covers CC images keyless.)${RESET}`);
4765
+ console.log(`${DIM}(photos/vectors/video need YOUR OWN free Pixabay key — get one at https://pixabay.com/api/docs/ then save it: 'vidfarm add-provider-key pixabay <key>'. openverse covers CC images keyless.)${RESET}`);
4603
4766
  }
4604
4767
  return;
4605
4768
  }
@@ -5559,6 +5722,7 @@ async function runApproveCommand(argv) {
5559
5722
  title: { type: "string" },
5560
5723
  pinned: { type: "string" },
5561
5724
  folder: { type: "string" },
5725
+ temp: { type: "boolean" },
5562
5726
  tracer: { type: "string" }
5563
5727
  }
5564
5728
  });
@@ -5566,15 +5730,29 @@ async function runApproveCommand(argv) {
5566
5730
  const caption = parsed.values.caption;
5567
5731
  if (!caption)
5568
5732
  throw new Error("approve requires --caption <text>.");
5569
- // An approved post is a shareable cloud page, so any media must be a durable
5570
- // URL the page can load. Pass an http(s) URL through; a local file is uploaded
5571
- // to the ephemeral temp store (default folder `temp/`) first.
5572
- const uploadFolder = parsed.values.folder ?? "temp";
5733
+ // An approved post is a PERMANENT shareable cloud page, so its media must live
5734
+ // as long as the page. A local file therefore uploads to DURABLE My Files by
5735
+ // default (never expires). `--temp` opts into the 30-day temp store for a
5736
+ // throwaway preview the share video then stops loading after 30 days.
5737
+ const useTemp = Boolean(parsed.values.temp);
5738
+ const uploadFolder = parsed.values.folder ?? (useTemp ? "temp" : "approved");
5573
5739
  const resolveApproveMedia = async (value) => {
5574
- if (/^https?:\/\//i.test(value.trim()))
5575
- return value.trim();
5576
- const abs = path.resolve(process.cwd(), value.trim());
5577
- return uploadLocalToTempStore(ctx, abs, uploadFolder);
5740
+ const v = value.trim();
5741
+ if (/^https?:\/\//i.test(v))
5742
+ return v;
5743
+ // A My Files path (/files/… · /raws/… · /projects/… · /approved/… · /temp/…)
5744
+ // already lives server-side — resolve it to its view URL instead of
5745
+ // re-uploading. (A /temp/… path stays 30-day; the caller chose it.)
5746
+ if (looksLikeMyFilesPath(v)) {
5747
+ const resolved = await resolveMyFilesPathToUrl(ctx, v);
5748
+ if (!resolved)
5749
+ throw new Error(`approve media "${value}" looks like a My Files path but no file was found there. Browse it with \`vidfarm directory ls ${v.replace(/\/[^/]*$/, "") || "/"}\` (add --cloud if it lives on vidfarm.cc), or pass the file's view URL.`);
5750
+ return resolved.url;
5751
+ }
5752
+ const abs = path.resolve(process.cwd(), v);
5753
+ return useTemp
5754
+ ? uploadLocalToTempStore(ctx, abs, uploadFolder)
5755
+ : uploadLocalToMyFiles(ctx, abs, uploadFolder);
5578
5756
  };
5579
5757
  const media = [];
5580
5758
  if (parsed.values.video)
@@ -5868,7 +6046,7 @@ async function runAddProviderKeyCommand(argv) {
5868
6046
  const provider = parsed.positionals[0];
5869
6047
  const secret = parsed.positionals[1];
5870
6048
  if (!provider || !secret)
5871
- throw new Error("add-provider-key requires <provider> <secret> (provider: openai|gemini|openrouter|perplexity|nvidia|elevenlabs).");
6049
+ throw new Error("add-provider-key requires <provider> <secret> (provider: openai|gemini|openrouter|perplexity|nvidia|elevenlabs|pixabay).");
5872
6050
  const ctx = commonContext(parsed.values);
5873
6051
  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 } });
5874
6052
  assertApiOk(result, "add-provider-key");
@@ -87,8 +87,12 @@ export function resolveCostMode(values) {
87
87
  }
88
88
  export const COST_MODE_BLURB = {
89
89
  minimize: "Minimize costs — stay on FREE local compute wherever possible (local render, " +
90
- "local TTS/STT, local greenscreen/matting, reused clips + HTML motion). Billed " +
91
- "cloud/AI generation is refused unless you re-confirm it (--yes).",
90
+ "local TTS/STT, local greenscreen/matting, reused clips + HTML motion). For assets, " +
91
+ 'reach for the free stock catalog first vidfarm media search "<meaning>" --type ' +
92
+ "bgm|sfx|image|vector|icon|video. Check the KEYLESS sources first: Openverse (CC/CC0 " +
93
+ "music, SFX, images) and iconify (icons) need no account at all. A free Pixabay key adds " +
94
+ "photos/vectors/stock-video and may already be saved (check vidfarm provider-keys). " +
95
+ "Billed cloud/AI generation is refused unless you re-confirm it (--yes).",
92
96
  hybrid: "Hybrid (recommended) — free where it's free, spend AI credits only where they " +
93
97
  "clearly win (a hero shot, a voice you can't fake locally). Billed ops run but " +
94
98
  "each prints its cost so nothing is a surprise.",
@@ -104,9 +108,14 @@ export function costModeSummaryLine(resolved) {
104
108
  export function costModeExplainer() {
105
109
  return [
106
110
  "How much do you want Vidfarm to spend on AI credits?",
107
- " • minimize — cheapest: free local compute, no surprise AI spend.",
111
+ " • minimize — cheapest: free local compute + free stock media, no surprise AI spend.",
108
112
  " • hybrid — recommended: free where free, pay AI only where it clearly wins.",
109
- " • pure-ai — best quality: use AI image/video/voice/music freely."
113
+ " • pure-ai — best quality: use AI image/video/voice/music freely.",
114
+ "Tip: before paying to generate music/SFX/images/video, try the free stock catalog —",
115
+ ' vidfarm media search "<meaning>" --type bgm|sfx|image|vector|icon|video.',
116
+ " Check the keyless sources first — Openverse (CC/CC0 music, SFX, images) and iconify (icons)",
117
+ " need no account. A free Pixabay key adds photos/vectors/stock-video (it may already be saved —",
118
+ " check `vidfarm provider-keys` or vidfarm.cc/settings/developer; add via `vidfarm add-provider-key pixabay <key>`)."
110
119
  ].join("\n");
111
120
  }
112
121
  export class CostModeBlockedError extends Error {
@@ -420,7 +420,7 @@ function localRenderStatusJson(renderId, record) {
420
420
  title: record.title,
421
421
  status: record.status,
422
422
  progress,
423
- cost: "$0.00 (local render)",
423
+ cost: "$0.00 (local Vidfarm render)",
424
424
  outputUrl: record.status === "SUCCEEDED" ? `/local-renders/${encodeURIComponent(renderId)}.mp4` : undefined,
425
425
  error: record.error,
426
426
  fatalErrorEncountered: record.status === "FAILED" ? true : undefined
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@officexapp/vidfarm-devcli",
3
- "version": "0.21.10",
3
+ "version": "0.21.12",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1266,12 +1266,25 @@ html,body{margin:0;background:#050604;color:#fffbe6}
1266
1266
  // Desktop-agents nudge banner: a full-width alert pinned to the top of the
1267
1267
  // /editor and /chat surfaces pointing to the setup guide. Both surfaces embed
1268
1268
  // this chrome script, so gate strictly by pathname. Dismissible per session.
1269
+ // The nudge is aimed at the DIRECTOR (the paid operator who drives Vidfarm with
1270
+ // a desktop AI agent). A client reviewing a shared video in the editor should
1271
+ // NOT be told to go install Claude Code / Codex — so on the editor we suppress
1272
+ // it for free-tier (client / reviewer) sessions, read from the editor boot JSON.
1269
1273
  (function mountDesktopAgentsBanner() {
1270
1274
  try {
1271
1275
  var path = location.pathname;
1272
1276
  var onEditor = path === '/editor' || path.indexOf('/editor/') === 0;
1273
1277
  var onChat = path === '/chat' || path.indexOf('/chat/') === 0;
1274
1278
  if (!onEditor && !onChat) return;
1279
+ if (onEditor) {
1280
+ var bootEl = document.getElementById('hf-boot');
1281
+ if (bootEl) {
1282
+ try {
1283
+ var boot = JSON.parse(bootEl.textContent || '{}');
1284
+ if (boot && boot.freeTier) return;
1285
+ } catch (e) {}
1286
+ }
1287
+ }
1275
1288
  if (document.querySelector('.vf-topbanner')) return;
1276
1289
  if (sessionStorage.getItem('vf-topbanner-dismissed') === '1') return;
1277
1290
  var GUIDE = '/blog/desktop-ai-agents';
@@ -1575,12 +1575,25 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
1575
1575
  // Desktop-agents nudge banner: a full-width alert pinned to the top of the
1576
1576
  // /editor and /chat surfaces pointing to the setup guide. Both surfaces embed
1577
1577
  // this chrome script, so gate strictly by pathname. Dismissible per session.
1578
+ // The nudge is aimed at the DIRECTOR (the paid operator who drives Vidfarm with
1579
+ // a desktop AI agent). A client reviewing a shared video in the editor should
1580
+ // NOT be told to go install Claude Code / Codex — so on the editor we suppress
1581
+ // it for free-tier (client / reviewer) sessions, read from the editor boot JSON.
1578
1582
  (function mountDesktopAgentsBanner() {
1579
1583
  try {
1580
1584
  var path = location.pathname;
1581
1585
  var onEditor = path === '/editor' || path.indexOf('/editor/') === 0;
1582
1586
  var onChat = path === '/chat' || path.indexOf('/chat/') === 0;
1583
1587
  if (!onEditor && !onChat) return;
1588
+ if (onEditor) {
1589
+ var bootEl = document.getElementById('hf-boot');
1590
+ if (bootEl) {
1591
+ try {
1592
+ var boot = JSON.parse(bootEl.textContent || '{}');
1593
+ if (boot && boot.freeTier) return;
1594
+ } catch (e) {}
1595
+ }
1596
+ }
1584
1597
  if (document.querySelector('.vf-topbanner')) return;
1585
1598
  if (sessionStorage.getItem('vf-topbanner-dismissed') === '1') return;
1586
1599
  var GUIDE = '/blog/desktop-ai-agents';
@@ -2529,12 +2529,25 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2529
2529
  // Desktop-agents nudge banner: a full-width alert pinned to the top of the
2530
2530
  // /editor and /chat surfaces pointing to the setup guide. Both surfaces embed
2531
2531
  // this chrome script, so gate strictly by pathname. Dismissible per session.
2532
+ // The nudge is aimed at the DIRECTOR (the paid operator who drives Vidfarm with
2533
+ // a desktop AI agent). A client reviewing a shared video in the editor should
2534
+ // NOT be told to go install Claude Code / Codex — so on the editor we suppress
2535
+ // it for free-tier (client / reviewer) sessions, read from the editor boot JSON.
2532
2536
  (function mountDesktopAgentsBanner() {
2533
2537
  try {
2534
2538
  var path = location.pathname;
2535
2539
  var onEditor = path === '/editor' || path.indexOf('/editor/') === 0;
2536
2540
  var onChat = path === '/chat' || path.indexOf('/chat/') === 0;
2537
2541
  if (!onEditor && !onChat) return;
2542
+ if (onEditor) {
2543
+ var bootEl = document.getElementById('hf-boot');
2544
+ if (bootEl) {
2545
+ try {
2546
+ var boot = JSON.parse(bootEl.textContent || '{}');
2547
+ if (boot && boot.freeTier) return;
2548
+ } catch (e) {}
2549
+ }
2550
+ }
2538
2551
  if (document.querySelector('.vf-topbanner')) return;
2539
2552
  if (sessionStorage.getItem('vf-topbanner-dismissed') === '1') return;
2540
2553
  var GUIDE = '/blog/desktop-ai-agents';
@@ -2116,12 +2116,25 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2116
2116
  // Desktop-agents nudge banner: a full-width alert pinned to the top of the
2117
2117
  // /editor and /chat surfaces pointing to the setup guide. Both surfaces embed
2118
2118
  // this chrome script, so gate strictly by pathname. Dismissible per session.
2119
+ // The nudge is aimed at the DIRECTOR (the paid operator who drives Vidfarm with
2120
+ // a desktop AI agent). A client reviewing a shared video in the editor should
2121
+ // NOT be told to go install Claude Code / Codex — so on the editor we suppress
2122
+ // it for free-tier (client / reviewer) sessions, read from the editor boot JSON.
2119
2123
  (function mountDesktopAgentsBanner() {
2120
2124
  try {
2121
2125
  var path = location.pathname;
2122
2126
  var onEditor = path === '/editor' || path.indexOf('/editor/') === 0;
2123
2127
  var onChat = path === '/chat' || path.indexOf('/chat/') === 0;
2124
2128
  if (!onEditor && !onChat) return;
2129
+ if (onEditor) {
2130
+ var bootEl = document.getElementById('hf-boot');
2131
+ if (bootEl) {
2132
+ try {
2133
+ var boot = JSON.parse(bootEl.textContent || '{}');
2134
+ if (boot && boot.freeTier) return;
2135
+ } catch (e) {}
2136
+ }
2137
+ }
2125
2138
  if (document.querySelector('.vf-topbanner')) return;
2126
2139
  if (sessionStorage.getItem('vf-topbanner-dismissed') === '1') return;
2127
2140
  var GUIDE = '/blog/desktop-ai-agents';
@@ -3709,12 +3709,25 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3709
3709
  // Desktop-agents nudge banner: a full-width alert pinned to the top of the
3710
3710
  // /editor and /chat surfaces pointing to the setup guide. Both surfaces embed
3711
3711
  // this chrome script, so gate strictly by pathname. Dismissible per session.
3712
+ // The nudge is aimed at the DIRECTOR (the paid operator who drives Vidfarm with
3713
+ // a desktop AI agent). A client reviewing a shared video in the editor should
3714
+ // NOT be told to go install Claude Code / Codex — so on the editor we suppress
3715
+ // it for free-tier (client / reviewer) sessions, read from the editor boot JSON.
3712
3716
  (function mountDesktopAgentsBanner() {
3713
3717
  try {
3714
3718
  var path = location.pathname;
3715
3719
  var onEditor = path === '/editor' || path.indexOf('/editor/') === 0;
3716
3720
  var onChat = path === '/chat' || path.indexOf('/chat/') === 0;
3717
3721
  if (!onEditor && !onChat) return;
3722
+ if (onEditor) {
3723
+ var bootEl = document.getElementById('hf-boot');
3724
+ if (bootEl) {
3725
+ try {
3726
+ var boot = JSON.parse(bootEl.textContent || '{}');
3727
+ if (boot && boot.freeTier) return;
3728
+ } catch (e) {}
3729
+ }
3730
+ }
3718
3731
  if (document.querySelector('.vf-topbanner')) return;
3719
3732
  if (sessionStorage.getItem('vf-topbanner-dismissed') === '1') return;
3720
3733
  var GUIDE = '/blog/desktop-ai-agents';
@@ -2529,12 +2529,25 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2529
2529
  // Desktop-agents nudge banner: a full-width alert pinned to the top of the
2530
2530
  // /editor and /chat surfaces pointing to the setup guide. Both surfaces embed
2531
2531
  // this chrome script, so gate strictly by pathname. Dismissible per session.
2532
+ // The nudge is aimed at the DIRECTOR (the paid operator who drives Vidfarm with
2533
+ // a desktop AI agent). A client reviewing a shared video in the editor should
2534
+ // NOT be told to go install Claude Code / Codex — so on the editor we suppress
2535
+ // it for free-tier (client / reviewer) sessions, read from the editor boot JSON.
2532
2536
  (function mountDesktopAgentsBanner() {
2533
2537
  try {
2534
2538
  var path = location.pathname;
2535
2539
  var onEditor = path === '/editor' || path.indexOf('/editor/') === 0;
2536
2540
  var onChat = path === '/chat' || path.indexOf('/chat/') === 0;
2537
2541
  if (!onEditor && !onChat) return;
2542
+ if (onEditor) {
2543
+ var bootEl = document.getElementById('hf-boot');
2544
+ if (bootEl) {
2545
+ try {
2546
+ var boot = JSON.parse(bootEl.textContent || '{}');
2547
+ if (boot && boot.freeTier) return;
2548
+ } catch (e) {}
2549
+ }
2550
+ }
2538
2551
  if (document.querySelector('.vf-topbanner')) return;
2539
2552
  if (sessionStorage.getItem('vf-topbanner-dismissed') === '1') return;
2540
2553
  var GUIDE = '/blog/desktop-ai-agents';