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:
parent
03e8676db0
commit
8965d2240b
19
server/operators/vidgod_index/manifest.json
Normal file
19
server/operators/vidgod_index/manifest.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
server/operators/vidgod_index/run.py
Normal file
61
server/operators/vidgod_index/run.py
Normal 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)
|
||||
22
server/operators/vidgod_roto/manifest.json
Normal file
22
server/operators/vidgod_roto/manifest.json
Normal 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)"}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
server/operators/vidgod_roto/run.py
Normal file
58
server/operators/vidgod_roto/run.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user