@mevdragon/vidfarm-devcli 0.7.1 → 0.9.0

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.
Files changed (30) hide show
  1. package/README.md +8 -0
  2. package/demo/dist/app.js +81 -53
  3. package/dist/src/app.js +1010 -8
  4. package/dist/src/cli.js +390 -53
  5. package/dist/src/config.js +6 -0
  6. package/dist/src/devcli/clip-store.js +335 -0
  7. package/dist/src/devcli/clips.js +803 -0
  8. package/dist/src/devcli/telemetry.js +236 -0
  9. package/dist/src/frontend/homepage-view.js +5 -0
  10. package/dist/src/frontend/template-editor-chat.js +212 -0
  11. package/dist/src/services/clip-curation/cost.js +112 -0
  12. package/dist/src/services/clip-curation/ffmpeg.js +258 -0
  13. package/dist/src/services/clip-curation/gemini.js +342 -0
  14. package/dist/src/services/clip-curation/index.js +15 -0
  15. package/dist/src/services/clip-curation/presets.js +20 -0
  16. package/dist/src/services/clip-curation/presets.v1.json +59 -0
  17. package/dist/src/services/clip-curation/query.js +163 -0
  18. package/dist/src/services/clip-curation/refine.js +136 -0
  19. package/dist/src/services/clip-curation/scan.js +137 -0
  20. package/dist/src/services/clip-curation/taxonomy.js +71 -0
  21. package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
  22. package/dist/src/services/clip-curation/types.js +7 -0
  23. package/dist/src/services/clip-records.js +209 -0
  24. package/dist/src/services/clip-search.js +47 -0
  25. package/dist/src/services/clip-vectors.js +125 -0
  26. package/dist/src/services/hyperframes.js +369 -9
  27. package/dist/src/services/scene-annotations.js +32 -0
  28. package/package.json +5 -1
  29. package/public/assets/homepage-client-app.js +17 -17
  30. package/public/assets/page-runtime-client-app.js +31 -31
@@ -0,0 +1,803 @@
1
+ // devcli `vidfarm clips …` — the local clip-curation surface. Uses local
2
+ // ffmpeg (near-zero compute) + BYOK Gemini, persisting to a SQLite clip library
3
+ // (~/.vidfarm). Every scan shows an estimated cost first; scans over $1 require
4
+ // --yes or an interactive confirm. Mirrors the cloud REST surface 1:1.
5
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { parseArgs } from "node:util";
9
+ import { createInterface } from "node:readline/promises";
10
+ import { createIdV7 } from "../lib/ids.js";
11
+ import { ClipModelClient, estimateScanCostFromScenes, formatCostEstimate, detectScenes, hasFfmpeg, probeVideo, scanVideo, searchClips } from "../services/clip-curation/index.js";
12
+ import { ClipStore } from "./clip-store.js";
13
+ export const CLIPS_HELP = `vidfarm clips — build & search a local clip library (local ffmpeg + BYOK Gemini)
14
+
15
+ clips scan <video-path> Mine a long-form video into tagged, searchable clips
16
+ --provider <p> gemini | openai | openrouter (default: gemini)
17
+ gemini→gemini-2.5-flash-lite, openai→gpt-5.4-nano,
18
+ openrouter→google/gemini-2.5-flash
19
+ --tier flash|flash-lite Gemini tier (default: flash-lite; ignored for openai/openrouter)
20
+ --prompt "<guidance>" Reshape clipping toward your goal: an LLM pass
21
+ keeps/drops/merges scenes, and tags are biased to it
22
+ --no-refine Keep prompt tag-bias but skip the keep/drop/merge pass
23
+ --dry-run Print the estimated cost and exit
24
+ --yes Skip the >$1 confirmation prompt
25
+ --frames <n> Keyframes per scene for tagging (1-3, default 2)
26
+ --no-audio Skip per-scene audio (Gemini only; no transcripts; cheaper)
27
+ --concurrency <n> Parallel scenes (default 3; respect rate limits)
28
+ --threshold <0..1> Scene-cut sensitivity (default 0.3; lower = more cuts)
29
+ --min-scene <sec> Merge scenes shorter than this (default 0.6)
30
+ --max-scene <sec> Split scenes longer than this (default 8)
31
+ --gemini-key / --openai-key / --openrouter-key <key> Provider key (or *_API_KEY env)
32
+ --embed-provider gemini|openai --embed-key <key> Embeddings when using openrouter
33
+ --home <dir> Library dir (default: ~/.vidfarm or VIDFARM_HOME)
34
+
35
+ clips list List clips in the library
36
+ --source <id> Only clips from one source video
37
+ --limit <n> | --json
38
+
39
+ clips search "<natural language>" Hybrid structured + semantic search
40
+ --limit <n> | --json | --gemini-key <key>
41
+
42
+ clips match "<text>" Top library clips to REPLACE a scene (vector search)
43
+ --criteria '<json>' Match a structured SplicingCriteria instead of text
44
+ --scenes <annotations.json> Batch: match every scene of a decomposed video
45
+ (array of scene annotations, or {annotations|scenes:[…]})
46
+ --limit <n> | --json
47
+
48
+ clips preset list List built-in + saved presets
49
+ clips preset run <name> Run a preset query [--limit <n>] [--json]
50
+ clips preset save <name> --from-query '<json | natural language>'
51
+
52
+ clips sources List scanned source videos
53
+ clips export <clip-ids...> --to <dir> Copy clip mp4s (+ metadata.json) out
54
+ `;
55
+ export async function runClipsCommand(argv) {
56
+ const sub = argv[0];
57
+ const rest = argv.slice(1);
58
+ switch (sub) {
59
+ case "scan":
60
+ return runScan(rest);
61
+ case "list":
62
+ return runList(rest);
63
+ case "search":
64
+ return runSearch(rest);
65
+ case "match":
66
+ return runMatch(rest);
67
+ case "preset":
68
+ return runPreset(rest);
69
+ case "sources":
70
+ return runSources(rest);
71
+ case "export":
72
+ return runExport(rest);
73
+ case undefined:
74
+ case "help":
75
+ case "--help":
76
+ case "-h":
77
+ return runOverview();
78
+ default:
79
+ console.error(`Unknown clips subcommand: ${sub}\n`);
80
+ console.log(CLIPS_HELP);
81
+ process.exitCode = 1;
82
+ }
83
+ }
84
+ // ── clips (overview) ────────────────────────────────────────────────────────
85
+ function runOverview() {
86
+ const store = new ClipStore();
87
+ const stats = store.stats();
88
+ store.close();
89
+ console.log(`vidfarm clips — library at ${resolveHome()}`);
90
+ console.log(` ${stats.clips} clips · ${stats.sources} source videos · ${stats.presets} presets\n`);
91
+ console.log(CLIPS_HELP);
92
+ }
93
+ // ── clips scan ──────────────────────────────────────────────────────────────
94
+ async function runScan(argv) {
95
+ const { values, positionals } = parseArgs({
96
+ args: argv,
97
+ allowPositionals: true,
98
+ options: {
99
+ provider: { type: "string" },
100
+ tier: { type: "string" },
101
+ prompt: { type: "string" },
102
+ "no-refine": { type: "boolean" },
103
+ "dry-run": { type: "boolean" },
104
+ yes: { type: "boolean" },
105
+ frames: { type: "string" },
106
+ "no-audio": { type: "boolean" },
107
+ concurrency: { type: "string" },
108
+ threshold: { type: "string" },
109
+ "min-scene": { type: "string" },
110
+ "max-scene": { type: "string" },
111
+ "gemini-key": { type: "string" },
112
+ "openai-key": { type: "string" },
113
+ "openrouter-key": { type: "string" },
114
+ "embed-provider": { type: "string" },
115
+ "embed-key": { type: "string" },
116
+ home: { type: "string" }
117
+ }
118
+ });
119
+ const videoArg = positionals[0];
120
+ if (!videoArg)
121
+ throw new Error("Usage: vidfarm clips scan <video-path> [--tier flash|flash-lite] [--dry-run]");
122
+ const videoPath = path.resolve(process.cwd(), videoArg);
123
+ if (!existsSync(videoPath))
124
+ throw new Error(`No such video: ${videoPath}`);
125
+ if (!(await hasFfmpeg())) {
126
+ throw new Error("ffmpeg is required for `clips scan` but was not found (bundled ffmpeg-static missing and none on PATH).");
127
+ }
128
+ const provider = normalizeProvider(values.provider);
129
+ const tier = normalizeTier(values.tier);
130
+ const framesPerScene = clampInt(values.frames, 2, 1, 3);
131
+ // Audio is Gemini-only (chat models on openai/openrouter don't take the clip).
132
+ const includeAudio = !values["no-audio"] && provider === "gemini";
133
+ const guidancePrompt = values.prompt?.trim() || undefined;
134
+ const concurrency = clampInt(values.concurrency, 3, 1, 12);
135
+ const sceneOptions = {
136
+ threshold: values.threshold ? Number(values.threshold) : undefined,
137
+ minSceneSec: values["min-scene"] ? Number(values["min-scene"]) : undefined,
138
+ maxSceneSec: values["max-scene"] ? Number(values["max-scene"]) : undefined
139
+ };
140
+ // Resolve the tagging key + optional embedding config. Optional here so
141
+ // --dry-run can show the cost without a key; the key is required below to scan.
142
+ const apiKey = resolveProviderKey(provider, values, { optional: true });
143
+ const embedding = apiKey ? resolveEmbeddingConfig(provider, apiKey, values) : null;
144
+ console.log(`[clips] probing ${path.basename(videoPath)} …`);
145
+ const probe = await probeVideo(videoPath);
146
+ console.log(`[clips] ${fmtDuration(probe.duration_sec)} · ${probe.width ?? "?"}x${probe.height ?? "?"} · ${probe.codec ?? "?"}`);
147
+ console.log(`[clips] detecting scenes …`);
148
+ const scenes = await detectScenes(videoPath, { durationSec: probe.duration_sec, ...sceneOptions });
149
+ if (scenes.length === 0)
150
+ throw new Error("No scenes detected (is the video readable / non-empty?).");
151
+ const store = new ClipStore(values.home);
152
+ const client = apiKey ? new ClipModelClient({ provider, apiKey, tier, embedding }) : null;
153
+ if (client && !client.canEmbed) {
154
+ console.warn(`[clips] no embedding provider for "${provider}" — clips will be structured-searchable only.`);
155
+ console.warn(` Add --embed-provider gemini|openai --embed-key <key> for semantic search.`);
156
+ }
157
+ const estimate = estimateScanCostFromScenes(scenes, {
158
+ tier,
159
+ provider,
160
+ tagModelId: client?.tagModelId,
161
+ embedModelId: client ? client.embeddingModelId : defaultEmbedModelForEstimate(provider),
162
+ framesPerScene,
163
+ includeAudio
164
+ });
165
+ console.log(`\n Cost estimate: ${formatCostEstimate(estimate)}`);
166
+ console.log(` Target: $0.05–0.10 per source hour (${withinTarget(estimate.usd_per_source_hour) ? "on target" : "note"}).`);
167
+ if (guidancePrompt)
168
+ console.log(` Guidance: "${guidancePrompt}"`);
169
+ console.log("");
170
+ if (values["dry-run"]) {
171
+ console.log("[clips] --dry-run: not scanning. Re-run without --dry-run to build the library.");
172
+ return;
173
+ }
174
+ // Actually scanning now — a key is required.
175
+ if (!client)
176
+ resolveProviderKey(provider, values); // throws the "No <provider> API key" error
177
+ const activeClient = client;
178
+ // $1 confirmation gate (handoff Target 1).
179
+ if (estimate.usd > 1 && !values.yes) {
180
+ const ok = await confirm(`This scan is estimated at $${estimate.usd.toFixed(2)}. Proceed? [y/N] `);
181
+ if (!ok) {
182
+ console.log("[clips] aborted.");
183
+ return;
184
+ }
185
+ }
186
+ // Warn if this scan's embedding model differs from the library's (vectors won't compare).
187
+ if (activeClient.embeddingModelId) {
188
+ const existing = store.getLibraryEmbeddingModel();
189
+ if (existing && existing !== activeClient.embeddingModelId) {
190
+ console.warn(`[clips] WARNING: library was built with "${existing}" embeddings but this scan uses "${activeClient.embeddingModelId}". Semantic search across them will be unreliable.`);
191
+ }
192
+ else {
193
+ store.rememberEmbeddingModel(activeClient.embeddingModelId);
194
+ }
195
+ }
196
+ const ctx = {
197
+ client: activeClient,
198
+ workDir: path.join(store.paths.workDir, createIdV7("scan")),
199
+ clipsDir: store.paths.clipsDir,
200
+ thumbsDir: store.paths.thumbsDir,
201
+ framesPerScene,
202
+ includeAudio,
203
+ guidancePrompt,
204
+ refineSegmentation: !values["no-refine"],
205
+ reencodeClips: true
206
+ };
207
+ console.log(`[clips] scanning ${scenes.length} scenes (${provider}/${activeClient.tagModelId}, ${concurrency}x) …`);
208
+ let errors = 0;
209
+ const started = timestamp();
210
+ const result = await scanVideo({
211
+ ctx,
212
+ videoPath,
213
+ scenes,
214
+ probe,
215
+ sourceFilename: path.basename(videoPath),
216
+ concurrency,
217
+ onRefine: (r) => {
218
+ if (r.refined) {
219
+ console.log(`[clips] guided segmentation: ${scenes.length} → ${r.kept} clips (dropped ${r.dropped}, merged ${r.merged}).`);
220
+ }
221
+ else if (r.note) {
222
+ console.log(`[clips] guided segmentation skipped: ${r.note}.`);
223
+ }
224
+ },
225
+ onClip: (clip, i, total) => {
226
+ store.insertClip(clip);
227
+ const tagline = summarizeTags(clip);
228
+ console.log(` [${i}/${total}] ${clip.clip_id.slice(0, 14)} ${fmtRange(clip)} ${tagline}`);
229
+ },
230
+ onError: (scene, error) => {
231
+ errors++;
232
+ console.warn(` ✗ scene @${scene.start_time_sec.toFixed(1)}s: ${error instanceof Error ? error.message : String(error)}`);
233
+ }
234
+ });
235
+ store.recordSourceVideo({
236
+ source_video_id: result.source_video_id,
237
+ filename: path.basename(videoPath),
238
+ source_path: videoPath,
239
+ duration_sec: probe.duration_sec,
240
+ clip_count: result.clips.length,
241
+ tier
242
+ });
243
+ // Scratch frames/audio are disposable.
244
+ try {
245
+ rmSync(ctx.workDir, { recursive: true, force: true });
246
+ }
247
+ catch {
248
+ /* ignore */
249
+ }
250
+ store.close();
251
+ console.log(`\n[clips] done in ${elapsed(started)}: ${result.clips.length} clips${errors ? `, ${errors} scene(s) failed` : ""}.`);
252
+ console.log(` clips → ${store.paths.clipsDir}`);
253
+ console.log(` db → ${store.paths.db}`);
254
+ console.log(` try: vidfarm clips search "…" | vidfarm clips preset run "funny reaction gifs"`);
255
+ }
256
+ // ── clips list ──────────────────────────────────────────────────────────────
257
+ async function runList(argv) {
258
+ const { values } = parseArgs({
259
+ args: argv,
260
+ options: {
261
+ source: { type: "string" },
262
+ limit: { type: "string" },
263
+ json: { type: "boolean" },
264
+ home: { type: "string" }
265
+ }
266
+ });
267
+ const store = new ClipStore(values.home);
268
+ const clips = store.listClips({ sourceVideoId: values.source, limit: clampInt(values.limit, 50, 1, 5000) });
269
+ store.close();
270
+ if (values.json) {
271
+ console.log(JSON.stringify(clips, null, 2));
272
+ return;
273
+ }
274
+ if (clips.length === 0) {
275
+ console.log("No clips yet. Run: vidfarm clips scan <video-path>");
276
+ return;
277
+ }
278
+ for (const clip of clips) {
279
+ console.log(`${clip.clip_id.slice(0, 14)} ${fmtRange(clip)} ${clip.source_filename} ${summarizeTags(clip)}`);
280
+ }
281
+ console.log(`\n${clips.length} clips.`);
282
+ }
283
+ // ── clips search ────────────────────────────────────────────────────────────
284
+ async function runSearch(argv) {
285
+ const { values, positionals } = parseArgs({
286
+ args: argv,
287
+ allowPositionals: true,
288
+ options: {
289
+ provider: { type: "string" },
290
+ limit: { type: "string" },
291
+ json: { type: "boolean" },
292
+ "gemini-key": { type: "string" },
293
+ "openai-key": { type: "string" },
294
+ "openrouter-key": { type: "string" },
295
+ home: { type: "string" }
296
+ }
297
+ });
298
+ const query = positionals.join(" ").trim();
299
+ if (!query)
300
+ throw new Error('Usage: vidfarm clips search "<natural language>"');
301
+ const limit = clampInt(values.limit, 10, 1, 200);
302
+ const store = new ClipStore(values.home);
303
+ if (store.stats().clips === 0) {
304
+ store.close();
305
+ console.log("Library is empty — scan a video first: vidfarm clips scan <video-path>");
306
+ return;
307
+ }
308
+ const provider = normalizeProvider(values.provider);
309
+ const apiKey = resolveProviderKey(provider, values, { optional: true });
310
+ let criteria = { semantic_text: query };
311
+ let queryEmbedding;
312
+ if (apiKey) {
313
+ const client = new ClipModelClient({ provider, apiKey });
314
+ criteria = await client.naturalLanguageToCriteria(query);
315
+ if (client.canEmbed) {
316
+ const libModel = store.getLibraryEmbeddingModel();
317
+ if (libModel && client.embeddingModelId && libModel !== client.embeddingModelId) {
318
+ console.warn(`[clips] note: library uses "${libModel}" embeddings but you're searching with "${client.embeddingModelId}" — semantic re-rank may be off; pass --provider to match.`);
319
+ }
320
+ queryEmbedding = await client.embedQuery(criteria.semantic_text ?? query);
321
+ }
322
+ }
323
+ else {
324
+ console.warn(`[clips] no ${provider} key — structured/keyword search only (set the provider key for semantic search).`);
325
+ }
326
+ const t0 = timestamp();
327
+ const hits = store.search({ criteria, queryEmbedding, limit });
328
+ const ms = elapsedMs(t0);
329
+ store.close();
330
+ if (values.json) {
331
+ console.log(JSON.stringify(hits, null, 2));
332
+ return;
333
+ }
334
+ printHits(hits, { query, ms, criteria });
335
+ }
336
+ function normalizeMatchScene(s, i) {
337
+ const mc = (s.match_criteria ?? s.criteria);
338
+ const embText = (s.embedding_text ?? s.text);
339
+ const criteria = mc ?? (embText ? { semantic_text: embText } : {});
340
+ const text = embText || criteria.semantic_text || "";
341
+ return {
342
+ id: s.scene_slug || s.id || `scene_${i}`,
343
+ criteria,
344
+ text,
345
+ meta: {
346
+ replaceability: typeof s.replaceability === "string" ? s.replaceability : undefined,
347
+ duration: typeof s.duration === "number" ? s.duration : undefined,
348
+ start: typeof s.start === "number" ? s.start : undefined,
349
+ scene_role: typeof s.scene_role === "string" ? s.scene_role : undefined
350
+ }
351
+ };
352
+ }
353
+ async function runMatch(argv) {
354
+ const { values, positionals } = parseArgs({
355
+ args: argv,
356
+ allowPositionals: true,
357
+ options: {
358
+ criteria: { type: "string" },
359
+ scenes: { type: "string" },
360
+ provider: { type: "string" },
361
+ limit: { type: "string" },
362
+ json: { type: "boolean" },
363
+ "gemini-key": { type: "string" },
364
+ "openai-key": { type: "string" },
365
+ "openrouter-key": { type: "string" },
366
+ home: { type: "string" }
367
+ }
368
+ });
369
+ const store = new ClipStore(values.home);
370
+ if (store.stats().clips === 0) {
371
+ store.close();
372
+ console.log("Library is empty — scan a video first: vidfarm clips scan <video-path>");
373
+ return;
374
+ }
375
+ // Embed the match query in the SAME space the library was built with.
376
+ const libModel = store.getLibraryEmbeddingModel();
377
+ const embedProvider = libModel && libModel.startsWith("text-embedding") ? "openai" : "gemini";
378
+ const embedKey = resolveProviderKey(embedProvider, values, { optional: true });
379
+ const client = embedKey ? new ClipModelClient({ provider: embedProvider, apiKey: embedKey }) : null;
380
+ if (!client)
381
+ console.warn(`[clips] no ${embedProvider} key — structured match only (set the key for vector re-rank).`);
382
+ if (values.scenes) {
383
+ const filePath = path.resolve(process.cwd(), values.scenes);
384
+ if (!existsSync(filePath))
385
+ throw new Error(`No such file: ${filePath}`);
386
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
387
+ const rawScenes = (Array.isArray(parsed)
388
+ ? parsed
389
+ : (parsed.annotations ??
390
+ parsed.scenes ??
391
+ []));
392
+ if (rawScenes.length === 0)
393
+ throw new Error("No scene annotations found in file (expected an array, or {annotations|scenes:[…]}).");
394
+ const scenes = rawScenes.map(normalizeMatchScene);
395
+ const perScene = clampInt(values.limit, 5, 1, 50);
396
+ const embeddings = client ? await client.embedQueries(scenes.map((s) => s.text || " ")) : [];
397
+ // Load the library once for the whole batch.
398
+ const all = store.listClips();
399
+ store.attachEmbeddings(all);
400
+ store.close();
401
+ const output = scenes.map((s, i) => ({
402
+ scene_slug: s.id,
403
+ ...s.meta,
404
+ matches: searchClips(all, {
405
+ criteria: s.criteria,
406
+ queryEmbedding: client ? embeddings[i] : undefined,
407
+ limit: perScene,
408
+ relaxWhenEmpty: true
409
+ })
410
+ }));
411
+ if (values.json) {
412
+ console.log(JSON.stringify(output, null, 2));
413
+ return;
414
+ }
415
+ console.log(`Matched ${scenes.length} scenes against ${all.length} clips:`);
416
+ for (const o of output) {
417
+ const rep = o.replaceability ? ` [replaceability: ${o.replaceability}]` : "";
418
+ const dur = typeof o.duration === "number" ? ` (${o.duration.toFixed(1)}s)` : "";
419
+ console.log(`\n▶ ${o.scene_slug}${rep}${dur}`);
420
+ if (o.matches.length === 0) {
421
+ console.log(" (no library match)");
422
+ continue;
423
+ }
424
+ o.matches.forEach((h, j) => {
425
+ const sim = h.similarity != null ? ` sim=${h.similarity.toFixed(2)}` : "";
426
+ console.log(` ${j + 1}. ${h.clip.clip_id.slice(0, 14)} ${fmtRange(h.clip)} score=${h.score.toFixed(2)}${sim} ${summarizeTags(h.clip)}`);
427
+ });
428
+ }
429
+ return;
430
+ }
431
+ // Single scene: --criteria '<json>' or a free-text query.
432
+ const limit = clampInt(values.limit, 8, 1, 50);
433
+ let criteria;
434
+ let text;
435
+ const asJson = values.criteria ? tryParseJson(values.criteria) : null;
436
+ if (asJson && typeof asJson === "object") {
437
+ criteria = asJson;
438
+ text = criteria.semantic_text ?? positionals.join(" ").trim();
439
+ }
440
+ else {
441
+ text = positionals.join(" ").trim();
442
+ if (!text) {
443
+ store.close();
444
+ throw new Error('Usage: vidfarm clips match "<text>" | --criteria \'<json>\' | --scenes <annotations.json>');
445
+ }
446
+ criteria = { semantic_text: text };
447
+ }
448
+ const queryEmbedding = client && text ? await client.embedQuery(text) : undefined;
449
+ const t0 = timestamp();
450
+ const hits = store.search({ criteria, queryEmbedding, limit });
451
+ const ms = elapsedMs(t0);
452
+ store.close();
453
+ if (values.json) {
454
+ console.log(JSON.stringify(hits, null, 2));
455
+ return;
456
+ }
457
+ printHits(hits, { query: text || "criteria", ms, criteria });
458
+ }
459
+ // ── clips preset ────────────────────────────────────────────────────────────
460
+ async function runPreset(argv) {
461
+ const action = argv[0];
462
+ const rest = argv.slice(1);
463
+ if (action === "list")
464
+ return runPresetList(rest);
465
+ if (action === "run")
466
+ return runPresetRun(rest);
467
+ if (action === "save")
468
+ return runPresetSave(rest);
469
+ throw new Error("Usage: vidfarm clips preset <list|run <name>|save <name> --from-query '<...>'>");
470
+ }
471
+ async function runPresetList(argv) {
472
+ const { values } = parseArgs({ args: argv, options: { json: { type: "boolean" }, home: { type: "string" } } });
473
+ const store = new ClipStore(values.home);
474
+ const presets = store.listPresets();
475
+ store.close();
476
+ if (values.json) {
477
+ console.log(JSON.stringify(presets, null, 2));
478
+ return;
479
+ }
480
+ for (const p of presets) {
481
+ const tag = p.builtin ? "(built-in)" : "(saved)";
482
+ console.log(`${p.name} ${tag}\n ${p.description ?? ""}\n ${describeCriteria(p.criteria)}`);
483
+ }
484
+ }
485
+ async function runPresetRun(argv) {
486
+ const { values, positionals } = parseArgs({
487
+ args: argv,
488
+ allowPositionals: true,
489
+ options: {
490
+ provider: { type: "string" },
491
+ limit: { type: "string" },
492
+ json: { type: "boolean" },
493
+ "gemini-key": { type: "string" },
494
+ "openai-key": { type: "string" },
495
+ "openrouter-key": { type: "string" },
496
+ home: { type: "string" }
497
+ }
498
+ });
499
+ const name = positionals.join(" ").trim();
500
+ if (!name)
501
+ throw new Error("Usage: vidfarm clips preset run <name>");
502
+ const store = new ClipStore(values.home);
503
+ const preset = store.findPreset(name);
504
+ if (!preset) {
505
+ store.close();
506
+ throw new Error(`No preset "${name}". Try: vidfarm clips preset list`);
507
+ }
508
+ const limit = clampInt(values.limit, 10, 1, 200);
509
+ // Presets carry semantic_text; embed it if we have a key for the re-rank.
510
+ let queryEmbedding;
511
+ const provider = normalizeProvider(values.provider);
512
+ const apiKey = resolveProviderKey(provider, values, { optional: true });
513
+ if (apiKey && preset.criteria.semantic_text) {
514
+ const client = new ClipModelClient({ provider, apiKey });
515
+ if (client.canEmbed)
516
+ queryEmbedding = await client.embedQuery(preset.criteria.semantic_text);
517
+ }
518
+ const t0 = timestamp();
519
+ const hits = store.search({ criteria: preset.criteria, queryEmbedding, limit });
520
+ const ms = elapsedMs(t0);
521
+ store.close();
522
+ if (values.json) {
523
+ console.log(JSON.stringify(hits, null, 2));
524
+ return;
525
+ }
526
+ printHits(hits, { query: preset.name, ms, criteria: preset.criteria });
527
+ }
528
+ async function runPresetSave(argv) {
529
+ const { values, positionals } = parseArgs({
530
+ args: argv,
531
+ allowPositionals: true,
532
+ options: {
533
+ "from-query": { type: "string" },
534
+ description: { type: "string" },
535
+ provider: { type: "string" },
536
+ "gemini-key": { type: "string" },
537
+ "openai-key": { type: "string" },
538
+ "openrouter-key": { type: "string" },
539
+ home: { type: "string" }
540
+ }
541
+ });
542
+ const name = positionals.join(" ").trim();
543
+ const fromQuery = values["from-query"];
544
+ if (!name || !fromQuery) {
545
+ throw new Error("Usage: vidfarm clips preset save <name> --from-query '<json | natural language>'");
546
+ }
547
+ let criteria;
548
+ const asJson = tryParseJson(fromQuery);
549
+ if (asJson && typeof asJson === "object") {
550
+ criteria = asJson;
551
+ }
552
+ else {
553
+ // Treat as natural language → structured (needs a provider key).
554
+ const provider = normalizeProvider(values.provider);
555
+ const apiKey = resolveProviderKey(provider, values);
556
+ criteria = await new ClipModelClient({ provider, apiKey }).naturalLanguageToCriteria(fromQuery);
557
+ }
558
+ const store = new ClipStore(values.home);
559
+ const preset = store.savePreset({ name, criteria, description: values.description });
560
+ store.close();
561
+ console.log(`Saved preset "${preset.name}" (${preset.preset_id}).`);
562
+ console.log(` ${describeCriteria(preset.criteria)}`);
563
+ }
564
+ // ── clips sources ───────────────────────────────────────────────────────────
565
+ async function runSources(argv) {
566
+ const { values } = parseArgs({ args: argv, options: { json: { type: "boolean" }, home: { type: "string" } } });
567
+ const store = new ClipStore(values.home);
568
+ const sources = store.listSourceVideos();
569
+ store.close();
570
+ if (values.json) {
571
+ console.log(JSON.stringify(sources, null, 2));
572
+ return;
573
+ }
574
+ if (sources.length === 0) {
575
+ console.log("No sources scanned yet.");
576
+ return;
577
+ }
578
+ for (const s of sources) {
579
+ console.log(`${s.source_video_id.slice(0, 14)} ${s.filename} ${s.clip_count} clips ${s.duration_sec ? fmtDuration(s.duration_sec) : ""} ${s.scanned_at}`);
580
+ }
581
+ }
582
+ // ── clips export ────────────────────────────────────────────────────────────
583
+ async function runExport(argv) {
584
+ const { values, positionals } = parseArgs({
585
+ args: argv,
586
+ allowPositionals: true,
587
+ options: { to: { type: "string" }, home: { type: "string" } }
588
+ });
589
+ const ids = positionals;
590
+ const to = values.to;
591
+ if (ids.length === 0 || !to) {
592
+ throw new Error("Usage: vidfarm clips export <clip-ids...> --to <dir>");
593
+ }
594
+ const outDir = path.resolve(process.cwd(), to);
595
+ mkdirSync(outDir, { recursive: true });
596
+ const store = new ClipStore(values.home);
597
+ const exported = [];
598
+ for (const id of ids) {
599
+ const clip = store.getClip(id);
600
+ if (!clip) {
601
+ console.warn(` ✗ no clip ${id}`);
602
+ continue;
603
+ }
604
+ const src = path.join(store.paths.clipsDir, `${clip.clip_id}.mp4`);
605
+ if (!existsSync(src)) {
606
+ console.warn(` ✗ missing file for ${id} (${src})`);
607
+ continue;
608
+ }
609
+ const dest = path.join(outDir, `${clip.clip_id}.mp4`);
610
+ copyFileSync(src, dest);
611
+ const thumb = path.join(store.paths.thumbsDir, `${clip.clip_id}.jpg`);
612
+ if (existsSync(thumb))
613
+ copyFileSync(thumb, path.join(outDir, `${clip.clip_id}.jpg`));
614
+ exported.push(clip);
615
+ console.log(` ✓ ${clip.clip_id} → ${dest}`);
616
+ }
617
+ store.close();
618
+ const { writeFileSync } = await import("node:fs");
619
+ writeFileSync(path.join(outDir, "clips.metadata.json"), JSON.stringify(exported, null, 2));
620
+ console.log(`\nExported ${exported.length} clips + clips.metadata.json → ${outDir}`);
621
+ }
622
+ // ── helpers ─────────────────────────────────────────────────────────────────
623
+ function normalizeProvider(value) {
624
+ if (value === undefined || value === "gemini")
625
+ return "gemini";
626
+ if (value === "openai" || value === "openrouter")
627
+ return value;
628
+ throw new Error(`--provider must be gemini | openai | openrouter (got "${value}")`);
629
+ }
630
+ function normalizeTier(value) {
631
+ if (value === "flash")
632
+ return "flash";
633
+ if (value === "flash-lite" || value === undefined)
634
+ return "flash-lite";
635
+ throw new Error(`--tier must be "flash" or "flash-lite" (got "${value}")`);
636
+ }
637
+ // Best-guess embedding model for the --dry-run estimate when no key is present
638
+ // (openrouter falls back to gemini embeddings by default).
639
+ function defaultEmbedModelForEstimate(provider) {
640
+ return provider === "openai" ? "text-embedding-3-small" : "gemini-embedding-001";
641
+ }
642
+ const ENV_KEYS = {
643
+ gemini: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
644
+ openai: ["OPENAI_API_KEY"],
645
+ openrouter: ["OPENROUTER_API_KEY"]
646
+ };
647
+ const CONFIG_KEYS = {
648
+ gemini: "gemini_api_key",
649
+ openai: "openai_api_key",
650
+ openrouter: "openrouter_api_key"
651
+ };
652
+ /** Resolve a provider key: --<provider>-key flag → *_API_KEY env → ~/.vidfarm/config.json. */
653
+ function resolveProviderKey(provider, flags, opts = {}) {
654
+ const flagVal = flags[`${provider}-key`]?.trim();
655
+ if (flagVal)
656
+ return flagVal;
657
+ for (const env of ENV_KEYS[provider]) {
658
+ const v = (process.env[env] || "").trim();
659
+ if (v)
660
+ return v;
661
+ }
662
+ const cfgPath = path.join(resolveHome(), "config.json");
663
+ if (existsSync(cfgPath)) {
664
+ try {
665
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
666
+ const v = cfg[CONFIG_KEYS[provider]]?.trim();
667
+ if (v)
668
+ return v;
669
+ }
670
+ catch {
671
+ /* ignore malformed config */
672
+ }
673
+ }
674
+ if (opts.optional)
675
+ return "";
676
+ const envHint = ENV_KEYS[provider][0];
677
+ throw new Error(`No ${provider} API key. Pass --${provider}-key <key> or set ${envHint}.`);
678
+ }
679
+ /**
680
+ * Embedding config for a scan. gemini/openai embed with their own key; openrouter
681
+ * can't embed, so pair it with a gemini/openai key via --embed-provider/--embed-key
682
+ * (or GEMINI_API_KEY/OPENAI_API_KEY env). Returns null → semantic search disabled.
683
+ */
684
+ function resolveEmbeddingConfig(provider, apiKey, flags) {
685
+ if (provider === "gemini")
686
+ return { provider: "gemini", apiKey };
687
+ if (provider === "openai")
688
+ return { provider: "openai", apiKey };
689
+ // openrouter → look for an explicit embed provider, else any gemini/openai key.
690
+ const explicit = flags["embed-provider"]?.trim();
691
+ const tryProvider = (p) => {
692
+ const key = flags["embed-key"]?.trim() || resolveProviderKey(p, flags, { optional: true });
693
+ return key ? { provider: p, apiKey: key } : null;
694
+ };
695
+ if (explicit === "gemini")
696
+ return tryProvider("gemini");
697
+ if (explicit === "openai")
698
+ return tryProvider("openai");
699
+ return tryProvider("gemini") ?? tryProvider("openai");
700
+ }
701
+ function clampInt(value, fallback, min, max) {
702
+ if (value === undefined)
703
+ return fallback;
704
+ const n = Math.round(Number(value));
705
+ if (!Number.isFinite(n))
706
+ return fallback;
707
+ return Math.max(min, Math.min(max, n));
708
+ }
709
+ function resolveHome() {
710
+ return process.env.VIDFARM_HOME ? path.resolve(process.env.VIDFARM_HOME) : path.join(homedir(), ".vidfarm");
711
+ }
712
+ async function confirm(question) {
713
+ if (!process.stdin.isTTY)
714
+ return false;
715
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
716
+ try {
717
+ const answer = (await rl.question(question)).trim().toLowerCase();
718
+ return answer === "y" || answer === "yes";
719
+ }
720
+ finally {
721
+ rl.close();
722
+ }
723
+ }
724
+ function tryParseJson(text) {
725
+ const trimmed = text.trim();
726
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("["))
727
+ return null;
728
+ try {
729
+ return JSON.parse(trimmed);
730
+ }
731
+ catch {
732
+ return null;
733
+ }
734
+ }
735
+ function summarizeTags(clip) {
736
+ const t = clip.tags;
737
+ const bits = [...t.subject.slice(0, 1), ...t.action.slice(0, 1), ...t.emotion.slice(0, 1)].filter(Boolean);
738
+ const energy = `⚡${t.energy}`;
739
+ const desc = clip.description ? `— ${clip.description.slice(0, 48)}` : "";
740
+ return `${bits.join("/") || "untagged"} ${energy} ${desc}`.trim();
741
+ }
742
+ function printHits(hits, ctx) {
743
+ console.log(`\n"${ctx.query}" → ${hits.length} results in ${ctx.ms}ms`);
744
+ console.log(` filter: ${describeCriteria(ctx.criteria)}\n`);
745
+ if (hits.length === 0) {
746
+ console.log(" (no matches — try a broader query or scan more footage)");
747
+ return;
748
+ }
749
+ hits.forEach((hit, i) => {
750
+ const c = hit.clip;
751
+ const sim = hit.similarity != null ? ` sim=${hit.similarity.toFixed(2)}` : "";
752
+ console.log(` ${String(i + 1).padStart(2)}. ${c.clip_id.slice(0, 14)} ${fmtRange(c)} score=${hit.score.toFixed(2)}${sim}`);
753
+ console.log(` ${summarizeTags(c)}`);
754
+ console.log(` ${c.thumbnail_path}`);
755
+ });
756
+ }
757
+ function describeCriteria(criteria) {
758
+ const parts = [];
759
+ for (const key of ["subject", "action", "emotion", "setting", "composition", "motion", "audio_type", "energy"]) {
760
+ const v = criteria[key];
761
+ if (Array.isArray(v) && v.length)
762
+ parts.push(`${key} ∈ {${v.join(",")}}`);
763
+ }
764
+ if (criteria.min_duration_sec != null)
765
+ parts.push(`≥${criteria.min_duration_sec}s`);
766
+ if (criteria.max_duration_sec != null)
767
+ parts.push(`≤${criteria.max_duration_sec}s`);
768
+ if (criteria.has_dialogue != null)
769
+ parts.push(criteria.has_dialogue ? "has dialogue" : "no dialogue");
770
+ if (criteria.has_subject != null)
771
+ parts.push(criteria.has_subject ? "has subject" : "no subject");
772
+ if (criteria.semantic_text)
773
+ parts.push(`~"${criteria.semantic_text}"`);
774
+ return parts.length ? parts.join(" · ") : "(no constraints)";
775
+ }
776
+ function withinTarget(usdPerHour) {
777
+ return usdPerHour <= 0.12;
778
+ }
779
+ function fmtRange(clip) {
780
+ return `${fmtClock(clip.start_time_sec)}–${fmtClock(clip.end_time_sec)} (${clip.duration_sec.toFixed(1)}s)`;
781
+ }
782
+ function fmtClock(sec) {
783
+ const m = Math.floor(sec / 60);
784
+ const s = Math.floor(sec % 60);
785
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
786
+ }
787
+ function fmtDuration(sec) {
788
+ if (sec >= 60)
789
+ return `${Math.floor(sec / 60)}m${Math.round(sec % 60)}s`;
790
+ return `${sec.toFixed(1)}s`;
791
+ }
792
+ function timestamp() {
793
+ return process.hrtime();
794
+ }
795
+ function elapsedMs(start) {
796
+ const [s, ns] = process.hrtime(start);
797
+ return Math.round(s * 1000 + ns / 1e6);
798
+ }
799
+ function elapsed(start) {
800
+ const ms = elapsedMs(start);
801
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
802
+ }
803
+ //# sourceMappingURL=clips.js.map