@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.
- package/README.md +19 -1
- package/SKILL.director.md +57 -0
- package/SKILL.platform.md +13 -1
- package/dist/src/account-pages-legacy.js +15 -10
- package/dist/src/app.js +531 -183
- package/dist/src/cli.js +10 -4
- package/dist/src/config.js +5 -0
- package/dist/src/devcli/clips.js +312 -49
- package/dist/src/editor-chat.js +1 -1
- package/dist/src/frontend/homepage-view.js +0 -5
- 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/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
|
@@ -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");
|
|
@@ -804,6 +804,11 @@ class ServerlessRecordsServiceImpl {
|
|
|
804
804
|
}
|
|
805
805
|
async createUserTemporaryFile(input) {
|
|
806
806
|
const timestamp = nowIso();
|
|
807
|
+
const expiresAt = input.expiresAt ?? timestamp;
|
|
808
|
+
// Native DynamoDB TTL (top-level expires_at_epoch_seconds) so temp-file
|
|
809
|
+
// records auto-expire even if no list sweep ever runs; the S3 object is
|
|
810
|
+
// separately expired by the tag-scoped bucket lifecycle rule.
|
|
811
|
+
const expiresAtEpochSeconds = Math.floor(Date.parse(expiresAt) / 1000);
|
|
807
812
|
return this.putRecord("user_temporary_file", {
|
|
808
813
|
id: input.id ?? createId("tmp"),
|
|
809
814
|
customerId: input.customerId,
|
|
@@ -812,9 +817,9 @@ class ServerlessRecordsServiceImpl {
|
|
|
812
817
|
sizeBytes: input.sizeBytes ?? 0,
|
|
813
818
|
storageKey: input.storageKey,
|
|
814
819
|
folderPath: normalizeFolder(input.folderPath),
|
|
815
|
-
expiresAt
|
|
820
|
+
expiresAt,
|
|
816
821
|
createdAt: input.createdAt ?? timestamp
|
|
817
|
-
});
|
|
822
|
+
}, Number.isFinite(expiresAtEpochSeconds) ? { expiresAtEpochSeconds } : undefined);
|
|
818
823
|
}
|
|
819
824
|
async listUserTemporaryFiles(customerId) {
|
|
820
825
|
return this.listByCustomer("user_temporary_file", customerId);
|
|
@@ -1196,6 +1201,8 @@ class ServerlessRecordsServiceImpl {
|
|
|
1196
1201
|
entity_type: entityType(collection),
|
|
1197
1202
|
gsi1pk,
|
|
1198
1203
|
gsi1sk: `${String(data.updatedAt)}#${id}`,
|
|
1204
|
+
// Top-level so the table's native TTL (timeToLiveAttribute) can act on it.
|
|
1205
|
+
...(options?.expiresAtEpochSeconds ? { expires_at_epoch_seconds: options.expiresAtEpochSeconds } : {}),
|
|
1199
1206
|
data
|
|
1200
1207
|
};
|
|
1201
1208
|
// Private templates stay out of the global FEED partition (sparse gsi2):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
3
|
+
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, PutObjectTaggingCommand, S3Client } from "@aws-sdk/client-s3";
|
|
4
4
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
5
5
|
import { config } from "../config.js";
|
|
6
6
|
function isS3NotFoundError(error) {
|
|
@@ -107,6 +107,21 @@ export class StorageService {
|
|
|
107
107
|
writeFileSync(filePath, value);
|
|
108
108
|
return { key, url: this.getPublicUrl(key) };
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Tag an existing S3 object. Used to mark temp-folder uploads with
|
|
112
|
+
* `vidfarm-temp=1` AFTER the client's presigned PUT lands (server-side, so
|
|
113
|
+
* no client header contract), which the bucket's tag-scoped lifecycle rule
|
|
114
|
+
* expires after 30 days. No-op on the local filesystem driver.
|
|
115
|
+
*/
|
|
116
|
+
async putObjectTags(key, tags) {
|
|
117
|
+
if (!this.s3 || !config.AWS_S3_BUCKET)
|
|
118
|
+
return;
|
|
119
|
+
await this.s3.send(new PutObjectTaggingCommand({
|
|
120
|
+
Bucket: config.AWS_S3_BUCKET,
|
|
121
|
+
Key: key,
|
|
122
|
+
Tagging: { TagSet: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })) }
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
110
125
|
// Returns a directly-fetchable URL for a storage key when the key is
|
|
111
126
|
// stored under a prefix the bucket resource policy grants public read to.
|
|
112
127
|
// Callers get a real S3 URL that browsers/CDNs/GhostCut/etc. can hit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mevdragon/vidfarm-devcli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|