@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,258 @@
1
+ // ffmpeg/ffprobe helpers for clip curation — scene detection, keyframe/clip/
2
+ // thumbnail extraction, per-scene audio for ASR. Same flags on devcli (local
3
+ // ffmpeg-static) and cloud (bundled ffmpeg-static in the scan Lambda), so both
4
+ // targets cut identical clips. Resolution order matches the rest of the repo:
5
+ // HYPERFRAMES_FFMPEG_PATH/FFPROBE env → ffmpeg-static/ffprobe-static → PATH.
6
+ import { spawn } from "node:child_process";
7
+ import { existsSync, mkdirSync } from "node:fs";
8
+ import path from "node:path";
9
+ let cachedFfmpeg;
10
+ let cachedFfprobe;
11
+ export async function resolveFfmpeg() {
12
+ if (cachedFfmpeg !== undefined)
13
+ return cachedFfmpeg ?? "ffmpeg";
14
+ const envPath = process.env.HYPERFRAMES_FFMPEG_PATH?.trim();
15
+ if (envPath && existsSync(envPath))
16
+ return (cachedFfmpeg = envPath);
17
+ try {
18
+ const mod = (await import("ffmpeg-static"));
19
+ const resolved = typeof mod === "string" ? mod : mod.default;
20
+ if (typeof resolved === "string" && resolved && existsSync(resolved))
21
+ return (cachedFfmpeg = resolved);
22
+ }
23
+ catch {
24
+ /* fall through to PATH */
25
+ }
26
+ cachedFfmpeg = null;
27
+ return "ffmpeg";
28
+ }
29
+ export async function resolveFfprobe() {
30
+ if (cachedFfprobe !== undefined)
31
+ return cachedFfprobe ?? "ffprobe";
32
+ const envPath = process.env.HYPERFRAMES_FFPROBE_PATH?.trim() ?? process.env.FFPROBE_PATH?.trim();
33
+ if (envPath && existsSync(envPath))
34
+ return (cachedFfprobe = envPath);
35
+ try {
36
+ const mod = (await import("ffprobe-static"));
37
+ const p = (mod.path ?? mod.default?.path);
38
+ if (typeof p === "string" && p && existsSync(p))
39
+ return (cachedFfprobe = p);
40
+ }
41
+ catch {
42
+ /* fall through to PATH */
43
+ }
44
+ cachedFfprobe = null;
45
+ return "ffprobe";
46
+ }
47
+ /** True if a usable ffmpeg is available (bundled or on PATH). */
48
+ export async function hasFfmpeg() {
49
+ const bin = await resolveFfmpeg();
50
+ const { code } = await run(bin, ["-version"]).catch(() => ({ code: 1, stdout: "", stderr: "" }));
51
+ return code === 0;
52
+ }
53
+ function run(bin, args) {
54
+ return new Promise((resolve, reject) => {
55
+ const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
56
+ let stdout = "";
57
+ let stderr = "";
58
+ child.stdout.on("data", (d) => (stdout += d.toString()));
59
+ child.stderr.on("data", (d) => (stderr += d.toString()));
60
+ child.on("error", reject);
61
+ child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr }));
62
+ });
63
+ }
64
+ /** Probe a source video for duration/dimensions/codec/size/fps. */
65
+ export async function probeVideo(videoPath) {
66
+ const ffprobe = await resolveFfprobe();
67
+ const { stdout } = await run(ffprobe, [
68
+ "-v", "error",
69
+ "-print_format", "json",
70
+ "-show_format",
71
+ "-show_streams",
72
+ videoPath
73
+ ]);
74
+ let parsed = {};
75
+ try {
76
+ parsed = JSON.parse(stdout || "{}");
77
+ }
78
+ catch {
79
+ parsed = {};
80
+ }
81
+ const streams = Array.isArray(parsed.streams) ? parsed.streams : [];
82
+ const video = streams.find((s) => s.codec_type === "video");
83
+ const format = parsed.format ?? {};
84
+ const duration = Number(format.duration ?? video?.duration ?? 0) || 0;
85
+ let fps = null;
86
+ if (video?.avg_frame_rate && typeof video.avg_frame_rate === "string") {
87
+ const [num, den] = video.avg_frame_rate.split("/").map(Number);
88
+ if (num && den)
89
+ fps = num / den;
90
+ }
91
+ return {
92
+ duration_sec: duration,
93
+ width: video?.width ?? null,
94
+ height: video?.height ?? null,
95
+ codec: video?.codec_name ?? null,
96
+ size_bytes: format.size ? Number(format.size) : null,
97
+ fps
98
+ };
99
+ }
100
+ /**
101
+ * Detect scene boundaries with the portable `select='gt(scene,T)',showinfo`
102
+ * pass, parsing pts_time from ffmpeg's stderr. Falls back to fixed-interval
103
+ * chunking when nothing is detected (e.g. a single continuous take), so a scan
104
+ * always yields clips. Same command shape on devcli and cloud.
105
+ */
106
+ export async function detectScenes(videoPath, opts = {}) {
107
+ const threshold = opts.threshold ?? 0.3;
108
+ const minScene = opts.minSceneSec ?? 0.6;
109
+ const maxScene = opts.maxSceneSec ?? 8;
110
+ const duration = opts.durationSec ?? (await probeVideo(videoPath)).duration_sec;
111
+ if (!Number.isFinite(duration) || duration <= 0)
112
+ return [];
113
+ const ffmpeg = await resolveFfmpeg();
114
+ const { stderr } = await run(ffmpeg, [
115
+ "-hide_banner",
116
+ "-i", videoPath,
117
+ "-filter:v", `select='gt(scene,${threshold})',showinfo`,
118
+ "-f", "null",
119
+ "-"
120
+ ]);
121
+ const cuts = [];
122
+ const re = /pts_time:([0-9.]+)/g;
123
+ let m;
124
+ while ((m = re.exec(stderr)) !== null) {
125
+ const t = Number(m[1]);
126
+ if (Number.isFinite(t) && t > 0 && t < duration)
127
+ cuts.push(t);
128
+ }
129
+ cuts.sort((a, b) => a - b);
130
+ // Build [0, cut1, cut2, …, duration] boundaries.
131
+ let bounds = [0, ...cuts, duration];
132
+ // If nothing detected, chunk at maxScene.
133
+ if (cuts.length === 0) {
134
+ bounds = [0];
135
+ for (let t = maxScene; t < duration; t += maxScene)
136
+ bounds.push(t);
137
+ bounds.push(duration);
138
+ }
139
+ // Merge boundaries closer than minScene.
140
+ const merged = [bounds[0]];
141
+ for (let i = 1; i < bounds.length; i++) {
142
+ if (bounds[i] - merged[merged.length - 1] >= minScene || i === bounds.length - 1) {
143
+ merged.push(bounds[i]);
144
+ }
145
+ }
146
+ const scenes = [];
147
+ for (let i = 0; i < merged.length - 1; i++) {
148
+ let start = merged[i];
149
+ const end = merged[i + 1];
150
+ // Split over-long scenes so clips stay usable and tagging stays cheap.
151
+ const span = end - start;
152
+ if (span > maxScene) {
153
+ const chunks = Math.ceil(span / maxScene);
154
+ const step = span / chunks;
155
+ for (let c = 0; c < chunks; c++) {
156
+ const s = start + c * step;
157
+ const e = c === chunks - 1 ? end : s + step;
158
+ scenes.push(makeScene(scenes.length, s, e));
159
+ }
160
+ }
161
+ else {
162
+ scenes.push(makeScene(scenes.length, start, end));
163
+ }
164
+ }
165
+ return scenes;
166
+ }
167
+ function makeScene(index, start, end) {
168
+ const s = Math.max(0, start);
169
+ const e = Math.max(s + 0.01, end);
170
+ return { index, start_time_sec: round3(s), end_time_sec: round3(e), duration_sec: round3(e - s) };
171
+ }
172
+ /** Extract up to `count` small keyframe JPEGs across a scene (for low-cost tagging). */
173
+ export async function extractKeyframes(input) {
174
+ const count = Math.max(1, Math.min(3, input.count ?? 2));
175
+ const width = input.width ?? 384;
176
+ mkdirSync(input.outDir, { recursive: true });
177
+ const ffmpeg = await resolveFfmpeg();
178
+ const { start_time_sec: start, duration_sec: dur } = input.scene;
179
+ // Sample points inside the clip, avoiding the very edges.
180
+ const points = count === 1
181
+ ? [start + dur / 2]
182
+ : Array.from({ length: count }, (_, i) => start + dur * ((i + 1) / (count + 1)));
183
+ const outPaths = [];
184
+ for (let i = 0; i < points.length; i++) {
185
+ const out = path.join(input.outDir, `scene-${input.scene.index}-kf${i}.jpg`);
186
+ const { code } = await run(ffmpeg, [
187
+ "-y",
188
+ "-ss", points[i].toFixed(3),
189
+ "-i", input.videoPath,
190
+ "-frames:v", "1",
191
+ "-vf", `scale=${width}:-2`,
192
+ "-q:v", "5",
193
+ out
194
+ ]);
195
+ if (code === 0 && existsSync(out))
196
+ outPaths.push(out);
197
+ }
198
+ return outPaths;
199
+ }
200
+ /** Cut the scene into a standalone mp4 (re-encoded for frame-accurate boundaries). */
201
+ export async function extractClip(input) {
202
+ mkdirSync(path.dirname(input.outPath), { recursive: true });
203
+ const ffmpeg = await resolveFfmpeg();
204
+ const { start_time_sec: start, duration_sec: dur } = input.scene;
205
+ const args = input.reencode === false
206
+ ? ["-y", "-ss", start.toFixed(3), "-i", input.videoPath, "-t", dur.toFixed(3), "-c", "copy", "-avoid_negative_ts", "make_zero", input.outPath]
207
+ : [
208
+ "-y",
209
+ "-ss", start.toFixed(3),
210
+ "-i", input.videoPath,
211
+ "-t", dur.toFixed(3),
212
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
213
+ "-c:a", "aac", "-b:a", "128k",
214
+ "-movflags", "+faststart",
215
+ input.outPath
216
+ ];
217
+ const { code } = await run(ffmpeg, args);
218
+ return code === 0 && existsSync(input.outPath);
219
+ }
220
+ /** Single representative thumbnail for the scene. */
221
+ export async function extractThumbnail(input) {
222
+ mkdirSync(path.dirname(input.outPath), { recursive: true });
223
+ const ffmpeg = await resolveFfmpeg();
224
+ const mid = input.scene.start_time_sec + input.scene.duration_sec / 2;
225
+ const { code } = await run(ffmpeg, [
226
+ "-y",
227
+ "-ss", mid.toFixed(3),
228
+ "-i", input.videoPath,
229
+ "-frames:v", "1",
230
+ "-vf", `scale=${input.width ?? 320}:-2`,
231
+ "-q:v", "3",
232
+ input.outPath
233
+ ]);
234
+ return code === 0 && existsSync(input.outPath);
235
+ }
236
+ /** Extract a small mono/16k audio clip for the scene (feeds Gemini ASR). Returns null if no audio track. */
237
+ export async function extractSceneAudio(input) {
238
+ mkdirSync(path.dirname(input.outPath), { recursive: true });
239
+ const ffmpeg = await resolveFfmpeg();
240
+ const { start_time_sec: start, duration_sec: dur } = input.scene;
241
+ const { code } = await run(ffmpeg, [
242
+ "-y",
243
+ "-ss", start.toFixed(3),
244
+ "-i", input.videoPath,
245
+ "-t", dur.toFixed(3),
246
+ "-vn",
247
+ "-ac", "1",
248
+ "-ar", "16000",
249
+ "-c:a", "libmp3lame",
250
+ "-b:a", "32k",
251
+ input.outPath
252
+ ]);
253
+ return code === 0 && existsSync(input.outPath) ? input.outPath : null;
254
+ }
255
+ function round3(n) {
256
+ return Math.round(n * 1000) / 1000;
257
+ }
258
+ //# sourceMappingURL=ffmpeg.js.map
@@ -0,0 +1,342 @@
1
+ // Model client shared by devcli + cloud. Multi-provider: the TAGGING model
2
+ // (vision + optional audio ASR) is swappable across gemini / openai / openrouter
3
+ // so users pick their cost/quality tradeoff; EMBEDDINGS ride on a resolvable
4
+ // embedding provider (gemini or openai — OpenRouter has no /embeddings) so a
5
+ // library stays in ONE vector space. BYOK: the caller passes provider keys.
6
+ //
7
+ // One place for the prompt templates so every provider produces byte-identical
8
+ // clip metadata (same taxonomy, same schema).
9
+ import { readFile } from "node:fs/promises";
10
+ import { embedMany, generateObject } from "ai";
11
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
12
+ import { createOpenAI } from "@ai-sdk/openai";
13
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
14
+ import { z } from "zod";
15
+ import { ACTIVE_TAXONOMY_VERSION, getTaxonomy, sanitizeTagValues, taxonomyForPrompt } from "./taxonomy.js";
16
+ // Tag model per provider/tier. Overridable via env so pinned models can move
17
+ // forward without a code change. Handoff: gemini flash-lite (cheap, low media_resolution).
18
+ function resolveTagModel(provider, tier) {
19
+ switch (provider) {
20
+ case "openai":
21
+ return process.env.VIDFARM_CLIP_TAG_MODEL_OPENAI ?? "gpt-5.4-nano";
22
+ case "openrouter":
23
+ return process.env.VIDFARM_CLIP_TAG_MODEL_OPENROUTER ?? "google/gemini-2.5-flash";
24
+ case "gemini":
25
+ default:
26
+ return tier === "flash"
27
+ ? process.env.VIDFARM_CLIP_TAG_MODEL_FLASH ?? "gemini-2.5-flash"
28
+ : process.env.VIDFARM_CLIP_TAG_MODEL_LITE ?? "gemini-2.5-flash-lite";
29
+ }
30
+ }
31
+ export const EMBEDDING_MODELS = {
32
+ gemini: process.env.VIDFARM_CLIP_EMBEDDING_MODEL ?? "gemini-embedding-001",
33
+ openai: process.env.VIDFARM_CLIP_EMBEDDING_MODEL_OPENAI ?? "text-embedding-3-small"
34
+ };
35
+ // gemini-embedding-001 and text-embedding-3-small both support reducing to 768
36
+ // dims, so every provider's vectors fit the same fixed-width index. Cosine is
37
+ // dimension-agnostic; still, vectors from DIFFERENT models are NOT comparable —
38
+ // a library should stay on one embedding model (callers store embedding_model
39
+ // and warn on mismatch).
40
+ export const EMBEDDING_DIM = Number(process.env.VIDFARM_CLIP_EMBEDDING_DIM ?? 768);
41
+ export class ClipModelClient {
42
+ provider;
43
+ tier;
44
+ taxonomyVersion;
45
+ tagModelId;
46
+ embeddingProvider;
47
+ embeddingModelId;
48
+ tagModel;
49
+ embedModel;
50
+ constructor(opts) {
51
+ if (!opts.apiKey)
52
+ throw new Error(`A ${opts.provider ?? "gemini"} API key is required.`);
53
+ this.provider = opts.provider ?? "gemini";
54
+ this.tier = opts.tier ?? "flash-lite";
55
+ this.taxonomyVersion = opts.taxonomyVersion ?? ACTIVE_TAXONOMY_VERSION;
56
+ this.tagModelId = resolveTagModel(this.provider, this.tier);
57
+ this.tagModel = buildLanguageModel(this.provider, opts.apiKey, this.tagModelId, opts.publicBaseUrl);
58
+ const embedding = resolveEmbeddingConfig(this.provider, opts.apiKey, opts.embedding);
59
+ if (embedding) {
60
+ this.embeddingProvider = embedding.provider;
61
+ this.embeddingModelId = EMBEDDING_MODELS[embedding.provider];
62
+ this.embedModel = buildEmbeddingModel(embedding.provider, embedding.apiKey, this.embeddingModelId);
63
+ }
64
+ else {
65
+ this.embeddingProvider = null;
66
+ this.embeddingModelId = null;
67
+ this.embedModel = null;
68
+ }
69
+ }
70
+ /** True when this client can produce embeddings (semantic search available). */
71
+ get canEmbed() {
72
+ return this.embedModel !== null;
73
+ }
74
+ /** The underlying tagging LanguageModel — used by the guided-segmentation pass. */
75
+ get taggingModel() {
76
+ return this.tagModel;
77
+ }
78
+ tagSchema() {
79
+ const cats = getTaxonomy(this.taxonomyVersion).categories;
80
+ const enumArray = (values) => z.array(z.enum(values)).default([]);
81
+ return z.object({
82
+ subject: enumArray(cats.subject),
83
+ action: enumArray(cats.action),
84
+ emotion: enumArray(cats.emotion),
85
+ setting: enumArray(cats.setting),
86
+ composition: enumArray(cats.composition),
87
+ motion: enumArray(cats.motion),
88
+ energy: z.enum(cats.energy),
89
+ audio_type: enumArray(cats.audio_type),
90
+ transcript: z.string().default(""),
91
+ description: z.string()
92
+ });
93
+ }
94
+ /**
95
+ * Tag one scene from its keyframes (+ audio on Gemini, which transcribes in
96
+ * the same call). OpenAI/OpenRouter get frames only — their chat models don't
97
+ * take raw audio, so transcript stays empty unless supplied. Degrades to an
98
+ * empty-but-valid tag set on error so one bad scene never kills a scan.
99
+ */
100
+ async tagScene(input) {
101
+ const supportsAudio = this.provider === "gemini";
102
+ const hasAudio = supportsAudio && Boolean(input.audioPath);
103
+ const prompt = buildTaggingPrompt(input.transcript, this.taxonomyVersion, hasAudio, input.guidancePrompt);
104
+ const images = await Promise.all(input.keyframePaths.map((p) => readFile(p)));
105
+ const audio = hasAudio && input.audioPath ? await readFile(input.audioPath).catch(() => null) : null;
106
+ try {
107
+ const { object } = await generateObject({
108
+ model: this.tagModel,
109
+ schema: this.tagSchema(),
110
+ maxOutputTokens: this.provider === "openai" ? 1500 : 500,
111
+ providerOptions: this.taggingProviderOptions(),
112
+ messages: [
113
+ {
114
+ role: "user",
115
+ content: [
116
+ { type: "text", text: prompt },
117
+ ...images.map((image) => this.imagePart(image)),
118
+ ...(audio ? [{ type: "file", data: audio, mediaType: "audio/mpeg" }] : [])
119
+ ]
120
+ }
121
+ ]
122
+ });
123
+ const sanitized = sanitizeTagValues(object, this.taxonomyVersion);
124
+ const tags = {
125
+ ...sanitized,
126
+ transcript: (input.transcript || object.transcript || "").trim()
127
+ };
128
+ return { tags, description: String(object.description ?? "").trim() };
129
+ }
130
+ catch (error) {
131
+ const message = error instanceof Error ? error.message : String(error);
132
+ // eslint-disable-next-line no-console
133
+ console.warn(`[clips] tag failed (${this.provider}) @${input.scene.start_time_sec.toFixed(1)}s: ${message}`);
134
+ return {
135
+ tags: {
136
+ subject: [],
137
+ action: [],
138
+ emotion: [],
139
+ setting: [],
140
+ composition: [],
141
+ motion: [],
142
+ energy: "medium",
143
+ audio_type: input.transcript ? ["dialogue"] : ["silence"],
144
+ transcript: input.transcript || ""
145
+ },
146
+ description: input.transcript ? input.transcript.slice(0, 120) : ""
147
+ };
148
+ }
149
+ }
150
+ taggingProviderOptions() {
151
+ if (this.provider === "gemini") {
152
+ return { google: { mediaResolution: "MEDIA_RESOLUTION_LOW", structuredOutputs: true } };
153
+ }
154
+ return undefined;
155
+ }
156
+ imagePart(image) {
157
+ // Low image detail on OpenAI keeps the token cost near the Gemini low-res target.
158
+ if (this.provider === "openai") {
159
+ return { type: "image", image, providerOptions: { openai: { imageDetail: "low" } } };
160
+ }
161
+ return { type: "image", image };
162
+ }
163
+ async embedDocuments(texts) {
164
+ return this.embed(texts, "RETRIEVAL_DOCUMENT");
165
+ }
166
+ async embedQuery(text) {
167
+ const [vec] = await this.embed([text], "RETRIEVAL_QUERY");
168
+ return vec;
169
+ }
170
+ /** Batch-embed many retrieval queries (e.g. one per scene annotation) in a single call. */
171
+ async embedQueries(texts) {
172
+ return this.embed(texts, "RETRIEVAL_QUERY");
173
+ }
174
+ async embed(texts, taskType) {
175
+ if (texts.length === 0 || !this.embedModel)
176
+ return [];
177
+ const providerOptions = this.embeddingProvider === "gemini"
178
+ ? { google: { taskType, outputDimensionality: EMBEDDING_DIM } }
179
+ : { openai: { dimensions: EMBEDDING_DIM } };
180
+ const { embeddings } = await embedMany({
181
+ model: this.embedModel,
182
+ values: texts.map((t) => (t.trim() ? t.trim() : " ")),
183
+ providerOptions
184
+ });
185
+ return embeddings.map((e) => normalize(e));
186
+ }
187
+ /** Convert a natural-language query into a structured SplicingCriteria (categoricals constrained to the taxonomy). */
188
+ async naturalLanguageToCriteria(query) {
189
+ const cats = getTaxonomy(this.taxonomyVersion).categories;
190
+ const opt = (values) => z.array(z.enum(values)).optional();
191
+ const schema = z.object({
192
+ subject: opt(cats.subject),
193
+ action: opt(cats.action),
194
+ emotion: opt(cats.emotion),
195
+ setting: opt(cats.setting),
196
+ composition: opt(cats.composition),
197
+ motion: opt(cats.motion),
198
+ audio_type: opt(cats.audio_type),
199
+ energy: z.array(z.enum(cats.energy)).optional(),
200
+ min_duration_sec: z.number().optional(),
201
+ max_duration_sec: z.number().optional(),
202
+ has_dialogue: z.boolean().optional(),
203
+ has_subject: z.boolean().optional()
204
+ });
205
+ try {
206
+ const { object } = await generateObject({
207
+ model: this.tagModel,
208
+ schema,
209
+ maxOutputTokens: this.provider === "openai" ? 1200 : 300,
210
+ prompt: buildQueryPrompt(query, this.taxonomyVersion)
211
+ });
212
+ return { ...pruneUndefined(object), semantic_text: query };
213
+ }
214
+ catch {
215
+ return { semantic_text: query };
216
+ }
217
+ }
218
+ }
219
+ /** Back-compat alias — the client used to be Gemini-only. */
220
+ export const ClipGeminiClient = ClipModelClient;
221
+ function buildLanguageModel(provider, apiKey, modelId, publicBaseUrl) {
222
+ switch (provider) {
223
+ case "openai":
224
+ return createOpenAI({ apiKey })(modelId);
225
+ case "openrouter":
226
+ return createOpenAICompatible({
227
+ name: "openrouter",
228
+ apiKey,
229
+ baseURL: "https://openrouter.ai/api/v1",
230
+ headers: { "HTTP-Referer": publicBaseUrl ?? "https://vidfarm.cc", "X-Title": "Vidfarm Clips" }
231
+ }).chatModel(modelId);
232
+ case "gemini":
233
+ default:
234
+ return createGoogleGenerativeAI({ apiKey })(modelId);
235
+ }
236
+ }
237
+ function buildEmbeddingModel(provider, apiKey, modelId) {
238
+ return provider === "openai"
239
+ ? createOpenAI({ apiKey }).textEmbeddingModel(modelId)
240
+ : createGoogleGenerativeAI({ apiKey }).textEmbeddingModel(modelId);
241
+ }
242
+ function resolveEmbeddingConfig(provider, apiKey, explicit) {
243
+ if (explicit === null)
244
+ return null; // caller explicitly disabled embeddings
245
+ if (explicit)
246
+ return explicit;
247
+ if (provider === "gemini")
248
+ return { provider: "gemini", apiKey };
249
+ if (provider === "openai")
250
+ return { provider: "openai", apiKey };
251
+ return null; // openrouter: no embeddings unless an explicit config is given
252
+ }
253
+ /** The document text embedded for a clip: description + transcript + salient tags. */
254
+ export function buildClipEmbeddingText(tags, description) {
255
+ const parts = [];
256
+ if (description)
257
+ parts.push(description);
258
+ if (tags.transcript)
259
+ parts.push(`Transcript: ${tags.transcript}`);
260
+ const tagBits = [
261
+ ...tags.subject,
262
+ ...tags.action,
263
+ ...tags.emotion,
264
+ ...tags.setting,
265
+ ...tags.composition,
266
+ ...tags.motion,
267
+ ...tags.audio_type,
268
+ `energy:${tags.energy}`
269
+ ];
270
+ if (tagBits.length)
271
+ parts.push(tagBits.join(", "));
272
+ return parts.join("\n").trim() || "video clip";
273
+ }
274
+ function buildTaggingPrompt(transcript, version, hasAudio = false, guidancePrompt) {
275
+ const speech = transcript?.trim()
276
+ ? `The scene's spoken audio (ASR transcript) is: "${transcript.trim()}"`
277
+ : hasAudio
278
+ ? `An audio clip of the scene is attached — transcribe any speech into the transcript field (use "" if there is no speech).`
279
+ : `There is no clear speech in this scene (no dialogue).`;
280
+ const guidance = guidancePrompt?.trim()
281
+ ? `\nThe user is curating clips for this goal: "${guidancePrompt.trim()}". Bias the description and tags toward what matters for that goal (stay accurate — never invent tags not visible in the scene).\n`
282
+ : "";
283
+ return [
284
+ "You are tagging one short video scene for a searchable clip library.",
285
+ "You are given 1-3 keyframes from the scene and its audio transcript.",
286
+ speech,
287
+ guidance,
288
+ "",
289
+ "Return JSON matching the schema. Use ONLY values from this controlled vocabulary:",
290
+ taxonomyForPrompt(version),
291
+ "",
292
+ "Rules:",
293
+ "- Pick only tags you are confident about from the keyframes/audio. Omit a category (empty list) rather than guess.",
294
+ "- If there is no clear person/subject, use subject: [\"no_subject\"].",
295
+ "- If there is no speech, set audio_type appropriately (e.g. music/ambient/silence) and leave transcript empty.",
296
+ "- `energy` is a single value: low | medium | high.",
297
+ "- `transcript` should echo the spoken words verbatim (or \"\" if none).",
298
+ "- `description`: one short factual sentence (<= 20 words) describing what happens — this drives semantic search, so be concrete.",
299
+ "Keep the whole response under ~200 tokens."
300
+ ].join("\n");
301
+ }
302
+ function buildQueryPrompt(query, version) {
303
+ return [
304
+ "Convert this clip-search request into a structured filter.",
305
+ `Request: "${query}"`,
306
+ "",
307
+ "Use ONLY values from this controlled vocabulary (omit a field if the request doesn't constrain it):",
308
+ taxonomyForPrompt(version),
309
+ "",
310
+ "Only include fields the request clearly implies. Prefer fewer, high-precision constraints.",
311
+ "Set has_dialogue/has_subject only when the request is explicit about speech or a subject.",
312
+ "Durations are in seconds."
313
+ ].join("\n");
314
+ }
315
+ function pruneUndefined(obj) {
316
+ const out = {};
317
+ for (const [k, v] of Object.entries(obj)) {
318
+ if (v === undefined)
319
+ continue;
320
+ if (Array.isArray(v) && v.length === 0)
321
+ continue;
322
+ out[k] = v;
323
+ }
324
+ return out;
325
+ }
326
+ /** L2-normalize a vector so cosine == dot product (and reduced-dim embeddings compare correctly). */
327
+ export function normalize(vec) {
328
+ let sum = 0;
329
+ for (const v of vec)
330
+ sum += v * v;
331
+ const norm = Math.sqrt(sum);
332
+ if (norm === 0)
333
+ return vec;
334
+ return vec.map((v) => v / norm);
335
+ }
336
+ // Legacy export kept for callers that referenced the old constant.
337
+ export const TAG_MODEL_BY_TIER = {
338
+ "flash-lite": resolveTagModel("gemini", "flash-lite"),
339
+ flash: resolveTagModel("gemini", "flash")
340
+ };
341
+ export const EMBEDDING_MODEL = EMBEDDING_MODELS.gemini;
342
+ //# sourceMappingURL=gemini.js.map
@@ -0,0 +1,15 @@
1
+ // vidfarm clip-curation core — the shared "third pillar" library consumed by
2
+ // devcli (local), the Hono API (src/app.ts), and the cloud scan Lambdas. Build
3
+ // once, use in both targets (handoff: Shared Components).
4
+ export * from "./types.js";
5
+ export { ACTIVE_TAXONOMY_VERSION, MULTI_VALUE_CATEGORIES, getTaxonomy, sanitizeTagValues, taxonomyForPrompt } from "./taxonomy.js";
6
+ export { BUILTIN_PRESETS, findBuiltinPreset } from "./presets.js";
7
+ export { ClipModelClient, ClipGeminiClient, EMBEDDING_DIM, EMBEDDING_MODEL, EMBEDDING_MODELS, TAG_MODEL_BY_TIER, buildClipEmbeddingText, normalize } from "./gemini.js";
8
+ export { detectScenes, extractClip, extractKeyframes, extractSceneAudio, extractThumbnail, hasFfmpeg, probeVideo, resolveFfmpeg, resolveFfprobe } from "./ffmpeg.js";
9
+ export { cosineSimilarity, matchesCriteria, rankHits, scoreClip, searchClips, structuredMatchRatio } from "./query.js";
10
+ export { estimateScanCostFromDuration, estimateScanCostFromScenes, formatCostEstimate } from "./cost.js";
11
+ export { processSceneToClip, scanVideo } from "./scan.js";
12
+ export { refineScenesWithGuidance } from "./refine.js";
13
+ // Re-export ClipPreset-related helpers already covered by ./presets and ./types
14
+ // via the wildcard exports above; nothing extra needed here.
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,20 @@
1
+ // Starter preset criteria library. Presets are just saved SplicingCriteria
2
+ // (see types.ts). The shipped ones are `builtin: true`; users save their own
3
+ // with `builtin: false` (persisted per-store in devcli/cloud).
4
+ import presetsV1 from "./presets.v1.json" with { type: "json" };
5
+ const SEED = presetsV1;
6
+ /** The shipped built-in presets. Frozen so callers can't mutate the shared array. */
7
+ export const BUILTIN_PRESETS = SEED.presets.map((p) => ({
8
+ preset_id: p.preset_id,
9
+ name: p.name,
10
+ description: p.description,
11
+ criteria: p.criteria,
12
+ builtin: true,
13
+ created_at: "1970-01-01T00:00:00.000Z"
14
+ }));
15
+ /** Look up a built-in preset by its id or (case-insensitive) name. */
16
+ export function findBuiltinPreset(nameOrId) {
17
+ const needle = nameOrId.trim().toLowerCase();
18
+ return BUILTIN_PRESETS.find((p) => p.preset_id.toLowerCase() === needle || p.name.toLowerCase() === needle);
19
+ }
20
+ //# sourceMappingURL=presets.js.map