From 8965d2240b947373c31841c8d92fba8f42b687c7 Mon Sep 17 00:00:00 2001 From: John King Date: Mon, 24 Aug 2026 14:44:48 +1000 Subject: [PATCH] feat(operators): vidgod_roto + vidgod_index, requires_path node gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/operators/vidgod_index/manifest.json | 19 +++++++ server/operators/vidgod_index/run.py | 61 +++++++++++++++++++++ server/operators/vidgod_roto/manifest.json | 22 ++++++++ server/operators/vidgod_roto/run.py | 58 ++++++++++++++++++++ server/remote.py | 8 ++- server/runner.py | 2 +- 6 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 server/operators/vidgod_index/manifest.json create mode 100644 server/operators/vidgod_index/run.py create mode 100644 server/operators/vidgod_roto/manifest.json create mode 100644 server/operators/vidgod_roto/run.py diff --git a/server/operators/vidgod_index/manifest.json b/server/operators/vidgod_index/manifest.json new file mode 100644 index 0000000..b64b118 --- /dev/null +++ b/server/operators/vidgod_index/manifest.json @@ -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"} + } + } +} diff --git a/server/operators/vidgod_index/run.py b/server/operators/vidgod_index/run.py new file mode 100644 index 0000000..2e1daeb --- /dev/null +++ b/server/operators/vidgod_index/run.py @@ -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 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) diff --git a/server/operators/vidgod_roto/manifest.json b/server/operators/vidgod_roto/manifest.json new file mode 100644 index 0000000..aa4e2b0 --- /dev/null +++ b/server/operators/vidgod_roto/manifest.json @@ -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)"} + } + } +} diff --git a/server/operators/vidgod_roto/run.py b/server/operators/vidgod_roto/run.py new file mode 100644 index 0000000..172d0b3 --- /dev/null +++ b/server/operators/vidgod_roto/run.py @@ -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 _{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) diff --git a/server/remote.py b/server/remote.py index e7cd301..4a095fd 100644 --- a/server/remote.py +++ b/server/remote.py @@ -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) diff --git a/server/runner.py b/server/runner.py index 0e87d85..6791b93 100644 --- a/server/runner.py +++ b/server/runner.py @@ -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: