@mevdragon/vidfarm-devcli 0.9.0 → 0.10.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.
@@ -101,18 +101,50 @@ export async function probeVideo(videoPath) {
101
101
  * Detect scene boundaries with the portable `select='gt(scene,T)',showinfo`
102
102
  * pass, parsing pts_time from ffmpeg's stderr. Falls back to fixed-interval
103
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.
104
+ * always yields clips. Same command shape on devcli and cloud. With
105
+ * opts.windows set, each window is detected independently and scene times are
106
+ * offset back into source time.
105
107
  */
106
108
  export async function detectScenes(videoPath, opts = {}) {
109
+ const duration = opts.durationSec ?? (await probeVideo(videoPath)).duration_sec;
110
+ if (!Number.isFinite(duration) || duration <= 0)
111
+ return [];
112
+ const windows = (opts.windows ?? [])
113
+ .map((w) => ({ start_sec: Math.max(0, w.start_sec), end_sec: Math.min(w.end_sec, duration) }))
114
+ .filter((w) => w.end_sec - w.start_sec > 0.25);
115
+ if (windows.length === 0) {
116
+ return detectScenesInSpan(videoPath, { ...opts, durationSec: duration, spanStart: 0, spanEnd: duration, indexOffset: 0 });
117
+ }
118
+ const scenes = [];
119
+ for (const window of windows) {
120
+ const detected = await detectScenesInSpan(videoPath, {
121
+ ...opts,
122
+ durationSec: duration,
123
+ spanStart: window.start_sec,
124
+ spanEnd: window.end_sec,
125
+ indexOffset: scenes.length
126
+ });
127
+ scenes.push(...detected);
128
+ }
129
+ return scenes;
130
+ }
131
+ async function detectScenesInSpan(videoPath, opts) {
107
132
  const threshold = opts.threshold ?? 0.3;
108
133
  const minScene = opts.minSceneSec ?? 0.6;
109
134
  const maxScene = opts.maxSceneSec ?? 8;
110
- const duration = opts.durationSec ?? (await probeVideo(videoPath)).duration_sec;
111
- if (!Number.isFinite(duration) || duration <= 0)
135
+ const { spanStart, spanEnd } = opts;
136
+ const spanDuration = spanEnd - spanStart;
137
+ if (!Number.isFinite(spanDuration) || spanDuration <= 0)
112
138
  return [];
113
139
  const ffmpeg = await resolveFfmpeg();
140
+ // -ss/-t BEFORE -i: input-seek so only the window is decoded. pts_time then
141
+ // restarts near 0, so cuts are offset back by spanStart below.
142
+ const seekArgs = spanStart > 0 || spanEnd < (opts.durationSec ?? Infinity)
143
+ ? ["-ss", spanStart.toFixed(3), "-t", spanDuration.toFixed(3)]
144
+ : [];
114
145
  const { stderr } = await run(ffmpeg, [
115
146
  "-hide_banner",
147
+ ...seekArgs,
116
148
  "-i", videoPath,
117
149
  "-filter:v", `select='gt(scene,${threshold})',showinfo`,
118
150
  "-f", "null",
@@ -122,19 +154,19 @@ export async function detectScenes(videoPath, opts = {}) {
122
154
  const re = /pts_time:([0-9.]+)/g;
123
155
  let m;
124
156
  while ((m = re.exec(stderr)) !== null) {
125
- const t = Number(m[1]);
126
- if (Number.isFinite(t) && t > 0 && t < duration)
157
+ const t = Number(m[1]) + (seekArgs.length ? spanStart : 0);
158
+ if (Number.isFinite(t) && t > spanStart && t < spanEnd)
127
159
  cuts.push(t);
128
160
  }
129
161
  cuts.sort((a, b) => a - b);
130
- // Build [0, cut1, cut2, …, duration] boundaries.
131
- let bounds = [0, ...cuts, duration];
162
+ // Build [spanStart, cut1, cut2, …, spanEnd] boundaries.
163
+ let bounds = [spanStart, ...cuts, spanEnd];
132
164
  // If nothing detected, chunk at maxScene.
133
165
  if (cuts.length === 0) {
134
- bounds = [0];
135
- for (let t = maxScene; t < duration; t += maxScene)
166
+ bounds = [spanStart];
167
+ for (let t = spanStart + maxScene; t < spanEnd; t += maxScene)
136
168
  bounds.push(t);
137
- bounds.push(duration);
169
+ bounds.push(spanEnd);
138
170
  }
139
171
  // Merge boundaries closer than minScene.
140
172
  const merged = [bounds[0]];
@@ -155,11 +187,11 @@ export async function detectScenes(videoPath, opts = {}) {
155
187
  for (let c = 0; c < chunks; c++) {
156
188
  const s = start + c * step;
157
189
  const e = c === chunks - 1 ? end : s + step;
158
- scenes.push(makeScene(scenes.length, s, e));
190
+ scenes.push(makeScene(opts.indexOffset + scenes.length, s, e));
159
191
  }
160
192
  }
161
193
  else {
162
- scenes.push(makeScene(scenes.length, start, end));
194
+ scenes.push(makeScene(opts.indexOffset + scenes.length, start, end));
163
195
  }
164
196
  }
165
197
  return scenes;
@@ -180,6 +212,7 @@ export async function extractKeyframes(input) {
180
212
  const points = count === 1
181
213
  ? [start + dur / 2]
182
214
  : Array.from({ length: count }, (_, i) => start + dur * ((i + 1) / (count + 1)));
215
+ const vf = input.cropFilter ? `${input.cropFilter},scale=${width}:-2` : `scale=${width}:-2`;
183
216
  const outPaths = [];
184
217
  for (let i = 0; i < points.length; i++) {
185
218
  const out = path.join(input.outDir, `scene-${input.scene.index}-kf${i}.jpg`);
@@ -188,7 +221,7 @@ export async function extractKeyframes(input) {
188
221
  "-ss", points[i].toFixed(3),
189
222
  "-i", input.videoPath,
190
223
  "-frames:v", "1",
191
- "-vf", `scale=${width}:-2`,
224
+ "-vf", vf,
192
225
  "-q:v", "5",
193
226
  out
194
227
  ]);
@@ -202,32 +235,41 @@ export async function extractClip(input) {
202
235
  mkdirSync(path.dirname(input.outPath), { recursive: true });
203
236
  const ffmpeg = await resolveFfmpeg();
204
237
  const { start_time_sec: start, duration_sec: dur } = input.scene;
205
- const args = input.reencode === false
238
+ // A crop cannot ride on stream-copy — force the re-encode path when present.
239
+ const args = input.reencode === false && !input.cropFilter
206
240
  ? ["-y", "-ss", start.toFixed(3), "-i", input.videoPath, "-t", dur.toFixed(3), "-c", "copy", "-avoid_negative_ts", "make_zero", input.outPath]
207
241
  : [
208
242
  "-y",
209
243
  "-ss", start.toFixed(3),
210
244
  "-i", input.videoPath,
211
245
  "-t", dur.toFixed(3),
246
+ ...(input.cropFilter ? ["-vf", input.cropFilter] : []),
212
247
  "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
213
248
  "-c:a", "aac", "-b:a", "128k",
214
249
  "-movflags", "+faststart",
215
250
  input.outPath
216
251
  ];
217
- const { code } = await run(ffmpeg, args);
218
- return code === 0 && existsSync(input.outPath);
252
+ const { code, stderr } = await run(ffmpeg, args);
253
+ const ok = code === 0 && existsSync(input.outPath);
254
+ if (!ok) {
255
+ input.onError?.(`ffmpeg exit ${code}: ${stderr.slice(-500).replace(/\s+/g, " ").trim()}`);
256
+ }
257
+ return ok;
219
258
  }
220
259
  /** Single representative thumbnail for the scene. */
221
260
  export async function extractThumbnail(input) {
222
261
  mkdirSync(path.dirname(input.outPath), { recursive: true });
223
262
  const ffmpeg = await resolveFfmpeg();
224
263
  const mid = input.scene.start_time_sec + input.scene.duration_sec / 2;
264
+ const vf = input.cropFilter
265
+ ? `${input.cropFilter},scale=${input.width ?? 320}:-2`
266
+ : `scale=${input.width ?? 320}:-2`;
225
267
  const { code } = await run(ffmpeg, [
226
268
  "-y",
227
269
  "-ss", mid.toFixed(3),
228
270
  "-i", input.videoPath,
229
271
  "-frames:v", "1",
230
- "-vf", `scale=${input.width ?? 320}:-2`,
272
+ "-vf", vf,
231
273
  "-q:v", "3",
232
274
  input.outPath
233
275
  ]);
@@ -38,6 +38,34 @@ export const EMBEDDING_MODELS = {
38
38
  // a library should stay on one embedding model (callers store embedding_model
39
39
  // and warn on mismatch).
40
40
  export const EMBEDDING_DIM = Number(process.env.VIDFARM_CLIP_EMBEDDING_DIM ?? 768);
41
+ /**
42
+ * Standalone embedding surface — also composed by LocalAgentClipClient so a
43
+ * local-agent scan can still build a semantically searchable library when the
44
+ * user has a gemini/openai key lying around for embeddings only.
45
+ */
46
+ export class ClipEmbeddingClient {
47
+ provider;
48
+ modelId;
49
+ model;
50
+ constructor(config) {
51
+ this.provider = config.provider;
52
+ this.modelId = EMBEDDING_MODELS[config.provider];
53
+ this.model = buildEmbeddingModel(config.provider, config.apiKey, this.modelId);
54
+ }
55
+ async embed(texts, taskType) {
56
+ if (texts.length === 0)
57
+ return [];
58
+ const providerOptions = this.provider === "gemini"
59
+ ? { google: { taskType, outputDimensionality: EMBEDDING_DIM } }
60
+ : { openai: { dimensions: EMBEDDING_DIM } };
61
+ const { embeddings } = await embedMany({
62
+ model: this.model,
63
+ values: texts.map((t) => (t.trim() ? t.trim() : " ")),
64
+ providerOptions
65
+ });
66
+ return embeddings.map((e) => normalize(e));
67
+ }
68
+ }
41
69
  export class ClipModelClient {
42
70
  provider;
43
71
  tier;
@@ -46,7 +74,7 @@ export class ClipModelClient {
46
74
  embeddingProvider;
47
75
  embeddingModelId;
48
76
  tagModel;
49
- embedModel;
77
+ embedder;
50
78
  constructor(opts) {
51
79
  if (!opts.apiKey)
52
80
  throw new Error(`A ${opts.provider ?? "gemini"} API key is required.`);
@@ -57,19 +85,19 @@ export class ClipModelClient {
57
85
  this.tagModel = buildLanguageModel(this.provider, opts.apiKey, this.tagModelId, opts.publicBaseUrl);
58
86
  const embedding = resolveEmbeddingConfig(this.provider, opts.apiKey, opts.embedding);
59
87
  if (embedding) {
88
+ this.embedder = new ClipEmbeddingClient(embedding);
60
89
  this.embeddingProvider = embedding.provider;
61
- this.embeddingModelId = EMBEDDING_MODELS[embedding.provider];
62
- this.embedModel = buildEmbeddingModel(embedding.provider, embedding.apiKey, this.embeddingModelId);
90
+ this.embeddingModelId = this.embedder.modelId;
63
91
  }
64
92
  else {
65
93
  this.embeddingProvider = null;
66
94
  this.embeddingModelId = null;
67
- this.embedModel = null;
95
+ this.embedder = null;
68
96
  }
69
97
  }
70
98
  /** True when this client can produce embeddings (semantic search available). */
71
99
  get canEmbed() {
72
- return this.embedModel !== null;
100
+ return this.embedder !== null;
73
101
  }
74
102
  /** The underlying tagging LanguageModel — used by the guided-segmentation pass. */
75
103
  get taggingModel() {
@@ -160,29 +188,50 @@ export class ClipModelClient {
160
188
  }
161
189
  return { type: "image", image };
162
190
  }
191
+ /**
192
+ * Keep/drop/merge decisions for prompt-guided segmentation — one multimodal
193
+ * call over one keyframe per candidate scene. Null on failure so the scan
194
+ * falls back to raw scenes.
195
+ */
196
+ async decideScenes(input) {
197
+ // No fixed-length constraint — Gemini's structured-output schema translation
198
+ // rejects minItems==maxItems; callers align by index instead.
199
+ const schema = z.object({
200
+ decisions: z.array(z.object({ keep: z.boolean(), merge_with_previous: z.boolean() }))
201
+ });
202
+ try {
203
+ const images = await Promise.all(input.imagePaths.map((p) => readFile(p)));
204
+ const { object } = await generateObject({
205
+ model: this.tagModel,
206
+ schema,
207
+ maxOutputTokens: input.maxOutputTokens,
208
+ messages: [
209
+ {
210
+ role: "user",
211
+ content: [{ type: "text", text: input.prompt }, ...images.map((image) => ({ type: "image", image }))]
212
+ }
213
+ ]
214
+ });
215
+ return object.decisions;
216
+ }
217
+ catch (error) {
218
+ if (process.env.VIDFARM_DEBUG_LOGS === "true") {
219
+ // eslint-disable-next-line no-console
220
+ console.warn(`[clips] refine call failed: ${error instanceof Error ? error.message : String(error)}`);
221
+ }
222
+ return null;
223
+ }
224
+ }
163
225
  async embedDocuments(texts) {
164
- return this.embed(texts, "RETRIEVAL_DOCUMENT");
226
+ return this.embedder ? this.embedder.embed(texts, "RETRIEVAL_DOCUMENT") : [];
165
227
  }
166
228
  async embedQuery(text) {
167
- const [vec] = await this.embed([text], "RETRIEVAL_QUERY");
229
+ const [vec] = await (this.embedder ? this.embedder.embed([text], "RETRIEVAL_QUERY") : Promise.resolve([]));
168
230
  return vec;
169
231
  }
170
232
  /** Batch-embed many retrieval queries (e.g. one per scene annotation) in a single call. */
171
233
  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));
234
+ return this.embedder ? this.embedder.embed(texts, "RETRIEVAL_QUERY") : [];
186
235
  }
187
236
  /** Convert a natural-language query into a structured SplicingCriteria (categoricals constrained to the taxonomy). */
188
237
  async naturalLanguageToCriteria(query) {
@@ -271,7 +320,7 @@ export function buildClipEmbeddingText(tags, description) {
271
320
  parts.push(tagBits.join(", "));
272
321
  return parts.join("\n").trim() || "video clip";
273
322
  }
274
- function buildTaggingPrompt(transcript, version, hasAudio = false, guidancePrompt) {
323
+ export function buildTaggingPrompt(transcript, version, hasAudio = false, guidancePrompt) {
275
324
  const speech = transcript?.trim()
276
325
  ? `The scene's spoken audio (ASR transcript) is: "${transcript.trim()}"`
277
326
  : hasAudio
@@ -299,7 +348,7 @@ function buildTaggingPrompt(transcript, version, hasAudio = false, guidancePromp
299
348
  "Keep the whole response under ~200 tokens."
300
349
  ].join("\n");
301
350
  }
302
- function buildQueryPrompt(query, version) {
351
+ export function buildQueryPrompt(query, version) {
303
352
  return [
304
353
  "Convert this clip-search request into a structured filter.",
305
354
  `Request: "${query}"`,
@@ -0,0 +1,332 @@
1
+ // Clip-hunt spec — the user-facing controls for turning long-form video into
2
+ // short-form clips: time WINDOWS ("only hunt between 12:30 and 15:45"), a soft
3
+ // target DURATION band ("30 sec clips" → 20–40s), an output ASPECT crop
4
+ // ("vertical video of people holding food to their face"), and an avoid-text
5
+ // constraint ("without captions / no on-screen text" — a scene-SELECTION filter,
6
+ // NEVER GhostCut on the long-form source). Shared by devcli (local), the Hono
7
+ // API, and the cloud scan Lambda so every surface parses and normalizes
8
+ // identically. All helpers are pure/deterministic — no AI calls here.
9
+ // ── Duration band ───────────────────────────────────────────────────────────
10
+ /**
11
+ * Map a spoken target ("10 sec", "30 sec", "60 sec", "120 sec") to a soft
12
+ * range. Anchors from product: 10 → 5–20, 30 → 20–40; the same formula extends
13
+ * smoothly (60 → 40–80, 120 → 80–160): ± target/3, with a minimum slack of
14
+ * −5s / +10s so short targets keep a usable band.
15
+ */
16
+ export function resolveDurationBand(targetSec) {
17
+ if (!Number.isFinite(targetSec) || targetSec <= 0)
18
+ return null;
19
+ const target = Math.max(3, Math.min(600, targetSec));
20
+ const minSec = Math.max(2, round1(target - Math.max(target / 3, 5)));
21
+ const maxSec = round1(target + Math.max(target / 3, 10));
22
+ return { min_sec: minSec, max_sec: maxSec, target_sec: round1(target) };
23
+ }
24
+ // ── Timecodes & windows ─────────────────────────────────────────────────────
25
+ /** "1:02:03(.5)" | "12:30" | "95" | "95.5" → seconds (null when unparseable). */
26
+ export function parseTimecode(text) {
27
+ const trimmed = (text ?? "").trim();
28
+ if (!trimmed)
29
+ return null;
30
+ const parts = trimmed.split(":");
31
+ if (parts.length > 3)
32
+ return null;
33
+ let seconds = 0;
34
+ for (const part of parts) {
35
+ if (!/^\d+(\.\d+)?$/.test(part))
36
+ return null;
37
+ seconds = seconds * 60 + Number(part);
38
+ }
39
+ return Number.isFinite(seconds) ? seconds : null;
40
+ }
41
+ /**
42
+ * Parse "12:30-15:45, 20:00-22:10" (also "–" and "to" separators, or
43
+ * {start,end} objects / [start,end] tuples) into windows. Invalid entries are
44
+ * dropped rather than failing the scan.
45
+ */
46
+ export function parseTimeRanges(input) {
47
+ const out = [];
48
+ const push = (start, end) => {
49
+ if (start == null || end == null)
50
+ return;
51
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start)
52
+ return;
53
+ out.push({ start_sec: round3(Math.max(0, start)), end_sec: round3(end) });
54
+ };
55
+ const parseOne = (value) => {
56
+ if (typeof value === "string") {
57
+ const m = value.trim().match(/^(.+?)\s*(?:-|–|—|\bto\b)\s*(.+)$/i);
58
+ if (m)
59
+ push(parseTimecode(m[1]), parseTimecode(m[2]));
60
+ return;
61
+ }
62
+ if (Array.isArray(value) && value.length === 2) {
63
+ push(toSeconds(value[0]), toSeconds(value[1]));
64
+ return;
65
+ }
66
+ if (value && typeof value === "object") {
67
+ const rec = value;
68
+ push(toSeconds(rec.start_sec ?? rec.start ?? rec.from), toSeconds(rec.end_sec ?? rec.end ?? rec.to));
69
+ }
70
+ };
71
+ if (typeof input === "string") {
72
+ for (const piece of input.split(/[,;]+/))
73
+ parseOne(piece);
74
+ }
75
+ else if (Array.isArray(input)) {
76
+ for (const piece of input)
77
+ parseOne(piece);
78
+ }
79
+ else if (input != null) {
80
+ parseOne(input);
81
+ }
82
+ return out;
83
+ }
84
+ function toSeconds(value) {
85
+ if (typeof value === "number")
86
+ return Number.isFinite(value) ? value : null;
87
+ if (typeof value === "string")
88
+ return parseTimecode(value);
89
+ return null;
90
+ }
91
+ /** Clamp to [0, durationSec], drop empties, sort, and merge overlaps. */
92
+ export function normalizeWindows(windows, durationSec) {
93
+ if (!windows?.length)
94
+ return [];
95
+ const clamped = windows
96
+ .map((w) => ({
97
+ start_sec: Math.max(0, w.start_sec),
98
+ end_sec: durationSec && durationSec > 0 ? Math.min(w.end_sec, durationSec) : w.end_sec
99
+ }))
100
+ .filter((w) => Number.isFinite(w.start_sec) && Number.isFinite(w.end_sec) && w.end_sec - w.start_sec > 0.25)
101
+ .sort((a, b) => a.start_sec - b.start_sec);
102
+ const merged = [];
103
+ for (const w of clamped) {
104
+ const prev = merged[merged.length - 1];
105
+ if (prev && w.start_sec <= prev.end_sec + 0.05) {
106
+ prev.end_sec = Math.max(prev.end_sec, w.end_sec);
107
+ }
108
+ else {
109
+ merged.push({ ...w });
110
+ }
111
+ }
112
+ return merged;
113
+ }
114
+ /** The duration the scan actually processes — the cost-estimate input. */
115
+ export function effectiveHuntDurationSec(fullDurationSec, windows) {
116
+ const normalized = normalizeWindows(windows, fullDurationSec);
117
+ if (!normalized.length)
118
+ return fullDurationSec;
119
+ return round3(normalized.reduce((acc, w) => acc + (w.end_sec - w.start_sec), 0));
120
+ }
121
+ // ── Aspect / crop ───────────────────────────────────────────────────────────
122
+ const ASPECT_SYNONYMS = {
123
+ "9:16": "9:16",
124
+ "16:9": "16:9",
125
+ "4:3": "4:3",
126
+ "1:1": "1:1",
127
+ vertical: "9:16",
128
+ portrait: "9:16",
129
+ shorts: "9:16",
130
+ reels: "9:16",
131
+ tiktok: "9:16",
132
+ horizontal: "16:9",
133
+ landscape: "16:9",
134
+ widescreen: "16:9",
135
+ square: "1:1",
136
+ original: "original"
137
+ };
138
+ export function normalizeAspect(value) {
139
+ if (typeof value !== "string")
140
+ return null;
141
+ return ASPECT_SYNONYMS[value.trim().toLowerCase()] ?? null;
142
+ }
143
+ export function normalizeCropFocus(value) {
144
+ return value === "top" || value === "bottom" || value === "left" || value === "right" ? value : "center";
145
+ }
146
+ /**
147
+ * ffmpeg -vf crop expression for the target aspect: full-height (or full-width)
148
+ * centre crop with a focus bias — "smart centre" framing; the content guidance
149
+ * drives WHICH scenes are kept, this drives how each frame is cropped. Even
150
+ * output dims keep libx264 happy. Null = no crop.
151
+ */
152
+ export function cropFilterFor(aspect, focus = "center") {
153
+ if (!aspect || aspect === "original")
154
+ return null;
155
+ const [aw, ah] = aspect.split(":").map(Number);
156
+ if (!aw || !ah)
157
+ return null;
158
+ const ratio = (aw / ah).toFixed(6);
159
+ const xf = focus === "left" ? "0.15" : focus === "right" ? "0.85" : "0.5";
160
+ // "top" biases toward faces/upper third when cropping vertically.
161
+ const yf = focus === "top" ? "0.2" : focus === "bottom" ? "0.8" : "0.5";
162
+ const w = `2*floor(min(iw\\,ih*${ratio})/2)`;
163
+ const h = `2*floor(min(ih\\,iw/${ratio})/2)`;
164
+ return `crop=w=${w}:h=${h}:x=(iw-ow)*${xf}:y=(ih-oh)*${yf}`;
165
+ }
166
+ // ── Scene fitting ───────────────────────────────────────────────────────────
167
+ /**
168
+ * Fit detected scenes to a soft duration band: greedily merge CONTIGUOUS
169
+ * scenes toward the target (never across dropped gaps — post-refine lists have
170
+ * holes and merging across them would re-include dropped footage), and split
171
+ * over-long spans into ≤ max chunks. Safe to run both before refine (fewer,
172
+ * clip-sized units to judge) and after (re-enforce max on refine merges).
173
+ */
174
+ export function fitScenesToDurationBand(scenes, band) {
175
+ if (!band || scenes.length === 0)
176
+ return scenes;
177
+ const EPS = 0.05;
178
+ const spans = [];
179
+ let cur = null;
180
+ for (const s of scenes) {
181
+ if (!cur) {
182
+ cur = { start: s.start_time_sec, end: s.end_time_sec };
183
+ continue;
184
+ }
185
+ const contiguous = Math.abs(s.start_time_sec - cur.end) <= EPS;
186
+ const curDur = cur.end - cur.start;
187
+ const nextDur = s.end_time_sec - s.start_time_sec;
188
+ const wantMore = curDur < band.min_sec || (curDur < band.target_sec && curDur + nextDur <= band.max_sec);
189
+ if (contiguous && wantMore) {
190
+ cur.end = s.end_time_sec;
191
+ continue;
192
+ }
193
+ spans.push(cur);
194
+ cur = { start: s.start_time_sec, end: s.end_time_sec };
195
+ }
196
+ if (cur)
197
+ spans.push(cur);
198
+ const fitted = [];
199
+ for (const span of spans) {
200
+ const dur = span.end - span.start;
201
+ if (dur > band.max_sec) {
202
+ const chunks = Math.ceil(dur / band.max_sec);
203
+ const step = dur / chunks;
204
+ for (let i = 0; i < chunks; i++) {
205
+ const start = span.start + i * step;
206
+ const end = i === chunks - 1 ? span.end : start + step;
207
+ fitted.push(makeScene(fitted.length, start, end));
208
+ }
209
+ }
210
+ else {
211
+ fitted.push(makeScene(fitted.length, span.start, span.end));
212
+ }
213
+ }
214
+ // Orphan slivers (isolated scenes far below the band) are noise when the user
215
+ // asked for e.g. 30s clips — drop them unless they are all we have.
216
+ const solid = fitted.filter((s) => s.duration_sec >= band.min_sec / 2);
217
+ const kept = solid.length > 0 ? solid : fitted;
218
+ return kept.map((s, i) => ({ ...s, index: i }));
219
+ }
220
+ // ── Guidance composition ────────────────────────────────────────────────────
221
+ export const AVOID_TEXT_GUIDANCE = "Only keep scenes with NO burned-in captions, subtitles, watermark text, or on-screen text overlays; drop any scene whose frame shows readable overlay text.";
222
+ /**
223
+ * Compose the effective guidance prompt for the refine (keep/drop/merge) and
224
+ * tagging passes: the user's content intent plus the avoid-text constraint.
225
+ * avoid_text is deliberately a SELECTION filter — never caption removal
226
+ * (GhostCut) on the long-form source.
227
+ */
228
+ export function buildEffectiveGuidance(input) {
229
+ const parts = [];
230
+ if (input.contentPrompt?.trim())
231
+ parts.push(input.contentPrompt.trim());
232
+ if (input.avoidText)
233
+ parts.push(AVOID_TEXT_GUIDANCE);
234
+ return parts.join("\n");
235
+ }
236
+ /**
237
+ * Deterministically extract structured hints from a free-form hunt prompt:
238
+ * "please only hunt for clips between 12:30 and 15:45", "short 10 sec clips",
239
+ * "vertical video … without captions". Structured request fields always take
240
+ * precedence; this only fills gaps. The prompt itself stays intact as content
241
+ * guidance.
242
+ */
243
+ export function parseClipHuntPrompt(prompt) {
244
+ const text = (prompt ?? "").trim();
245
+ const result = { windows: [], target_duration_sec: null, aspect: null, avoid_text: false };
246
+ if (!text)
247
+ return result;
248
+ const tc = "\\d{1,2}:\\d{2}(?::\\d{2})?(?:\\.\\d+)?";
249
+ // "between 12:30 and 15:45" / "from 12:30 to 15:45" / bare "12:30-15:45"
250
+ const rangeRe = new RegExp(`(?:between|from)?\\s*(${tc})\\s*(?:-|–|—|to|and|until)\\s*(${tc})`, "gi");
251
+ let m;
252
+ while ((m = rangeRe.exec(text)) !== null) {
253
+ const start = parseTimecode(m[1]);
254
+ const end = parseTimecode(m[2]);
255
+ if (start != null && end != null && end > start) {
256
+ result.windows.push({ start_sec: round3(start), end_sec: round3(end) });
257
+ }
258
+ }
259
+ const secMatch = /(\d{1,3}(?:\.\d+)?)\s*-?\s*(?:sec(?:ond)?s?)\b/i.exec(text);
260
+ const minMatch = /(\d{1,2}(?:\.\d+)?)\s*-?\s*(?:min(?:ute)?s?)\b(?!\s*(?:mark|point))/i.exec(text);
261
+ if (secMatch) {
262
+ result.target_duration_sec = Number(secMatch[1]);
263
+ }
264
+ else if (minMatch && /clip/i.test(text)) {
265
+ // "2 minute clips" — only treat minutes as a duration when clips are the topic.
266
+ result.target_duration_sec = Number(minMatch[1]) * 60;
267
+ }
268
+ const aspectToken = /(\d{1,2}:\d{1,2})\s*(?:video|clip|ratio|aspect|crop)/i.exec(text)?.[1];
269
+ result.aspect =
270
+ normalizeAspect(aspectToken) ??
271
+ (/(vertical|portrait)\b/i.test(text) ? "9:16"
272
+ : /(horizontal|landscape|widescreen)\b/i.test(text) ? "16:9"
273
+ : /\bsquare\b/i.test(text) ? "1:1"
274
+ : /\b4:3\b/.test(text) ? "4:3"
275
+ : null);
276
+ result.avoid_text =
277
+ /(without|no)\s+(?:any\s+)?(captions?|subtitles?|(?:on[- ]?screen\s+)?text)/i.test(text) ||
278
+ /\b(caption|text)-free\b/i.test(text) ||
279
+ /no\s+text\s+on\s+screen/i.test(text);
280
+ return result;
281
+ }
282
+ // ── Wire normalization (API / SFN / tool payloads) ──────────────────────────
283
+ /**
284
+ * Normalize an untrusted hunt-spec-shaped payload (HTTP body, tool args, SFN
285
+ * input) into a clean ClipHuntSpec. Unknown/invalid fields drop out silently.
286
+ */
287
+ export function normalizeHuntSpec(raw) {
288
+ if (!raw || typeof raw !== "object")
289
+ return {};
290
+ const rec = raw;
291
+ const spec = {};
292
+ const windows = parseTimeRanges(rec.windows ?? rec.ranges ?? rec.time_ranges);
293
+ if (windows.length)
294
+ spec.windows = windows;
295
+ const band = rec.duration_band;
296
+ if (band && typeof band === "object" && Number(band.min_sec) > 0 && Number(band.max_sec) > Number(band.min_sec)) {
297
+ spec.duration_band = {
298
+ min_sec: Number(band.min_sec),
299
+ max_sec: Number(band.max_sec),
300
+ target_sec: Number(band.target_sec) > 0 ? Number(band.target_sec) : (Number(band.min_sec) + Number(band.max_sec)) / 2
301
+ };
302
+ }
303
+ else {
304
+ const target = Number(rec.target_duration_sec ?? rec.duration_sec ?? rec.clip_duration_sec);
305
+ const resolved = resolveDurationBand(target);
306
+ if (resolved)
307
+ spec.duration_band = resolved;
308
+ }
309
+ const aspect = normalizeAspect(rec.aspect ?? rec.aspect_ratio ?? rec.orientation);
310
+ if (aspect)
311
+ spec.aspect = aspect;
312
+ if (rec.crop_focus != null)
313
+ spec.crop_focus = normalizeCropFocus(rec.crop_focus);
314
+ if (rec.avoid_text === true || rec.no_text === true || rec.no_captions === true)
315
+ spec.avoid_text = true;
316
+ if (typeof rec.source_url === "string" && rec.source_url.trim())
317
+ spec.source_url = rec.source_url.trim();
318
+ return spec;
319
+ }
320
+ // ── misc ────────────────────────────────────────────────────────────────────
321
+ function makeScene(index, start, end) {
322
+ const s = Math.max(0, start);
323
+ const e = Math.max(s + 0.01, end);
324
+ return { index, start_time_sec: round3(s), end_time_sec: round3(e), duration_sec: round3(e - s) };
325
+ }
326
+ function round1(n) {
327
+ return Math.round(n * 10) / 10;
328
+ }
329
+ function round3(n) {
330
+ return Math.round(n * 1000) / 1000;
331
+ }
332
+ //# sourceMappingURL=hunt.js.map
@@ -4,7 +4,9 @@
4
4
  export * from "./types.js";
5
5
  export { ACTIVE_TAXONOMY_VERSION, MULTI_VALUE_CATEGORIES, getTaxonomy, sanitizeTagValues, taxonomyForPrompt } from "./taxonomy.js";
6
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";
7
+ export { ClipModelClient, ClipGeminiClient, ClipEmbeddingClient, EMBEDDING_DIM, EMBEDDING_MODEL, EMBEDDING_MODELS, TAG_MODEL_BY_TIER, buildClipEmbeddingText, buildQueryPrompt, buildTaggingPrompt, normalize } from "./gemini.js";
8
+ export { LocalAgentClipClient, detectLocalAgent, extractJsonObject } from "./local-agent.js";
9
+ export { AVOID_TEXT_GUIDANCE, buildEffectiveGuidance, cropFilterFor, effectiveHuntDurationSec, fitScenesToDurationBand, normalizeAspect, normalizeCropFocus, normalizeHuntSpec, normalizeWindows, parseClipHuntPrompt, parseTimecode, parseTimeRanges, resolveDurationBand } from "./hunt.js";
8
10
  export { detectScenes, extractClip, extractKeyframes, extractSceneAudio, extractThumbnail, hasFfmpeg, probeVideo, resolveFfmpeg, resolveFfprobe } from "./ffmpeg.js";
9
11
  export { cosineSimilarity, matchesCriteria, rankHits, scoreClip, searchClips, structuredMatchRatio } from "./query.js";
10
12
  export { estimateScanCostFromDuration, estimateScanCostFromScenes, formatCostEstimate } from "./cost.js";