@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.
- package/.agents/skills/vidfarm-director/references/assets-and-sourcing.md +8 -2
- package/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +3 -1
- package/.agents/skills/vidfarm-director/references/primitives.md +52 -10
- package/SKILL.director.md +63 -13
- package/dist/src/cli.js +294 -24
- package/dist/src/devcli/clips.js +14 -2
- package/dist/src/services/clip-curation/gemini.js +4 -2
- package/dist/src/services/clip-curation/hunt.js +56 -0
- package/dist/src/services/clip-curation/index.js +1 -1
- package/dist/src/services/clip-curation/scan.js +5 -3
- package/package.json +1 -1
|
@@ -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 /
|
|
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`
|
|
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-
|
|
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:
|
|
20
|
+
## Primitive: remove-greenscreen (image **or** video chroma-key)
|
|
21
21
|
|
|
22
|
-
|
|
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/
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
- `
|
|
28
|
-
- `
|
|
29
|
-
|
|
30
|
-
-
|
|
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` (0–1) 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 /
|
|
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`
|
|
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-
|
|
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:
|
|
1160
|
+
## Primitive: remove-greenscreen (image **or** video chroma-key)
|
|
1153
1161
|
|
|
1154
|
-
|
|
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/
|
|
1157
|
-
-
|
|
1158
|
-
|
|
1159
|
-
- `
|
|
1160
|
-
- `
|
|
1161
|
-
|
|
1162
|
-
-
|
|
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` (0–1) 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-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
--
|
|
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
|
|
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
|
|
3555
|
-
//
|
|
3556
|
-
//
|
|
3557
|
-
|
|
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-
|
|
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
|
|
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-
|
|
3592
|
-
const
|
|
3593
|
-
|
|
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
|
|
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
|
|
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.
|
package/dist/src/devcli/clips.js
CHANGED
|
@@ -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({
|
|
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(
|
|
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
|
|
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.
|
|
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": {
|