@mevdragon/vidfarm-devcli 0.20.8 → 0.20.10

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.
@@ -104,8 +104,14 @@ Recurring characters that must look the same across videos have a **dedicated, b
104
104
  4. **Write `character_about.md` and `<character_id>.json`** (e.g. `character_zara.json`) into the same folder (`browse_files action=write` with `content`, or `vidfarm put-file --content`).
105
105
  5. **Annotate all three** with notes naming the character so `files --search "our mascot"` finds them from any phrasing.
106
106
 
107
- **Renaming / fixing up.** Character folders and files are renamable in place `browse_files action=rename` (web copilot) or `vidfarm directory rename <path> <new-name> [--file-id <id>]` (devcli); the explorer UI has a **Rename** action on every file/folder row. If you rename a character's folder, update the `id`, `sprite_card_path`, and `about_path` inside its manifest to match (and rename the `<character_id>.json` file itself).
107
+ **Renaming / moving / copying.** The whole directory is reorganizable in place, for **paid AND free** users, via three REST routes, their devcli twins, and the explorer's kebab / right-click menu:
108
+
109
+ - **rename** — `POST /api/v1/user/me/directory/rename` `{ path, new_name, file_id? }` · `vidfarm directory rename <path> <new-name> [--file-id <id>]`. Renames a `/files`/`/temp` file (display name only — the view URL keeps working) or a folder in any writable root (its files + nested subfolders come along).
110
+ - **move** — `POST /api/v1/user/me/directory/move` `{ path, to, file_id? }` · `vidfarm directory move <path> <to-folder> [--file-id <id>]`. Relocates a FILE into `to` (with `file_id`) or nests a whole FOLDER under `to`. **Same root only** (`/files`·`/temp`·`/raws`·`/approved`); metadata-only, so S3 objects are untouched.
111
+ - **copy** — `POST /api/v1/user/me/directory/copy` `{ path, to?, file_id?, new_name? }` · `vidfarm directory copy <path> [<to-folder>] [--file-id <id>] [--as <name>]`. Duplicates a file/folder sharing the same underlying S3 object (cheap, no re-upload). Same root only, `/files`·`/temp`·`/raws` (not `/approved` — a ready post is a single publishable unit; not `/projects` — read-only). Omit `to` to duplicate in place.
112
+
113
+ The web copilot exposes the same three as `browse_files action=rename|move|copy`. If you rename or move a character's folder, update the `id`, `sprite_card_path`, and `about_path` inside its manifest to match (and rename the `<character_id>.json` file itself).
108
114
 
109
115
  **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.
110
116
 
111
- In the editor web copilot the same filesystem is exposed via the **`browse_files` tool** (`action=list` / `action=search` / `action=read` / `action=write` / `action=annotate` / `action=move` / `action=rename`), so the copilot follows the identical reasoning: search or list to find the right offer's folder, then read an asset — or `write` a text doc (About.md, awareness-levels.md, …) or import a media URL (`source_url`) into that folder, annotating anything worth finding again. `action=rename` renames a `/files` or `/temp` file or folder in place (pass `path` + `new_name`, plus `file_id` for a file) — use it to keep character folders and asset names tidy. `browse_files list` defaults to `path='/'` when `path` is omitted, so the `/raws` (hunted raws) and `/temp` (scratch) roots surface alongside the My Files folders instead of being hidden; a `/raws` listing also accepts a `content_type` filter (exact shot-kind — `talking_head`, `b_roll`, `product_shot`, `screen_recording`, …), and every listing paginates via `offset` / `limit`. The devcli equivalents are `vidfarm files [--search]` / `get-file` / `put-file [--notes]` / `annotate-file` / `directory rename`.
117
+ In the editor web copilot the same filesystem is exposed via the **`browse_files` tool** (`action=list` / `action=search` / `action=read` / `action=write` / `action=annotate` / `action=move` / `action=copy` / `action=rename`), so the copilot follows the identical reasoning: search or list to find the right offer's folder, then read an asset — or `write` a text doc (About.md, awareness-levels.md, …) or import a media URL (`source_url`) into that folder, annotating anything worth finding again. `action=rename`/`move`/`copy` reorganize the tree (see the three routes above) — use them to keep character folders and asset names tidy. `browse_files list` defaults to `path='/'` when `path` is omitted, so the `/raws` (hunted raws) and `/temp` (scratch) roots surface alongside the My Files folders instead of being hidden; a `/raws` listing also accepts a `content_type` filter (exact shot-kind — `talking_head`, `b_roll`, `product_shot`, `screen_recording`, …), and every listing paginates via `offset` / `limit`. The devcli equivalents are `vidfarm files [--search]` / `get-file` / `put-file [--notes]` / `annotate-file` / `directory rename|move|copy`.
@@ -121,7 +121,7 @@ If a local AI script rewrites text or scenes without consuming those files first
121
121
  | `vidfarm generate <image\|video> --prompt "…"` | `POST /api/v1/primitives/{images,videos}/generate` (polls job) | generate AI media → finished URL |
122
122
  | `vidfarm inpaint <image> --mask <png> --prompt "…" [--region "label=…"] [--ref …] [--out <f>]` | `POST /api/v1/primitives/images/inpaint` (polls job) | masked image EDIT — replace ONLY the transparent-mask region, keep everything else (devcli twin of the /inpaint page) |
123
123
  | `vidfarm create-overlay "<subject>" [--key-color #00FF00] [--aspect-ratio 1:1] [--place <dir>] [--out <f>]` | `POST /api/v1/primitives/images/create-overlay` (polls job) | **Vox-style** transparent OVERLAY — AI image on a forced key-color background, chroma-keyed out in one job → ready-to-composite transparent PNG |
124
- | `vidfarm remove-background-greenscreen <image> [--key-color #00FF00] [--tolerance 0.3] [--out <f>]` | `POST /api/v1/primitives/images/remove-background-greenscreen` (polls job) | cheap chroma-key removal of a FLAT solid background → transparent PNG (cloud primitive) |
124
+ | `vidfarm remove-greenscreen <image\|video> [--preset green\|blue\|white\|black\|digital-green\|magenta] [--key-color #00FF00] [--tolerance 0.3] [--local] [--out <f>]` | `POST /api/v1/primitives/remove-greenscreen` (polls job) | chroma-key a FLAT solid background → transparent PNG/WebP (image) or WebM/VP9-alpha (video); auto-detects media kind. `--local` runs it FREE in-process (sharp/ffmpeg, no wallet); default cloud is billed at real compute × 1.2. Aliases: `greenscreen`, `remove-background-greenscreen`. |
125
125
  | `vidfarm tts "…" [--style "…"] [--voice <v>] [--out <file>]` | (LOCAL-FIRST: your own OPENAI/GEMINI/OPENROUTER_API_KEY → audio file on disk; `--cloud` = `POST /api/v1/primitives/audio/speech` + poll, ElevenLabs on the platform key by default, `--own-key` for yours) | text → narration audio; `--cloud --voice <voice_id>` picks an ElevenLabs voice |
126
126
  | `vidfarm music "<prompt>" [--length <sec>] [--out <f>] [--own-key]` | `POST /api/v1/primitives/music/generate` (polls job) | prompt → music track (ElevenLabs; platform key + wallet by default, `--own-key` for yours) |
127
127
  | `vidfarm voices [--own-key] [--limit N]` | `GET /api/v1/primitives/audio/voices` | list ElevenLabs voices (voice_id/name/labels) for `tts --voice`; default a voice + tell the user they can choose |
@@ -149,6 +149,8 @@ If a local AI script rewrites text or scenes without consuming those files first
149
149
  | `vidfarm provider-keys` / `vidfarm add-provider-key <p> <secret>` | `GET`·`POST /api/v1/user/me/provider-keys` | manage AI keys |
150
150
  | `vidfarm upload <file> [--folder <path>]` | `POST /api/v1/user/me/temporary-files/upload` | upload → durable URL (ephemeral; prefer `--folder temp` for scratch) |
151
151
  | `vidfarm download <url> [dest]` | (streams any URL to disk) | download media |
152
+ | `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 |
153
+ | `vidfarm download-post-audio <url>` | `POST /api/v1/primitives/audio/download` + poll | download a social/media post's audio into a durable audio file |
152
154
  | `vidfarm files [--folder <path>]` | `GET /api/v1/user/me/attachments` | list My Files assets + folders |
153
155
  | `vidfarm files --search "…" [--folder <path>]` | `POST /api/v1/user/me/attachments/search` | find My Files assets by MEANING (keyword + vector over name/folder/notes) |
154
156
  | `vidfarm get-file <id> [dest] [--print]` | (resolve id → view_url, then stream/print) | read one My Files asset |
@@ -17,18 +17,17 @@ curl -X POST "$VIDFARM_BASE/api/v1/primitives/images/remove-background" \
17
17
  -d '{"source_image_url": "https://cdn.example.com/photo.jpg"}'
18
18
  ```
19
19
 
20
- ## Primitive: image_remove_background_greenscreen
20
+ ## Primitive: remove-greenscreen (image **or** video chroma-key)
21
21
 
22
- Cheap **local chroma-key** background removal no third-party API, just `sharp` doing per-pixel color-distance keying, so it costs a fraction of `image_remove_background`. Use it whenever the source sits on a **flat, solid, evenly-lit background**: a green screen, or any single fill color you pass via `key_color` (e.g. `#FFFFFF` for a white background, `#0047BB` for blue). The result is a transparent PNG/WebP at a durable Vidfarm URL.
22
+ **Local chroma-key** background removal for an IMAGE **or** a VIDEO no third-party API, so it costs a fraction of `image_remove_background`. Use it whenever the source sits on a **flat, solid, evenly-lit background**: a green screen, or any single fill color. An IMAGE returns a transparent PNG/WebP; a VIDEO returns a transparent **WebM (VP9 alpha)** that stays transparent in the `/editor` preview AND the final render (it composites over whatever layers sit behind it).
23
23
 
24
- - `POST /api/v1/primitives/images/remove-background-greenscreen` (flat alias: `POST /api/v1/primitives/remove-background-greenscreen`)
25
- - Body: `{ "tracer": "...", "payload": { "source_image_url": "https://...", "key_color"?: "#00FF00", "tolerance"?: 0.3, "softness"?: 0.1, "despill"?: true, "output_format"?: "png" | "webp" }, "webhook_url"?: "..." }`
26
- - `key_color` the background color to key out (hex / `rgb(...)` / named `green`/`white`/`blue`/`black`). Default `#00FF00`.
27
- - `tolerance` (0–1, default `0.3`) how close a pixel must be to `key_color` to be removed. Raise it if fringe survives, lower it if the subject is being eaten.
28
- - `softness` (0–1, default `0.1`) feather band beyond `tolerance` for anti-aliased edges.
29
- - `despill` (default `true`) suppresses the residual key-color rim on the subject's edges (only applied for chromatic keys like green/blue).
30
- - Response: standard primitive job; poll for completion, then read `image.file_url` / `primary_file_url`. `keyed_fraction` in the output tells you what share of pixels were removed (near-0 usually means the wrong `key_color` or too-low `tolerance`).
31
- - Billing: small compute-only wallet fee (`greenscreen_chroma_key` cost center) — cheaper than the RapidAPI route.
24
+ - **Unified (auto-detect):** `POST /api/v1/primitives/remove-greenscreen` detects image vs video from the source and dispatches. Preferred entry point.
25
+ - Image-only: `POST /api/v1/primitives/images/remove-background-greenscreen` (flat alias: `POST /api/v1/primitives/remove-background-greenscreen`). Video-only: `POST /api/v1/primitives/videos/remove-greenscreen`.
26
+ - Body: `{ "tracer": "...", "payload": { "source_url": "https://..." (image OR video), "media_type"?: "image" | "video" (skip auto-detect), "preset"?: "green" | "digital-green" | "blue" | "white" | "black" | "magenta", "key_color"?: "#00FF00", "tolerance"?: 0.3, "softness"?: 0.1, "despill"?: true, "output_format"?: "png" | "webp" (image only) }, "webhook_url"?: "..." }`
27
+ - `preset` pick a common background color by name; fills `key_color` + tuned defaults. `key_color` (hex / `rgb(...)` / named) overrides the preset for a custom color/range.
28
+ - `tolerance` (0–1) — how close a pixel must be to key it out. Raise it if fringe survives, lower it if the subject is eaten. `softness` (01) feathers the edge; `despill` (default `true`) trims the residual key-color rim (green/blue only).
29
+ - Response: standard primitive job; poll, then read `primary_file_url` (a `.png/.webp` for image, `.webm` for video). After keying, `set_layer_media` the URL back onto the source layer to swap the transparent cut-out in place.
30
+ - Billing: the **real measured AWS compute cost × 1.2** (`greenscreen_chroma_key` cost center) no flat fee. Far cheaper than the RapidAPI matting route.
32
31
 
33
32
  ## Primitive: media_overlay ("create media overlay" — Vox-style)
34
33
 
@@ -52,6 +51,49 @@ curl -X POST "$VIDFARM_BASE/api/v1/primitives/images/create-overlay" \
52
51
  -d '{"tracer": "vox-overlay", "payload": {"prompt": "a friendly cartoon lightbulb mascot, flat vector illustration", "aspect_ratio": "1:1"}}'
53
52
  ```
54
53
 
54
+ ## Primitive: videos/download
55
+
56
+ Download a supported social/media post URL into a durable Vidfarm-hosted VISUAL asset. This route is intentionally broader than its old name suggests: it returns either a normal MP4 for video posts, or a slideshow payload for photo/carousel posts.
57
+
58
+ - `POST /api/v1/primitives/videos/download`
59
+ - Body: `{ "tracer": "...", "payload": { "source_url": "https://...", "quality"?: "best" | "hd" | "full_hd", "save_manifest"?: true }, "webhook_url"?: "..." }`
60
+ - Response: standard primitive job. Poll `GET /api/v1/primitives/jobs/:jobId` until `status: "succeeded"`.
61
+ - Video post: read `primary_file_url` / `video.file_url` / `videoUrl` for the durable MP4.
62
+ - Photo/carousel post: read `mediaKind: "slideshow"`, ordered `slideImageUrls[]`, optional `slideAudioUrl`, and `primary_file_url` (first slide image).
63
+ - Billing: RapidAPI pass-through wallet charge (`rapidapi_video_download`) plus the small mirror/extract pass (`video_download_lambda` when MP4 download is involved).
64
+ - Free-plan / no-spend rule: do **not** use this paid route when the user wants a free path. In the web app, tell them to use the browser to find a downloader instead. Fallback wording is explicit: suggest Googling `"youtube video downloader"` or `"tiktok/twitter/instagram/etc audio/video downloader"`.
65
+ - devcli wrapper: `vidfarm download-post <url> [--quality best|hd|full_hd]`
66
+
67
+ Example:
68
+
69
+ ```bash
70
+ curl -X POST "$VIDFARM_BASE/api/v1/primitives/videos/download" \
71
+ -H "vidfarm-api-key: $VIDFARM_API_KEY" \
72
+ -H "content-type: application/json" \
73
+ -d '{"tracer": "demo-download-post", "payload": {"source_url": "https://www.tiktok.com/@example/video/123"}}'
74
+ ```
75
+
76
+ ## Primitive: audio/download
77
+
78
+ Download just the original audio/music/voice track from a supported social/media post URL into a durable Vidfarm-hosted audio file. It reuses the same RapidAPI resolver as `videos/download`, but returns audio instead of a video/slideshow.
79
+
80
+ - `POST /api/v1/primitives/audio/download`
81
+ - Body: `{ "tracer": "...", "payload": { "source_url": "https://...", "save_manifest"?: true }, "webhook_url"?: "..." }`
82
+ - Response: standard primitive job. Poll `GET /api/v1/primitives/jobs/:jobId` until `status: "succeeded"`, then read `primary_file_url` / `audio.file_url` / `audioUrl`.
83
+ - Behavior: prefers the provider's direct audio track when present; otherwise resolves the source video and extracts audio server-side.
84
+ - Billing: same RapidAPI wallet class as `videos/download`.
85
+ - Free-plan / no-spend rule: same as the visual download route. Use the browser and, if needed, suggest Googling `"youtube audio downloader"` or `"tiktok/twitter/instagram/etc audio/video downloader"` instead of spending wallet credits.
86
+ - devcli wrapper: `vidfarm download-post-audio <url>`
87
+
88
+ Example:
89
+
90
+ ```bash
91
+ curl -X POST "$VIDFARM_BASE/api/v1/primitives/audio/download" \
92
+ -H "vidfarm-api-key: $VIDFARM_API_KEY" \
93
+ -H "content-type: application/json" \
94
+ -d '{"tracer": "demo-download-audio", "payload": {"source_url": "https://www.youtube.com/watch?v=example"}}'
95
+ ```
96
+
55
97
  ## Primitive: video_remove_captions
56
98
 
57
99
  Remove burned-in captions/subtitles/on-screen text from any video URL (GhostCut-powered — the same pipeline auto-decompose uses for its caption-free mirror, exposed as a standalone reusable primitive). Result is a durable caption-free MP4 stored at a Vidfarm URL.
package/SKILL.director.md CHANGED
@@ -851,11 +851,17 @@ Recurring characters that must look the same across videos have a **dedicated, b
851
851
  4. **Write `character_about.md` and `<character_id>.json`** (e.g. `character_zara.json`) into the same folder (`browse_files action=write` with `content`, or `vidfarm put-file --content`).
852
852
  5. **Annotate all three** with notes naming the character so `files --search "our mascot"` finds them from any phrasing.
853
853
 
854
- **Renaming / fixing up.** Character folders and files are renamable in place `browse_files action=rename` (web copilot) or `vidfarm directory rename <path> <new-name> [--file-id <id>]` (devcli); the explorer UI has a **Rename** action on every file/folder row. If you rename a character's folder, update the `id`, `sprite_card_path`, and `about_path` inside its manifest to match (and rename the `<character_id>.json` file itself).
854
+ **Renaming / moving / copying.** The whole directory is reorganizable in place, for **paid AND free** users, via three REST routes, their devcli twins, and the explorer's kebab / right-click menu:
855
+
856
+ - **rename** — `POST /api/v1/user/me/directory/rename` `{ path, new_name, file_id? }` · `vidfarm directory rename <path> <new-name> [--file-id <id>]`. Renames a `/files`/`/temp` file (display name only — the view URL keeps working) or a folder in any writable root (its files + nested subfolders come along).
857
+ - **move** — `POST /api/v1/user/me/directory/move` `{ path, to, file_id? }` · `vidfarm directory move <path> <to-folder> [--file-id <id>]`. Relocates a FILE into `to` (with `file_id`) or nests a whole FOLDER under `to`. **Same root only** (`/files`·`/temp`·`/raws`·`/approved`); metadata-only, so S3 objects are untouched.
858
+ - **copy** — `POST /api/v1/user/me/directory/copy` `{ path, to?, file_id?, new_name? }` · `vidfarm directory copy <path> [<to-folder>] [--file-id <id>] [--as <name>]`. Duplicates a file/folder sharing the same underlying S3 object (cheap, no re-upload). Same root only, `/files`·`/temp`·`/raws` (not `/approved` — a ready post is a single publishable unit; not `/projects` — read-only). Omit `to` to duplicate in place.
859
+
860
+ The web copilot exposes the same three as `browse_files action=rename|move|copy`. If you rename or move a character's folder, update the `id`, `sprite_card_path`, and `about_path` inside its manifest to match (and rename the `<character_id>.json` file itself).
855
861
 
856
862
  **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.
857
863
 
858
- In the editor web copilot the same filesystem is exposed via the **`browse_files` tool** (`action=list` / `action=search` / `action=read` / `action=write` / `action=annotate` / `action=move` / `action=rename`), so the copilot follows the identical reasoning: search or list to find the right offer's folder, then read an asset — or `write` a text doc (About.md, awareness-levels.md, …) or import a media URL (`source_url`) into that folder, annotating anything worth finding again. `action=rename` renames a `/files` or `/temp` file or folder in place (pass `path` + `new_name`, plus `file_id` for a file) — use it to keep character folders and asset names tidy. `browse_files list` defaults to `path='/'` when `path` is omitted, so the `/raws` (hunted raws) and `/temp` (scratch) roots surface alongside the My Files folders instead of being hidden; a `/raws` listing also accepts a `content_type` filter (exact shot-kind — `talking_head`, `b_roll`, `product_shot`, `screen_recording`, …), and every listing paginates via `offset` / `limit`. The devcli equivalents are `vidfarm files [--search]` / `get-file` / `put-file [--notes]` / `annotate-file` / `directory rename`.
864
+ In the editor web copilot the same filesystem is exposed via the **`browse_files` tool** (`action=list` / `action=search` / `action=read` / `action=write` / `action=annotate` / `action=move` / `action=copy` / `action=rename`), so the copilot follows the identical reasoning: search or list to find the right offer's folder, then read an asset — or `write` a text doc (About.md, awareness-levels.md, …) or import a media URL (`source_url`) into that folder, annotating anything worth finding again. `action=rename`/`move`/`copy` reorganize the tree (see the three routes above) — use them to keep character folders and asset names tidy. `browse_files list` defaults to `path='/'` when `path` is omitted, so the `/raws` (hunted raws) and `/temp` (scratch) roots surface alongside the My Files folders instead of being hidden; a `/raws` listing also accepts a `content_type` filter (exact shot-kind — `talking_head`, `b_roll`, `product_shot`, `screen_recording`, …), and every listing paginates via `offset` / `limit`. The devcli equivalents are `vidfarm files [--search]` / `get-file` / `put-file [--notes]` / `annotate-file` / `directory rename|move|copy`.
859
865
 
860
866
  ## Automate a template via REST
861
867
 
@@ -980,7 +986,7 @@ If a local AI script rewrites text or scenes without consuming those files first
980
986
  | `vidfarm generate <image\|video> --prompt "…"` | `POST /api/v1/primitives/{images,videos}/generate` (polls job) | generate AI media → finished URL |
981
987
  | `vidfarm inpaint <image> --mask <png> --prompt "…" [--region "label=…"] [--ref …] [--out <f>]` | `POST /api/v1/primitives/images/inpaint` (polls job) | masked image EDIT — replace ONLY the transparent-mask region, keep everything else (devcli twin of the /inpaint page) |
982
988
  | `vidfarm create-overlay "<subject>" [--key-color #00FF00] [--aspect-ratio 1:1] [--place <dir>] [--out <f>]` | `POST /api/v1/primitives/images/create-overlay` (polls job) | **Vox-style** transparent OVERLAY — AI image on a forced key-color background, chroma-keyed out in one job → ready-to-composite transparent PNG |
983
- | `vidfarm remove-background-greenscreen <image> [--key-color #00FF00] [--tolerance 0.3] [--out <f>]` | `POST /api/v1/primitives/images/remove-background-greenscreen` (polls job) | cheap chroma-key removal of a FLAT solid background → transparent PNG (cloud primitive) |
989
+ | `vidfarm remove-greenscreen <image\|video> [--preset green\|blue\|white\|black\|digital-green\|magenta] [--key-color #00FF00] [--tolerance 0.3] [--local] [--out <f>]` | `POST /api/v1/primitives/remove-greenscreen` (polls job) | chroma-key a FLAT solid background → transparent PNG/WebP (image) or WebM/VP9-alpha (video); auto-detects media kind. `--local` runs it FREE in-process (sharp/ffmpeg, no wallet); default cloud is billed at real compute × 1.2. Aliases: `greenscreen`, `remove-background-greenscreen`. |
984
990
  | `vidfarm tts "…" [--style "…"] [--voice <v>] [--out <file>]` | (LOCAL-FIRST: your own OPENAI/GEMINI/OPENROUTER_API_KEY → audio file on disk; `--cloud` = `POST /api/v1/primitives/audio/speech` + poll, ElevenLabs on the platform key by default, `--own-key` for yours) | text → narration audio; `--cloud --voice <voice_id>` picks an ElevenLabs voice |
985
991
  | `vidfarm music "<prompt>" [--length <sec>] [--out <f>] [--own-key]` | `POST /api/v1/primitives/music/generate` (polls job) | prompt → music track (ElevenLabs; platform key + wallet by default, `--own-key` for yours) |
986
992
  | `vidfarm voices [--own-key] [--limit N]` | `GET /api/v1/primitives/audio/voices` | list ElevenLabs voices (voice_id/name/labels) for `tts --voice`; default a voice + tell the user they can choose |
@@ -1008,6 +1014,8 @@ If a local AI script rewrites text or scenes without consuming those files first
1008
1014
  | `vidfarm provider-keys` / `vidfarm add-provider-key <p> <secret>` | `GET`·`POST /api/v1/user/me/provider-keys` | manage AI keys |
1009
1015
  | `vidfarm upload <file> [--folder <path>]` | `POST /api/v1/user/me/temporary-files/upload` | upload → durable URL (ephemeral; prefer `--folder temp` for scratch) |
1010
1016
  | `vidfarm download <url> [dest]` | (streams any URL to disk) | download media |
1017
+ | `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 |
1018
+ | `vidfarm download-post-audio <url>` | `POST /api/v1/primitives/audio/download` + poll | download a social/media post's audio into a durable audio file |
1011
1019
  | `vidfarm files [--folder <path>]` | `GET /api/v1/user/me/attachments` | list My Files assets + folders |
1012
1020
  | `vidfarm files --search "…" [--folder <path>]` | `POST /api/v1/user/me/attachments/search` | find My Files assets by MEANING (keyword + vector over name/folder/notes) |
1013
1021
  | `vidfarm get-file <id> [dest] [--print]` | (resolve id → view_url, then stream/print) | read one My Files asset |
@@ -1149,18 +1157,17 @@ curl -X POST "$VIDFARM_BASE/api/v1/primitives/images/remove-background" \
1149
1157
  -d '{"source_image_url": "https://cdn.example.com/photo.jpg"}'
1150
1158
  ```
1151
1159
 
1152
- ## Primitive: image_remove_background_greenscreen
1160
+ ## Primitive: remove-greenscreen (image **or** video chroma-key)
1153
1161
 
1154
- Cheap **local chroma-key** background removal no third-party API, just `sharp` doing per-pixel color-distance keying, so it costs a fraction of `image_remove_background`. Use it whenever the source sits on a **flat, solid, evenly-lit background**: a green screen, or any single fill color you pass via `key_color` (e.g. `#FFFFFF` for a white background, `#0047BB` for blue). The result is a transparent PNG/WebP at a durable Vidfarm URL.
1162
+ **Local chroma-key** background removal for an IMAGE **or** a VIDEO no third-party API, so it costs a fraction of `image_remove_background`. Use it whenever the source sits on a **flat, solid, evenly-lit background**: a green screen, or any single fill color. An IMAGE returns a transparent PNG/WebP; a VIDEO returns a transparent **WebM (VP9 alpha)** that stays transparent in the `/editor` preview AND the final render (it composites over whatever layers sit behind it).
1155
1163
 
1156
- - `POST /api/v1/primitives/images/remove-background-greenscreen` (flat alias: `POST /api/v1/primitives/remove-background-greenscreen`)
1157
- - Body: `{ "tracer": "...", "payload": { "source_image_url": "https://...", "key_color"?: "#00FF00", "tolerance"?: 0.3, "softness"?: 0.1, "despill"?: true, "output_format"?: "png" | "webp" }, "webhook_url"?: "..." }`
1158
- - `key_color` the background color to key out (hex / `rgb(...)` / named `green`/`white`/`blue`/`black`). Default `#00FF00`.
1159
- - `tolerance` (0–1, default `0.3`) how close a pixel must be to `key_color` to be removed. Raise it if fringe survives, lower it if the subject is being eaten.
1160
- - `softness` (0–1, default `0.1`) feather band beyond `tolerance` for anti-aliased edges.
1161
- - `despill` (default `true`) suppresses the residual key-color rim on the subject's edges (only applied for chromatic keys like green/blue).
1162
- - Response: standard primitive job; poll for completion, then read `image.file_url` / `primary_file_url`. `keyed_fraction` in the output tells you what share of pixels were removed (near-0 usually means the wrong `key_color` or too-low `tolerance`).
1163
- - Billing: small compute-only wallet fee (`greenscreen_chroma_key` cost center) — cheaper than the RapidAPI route.
1164
+ - **Unified (auto-detect):** `POST /api/v1/primitives/remove-greenscreen` detects image vs video from the source and dispatches. Preferred entry point.
1165
+ - Image-only: `POST /api/v1/primitives/images/remove-background-greenscreen` (flat alias: `POST /api/v1/primitives/remove-background-greenscreen`). Video-only: `POST /api/v1/primitives/videos/remove-greenscreen`.
1166
+ - Body: `{ "tracer": "...", "payload": { "source_url": "https://..." (image OR video), "media_type"?: "image" | "video" (skip auto-detect), "preset"?: "green" | "digital-green" | "blue" | "white" | "black" | "magenta", "key_color"?: "#00FF00", "tolerance"?: 0.3, "softness"?: 0.1, "despill"?: true, "output_format"?: "png" | "webp" (image only) }, "webhook_url"?: "..." }`
1167
+ - `preset` pick a common background color by name; fills `key_color` + tuned defaults. `key_color` (hex / `rgb(...)` / named) overrides the preset for a custom color/range.
1168
+ - `tolerance` (0–1) — how close a pixel must be to key it out. Raise it if fringe survives, lower it if the subject is eaten. `softness` (01) feathers the edge; `despill` (default `true`) trims the residual key-color rim (green/blue only).
1169
+ - Response: standard primitive job; poll, then read `primary_file_url` (a `.png/.webp` for image, `.webm` for video). After keying, `set_layer_media` the URL back onto the source layer to swap the transparent cut-out in place.
1170
+ - Billing: the **real measured AWS compute cost × 1.2** (`greenscreen_chroma_key` cost center) no flat fee. Far cheaper than the RapidAPI matting route.
1164
1171
 
1165
1172
  ## Primitive: media_overlay ("create media overlay" — Vox-style)
1166
1173
 
@@ -1184,6 +1191,49 @@ curl -X POST "$VIDFARM_BASE/api/v1/primitives/images/create-overlay" \
1184
1191
  -d '{"tracer": "vox-overlay", "payload": {"prompt": "a friendly cartoon lightbulb mascot, flat vector illustration", "aspect_ratio": "1:1"}}'
1185
1192
  ```
1186
1193
 
1194
+ ## Primitive: videos/download
1195
+
1196
+ Download a supported social/media post URL into a durable Vidfarm-hosted VISUAL asset. This route is intentionally broader than its old name suggests: it returns either a normal MP4 for video posts, or a slideshow payload for photo/carousel posts.
1197
+
1198
+ - `POST /api/v1/primitives/videos/download`
1199
+ - Body: `{ "tracer": "...", "payload": { "source_url": "https://...", "quality"?: "best" | "hd" | "full_hd", "save_manifest"?: true }, "webhook_url"?: "..." }`
1200
+ - Response: standard primitive job. Poll `GET /api/v1/primitives/jobs/:jobId` until `status: "succeeded"`.
1201
+ - Video post: read `primary_file_url` / `video.file_url` / `videoUrl` for the durable MP4.
1202
+ - Photo/carousel post: read `mediaKind: "slideshow"`, ordered `slideImageUrls[]`, optional `slideAudioUrl`, and `primary_file_url` (first slide image).
1203
+ - Billing: RapidAPI pass-through wallet charge (`rapidapi_video_download`) plus the small mirror/extract pass (`video_download_lambda` when MP4 download is involved).
1204
+ - Free-plan / no-spend rule: do **not** use this paid route when the user wants a free path. In the web app, tell them to use the browser to find a downloader instead. Fallback wording is explicit: suggest Googling `"youtube video downloader"` or `"tiktok/twitter/instagram/etc audio/video downloader"`.
1205
+ - devcli wrapper: `vidfarm download-post <url> [--quality best|hd|full_hd]`
1206
+
1207
+ Example:
1208
+
1209
+ ```bash
1210
+ curl -X POST "$VIDFARM_BASE/api/v1/primitives/videos/download" \
1211
+ -H "vidfarm-api-key: $VIDFARM_API_KEY" \
1212
+ -H "content-type: application/json" \
1213
+ -d '{"tracer": "demo-download-post", "payload": {"source_url": "https://www.tiktok.com/@example/video/123"}}'
1214
+ ```
1215
+
1216
+ ## Primitive: audio/download
1217
+
1218
+ Download just the original audio/music/voice track from a supported social/media post URL into a durable Vidfarm-hosted audio file. It reuses the same RapidAPI resolver as `videos/download`, but returns audio instead of a video/slideshow.
1219
+
1220
+ - `POST /api/v1/primitives/audio/download`
1221
+ - Body: `{ "tracer": "...", "payload": { "source_url": "https://...", "save_manifest"?: true }, "webhook_url"?: "..." }`
1222
+ - Response: standard primitive job. Poll `GET /api/v1/primitives/jobs/:jobId` until `status: "succeeded"`, then read `primary_file_url` / `audio.file_url` / `audioUrl`.
1223
+ - Behavior: prefers the provider's direct audio track when present; otherwise resolves the source video and extracts audio server-side.
1224
+ - Billing: same RapidAPI wallet class as `videos/download`.
1225
+ - Free-plan / no-spend rule: same as the visual download route. Use the browser and, if needed, suggest Googling `"youtube audio downloader"` or `"tiktok/twitter/instagram/etc audio/video downloader"` instead of spending wallet credits.
1226
+ - devcli wrapper: `vidfarm download-post-audio <url>`
1227
+
1228
+ Example:
1229
+
1230
+ ```bash
1231
+ curl -X POST "$VIDFARM_BASE/api/v1/primitives/audio/download" \
1232
+ -H "vidfarm-api-key: $VIDFARM_API_KEY" \
1233
+ -H "content-type: application/json" \
1234
+ -d '{"tracer": "demo-download-audio", "payload": {"source_url": "https://www.youtube.com/watch?v=example"}}'
1235
+ ```
1236
+
1187
1237
  ## Primitive: video_remove_captions
1188
1238
 
1189
1239
  Remove burned-in captions/subtitles/on-screen text from any video URL (GhostCut-powered — the same pipeline auto-decompose uses for its caption-free mirror, exposed as a standalone reusable primitive). Result is a durable caption-free MP4 stored at a Vidfarm URL.
package/dist/src/cli.js CHANGED
@@ -22,7 +22,7 @@ import { renderCompositionStills } from "./devcli/stills.js";
22
22
  import { runDoctorCommand } from "./devcli/doctor.js";
23
23
  import { runSkillsCommand } from "./devcli/skills.js";
24
24
  import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
25
- import { resolveLocalDataDir, localBackendAvailable, LocalModeUnavailableError } from "./devcli/local-backend.js";
25
+ import { resolveLocalDataDir, localBackendAvailable, LocalModeUnavailableError, localApiRequest } from "./devcli/local-backend.js";
26
26
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
27
27
  // `serve` command boots the FULL editor locally (single origin, disk-backed
28
28
  // records + storage) so power users edit compositions on disk while a browser
@@ -172,14 +172,20 @@ Generate AI media and drop it on the timeline (for local coding agents):
172
172
  --out <file> Download the transparent PNG to this path
173
173
  --no-wait Return the job id instead of polling to the URL
174
174
  --place <dir> --at <time>|--replace <layer_key> Fuse onto a composition
175
- remove-background-greenscreen <image|url> Cheap chroma-key removal of a FLAT
176
- solid background → transparent PNG (cloud primitive;
177
- differs from remove-background = local ONNX matting)
178
- POST /api/v1/primitives/images/remove-background-greenscreen
179
- --key-color <c> Background color to key out (default #00FF00; e.g. #FFFFFF)
175
+ remove-greenscreen <image|video|url> Key out a FLAT solid background from an
176
+ IMAGE (→ transparent PNG/WebP) or VIDEO ( transparent
177
+ WebM/VP9 alpha), auto-detecting the media kind.
178
+ (aliases: greenscreen, remove-background-greenscreen)
179
+ POST /api/v1/primitives/remove-greenscreen
180
+ --preset <name> green | digital-green | blue | white | black | magenta
181
+ --key-color <c> Custom background color (hex/rgb/named; overrides --preset)
182
+ --media-type <k> Force image|video instead of auto-detecting
180
183
  --tolerance <0..1> Key radius (default 0.3); --softness <0..1> edge feather (0.1)
181
184
  --no-despill Skip edge color-fringe suppression
182
- --out <file> Download the transparent PNG to this path
185
+ --output-format <f> Image only: png|webp
186
+ --local Run the keyer in-process for FREE (sharp/ffmpeg, no wallet)
187
+ --cloud Force the billed cloud primitive (default when a key is set)
188
+ --out <file> Download the transparent result to this path
183
189
  place <dir|composition.html> Insert media into a local composition
184
190
  (fill a gap, replace a scene, or overlay an AUDIO
185
191
  track) — same clip markup the browser editor makes;
@@ -276,6 +282,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
276
282
  --clip [--prompt "..."] [--provider gemini|openai|openrouter|nvidia]
277
283
  (raw) AI-clip the video into matching moments instead of
278
284
  importing the whole thing (BYOK; nvidia = lite clipping key)
285
+ Note: raw mode needs a VIDEO URL. For a LOCAL FILE from
286
+ your computer, use 'vidfarm clipper ./file.mp4 --cloud'
287
+ to upload it and import the whole video into /raws, or
288
+ add --start/--end to save one exact subrange.
279
289
 
280
290
  Raws (the third library — mine long-form video into a reusable raws store):
281
291
  raws scan <video-path> Hunt short clips out of a long video. LOCAL-FIRST: local ffmpeg +
@@ -285,6 +295,9 @@ Raws (the third library — mine long-form video into a reusable raws store):
285
295
  --duration <sec> Target clip length as a SOFT band (10→5-20s, 30→20-40s)
286
296
  --aspect 9:16|16:9|4:3|1:1 Crop clips (also: vertical/horizontal/square) [--crop-focus <f>]
287
297
  --no-text Prefer scenes WITHOUT captions/on-screen text (selection only)
298
+ --notes "<private notes>" Store private owner notes as user_notes on derived raws and
299
+ bias decomposition/categorization with them. Explicit private
300
+ shot-kind hints like "content_type: demo" stay owner-only.
288
301
  --cloud [--url <url>] BACKUP: run on the deployed pipeline → POST /raws/scan (+ poll)
289
302
  (uploads to your temp folder — 30-day TTL — bills AWS compute only)
290
303
  --folder <name> (with --cloud) Save the mined clips into /raws/<name>
@@ -308,9 +321,18 @@ Raws (the third library — mine long-form video into a reusable raws store):
308
321
  --tracer <name> Group this session's clips (also the default folder)
309
322
  --folder <name> Save the clip into /raws/<name>
310
323
  --name <text> Human name for the clip
324
+ --notes "<private notes>" Store private owner notes as user_notes on the imported raw.
325
+ The notes also bias categorization/decomposition, and an
326
+ explicit private shot-kind hint like 'content_type: demo'
327
+ or 'category: talking_head' is honored only for that owner.
311
328
  --audio-only Save just the audio of the subrange as an .m4a raw
312
329
  (needs --start/--end; e.g. lift a copyright-free track)
313
330
  (multi-clip: re-run with a new range + same --tracer)
331
+ Direct local-file upload to CLOUD raws is supported:
332
+ 'vidfarm clipper ./source.mp4 --cloud --folder campaign'
333
+ imports the whole local file as one raw; add
334
+ '--start ... --end ...' to upload the local file and
335
+ save only that exact clipped range.
314
336
 
315
337
  Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit backup:
316
338
  tts "<text>" Text → narration audio file. LOCAL: your OPENAI/GEMINI/
@@ -450,6 +472,8 @@ Approve & schedule (publish a finished MP4 as a shareable post):
450
472
  --media <url|file> Extra media (repeatable; local files auto-upload to temp/)
451
473
  --folder <path> Temp-store folder for uploaded local files (default: temp)
452
474
  --caption <text> Post caption (required)
475
+ Direct local-file upload is supported here:
476
+ 'vidfarm approve --video ./final.mp4 --caption "..."'
453
477
  posts List your approved posts → GET /api/v1/approved/posts
454
478
  post <postId> Read one approved post (prints share URL) → GET /api/v1/approved/posts/:postId
455
479
  schedule <postId> Schedule an approved post to a channel → POST /api/v1/approved/posts/:postId/schedules
@@ -482,6 +506,11 @@ Files (multi-step flows the devcli handles for you):
482
506
  e.g. vidfarm put-file About.md --folder acme-skincare
483
507
  echo "..." | vidfarm put-file --stdin --as About.md --folder acme
484
508
  download <url> [dest] Stream any Vidfarm/media URL to disk
509
+ download-post <url> Download a social/media post into Vidfarm media → POST /api/v1/primitives/videos/download
510
+ --quality <q> best | hd | full_hd (default: best)
511
+ --no-wait Return the queued job immediately instead of polling
512
+ download-post-audio <url> Download a social/media post's audio into Vidfarm → POST /api/v1/primitives/audio/download
513
+ --no-wait Return the queued job immediately instead of polling
485
514
  files List My Files assets + folders → GET /api/v1/user/me/attachments
486
515
  --folder <path> Only files under this folder (e.g. a product/offer)
487
516
  --search <query> Find files by MEANING (keyword + vector over → POST /api/v1/user/me/attachments/search
@@ -500,6 +529,10 @@ Files (multi-step flows the devcli handles for you):
500
529
  --content-type <a,b> Filter the /raws branch by shot-KIND tag (talking_head, b_roll, …)
501
530
  directory rename <path> <new-name> Rename a /files or /temp file/folder → POST /api/v1/user/me/directory/rename
502
531
  --file-id <id> Rename a FILE (display name); omit to rename the folder at <path>
532
+ directory move <path> <to-folder> Move a file/folder into another folder → POST /api/v1/user/me/directory/move
533
+ --file-id <id> Move a FILE; omit to move the folder (same root, e.g. /raws/demos → /raws/archive)
534
+ directory copy <path> [<to-folder>] Duplicate a file/folder (shared S3 obj) → POST /api/v1/user/me/directory/copy
535
+ --file-id <id> --as <name> Copy a FILE; omit --file-id to copy the folder; --as renames the copy
503
536
  directory save-url <url> Save a durable media URL INTO My Files at a folder → POST /api/v1/user/me/attachments/from-url
504
537
  --folder <path> Destination folder under /files (e.g. inpaints, promos)
505
538
  --as <name> Name the saved file · --notes <text> vector-embedded notes
@@ -640,10 +673,11 @@ async function main() {
640
673
  case "overlay":
641
674
  await runCreateOverlayCommand(rest);
642
675
  return;
676
+ case "remove-greenscreen":
643
677
  case "remove-background-greenscreen":
644
678
  case "rmbg-green":
645
679
  case "greenscreen":
646
- await runRemoveBackgroundGreenscreenCommand(rest);
680
+ await runRemoveGreenscreenCommand(rest);
647
681
  return;
648
682
  case "tts":
649
683
  await runTtsCommand(rest);
@@ -740,6 +774,12 @@ async function main() {
740
774
  case "download":
741
775
  await runDownloadCommand(rest);
742
776
  return;
777
+ case "download-post":
778
+ await runDownloadPostCommand(rest);
779
+ return;
780
+ case "download-post-audio":
781
+ await runDownloadPostAudioCommand(rest);
782
+ return;
743
783
  case "files":
744
784
  await runFilesCommand(rest);
745
785
  return;
@@ -2849,6 +2889,7 @@ function resolveJobMediaUrl(job) {
2849
2889
  const output = (result.output && typeof result.output === "object") ? result.output : result;
2850
2890
  const direct = readStr(output.primary_file_url)
2851
2891
  ?? readStr(output.video?.file_url)
2892
+ ?? readStr(output.audio?.file_url)
2852
2893
  ?? readStr(output.image?.file_url)
2853
2894
  ?? readStr(output.render?.output_url);
2854
2895
  if (direct)
@@ -3551,33 +3592,67 @@ async function runCreateOverlayCommand(argv) {
3551
3592
  }
3552
3593
  }
3553
3594
  // ── Green-screen (chroma-key) background removal ──────────────────────────────
3554
- // The devcli twin of the image_remove_background_greenscreen primitive: cheap
3555
- // local chroma key on a flat, solid background. A cloud primitive job (distinct
3556
- // from `vidfarm remove-background`, which is the free LOCAL ONNX matting engine).
3557
- async function runRemoveBackgroundGreenscreenCommand(argv) {
3595
+ // The devcli twin of the remove-greenscreen primitive: key out a flat, solid
3596
+ // background from an IMAGE (→ transparent PNG/WebP) OR a VIDEO (→ transparent
3597
+ // WebM/VP9 alpha), auto-detecting the media kind. LOCAL vs CLOUD: --local runs
3598
+ // the keyer in-process for FREE (sharp/ffmpeg, no wallet); --cloud runs the
3599
+ // billed primitive. Default = cloud (paid), unless there's no cloud key AND the
3600
+ // local backend is present (free / offline), in which case it runs local.
3601
+ const GREENSCREEN_PRESET_NAMES = new Set(["green", "digital-green", "blue", "white", "black", "magenta"]);
3602
+ function resolveGreenscreenTarget(values) {
3603
+ if (values.local)
3604
+ return "local";
3605
+ if (values.cloud)
3606
+ return "cloud";
3607
+ const env = (process.env.VIDFARM_TARGET ?? "").trim().toLowerCase();
3608
+ if (env === "local" || env === "cloud")
3609
+ return env;
3610
+ const hasCloudKey = Boolean(values["api-key"] ?? process.env.VIDFARM_API_KEY);
3611
+ if (!hasCloudKey && localBackendAvailable())
3612
+ return "local";
3613
+ return "cloud";
3614
+ }
3615
+ async function runRemoveGreenscreenCommand(argv) {
3558
3616
  const parsed = parseArgs({
3559
3617
  args: argv,
3560
3618
  allowPositionals: true,
3561
3619
  options: {
3562
3620
  ...commonOptions(),
3563
3621
  source: { type: "string" },
3622
+ preset: { type: "string" },
3623
+ "media-type": { type: "string" },
3564
3624
  "key-color": { type: "string" },
3565
3625
  tolerance: { type: "string" },
3566
3626
  softness: { type: "string" },
3567
3627
  "no-despill": { type: "boolean", default: false },
3568
3628
  "output-format": { type: "string" },
3629
+ local: { type: "boolean", default: false },
3630
+ cloud: { type: "boolean", default: false },
3569
3631
  out: { type: "string" },
3570
3632
  "no-wait": { type: "boolean", default: false },
3571
3633
  tracer: { type: "string" }
3572
3634
  }
3573
3635
  });
3574
3636
  const ctx = commonContext(parsed.values);
3637
+ const target = resolveGreenscreenTarget(parsed.values);
3638
+ if (target === "local" && !localBackendAvailable())
3639
+ throw new LocalModeUnavailableError();
3575
3640
  const sourceArg = parsed.values.source ?? parsed.positionals[0];
3576
3641
  if (!sourceArg) {
3577
- throw new Error("remove-background-greenscreen requires a source image: `vidfarm remove-background-greenscreen <image|url> [--key-color #00FF00] [--out cutout.png]`.");
3642
+ throw new Error("remove-greenscreen requires a source image or video: `vidfarm remove-greenscreen <image|video|url> [--preset green|blue|white|black|digital-green|magenta] [--key-color #00FF00] [--local] [--out cutout.png|.webm]`.");
3578
3643
  }
3579
3644
  const sourceUrl = await resolveSingleMediaUrl(ctx, sourceArg);
3580
- const payload = { source_image_url: sourceUrl };
3645
+ const presetRaw = parsed.values.preset?.trim().toLowerCase();
3646
+ if (presetRaw && !GREENSCREEN_PRESET_NAMES.has(presetRaw)) {
3647
+ throw new Error(`Unknown --preset "${presetRaw}". Choose one of: ${[...GREENSCREEN_PRESET_NAMES].join(", ")}, or pass --key-color <hex>.`);
3648
+ }
3649
+ const mediaTypeArg = parsed.values["media-type"]?.trim().toLowerCase();
3650
+ const mediaType = mediaTypeArg === "image" || mediaTypeArg === "video"
3651
+ ? mediaTypeArg
3652
+ : (/\.(mp4|mov|webm|m4v|mkv)(\?|#|$)/i.test(sourceUrl) ? "video" : "image");
3653
+ const payload = { source_url: sourceUrl, media_type: mediaType };
3654
+ if (presetRaw)
3655
+ payload.preset = presetRaw;
3581
3656
  if (parsed.values["key-color"])
3582
3657
  payload.key_color = parsed.values["key-color"];
3583
3658
  if (parsed.values.tolerance)
@@ -3586,12 +3661,17 @@ async function runRemoveBackgroundGreenscreenCommand(argv) {
3586
3661
  payload.softness = Number(parsed.values.softness);
3587
3662
  if (parsed.values["no-despill"])
3588
3663
  payload.despill = false;
3589
- if (parsed.values["output-format"])
3664
+ if (mediaType === "image" && parsed.values["output-format"])
3590
3665
  payload.output_format = parsed.values["output-format"];
3591
- const tracer = parsed.values.tracer ?? `devcli-gsbg-${Date.now().toString(36)}`;
3592
- const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/images/remove-background-greenscreen", auth: ctx.auth, body: { tracer, payload } });
3593
- assertApiOk(submit, "remove-background-greenscreen");
3666
+ const tracer = parsed.values.tracer ?? `devcli-greenscreen-${Date.now().toString(36)}`;
3667
+ const route = "/api/v1/primitives/remove-greenscreen";
3668
+ const submit = target === "local"
3669
+ ? await localApiRequest({ method: "POST", path: route, auth: ctx.auth, body: { tracer, payload }, home: ctx.home })
3670
+ : await apiRequest({ method: "POST", host: ctx.host, path: route, auth: ctx.auth, body: { tracer, payload } });
3671
+ assertApiOk(submit, "remove-greenscreen");
3594
3672
  const jobId = submit.json?.job_id;
3673
+ if (!ctx.json)
3674
+ console.log(`${DIM}${target === "local" ? "Local (free)" : "Cloud"} ${mediaType} greenscreen removal…${RESET}`);
3595
3675
  const wait = !parsed.values["no-wait"];
3596
3676
  if (!wait || !jobId) {
3597
3677
  if (!ctx.json && jobId)
@@ -3601,7 +3681,7 @@ async function runRemoveBackgroundGreenscreenCommand(argv) {
3601
3681
  }
3602
3682
  if (!ctx.json)
3603
3683
  console.log(`${DIM}Keying out background (${jobId})… polling every 5s.${RESET}`);
3604
- const job = await pollPrimitiveJob(ctx, jobId);
3684
+ const job = await pollGreenscreenJob(ctx, target, jobId);
3605
3685
  const mediaUrl = resolveJobMediaUrl(job);
3606
3686
  const status = String(job?.status ?? "");
3607
3687
  if (!mediaUrl) {
@@ -3621,12 +3701,38 @@ async function runRemoveBackgroundGreenscreenCommand(argv) {
3621
3701
  await downloadUrlToFile(mediaUrl, outPath);
3622
3702
  }
3623
3703
  if (ctx.json) {
3624
- printJson({ ok: true, job_id: jobId, media_url: mediaUrl, out: outPath, keyed_fraction: job?.result?.keyed_fraction ?? null });
3704
+ printJson({ ok: true, job_id: jobId, media_type: mediaType, target, media_url: mediaUrl, out: outPath });
3625
3705
  }
3626
3706
  else {
3627
- console.log(`${GREEN}Transparent cut-out ready:${RESET} ${mediaUrl}`);
3707
+ console.log(`${GREEN}Transparent ${mediaType === "video" ? "WebM" : "cut-out"} ready:${RESET} ${mediaUrl}`);
3628
3708
  if (outPath)
3629
3709
  console.log(`${DIM}Saved to ${outPath}${RESET}`);
3710
+ console.log(`${DIM}Place it with: vidfarm set-media <dir> --src "${mediaUrl}" --replace <layer_key> (keeps its transparency).${RESET}`);
3711
+ }
3712
+ }
3713
+ // Poll a greenscreen job through the same backend it was submitted to (local
3714
+ // in-process app or cloud). Mirrors pollPrimitiveJob's terminal conditions.
3715
+ async function pollGreenscreenJob(ctx, target, jobId) {
3716
+ if (target !== "local")
3717
+ return pollPrimitiveJob(ctx, jobId);
3718
+ const MAX_WAIT_MS = 8 * 60 * 1000;
3719
+ const startedAt = Date.now();
3720
+ let last = null;
3721
+ for (;;) {
3722
+ await sleep(5000);
3723
+ const res = await localApiRequest({ method: "GET", path: `/api/v1/user/me/jobs/${encodeURIComponent(jobId)}`, auth: ctx.auth, home: ctx.home });
3724
+ if (res.ok && res.json) {
3725
+ last = res.json;
3726
+ const status = String(last.status ?? "");
3727
+ if (!ctx.json)
3728
+ console.log(`${DIM} ${status}${typeof last.progress === "number" ? ` ${Math.round(last.progress * 100)}%` : ""}${RESET}`);
3729
+ if (status === "succeeded" || status === "failed" || status === "cancelled")
3730
+ return last;
3731
+ if (resolveJobMediaUrl(last))
3732
+ return last;
3733
+ }
3734
+ if (Date.now() - startedAt > MAX_WAIT_MS)
3735
+ return last ?? { status: "timeout" };
3630
3736
  }
3631
3737
  }
3632
3738
  // ── Speech: tts / stt ────────────────────────────────────────────────────────
@@ -4760,6 +4866,7 @@ async function runClipperCommand(argv) {
4760
4866
  tracer: { type: "string" },
4761
4867
  folder: { type: "string" },
4762
4868
  name: { type: "string" },
4869
+ notes: { type: "string" },
4763
4870
  "audio-only": { type: "boolean", default: false }
4764
4871
  }
4765
4872
  });
@@ -4791,6 +4898,8 @@ async function runClipperCommand(argv) {
4791
4898
  body.folder_path = String(parsed.values.folder);
4792
4899
  if (parsed.values.name)
4793
4900
  body.name = String(parsed.values.name);
4901
+ if (parsed.values.notes)
4902
+ body.user_notes = String(parsed.values.notes);
4794
4903
  if (audioOnly)
4795
4904
  body.audio_only = true;
4796
4905
  // Local-first: local URL clipping hands the source page URL straight to the
@@ -5176,6 +5285,88 @@ async function runDownloadCommand(argv) {
5176
5285
  console.log(`${GREEN}Downloaded ${formatBytes(bytes)} → ${dest}${RESET}`);
5177
5286
  }
5178
5287
  }
5288
+ async function runDownloadPostCommand(argv) {
5289
+ const parsed = parseArgs({
5290
+ args: argv,
5291
+ allowPositionals: true,
5292
+ options: { ...commonOptions(), quality: { type: "string", default: "best" }, "no-wait": { type: "boolean", default: false }, tracer: { type: "string" } }
5293
+ });
5294
+ const url = parsed.positionals[0];
5295
+ if (!url)
5296
+ throw new Error("download-post requires a source URL.");
5297
+ const quality = String(parsed.values.quality ?? "best");
5298
+ if (!["best", "hd", "full_hd"].includes(quality)) {
5299
+ throw new Error("download-post --quality must be one of: best, hd, full_hd.");
5300
+ }
5301
+ const ctx = commonContext(parsed.values);
5302
+ const tracer = String(parsed.values.tracer ?? `devcli-download-post-${Date.now().toString(36)}`);
5303
+ const submit = await apiRequest({
5304
+ method: "POST",
5305
+ host: ctx.host,
5306
+ path: "/api/v1/primitives/videos/download",
5307
+ auth: ctx.auth,
5308
+ body: { tracer, payload: { source_url: url, quality } }
5309
+ });
5310
+ assertApiOk(submit, "download-post");
5311
+ const jobId = submit.json?.job_id;
5312
+ if (!jobId || parsed.values["no-wait"]) {
5313
+ emitResult(submit, ctx.json);
5314
+ return;
5315
+ }
5316
+ if (!ctx.json)
5317
+ console.log(`${DIM}Downloading social post (${jobId})… polling every 5s.${RESET}`);
5318
+ const final = await pollPrimitiveJob(ctx, jobId);
5319
+ emitPrimitiveJobResult(ctx, final, "download-post");
5320
+ }
5321
+ async function runDownloadPostAudioCommand(argv) {
5322
+ const parsed = parseArgs({
5323
+ args: argv,
5324
+ allowPositionals: true,
5325
+ options: { ...commonOptions(), "no-wait": { type: "boolean", default: false }, tracer: { type: "string" } }
5326
+ });
5327
+ const url = parsed.positionals[0];
5328
+ if (!url)
5329
+ throw new Error("download-post-audio requires a source URL.");
5330
+ const ctx = commonContext(parsed.values);
5331
+ const tracer = String(parsed.values.tracer ?? `devcli-download-post-audio-${Date.now().toString(36)}`);
5332
+ const submit = await apiRequest({
5333
+ method: "POST",
5334
+ host: ctx.host,
5335
+ path: "/api/v1/primitives/audio/download",
5336
+ auth: ctx.auth,
5337
+ body: { tracer, payload: { source_url: url } }
5338
+ });
5339
+ assertApiOk(submit, "download-post-audio");
5340
+ const jobId = submit.json?.job_id;
5341
+ if (!jobId || parsed.values["no-wait"]) {
5342
+ emitResult(submit, ctx.json);
5343
+ return;
5344
+ }
5345
+ if (!ctx.json)
5346
+ console.log(`${DIM}Downloading social post audio (${jobId})… polling every 5s.${RESET}`);
5347
+ const final = await pollPrimitiveJob(ctx, jobId);
5348
+ emitPrimitiveJobResult(ctx, final, "download-post-audio");
5349
+ }
5350
+ function emitPrimitiveJobResult(ctx, job, action) {
5351
+ const mediaUrl = resolveJobMediaUrl(job);
5352
+ if (ctx.json) {
5353
+ printJson(job);
5354
+ return;
5355
+ }
5356
+ const status = String(job?.status ?? "");
5357
+ if (status === "failed" || status === "cancelled") {
5358
+ throw new Error(`${action} ${status}: ${String(job?.error ?? "unknown error")}`);
5359
+ }
5360
+ if (status && status !== "succeeded") {
5361
+ console.log(`${DIM}${action} status: ${status}.${RESET}`);
5362
+ }
5363
+ if (mediaUrl) {
5364
+ console.log(`${GREEN}Done${RESET} ${mediaUrl}`);
5365
+ }
5366
+ else {
5367
+ printJson(job);
5368
+ }
5369
+ }
5179
5370
  // Browse the user's My Files library — the persistent per-user asset store
5180
5371
  // (videos, images, audio, docs) organized into virtual folders. `files` lists
5181
5372
  // them (optionally scoped to a folder); `get-file` resolves one attachment id to
@@ -5245,6 +5436,21 @@ const DIRECTORY_HELP = `vidfarm directory — browse the unified file tree (/fil
5245
5436
  e.g. vidfarm directory rename /files/characters/zara zara-fox
5246
5437
  vidfarm directory rename /files/characters/zara/notes.md character_about.md --file-id att_123
5247
5438
 
5439
+ directory move <path> <to-folder> Move a file/folder into another folder → POST /api/v1/user/me/directory/move
5440
+ --file-id <id> Move a FILE into <to-folder>; omit to move the folder at <path> (nests under <to-folder>)
5441
+ --json Raw JSON response
5442
+ Same root only (/files·/temp·/raws·/approved). Metadata-only — S3 objects untouched.
5443
+ e.g. vidfarm directory move /raws/demos /raws/archive
5444
+ vidfarm directory move /files/inbox/logo.png /files/brand --file-id att_123
5445
+
5446
+ directory copy <path> [<to-folder>] Duplicate a file/folder (shared S3 object) → POST /api/v1/user/me/directory/copy
5447
+ --file-id <id> Copy a FILE; omit to copy the folder at <path>
5448
+ --as <name> Rename the copied file/folder
5449
+ --json Raw JSON response
5450
+ Same root only (/files·/temp·/raws). Omit <to-folder> to duplicate in place. Not for /approved or /projects.
5451
+ e.g. vidfarm directory copy /raws/demos /raws/archive
5452
+ vidfarm directory copy /files/brand/logo.png /files/inbox --file-id att_123 --as logo-copy.png
5453
+
5248
5454
  directory save-url <url> Save a durable media URL INTO My Files at a folder → POST /api/v1/user/me/attachments/from-url
5249
5455
  --folder <path> Destination folder under /files (e.g. inpaints, promos)
5250
5456
  --as <name> Name the saved file (else derived from the URL)
@@ -5253,8 +5459,7 @@ const DIRECTORY_HELP = `vidfarm directory — browse the unified file tree (/fil
5253
5459
  e.g. vidfarm directory save-url "https://…/inpaint-out.png" --folder inpaints --as hero.png
5254
5460
  (server fetches the URL — handles cross-origin S3; the CLI twin of the pop-panel "Save to Files")
5255
5461
 
5256
- Aliases: directory | dir · Auth: --api-key <key> or VIDFARM_API_KEY, --host <url>
5257
- Move a raw between folders with: vidfarm api PATCH /raws/<clipId> --data '{"folder_path":"…"}'`;
5462
+ Aliases: directory | dir (move|mv · copy|cp) · Auth: --api-key <key> or VIDFARM_API_KEY, --host <url>`;
5258
5463
  async function runDirectoryCommand(argv) {
5259
5464
  const sub = argv[0];
5260
5465
  const rest = argv.slice(1);
@@ -5266,8 +5471,13 @@ async function runDirectoryCommand(argv) {
5266
5471
  case "find":
5267
5472
  return runDirectorySearch(rest);
5268
5473
  case "rename":
5269
- case "mv":
5270
5474
  return runDirectoryRename(rest);
5475
+ case "move":
5476
+ case "mv":
5477
+ return runDirectoryMove(rest);
5478
+ case "copy":
5479
+ case "cp":
5480
+ return runDirectoryCopy(rest);
5271
5481
  case "save-url":
5272
5482
  case "from-url":
5273
5483
  case "import-url":
@@ -5385,6 +5595,66 @@ async function runDirectoryRename(argv) {
5385
5595
  const newPath = result.json?.path ?? "";
5386
5596
  console.log(`${GREEN}✓${RESET} Renamed to ${BOLD}${newName}${RESET} ${DIM}[${space}]${RESET}${newPath ? ` ${DIM}${newPath}${RESET}` : ""}`);
5387
5597
  }
5598
+ // Move a file or folder into another folder (same root) → POST /me/directory/move.
5599
+ async function runDirectoryMove(argv) {
5600
+ const parsed = parseArgs({
5601
+ args: argv,
5602
+ allowPositionals: true,
5603
+ options: { ...commonOptions(), "file-id": { type: "string" }, to: { type: "string" } }
5604
+ });
5605
+ const ctx = commonContext(parsed.values);
5606
+ const sourcePath = parsed.positionals[0];
5607
+ const to = parsed.values.to ?? parsed.positionals[1];
5608
+ if (!sourcePath || !to) {
5609
+ throw new Error('directory move requires a source and a destination folder: vidfarm directory move <path> <to-folder> [--file-id <id>]\n e.g. vidfarm directory move /raws/demos /raws/archive\n vidfarm directory move /files/inbox/logo.png /files/brand --file-id att_123');
5610
+ }
5611
+ const fileId = parsed.values["file-id"];
5612
+ const space = targetSpaces(ctx.target)[0];
5613
+ const result = await dispatch(ctx, { method: "POST", path: "/api/v1/user/me/directory/move", body: { path: sourcePath, to, ...(fileId ? { file_id: fileId } : {}) } }, space);
5614
+ assertApiOk(result, "directory move");
5615
+ if (ctx.json) {
5616
+ printJson(result.json ?? result.text);
5617
+ return;
5618
+ }
5619
+ const newPath = result.json?.path ?? to;
5620
+ console.log(`${GREEN}✓${RESET} Moved to ${BOLD}${newPath}${RESET} ${DIM}[${space}]${RESET}`);
5621
+ }
5622
+ // Duplicate a file or folder into another folder (same root) → POST /me/directory/copy.
5623
+ async function runDirectoryCopy(argv) {
5624
+ const parsed = parseArgs({
5625
+ args: argv,
5626
+ allowPositionals: true,
5627
+ options: { ...commonOptions(), "file-id": { type: "string" }, to: { type: "string" }, as: { type: "string" } }
5628
+ });
5629
+ const ctx = commonContext(parsed.values);
5630
+ const sourcePath = parsed.positionals[0];
5631
+ const to = parsed.values.to ?? parsed.positionals[1];
5632
+ if (!sourcePath) {
5633
+ throw new Error('directory copy requires a source: vidfarm directory copy <path> [<to-folder>] [--file-id <id>] [--as <new-name>]\n e.g. vidfarm directory copy /raws/demos /raws/archive\n vidfarm directory copy /files/brand/logo.png /files/inbox --file-id att_123 --as logo-copy.png');
5634
+ }
5635
+ const fileId = parsed.values["file-id"];
5636
+ const newName = parsed.values.as;
5637
+ const space = targetSpaces(ctx.target)[0];
5638
+ const result = await dispatch(ctx, {
5639
+ method: "POST",
5640
+ path: "/api/v1/user/me/directory/copy",
5641
+ body: {
5642
+ path: sourcePath,
5643
+ ...(to ? { to } : {}),
5644
+ ...(fileId ? { file_id: fileId } : {}),
5645
+ ...(newName ? { new_name: newName } : {})
5646
+ }
5647
+ }, space);
5648
+ assertApiOk(result, "directory copy");
5649
+ if (ctx.json) {
5650
+ printJson(result.json ?? result.text);
5651
+ return;
5652
+ }
5653
+ const newPath = result.json?.path ?? to ?? "";
5654
+ const copied = result.json?.copied;
5655
+ const suffix = typeof copied === "number" ? ` ${DIM}(${copied} file${copied === 1 ? "" : "s"})${RESET}` : "";
5656
+ console.log(`${GREEN}✓${RESET} Copied to ${BOLD}${newPath}${RESET}${suffix} ${DIM}[${space}]${RESET}`);
5657
+ }
5388
5658
  // Save a durable media URL (e.g. a finished job's output) INTO My Files at a
5389
5659
  // folder — the CLI twin of the pop-panel "Save to Files" picker. Server fetches
5390
5660
  // the URL (handles cross-origin S3), so no local download round-trip needed.
@@ -12,7 +12,7 @@ import { homedir } from "node:os";
12
12
  import { parseArgs } from "node:util";
13
13
  import { createInterface } from "node:readline/promises";
14
14
  import { createIdV7 } from "../lib/ids.js";
15
- import { buildEffectiveGuidance, ClipModelClient, DEFAULT_CLIP_ASPECT, DEFAULT_CLIP_DURATION_BAND, detectLocalAgent, detectScenes, effectiveHuntDurationSec, estimateScanCostFromScenes, fitScenesToDurationBand, formatCostEstimate, hasFfmpeg, LocalAgentClipClient, normalizeAspect, normalizeCropFocus, normalizeWindows, parseClipHuntPrompt, parseTimeRanges, probeVideo, resolveDurationBand, resolveTargetClipCount, scanVideo, searchClips } from "../services/clip-curation/index.js";
15
+ import { buildEffectiveGuidance, buildPrivateUserNotesGuidance, ClipModelClient, DEFAULT_CLIP_ASPECT, DEFAULT_CLIP_DURATION_BAND, detectLocalAgent, detectScenes, effectiveHuntDurationSec, estimateScanCostFromScenes, fitScenesToDurationBand, formatCostEstimate, hasFfmpeg, LocalAgentClipClient, normalizeAspect, normalizeCropFocus, normalizeWindows, parseClipHuntPrompt, parseTimeRanges, probeVideo, resolveDurationBand, resolveTargetClipCount, scanVideo, searchClips } from "../services/clip-curation/index.js";
16
16
  import { ClipStore } from "./clip-store.js";
17
17
  export const CLIPS_HELP = `vidfarm raws — build & search a local raws library (local ffmpeg + local agent by default)
18
18
 
@@ -27,6 +27,10 @@ export const CLIPS_HELP = `vidfarm raws — build & search a local raws library
27
27
  keeps/drops/merges scenes, and tags are biased to it.
28
28
  Inline hints are honored too: "between 12:30 and 15:45",
29
29
  "30 sec clips", "vertical", "no captions".
30
+ --notes "<private notes>" Store private owner notes as user_notes on derived raws
31
+ and feed them into decomposition/categorization. Explicit
32
+ private shot-kind hints like "content_type: demo" or
33
+ "category: talking_head" are honored and stay owner-only.
30
34
  --range "MM:SS-MM:SS" Only hunt inside this source range (repeatable / comma-separated)
31
35
  --duration <sec> Target raw length as a SOFT band (10→5-20s, 30→20-40s, …)
32
36
  --aspect <a> Crop raws to 9:16 | 16:9 | 4:3 | 1:1 (or vertical/horizontal/square)
@@ -156,10 +160,12 @@ async function runScan(argv) {
156
160
  "nvidia-key": { type: "string" },
157
161
  "embed-provider": { type: "string" },
158
162
  "embed-key": { type: "string" },
163
+ notes: { type: "string" },
159
164
  home: { type: "string" }
160
165
  }
161
166
  });
162
167
  // ── Hunt spec: explicit flags win; inline prompt hints fill the gaps ──────
168
+ const userNotes = values.notes?.trim() || undefined;
163
169
  const guidancePromptRaw = values.prompt?.trim() || undefined;
164
170
  const parsedPrompt = parseClipHuntPrompt(guidancePromptRaw);
165
171
  const rawWindows = values.range?.length
@@ -181,7 +187,10 @@ async function runScan(argv) {
181
187
  const cropFocus = normalizeCropFocus(values["crop-focus"]);
182
188
  const avoidText = values["allow-text"] ? false : (Boolean(values["no-text"]) || parsedPrompt.avoid_text || true);
183
189
  const maxClips = values["max-clips"] ? Math.max(1, Math.round(Number(values["max-clips"]))) : parsedPrompt.max_clips ?? undefined;
184
- const guidance = buildEffectiveGuidance({ contentPrompt: guidancePromptRaw, avoidText }) || undefined;
190
+ const guidance = buildEffectiveGuidance({
191
+ contentPrompt: [guidancePromptRaw, buildPrivateUserNotesGuidance(userNotes)].filter(Boolean).join("\n"),
192
+ avoidText
193
+ }) || undefined;
185
194
  // ── BACKUP path: run the hunt on the deployed pipeline (--cloud) ──────────
186
195
  if (values.cloud) {
187
196
  const apiProvider = values.provider && !["agent", "local", "local-agent"].includes(values.provider)
@@ -195,6 +204,7 @@ async function runScan(argv) {
195
204
  tracer: values.tracer,
196
205
  folder: values.folder,
197
206
  prompt: guidancePromptRaw,
207
+ userNotes,
198
208
  provider: apiProvider,
199
209
  windows: rawWindows,
200
210
  durationBand,
@@ -339,6 +349,7 @@ async function runScan(argv) {
339
349
  framesPerScene,
340
350
  includeAudio,
341
351
  guidancePrompt: guidance,
352
+ userNotes,
342
353
  refineSegmentation: !values["no-refine"],
343
354
  reencodeClips: true,
344
355
  audioOnly: Boolean(values["audio-only"]),
@@ -461,6 +472,7 @@ async function runScanCloud(input) {
461
472
  body: JSON.stringify({
462
473
  ...(input.sourceUrl ? { source_url: input.sourceUrl } : { temp_file_id: tempFileId, filename: fileName }),
463
474
  prompt: input.prompt ?? "",
475
+ ...(input.userNotes ? { user_notes: input.userNotes } : {}),
464
476
  ...(input.provider ? { provider: input.provider } : {}),
465
477
  ...(input.tracer ? { tracer: input.tracer } : {}),
466
478
  ...(input.folder ? { folder_path: input.folder } : {}),
@@ -312,8 +312,8 @@ function resolveEmbeddingConfig(provider, apiKey, explicit) {
312
312
  return { provider: "openai", apiKey };
313
313
  return null; // openrouter: no embeddings unless an explicit config is given
314
314
  }
315
- /** The document text embedded for a clip: description + transcript + salient tags. */
316
- export function buildClipEmbeddingText(tags, description) {
315
+ /** The document text embedded for a clip: description + transcript + salient tags + private owner notes. */
316
+ export function buildClipEmbeddingText(tags, description, userNotes) {
317
317
  const parts = [];
318
318
  if (description)
319
319
  parts.push(description);
@@ -332,6 +332,8 @@ export function buildClipEmbeddingText(tags, description) {
332
332
  ];
333
333
  if (tagBits.length)
334
334
  parts.push(tagBits.join(", "));
335
+ if (userNotes?.trim())
336
+ parts.push(`Owner notes: ${userNotes.trim()}`);
335
337
  return parts.join("\n").trim() || "video clip";
336
338
  }
337
339
  export function buildTaggingPrompt(transcript, version, hasAudio = false, guidancePrompt) {
@@ -6,6 +6,7 @@
6
6
  // NEVER GhostCut on the long-form source). Shared by devcli (local), the Hono
7
7
  // API, and the cloud scan Lambda so every surface parses and normalizes
8
8
  // identically. All helpers are pure/deterministic — no AI calls here.
9
+ import { ACTIVE_TAXONOMY_VERSION, getTaxonomy } from "./taxonomy.js";
9
10
  // ── Unspecified-prompt defaults ─────────────────────────────────────────────
10
11
  // When a user imports a source video without saying what they want, we bias
11
12
  // toward the short-form product default: a handful of DISTINCT, vertical,
@@ -293,6 +294,61 @@ export function buildEffectiveGuidance(input) {
293
294
  parts.push(AVOID_TEXT_GUIDANCE);
294
295
  return parts.join("\n");
295
296
  }
297
+ function canonicalizeContentTypeHint(value) {
298
+ const normalized = String(value ?? "")
299
+ .trim()
300
+ .toLowerCase()
301
+ .replace(/^#/, "")
302
+ .replace(/[\s-]+/g, "_");
303
+ if (!normalized)
304
+ return null;
305
+ if (normalized === "broll")
306
+ return "b_roll";
307
+ return normalized;
308
+ }
309
+ export function extractUserContentTypeHints(notes, version = ACTIVE_TAXONOMY_VERSION) {
310
+ const text = String(notes ?? "").trim();
311
+ if (!text)
312
+ return [];
313
+ const allowed = new Set(getTaxonomy(version).categories.content_type.map((value) => canonicalizeContentTypeHint(value)));
314
+ const found = new Set();
315
+ const capture = (value) => {
316
+ const normalized = canonicalizeContentTypeHint(value);
317
+ if (normalized && allowed.has(normalized))
318
+ found.add(normalized);
319
+ };
320
+ for (const match of text.matchAll(/\b(?:content[_\s-]*type|category|tag)s?\s*[:=]\s*([^\n.;]+)/gi)) {
321
+ const raw = String(match[1] ?? "");
322
+ for (const piece of raw.split(/[,/|]+|\band\b/gi))
323
+ capture(piece);
324
+ }
325
+ for (const match of text.matchAll(/\b#?(talking[\s_-]*head|b[\s_-]*roll|product[\s_-]*shot|screen[\s_-]*recording|demo|reaction|interview|establishing|lifestyle|text[\s_-]*graphic)\b/gi)) {
326
+ capture(String(match[1] ?? ""));
327
+ }
328
+ return [...found];
329
+ }
330
+ export function applyUserContentTypeHints(tags, notes, version = ACTIVE_TAXONOMY_VERSION) {
331
+ const hinted = extractUserContentTypeHints(notes, version);
332
+ if (!hinted.length)
333
+ return tags;
334
+ return {
335
+ ...tags,
336
+ content_type: [...new Set([...(tags.content_type ?? []), ...hinted])]
337
+ };
338
+ }
339
+ export function buildPrivateUserNotesGuidance(notes, version = ACTIVE_TAXONOMY_VERSION) {
340
+ const text = String(notes ?? "").trim();
341
+ if (!text)
342
+ return "";
343
+ const hinted = extractUserContentTypeHints(text, version);
344
+ const parts = [
345
+ `Private owner notes for this raw: "${text}". Use them to bias categorization and description, but stay truthful to the footage.`
346
+ ];
347
+ if (hinted.length) {
348
+ parts.push(`If the footage supports it, include these owner-requested private content_type tags: ${hinted.join(", ")}.`);
349
+ }
350
+ return parts.join("\n");
351
+ }
296
352
  /**
297
353
  * Deterministically extract structured hints from a free-form hunt prompt:
298
354
  * "please only hunt for clips between 12:30 and 15:45", "short 10 sec clips",
@@ -6,7 +6,7 @@ export { ACTIVE_TAXONOMY_VERSION, MULTI_VALUE_CATEGORIES, getTaxonomy, sanitizeT
6
6
  export { BUILTIN_PRESETS, findBuiltinPreset } from "./presets.js";
7
7
  export { ClipModelClient, ClipGeminiClient, ClipEmbeddingClient, EMBEDDING_DIM, EMBEDDING_MODEL, EMBEDDING_MODELS, TAG_MODEL_BY_TIER, buildClipEmbeddingText, buildQueryPrompt, buildTaggingPrompt, normalize } from "./gemini.js";
8
8
  export { LocalAgentClipClient, detectLocalAgent, extractJsonObject } from "./local-agent.js";
9
- export { AVOID_TEXT_GUIDANCE, DEFAULT_CLIP_ASPECT, DEFAULT_CLIP_DURATION_BAND, DEFAULT_CLIPS_PER_10_MIN, applyHuntSpecDefaults, buildEffectiveGuidance, capScenesEvenlyToCount, cropFilterFor, effectiveHuntDurationSec, fitScenesToDurationBand, normalizeAspect, normalizeCropFocus, normalizeHuntSpec, normalizeWindows, parseClipHuntPrompt, parseTimecode, parseTimeRanges, resolveDurationBand, resolveTargetClipCount } from "./hunt.js";
9
+ export { AVOID_TEXT_GUIDANCE, DEFAULT_CLIP_ASPECT, DEFAULT_CLIP_DURATION_BAND, DEFAULT_CLIPS_PER_10_MIN, applyHuntSpecDefaults, applyUserContentTypeHints, buildEffectiveGuidance, buildPrivateUserNotesGuidance, capScenesEvenlyToCount, cropFilterFor, effectiveHuntDurationSec, extractUserContentTypeHints, fitScenesToDurationBand, normalizeAspect, normalizeCropFocus, normalizeHuntSpec, normalizeWindows, parseClipHuntPrompt, parseTimecode, parseTimeRanges, resolveDurationBand, resolveTargetClipCount } from "./hunt.js";
10
10
  export { detectScenes, extractAudioClip, extractClip, extractKeyframes, extractSceneAudio, extractThumbnail, extractWaveformThumbnail, hasFfmpeg, probeVideo, resolveFfmpeg, resolveFfprobe } from "./ffmpeg.js";
11
11
  export { cosineSimilarity, matchesCriteria, rankHits, scoreClip, searchClips, structuredMatchRatio } from "./query.js";
12
12
  export { estimateScanCostFromDuration, estimateScanCostFromScenes, formatCostEstimate } from "./cost.js";
@@ -9,7 +9,7 @@ import { createIdV7 } from "../../lib/ids.js";
9
9
  import { ACTIVE_TAXONOMY_VERSION } from "./taxonomy.js";
10
10
  import { buildClipEmbeddingText } from "./gemini.js";
11
11
  import { detectScenes, extractAudioClip, extractClip, extractKeyframes, extractSceneAudio, extractThumbnail, extractWaveformThumbnail, probeVideo } from "./ffmpeg.js";
12
- import { capScenesEvenlyToCount, cropFilterFor, fitScenesToDurationBand, normalizeWindows } from "./hunt.js";
12
+ import { applyUserContentTypeHints, capScenesEvenlyToCount, cropFilterFor, fitScenesToDurationBand, normalizeWindows } from "./hunt.js";
13
13
  import { refineScenesWithGuidance } from "./refine.js";
14
14
  /** Turn one scene into a Clip. Throws only on unrecoverable extraction failure. */
15
15
  export async function processSceneToClip(input) {
@@ -46,6 +46,7 @@ export async function processSceneToClip(input) {
46
46
  scene,
47
47
  guidancePrompt: ctx.guidancePrompt
48
48
  });
49
+ const tags = applyUserContentTypeHints(tagResult.tags, ctx.userNotes, ctx.client.taxonomyVersion ?? ACTIVE_TAXONOMY_VERSION);
49
50
  // 4. Cut the clip + thumbnail. Audio-only hunts emit an .m4a (no video) with a
50
51
  // waveform PNG thumbnail; ordinary hunts emit an .mp4 cropped to the target
51
52
  // aspect when requested.
@@ -77,7 +78,7 @@ export async function processSceneToClip(input) {
77
78
  // 5. Embed the document text for semantic search (skipped when the client can't embed).
78
79
  let embedding;
79
80
  if (ctx.client.canEmbed) {
80
- const embedText = buildClipEmbeddingText(tagResult.tags, tagResult.description);
81
+ const embedText = buildClipEmbeddingText(tags, tagResult.description, ctx.userNotes);
81
82
  [embedding] = await ctx.client.embedDocuments([embedText]);
82
83
  }
83
84
  const clip = {
@@ -89,7 +90,7 @@ export async function processSceneToClip(input) {
89
90
  duration_sec: scene.duration_sec,
90
91
  file_path: ctx.clipStoredPath ? ctx.clipStoredPath(clipId, clipLocal) : clipLocal,
91
92
  thumbnail_path: ctx.thumbStoredPath ? ctx.thumbStoredPath(clipId, thumbLocal) : thumbLocal,
92
- tags: tagResult.tags,
93
+ tags,
93
94
  description: tagResult.description,
94
95
  embedding,
95
96
  taxonomy_version: ctx.client.taxonomyVersion ?? ACTIVE_TAXONOMY_VERSION,
@@ -98,6 +99,7 @@ export async function processSceneToClip(input) {
98
99
  embedding_model: ctx.client.embeddingModelId ?? undefined,
99
100
  aspect: !audioOnly && cropFilter && ctx.crop ? ctx.crop.aspect : undefined,
100
101
  media_kind: audioOnly ? "audio" : undefined,
102
+ user_notes: ctx.userNotes?.trim() || undefined,
101
103
  owner_id: input.ownerId,
102
104
  created_at: new Date().toISOString()
103
105
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.20.8",
3
+ "version": "0.20.10",
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": {