@mevdragon/vidfarm-devcli 0.7.0 → 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.
Files changed (37) hide show
  1. package/SKILL.director.md +43 -10
  2. package/SKILL.platform.md +5 -3
  3. package/demo/dist/app.js +107 -57
  4. package/dist/src/app.js +1115 -17
  5. package/dist/src/cli.js +304 -43
  6. package/dist/src/composition-runtime.js +26 -0
  7. package/dist/src/config.js +6 -0
  8. package/dist/src/devcli/clip-store.js +335 -0
  9. package/dist/src/devcli/clips.js +803 -0
  10. package/dist/src/devcli/composition-edit.js +12 -0
  11. package/dist/src/editor-chat.js +1 -0
  12. package/dist/src/frontend/homepage-client.js +61 -6
  13. package/dist/src/frontend/homepage-view.js +22 -4
  14. package/dist/src/homepage.js +46 -0
  15. package/dist/src/hyperframes/composition.js +87 -0
  16. package/dist/src/primitive-registry.js +136 -0
  17. package/dist/src/services/billing.js +1 -0
  18. package/dist/src/services/clip-curation/cost.js +112 -0
  19. package/dist/src/services/clip-curation/ffmpeg.js +258 -0
  20. package/dist/src/services/clip-curation/gemini.js +342 -0
  21. package/dist/src/services/clip-curation/index.js +15 -0
  22. package/dist/src/services/clip-curation/presets.js +20 -0
  23. package/dist/src/services/clip-curation/presets.v1.json +59 -0
  24. package/dist/src/services/clip-curation/query.js +163 -0
  25. package/dist/src/services/clip-curation/refine.js +136 -0
  26. package/dist/src/services/clip-curation/scan.js +137 -0
  27. package/dist/src/services/clip-curation/taxonomy.js +71 -0
  28. package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
  29. package/dist/src/services/clip-curation/types.js +7 -0
  30. package/dist/src/services/clip-records.js +209 -0
  31. package/dist/src/services/clip-search.js +47 -0
  32. package/dist/src/services/clip-vectors.js +125 -0
  33. package/dist/src/services/hyperframes.js +373 -1
  34. package/dist/src/services/scene-annotations.js +32 -0
  35. package/dist/src/services/storage.js +6 -0
  36. package/package.json +5 -1
  37. package/public/assets/homepage-client-app.js +18 -18
@@ -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