@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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Local-agent model client — devcli-only. Runs scene tagging, prompt-guided
|
|
2
|
+
// keep/drop/merge, and NL→criteria through the user's LOCAL agent CLI
|
|
3
|
+
// subscription (claude or codex) instead of a provider API key, so `vidfarm
|
|
4
|
+
// clips scan` works out of the box on a machine that has Claude Code or Codex
|
|
5
|
+
// installed: local ffmpeg for compute, local agent for evaluation. Cloud is the
|
|
6
|
+
// explicit backup (devcli `--cloud`), never the default. Embeddings cannot ride
|
|
7
|
+
// on a CLI agent — pair with a gemini/openai key (embedding-only) or the
|
|
8
|
+
// library stays structured-searchable, the same degraded mode as
|
|
9
|
+
// openrouter-without-embed-key. NEVER constructed in Lambda.
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { ACTIVE_TAXONOMY_VERSION, getTaxonomy, sanitizeTagValues } from "./taxonomy.js";
|
|
13
|
+
import { buildQueryPrompt, buildTaggingPrompt, ClipEmbeddingClient } from "./gemini.js";
|
|
14
|
+
/** Find a usable local agent CLI on PATH. Returns null when none is installed. */
|
|
15
|
+
export function detectLocalAgent(preferred) {
|
|
16
|
+
const candidates = preferred === "claude" ? ["claude"]
|
|
17
|
+
: preferred === "codex" ? ["codex"]
|
|
18
|
+
: ["claude", "codex"];
|
|
19
|
+
for (const kind of candidates) {
|
|
20
|
+
const found = spawnSync("which", [kind], { encoding: "utf8" });
|
|
21
|
+
const bin = (found.stdout || "").trim();
|
|
22
|
+
if (found.status === 0 && bin)
|
|
23
|
+
return { kind, bin };
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export class LocalAgentClipClient {
|
|
28
|
+
provider = "local-agent";
|
|
29
|
+
tier;
|
|
30
|
+
taxonomyVersion;
|
|
31
|
+
tagModelId;
|
|
32
|
+
embeddingProvider;
|
|
33
|
+
embeddingModelId;
|
|
34
|
+
kind;
|
|
35
|
+
bin;
|
|
36
|
+
embedder;
|
|
37
|
+
timeoutMs;
|
|
38
|
+
constructor(opts = {}) {
|
|
39
|
+
const detected = opts.bin
|
|
40
|
+
? { kind: (opts.agent === "codex" ? "codex" : "claude"), bin: opts.bin }
|
|
41
|
+
: detectLocalAgent(opts.agent === "auto" ? undefined : opts.agent);
|
|
42
|
+
if (!detected) {
|
|
43
|
+
throw new Error("No local agent CLI found. Install Claude Code (`claude`) or Codex (`codex`), pass a provider key (--gemini-key / GEMINI_API_KEY), or run with --cloud.");
|
|
44
|
+
}
|
|
45
|
+
this.kind = detected.kind;
|
|
46
|
+
this.bin = detected.bin;
|
|
47
|
+
this.tagModelId = `${this.kind}-cli`;
|
|
48
|
+
this.tier = opts.tier ?? "flash-lite";
|
|
49
|
+
this.taxonomyVersion = opts.taxonomyVersion ?? ACTIVE_TAXONOMY_VERSION;
|
|
50
|
+
this.embedder = opts.embedding ? new ClipEmbeddingClient(opts.embedding) : null;
|
|
51
|
+
this.embeddingProvider = this.embedder ? this.embedder.provider : null;
|
|
52
|
+
this.embeddingModelId = this.embedder ? this.embedder.modelId : null;
|
|
53
|
+
this.timeoutMs = Math.max(30_000, opts.timeoutMs ?? 300_000);
|
|
54
|
+
}
|
|
55
|
+
get canEmbed() {
|
|
56
|
+
return this.embedder !== null;
|
|
57
|
+
}
|
|
58
|
+
/** Tag one scene: the agent Reads the keyframe files itself, then emits the tag JSON. */
|
|
59
|
+
async tagScene(input) {
|
|
60
|
+
// No audio path — CLI agents can't transcribe the mp3; transcript stays
|
|
61
|
+
// whatever the caller supplied (usually empty on local-agent scans).
|
|
62
|
+
const base = buildTaggingPrompt(input.transcript, this.taxonomyVersion, false, input.guidancePrompt);
|
|
63
|
+
const prompt = [
|
|
64
|
+
base,
|
|
65
|
+
"",
|
|
66
|
+
`The scene's keyframe image files are on disk — open and inspect EVERY one of these ${input.keyframePaths.length} file(s) before answering:`,
|
|
67
|
+
...input.keyframePaths.map((p) => `- ${p}`),
|
|
68
|
+
"",
|
|
69
|
+
"Respond with ONLY the JSON object (no prose, no markdown fence) with keys:",
|
|
70
|
+
`subject, action, emotion, setting, composition, motion (string arrays), energy (string), audio_type (string array), transcript (string), description (string).`
|
|
71
|
+
].join("\n");
|
|
72
|
+
try {
|
|
73
|
+
const raw = await this.runAgent(prompt);
|
|
74
|
+
const parsed = extractJsonObject(raw);
|
|
75
|
+
if (!parsed)
|
|
76
|
+
throw new Error("local agent returned no JSON");
|
|
77
|
+
const withDefaults = {
|
|
78
|
+
subject: [], action: [], emotion: [], setting: [], composition: [], motion: [],
|
|
79
|
+
energy: "medium", audio_type: [], transcript: "", description: "",
|
|
80
|
+
...parsed
|
|
81
|
+
};
|
|
82
|
+
const sanitized = sanitizeTagValues(withDefaults, this.taxonomyVersion);
|
|
83
|
+
const tags = {
|
|
84
|
+
...sanitized,
|
|
85
|
+
transcript: (input.transcript || String(withDefaults.transcript ?? "")).trim()
|
|
86
|
+
};
|
|
87
|
+
return { tags, description: String(withDefaults.description ?? "").trim() };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.warn(`[clips] tag failed (local-agent/${this.kind}) @${input.scene.start_time_sec.toFixed(1)}s: ${message}`);
|
|
93
|
+
return {
|
|
94
|
+
tags: {
|
|
95
|
+
subject: [], action: [], emotion: [], setting: [], composition: [], motion: [],
|
|
96
|
+
energy: "medium",
|
|
97
|
+
audio_type: input.transcript ? ["dialogue"] : ["silence"],
|
|
98
|
+
transcript: input.transcript || ""
|
|
99
|
+
},
|
|
100
|
+
description: input.transcript ? input.transcript.slice(0, 120) : ""
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Keep/drop/merge for prompt-guided segmentation via the local agent. */
|
|
105
|
+
async decideScenes(input) {
|
|
106
|
+
const prompt = [
|
|
107
|
+
input.prompt,
|
|
108
|
+
"",
|
|
109
|
+
`The candidate scenes' keyframe image files are on disk, in timeline order — open and inspect EVERY one of these ${input.imagePaths.length} file(s) before deciding:`,
|
|
110
|
+
...input.imagePaths.map((p, i) => `- scene ${i}: ${p}`),
|
|
111
|
+
"",
|
|
112
|
+
`Respond with ONLY a JSON object (no prose, no markdown fence): {"decisions":[{"keep":true|false,"merge_with_previous":true|false}, …]} with exactly ${input.imagePaths.length} entries in scene order.`
|
|
113
|
+
].join("\n");
|
|
114
|
+
try {
|
|
115
|
+
const raw = await this.runAgent(prompt);
|
|
116
|
+
const parsed = extractJsonObject(raw);
|
|
117
|
+
if (!parsed || !Array.isArray(parsed.decisions))
|
|
118
|
+
return null;
|
|
119
|
+
return parsed.decisions.map((d) => ({
|
|
120
|
+
keep: Boolean(d?.keep),
|
|
121
|
+
merge_with_previous: Boolean(d?.merge_with_previous)
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (process.env.VIDFARM_DEBUG_LOGS === "true") {
|
|
126
|
+
// eslint-disable-next-line no-console
|
|
127
|
+
console.warn(`[clips] local-agent refine failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async embedDocuments(texts) {
|
|
133
|
+
return this.embedder ? this.embedder.embed(texts, "RETRIEVAL_DOCUMENT") : [];
|
|
134
|
+
}
|
|
135
|
+
async embedQuery(text) {
|
|
136
|
+
if (!this.embedder)
|
|
137
|
+
return undefined;
|
|
138
|
+
const [vec] = await this.embedder.embed([text], "RETRIEVAL_QUERY");
|
|
139
|
+
return vec;
|
|
140
|
+
}
|
|
141
|
+
async embedQueries(texts) {
|
|
142
|
+
return this.embedder ? this.embedder.embed(texts, "RETRIEVAL_QUERY") : [];
|
|
143
|
+
}
|
|
144
|
+
/** NL → structured criteria (text-only, so a clean fit for a CLI agent). */
|
|
145
|
+
async naturalLanguageToCriteria(query) {
|
|
146
|
+
const prompt = [
|
|
147
|
+
buildQueryPrompt(query, this.taxonomyVersion),
|
|
148
|
+
"",
|
|
149
|
+
"Respond with ONLY the JSON object (no prose, no markdown fence)."
|
|
150
|
+
].join("\n");
|
|
151
|
+
try {
|
|
152
|
+
const raw = await this.runAgent(prompt);
|
|
153
|
+
const parsed = extractJsonObject(raw);
|
|
154
|
+
if (!parsed)
|
|
155
|
+
return { semantic_text: query };
|
|
156
|
+
return { ...sanitizeCriteria(parsed, this.taxonomyVersion), semantic_text: query };
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return { semantic_text: query };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// ── CLI plumbing ──────────────────────────────────────────────────────────
|
|
163
|
+
runAgent(prompt) {
|
|
164
|
+
const args = this.kind === "claude"
|
|
165
|
+
? [
|
|
166
|
+
"-p", prompt,
|
|
167
|
+
"--output-format", "text",
|
|
168
|
+
// Reading N keyframes = N tool turns; leave headroom.
|
|
169
|
+
"--max-turns", "50",
|
|
170
|
+
"--allowedTools", "Read",
|
|
171
|
+
...(process.env.VIDFARM_LOCAL_AGENT_MODEL ? ["--model", process.env.VIDFARM_LOCAL_AGENT_MODEL] : [])
|
|
172
|
+
]
|
|
173
|
+
: [
|
|
174
|
+
"exec",
|
|
175
|
+
"--skip-git-repo-check",
|
|
176
|
+
...(process.env.VIDFARM_LOCAL_AGENT_MODEL ? ["--model", process.env.VIDFARM_LOCAL_AGENT_MODEL] : []),
|
|
177
|
+
prompt
|
|
178
|
+
];
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const child = spawn(this.bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
181
|
+
let stdout = "";
|
|
182
|
+
let stderr = "";
|
|
183
|
+
const timer = setTimeout(() => {
|
|
184
|
+
child.kill("SIGKILL");
|
|
185
|
+
reject(new Error(`${this.kind} CLI timed out after ${Math.round(this.timeoutMs / 1000)}s`));
|
|
186
|
+
}, this.timeoutMs);
|
|
187
|
+
timer.unref?.();
|
|
188
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
189
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
190
|
+
child.on("error", (error) => {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
reject(error);
|
|
193
|
+
});
|
|
194
|
+
child.on("close", (code) => {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
if (code === 0)
|
|
197
|
+
resolve(stdout);
|
|
198
|
+
else
|
|
199
|
+
reject(new Error(`${this.kind} CLI exited ${code}: ${(stderr || stdout).slice(0, 400)}`));
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Pull the first parseable JSON object out of agent output (fenced block, or outermost braces). */
|
|
205
|
+
export function extractJsonObject(text) {
|
|
206
|
+
const trimmed = (text ?? "").trim();
|
|
207
|
+
if (!trimmed)
|
|
208
|
+
return null;
|
|
209
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
|
|
210
|
+
for (const candidate of [fenced?.[1], trimmed]) {
|
|
211
|
+
if (!candidate)
|
|
212
|
+
continue;
|
|
213
|
+
const start = candidate.indexOf("{");
|
|
214
|
+
const end = candidate.lastIndexOf("}");
|
|
215
|
+
if (start < 0 || end <= start)
|
|
216
|
+
continue;
|
|
217
|
+
try {
|
|
218
|
+
return JSON.parse(candidate.slice(start, end + 1));
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
/* try the next candidate */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
/** Constrain agent-produced criteria to the controlled vocabulary + known fields. */
|
|
227
|
+
function sanitizeCriteria(raw, version) {
|
|
228
|
+
const cats = getTaxonomy(version).categories;
|
|
229
|
+
const out = {};
|
|
230
|
+
const arrayFields = ["subject", "action", "emotion", "setting", "composition", "motion", "audio_type", "energy"];
|
|
231
|
+
for (const field of arrayFields) {
|
|
232
|
+
const allowed = new Set(cats[field] ?? []);
|
|
233
|
+
const values = Array.isArray(raw[field]) ? raw[field].map(String).filter((v) => allowed.has(v)) : [];
|
|
234
|
+
if (values.length)
|
|
235
|
+
out[field] = values;
|
|
236
|
+
}
|
|
237
|
+
if (Number.isFinite(Number(raw.min_duration_sec)))
|
|
238
|
+
out.min_duration_sec = Number(raw.min_duration_sec);
|
|
239
|
+
if (Number.isFinite(Number(raw.max_duration_sec)))
|
|
240
|
+
out.max_duration_sec = Number(raw.max_duration_sec);
|
|
241
|
+
if (typeof raw.has_dialogue === "boolean")
|
|
242
|
+
out.has_dialogue = raw.has_dialogue;
|
|
243
|
+
if (typeof raw.has_subject === "boolean")
|
|
244
|
+
out.has_subject = raw.has_subject;
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
//# sourceMappingURL=local-agent.js.map
|
|
@@ -6,10 +6,7 @@
|
|
|
6
6
|
// then rebuild the scene list from those decisions. Falls back to the raw scenes
|
|
7
7
|
// on any mismatch/error so a scan never breaks.
|
|
8
8
|
import { mkdirSync, rmSync } from "node:fs";
|
|
9
|
-
import { readFile } from "node:fs/promises";
|
|
10
9
|
import path from "node:path";
|
|
11
|
-
import { generateObject } from "ai";
|
|
12
|
-
import { z } from "zod";
|
|
13
10
|
import { extractKeyframes } from "./ffmpeg.js";
|
|
14
11
|
// Sending one small keyframe per scene; cap the montage so a single call stays
|
|
15
12
|
// affordable/latency-bounded. Longer videos keep their raw segmentation (logged).
|
|
@@ -61,7 +58,6 @@ export async function refineScenesWithGuidance(input) {
|
|
|
61
58
|
}
|
|
62
59
|
}
|
|
63
60
|
async function askForDecisions(client, guidancePrompt, scenes, frames) {
|
|
64
|
-
const images = await Promise.all(frames.map((f) => readFile(f.path)));
|
|
65
61
|
const listing = scenes
|
|
66
62
|
.map((s, i) => `Scene ${i}: ${s.start_time_sec.toFixed(1)}s–${s.end_time_sec.toFixed(1)}s (${s.duration_sec.toFixed(1)}s)`)
|
|
67
63
|
.join("\n");
|
|
@@ -76,32 +72,14 @@ async function askForDecisions(client, guidancePrompt, scenes, frames) {
|
|
|
76
72
|
"",
|
|
77
73
|
`Return exactly ${scenes.length} decisions, in the same order as the scenes. Be selective — dropping off-topic scenes and merging fragmented ones is the point.`
|
|
78
74
|
].join("\n");
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
75
|
+
// The client owns the model call (API providers via structured output, the
|
|
76
|
+
// local devcli agent via CLI) — refine only builds the prompt and rebuilds
|
|
77
|
+
// the scene list from the decisions.
|
|
78
|
+
return client.decideScenes({
|
|
79
|
+
prompt,
|
|
80
|
+
imagePaths: frames.map((f) => f.path),
|
|
81
|
+
maxOutputTokens: Math.max(1000, scenes.length * 40)
|
|
83
82
|
});
|
|
84
|
-
try {
|
|
85
|
-
const { object } = await generateObject({
|
|
86
|
-
model: client.taggingModel,
|
|
87
|
-
schema,
|
|
88
|
-
maxOutputTokens: Math.max(1000, scenes.length * 40),
|
|
89
|
-
messages: [
|
|
90
|
-
{
|
|
91
|
-
role: "user",
|
|
92
|
-
content: [{ type: "text", text: prompt }, ...images.map((image) => ({ type: "image", image }))]
|
|
93
|
-
}
|
|
94
|
-
]
|
|
95
|
-
});
|
|
96
|
-
return object.decisions;
|
|
97
|
-
}
|
|
98
|
-
catch (error) {
|
|
99
|
-
if (process.env.VIDFARM_DEBUG_LOGS === "true") {
|
|
100
|
-
// eslint-disable-next-line no-console
|
|
101
|
-
console.warn(`[clips] refine call failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
102
|
-
}
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
83
|
}
|
|
106
84
|
function rebuildScenes(scenes, decisions) {
|
|
107
85
|
const out = [];
|
|
@@ -9,6 +9,7 @@ import { createIdV7 } from "../../lib/ids.js";
|
|
|
9
9
|
import { ACTIVE_TAXONOMY_VERSION } from "./taxonomy.js";
|
|
10
10
|
import { buildClipEmbeddingText } from "./gemini.js";
|
|
11
11
|
import { detectScenes, extractClip, extractKeyframes, extractSceneAudio, extractThumbnail, probeVideo } from "./ffmpeg.js";
|
|
12
|
+
import { cropFilterFor, fitScenesToDurationBand, normalizeWindows } from "./hunt.js";
|
|
12
13
|
import { refineScenesWithGuidance } from "./refine.js";
|
|
13
14
|
/** Turn one scene into a Clip. Throws only on unrecoverable extraction failure. */
|
|
14
15
|
export async function processSceneToClip(input) {
|
|
@@ -18,12 +19,14 @@ export async function processSceneToClip(input) {
|
|
|
18
19
|
mkdirSync(ctx.clipsDir, { recursive: true });
|
|
19
20
|
mkdirSync(ctx.thumbsDir, { recursive: true });
|
|
20
21
|
const clipId = createIdV7("clip");
|
|
21
|
-
|
|
22
|
+
const cropFilter = ctx.crop ? cropFilterFor(ctx.crop.aspect, ctx.crop.focus) : null;
|
|
23
|
+
// 1. Keyframes for visual tagging (small, low-res), framed like the output.
|
|
22
24
|
const keyframePaths = await extractKeyframes({
|
|
23
25
|
videoPath,
|
|
24
26
|
scene,
|
|
25
27
|
outDir: framesDir,
|
|
26
|
-
count: ctx.framesPerScene ?? 2
|
|
28
|
+
count: ctx.framesPerScene ?? 2,
|
|
29
|
+
cropFilter
|
|
27
30
|
});
|
|
28
31
|
// 2. Per-scene audio for ASR (one combined tag+transcribe call).
|
|
29
32
|
let audioPath;
|
|
@@ -43,13 +46,22 @@ export async function processSceneToClip(input) {
|
|
|
43
46
|
scene,
|
|
44
47
|
guidancePrompt: ctx.guidancePrompt
|
|
45
48
|
});
|
|
46
|
-
// 4. Cut the clip + thumbnail.
|
|
49
|
+
// 4. Cut the clip + thumbnail (cropped to the target aspect when requested).
|
|
47
50
|
const clipLocal = path.join(ctx.clipsDir, `${clipId}.mp4`);
|
|
48
51
|
const thumbLocal = path.join(ctx.thumbsDir, `${clipId}.jpg`);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
let extractDetail = "";
|
|
53
|
+
const clipOk = await extractClip({
|
|
54
|
+
videoPath,
|
|
55
|
+
scene,
|
|
56
|
+
outPath: clipLocal,
|
|
57
|
+
reencode: ctx.reencodeClips !== false,
|
|
58
|
+
cropFilter,
|
|
59
|
+
onError: (detail) => (extractDetail = detail)
|
|
60
|
+
});
|
|
61
|
+
if (!clipOk) {
|
|
62
|
+
throw new Error(`ffmpeg failed to extract clip for scene @${scene.start_time_sec}s${extractDetail ? ` — ${extractDetail}` : ""}`);
|
|
63
|
+
}
|
|
64
|
+
await extractThumbnail({ videoPath, scene, outPath: thumbLocal, cropFilter });
|
|
53
65
|
// 5. Embed the document text for semantic search (skipped when the client can't embed).
|
|
54
66
|
let embedding;
|
|
55
67
|
if (ctx.client.canEmbed) {
|
|
@@ -72,6 +84,7 @@ export async function processSceneToClip(input) {
|
|
|
72
84
|
tier: ctx.client.tier,
|
|
73
85
|
provider: ctx.client.provider,
|
|
74
86
|
embedding_model: ctx.client.embeddingModelId ?? undefined,
|
|
87
|
+
aspect: cropFilter && ctx.crop ? ctx.crop.aspect : undefined,
|
|
75
88
|
owner_id: input.ownerId,
|
|
76
89
|
created_at: new Date().toISOString()
|
|
77
90
|
};
|
|
@@ -83,11 +96,23 @@ export async function processSceneToClip(input) {
|
|
|
83
96
|
*/
|
|
84
97
|
export async function scanVideo(opts) {
|
|
85
98
|
const probe = opts.probe ?? (await probeVideo(opts.videoPath));
|
|
99
|
+
const windows = normalizeWindows(opts.windows, probe.duration_sec);
|
|
100
|
+
const band = opts.durationBand ?? null;
|
|
101
|
+
// Fit to the soft duration band BEFORE refine: the model then judges
|
|
102
|
+
// clip-sized units (fewer, more meaningful keyframes) instead of raw cuts.
|
|
103
|
+
// Callers passing pre-detected scenes (devcli estimate pass) must pre-fit
|
|
104
|
+
// them — the fit is intentionally NOT re-run on supplied scenes since
|
|
105
|
+
// fitting is not idempotent (a fitted 20s scene could re-merge toward the
|
|
106
|
+
// target on a second pass).
|
|
86
107
|
let scenes = opts.scenes ??
|
|
87
|
-
(await detectScenes(opts.videoPath, {
|
|
108
|
+
fitScenesToDurationBand(await detectScenes(opts.videoPath, {
|
|
88
109
|
durationSec: probe.duration_sec,
|
|
110
|
+
windows,
|
|
111
|
+
// A duration band lifts the scene-split ceiling so raw cuts can merge
|
|
112
|
+
// up to the requested clip length instead of being pre-chopped at 8s.
|
|
113
|
+
...(band ? { maxSceneSec: band.max_sec } : {}),
|
|
89
114
|
...opts.sceneOptions
|
|
90
|
-
}));
|
|
115
|
+
}), band);
|
|
91
116
|
// Prompt-guided segmentation: let the guidance reshape which scenes become
|
|
92
117
|
// clips (keep/drop/merge) before we spend tagging tokens on them.
|
|
93
118
|
if (opts.ctx.guidancePrompt?.trim() && opts.ctx.refineSegmentation !== false) {
|
|
@@ -98,7 +123,9 @@ export async function scanVideo(opts) {
|
|
|
98
123
|
guidancePrompt: opts.ctx.guidancePrompt,
|
|
99
124
|
workDir: opts.ctx.workDir
|
|
100
125
|
});
|
|
101
|
-
|
|
126
|
+
// Refine merges can exceed the band max — re-enforce it (fit never merges
|
|
127
|
+
// across the gaps refine's drops created, only contiguous spans).
|
|
128
|
+
scenes = fitScenesToDurationBand(refined.scenes, band);
|
|
102
129
|
opts.onRefine?.(refined);
|
|
103
130
|
}
|
|
104
131
|
const sourceVideoId = opts.sourceVideoId ?? createIdV7("srcvid");
|
|
Binary file
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Provider error taxonomy + HTTP-response classification, shared by every
|
|
2
|
+
// surface that talks to an AI provider directly: ProviderService (cloud, leased
|
|
3
|
+
// BYOK keys) and the speech module (also driven key-less from the devcli with a
|
|
4
|
+
// local env key). Lives outside providers.ts so speech.ts can import it without
|
|
5
|
+
// a circular dependency.
|
|
6
|
+
export class ProviderKeyUnavailableError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export class ProviderAuthError extends Error {
|
|
9
|
+
}
|
|
10
|
+
export class ProviderQuotaExceededError extends Error {
|
|
11
|
+
}
|
|
12
|
+
export class ProviderWaitExceededError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export class ProviderLeaseTimeoutError extends Error {
|
|
15
|
+
retryAfterSeconds;
|
|
16
|
+
constructor(message, retryAfterSeconds = 5) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class ProviderRateLimitError extends Error {
|
|
22
|
+
retryAfterSeconds;
|
|
23
|
+
constructor(message, retryAfterSeconds = 60) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class ProviderTransientError extends Error {
|
|
29
|
+
retryAfterSeconds;
|
|
30
|
+
constructor(message, retryAfterSeconds = 5) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function parseRetryAfterSeconds(value) {
|
|
36
|
+
if (!value) {
|
|
37
|
+
return 60;
|
|
38
|
+
}
|
|
39
|
+
const numeric = Number(value);
|
|
40
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
41
|
+
return Math.max(1, Math.min(Math.ceil(numeric), 60 * 60));
|
|
42
|
+
}
|
|
43
|
+
const parsedDate = Date.parse(value);
|
|
44
|
+
if (Number.isFinite(parsedDate)) {
|
|
45
|
+
return Math.max(1, Math.min(Math.ceil((parsedDate - Date.now()) / 1000), 60 * 60));
|
|
46
|
+
}
|
|
47
|
+
return 60;
|
|
48
|
+
}
|
|
49
|
+
export function isHardQuotaError(details) {
|
|
50
|
+
const normalized = details.toLowerCase();
|
|
51
|
+
return [
|
|
52
|
+
"insufficient_quota",
|
|
53
|
+
"quota_exceeded",
|
|
54
|
+
"exceeded your current quota",
|
|
55
|
+
"billing",
|
|
56
|
+
"insufficient credits",
|
|
57
|
+
"not enough credits",
|
|
58
|
+
"credit balance",
|
|
59
|
+
"prepaid credits",
|
|
60
|
+
"resource_exhausted"
|
|
61
|
+
].some((pattern) => normalized.includes(pattern));
|
|
62
|
+
}
|
|
63
|
+
export function conciseProviderDetails(details) {
|
|
64
|
+
const compact = details.replace(/\s+/g, " ").trim();
|
|
65
|
+
return compact ? `: ${compact.slice(0, 500)}` : "";
|
|
66
|
+
}
|
|
67
|
+
export async function rateLimitOrQuotaError(provider, response) {
|
|
68
|
+
const details = await response.text();
|
|
69
|
+
if (isHardQuotaError(details)) {
|
|
70
|
+
return new ProviderQuotaExceededError(`${provider} quota or billing limit was reached${conciseProviderDetails(details)}`);
|
|
71
|
+
}
|
|
72
|
+
return new ProviderRateLimitError(`${provider} rate limited${conciseProviderDetails(details)}`, parseRetryAfterSeconds(response.headers.get("retry-after")));
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=provider-errors.js.map
|