@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.
@@ -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
- // No fixed-length constraint Gemini's structured-output schema translation
80
- // rejects minItems==maxItems; we align by index instead (rebuildScenes).
81
- const schema = z.object({
82
- decisions: z.array(z.object({ keep: z.boolean(), merge_with_previous: z.boolean() }))
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
- // 1. Keyframes for visual tagging (small, low-res).
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
- const clipOk = await extractClip({ videoPath, scene, outPath: clipLocal, reencode: ctx.reencodeClips !== false });
50
- if (!clipOk)
51
- throw new Error(`ffmpeg failed to extract clip for scene @${scene.start_time_sec}s`);
52
- await extractThumbnail({ videoPath, scene, outPath: thumbLocal });
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
- scenes = refined.scenes;
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");
@@ -1671,6 +1671,27 @@ async function callOpenAIVision(input) {
1671
1671
  image_url: { url: `data:image/jpeg;base64,${frame.data}` }
1672
1672
  });
1673
1673
  }
1674
+ // OpenRouter proxies many models and still accepts the classic Chat
1675
+ // Completions params. Native OpenAI's current models (gpt-5.x / o-series)
1676
+ // rejected `max_tokens` (they require `max_completion_tokens`) and only allow
1677
+ // the default temperature, so sending `max_tokens`/`temperature:0.15` 400s
1678
+ // every call. Branch the body by endpoint.
1679
+ const isOpenRouter = Boolean(input.endpoint && input.endpoint.includes("openrouter"));
1680
+ // Give the JSON response room to breathe so a video with 12+ text overlays
1681
+ // doesn't get truncated (same rationale as Gemini's maxOutputTokens).
1682
+ const OUTPUT_TOKEN_BUDGET = 16384;
1683
+ const body = {
1684
+ model: input.model,
1685
+ response_format: { type: "json_object" },
1686
+ messages: [{ role: "user", content }]
1687
+ };
1688
+ if (isOpenRouter) {
1689
+ body.temperature = 0.15;
1690
+ body.max_tokens = OUTPUT_TOKEN_BUDGET;
1691
+ }
1692
+ else {
1693
+ body.max_completion_tokens = OUTPUT_TOKEN_BUDGET;
1694
+ }
1674
1695
  const response = await fetch(input.endpoint ?? "https://api.openai.com/v1/chat/completions", {
1675
1696
  method: "POST",
1676
1697
  headers: {
@@ -1678,15 +1699,7 @@ async function callOpenAIVision(input) {
1678
1699
  "authorization": `Bearer ${input.apiKey}`,
1679
1700
  ...(input.extraHeaders ?? {})
1680
1701
  },
1681
- body: JSON.stringify({
1682
- model: input.model,
1683
- temperature: 0.15,
1684
- // Same rationale as Gemini's maxOutputTokens: give the JSON response room
1685
- // to breathe so a video with 12+ text overlays doesn't get truncated.
1686
- max_tokens: 16384,
1687
- response_format: { type: "json_object" },
1688
- messages: [{ role: "user", content }]
1689
- })
1702
+ body: JSON.stringify(body)
1690
1703
  });
1691
1704
  const raw = await response.text();
1692
1705
  if (!response.ok) {
@@ -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: input.expiresAt ?? timestamp,
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.8.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": {