@mevdragon/vidfarm-devcli 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.director.md +107 -15
- package/SKILL.platform.md +15 -11
- package/dist/src/cli.js +550 -7
- package/package.json +1 -5
package/SKILL.director.md
CHANGED
|
@@ -72,7 +72,8 @@ Priority: Gemini → OpenAI → OpenRouter. If a director has no keys, smart dec
|
|
|
72
72
|
Templates are listed on the Vidfarm homepage and `/discover`. Each has a `template_...` id, a preview MP4, source video metadata, and viral DNA.
|
|
73
73
|
|
|
74
74
|
- Browser: browse `/discover`
|
|
75
|
-
- API: `GET /discover/feed` — returns `{ templates: [{ templateId, slugId, title, previewUrl, viralDna, durationSeconds, sourceType, ... }], next_cursor }`
|
|
75
|
+
- API: `GET /discover/feed` — returns `{ templates: [{ templateId, slugId, title, previewUrl, viralDna, durationSeconds, sourceType, promotions, keywords, summary, ... }], next_cursor }`
|
|
76
|
+
- Search: `GET /discover/feed?q=<offer>&limit=20` keyword-searches the catalog so you can answer "which templates suit my `<offer>`?" (e.g. `?q=weight%20loss%20app`). Ranking is a coarse keyword match over each template's title, viral DNA, and the decompose-derived `promotions` (offer categories the format can sell), `keywords`, and `summary` — read those fields to judge fit and rank the best 3-6 semantically. `GET /api/v1/videos?q=<offer>&limit=20[&mine=true]` runs the same search over source **inspirations**. `promotions`/`keywords`/`summary` are populated by the decompose pass, so a freshly ingested template that has not been decomposed yet won't match on them — decompose it (or fall back to broader/synonym terms) if a search comes up empty.
|
|
76
77
|
|
|
77
78
|
Each template exposes a public preview:
|
|
78
79
|
|
|
@@ -90,7 +91,7 @@ To bring a new viral video into the catalog as a **private** template you own, i
|
|
|
90
91
|
- `DELETE /discover/templates/:entryId` — remove a private inspiration/template you own (accepts either the `inspiration_...` or minted `template_...` id).
|
|
91
92
|
- `POST /api/v1/inspirations/:inspirationId/decompose { user_prompt? }` — AI-decompose an inspiration's downloaded video into scenes (requires a saved provider key; same 120s source cap as auto-decompose).
|
|
92
93
|
|
|
93
|
-
devcli: `vidfarm inspiration-add <url>`, `vidfarm inspiration-rm <id>`, `vidfarm inspiration-decompose <id>`, and `vidfarm
|
|
94
|
+
devcli: `vidfarm inspiration-add <url>`, `vidfarm inspiration-rm <id>`, `vidfarm inspiration-decompose <id>`, `vidfarm discover [query]` to browse/search templates, and `vidfarm videos [query] [--mine]` to browse/search source inspirations.
|
|
94
95
|
|
|
95
96
|
## Fork a template
|
|
96
97
|
|
|
@@ -122,7 +123,7 @@ The editor is a full timeline surface:
|
|
|
122
123
|
- **Timeline** — multi-track, drag/trim/split clips, group/ungroup layers
|
|
123
124
|
- **Inspector panel** — per-layer property editor (text, color, font, position, size, media source, playback start, volume)
|
|
124
125
|
- **Canvas frame overlay** — 8-handle resize + WYSIWYG positioning
|
|
125
|
-
- **Chat panel** — AI assistant that can programmatically edit the composition (
|
|
126
|
+
- **Chat panel** — AI assistant that can programmatically edit the composition (`add_layer`, `remove_layer`, `set_layer_timing/visual/style/media/text`, `group_layers`, `generate_layer`, `replace_composition_html`, ...). The assistant uses the caller's saved provider keys. **`generate_layer`** is the one-shot "generate AI media and place it": it submits the `/videos/generate` or `/images/generate` primitive job, drops a placeholder clip into the target slot (fill a blank gap, replace a scene, or overlay), and auto-swaps in the finished media when the job settles — no manual polling. In-flight generations surface in `editor_context.pending_generations`.
|
|
126
127
|
- **Auto-save** — every commit writes composition.html + composition.json to the working state. No explicit save button.
|
|
127
128
|
|
|
128
129
|
The editor persists to:
|
|
@@ -203,12 +204,42 @@ Use it whenever you need to know what the video says or shows: writing/translati
|
|
|
203
204
|
- **Editor chat (frontend AI)** exposes this as the optional `video_context` tool — the copilot calls it on demand.
|
|
204
205
|
- **Desktop agents (Claude Code / Codex)**: fetch the `video-context.json` route directly (e.g. `vidfarm api GET /api/v1/compositions/<forkId>/video-context.json`) to ground edits in what the video says and shows.
|
|
205
206
|
|
|
207
|
+
## Generate AI media and drop it on the timeline
|
|
208
|
+
|
|
209
|
+
Both surfaces can generate a NEW AI video/image (optionally conditioned on reference images) and place it into the timeline — filling a blank gap or replacing a scene — using the async generation primitives (`POST /api/v1/primitives/videos/generate`, `/images/generate`). Generation is a **queued job**: the POST returns `202 { job_id }`, and the finished media URL only appears after the job settles (`result.primary_file_url` / `video.file_url` / an artifact `public_url`). Poll `GET /api/v1/user/me/jobs/:jobId` until `status: "succeeded"`.
|
|
210
|
+
|
|
211
|
+
**Cost gate (see the Cost spectrum section):** AI **video** generation is the expensive end of the spectrum ($1–$10+/video) — **ask the director's permission before generating video**, and prefer reusing existing footage (My Files / their computer / a web search) or filming when it would do. **Image** generation is cheap — use it freely without asking. When a director wants specificity that only AI video can deliver and their budget allows, this is the right tool; just confirm first.
|
|
212
|
+
|
|
213
|
+
- **Editor web copilot**: use the `generate_layer` editor_action — it does the whole flow (submit job → placeholder clip → auto-swap on completion) and reads `pending_generations` from `editor_context`. Set `media_type`, `prompt`, `aspect_ratio` (match the canvas), and `intent` (`fill_gap` with `start`/`duration`, `replace_layer` with `replace_layer_key`, or `add`). For character consistency pass a cast `reference_url` in `input_references` (video) / `prompt_attachments` (image).
|
|
214
|
+
|
|
215
|
+
- **Desktop agents (Claude Code / Codex)** — two devcli commands:
|
|
216
|
+
```bash
|
|
217
|
+
# 1) Sync the fork to disk and see where the blank space and scenes are
|
|
218
|
+
vidfarm pull <forkId> --dir ./work
|
|
219
|
+
# → prints timeline gaps (e.g. 4-7s) and scene/layer keys, canvas aspect ratio,
|
|
220
|
+
# and whether video-context.json / cast.json are present for grounding.
|
|
221
|
+
|
|
222
|
+
# 2) Generate + place in one call (fill a gap)
|
|
223
|
+
vidfarm generate video --prompt "her walking through the door" \
|
|
224
|
+
--ref ./cast/girl.png --aspect-ratio 9:16 --duration 4 \
|
|
225
|
+
--place ./work --at 4
|
|
226
|
+
# → polls the job to the finished MP4, then inserts it at 4s in ./work/composition.html
|
|
227
|
+
|
|
228
|
+
# …or replace a scene with a generated still
|
|
229
|
+
vidfarm generate image --prompt "product hero on marble" --aspect-ratio 9:16 \
|
|
230
|
+
--place ./work --replace <layer_key>
|
|
231
|
+
|
|
232
|
+
# 3) Push it back (or it live-morphs automatically under `vidfarm serve`)
|
|
233
|
+
vidfarm publish --dir ./work --fork <forkId>
|
|
234
|
+
```
|
|
235
|
+
`--ref @localfile` (or a bare path) auto-uploads the image and uses its durable URL as a reference. `generate` prints the media URL (drop `--place` to just get the URL); `place` can be run separately on any media URL (`vidfarm place ./work --src <url> --at <time>|--replace <layer_key>`). Placement produces the exact same clip markup the browser editor makes, so a human can finish it in the Trackpad Editor.
|
|
236
|
+
|
|
206
237
|
## Render (publish to MP4)
|
|
207
238
|
|
|
208
|
-
Rendering publishes the fork's current working state to an MP4 using
|
|
239
|
+
Rendering publishes the fork's current working state to an MP4 using HyperFrames (cloud Lambda fan-out on the deployed host, or the free in-process renderer on a local `vidfarm serve` box). In the Trackpad Editor this is the **Render** button. The REST route is `/render`; the older `/export` path is kept as a deprecated alias for already-published devcli clients (treat "Render", "publish", `/render`, and `/export` as the same operation). The devcli exposes it as `vidfarm render <forkId> [--wait]`.
|
|
209
240
|
|
|
210
241
|
```
|
|
211
|
-
POST /api/v1/compositions/:forkId/
|
|
242
|
+
POST /api/v1/compositions/:forkId/render
|
|
212
243
|
Content-Type: application/json
|
|
213
244
|
|
|
214
245
|
{ "title": "optional", "version": null, "tracer": "optional-trace-id", "html": "optional HTML to save to working state before rendering" }
|
|
@@ -314,15 +345,40 @@ DELETE /api/v1/compositions/:forkId
|
|
|
314
345
|
|
|
315
346
|
Soft-delete only. The fork's `deletedAt` is set. Versions and share links are preserved for audit.
|
|
316
347
|
|
|
348
|
+
## Cost spectrum (free → $10+/video) — default to saving the director money
|
|
349
|
+
|
|
350
|
+
There is no single price for a video. The **approach** the director picks sets the cost, across a wide spectrum. Always steer toward the cheapest approach that still meets their goal, and make the tradeoff explicit rather than silently choosing an expensive path.
|
|
351
|
+
|
|
352
|
+
| Approach | Typical cost | How |
|
|
353
|
+
|---|---|---|
|
|
354
|
+
| **Reuse + render locally** | **free** | Fork an already-decomposed template, swap captions / images / video with existing MP4s (from **My Files**, the director's **local computer**, or a **web search**), and render **locally** via `vidfarm serve` (native in-process HyperFrames render — free and unguarded, no cloud). |
|
|
355
|
+
| **Reuse + cloud render** | **~$0.001 – $0.03** | Same reuse, but render on the cloud renderer (`POST /compositions/:forkId/render`, ~$0.01–$0.10 depending on length/res). Cheap **image** generation/edits fit in this band too. |
|
|
356
|
+
| **AI-generate some scenes** | **~$1** | Replace a few scenes with AI-generated video clips for high specificity/customization (see the "Generate AI media" section). |
|
|
357
|
+
| **Heavy AI generation** | **$10+** | Many/long AI video clips, custom characters, fully bespoke scenes. |
|
|
358
|
+
|
|
359
|
+
Rules of thumb:
|
|
360
|
+
|
|
361
|
+
- **Reusing existing footage — or footage the director films themselves — is the cheapest.** AI-generating characters/scenes is the most expensive. Editing captions/images on a reused template is nearly free.
|
|
362
|
+
- **Local render is free; cloud render costs pennies.** If the director is iterating with a coding agent on a `vidfarm serve` box, they can render locally at no charge until they want the durable cloud MP4.
|
|
363
|
+
- **Decompose is a one-time ~$0.10** (smart decompose ≈ provider passthrough + GhostCut ~$0.10/30s) per **new** source video — but the director can **skip it entirely** by forking a template that's **already decomposed** in the catalog.
|
|
364
|
+
- **Image generation is cheap** — use it freely, no need to ask permission.
|
|
365
|
+
- **AI video generation is expensive — ask the director's permission before using it.** Default to reuse/local/image approaches unless they've okayed video gen or told you the budget covers it.
|
|
366
|
+
- **Ask about budget.** During Getting Started (and whenever it's relevant per editor project, or when the director asks about cost), ask roughly what they want to spend per video, and pick the approach band that fits. If they haven't said, assume the cheapest approach that works.
|
|
367
|
+
|
|
368
|
+
The director always chooses the method that works for them — the point is to surface the tradeoff and default to thrift, not to force the cheapest path.
|
|
369
|
+
|
|
317
370
|
## Billing
|
|
318
371
|
|
|
319
372
|
Vidfarm charges directly in USD from the caller's wallet. There are no credits.
|
|
320
373
|
|
|
321
374
|
- **Wallet top-up** — Stripe checkout via the Settings UI, backed by `POST /settings/wallet/funding-link` (browser session; returns `{ checkout_url, client_reference_id }`)
|
|
322
375
|
- **Balance & history** — shown on the Settings page; billing events via `GET /u/:customerId/settings/wallet/events` (browser session). There is no `/api/v1/user/me/wallet` REST endpoint.
|
|
323
|
-
- **Cost anchors
|
|
324
|
-
-
|
|
325
|
-
-
|
|
376
|
+
- **Cost anchors** (see the Cost spectrum section above for how these combine per approach):
|
|
377
|
+
- Local render on a `vidfarm serve` box (in-process HyperFrames): **free**
|
|
378
|
+
- Cloud render (HyperFrames Lambda fan-out): typically $0.01 – $0.10 per MP4 depending on length/resolution
|
|
379
|
+
- Image generation / edit: cheap (provider passthrough) — use freely
|
|
380
|
+
- AI **video** generation: expensive ($1–$10+/video territory) — ask permission first
|
|
381
|
+
- Auto-decompose smart mode: pass-through of caller's AI provider spend (~$0.10 one-time with GhostCut), or skip it by forking an already-decomposed template
|
|
326
382
|
- Auto-decompose time-slice: free
|
|
327
383
|
- GhostCut subtitle removal: ~$0.10 per 30 seconds of source video
|
|
328
384
|
|
|
@@ -339,7 +395,7 @@ For agents that operate Vidfarm headlessly, the typical loop is:
|
|
|
339
395
|
1. `POST /api/v1/compositions { template_id }` → fork
|
|
340
396
|
2. `PUT /api/v1/compositions/:forkId/composition.html` → apply edits
|
|
341
397
|
3. `PATCH /api/v1/compositions/:forkId/composition.json` → update metadata
|
|
342
|
-
4. `POST /api/v1/compositions/:forkId/
|
|
398
|
+
4. `POST /api/v1/compositions/:forkId/render { tracer }` → render
|
|
343
399
|
5. Poll `GET /api/v1/compositions/:forkId/renders/:renderId` until `SUCCEEDED`
|
|
344
400
|
6. Use `outputUrl` in the next stage
|
|
345
401
|
|
|
@@ -353,16 +409,20 @@ Send a stable `tracer` on export so retries are traceable and filterable in job
|
|
|
353
409
|
|
|
354
410
|
| Command | REST route | Flow step |
|
|
355
411
|
|---|---|---|
|
|
356
|
-
| `vidfarm discover` | `GET /discover/feed` | browse
|
|
412
|
+
| `vidfarm discover [query]` | `GET /discover/feed[?q=]` | browse/search templates |
|
|
413
|
+
| `vidfarm videos [query] [--mine]` | `GET /api/v1/videos[?q=&mine=]` | browse/search source inspirations |
|
|
357
414
|
| `vidfarm inspiration-add <url>` | `POST /discover/templates` | add your own source |
|
|
358
415
|
| `vidfarm inspiration-rm <id>` | `DELETE /discover/templates/:id` | remove a private one |
|
|
359
416
|
| `vidfarm inspiration-decompose <id>` | `POST /api/v1/inspirations/:id/decompose` | AI-decompose an inspiration |
|
|
360
417
|
| `vidfarm fork <template_id>` | `POST /api/v1/compositions` | fork a template |
|
|
418
|
+
| `vidfarm pull <forkId> [--dir <p>]` | `GET .../compositions/:forkId/{composition.html,json,video-context.json,cast.json}` | sync a fork to disk + print gaps/scene keys |
|
|
419
|
+
| `vidfarm generate <image\|video> --prompt "…"` | `POST /api/v1/primitives/{images,videos}/generate` (polls job) | generate AI media → finished URL |
|
|
420
|
+
| `vidfarm place <dir> --src <url> [--at\|--replace]` | (edits local composition.html) | drop media into a gap / over a scene |
|
|
361
421
|
| `vidfarm decompose <forkId>` | `POST .../compositions/:forkId/auto-decompose` | split source into scenes |
|
|
362
422
|
| `vidfarm ghostcut <forkId>` | `GET .../compositions/:forkId/ghostcut` | subtitle-removal status |
|
|
363
423
|
| `vidfarm snapshot <forkId>` | `POST .../compositions/:forkId/versions` | save composition fork version |
|
|
364
424
|
| `vidfarm versions <forkId>` | `GET .../compositions/:forkId/versions` | list versions |
|
|
365
|
-
| `vidfarm render <forkId> [--wait]` | `POST .../compositions/:forkId/
|
|
425
|
+
| `vidfarm render <forkId> [--wait]` | `POST .../compositions/:forkId/render` | render video to MP4 |
|
|
366
426
|
| `vidfarm render-status <forkId> <renderId>` | `GET .../compositions/:forkId/renders/:renderId` | poll a render |
|
|
367
427
|
| `vidfarm visibility <forkId> <private\|public>` | `PATCH .../compositions/:forkId/visibility` | set visibility |
|
|
368
428
|
| `vidfarm clone <forkId>` | `POST .../compositions/:forkId/clone` | clone a fork |
|
|
@@ -374,12 +434,28 @@ Send a stable `tracer` on export so retries are traceable and filterable in job
|
|
|
374
434
|
| `vidfarm login <email>` / `vidfarm verify <email> <code>` | `POST /api/v1/user/request-otp` · `verify-otp` | get an API key |
|
|
375
435
|
| `vidfarm whoami` | `GET /api/v1/user/me` | who am I |
|
|
376
436
|
| `vidfarm provider-keys` / `vidfarm add-provider-key <p> <secret>` | `GET`·`POST /api/v1/user/me/provider-keys` | manage AI keys |
|
|
377
|
-
| `vidfarm upload <file
|
|
437
|
+
| `vidfarm upload <file> [--folder <path>]` | `POST /api/v1/user/me/temporary-files/upload` | upload → durable URL (ephemeral) |
|
|
378
438
|
| `vidfarm download <url> [dest]` | (streams any URL to disk) | download media |
|
|
439
|
+
| `vidfarm files [--folder <path>]` | `GET /api/v1/user/me/attachments` | list My Files assets + folders |
|
|
440
|
+
| `vidfarm get-file <id> [dest] [--print]` | (resolve id → view_url, then stream/print) | read one My Files asset |
|
|
441
|
+
| `vidfarm put-file <file> [--folder <p>] [--as <name>] [--content/--stdin]` | `POST /api/v1/user/me/attachments/upload` | write into My Files (persistent) |
|
|
379
442
|
|
|
380
443
|
**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).
|
|
381
444
|
|
|
382
|
-
**Uploads/downloads live in the devcli** because they are genuine multi-step / streaming flows: `upload` posts the file as multipart to the temporary-files route and prints the durable URL to drop into a composition or approved post; `download` streams any Vidfarm/media URL to disk.
|
|
445
|
+
**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.
|
|
446
|
+
|
|
447
|
+
## My Files (the user's asset library)
|
|
448
|
+
|
|
449
|
+
Each user has a persistent **My Files** filesystem — their own uploaded videos (mp4/mov/webm), images (png/jpg/jpeg/gif/webp/svg), audio (mp3/wav/m4a/aac), and documents (pdf/md/txt/csv) — organized into **virtual folders**. This is where a user keeps footage, brand assets, logos, music, briefs, scripts, and the durable strategy docs from Getting Started (About.md, awareness-levels.md, etc.). Both the editor AI copilot and an agent CLI can **read and write** it to find or save assets instead of asking the user to re-upload or paste URLs.
|
|
450
|
+
|
|
451
|
+
- **List** — `GET /api/v1/user/me/attachments` → `{ attachments: [{ id, fileName, contentType, sizeBytes, folderPath, viewUrl, createdAt }], folders: [string] }`. The `viewUrl` is a durable URL you can drop into a composition layer or pass into a primitive route. devcli: `vidfarm files [--folder <path>]`.
|
|
452
|
+
- **Read one** — resolve it from the list and stream/read its `viewUrl`. devcli: `vidfarm get-file <id> [dest]` (add `--print` to print text contents of md/txt/csv/json inline). For images/video/audio/pdf, reference the `viewUrl` as media — you can't read their bytes as text.
|
|
453
|
+
- **Write into My Files** — `POST /api/v1/user/me/attachments/upload` (multipart `file` + optional `folder_path`) persists a file into the library. devcli: `vidfarm put-file <localFile> --folder <path>`, or pipe/inline text without a temp file: `echo "…" | vidfarm put-file --stdin --as About.md --folder acme` / `vidfarm put-file --content "…" --as About.md`. This is how an agent saves Getting Started context (About.md, Interview.md, awareness-levels.md, persuasive-angles.md, ad-hooks.md) or drops a logo/product demo into a namescoped folder. In the web editor copilot the same capability is the **`browse_files` write action** (`action=write` with `file_name` + `content` + optional `folder_path`), which accepts text files (md/txt/csv/json/srt/vtt).
|
|
454
|
+
- **`vidfarm upload` is different** — it targets the **ephemeral** temporary-files store (`POST /api/v1/user/me/temporary-files/upload`) for a throwaway durable URL to drop straight into a composition or approved post. Use `put-file` when you want the asset to persist in the user's My Files library; use `upload` for one-shot media you just need a URL for.
|
|
455
|
+
|
|
456
|
+
**Assume My Files is multi-offer.** A user often runs more than one product, offer, brand, or region, and namescopes assets into folders accordingly — by product (`acme-skincare/`, `zensleep/`), by offer/campaign (`summer-sale/`), by region (`us/`, `eu/`), by asset type (`logos/`, `ugc-clips/`), or any arbitrary scheme. There is **no fixed layout** — read the `folders` tree first and reason about how this user organized things. Before pulling assets for a task, infer which folder(s) match the product/offer/region the current composition is about (match folder names to the composition title, video context, and what the user said), then scope reads to that folder so you never mix one brand's logo/product-shot/music into another brand's video. If the target offer is ambiguous, ask which product/offer/region (or which folder) this work is for rather than guessing across offers.
|
|
457
|
+
|
|
458
|
+
In the editor web copilot the same filesystem is exposed via the **`browse_files` tool** (`action=list` / `action=read` / `action=write`), so the copilot follows the identical reasoning: list folders, pick the right offer's folder, then read an asset — or `write` a text doc (About.md, awareness-levels.md, …) into that folder. The devcli equivalents are `vidfarm files` / `get-file` / `put-file`.
|
|
383
459
|
|
|
384
460
|
## Local dev loop (`vidfarm serve`)
|
|
385
461
|
|
|
@@ -409,7 +485,7 @@ To *analyze* the source media locally (videos, transcript, recurring cast), read
|
|
|
409
485
|
|
|
410
486
|
- Do **not** manipulate composition HTML by string concatenation. Always parse, edit, and re-serialize the DOM. The editor and runtime rely on `data-start`, `data-duration`, `data-track-index`, `data-hf-id`, `data-composition-id` attributes being well-formed.
|
|
411
487
|
- Do **not** save AI provider keys in the composition HTML or JSON. They belong in the caller's provider-keys record.
|
|
412
|
-
- Do **not** call
|
|
488
|
+
- Do **not** call the renderer directly. Publishing goes through `POST /api/v1/compositions/:forkId/render` so billing, retries, cost caps, and Lambda quotas are enforced.
|
|
413
489
|
- Do **not** re-invent scene manipulation as REST commands (`AddScene`, `RemoveScene`, `UpdateSceneField`). The Trackpad Editor is the surface for that. The pattern is: mutate the composition HTML DOM, PUT it back.
|
|
414
490
|
|
|
415
491
|
## Alpha posture
|
|
@@ -420,6 +496,22 @@ Vidfarm is in active development. Endpoints, response shapes, and the editor UI
|
|
|
420
496
|
- treat `latest_version` as the source of truth for what the editor loaded
|
|
421
497
|
- if a publish fails, check `renders/:renderId` for the error phase and stderr before retrying
|
|
422
498
|
|
|
499
|
+
## Getting started / onboarding a new director
|
|
500
|
+
|
|
501
|
+
Only run this flow when the director signals they **don't know where to start** ("getting started", "help me set up", "I don't know where to begin"). If they already know what they want, skip straight to it — never force onboarding. Directors can also jump to any single step (e.g. "just find me a good template for X" → go straight to template discovery).
|
|
502
|
+
|
|
503
|
+
The point of onboarding is to build **durable, reusable context** in My Files, namescoped under the director's product/offer folder, so future chats and agents can read it back. Save each artifact as Markdown with `browse_files` write (web copilot) or `vidfarm put-file` (devcli):
|
|
504
|
+
|
|
505
|
+
1. **Product context** → `About.md` (basic offer/product) or a deeper `Interview.md` if they want depth. Drive the interview questions with `brainstorm/coldstart`.
|
|
506
|
+
2. **Awareness level** (Eugene Schwartz — problem-aware, solution-unaware, …) → `awareness-levels.md`. If it's genuinely unknown after thinking it through, note that ads for **every** level should be made and tested. Use `brainstorm/awareness_stages`.
|
|
507
|
+
3. **Persuasive angles** → `persuasive-angles.md`, via `brainstorm/angles`.
|
|
508
|
+
4. **Hooks** → `ad-hooks.md`, via `brainstorm/hooks`.
|
|
509
|
+
5. **Brand assets & demos** — ask if they have logos/mascots/themes (suggest a `/brand-assets/` folder, e.g. `/brand-assets/logo.png`) or product demos / screen recordings (suggest a `/product-demos/` folder). `browse_files list` / `vidfarm files` first to see what they already uploaded; filenames should be descriptive so assets are findable.
|
|
510
|
+
6. **Budget** — ask roughly what they want to spend per video, and map it to the Cost spectrum (free reuse+local render → pennies for cloud render → ~$1 for some AI scenes → $10+ for heavy AI gen). This sets which approach you default to and whether AI **video** generation is on the table (ask permission before using it; image gen is cheap and fine). Budget can also be revisited per editor project.
|
|
511
|
+
7. **Recommend & adapt a template** — pair what you now know about the offer against the decomposed template catalog (`GET /discover/feed?q=<offer>`, read each result's `promotions`/`keywords`/`summary`), recommend the best 3-6, then fork and **modify** the winner to fit their offer. Prefer already-decomposed templates so the director skips the ~$0.10 decompose cost.
|
|
512
|
+
|
|
513
|
+
**Assume multiple offers.** My Files is multi-offer (see the My Files section) — namescope every onboarding artifact under the right product/offer/region folder (`acme-skincare/About.md`, not a bare `About.md`) so one brand's context never bleeds into another's.
|
|
514
|
+
|
|
423
515
|
## Default assistance pattern
|
|
424
516
|
|
|
425
517
|
When a director asks "make me a video", the default sequence is:
|
|
@@ -459,7 +551,7 @@ Apply subtle camouflage transforms (zoom, tilt, rotate, saturation, playback spe
|
|
|
459
551
|
- Body: `{ "tracer": "...", "payload": { ...fields... }, "webhook_url"?: "..." }`
|
|
460
552
|
- 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.
|
|
461
553
|
- Response: standard primitive job. Poll to completion, then read `primary_file_url` (also `video.file_url` for MP4 or `image.file_url` for stills)
|
|
462
|
-
- Billing: metered as a
|
|
554
|
+
- Billing: metered as a HyperFrames render (via `hyperframes_lambda` on prod cloud render; free on a local serve box)
|
|
463
555
|
|
|
464
556
|
Payload fields:
|
|
465
557
|
|
package/SKILL.platform.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: vidfarm-platform
|
|
3
|
-
description: Vidfarm platform architecture reference. Use this for questions about how compositions, forks, versions, storage, DynamoDB records, the Trackpad Editor runtime,
|
|
3
|
+
description: Vidfarm platform architecture reference. Use this for questions about how compositions, forks, versions, storage, DynamoDB records, the Trackpad Editor runtime, HyperFrames render, editor-chat, primitives, and billing fit together. This is not a director skill (see SKILL.director.md) and not a runbook — it explains the system.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Vidfarm Platform Architecture
|
|
7
7
|
|
|
8
|
-
Vidfarm is a **serverless composition studio** for short-form video. Directors fork templates, edit compositions on a timeline (the Trackpad Editor), publish MP4s via
|
|
8
|
+
Vidfarm is a **serverless composition studio** for short-form video. Directors fork templates, edit compositions on a timeline (the Trackpad Editor), publish MP4s via HyperFrames render (shared cloud Lambda fan-out, or free in-process render on a local serve box), and share results.
|
|
9
9
|
|
|
10
10
|
This document is the architectural reference — what runs where, what data lives where, and how the pieces connect. Use it when planning changes, debugging cross-cutting behavior, or onboarding to the platform.
|
|
11
11
|
|
|
@@ -184,15 +184,15 @@ The editor is a single monolithic React component (~9k lines). It builds cleanly
|
|
|
184
184
|
|
|
185
185
|
Publish flow:
|
|
186
186
|
|
|
187
|
-
1. Client `POST /api/v1/compositions/:forkId/
|
|
187
|
+
1. Client `POST /api/v1/compositions/:forkId/render` (`/export` is a deprecated alias; the editor button and devcli command are both **Render**; treat "render", "publish", `/render`, and `/export` as the same operation)
|
|
188
188
|
2. API stamps `data-composition-id="<forkId>@v<N>"` into the composition HTML, PUTs the stamped HTML to working, and reads composition.json
|
|
189
189
|
3. API resolves the caller's provider keys (preflight check for required models)
|
|
190
190
|
4. API creates a `job` record and invokes the `hyperframes_render` primitive Lambda
|
|
191
|
-
5. The
|
|
191
|
+
5. The renderer runs HyperFrames: on the deployed host it stages the composition and dispatches to the shared HyperFrames render state machine (chunked Lambda fan-out); on a local `vidfarm serve` box with no cloud stack it renders in-process (headless Chromium → ffmpeg) for free. See `HyperframesService` in `src/services/hyperframes.ts`.
|
|
192
192
|
6. On success, the primitive returns `{ primary_file_url: "s3://.../output.mp4" }`, the API creates a `ForkVersionRecord` with `reason: "publish"`, writes the snapshot to `versions/<N>/`, and updates the fork's `latestVersion` and `currentRenderJobId`
|
|
193
193
|
7. Client polls `GET /api/v1/compositions/:forkId/renders/:renderId` for `{ status: "RUNNING" | "SUCCEEDED" | "FAILED", progress, outputUrl, cost }` (see `adaptJobToPublishStatus` in `src/app.ts`)
|
|
194
194
|
|
|
195
|
-
The
|
|
195
|
+
The HyperFrames render state machine is **shared across all templates and forks** — it's an infrastructure primitive, not a per-template deployment. Its ARN/bucket are passed to the Vidfarm stack as env vars (`HYPERFRAMES_RENDER_*`). Remotion has been fully removed.
|
|
196
196
|
|
|
197
197
|
## Auto-decompose
|
|
198
198
|
|
|
@@ -237,12 +237,14 @@ Configuration: `GHOSTCUT_KEY` and `GHOSTCUT_SECRET` env vars per stage. Pass-thr
|
|
|
237
237
|
|
|
238
238
|
The chat model uses the **caller's provider keys**. No Vidfarm-owned model calls happen in this path.
|
|
239
239
|
|
|
240
|
+
Beyond the Editor Action API, the chat harness registers shared tools defined in **two synced copies** (`src/app.ts` for the in-process dev server and `infra/lambda/editor-chat.ts` for the deployed Lambda — keep them in lockstep): `http_request` (allowlisted REST/primitive calls), `frontend_action` (tracer/thread state), `video_context`, and `browse_files`. `browse_files` reads **and writes** the caller's My Files library — `action=list`/`read`/`write`, where `write` saves a text file (md/txt/csv/json/srt/vtt) via `POST /me/attachments/upload`. Write is what lets the copilot persist Getting Started context (About.md, awareness-levels.md, persuasive-angles.md, ad-hooks.md) namescoped under a product/offer folder; the system prompt in `src/editor-chat.ts` drives when to save. The devcli mirror is `vidfarm put-file`.
|
|
241
|
+
|
|
240
242
|
## Primitives
|
|
241
243
|
|
|
242
244
|
Primitives are hosted operations that are NOT templates — they're reusable building blocks:
|
|
243
245
|
|
|
244
246
|
- `create_image`, `edit_image`, `inpaint_image` — image generation
|
|
245
|
-
- `create_html_image` —
|
|
247
|
+
- `create_html_image` — HyperFrames HTML → PNG (in-process headless Chromium)
|
|
246
248
|
- `remove_background` — RapidAPI-backed image background removal → durable PNG/WebP
|
|
247
249
|
- `download_media_video` — social/media URL → durable MP4
|
|
248
250
|
- `hyperframes_render` — composition HTML → MP4 (the render primitive publishing uses)
|
|
@@ -258,8 +260,11 @@ Key mechanics:
|
|
|
258
260
|
|
|
259
261
|
- Every wallet mutation carries a `tracer` (idempotency key)
|
|
260
262
|
- Provider spend is passed through at cost — no markup on AI provider calls
|
|
261
|
-
- Platform infrastructure (
|
|
263
|
+
- Platform infrastructure (cloud render, GhostCut, storage) has a small safety buffer applied
|
|
262
264
|
- Failed jobs are refunded automatically when the failure is on the platform side
|
|
265
|
+
- **Local `vidfarm serve` render is free + unguarded** — `publishCompositionToRenderer` runs the render in-process (`runPrimitiveJobInProcess`) and skips the wallet/paid guard when no cloud state machine is wired; only cloud render/templates are paid + wallet-guarded
|
|
266
|
+
|
|
267
|
+
**Cost spectrum (director-facing framing; the same guidance lives in `SKILL.director.md` and the editor-chat system prompt).** Per-video cost is set by the *approach*, not a fixed price: FREE (reuse a decomposed template, swap captions/images/existing MP4s, render locally) → ~$0.001–$0.03 (same reuse + cloud render ~$0.01–$0.10) → ~$1 (AI-generate a few scenes) → $10+ (heavy AI generation). Image generation is cheap; AI **video** generation is the expensive end — the copilot is instructed to ask permission before video gen but not before image gen. Smart decompose is a ~$0.10 one-time per new source (provider passthrough + GhostCut) and is avoided by forking already-decomposed catalog templates. The AI defaults to the cheapest approach that meets the goal and surfaces the tradeoff.
|
|
263
268
|
|
|
264
269
|
The billing Lambda has its own custom domain because Stripe webhooks require a stable URL not tied to the main API.
|
|
265
270
|
|
|
@@ -267,7 +272,7 @@ The billing Lambda has its own custom domain because Stripe webhooks require a s
|
|
|
267
272
|
|
|
268
273
|
There are two kinds of things directors can fork:
|
|
269
274
|
|
|
270
|
-
1. **Draft templates** — pre-decomposed compositions checked into the repo under `templates/vidfarm_template_XXXX/`. Each has a `composition.html`, `composition.json`, `template.config.json`, a `SKILL.md`, and a `src/`
|
|
275
|
+
1. **Draft templates** — pre-decomposed compositions checked into the repo under `templates/vidfarm_template_XXXX/`. Each has a `composition.html`, `composition.json`, `template.config.json`, a `SKILL.md`, and a `src/` for any custom assets. Registered templates open in the browser at `/editor/:templateId` (there is no `/hyperframes/:slug` route).
|
|
271
276
|
2. **Submitted videos** — raw uploads from directors, decomposed on demand via auto-decompose.
|
|
272
277
|
|
|
273
278
|
Templates are static. Compositions/forks are the mutable working copies.
|
|
@@ -281,7 +286,6 @@ Per-stage config is loaded via `.env.<stage>`. Critical variables:
|
|
|
281
286
|
- `VIDFARM_<STAGE>_ENCRYPTION_SECRET` — DynamoDB field encryption
|
|
282
287
|
- `VIDFARM_<STAGE>_API_KEY_SALT` — API key hashing
|
|
283
288
|
- `VIDFARM_<STAGE>_BOOTSTRAP_*` — initial admin user
|
|
284
|
-
- `VIDFARM_<STAGE>_REMOTION_*` — shared Remotion Lambda ARN + region
|
|
285
289
|
- `VIDFARM_<STAGE>_RESEND_*` — email
|
|
286
290
|
- `VIDFARM_<STAGE>_SENTRY_*` — Sentry DSN
|
|
287
291
|
- `GHOSTCUT_KEY`, `GHOSTCUT_SECRET` — GhostCut credentials
|
|
@@ -339,7 +343,7 @@ Legacy but still active:
|
|
|
339
343
|
## Common failure modes
|
|
340
344
|
|
|
341
345
|
- **Fork loads 404** — check ownership-agnostic migration ran; the fork may still be at the old `compositions/users/.../forks/` path.
|
|
342
|
-
- **Publish stuck in RUNNING** — check the primitive Lambda's CloudWatch logs.
|
|
346
|
+
- **Publish stuck in RUNNING** — check the primitive Lambda's CloudWatch logs. HyperFrames asset preflight failures usually mean a broken media URL in composition.html.
|
|
343
347
|
- **Smart decompose falls back to time-slice** — the caller has no provider keys, or all their keys returned errors. Check `provider_key_lease` records.
|
|
344
348
|
- **GhostCut task hangs** — client shows `pending` forever. Check the fork's `ghostcut.json` and the GhostCut dashboard. There is currently no cancel UI; the poll times out at 15 minutes.
|
|
345
349
|
- **Editor edits don't persist** — verify `PUT /composition.html` is 200. If the composition.html doesn't include `data-composition-id`, the API rejects the PUT at publish time.
|
|
@@ -362,7 +366,7 @@ Legacy but still active:
|
|
|
362
366
|
|
|
363
367
|
## Local editor dev loop (`vidfarm serve`)
|
|
364
368
|
|
|
365
|
-
`@mevdragon/vidfarm-devcli` (`src/cli.ts`) has two roles. First, `serve` boots the **full** Vidfarm backend locally — single origin, disk-backed records + storage — so Claude Code, Codex, or a human can edit `composition.html` on disk and see the browser live-morph the change (detailed below). Second, it wraps the **entire director REST flow** as 1:1 CLI commands — `discover`, `inspiration-add`, `fork`, `decompose`, `snapshot`, `render` (→ `/
|
|
369
|
+
`@mevdragon/vidfarm-devcli` (`src/cli.ts`) has two roles. First, `serve` boots the **full** Vidfarm backend locally — single origin, disk-backed records + storage — so Claude Code, Codex, or a human can edit `composition.html` on disk and see the browser live-morph the change (detailed below). Second, it wraps the **entire director REST flow** as 1:1 CLI commands — `discover`, `inspiration-add`, `fork`, `decompose`, `snapshot`, `render` (→ `/render`), `approve`, `schedule`, `posts`/`schedules`, `upload`/`download`, `files`/`get-file`/`put-file` (My Files read+write), `login`/`whoami`/`provider-keys`, plus a raw `api <METHOD> <path>` passthrough. Each command prints its prod frontend URL as a first-class output, and `--json` gives pure JSON for agents. The command↔route table lives in `SKILL.director.md`.
|
|
366
370
|
|
|
367
371
|
```bash
|
|
368
372
|
# Boot the full editor locally, pull a template's default fork onto disk, open pre-authed.
|
package/dist/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { Readable } from "node:stream";
|
|
9
9
|
import { pipeline } from "node:stream/promises";
|
|
10
|
+
import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./services/composition-edit.js";
|
|
10
11
|
// vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
|
|
11
12
|
// `serve` command boots the FULL editor locally (single origin, disk-backed
|
|
12
13
|
// records + storage) so power users edit compositions on disk while a browser
|
|
@@ -40,7 +41,8 @@ Usage:
|
|
|
40
41
|
|
|
41
42
|
Local editor loop:
|
|
42
43
|
serve [template_id] [opts] Boot the FULL editor locally (single origin, disk-backed).
|
|
43
|
-
Records + storage run on disk; render
|
|
44
|
+
Records + storage run on disk; render runs in-process on
|
|
45
|
+
this box for FREE (no cloud, no wallet charge).
|
|
44
46
|
With a template_id or --fork, pulls that composition
|
|
45
47
|
(or the template's default/shared decomposition) from
|
|
46
48
|
the cloud host onto local disk to edit offline.
|
|
@@ -56,19 +58,59 @@ Local editor loop:
|
|
|
56
58
|
publish [template_id] Push local composition.html/json to your fork → PUT/PATCH composition + POST versions
|
|
57
59
|
|
|
58
60
|
Discover & inspiration (browse the viral-video catalog, add your own source):
|
|
59
|
-
discover
|
|
61
|
+
discover [query...] List/search TEMPLATES (best for an offer) → GET /discover/feed
|
|
62
|
+
--query <text> Keyword search (e.g. "weight loss app")
|
|
63
|
+
--limit <n> Max results
|
|
64
|
+
videos [query...] List/search source INSPIRATIONS → GET /api/v1/videos
|
|
65
|
+
--query <text> Keyword search over the catalog
|
|
66
|
+
--mine Only your own uploaded inspirations
|
|
67
|
+
--limit <n> Max results
|
|
60
68
|
inspiration-add <url> Ingest a TikTok/YT/IG/X video as a template → POST /discover/templates
|
|
61
69
|
inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
|
|
62
70
|
inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
|
|
63
71
|
|
|
64
72
|
Composition lifecycle (fork → decompose → snapshot → render):
|
|
65
73
|
fork <template_id> Fork a template into an editable composition → POST /api/v1/compositions
|
|
74
|
+
pull <forkId> Sync composition.html/json + video-context → GET .../compositions/:forkId/{composition.html,...}
|
|
75
|
+
+ cast.json to disk, then print timeline gaps
|
|
76
|
+
(blank space) and scene/layer keys to target.
|
|
77
|
+
--dir <path> Local dir (default: .vidfarm/<forkId>)
|
|
78
|
+
--refetch Overwrite local copies with the cloud version
|
|
66
79
|
decompose <forkId> Split the fork's source into scenes (smart) → POST .../compositions/:forkId/auto-decompose
|
|
80
|
+
|
|
81
|
+
Generate AI media and drop it on the timeline (for local coding agents):
|
|
82
|
+
generate <image|video> Generate AI media, poll to the finished URL → POST /api/v1/primitives/{images,videos}/generate
|
|
83
|
+
--prompt <text> What to generate (required)
|
|
84
|
+
--ref <url|@file> Reference image (repeatable): input_references
|
|
85
|
+
for video, prompt_attachments for image. @file
|
|
86
|
+
auto-uploads a local image and uses its URL.
|
|
87
|
+
--aspect-ratio <r> e.g. 9:16 (match the composition canvas)
|
|
88
|
+
--duration <n> Video only: seconds of footage (e.g. 4)
|
|
89
|
+
--resolution <r> Video only: 480p|720p|1080p|1K|2K|4K
|
|
90
|
+
--audio Video only: generate an audio track
|
|
91
|
+
--provider <p> openai|gemini|openrouter (omit to auto-pick)
|
|
92
|
+
--model <m> Model override (omit for provider defaults)
|
|
93
|
+
--no-wait Return the job id instead of polling to the URL
|
|
94
|
+
--place <dir> After generating, place it into that composition
|
|
95
|
+
--at <time> | --replace <layer_key> Placement (fill gap vs replace scene)
|
|
96
|
+
place <dir|composition.html> Insert a media URL into a local composition
|
|
97
|
+
(fill a gap or replace a scene) — same clip markup
|
|
98
|
+
the browser editor makes; serve live-morphs it.
|
|
99
|
+
--src <url> Direct media URL to place (required)
|
|
100
|
+
--kind <video|image> Media kind (inferred from the URL if omitted)
|
|
101
|
+
--at <time> Start it at this time (seconds / mm:ss) — fill a gap
|
|
102
|
+
--replace <layer_key> Replace this scene/layer (copies its timing+geometry)
|
|
103
|
+
--duration <n> Timeline length in seconds
|
|
104
|
+
--track <n> Timeline layer index (auto if omitted)
|
|
105
|
+
--x --y --width --height Geometry as % (default full canvas 0/0/100/100)
|
|
106
|
+
--object-fit <fit> cover|contain|fill|none|scale-down (default cover)
|
|
67
107
|
ghostcut <forkId> Read the two video sources: original vs → GET .../compositions/:forkId/ghostcut
|
|
68
108
|
decomposed (caption-free), non-billing
|
|
69
109
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
70
110
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
71
|
-
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/
|
|
111
|
+
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
|
|
112
|
+
Cloud render costs ~$0.01-$0.10; against a local
|
|
113
|
+
'serve' host it renders in-process for FREE.
|
|
72
114
|
render-status <forkId> <renderId> Poll one render job → GET .../compositions/:forkId/renders/:renderId
|
|
73
115
|
visibility <forkId> <private|public> Set fork visibility → PATCH .../compositions/:forkId/visibility
|
|
74
116
|
clone <forkId> Clone a fork as a new starting point → POST .../compositions/:forkId/clone
|
|
@@ -96,13 +138,30 @@ Agent skill (install the latest director skill so your AI agent can act):
|
|
|
96
138
|
--print Print the skill to stdout instead of writing a file
|
|
97
139
|
|
|
98
140
|
Files (multi-step flows the devcli handles for you):
|
|
99
|
-
upload <file> Upload
|
|
141
|
+
upload <file> Upload to the EPHEMERAL temp store, print URL → POST /api/v1/user/me/temporary-files/upload
|
|
100
142
|
--folder <path> Namescope the upload under a product/offer folder
|
|
143
|
+
put-file [file] Write a file INTO My Files (persistent lib) → POST /api/v1/user/me/attachments/upload
|
|
144
|
+
--folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
|
|
145
|
+
--as <name> Name the saved file (required for --content/--stdin)
|
|
146
|
+
--content <text> Inline text to save (needs --as); or pipe stdin with --stdin
|
|
147
|
+
e.g. vidfarm put-file About.md --folder acme-skincare
|
|
148
|
+
echo "..." | vidfarm put-file --stdin --as About.md --folder acme
|
|
101
149
|
download <url> [dest] Stream any Vidfarm/media URL to disk
|
|
102
150
|
files List My Files assets + folders → GET /api/v1/user/me/attachments
|
|
103
151
|
--folder <path> Only files under this folder (e.g. a product/offer)
|
|
104
152
|
get-file <id> [dest] Resolve a My Files id to its URL and download it
|
|
105
153
|
--print Print text contents (md/txt/csv/json) instead of saving
|
|
154
|
+
put-file / get-file / files are the My Files (persistent) read+write set;
|
|
155
|
+
upload is the throwaway temp store for dropping media into a composition.
|
|
156
|
+
|
|
157
|
+
Cost spectrum (default to the cheapest approach that works; see SKILL.director.md):
|
|
158
|
+
free Reuse a decomposed template, swap captions/images/existing MP4s
|
|
159
|
+
(My Files / your computer / web search), render on a local 'serve' box.
|
|
160
|
+
~$0.001-$0.03 Same reuse, but cloud 'render' (~$0.01-$0.10). Image gen is cheap too.
|
|
161
|
+
~$1 AI-generate a few scenes for specificity (generate video ...).
|
|
162
|
+
$10+ Heavy AI generation (many/long AI clips, custom characters).
|
|
163
|
+
Notes: image gen is cheap (use freely); AI VIDEO gen is expensive (ask the user
|
|
164
|
+
first). Decompose is a one-time ~$0.10 — or skip it by forking a decomposed template.
|
|
106
165
|
|
|
107
166
|
Escape hatch — call ANY route directly:
|
|
108
167
|
api <METHOD> <path> Raw REST call with auth + pretty errors
|
|
@@ -158,6 +217,9 @@ async function main() {
|
|
|
158
217
|
case "discover":
|
|
159
218
|
await runDiscoverCommand(rest);
|
|
160
219
|
return;
|
|
220
|
+
case "videos":
|
|
221
|
+
await runVideosCommand(rest);
|
|
222
|
+
return;
|
|
161
223
|
case "inspiration-add":
|
|
162
224
|
await runInspirationAddCommand(rest);
|
|
163
225
|
return;
|
|
@@ -170,6 +232,15 @@ async function main() {
|
|
|
170
232
|
case "fork":
|
|
171
233
|
await runForkCommand(rest);
|
|
172
234
|
return;
|
|
235
|
+
case "pull":
|
|
236
|
+
await runPullCommand(rest);
|
|
237
|
+
return;
|
|
238
|
+
case "generate":
|
|
239
|
+
await runGenerateCommand(rest);
|
|
240
|
+
return;
|
|
241
|
+
case "place":
|
|
242
|
+
await runPlaceCommand(rest);
|
|
243
|
+
return;
|
|
173
244
|
case "decompose":
|
|
174
245
|
await runDecomposeCommand(rest);
|
|
175
246
|
return;
|
|
@@ -243,6 +314,10 @@ async function main() {
|
|
|
243
314
|
case "get-file":
|
|
244
315
|
await runGetFileCommand(rest);
|
|
245
316
|
return;
|
|
317
|
+
case "put-file":
|
|
318
|
+
case "save-file":
|
|
319
|
+
await runPutFileCommand(rest);
|
|
320
|
+
return;
|
|
246
321
|
default:
|
|
247
322
|
if (!command.startsWith("-")) {
|
|
248
323
|
// Positional template id → default to booting the local editor.
|
|
@@ -972,6 +1047,39 @@ function safeSize(target) {
|
|
|
972
1047
|
return 0;
|
|
973
1048
|
}
|
|
974
1049
|
}
|
|
1050
|
+
// Guess a content type from a file name so My Files stores text docs as text/*
|
|
1051
|
+
// (a bare multipart Blob otherwise defaults to application/octet-stream, which
|
|
1052
|
+
// makes browsers download .md/.txt instead of showing them). Mirrors the common
|
|
1053
|
+
// cases of the server's inferAttachmentContentType; returns "" when unknown so
|
|
1054
|
+
// the server can fall back to its own inference.
|
|
1055
|
+
function guessContentType(fileName) {
|
|
1056
|
+
const dot = fileName.lastIndexOf(".");
|
|
1057
|
+
const ext = dot >= 0 ? fileName.slice(dot).toLowerCase() : "";
|
|
1058
|
+
switch (ext) {
|
|
1059
|
+
case ".md": return "text/markdown; charset=utf-8";
|
|
1060
|
+
case ".txt": return "text/plain; charset=utf-8";
|
|
1061
|
+
case ".csv": return "text/csv; charset=utf-8";
|
|
1062
|
+
case ".json": return "application/json";
|
|
1063
|
+
case ".srt":
|
|
1064
|
+
case ".vtt": return "text/plain; charset=utf-8";
|
|
1065
|
+
case ".png": return "image/png";
|
|
1066
|
+
case ".jpg":
|
|
1067
|
+
case ".jpeg": return "image/jpeg";
|
|
1068
|
+
case ".gif": return "image/gif";
|
|
1069
|
+
case ".webp": return "image/webp";
|
|
1070
|
+
case ".svg": return "image/svg+xml";
|
|
1071
|
+
case ".mp4":
|
|
1072
|
+
case ".m4v": return "video/mp4";
|
|
1073
|
+
case ".mov": return "video/quicktime";
|
|
1074
|
+
case ".webm": return "video/webm";
|
|
1075
|
+
case ".mp3": return "audio/mpeg";
|
|
1076
|
+
case ".wav": return "audio/wav";
|
|
1077
|
+
case ".m4a": return "audio/mp4";
|
|
1078
|
+
case ".aac": return "audio/aac";
|
|
1079
|
+
case ".pdf": return "application/pdf";
|
|
1080
|
+
default: return "";
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
975
1083
|
function formatBytes(bytes) {
|
|
976
1084
|
if (bytes >= 1024 * 1024)
|
|
977
1085
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
@@ -1032,13 +1140,33 @@ async function runApiCommand(argv) {
|
|
|
1032
1140
|
process.exitCode = 1;
|
|
1033
1141
|
}
|
|
1034
1142
|
// ── Discover & inspiration ──────────────────────────────────────────────────
|
|
1143
|
+
// `discover` lists TEMPLATES; `--query` keyword-searches the catalog so an agent
|
|
1144
|
+
// CLI (Claude Code / Codex) can answer "which templates suit my <offer>?". Each
|
|
1145
|
+
// result carries decompose-derived promotions/keywords/summary to reason over.
|
|
1035
1146
|
async function runDiscoverCommand(argv) {
|
|
1036
|
-
const parsed = parseArgs({ args: argv, allowPositionals:
|
|
1147
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), limit: { type: "string" }, query: { type: "string" } } });
|
|
1037
1148
|
const ctx = commonContext(parsed.values);
|
|
1038
|
-
const
|
|
1149
|
+
const query = (parsed.values.query ?? parsed.positionals.join(" ").trim()) || undefined;
|
|
1150
|
+
const result = await apiRequest({ method: "GET", host: ctx.host, path: "/discover/feed", auth: ctx.auth, query: { limit: parsed.values.limit, q: query } });
|
|
1039
1151
|
assertApiOk(result, "discover");
|
|
1040
1152
|
emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
|
|
1041
1153
|
}
|
|
1154
|
+
// `videos` lists source INSPIRATIONS (the raw viral videos), with the same
|
|
1155
|
+
// keyword search and an optional --mine for the caller's own uploads.
|
|
1156
|
+
async function runVideosCommand(argv) {
|
|
1157
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), limit: { type: "string" }, query: { type: "string" }, mine: { type: "boolean", default: false } } });
|
|
1158
|
+
const ctx = commonContext(parsed.values);
|
|
1159
|
+
const query = (parsed.values.query ?? parsed.positionals.join(" ").trim()) || undefined;
|
|
1160
|
+
const result = await apiRequest({
|
|
1161
|
+
method: "GET",
|
|
1162
|
+
host: ctx.host,
|
|
1163
|
+
path: "/api/v1/videos",
|
|
1164
|
+
auth: ctx.auth,
|
|
1165
|
+
query: { limit: parsed.values.limit, q: query, mine: parsed.values.mine ? "true" : undefined }
|
|
1166
|
+
});
|
|
1167
|
+
assertApiOk(result, "videos");
|
|
1168
|
+
emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
|
|
1169
|
+
}
|
|
1042
1170
|
async function runInspirationAddCommand(argv) {
|
|
1043
1171
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), tagline: { type: "string" }, notes: { type: "string" } } });
|
|
1044
1172
|
const sourceUrl = parsed.positionals[0];
|
|
@@ -1168,7 +1296,7 @@ async function runRenderCommand(argv) {
|
|
|
1168
1296
|
const result = await apiRequest({
|
|
1169
1297
|
method: "POST",
|
|
1170
1298
|
host: ctx.host,
|
|
1171
|
-
path: `/api/v1/compositions/${encodeURIComponent(forkId)}/
|
|
1299
|
+
path: `/api/v1/compositions/${encodeURIComponent(forkId)}/render`,
|
|
1172
1300
|
auth: ctx.auth,
|
|
1173
1301
|
body: { title: parsed.values.title, tracer: parsed.values.tracer }
|
|
1174
1302
|
});
|
|
@@ -1212,6 +1340,351 @@ async function runRenderStatusCommand(argv) {
|
|
|
1212
1340
|
assertApiOk(result, "render-status");
|
|
1213
1341
|
emitResult(result, ctx.json, [["Rendered MP4 ", result.json?.outputUrl]]);
|
|
1214
1342
|
}
|
|
1343
|
+
// ── AI generation + timeline placement (for local coding agents) ────────────
|
|
1344
|
+
// `generate` submits an AI image/video primitive job and (by default) polls it
|
|
1345
|
+
// to the finished media URL — the async poll the raw `api` command can't do for
|
|
1346
|
+
// you. `place` drops any media URL into a local composition.html (fill a gap or
|
|
1347
|
+
// replace a scene), producing the SAME clip markup the browser editor makes, so
|
|
1348
|
+
// `serve` live-morphs it and `publish` pushes it. Together they let a Claude
|
|
1349
|
+
// Code / Codex agent do end-to-end "generate → insert" without hand-editing HTML.
|
|
1350
|
+
// Parse "5", "5.5", "mm:ss", or "hh:mm:ss" into seconds.
|
|
1351
|
+
function parseTimeToSeconds(raw) {
|
|
1352
|
+
const trimmed = raw.trim();
|
|
1353
|
+
if (/^\d+(\.\d+)?$/.test(trimmed))
|
|
1354
|
+
return Number(trimmed);
|
|
1355
|
+
const parts = trimmed.split(":").map((p) => Number(p));
|
|
1356
|
+
if (parts.some((n) => !Number.isFinite(n)))
|
|
1357
|
+
throw new Error(`Invalid time: ${raw} (use seconds, mm:ss, or hh:mm:ss).`);
|
|
1358
|
+
return parts.reduce((acc, n) => acc * 60 + n, 0);
|
|
1359
|
+
}
|
|
1360
|
+
// Pull the finished media URL out of a settled primitive job (GET /me/jobs/:id).
|
|
1361
|
+
// Mirrors the server's extractOutputUrlFromJob.
|
|
1362
|
+
function resolveJobMediaUrl(job) {
|
|
1363
|
+
if (!job || typeof job !== "object")
|
|
1364
|
+
return null;
|
|
1365
|
+
const readStr = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
|
|
1366
|
+
const result = (job.result && typeof job.result === "object") ? job.result : {};
|
|
1367
|
+
const output = (result.output && typeof result.output === "object") ? result.output : result;
|
|
1368
|
+
const direct = readStr(output.primary_file_url)
|
|
1369
|
+
?? readStr(output.video?.file_url)
|
|
1370
|
+
?? readStr(output.image?.file_url)
|
|
1371
|
+
?? readStr(output.render?.output_url);
|
|
1372
|
+
if (direct)
|
|
1373
|
+
return direct;
|
|
1374
|
+
if (Array.isArray(output.files)) {
|
|
1375
|
+
for (const f of output.files) {
|
|
1376
|
+
const u = readStr(typeof f === "string" ? f : (f?.file_url ?? f?.url));
|
|
1377
|
+
if (u)
|
|
1378
|
+
return u;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (Array.isArray(job.artifacts)) {
|
|
1382
|
+
for (const a of job.artifacts) {
|
|
1383
|
+
const u = readStr(a?.public_url);
|
|
1384
|
+
if (u)
|
|
1385
|
+
return u;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1390
|
+
// Poll a primitive job until it settles (or times out). Returns the final job.
|
|
1391
|
+
async function pollPrimitiveJob(ctx, jobId) {
|
|
1392
|
+
const MAX_WAIT_MS = 8 * 60 * 1000;
|
|
1393
|
+
const startedAt = Date.now();
|
|
1394
|
+
let last = null;
|
|
1395
|
+
for (;;) {
|
|
1396
|
+
await sleep(5000);
|
|
1397
|
+
const res = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/user/me/jobs/${encodeURIComponent(jobId)}`, auth: ctx.auth });
|
|
1398
|
+
if (res.ok && res.json) {
|
|
1399
|
+
last = res.json;
|
|
1400
|
+
const status = String(last.status ?? "");
|
|
1401
|
+
if (!ctx.json)
|
|
1402
|
+
console.log(`${DIM} ${status}${typeof last.progress === "number" ? ` ${Math.round(last.progress * 100)}%` : ""}${RESET}`);
|
|
1403
|
+
if (status === "succeeded" || status === "failed" || status === "cancelled")
|
|
1404
|
+
return last;
|
|
1405
|
+
if (resolveJobMediaUrl(last))
|
|
1406
|
+
return last;
|
|
1407
|
+
}
|
|
1408
|
+
if (Date.now() - startedAt > MAX_WAIT_MS)
|
|
1409
|
+
return last ?? { status: "timeout" };
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
// Upload a local file to the ephemeral temp store, returning its durable URL.
|
|
1413
|
+
// Used to turn `--ref @local.png` into a direct asset URL for reference images.
|
|
1414
|
+
async function uploadTempFileForRef(ctx, absPath) {
|
|
1415
|
+
if (!existsSync(absPath))
|
|
1416
|
+
throw new Error(`No such reference file: ${absPath}`);
|
|
1417
|
+
const form = new FormData();
|
|
1418
|
+
form.append("file", new Blob([readFileSync(absPath)]), path.basename(absPath));
|
|
1419
|
+
const res = await fetch(new URL("/api/v1/user/me/temporary-files/upload", ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
|
|
1420
|
+
const text = await res.text();
|
|
1421
|
+
let json = null;
|
|
1422
|
+
try {
|
|
1423
|
+
json = text ? JSON.parse(text) : null;
|
|
1424
|
+
}
|
|
1425
|
+
catch {
|
|
1426
|
+
json = null;
|
|
1427
|
+
}
|
|
1428
|
+
if (!res.ok)
|
|
1429
|
+
throw new Error(`reference upload failed (${res.status}): ${text.slice(0, 200)}`);
|
|
1430
|
+
const url = json?.file?.view_url ?? json?.file?.s3_url;
|
|
1431
|
+
if (!url)
|
|
1432
|
+
throw new Error("reference upload returned no durable URL.");
|
|
1433
|
+
return url;
|
|
1434
|
+
}
|
|
1435
|
+
// Resolve --ref values: pass http(s) URLs through; upload @local files.
|
|
1436
|
+
async function resolveReferenceUrls(ctx, refs) {
|
|
1437
|
+
const out = [];
|
|
1438
|
+
for (const raw of refs ?? []) {
|
|
1439
|
+
const value = raw.trim();
|
|
1440
|
+
if (!value)
|
|
1441
|
+
continue;
|
|
1442
|
+
if (value.startsWith("@")) {
|
|
1443
|
+
out.push(await uploadTempFileForRef(ctx, path.resolve(process.cwd(), value.slice(1))));
|
|
1444
|
+
}
|
|
1445
|
+
else if (/^https?:\/\//i.test(value)) {
|
|
1446
|
+
out.push(value);
|
|
1447
|
+
}
|
|
1448
|
+
else {
|
|
1449
|
+
// Bare path with no @ — treat as a local file too, for convenience.
|
|
1450
|
+
out.push(await uploadTempFileForRef(ctx, path.resolve(process.cwd(), value)));
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
return out;
|
|
1454
|
+
}
|
|
1455
|
+
function inferMediaKindFromUrl(url) {
|
|
1456
|
+
return /\.(mp4|mov|webm|m4v|mkv)(\?|#|$)/i.test(url) ? "video" : "image";
|
|
1457
|
+
}
|
|
1458
|
+
// Resolve a composition.html on disk from a file path or a directory holding one.
|
|
1459
|
+
function resolveCompositionHtmlPath(target) {
|
|
1460
|
+
const abs = path.resolve(process.cwd(), target);
|
|
1461
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
|
1462
|
+
const inside = path.join(abs, "composition.html");
|
|
1463
|
+
if (!existsSync(inside))
|
|
1464
|
+
throw new Error(`No composition.html inside ${abs}. Run \`vidfarm pull <forkId> --dir ${target}\` first.`);
|
|
1465
|
+
return inside;
|
|
1466
|
+
}
|
|
1467
|
+
if (!existsSync(abs))
|
|
1468
|
+
throw new Error(`No such composition file or dir: ${abs}.`);
|
|
1469
|
+
return abs;
|
|
1470
|
+
}
|
|
1471
|
+
// Apply an insert/replace of a media URL onto a composition.html on disk.
|
|
1472
|
+
function placeMediaOnDisk(input) {
|
|
1473
|
+
const html = readFileSync(input.htmlPath, "utf8");
|
|
1474
|
+
const opts = {
|
|
1475
|
+
kind: input.kind,
|
|
1476
|
+
src: input.src,
|
|
1477
|
+
layerKey: input.layerKey,
|
|
1478
|
+
start: input.at,
|
|
1479
|
+
duration: input.duration,
|
|
1480
|
+
track: input.track,
|
|
1481
|
+
x: input.x,
|
|
1482
|
+
y: input.y,
|
|
1483
|
+
width: input.width,
|
|
1484
|
+
height: input.height,
|
|
1485
|
+
objectFit: input.objectFit,
|
|
1486
|
+
slug: input.slug
|
|
1487
|
+
};
|
|
1488
|
+
const result = input.replace
|
|
1489
|
+
? replaceLayerWithMedia(html, input.replace, opts)
|
|
1490
|
+
: insertMediaLayer(html, opts);
|
|
1491
|
+
writeFileSync(input.htmlPath, result.html);
|
|
1492
|
+
return { layerKey: result.layerKey, gaps: computeCompositionGaps(result.html) };
|
|
1493
|
+
}
|
|
1494
|
+
async function runGenerateCommand(argv) {
|
|
1495
|
+
const parsed = parseArgs({
|
|
1496
|
+
args: argv,
|
|
1497
|
+
allowPositionals: true,
|
|
1498
|
+
options: {
|
|
1499
|
+
...commonOptions(),
|
|
1500
|
+
prompt: { type: "string" },
|
|
1501
|
+
ref: { type: "string", multiple: true },
|
|
1502
|
+
"aspect-ratio": { type: "string" },
|
|
1503
|
+
duration: { type: "string" },
|
|
1504
|
+
provider: { type: "string" },
|
|
1505
|
+
model: { type: "string" },
|
|
1506
|
+
resolution: { type: "string" },
|
|
1507
|
+
audio: { type: "boolean", default: false },
|
|
1508
|
+
"no-wait": { type: "boolean", default: false },
|
|
1509
|
+
tracer: { type: "string" },
|
|
1510
|
+
// Fused placement onto a local composition.html.
|
|
1511
|
+
place: { type: "string" },
|
|
1512
|
+
at: { type: "string" },
|
|
1513
|
+
replace: { type: "string" },
|
|
1514
|
+
track: { type: "string" },
|
|
1515
|
+
"layer-key": { type: "string" }
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
const mediaType = parsed.positionals[0];
|
|
1519
|
+
if (mediaType !== "image" && mediaType !== "video") {
|
|
1520
|
+
throw new Error("generate requires a media type: `vidfarm generate image ...` or `vidfarm generate video ...`.");
|
|
1521
|
+
}
|
|
1522
|
+
const prompt = parsed.values.prompt;
|
|
1523
|
+
if (!prompt || !prompt.trim())
|
|
1524
|
+
throw new Error("generate requires --prompt \"...\".");
|
|
1525
|
+
const ctx = commonContext(parsed.values);
|
|
1526
|
+
const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
|
|
1527
|
+
const payload = { prompt };
|
|
1528
|
+
if (parsed.values["aspect-ratio"])
|
|
1529
|
+
payload.aspect_ratio = parsed.values["aspect-ratio"];
|
|
1530
|
+
if (parsed.values.provider)
|
|
1531
|
+
payload.provider = parsed.values.provider;
|
|
1532
|
+
if (parsed.values.model)
|
|
1533
|
+
payload.model = parsed.values.model;
|
|
1534
|
+
if (mediaType === "video") {
|
|
1535
|
+
if (parsed.values.duration)
|
|
1536
|
+
payload.duration = Math.round(Number(parsed.values.duration));
|
|
1537
|
+
if (parsed.values.resolution)
|
|
1538
|
+
payload.resolution = parsed.values.resolution;
|
|
1539
|
+
if (parsed.values.audio)
|
|
1540
|
+
payload.generate_audio = true;
|
|
1541
|
+
if (refs.length)
|
|
1542
|
+
payload.input_references = refs.slice(0, 8);
|
|
1543
|
+
}
|
|
1544
|
+
else if (refs.length) {
|
|
1545
|
+
payload.prompt_attachments = refs.slice(0, 16);
|
|
1546
|
+
}
|
|
1547
|
+
const tracer = parsed.values.tracer ?? `devcli-gen-${Date.now().toString(36)}`;
|
|
1548
|
+
const route = mediaType === "video" ? "/api/v1/primitives/videos/generate" : "/api/v1/primitives/images/generate";
|
|
1549
|
+
const submit = await apiRequest({ method: "POST", host: ctx.host, path: route, auth: ctx.auth, body: { tracer, payload } });
|
|
1550
|
+
assertApiOk(submit, "generate");
|
|
1551
|
+
const jobId = submit.json?.job_id;
|
|
1552
|
+
const wait = !parsed.values["no-wait"];
|
|
1553
|
+
if (!wait || !jobId) {
|
|
1554
|
+
if (!ctx.json && jobId)
|
|
1555
|
+
console.log(`${DIM}Queued ${jobId} (tracer ${tracer}). Poll: vidfarm api GET /api/v1/user/me/jobs/${jobId} — or drop --no-wait to auto-poll.${RESET}`);
|
|
1556
|
+
emitResult(submit, ctx.json);
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
if (!ctx.json)
|
|
1560
|
+
console.log(`${DIM}Generating ${mediaType} (${jobId})… polling every 5s.${RESET}`);
|
|
1561
|
+
const job = await pollPrimitiveJob(ctx, jobId);
|
|
1562
|
+
const mediaUrl = resolveJobMediaUrl(job);
|
|
1563
|
+
const status = String(job?.status ?? "");
|
|
1564
|
+
if (!mediaUrl) {
|
|
1565
|
+
if (ctx.json) {
|
|
1566
|
+
printJson({ ok: false, job_id: jobId, status, job });
|
|
1567
|
+
}
|
|
1568
|
+
else {
|
|
1569
|
+
console.log(`${RED}Generation ${status || "did not finish"} — no media URL.${RESET}`);
|
|
1570
|
+
printJson(job);
|
|
1571
|
+
}
|
|
1572
|
+
process.exitCode = 1;
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
// Optional fused placement onto a local composition.html.
|
|
1576
|
+
let placement = null;
|
|
1577
|
+
if (parsed.values.place) {
|
|
1578
|
+
const htmlPath = resolveCompositionHtmlPath(String(parsed.values.place));
|
|
1579
|
+
placement = placeMediaOnDisk({
|
|
1580
|
+
htmlPath,
|
|
1581
|
+
src: mediaUrl,
|
|
1582
|
+
kind: mediaType,
|
|
1583
|
+
at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
|
|
1584
|
+
replace: parsed.values.replace,
|
|
1585
|
+
track: parsed.values.track ? Number(parsed.values.track) : undefined,
|
|
1586
|
+
layerKey: parsed.values["layer-key"]
|
|
1587
|
+
});
|
|
1588
|
+
if (!ctx.json)
|
|
1589
|
+
console.log(`${GREEN}Placed as ${placement.layerKey} in ${htmlPath}. Run \`vidfarm publish --dir ${path.dirname(htmlPath)}\` (or it live-morphs under \`serve\`).${RESET}`);
|
|
1590
|
+
}
|
|
1591
|
+
if (ctx.json) {
|
|
1592
|
+
printJson({ ok: true, job_id: jobId, media_url: mediaUrl, media_type: mediaType, placement });
|
|
1593
|
+
}
|
|
1594
|
+
else {
|
|
1595
|
+
console.log(`${GREEN}${mediaType} ready:${RESET} ${mediaUrl}`);
|
|
1596
|
+
if (!placement)
|
|
1597
|
+
console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind ${mediaType} --at <time>|--replace <layer_key>${RESET}`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
async function runPlaceCommand(argv) {
|
|
1601
|
+
const parsed = parseArgs({
|
|
1602
|
+
args: argv,
|
|
1603
|
+
allowPositionals: true,
|
|
1604
|
+
options: {
|
|
1605
|
+
...commonOptions(),
|
|
1606
|
+
src: { type: "string" },
|
|
1607
|
+
kind: { type: "string" },
|
|
1608
|
+
at: { type: "string" },
|
|
1609
|
+
duration: { type: "string" },
|
|
1610
|
+
replace: { type: "string" },
|
|
1611
|
+
track: { type: "string" },
|
|
1612
|
+
x: { type: "string" },
|
|
1613
|
+
y: { type: "string" },
|
|
1614
|
+
width: { type: "string" },
|
|
1615
|
+
height: { type: "string" },
|
|
1616
|
+
"object-fit": { type: "string" },
|
|
1617
|
+
"layer-key": { type: "string" },
|
|
1618
|
+
slug: { type: "string" }
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
const target = parsed.positionals[0];
|
|
1622
|
+
if (!target)
|
|
1623
|
+
throw new Error("place requires a composition path: `vidfarm place <dir-or-composition.html> --src <url> ...`.");
|
|
1624
|
+
const src = parsed.values.src;
|
|
1625
|
+
if (!src || !/^https?:\/\//i.test(src))
|
|
1626
|
+
throw new Error("place requires --src <direct media URL>.");
|
|
1627
|
+
const ctx = commonContext(parsed.values);
|
|
1628
|
+
const htmlPath = resolveCompositionHtmlPath(target);
|
|
1629
|
+
const kind = parsed.values.kind === "video" || parsed.values.kind === "image"
|
|
1630
|
+
? parsed.values.kind
|
|
1631
|
+
: inferMediaKindFromUrl(src);
|
|
1632
|
+
const num = (v) => (v !== undefined ? Number(v) : undefined);
|
|
1633
|
+
const placement = placeMediaOnDisk({
|
|
1634
|
+
htmlPath,
|
|
1635
|
+
src,
|
|
1636
|
+
kind,
|
|
1637
|
+
at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
|
|
1638
|
+
duration: num(parsed.values.duration),
|
|
1639
|
+
replace: parsed.values.replace,
|
|
1640
|
+
track: num(parsed.values.track),
|
|
1641
|
+
x: num(parsed.values.x),
|
|
1642
|
+
y: num(parsed.values.y),
|
|
1643
|
+
width: num(parsed.values.width),
|
|
1644
|
+
height: num(parsed.values.height),
|
|
1645
|
+
objectFit: parsed.values["object-fit"],
|
|
1646
|
+
layerKey: parsed.values["layer-key"],
|
|
1647
|
+
slug: parsed.values.slug
|
|
1648
|
+
});
|
|
1649
|
+
if (ctx.json) {
|
|
1650
|
+
printJson({ ok: true, layer_key: placement.layerKey, path: htmlPath, timeline_gaps: placement.gaps });
|
|
1651
|
+
}
|
|
1652
|
+
else {
|
|
1653
|
+
console.log(`${GREEN}Placed ${kind} as ${placement.layerKey}${parsed.values.replace ? ` (replaced ${parsed.values.replace})` : ""} in ${htmlPath}.${RESET}`);
|
|
1654
|
+
console.log(`${DIM}Remaining timeline gaps: ${placement.gaps.length ? placement.gaps.map((g) => `${g.start}-${g.end}s`).join(", ") : "none"}.${RESET}`);
|
|
1655
|
+
console.log(`${DIM}Publish with: vidfarm publish --dir ${path.dirname(htmlPath)} --fork <forkId> (or it live-morphs under \`serve\`).${RESET}`);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
async function runPullCommand(argv) {
|
|
1659
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), dir: { type: "string" }, refetch: { type: "boolean", default: false } } });
|
|
1660
|
+
const forkId = parsed.positionals[0];
|
|
1661
|
+
if (!forkId)
|
|
1662
|
+
throw new Error("pull requires a fork id: `vidfarm pull <forkId> [--dir <path>]`.");
|
|
1663
|
+
const ctx = commonContext(parsed.values);
|
|
1664
|
+
const dir = path.resolve(process.cwd(), parsed.values.dir ?? path.join(".vidfarm", forkId));
|
|
1665
|
+
mkdirSync(dir, { recursive: true });
|
|
1666
|
+
await fetchCompositionFiles({ host: ctx.host, forkId, dir, apiKey: ctx.auth.apiKey, shareToken: ctx.auth.shareToken, refetch: Boolean(parsed.values.refetch) });
|
|
1667
|
+
const htmlPath = path.join(dir, "composition.html");
|
|
1668
|
+
if (!existsSync(htmlPath))
|
|
1669
|
+
throw new Error(`pull did not fetch composition.html for ${forkId} (check credentials / fork id).`);
|
|
1670
|
+
const info = inspectComposition(readFileSync(htmlPath, "utf8"));
|
|
1671
|
+
const hasContext = existsSync(path.join(dir, "video-context.json"));
|
|
1672
|
+
const hasCast = existsSync(path.join(dir, "cast.json"));
|
|
1673
|
+
if (ctx.json) {
|
|
1674
|
+
printJson({ ok: true, fork_id: forkId, dir, has_video_context: hasContext, has_cast: hasCast, ...info });
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
console.log(`${GREEN}Pulled ${forkId} → ${dir}${RESET}`);
|
|
1678
|
+
console.log(`${DIM}Canvas ${info.width ?? "?"}x${info.height ?? "?"} (${info.aspect_ratio ?? "?"}), ${info.duration_seconds}s, ${info.layers.length} layers.${RESET}`);
|
|
1679
|
+
console.log(`${BOLD}Timeline gaps (blank space to fill):${RESET} ${info.gaps.length ? info.gaps.map((g) => `${g.start}-${g.end}s (${g.duration}s)`).join(", ") : "none"}`);
|
|
1680
|
+
console.log(`${BOLD}Scenes / layers (keys to replace):${RESET}`);
|
|
1681
|
+
for (const layer of info.layers) {
|
|
1682
|
+
const flags = [layer.is_timeline_proxy ? "proxy" : null, layer.is_full_canvas ? "full-canvas" : null].filter(Boolean).join(",");
|
|
1683
|
+
console.log(` ${layer.key} ${DIM}${layer.kind ?? "?"} ${layer.start}-${(layer.start + layer.duration).toFixed(2)}s track${layer.track}${flags ? ` [${flags}]` : ""}${layer.slug ? ` slug=${layer.slug}` : ""}${RESET}`);
|
|
1684
|
+
}
|
|
1685
|
+
console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}.${RESET}`);
|
|
1686
|
+
console.log(`${DIM}Next: vidfarm generate video --prompt "..." --aspect-ratio ${info.aspect_ratio ?? "9:16"} --place ${dir} --at <gap start>|--replace <layer_key>${RESET}`);
|
|
1687
|
+
}
|
|
1215
1688
|
async function runVisibilityCommand(argv) {
|
|
1216
1689
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
|
|
1217
1690
|
const forkId = parsed.positionals[0];
|
|
@@ -1526,6 +1999,76 @@ async function runGetFileCommand(argv) {
|
|
|
1526
1999
|
console.log(`${GREEN}Downloaded ${formatBytes(bytes)} → ${dest}${RESET}`);
|
|
1527
2000
|
}
|
|
1528
2001
|
}
|
|
2002
|
+
// Write a file INTO My Files — the persistent per-user library (`/me/attachments`),
|
|
2003
|
+
// NOT the ephemeral temporary-files store that `upload` targets. This is what an
|
|
2004
|
+
// agent uses to save durable context docs during Getting Started (About.md,
|
|
2005
|
+
// Interview.md, awareness-levels.md, persuasive-angles.md, ad-hooks.md) or to drop
|
|
2006
|
+
// brand assets under /brand-assets, product demos under /product-demos, etc.
|
|
2007
|
+
// Content can come from a local file (positional), inline --content, or piped stdin.
|
|
2008
|
+
// It is the write counterpart to `files`/`get-file` and the same store the /editor
|
|
2009
|
+
// AI copilot writes to via the browse_files write action.
|
|
2010
|
+
async function runPutFileCommand(argv) {
|
|
2011
|
+
const parsed = parseArgs({
|
|
2012
|
+
args: argv,
|
|
2013
|
+
allowPositionals: true,
|
|
2014
|
+
options: {
|
|
2015
|
+
...commonOptions(),
|
|
2016
|
+
folder: { type: "string" },
|
|
2017
|
+
as: { type: "string" },
|
|
2018
|
+
content: { type: "string" },
|
|
2019
|
+
stdin: { type: "boolean", default: false }
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
const ctx = commonContext(parsed.values);
|
|
2023
|
+
const localPath = parsed.positionals[0];
|
|
2024
|
+
const asName = parsed.values.as ? String(parsed.values.as) : undefined;
|
|
2025
|
+
let buffer;
|
|
2026
|
+
let fileName = asName;
|
|
2027
|
+
if (parsed.values.stdin) {
|
|
2028
|
+
if (!fileName)
|
|
2029
|
+
throw new Error("put-file --stdin requires --as <name> to name the file in My Files.");
|
|
2030
|
+
buffer = readFileSync(0); // fd 0 = stdin (piped content)
|
|
2031
|
+
}
|
|
2032
|
+
else if (parsed.values.content != null) {
|
|
2033
|
+
if (!fileName)
|
|
2034
|
+
throw new Error("put-file --content requires --as <name> to name the file in My Files.");
|
|
2035
|
+
buffer = Buffer.from(String(parsed.values.content), "utf8");
|
|
2036
|
+
}
|
|
2037
|
+
else if (localPath) {
|
|
2038
|
+
const abs = path.resolve(process.cwd(), localPath);
|
|
2039
|
+
if (!existsSync(abs))
|
|
2040
|
+
throw new Error(`No such file: ${abs}`);
|
|
2041
|
+
buffer = readFileSync(abs);
|
|
2042
|
+
if (!fileName)
|
|
2043
|
+
fileName = path.basename(abs);
|
|
2044
|
+
}
|
|
2045
|
+
else {
|
|
2046
|
+
throw new Error("put-file requires a local file path, or --content/--stdin with --as <name>.");
|
|
2047
|
+
}
|
|
2048
|
+
if (!fileName)
|
|
2049
|
+
throw new Error("put-file could not determine a file name. Pass --as <name>.");
|
|
2050
|
+
const contentType = guessContentType(fileName);
|
|
2051
|
+
const form = new FormData();
|
|
2052
|
+
form.append("file", new Blob([new Uint8Array(buffer)], contentType ? { type: contentType } : undefined), fileName);
|
|
2053
|
+
if (parsed.values.folder)
|
|
2054
|
+
form.append("folder_path", String(parsed.values.folder));
|
|
2055
|
+
const res = await fetch(new URL("/api/v1/user/me/attachments/upload", ctx.host), {
|
|
2056
|
+
method: "POST",
|
|
2057
|
+
headers: buildAuthHeaders(ctx.auth),
|
|
2058
|
+
body: form
|
|
2059
|
+
});
|
|
2060
|
+
const text = await res.text();
|
|
2061
|
+
let json = null;
|
|
2062
|
+
try {
|
|
2063
|
+
json = text ? JSON.parse(text) : null;
|
|
2064
|
+
}
|
|
2065
|
+
catch {
|
|
2066
|
+
json = null;
|
|
2067
|
+
}
|
|
2068
|
+
const result = { status: res.status, ok: res.ok, json, text };
|
|
2069
|
+
assertApiOk(result, "put-file");
|
|
2070
|
+
emitResult(result, ctx.json, [["My Files URL", result.json?.attachment?.viewUrl]]);
|
|
2071
|
+
}
|
|
1529
2072
|
// ── Agent skill ───────────────────────────────────────────────────────────────
|
|
1530
2073
|
// Install the latest director skill onto disk as a Claude Code / agent skill so
|
|
1531
2074
|
// the user's AI agent can read SKILL.director.md and act. We pull the freshest
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mevdragon/vidfarm-devcli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Local bridge for the Vidfarm Trackpad Editor. Point it at a template id, edit composition.html on disk (Claude Code, Codex, etc.), preview live in the browser at https://vidfarm.cc/editor/.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -63,9 +63,6 @@
|
|
|
63
63
|
"@fontsource/tiktok-sans": "^5.2.4",
|
|
64
64
|
"@hono/node-server": "^1.14.4",
|
|
65
65
|
"@hyperframes/aws-lambda": "^0.7.21",
|
|
66
|
-
"@remotion/bundler": "4.0.355",
|
|
67
|
-
"@remotion/lambda": "4.0.355",
|
|
68
|
-
"@remotion/renderer": "4.0.355",
|
|
69
66
|
"@sentry/aws-serverless": "^10.56.0",
|
|
70
67
|
"@sentry/hono": "^10.56.0",
|
|
71
68
|
"@sentry/node": "^10.56.0",
|
|
@@ -81,7 +78,6 @@
|
|
|
81
78
|
"linkedom": "^0.18.12",
|
|
82
79
|
"react": "^18.3.1",
|
|
83
80
|
"react-dom": "^18.3.1",
|
|
84
|
-
"remotion": "4.0.355",
|
|
85
81
|
"sharp": "^0.34.5",
|
|
86
82
|
"zod": "^3.25.28"
|
|
87
83
|
},
|