@mevdragon/vidfarm-devcli 0.8.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.
- package/README.md +27 -1
- package/SKILL.director.md +57 -0
- package/SKILL.platform.md +13 -1
- package/demo/dist/app.js +56 -28
- package/dist/src/account-pages-legacy.js +15 -10
- package/dist/src/app.js +678 -189
- package/dist/src/cli.js +341 -54
- package/dist/src/config.js +5 -0
- package/dist/src/devcli/clips.js +312 -49
- package/dist/src/devcli/telemetry.js +236 -0
- package/dist/src/editor-chat.js +1 -1
- package/dist/src/frontend/homepage-view.js +0 -5
- package/dist/src/frontend/template-editor-chat.js +212 -0
- package/dist/src/page-shell.js +47 -0
- package/dist/src/primitive-registry.js +7 -0
- package/dist/src/services/billing.js +3 -0
- package/dist/src/services/clip-curation/ffmpeg.js +59 -17
- package/dist/src/services/clip-curation/gemini.js +72 -23
- package/dist/src/services/clip-curation/hunt.js +332 -0
- package/dist/src/services/clip-curation/index.js +3 -1
- package/dist/src/services/clip-curation/local-agent.js +247 -0
- package/dist/src/services/clip-curation/refine.js +7 -29
- package/dist/src/services/clip-curation/scan.js +37 -10
- package/dist/src/services/hyperframes.js +22 -9
- package/dist/src/services/serverless-records.js +9 -2
- package/dist/src/services/storage.js +16 -1
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +17 -17
- package/public/assets/page-runtime-client-app.js +31 -31
|
@@ -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
|
|
111
|
-
|
|
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 >
|
|
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 [
|
|
131
|
-
let bounds = [
|
|
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 = [
|
|
135
|
-
for (let t = maxScene; t <
|
|
166
|
+
bounds = [spanStart];
|
|
167
|
+
for (let t = spanStart + maxScene; t < spanEnd; t += maxScene)
|
|
136
168
|
bounds.push(t);
|
|
137
|
-
bounds.push(
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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";
|