@mevdragon/vidfarm-devcli 0.9.0 → 0.11.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 +37 -1
- package/SKILL.director.md +123 -0
- package/SKILL.platform.md +15 -2
- package/dist/src/account-pages-legacy.js +15 -10
- package/dist/src/app.js +550 -184
- package/dist/src/cli.js +397 -14
- package/dist/src/config.js +5 -0
- package/dist/src/devcli/clips.js +312 -49
- package/dist/src/devcli/speech.js +175 -0
- package/dist/src/editor-chat.js +3 -2
- package/dist/src/frontend/homepage-view.js +0 -5
- package/dist/src/page-shell.js +47 -0
- package/dist/src/primitive-context.js +16 -0
- package/dist/src/primitive-registry.js +239 -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/local-dynamo.js +0 -0
- package/dist/src/services/provider-errors.js +74 -0
- package/dist/src/services/providers.js +30 -429
- package/dist/src/services/serverless-records.js +9 -2
- package/dist/src/services/speech.js +467 -0
- package/dist/src/services/storage.js +16 -1
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +17 -17
|
@@ -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";
|