vidgod/bin/vg-index
type-two 47412ca4b7 fix: sqlite WAL + busy timeout (concurrent farm index jobs), hatchling for -e installs
Two concurrent vidgod_index jobs on one node hit "database is locked" —
vg-index now opens the library in WAL mode with a 60s busy timeout (vg-find
30s). setup_venvs.sh installs hatchling+editables and does the MatAnyone -e
with --no-deps (its pyproject drags in cchardet, dead on py3.12).
Found during the m4probook standup + farm spill test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:29:56 +10:00

183 lines
7.2 KiB
Bash
Executable File

#!/bin/sh
"exec" "`dirname $0`/../venvs/index/bin/python" "$0" "$@"
"""vg-index — build a searchable clip library: shot detection + transcript + thumbnails.
Usage: vg-index PATH [PATH...] [--no-whisper] [--force] [--min-shot 0.5]
For every video found under the given paths:
1. PySceneDetect splits it into shots
2. a thumbnail is saved for each shot -> library/thumbs/
3. Whisper (mlx) transcribes the audio -> timestamped segments
4. everything lands in library/clips.sqlite (FTS5 full-text over dialogue)
Search it with vg-find. Re-running skips unchanged files. Heartbeat: ~/.jobs/vidgod-index.status
"""
import argparse, hashlib, os, sqlite3, subprocess, sys, time
from datetime import datetime
from pathlib import Path
os.environ["PATH"] = "/opt/homebrew/bin:" + os.environ.get("PATH", "")
VG = Path(__file__).resolve().parent.parent
LIB = VG / "library"
DB = LIB / "clips.sqlite"
THUMBS = LIB / "thumbs"
WHISPER_MODEL = "mlx-community/whisper-large-v3-turbo"
VIDEO_EXTS = {".mov", ".mp4", ".mkv", ".avi", ".mpg", ".mpeg", ".wmv", ".webm", ".m4v", ".flv", ".mts", ".m2ts", ".vob"}
def hb(msg, note=""):
j = Path.home() / ".jobs"
j.mkdir(exist_ok=True)
(j / "vidgod-index.status").write_text(f"{datetime.now():%Y-%m-%d %H:%M:%S} | {msg} | {note}\n")
def open_db():
LIB.mkdir(parents=True, exist_ok=True)
THUMBS.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(DB, timeout=60)
db.execute("PRAGMA journal_mode=WAL") # concurrent index jobs on one node
db.executescript("""
CREATE TABLE IF NOT EXISTS videos(
id INTEGER PRIMARY KEY, path TEXT UNIQUE, mtime REAL, duration REAL,
fps REAL, width INT, height INT, has_transcript INT DEFAULT 0, indexed_at TEXT);
CREATE TABLE IF NOT EXISTS shots(
id INTEGER PRIMARY KEY, video_id INT, idx INT, start_s REAL, end_s REAL, thumb TEXT);
CREATE TABLE IF NOT EXISTS segs(
id INTEGER PRIMARY KEY, video_id INT, start_s REAL, end_s REAL, text TEXT);
CREATE VIRTUAL TABLE IF NOT EXISTS segs_fts USING fts5(text, content='segs', content_rowid='id');
CREATE INDEX IF NOT EXISTS idx_shots_video ON shots(video_id);
CREATE INDEX IF NOT EXISTS idx_segs_video ON segs(video_id);
""")
return db
def probe(path):
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,avg_frame_rate:format=duration",
"-of", "csv=p=0", str(path)],
capture_output=True, text=True).stdout.strip().splitlines()
w = h = 0; fps = 25.0; dur = 0.0
for line in out:
parts = line.split(",")
if len(parts) >= 3:
w, h = int(parts[0]), int(parts[1])
try:
num, den = parts[2].split("/")
fps = float(num) / float(den) if float(den) else 25.0
except ValueError:
pass
elif len(parts) == 1 and parts[0]:
try: dur = float(parts[0])
except ValueError: pass
return dur, fps, w, h
def detect_shots(path, min_shot):
from scenedetect import detect, ContentDetector
scenes = detect(str(path), ContentDetector(min_scene_len=int(min_shot * 25)))
if not scenes:
return None # single-shot video; caller uses full duration
return [(s.seconds, e.seconds) for s, e in scenes]
def save_thumb(path, t, dest):
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-ss", f"{t:.3f}", "-i", str(path),
"-frames:v", "1", "-vf", "scale=480:-2", str(dest)],
capture_output=True)
return dest.exists()
def transcribe(path):
import mlx_whisper
res = mlx_whisper.transcribe(str(path), path_or_hf_repo=WHISPER_MODEL)
return [(s["start"], s["end"], s["text"].strip()) for s in res.get("segments", []) if s["text"].strip()]
def index_file(db, path, args, pos, total):
st = path.stat()
row = db.execute("SELECT id, mtime FROM videos WHERE path=?", (str(path),)).fetchone()
if row and abs(row[1] - st.st_mtime) < 1 and not args.force:
return "skip"
if row: # stale entry — wipe and redo
vid = row[0]
db.execute("DELETE FROM segs_fts WHERE rowid IN (SELECT id FROM segs WHERE video_id=?)", (vid,))
db.execute("DELETE FROM segs WHERE video_id=?", (vid,))
db.execute("DELETE FROM shots WHERE video_id=?", (vid,))
db.execute("DELETE FROM videos WHERE id=?", (vid,))
hb(f"{pos}/{total}", f"probing {path.name}")
dur, fps, w, h = probe(path)
if dur <= 0:
return "unreadable"
cur = db.execute(
"INSERT INTO videos(path, mtime, duration, fps, width, height, indexed_at) VALUES(?,?,?,?,?,?,?)",
(str(path), st.st_mtime, dur, fps, w, h, datetime.now().isoformat(timespec="seconds")))
vid = cur.lastrowid
hb(f"{pos}/{total}", f"shots: {path.name}")
shots = detect_shots(path, args.min_shot) or [(0.0, dur)]
tag = hashlib.md5(str(path).encode()).hexdigest()[:10]
for i, (a, b) in enumerate(shots):
thumb = THUMBS / f"{tag}_{i:04d}.jpg"
save_thumb(path, a + (b - a) / 2, thumb)
db.execute("INSERT INTO shots(video_id, idx, start_s, end_s, thumb) VALUES(?,?,?,?,?)",
(vid, i, a, b, str(thumb)))
if not args.no_whisper:
hb(f"{pos}/{total}", f"whisper: {path.name}")
try:
for a, b, text in transcribe(path):
cur = db.execute("INSERT INTO segs(video_id, start_s, end_s, text) VALUES(?,?,?,?)",
(vid, a, b, text))
db.execute("INSERT INTO segs_fts(rowid, text) VALUES(?,?)", (cur.lastrowid, text))
db.execute("UPDATE videos SET has_transcript=1 WHERE id=?", (vid,))
except Exception as e: # no audio track etc — keep the shots
print(f" whisper failed on {path.name}: {e}", file=sys.stderr)
db.commit()
return f"{len(shots)} shots"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("paths", nargs="+")
ap.add_argument("--no-whisper", action="store_true")
ap.add_argument("--force", action="store_true", help="re-index even if unchanged")
ap.add_argument("--min-shot", type=float, default=0.5, help="minimum shot length, seconds")
args = ap.parse_args()
files = []
for p in args.paths:
p = Path(p).expanduser().resolve()
if p.is_dir():
files += sorted(x for x in p.rglob("*") if x.suffix.lower() in VIDEO_EXTS and x.is_file())
elif p.is_file():
files.append(p)
if not files:
sys.exit("no video files found")
db = open_db()
t0 = time.time()
counts = {}
for i, f in enumerate(files, 1):
try:
r = index_file(db, f, args, i, len(files))
except KeyboardInterrupt:
raise
except Exception as e:
r = "error"
print(f"ERROR indexing {f}: {e}", file=sys.stderr)
counts[r] = counts.get(r, 0) + 1
print(f"[{i}/{len(files)}] {f.name}: {r}")
hb(f"DONE {len(files)}/{len(files)}", str(counts))
n = db.execute("SELECT COUNT(*) FROM shots").fetchone()[0]
s = db.execute("SELECT COUNT(*) FROM segs").fetchone()[0]
print(f"\nlibrary now: {n} shots, {s} dialogue segments ({time.time()-t0:.0f}s). Search with vg-find.")
if __name__ == "__main__":
main()