@mevdragon/vidfarm-devcli 0.20.8 → 0.20.9
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 +2 -0
- package/.agents/skills/vidfarm-director/references/primitives.md +43 -0
- package/SKILL.director.md +53 -2
- package/dist/src/cli.js +201 -3
- 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`.
|
|
@@ -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 |
|
|
@@ -52,6 +52,49 @@ curl -X POST "$VIDFARM_BASE/api/v1/primitives/images/create-overlay" \
|
|
|
52
52
|
-d '{"tracer": "vox-overlay", "payload": {"prompt": "a friendly cartoon lightbulb mascot, flat vector illustration", "aspect_ratio": "1:1"}}'
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
## Primitive: videos/download
|
|
56
|
+
|
|
57
|
+
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.
|
|
58
|
+
|
|
59
|
+
- `POST /api/v1/primitives/videos/download`
|
|
60
|
+
- Body: `{ "tracer": "...", "payload": { "source_url": "https://...", "quality"?: "best" | "hd" | "full_hd", "save_manifest"?: true }, "webhook_url"?: "..." }`
|
|
61
|
+
- Response: standard primitive job. Poll `GET /api/v1/primitives/jobs/:jobId` until `status: "succeeded"`.
|
|
62
|
+
- Video post: read `primary_file_url` / `video.file_url` / `videoUrl` for the durable MP4.
|
|
63
|
+
- Photo/carousel post: read `mediaKind: "slideshow"`, ordered `slideImageUrls[]`, optional `slideAudioUrl`, and `primary_file_url` (first slide image).
|
|
64
|
+
- Billing: RapidAPI pass-through wallet charge (`rapidapi_video_download`) plus the small mirror/extract pass (`video_download_lambda` when MP4 download is involved).
|
|
65
|
+
- 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"`.
|
|
66
|
+
- devcli wrapper: `vidfarm download-post <url> [--quality best|hd|full_hd]`
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
curl -X POST "$VIDFARM_BASE/api/v1/primitives/videos/download" \
|
|
72
|
+
-H "vidfarm-api-key: $VIDFARM_API_KEY" \
|
|
73
|
+
-H "content-type: application/json" \
|
|
74
|
+
-d '{"tracer": "demo-download-post", "payload": {"source_url": "https://www.tiktok.com/@example/video/123"}}'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Primitive: audio/download
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
- `POST /api/v1/primitives/audio/download`
|
|
82
|
+
- Body: `{ "tracer": "...", "payload": { "source_url": "https://...", "save_manifest"?: true }, "webhook_url"?: "..." }`
|
|
83
|
+
- Response: standard primitive job. Poll `GET /api/v1/primitives/jobs/:jobId` until `status: "succeeded"`, then read `primary_file_url` / `audio.file_url` / `audioUrl`.
|
|
84
|
+
- Behavior: prefers the provider's direct audio track when present; otherwise resolves the source video and extracts audio server-side.
|
|
85
|
+
- Billing: same RapidAPI wallet class as `videos/download`.
|
|
86
|
+
- 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.
|
|
87
|
+
- devcli wrapper: `vidfarm download-post-audio <url>`
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
curl -X POST "$VIDFARM_BASE/api/v1/primitives/audio/download" \
|
|
93
|
+
-H "vidfarm-api-key: $VIDFARM_API_KEY" \
|
|
94
|
+
-H "content-type: application/json" \
|
|
95
|
+
-d '{"tracer": "demo-download-audio", "payload": {"source_url": "https://www.youtube.com/watch?v=example"}}'
|
|
96
|
+
```
|
|
97
|
+
|
|
55
98
|
## Primitive: video_remove_captions
|
|
56
99
|
|
|
57
100
|
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
|
|
|
@@ -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 |
|
|
@@ -1184,6 +1192,49 @@ curl -X POST "$VIDFARM_BASE/api/v1/primitives/images/create-overlay" \
|
|
|
1184
1192
|
-d '{"tracer": "vox-overlay", "payload": {"prompt": "a friendly cartoon lightbulb mascot, flat vector illustration", "aspect_ratio": "1:1"}}'
|
|
1185
1193
|
```
|
|
1186
1194
|
|
|
1195
|
+
## Primitive: videos/download
|
|
1196
|
+
|
|
1197
|
+
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.
|
|
1198
|
+
|
|
1199
|
+
- `POST /api/v1/primitives/videos/download`
|
|
1200
|
+
- Body: `{ "tracer": "...", "payload": { "source_url": "https://...", "quality"?: "best" | "hd" | "full_hd", "save_manifest"?: true }, "webhook_url"?: "..." }`
|
|
1201
|
+
- Response: standard primitive job. Poll `GET /api/v1/primitives/jobs/:jobId` until `status: "succeeded"`.
|
|
1202
|
+
- Video post: read `primary_file_url` / `video.file_url` / `videoUrl` for the durable MP4.
|
|
1203
|
+
- Photo/carousel post: read `mediaKind: "slideshow"`, ordered `slideImageUrls[]`, optional `slideAudioUrl`, and `primary_file_url` (first slide image).
|
|
1204
|
+
- Billing: RapidAPI pass-through wallet charge (`rapidapi_video_download`) plus the small mirror/extract pass (`video_download_lambda` when MP4 download is involved).
|
|
1205
|
+
- 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"`.
|
|
1206
|
+
- devcli wrapper: `vidfarm download-post <url> [--quality best|hd|full_hd]`
|
|
1207
|
+
|
|
1208
|
+
Example:
|
|
1209
|
+
|
|
1210
|
+
```bash
|
|
1211
|
+
curl -X POST "$VIDFARM_BASE/api/v1/primitives/videos/download" \
|
|
1212
|
+
-H "vidfarm-api-key: $VIDFARM_API_KEY" \
|
|
1213
|
+
-H "content-type: application/json" \
|
|
1214
|
+
-d '{"tracer": "demo-download-post", "payload": {"source_url": "https://www.tiktok.com/@example/video/123"}}'
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
## Primitive: audio/download
|
|
1218
|
+
|
|
1219
|
+
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.
|
|
1220
|
+
|
|
1221
|
+
- `POST /api/v1/primitives/audio/download`
|
|
1222
|
+
- Body: `{ "tracer": "...", "payload": { "source_url": "https://...", "save_manifest"?: true }, "webhook_url"?: "..." }`
|
|
1223
|
+
- Response: standard primitive job. Poll `GET /api/v1/primitives/jobs/:jobId` until `status: "succeeded"`, then read `primary_file_url` / `audio.file_url` / `audioUrl`.
|
|
1224
|
+
- Behavior: prefers the provider's direct audio track when present; otherwise resolves the source video and extracts audio server-side.
|
|
1225
|
+
- Billing: same RapidAPI wallet class as `videos/download`.
|
|
1226
|
+
- 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.
|
|
1227
|
+
- devcli wrapper: `vidfarm download-post-audio <url>`
|
|
1228
|
+
|
|
1229
|
+
Example:
|
|
1230
|
+
|
|
1231
|
+
```bash
|
|
1232
|
+
curl -X POST "$VIDFARM_BASE/api/v1/primitives/audio/download" \
|
|
1233
|
+
-H "vidfarm-api-key: $VIDFARM_API_KEY" \
|
|
1234
|
+
-H "content-type: application/json" \
|
|
1235
|
+
-d '{"tracer": "demo-download-audio", "payload": {"source_url": "https://www.youtube.com/watch?v=example"}}'
|
|
1236
|
+
```
|
|
1237
|
+
|
|
1187
1238
|
## Primitive: video_remove_captions
|
|
1188
1239
|
|
|
1189
1240
|
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
|
@@ -276,6 +276,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
276
276
|
--clip [--prompt "..."] [--provider gemini|openai|openrouter|nvidia]
|
|
277
277
|
(raw) AI-clip the video into matching moments instead of
|
|
278
278
|
importing the whole thing (BYOK; nvidia = lite clipping key)
|
|
279
|
+
Note: raw mode needs a VIDEO URL. For a LOCAL FILE from
|
|
280
|
+
your computer, use 'vidfarm clipper ./file.mp4 --cloud'
|
|
281
|
+
to upload it and import the whole video into /raws, or
|
|
282
|
+
add --start/--end to save one exact subrange.
|
|
279
283
|
|
|
280
284
|
Raws (the third library — mine long-form video into a reusable raws store):
|
|
281
285
|
raws scan <video-path> Hunt short clips out of a long video. LOCAL-FIRST: local ffmpeg +
|
|
@@ -285,6 +289,9 @@ Raws (the third library — mine long-form video into a reusable raws store):
|
|
|
285
289
|
--duration <sec> Target clip length as a SOFT band (10→5-20s, 30→20-40s)
|
|
286
290
|
--aspect 9:16|16:9|4:3|1:1 Crop clips (also: vertical/horizontal/square) [--crop-focus <f>]
|
|
287
291
|
--no-text Prefer scenes WITHOUT captions/on-screen text (selection only)
|
|
292
|
+
--notes "<private notes>" Store private owner notes as user_notes on derived raws and
|
|
293
|
+
bias decomposition/categorization with them. Explicit private
|
|
294
|
+
shot-kind hints like "content_type: demo" stay owner-only.
|
|
288
295
|
--cloud [--url <url>] BACKUP: run on the deployed pipeline → POST /raws/scan (+ poll)
|
|
289
296
|
(uploads to your temp folder — 30-day TTL — bills AWS compute only)
|
|
290
297
|
--folder <name> (with --cloud) Save the mined clips into /raws/<name>
|
|
@@ -308,9 +315,18 @@ Raws (the third library — mine long-form video into a reusable raws store):
|
|
|
308
315
|
--tracer <name> Group this session's clips (also the default folder)
|
|
309
316
|
--folder <name> Save the clip into /raws/<name>
|
|
310
317
|
--name <text> Human name for the clip
|
|
318
|
+
--notes "<private notes>" Store private owner notes as user_notes on the imported raw.
|
|
319
|
+
The notes also bias categorization/decomposition, and an
|
|
320
|
+
explicit private shot-kind hint like 'content_type: demo'
|
|
321
|
+
or 'category: talking_head' is honored only for that owner.
|
|
311
322
|
--audio-only Save just the audio of the subrange as an .m4a raw
|
|
312
323
|
(needs --start/--end; e.g. lift a copyright-free track)
|
|
313
324
|
(multi-clip: re-run with a new range + same --tracer)
|
|
325
|
+
Direct local-file upload to CLOUD raws is supported:
|
|
326
|
+
'vidfarm clipper ./source.mp4 --cloud --folder campaign'
|
|
327
|
+
imports the whole local file as one raw; add
|
|
328
|
+
'--start ... --end ...' to upload the local file and
|
|
329
|
+
save only that exact clipped range.
|
|
314
330
|
|
|
315
331
|
Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit backup:
|
|
316
332
|
tts "<text>" Text → narration audio file. LOCAL: your OPENAI/GEMINI/
|
|
@@ -450,6 +466,8 @@ Approve & schedule (publish a finished MP4 as a shareable post):
|
|
|
450
466
|
--media <url|file> Extra media (repeatable; local files auto-upload to temp/)
|
|
451
467
|
--folder <path> Temp-store folder for uploaded local files (default: temp)
|
|
452
468
|
--caption <text> Post caption (required)
|
|
469
|
+
Direct local-file upload is supported here:
|
|
470
|
+
'vidfarm approve --video ./final.mp4 --caption "..."'
|
|
453
471
|
posts List your approved posts → GET /api/v1/approved/posts
|
|
454
472
|
post <postId> Read one approved post (prints share URL) → GET /api/v1/approved/posts/:postId
|
|
455
473
|
schedule <postId> Schedule an approved post to a channel → POST /api/v1/approved/posts/:postId/schedules
|
|
@@ -482,6 +500,11 @@ Files (multi-step flows the devcli handles for you):
|
|
|
482
500
|
e.g. vidfarm put-file About.md --folder acme-skincare
|
|
483
501
|
echo "..." | vidfarm put-file --stdin --as About.md --folder acme
|
|
484
502
|
download <url> [dest] Stream any Vidfarm/media URL to disk
|
|
503
|
+
download-post <url> Download a social/media post into Vidfarm media → POST /api/v1/primitives/videos/download
|
|
504
|
+
--quality <q> best | hd | full_hd (default: best)
|
|
505
|
+
--no-wait Return the queued job immediately instead of polling
|
|
506
|
+
download-post-audio <url> Download a social/media post's audio into Vidfarm → POST /api/v1/primitives/audio/download
|
|
507
|
+
--no-wait Return the queued job immediately instead of polling
|
|
485
508
|
files List My Files assets + folders → GET /api/v1/user/me/attachments
|
|
486
509
|
--folder <path> Only files under this folder (e.g. a product/offer)
|
|
487
510
|
--search <query> Find files by MEANING (keyword + vector over → POST /api/v1/user/me/attachments/search
|
|
@@ -500,6 +523,10 @@ Files (multi-step flows the devcli handles for you):
|
|
|
500
523
|
--content-type <a,b> Filter the /raws branch by shot-KIND tag (talking_head, b_roll, …)
|
|
501
524
|
directory rename <path> <new-name> Rename a /files or /temp file/folder → POST /api/v1/user/me/directory/rename
|
|
502
525
|
--file-id <id> Rename a FILE (display name); omit to rename the folder at <path>
|
|
526
|
+
directory move <path> <to-folder> Move a file/folder into another folder → POST /api/v1/user/me/directory/move
|
|
527
|
+
--file-id <id> Move a FILE; omit to move the folder (same root, e.g. /raws/demos → /raws/archive)
|
|
528
|
+
directory copy <path> [<to-folder>] Duplicate a file/folder (shared S3 obj) → POST /api/v1/user/me/directory/copy
|
|
529
|
+
--file-id <id> --as <name> Copy a FILE; omit --file-id to copy the folder; --as renames the copy
|
|
503
530
|
directory save-url <url> Save a durable media URL INTO My Files at a folder → POST /api/v1/user/me/attachments/from-url
|
|
504
531
|
--folder <path> Destination folder under /files (e.g. inpaints, promos)
|
|
505
532
|
--as <name> Name the saved file · --notes <text> vector-embedded notes
|
|
@@ -740,6 +767,12 @@ async function main() {
|
|
|
740
767
|
case "download":
|
|
741
768
|
await runDownloadCommand(rest);
|
|
742
769
|
return;
|
|
770
|
+
case "download-post":
|
|
771
|
+
await runDownloadPostCommand(rest);
|
|
772
|
+
return;
|
|
773
|
+
case "download-post-audio":
|
|
774
|
+
await runDownloadPostAudioCommand(rest);
|
|
775
|
+
return;
|
|
743
776
|
case "files":
|
|
744
777
|
await runFilesCommand(rest);
|
|
745
778
|
return;
|
|
@@ -2849,6 +2882,7 @@ function resolveJobMediaUrl(job) {
|
|
|
2849
2882
|
const output = (result.output && typeof result.output === "object") ? result.output : result;
|
|
2850
2883
|
const direct = readStr(output.primary_file_url)
|
|
2851
2884
|
?? readStr(output.video?.file_url)
|
|
2885
|
+
?? readStr(output.audio?.file_url)
|
|
2852
2886
|
?? readStr(output.image?.file_url)
|
|
2853
2887
|
?? readStr(output.render?.output_url);
|
|
2854
2888
|
if (direct)
|
|
@@ -4760,6 +4794,7 @@ async function runClipperCommand(argv) {
|
|
|
4760
4794
|
tracer: { type: "string" },
|
|
4761
4795
|
folder: { type: "string" },
|
|
4762
4796
|
name: { type: "string" },
|
|
4797
|
+
notes: { type: "string" },
|
|
4763
4798
|
"audio-only": { type: "boolean", default: false }
|
|
4764
4799
|
}
|
|
4765
4800
|
});
|
|
@@ -4791,6 +4826,8 @@ async function runClipperCommand(argv) {
|
|
|
4791
4826
|
body.folder_path = String(parsed.values.folder);
|
|
4792
4827
|
if (parsed.values.name)
|
|
4793
4828
|
body.name = String(parsed.values.name);
|
|
4829
|
+
if (parsed.values.notes)
|
|
4830
|
+
body.user_notes = String(parsed.values.notes);
|
|
4794
4831
|
if (audioOnly)
|
|
4795
4832
|
body.audio_only = true;
|
|
4796
4833
|
// Local-first: local URL clipping hands the source page URL straight to the
|
|
@@ -5176,6 +5213,88 @@ async function runDownloadCommand(argv) {
|
|
|
5176
5213
|
console.log(`${GREEN}Downloaded ${formatBytes(bytes)} → ${dest}${RESET}`);
|
|
5177
5214
|
}
|
|
5178
5215
|
}
|
|
5216
|
+
async function runDownloadPostCommand(argv) {
|
|
5217
|
+
const parsed = parseArgs({
|
|
5218
|
+
args: argv,
|
|
5219
|
+
allowPositionals: true,
|
|
5220
|
+
options: { ...commonOptions(), quality: { type: "string", default: "best" }, "no-wait": { type: "boolean", default: false }, tracer: { type: "string" } }
|
|
5221
|
+
});
|
|
5222
|
+
const url = parsed.positionals[0];
|
|
5223
|
+
if (!url)
|
|
5224
|
+
throw new Error("download-post requires a source URL.");
|
|
5225
|
+
const quality = String(parsed.values.quality ?? "best");
|
|
5226
|
+
if (!["best", "hd", "full_hd"].includes(quality)) {
|
|
5227
|
+
throw new Error("download-post --quality must be one of: best, hd, full_hd.");
|
|
5228
|
+
}
|
|
5229
|
+
const ctx = commonContext(parsed.values);
|
|
5230
|
+
const tracer = String(parsed.values.tracer ?? `devcli-download-post-${Date.now().toString(36)}`);
|
|
5231
|
+
const submit = await apiRequest({
|
|
5232
|
+
method: "POST",
|
|
5233
|
+
host: ctx.host,
|
|
5234
|
+
path: "/api/v1/primitives/videos/download",
|
|
5235
|
+
auth: ctx.auth,
|
|
5236
|
+
body: { tracer, payload: { source_url: url, quality } }
|
|
5237
|
+
});
|
|
5238
|
+
assertApiOk(submit, "download-post");
|
|
5239
|
+
const jobId = submit.json?.job_id;
|
|
5240
|
+
if (!jobId || parsed.values["no-wait"]) {
|
|
5241
|
+
emitResult(submit, ctx.json);
|
|
5242
|
+
return;
|
|
5243
|
+
}
|
|
5244
|
+
if (!ctx.json)
|
|
5245
|
+
console.log(`${DIM}Downloading social post (${jobId})… polling every 5s.${RESET}`);
|
|
5246
|
+
const final = await pollPrimitiveJob(ctx, jobId);
|
|
5247
|
+
emitPrimitiveJobResult(ctx, final, "download-post");
|
|
5248
|
+
}
|
|
5249
|
+
async function runDownloadPostAudioCommand(argv) {
|
|
5250
|
+
const parsed = parseArgs({
|
|
5251
|
+
args: argv,
|
|
5252
|
+
allowPositionals: true,
|
|
5253
|
+
options: { ...commonOptions(), "no-wait": { type: "boolean", default: false }, tracer: { type: "string" } }
|
|
5254
|
+
});
|
|
5255
|
+
const url = parsed.positionals[0];
|
|
5256
|
+
if (!url)
|
|
5257
|
+
throw new Error("download-post-audio requires a source URL.");
|
|
5258
|
+
const ctx = commonContext(parsed.values);
|
|
5259
|
+
const tracer = String(parsed.values.tracer ?? `devcli-download-post-audio-${Date.now().toString(36)}`);
|
|
5260
|
+
const submit = await apiRequest({
|
|
5261
|
+
method: "POST",
|
|
5262
|
+
host: ctx.host,
|
|
5263
|
+
path: "/api/v1/primitives/audio/download",
|
|
5264
|
+
auth: ctx.auth,
|
|
5265
|
+
body: { tracer, payload: { source_url: url } }
|
|
5266
|
+
});
|
|
5267
|
+
assertApiOk(submit, "download-post-audio");
|
|
5268
|
+
const jobId = submit.json?.job_id;
|
|
5269
|
+
if (!jobId || parsed.values["no-wait"]) {
|
|
5270
|
+
emitResult(submit, ctx.json);
|
|
5271
|
+
return;
|
|
5272
|
+
}
|
|
5273
|
+
if (!ctx.json)
|
|
5274
|
+
console.log(`${DIM}Downloading social post audio (${jobId})… polling every 5s.${RESET}`);
|
|
5275
|
+
const final = await pollPrimitiveJob(ctx, jobId);
|
|
5276
|
+
emitPrimitiveJobResult(ctx, final, "download-post-audio");
|
|
5277
|
+
}
|
|
5278
|
+
function emitPrimitiveJobResult(ctx, job, action) {
|
|
5279
|
+
const mediaUrl = resolveJobMediaUrl(job);
|
|
5280
|
+
if (ctx.json) {
|
|
5281
|
+
printJson(job);
|
|
5282
|
+
return;
|
|
5283
|
+
}
|
|
5284
|
+
const status = String(job?.status ?? "");
|
|
5285
|
+
if (status === "failed" || status === "cancelled") {
|
|
5286
|
+
throw new Error(`${action} ${status}: ${String(job?.error ?? "unknown error")}`);
|
|
5287
|
+
}
|
|
5288
|
+
if (status && status !== "succeeded") {
|
|
5289
|
+
console.log(`${DIM}${action} status: ${status}.${RESET}`);
|
|
5290
|
+
}
|
|
5291
|
+
if (mediaUrl) {
|
|
5292
|
+
console.log(`${GREEN}Done${RESET} ${mediaUrl}`);
|
|
5293
|
+
}
|
|
5294
|
+
else {
|
|
5295
|
+
printJson(job);
|
|
5296
|
+
}
|
|
5297
|
+
}
|
|
5179
5298
|
// Browse the user's My Files library — the persistent per-user asset store
|
|
5180
5299
|
// (videos, images, audio, docs) organized into virtual folders. `files` lists
|
|
5181
5300
|
// them (optionally scoped to a folder); `get-file` resolves one attachment id to
|
|
@@ -5245,6 +5364,21 @@ const DIRECTORY_HELP = `vidfarm directory — browse the unified file tree (/fil
|
|
|
5245
5364
|
e.g. vidfarm directory rename /files/characters/zara zara-fox
|
|
5246
5365
|
vidfarm directory rename /files/characters/zara/notes.md character_about.md --file-id att_123
|
|
5247
5366
|
|
|
5367
|
+
directory move <path> <to-folder> Move a file/folder into another folder → POST /api/v1/user/me/directory/move
|
|
5368
|
+
--file-id <id> Move a FILE into <to-folder>; omit to move the folder at <path> (nests under <to-folder>)
|
|
5369
|
+
--json Raw JSON response
|
|
5370
|
+
Same root only (/files·/temp·/raws·/approved). Metadata-only — S3 objects untouched.
|
|
5371
|
+
e.g. vidfarm directory move /raws/demos /raws/archive
|
|
5372
|
+
vidfarm directory move /files/inbox/logo.png /files/brand --file-id att_123
|
|
5373
|
+
|
|
5374
|
+
directory copy <path> [<to-folder>] Duplicate a file/folder (shared S3 object) → POST /api/v1/user/me/directory/copy
|
|
5375
|
+
--file-id <id> Copy a FILE; omit to copy the folder at <path>
|
|
5376
|
+
--as <name> Rename the copied file/folder
|
|
5377
|
+
--json Raw JSON response
|
|
5378
|
+
Same root only (/files·/temp·/raws). Omit <to-folder> to duplicate in place. Not for /approved or /projects.
|
|
5379
|
+
e.g. vidfarm directory copy /raws/demos /raws/archive
|
|
5380
|
+
vidfarm directory copy /files/brand/logo.png /files/inbox --file-id att_123 --as logo-copy.png
|
|
5381
|
+
|
|
5248
5382
|
directory save-url <url> Save a durable media URL INTO My Files at a folder → POST /api/v1/user/me/attachments/from-url
|
|
5249
5383
|
--folder <path> Destination folder under /files (e.g. inpaints, promos)
|
|
5250
5384
|
--as <name> Name the saved file (else derived from the URL)
|
|
@@ -5253,8 +5387,7 @@ const DIRECTORY_HELP = `vidfarm directory — browse the unified file tree (/fil
|
|
|
5253
5387
|
e.g. vidfarm directory save-url "https://…/inpaint-out.png" --folder inpaints --as hero.png
|
|
5254
5388
|
(server fetches the URL — handles cross-origin S3; the CLI twin of the pop-panel "Save to Files")
|
|
5255
5389
|
|
|
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":"…"}'`;
|
|
5390
|
+
Aliases: directory | dir (move|mv · copy|cp) · Auth: --api-key <key> or VIDFARM_API_KEY, --host <url>`;
|
|
5258
5391
|
async function runDirectoryCommand(argv) {
|
|
5259
5392
|
const sub = argv[0];
|
|
5260
5393
|
const rest = argv.slice(1);
|
|
@@ -5266,8 +5399,13 @@ async function runDirectoryCommand(argv) {
|
|
|
5266
5399
|
case "find":
|
|
5267
5400
|
return runDirectorySearch(rest);
|
|
5268
5401
|
case "rename":
|
|
5269
|
-
case "mv":
|
|
5270
5402
|
return runDirectoryRename(rest);
|
|
5403
|
+
case "move":
|
|
5404
|
+
case "mv":
|
|
5405
|
+
return runDirectoryMove(rest);
|
|
5406
|
+
case "copy":
|
|
5407
|
+
case "cp":
|
|
5408
|
+
return runDirectoryCopy(rest);
|
|
5271
5409
|
case "save-url":
|
|
5272
5410
|
case "from-url":
|
|
5273
5411
|
case "import-url":
|
|
@@ -5385,6 +5523,66 @@ async function runDirectoryRename(argv) {
|
|
|
5385
5523
|
const newPath = result.json?.path ?? "";
|
|
5386
5524
|
console.log(`${GREEN}✓${RESET} Renamed to ${BOLD}${newName}${RESET} ${DIM}[${space}]${RESET}${newPath ? ` ${DIM}${newPath}${RESET}` : ""}`);
|
|
5387
5525
|
}
|
|
5526
|
+
// Move a file or folder into another folder (same root) → POST /me/directory/move.
|
|
5527
|
+
async function runDirectoryMove(argv) {
|
|
5528
|
+
const parsed = parseArgs({
|
|
5529
|
+
args: argv,
|
|
5530
|
+
allowPositionals: true,
|
|
5531
|
+
options: { ...commonOptions(), "file-id": { type: "string" }, to: { type: "string" } }
|
|
5532
|
+
});
|
|
5533
|
+
const ctx = commonContext(parsed.values);
|
|
5534
|
+
const sourcePath = parsed.positionals[0];
|
|
5535
|
+
const to = parsed.values.to ?? parsed.positionals[1];
|
|
5536
|
+
if (!sourcePath || !to) {
|
|
5537
|
+
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');
|
|
5538
|
+
}
|
|
5539
|
+
const fileId = parsed.values["file-id"];
|
|
5540
|
+
const space = targetSpaces(ctx.target)[0];
|
|
5541
|
+
const result = await dispatch(ctx, { method: "POST", path: "/api/v1/user/me/directory/move", body: { path: sourcePath, to, ...(fileId ? { file_id: fileId } : {}) } }, space);
|
|
5542
|
+
assertApiOk(result, "directory move");
|
|
5543
|
+
if (ctx.json) {
|
|
5544
|
+
printJson(result.json ?? result.text);
|
|
5545
|
+
return;
|
|
5546
|
+
}
|
|
5547
|
+
const newPath = result.json?.path ?? to;
|
|
5548
|
+
console.log(`${GREEN}✓${RESET} Moved to ${BOLD}${newPath}${RESET} ${DIM}[${space}]${RESET}`);
|
|
5549
|
+
}
|
|
5550
|
+
// Duplicate a file or folder into another folder (same root) → POST /me/directory/copy.
|
|
5551
|
+
async function runDirectoryCopy(argv) {
|
|
5552
|
+
const parsed = parseArgs({
|
|
5553
|
+
args: argv,
|
|
5554
|
+
allowPositionals: true,
|
|
5555
|
+
options: { ...commonOptions(), "file-id": { type: "string" }, to: { type: "string" }, as: { type: "string" } }
|
|
5556
|
+
});
|
|
5557
|
+
const ctx = commonContext(parsed.values);
|
|
5558
|
+
const sourcePath = parsed.positionals[0];
|
|
5559
|
+
const to = parsed.values.to ?? parsed.positionals[1];
|
|
5560
|
+
if (!sourcePath) {
|
|
5561
|
+
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');
|
|
5562
|
+
}
|
|
5563
|
+
const fileId = parsed.values["file-id"];
|
|
5564
|
+
const newName = parsed.values.as;
|
|
5565
|
+
const space = targetSpaces(ctx.target)[0];
|
|
5566
|
+
const result = await dispatch(ctx, {
|
|
5567
|
+
method: "POST",
|
|
5568
|
+
path: "/api/v1/user/me/directory/copy",
|
|
5569
|
+
body: {
|
|
5570
|
+
path: sourcePath,
|
|
5571
|
+
...(to ? { to } : {}),
|
|
5572
|
+
...(fileId ? { file_id: fileId } : {}),
|
|
5573
|
+
...(newName ? { new_name: newName } : {})
|
|
5574
|
+
}
|
|
5575
|
+
}, space);
|
|
5576
|
+
assertApiOk(result, "directory copy");
|
|
5577
|
+
if (ctx.json) {
|
|
5578
|
+
printJson(result.json ?? result.text);
|
|
5579
|
+
return;
|
|
5580
|
+
}
|
|
5581
|
+
const newPath = result.json?.path ?? to ?? "";
|
|
5582
|
+
const copied = result.json?.copied;
|
|
5583
|
+
const suffix = typeof copied === "number" ? ` ${DIM}(${copied} file${copied === 1 ? "" : "s"})${RESET}` : "";
|
|
5584
|
+
console.log(`${GREEN}✓${RESET} Copied to ${BOLD}${newPath}${RESET}${suffix} ${DIM}[${space}]${RESET}`);
|
|
5585
|
+
}
|
|
5388
5586
|
// Save a durable media URL (e.g. a finished job's output) INTO My Files at a
|
|
5389
5587
|
// folder — the CLI twin of the pop-panel "Save to Files" picker. Server fetches
|
|
5390
5588
|
// 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.9",
|
|
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": {
|