@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/demo/dist/app.js +49 -49
- package/dist/src/app.js +863 -2
- package/dist/src/cli.js +59 -3
- package/dist/src/config.js +6 -0
- package/dist/src/devcli/clip-store.js +335 -0
- package/dist/src/devcli/clips.js +803 -0
- package/dist/src/frontend/homepage-view.js +5 -0
- package/dist/src/services/clip-curation/cost.js +112 -0
- package/dist/src/services/clip-curation/ffmpeg.js +258 -0
- package/dist/src/services/clip-curation/gemini.js +342 -0
- package/dist/src/services/clip-curation/index.js +15 -0
- package/dist/src/services/clip-curation/presets.js +20 -0
- package/dist/src/services/clip-curation/presets.v1.json +59 -0
- package/dist/src/services/clip-curation/query.js +163 -0
- package/dist/src/services/clip-curation/refine.js +136 -0
- package/dist/src/services/clip-curation/scan.js +137 -0
- package/dist/src/services/clip-curation/taxonomy.js +71 -0
- package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
- package/dist/src/services/clip-curation/types.js +7 -0
- package/dist/src/services/clip-records.js +209 -0
- package/dist/src/services/clip-search.js +47 -0
- package/dist/src/services/clip-vectors.js +125 -0
- package/dist/src/services/hyperframes.js +347 -0
- package/dist/src/services/scene-annotations.js +32 -0
- package/package.json +5 -1
- package/public/assets/homepage-client-app.js +17 -17
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
|
-
|
|
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) {
|
package/dist/src/config.js
CHANGED
|
@@ -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
|