feat(operators): vidgod_roto + vidgod_index, requires_path node gating

Two stdlib-only wrapper ops around the VIDGOD stack (gitea monster/vidgod):
click-to-cutout (SAM2.1+MatAnyone -> ProRes 4444 alpha) and clip-library
indexing (PySceneDetect + mlx-whisper). They shell out to ~/Documents/VIDGOD
bin CLIs, which carry their own venvs — no MODELBEAST venv or install script.

New optional manifest field requires_path: gates the LOCAL node on a path
existing (expanduser), so ops whose stack lives only on some boxes are not
grabbed by the primary. Remote nodes stay allowlist-gated via nodes.json
(vidgod_* allowlisted on m1/ultra only for now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
John King 2026-08-24 14:44:48 +10:00
parent 03e8676db0
commit 8965d2240b
6 changed files with 168 additions and 2 deletions

View File

@ -0,0 +1,19 @@
{
"id": "vidgod_index",
"name": "Clip Library Index (VIDGOD)",
"category": "video",
"description": "Index a video into the node's VIDGOD clip library: PySceneDetect shot split + mlx-whisper transcript + thumbnails into library/clips.sqlite. Library lives on the node that runs the job (canonical: ultra) — search it there with vg-find. Returns an index summary JSON.",
"accepts": ["video"],
"produces": ["json"],
"resources": "cpu",
"entry": "run.py",
"requires_path": "~/Documents/VIDGOD/bin/vg-index",
"params_schema": {
"type": "object",
"properties": {
"no_whisper": {"type": "boolean", "default": false, "description": "Skip transcription (shots + thumbnails only)"},
"min_shot": {"type": "number", "default": 0.5, "description": "Minimum shot length, seconds"},
"keep_copy": {"type": "boolean", "default": true, "description": "Keep a copy of the video in VIDGOD/library/ingest/ so library entries outlive the job workspace"}
}
}
}

View File

@ -0,0 +1,61 @@
"""vidgod_index — MODELBEAST wrapper around VIDGOD's vg-index.
Stdlib-only: runs under system python3, shells out to ~/Documents/VIDGOD/bin/vg-index
(which brings its own venv). By default the video is copied into
VIDGOD/library/ingest/ first so library entries point at a path that outlives
the job workspace.
"""
import argparse
import json
import shutil
import sqlite3
import subprocess
import sys
from pathlib import Path
ap = argparse.ArgumentParser()
ap.add_argument("--input", required=True)
ap.add_argument("--outdir", required=True)
ap.add_argument("--params", default="{}")
args = ap.parse_args()
p = json.loads(args.params)
vg = Path.home() / "Documents/VIDGOD"
vg_index = vg / "bin/vg-index"
if not vg_index.exists():
sys.exit("VIDGOD not installed on this node — clone gitea monster/vidgod to "
"~/Documents/VIDGOD and run setup/setup_venvs.sh")
src = Path(args.input)
if p.get("keep_copy", True):
ingest = vg / "library/ingest"
ingest.mkdir(parents=True, exist_ok=True)
target = ingest / src.name
if not (target.exists() and target.stat().st_size == src.stat().st_size):
shutil.copy2(src, target)
src = target
cmd = [str(vg_index), str(src), "--min-shot", str(p.get("min_shot", 0.5))]
if p.get("no_whisper", False):
cmd.append("--no-whisper")
print("+", " ".join(cmd), flush=True)
subprocess.run(cmd, check=True, stderr=sys.stdout)
db = sqlite3.connect(vg / "library/clips.sqlite")
row = db.execute(
"""SELECT v.id, v.duration, COUNT(DISTINCT s.id), COUNT(DISTINCT g.id)
FROM videos v LEFT JOIN shots s ON s.video_id=v.id
LEFT JOIN segs g ON g.video_id=v.id WHERE v.path=? GROUP BY v.id""",
(str(src),)).fetchone()
if not row:
sys.exit(f"indexing ran but {src} not found in library db")
summary = {"indexed_path": str(src), "duration_s": row[1],
"shots": row[2], "dialogue_segments": row[3],
"search_hint": "vg-find <words> on this node"}
out = Path(args.outdir) / "index_summary.json"
out.write_text(json.dumps(summary, indent=2))
(Path(args.outdir) / "result.json").write_text(json.dumps(
{"outputs": [{"path": "index_summary.json", "name": "index_summary.json"}],
"summary": summary}))
print("done:", json.dumps(summary), flush=True)

View File

@ -0,0 +1,22 @@
{
"id": "vidgod_roto",
"name": "Actor Cutout (VIDGOD)",
"category": "video",
"description": "Video + click point → tracked actor cutout via SAM 2.1 + MatAnyone (VIDGOD stack). Returns ProRes 4444 alpha, grayscale matte, and a green-screen preview mp4. Needs the vidgod repo set up on the node (~/Documents/VIDGOD, gitea monster/vidgod). Runs on the node's own VIDGOD venvs — no MODELBEAST venv.",
"accepts": ["video"],
"produces": ["video"],
"resources": "gpu",
"entry": "run.py",
"requires_path": "~/Documents/VIDGOD/bin/vg-roto",
"params_schema": {
"type": "object",
"properties": {
"points": {"type": "string", "default": "", "description": "Positive clicks 'x,y' or 'x,y;x,y' on the prompt frame"},
"neg_points": {"type": "string", "default": "", "description": "Negative clicks 'x,y;x,y' (regions to exclude)"},
"box": {"type": "string", "default": "", "description": "Alternative to points: 'x1,y1,x2,y2' around the target"},
"frame": {"type": "integer", "default": 0, "description": "Frame index to prompt on (clip is trimmed from here in matte mode)"},
"mode": {"type": "string", "enum": ["matte", "mask"], "default": "matte", "description": "matte = soft alpha (MatAnyone), mask = hard binary (SAM2 propagation)"},
"max_size": {"type": "integer", "default": -1, "description": "Downscale min side for inference (-1 = native, 720 = faster)"}
}
}
}

View File

@ -0,0 +1,58 @@
"""vidgod_roto — MODELBEAST wrapper around VIDGOD's vg-roto.
Stdlib-only on purpose: runs under the node's system python3 and shells out to
~/Documents/VIDGOD/bin/vg-roto, which brings its own venv (SAM 2.1 + MatAnyone).
"""
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
ap = argparse.ArgumentParser()
ap.add_argument("--input", required=True)
ap.add_argument("--outdir", required=True)
ap.add_argument("--params", default="{}")
args = ap.parse_args()
p = json.loads(args.params)
vg_roto = Path.home() / "Documents/VIDGOD/bin/vg-roto"
if not vg_roto.exists():
sys.exit("VIDGOD not installed on this node — clone gitea monster/vidgod to "
"~/Documents/VIDGOD and run setup/setup_venvs.sh")
outdir = Path(args.outdir)
workdir = outdir / "roto"
cmd = [str(vg_roto), args.input, "--out", str(workdir),
"--frame", str(int(p.get("frame", 0))),
"--mode", p.get("mode", "matte"),
"--max-size", str(int(p.get("max_size", -1)))]
for pt in str(p.get("points", "")).split(";"):
if pt.strip():
cmd += ["--point", pt.strip()]
for pt in str(p.get("neg_points", "")).split(";"):
if pt.strip():
cmd += ["--neg", pt.strip()]
if str(p.get("box", "")).strip():
cmd += ["--box", str(p["box"]).strip()]
print("+", " ".join(cmd), flush=True)
subprocess.run(cmd, check=True, stderr=sys.stdout)
# vg-roto writes <stem>_{alpha.mov,matte.mov,green.mp4} into workdir; lift them
# to outdir top-level so the runner registers them as job outputs.
outputs = []
for f in sorted(workdir.glob("*_*.m*")):
dest = outdir / f.name
shutil.move(str(f), dest)
outputs.append({"path": f.name, "name": f.name})
if not outputs:
sys.exit("vg-roto produced no outputs")
shutil.rmtree(workdir, ignore_errors=True)
(outdir / "result.json").write_text(json.dumps({
"outputs": outputs,
"summary": {"mode": p.get("mode", "matte"), "files": [o["name"] for o in outputs]},
}))
print("done:", ", ".join(o["name"] for o in outputs), flush=True)

View File

@ -44,8 +44,14 @@ def load_gpu_nodes() -> list[dict]:
return nodes
def node_supports(node: dict, op_id: str) -> bool:
def node_supports(node: dict, op_id: str, op: dict | None = None) -> bool:
if node.get("local"):
# manifest "requires_path" pins an op to nodes that actually carry its
# stack (e.g. vidgod_* need ~/Documents/VIDGOD) — gates the local node;
# remotes are gated by their nodes.json allowlist as before.
req = (op or {}).get("requires_path")
if req and not Path(req).expanduser().exists():
return False
return True
allow = node.get("operators")
return (not allow) or (op_id in allow)

View File

@ -229,7 +229,7 @@ class Runner:
order = sorted(order, key=lambda n: _LIGHT_ORDER.get(n.get("name", ""), 9))
while True:
for n in order:
if not remote.node_supports(n, op_id):
if not remote.node_supports(n, op_id, self.operators.get(op_id)):
continue
cap = self._node_capacity(n, lane)
if cap <= 0 or n["inflight"].get(lane, 0) >= cap: