@mevdragon/vidfarm-devcli 0.7.1 → 0.8.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/dist/src/cli.js CHANGED
@@ -9,6 +9,7 @@ import { spawnSync } from "node:child_process";
9
9
  import { Readable } from "node:stream";
10
10
  import { pipeline } from "node:stream/promises";
11
11
  import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./devcli/composition-edit.js";
12
+ import { runClipsCommand } from "./devcli/clips.js";
12
13
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
13
14
  // `serve` command boots the FULL editor locally (single origin, disk-backed
14
15
  // records + storage) so power users edit compositions on disk while a browser
@@ -128,6 +129,17 @@ Generate AI media and drop it on the timeline (for local coding agents):
128
129
  remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
129
130
  original vs decomposed (caption-free),
130
131
  non-billing (alias: ghostcut)
132
+
133
+ Clip curation (the third library — mine long-form video into a reusable clip store):
134
+ clips scan <video-path> Detect scenes → tag + transcribe (Gemini) LOCAL: ffmpeg + BYOK Gemini
135
+ → embed → persist to ~/.vidfarm/clips.db. (cloud: POST /clips/scan)
136
+ --tier flash|flash-lite Gemini tier (default flash-lite, cheapest)
137
+ --dry-run Show the estimated cost and stop (over $1 needs --yes/confirm)
138
+ clips list List the local clip library → GET /clips
139
+ clips search "<text>" Hybrid structured + semantic search → POST /clips/search
140
+ clips preset list|run <name>|save <name> --from-query '<...>' → /clips/presets
141
+ clips export <ids...> --to <dir> Copy clip mp4s + metadata out
142
+ (run 'vidfarm clips' for the full clips help)
131
143
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
132
144
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
133
145
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
@@ -351,6 +363,9 @@ async function main() {
351
363
  case "save-file":
352
364
  await runPutFileCommand(rest);
353
365
  return;
366
+ case "clips":
367
+ await runClipsCommand(rest);
368
+ return;
354
369
  default:
355
370
  if (!command.startsWith("-")) {
356
371
  // Positional template id → default to booting the local editor.
@@ -803,7 +818,10 @@ async function fetchCompositionFiles(input) {
803
818
  // can ground edits in what the video actually says and shows.
804
819
  // cast.json carries the identified recurring people/characters plus their
805
820
  // reference-still URLs so agents can reuse "the same person" in new gens.
806
- const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json"];
821
+ // scene-annotations.json carries the detected content format + per-scene
822
+ // literal + viral DNA an agent needs to REPLACE a scene from the clip library
823
+ // or RE-CREATE it from scratch (recreation_prompt + recreation_notes).
824
+ const files = ["composition.html", "composition.json", "manifest.json", "video-context.json", "cast.json", "scene-annotations.json"];
807
825
  for (const filename of files) {
808
826
  const target = path.join(input.dir, filename);
809
827
  if (existsSync(target) && !input.refetch) {
@@ -1305,6 +1323,43 @@ async function runDecomposeCommand(argv) {
1305
1323
  if (!ctx.json && result.json?.ghostcut_pending) {
1306
1324
  console.log(`${DIM}Subtitle removal (GhostCut) is running in the background — poll \`vidfarm api POST /api/v1/compositions/${forkId}/remove-video-captions-poll\` or GET .../remove-video-captions until done.${RESET}`);
1307
1325
  }
1326
+ // The scene-annotation pass (per-scene clip-match + recreation DNA + content
1327
+ // format) is seeded "pending" by decompose and runs as its own bounded vision
1328
+ // call — kept out of the decompose request so that stays under the origin
1329
+ // timeout. Drive it to completion here so CLI/agent users get annotations
1330
+ // without needing the editor. Each poll is one call; usually one round
1331
+ // finishes it. Best-effort: transient failures leave it pending for another
1332
+ // client (or a later `vidfarm pull`) to finish.
1333
+ if (!ctx.json)
1334
+ process.stdout.write(`${DIM}Annotating scenes (clip-match + recreation DNA)… ${RESET}`);
1335
+ let annotationStatus = "pending";
1336
+ for (let attempt = 0; attempt < 20; attempt += 1) {
1337
+ let poll;
1338
+ try {
1339
+ poll = await apiRequest({
1340
+ method: "POST",
1341
+ host: ctx.host,
1342
+ path: `/api/v1/compositions/${encodeURIComponent(forkId)}/scene-annotations-poll`,
1343
+ auth: ctx.auth,
1344
+ body: {}
1345
+ });
1346
+ }
1347
+ catch {
1348
+ break;
1349
+ }
1350
+ annotationStatus = typeof poll.json?.status === "string" ? poll.json.status : "none";
1351
+ if (annotationStatus === "none" || annotationStatus === "done" || annotationStatus === "failed")
1352
+ break;
1353
+ await sleep(3000);
1354
+ }
1355
+ if (!ctx.json) {
1356
+ if (annotationStatus === "done")
1357
+ console.log(`${GREEN}done${RESET}`);
1358
+ else if (annotationStatus === "failed")
1359
+ console.log(`${RED}failed${RESET} ${DIM}(run \`vidfarm api POST /api/v1/compositions/${forkId}/scene-annotations-poll\` to retry)${RESET}`);
1360
+ else
1361
+ console.log(`${DIM}still running — poll .../scene-annotations-poll until done${RESET}`);
1362
+ }
1308
1363
  emitResult(result, ctx.json);
1309
1364
  }
1310
1365
  async function runRemoveVideoCaptionsCommand(argv) {
@@ -1827,8 +1882,9 @@ async function runPullCommand(argv) {
1827
1882
  const info = inspectComposition(readFileSync(htmlPath, "utf8"));
1828
1883
  const hasContext = existsSync(path.join(dir, "video-context.json"));
1829
1884
  const hasCast = existsSync(path.join(dir, "cast.json"));
1885
+ const hasAnnotations = existsSync(path.join(dir, "scene-annotations.json"));
1830
1886
  if (ctx.json) {
1831
- printJson({ ok: true, fork_id: forkId, dir, has_video_context: hasContext, has_cast: hasCast, ...info });
1887
+ printJson({ ok: true, fork_id: forkId, dir, has_video_context: hasContext, has_cast: hasCast, has_scene_annotations: hasAnnotations, ...info });
1832
1888
  return;
1833
1889
  }
1834
1890
  console.log(`${GREEN}Pulled ${forkId} → ${dir}${RESET}`);
@@ -1839,7 +1895,7 @@ async function runPullCommand(argv) {
1839
1895
  const flags = [layer.is_timeline_proxy ? "proxy" : null, layer.is_full_canvas ? "full-canvas" : null].filter(Boolean).join(",");
1840
1896
  console.log(` ${layer.key} ${DIM}${layer.kind ?? "?"} ${layer.start}-${(layer.start + layer.duration).toFixed(2)}s track${layer.track}${flags ? ` [${flags}]` : ""}${layer.slug ? ` slug=${layer.slug}` : ""}${RESET}`);
1841
1897
  }
1842
- console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}.${RESET}`);
1898
+ console.log(`${DIM}Grounding: ${hasContext ? "video-context.json ✓" : "video-context.json ✗ (run `vidfarm decompose`)"}, ${hasCast ? "cast.json ✓" : "cast.json ✗"}, ${hasAnnotations ? "scene-annotations.json ✓" : "scene-annotations.json ✗"}.${RESET}`);
1843
1899
  console.log(`${DIM}Next: vidfarm generate video --prompt "..." --aspect-ratio ${info.aspect_ratio ?? "9:16"} --place ${dir} --at <gap start>|--replace <layer_key>${RESET}`);
1844
1900
  }
1845
1901
  async function runVisibilityCommand(argv) {
@@ -108,6 +108,12 @@ const schema = z.object({
108
108
  VIDFARM_RATE_LIMITS_TABLE_NAME: z.string().optional(),
109
109
  VIDFARM_SERVERLESS_MAIN_TABLE_NAME: z.string().optional(),
110
110
  VIDFARM_JOB_STATE_MACHINE_ARN: z.string().optional(),
111
+ // Clip-curation scan pipeline (Step Functions). Optional: when unset, the
112
+ // /clips/scan route records the scan request but doesn't dispatch the machine.
113
+ VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN: z.string().optional(),
114
+ VIDFARM_CLIP_VECTOR_BACKEND: z.enum(["dynamo", "s3vectors"]).default("dynamo"),
115
+ VIDFARM_CLIP_VECTORS_BUCKET: z.string().optional(),
116
+ VIDFARM_CLIP_VECTORS_INDEX: z.string().optional(),
111
117
  SENTRY_DSN: z.string().optional(),
112
118
  SENTRY_ENVIRONMENT: z.string().optional(),
113
119
  SENTRY_RELEASE: z.string().optional(),
@@ -0,0 +1,335 @@
1
+ // Local clip library for the devcli. SQLite (better-sqlite3) holds clip
2
+ // metadata + presets + source-video bookkeeping; embeddings live in a
3
+ // sqlite-vec vec0 virtual table for KNN. Clip files/thumbnails are written to
4
+ // disk. Layout (handoff Target 1):
5
+ // ~/.vidfarm/clips.db metadata + vectors
6
+ // ~/.vidfarm/clips/<id>.mp4 clip files
7
+ // ~/.vidfarm/thumbs/<id>.jpg thumbnails
8
+ // Override the base dir with VIDFARM_HOME.
9
+ import Database from "better-sqlite3";
10
+ import * as sqliteVec from "sqlite-vec";
11
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
12
+ import path from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { createId } from "../lib/ids.js";
15
+ import { BUILTIN_PRESETS, EMBEDDING_DIM, cosineSimilarity, matchesCriteria, rankHits, scoreClip, structuredMatchRatio } from "../services/clip-curation/index.js";
16
+ export function resolveClipStorePaths(baseOverride) {
17
+ const base = baseOverride
18
+ ? path.resolve(baseOverride)
19
+ : process.env.VIDFARM_HOME
20
+ ? path.resolve(process.env.VIDFARM_HOME)
21
+ : path.join(homedir(), ".vidfarm");
22
+ return {
23
+ base,
24
+ db: path.join(base, "clips.db"),
25
+ clipsDir: path.join(base, "clips"),
26
+ thumbsDir: path.join(base, "thumbs"),
27
+ workDir: path.join(base, "scan-tmp")
28
+ };
29
+ }
30
+ export class ClipStore {
31
+ paths;
32
+ db;
33
+ dim;
34
+ constructor(baseOverride) {
35
+ this.paths = resolveClipStorePaths(baseOverride);
36
+ mkdirSync(this.paths.base, { recursive: true });
37
+ mkdirSync(this.paths.clipsDir, { recursive: true });
38
+ mkdirSync(this.paths.thumbsDir, { recursive: true });
39
+ this.db = new Database(this.paths.db);
40
+ this.db.pragma("journal_mode = WAL");
41
+ sqliteVec.load(this.db);
42
+ this.dim = this.initSchema();
43
+ this.seedBuiltinPresets();
44
+ }
45
+ /**
46
+ * Create the base tables, resolve the embedding dimension (pinned in `meta`
47
+ * so it stays stable across runs even if the env default changes), then
48
+ * create the vec table at that dimension. Returns the resolved dim.
49
+ */
50
+ initSchema() {
51
+ this.migrate();
52
+ const existing = this.db
53
+ .prepare("SELECT value FROM meta WHERE key = 'embedding_dim'")
54
+ .get();
55
+ const dim = existing ? Number(existing.value) || EMBEDDING_DIM : EMBEDDING_DIM;
56
+ this.db.prepare("INSERT OR IGNORE INTO meta(key, value) VALUES ('embedding_dim', ?)").run(String(dim));
57
+ this.db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vec_clips USING vec0(clip_id TEXT PRIMARY KEY, embedding float[${dim}]);`);
58
+ return dim;
59
+ }
60
+ migrate() {
61
+ this.db.exec(`
62
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
63
+ CREATE TABLE IF NOT EXISTS clips (
64
+ clip_id TEXT PRIMARY KEY,
65
+ source_video_id TEXT NOT NULL,
66
+ source_filename TEXT NOT NULL,
67
+ start_time_sec REAL NOT NULL,
68
+ end_time_sec REAL NOT NULL,
69
+ duration_sec REAL NOT NULL,
70
+ file_path TEXT NOT NULL,
71
+ thumbnail_path TEXT NOT NULL,
72
+ tags_json TEXT NOT NULL,
73
+ description TEXT NOT NULL DEFAULT '',
74
+ taxonomy_version TEXT NOT NULL,
75
+ tier TEXT NOT NULL,
76
+ user_notes TEXT,
77
+ created_at TEXT NOT NULL
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_clips_source ON clips(source_video_id);
80
+ CREATE INDEX IF NOT EXISTS idx_clips_created ON clips(created_at);
81
+ CREATE TABLE IF NOT EXISTS presets (
82
+ preset_id TEXT PRIMARY KEY,
83
+ name TEXT NOT NULL,
84
+ description TEXT,
85
+ criteria_json TEXT NOT NULL,
86
+ builtin INTEGER NOT NULL DEFAULT 0,
87
+ created_at TEXT NOT NULL
88
+ );
89
+ CREATE TABLE IF NOT EXISTS source_videos (
90
+ source_video_id TEXT PRIMARY KEY,
91
+ filename TEXT NOT NULL,
92
+ source_path TEXT,
93
+ duration_sec REAL,
94
+ clip_count INTEGER NOT NULL DEFAULT 0,
95
+ tier TEXT,
96
+ scanned_at TEXT NOT NULL
97
+ );
98
+ `);
99
+ }
100
+ seedBuiltinPresets() {
101
+ const stmt = this.db.prepare("INSERT OR REPLACE INTO presets(preset_id, name, description, criteria_json, builtin, created_at) VALUES (?, ?, ?, ?, 1, ?)");
102
+ for (const p of BUILTIN_PRESETS) {
103
+ stmt.run(p.preset_id, p.name, p.description ?? null, JSON.stringify(p.criteria), p.created_at);
104
+ }
105
+ }
106
+ // ── Clips ──────────────────────────────────────────────────────────────
107
+ insertClip(clip) {
108
+ const tx = this.db.transaction((c) => {
109
+ this.db
110
+ .prepare(`INSERT OR REPLACE INTO clips
111
+ (clip_id, source_video_id, source_filename, start_time_sec, end_time_sec, duration_sec,
112
+ file_path, thumbnail_path, tags_json, description, taxonomy_version, tier, user_notes, created_at)
113
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
114
+ .run(c.clip_id, c.source_video_id, c.source_filename, c.start_time_sec, c.end_time_sec, c.duration_sec, c.file_path, c.thumbnail_path, JSON.stringify(c.tags), c.description ?? "", c.taxonomy_version, c.tier, c.user_notes ?? null, c.created_at);
115
+ if (c.embedding && c.embedding.length === this.dim) {
116
+ this.db.prepare("DELETE FROM vec_clips WHERE clip_id = ?").run(c.clip_id);
117
+ this.db
118
+ .prepare("INSERT INTO vec_clips(clip_id, embedding) VALUES (?, ?)")
119
+ .run(c.clip_id, JSON.stringify(c.embedding));
120
+ }
121
+ });
122
+ tx(clip);
123
+ }
124
+ getClip(clipId, withEmbedding = false) {
125
+ const row = this.db.prepare("SELECT * FROM clips WHERE clip_id = ?").get(clipId);
126
+ if (!row)
127
+ return null;
128
+ const clip = rowToClip(row);
129
+ if (withEmbedding)
130
+ this.attachEmbeddings([clip]);
131
+ return clip;
132
+ }
133
+ /** All clips (metadata only), newest first, optionally scoped to a source video. */
134
+ listClips(opts = {}) {
135
+ let sql = "SELECT * FROM clips";
136
+ const args = [];
137
+ if (opts.sourceVideoId) {
138
+ sql += " WHERE source_video_id = ?";
139
+ args.push(opts.sourceVideoId);
140
+ }
141
+ sql += " ORDER BY created_at DESC";
142
+ if (opts.limit) {
143
+ sql += " LIMIT ?";
144
+ args.push(opts.limit);
145
+ }
146
+ return this.db.prepare(sql).all(...args).map(rowToClip);
147
+ }
148
+ deleteClip(clipId, removeFiles = true) {
149
+ const clip = this.getClip(clipId);
150
+ if (!clip)
151
+ return false;
152
+ this.db.prepare("DELETE FROM clips WHERE clip_id = ?").run(clipId);
153
+ this.db.prepare("DELETE FROM vec_clips WHERE clip_id = ?").run(clipId);
154
+ if (removeFiles) {
155
+ for (const f of [path.join(this.paths.clipsDir, `${clipId}.mp4`), path.join(this.paths.thumbsDir, `${clipId}.jpg`)]) {
156
+ try {
157
+ if (existsSync(f))
158
+ rmSync(f);
159
+ }
160
+ catch {
161
+ /* best effort */
162
+ }
163
+ }
164
+ }
165
+ return true;
166
+ }
167
+ /** Load embeddings from the vec table into the given clips (in place). */
168
+ attachEmbeddings(clips) {
169
+ if (clips.length === 0)
170
+ return;
171
+ const stmt = this.db.prepare("SELECT vec_to_json(embedding) AS e FROM vec_clips WHERE clip_id = ?");
172
+ for (const clip of clips) {
173
+ const row = stmt.get(clip.clip_id);
174
+ if (row?.e) {
175
+ try {
176
+ clip.embedding = JSON.parse(row.e);
177
+ }
178
+ catch {
179
+ /* skip */
180
+ }
181
+ }
182
+ }
183
+ }
184
+ /** sqlite-vec KNN: nearest clip ids to a (normalized) query vector. */
185
+ knnClipIds(queryEmbedding, k) {
186
+ if (queryEmbedding.length !== this.dim)
187
+ return [];
188
+ const rows = this.db
189
+ .prepare("SELECT clip_id FROM vec_clips WHERE embedding MATCH ? AND k = ? ORDER BY distance")
190
+ .all(JSON.stringify(queryEmbedding), k);
191
+ return rows.map((r) => r.clip_id);
192
+ }
193
+ /**
194
+ * Two-stage search (handoff): structured filter FIRST, then semantic re-rank
195
+ * the survivors by embedding similarity. If nothing matches structurally but
196
+ * we have a query vector, relax to a sqlite-vec KNN semantic pass.
197
+ */
198
+ search(input) {
199
+ const limit = input.limit ?? 20;
200
+ const all = this.listClips();
201
+ const structured = all.filter((c) => matchesCriteria(c, input.criteria));
202
+ if (structured.length > 0) {
203
+ if (input.queryEmbedding)
204
+ this.attachEmbeddings(structured);
205
+ const hits = structured.map((c) => scoreClip(c, input.criteria, input.queryEmbedding, false));
206
+ return rankHits(hits, limit);
207
+ }
208
+ // Nothing passed the hard filter. Relax to semantic-only if we can.
209
+ if (input.queryEmbedding) {
210
+ const ids = this.knnClipIds(input.queryEmbedding, Math.max(limit * 5, 50));
211
+ const byId = new Map(all.map((c) => [c.clip_id, c]));
212
+ const pool = ids.map((id) => byId.get(id)).filter((c) => Boolean(c));
213
+ this.attachEmbeddings(pool);
214
+ const hits = pool.map((c) => scoreClip(c, input.criteria, input.queryEmbedding, true));
215
+ return rankHits(hits, limit);
216
+ }
217
+ return [];
218
+ }
219
+ // ── Source videos ──────────────────────────────────────────────────────
220
+ recordSourceVideo(input) {
221
+ this.db
222
+ .prepare(`INSERT OR REPLACE INTO source_videos
223
+ (source_video_id, filename, source_path, duration_sec, clip_count, tier, scanned_at)
224
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
225
+ .run(input.source_video_id, input.filename, input.source_path ?? null, input.duration_sec ?? null, input.clip_count, input.tier ?? null, new Date().toISOString());
226
+ }
227
+ listSourceVideos() {
228
+ return this.db.prepare("SELECT * FROM source_videos ORDER BY scanned_at DESC").all();
229
+ }
230
+ // ── Presets ────────────────────────────────────────────────────────────
231
+ listPresets() {
232
+ const rows = this.db
233
+ .prepare("SELECT * FROM presets ORDER BY builtin DESC, name ASC")
234
+ .all();
235
+ return rows.map((r) => ({
236
+ preset_id: r.preset_id,
237
+ name: r.name,
238
+ description: r.description ?? undefined,
239
+ criteria: safeParse(r.criteria_json, {}),
240
+ builtin: r.builtin === 1,
241
+ created_at: r.created_at
242
+ }));
243
+ }
244
+ findPreset(nameOrId) {
245
+ const needle = nameOrId.trim().toLowerCase();
246
+ return this.listPresets().find((p) => p.preset_id.toLowerCase() === needle || p.name.toLowerCase() === needle);
247
+ }
248
+ savePreset(input) {
249
+ const preset = {
250
+ preset_id: createId("preset"),
251
+ name: input.name,
252
+ description: input.description,
253
+ criteria: input.criteria,
254
+ builtin: false,
255
+ created_at: new Date().toISOString()
256
+ };
257
+ this.db
258
+ .prepare("INSERT INTO presets(preset_id, name, description, criteria_json, builtin, created_at) VALUES (?, ?, ?, ?, 0, ?)")
259
+ .run(preset.preset_id, preset.name, preset.description ?? null, JSON.stringify(preset.criteria), preset.created_at);
260
+ return preset;
261
+ }
262
+ // ── Embedding-model bookkeeping ──────────────────────────────────────────
263
+ // Vectors from different embedding models aren't comparable, so a library
264
+ // should stay on one. Remember the first one seen; callers warn on mismatch.
265
+ getLibraryEmbeddingModel() {
266
+ const row = this.db.prepare("SELECT value FROM meta WHERE key = 'embedding_model'").get();
267
+ return row?.value ?? null;
268
+ }
269
+ rememberEmbeddingModel(model) {
270
+ this.db.prepare("INSERT OR IGNORE INTO meta(key, value) VALUES ('embedding_model', ?)").run(model);
271
+ }
272
+ // ── Stats ──────────────────────────────────────────────────────────────
273
+ stats() {
274
+ const n = (sql) => this.db.prepare(sql).get().n;
275
+ return {
276
+ clips: n("SELECT count(*) AS n FROM clips"),
277
+ sources: n("SELECT count(*) AS n FROM source_videos"),
278
+ presets: n("SELECT count(*) AS n FROM presets")
279
+ };
280
+ }
281
+ /** Local scoring helper reused by callers that already have a candidate pool. */
282
+ scoreAgainst(clips, criteria, queryEmbedding) {
283
+ return clips.map((c) => {
284
+ const relaxed = !matchesCriteria(c, criteria);
285
+ return {
286
+ clip: c,
287
+ similarity: queryEmbedding ? cosineSimilarity(c.embedding, queryEmbedding) : undefined,
288
+ score: relaxed ? structuredMatchRatio(c, criteria) : 1
289
+ };
290
+ });
291
+ }
292
+ close() {
293
+ this.db.close();
294
+ }
295
+ }
296
+ function rowToClip(row) {
297
+ return {
298
+ clip_id: row.clip_id,
299
+ source_video_id: row.source_video_id,
300
+ source_filename: row.source_filename,
301
+ start_time_sec: row.start_time_sec,
302
+ end_time_sec: row.end_time_sec,
303
+ duration_sec: row.duration_sec,
304
+ file_path: row.file_path,
305
+ thumbnail_path: row.thumbnail_path,
306
+ tags: safeParse(row.tags_json, emptyTags()),
307
+ description: row.description ?? "",
308
+ taxonomy_version: row.taxonomy_version,
309
+ tier: row.tier ?? "flash-lite",
310
+ user_notes: row.user_notes ?? undefined,
311
+ created_at: row.created_at
312
+ };
313
+ }
314
+ function emptyTags() {
315
+ return {
316
+ subject: [],
317
+ action: [],
318
+ emotion: [],
319
+ setting: [],
320
+ composition: [],
321
+ motion: [],
322
+ energy: "medium",
323
+ audio_type: [],
324
+ transcript: ""
325
+ };
326
+ }
327
+ function safeParse(json, fallback) {
328
+ try {
329
+ return JSON.parse(json);
330
+ }
331
+ catch {
332
+ return fallback;
333
+ }
334
+ }
335
+ //# sourceMappingURL=clip-store.js.map