diff --git a/server/operators/corridorkey_local/manifest.json b/server/operators/corridorkey_local/manifest.json new file mode 100644 index 0000000..403f39e --- /dev/null +++ b/server/operators/corridorkey_local/manifest.json @@ -0,0 +1,23 @@ +{ + "id": "corridorkey_local", + "name": "CorridorKey Green-Screen Unmix (local)", + "category": "video-prep", + "description": "Green/blue-screen clip or frame → true un-multiplied foreground color + linear alpha via Corridor's neural keyer (the corridorkey-mrp-mlx fork, MLX on Apple silicon). Preserves hair, motion blur and translucency — no binary roto masks. Input: a video shot on green/blue, or a single frame — NOT square (GVM's resize rejects smaller-edge ≥1024-after-scale square plates; 16:9/9:16 is fine). Output: zipped frame sequence (straight color + alpha) plus a comp preview when enabled. Free, fully local.", + "accepts": ["video", "image"], + "produces": ["archive"], + "resources": "gpu", + "entry": "run.py", + "python": "vendor/corridorkey/.venv/bin/python", + "params_schema": { + "type": "object", + "properties": { + "screen_color": {"type": "string", "enum": ["auto", "green", "blue"], "default": "auto", "description": "Screen color (blue = torch backend only for now)"}, + "backend": {"type": "string", "enum": ["auto", "mlx", "torch"], "default": "auto", "description": "Inference backend"}, + "despill": {"type": "integer", "minimum": 0, "maximum": 10, "default": 5, "description": "Despill strength"}, + "refiner": {"type": "number", "default": 1.0, "description": "Refiner strength multiplier"}, + "comp": {"type": "boolean", "default": true, "description": "Also render a comp preview"}, + "max_frames": {"type": "integer", "description": "Limit frames (quick tests)"}, + "image_size": {"type": "integer", "description": "Inference size override (default: model native)"} + } + } +} diff --git a/server/operators/corridorkey_local/run.py b/server/operators/corridorkey_local/run.py new file mode 100644 index 0000000..8b685f3 --- /dev/null +++ b/server/operators/corridorkey_local/run.py @@ -0,0 +1,115 @@ +"""corridorkey_local — Corridor's neural green-screen unmixer, headless. + +Shells out to the vendored CLI (vendor/corridorkey, the corridorkey-mrp-mlx fork): +stage the input as a clip → generate-alphas (GVM coarse hint) → run-inference with +every flag set (non-interactive) → zip Output/ as the result. + +Clip staging uses the job's outdir basename as the clip name, so concurrent jobs +can't collide; the shared ClipsForInference/Output dirs are cleaned afterwards +(win or lose) so the vendor tree doesn't accumulate gigabytes of frames. +""" +import argparse +import json +import os +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +ap = argparse.ArgumentParser() +ap.add_argument("--input", action="append", default=[]) +ap.add_argument("--outdir", required=True) +ap.add_argument("--params", default="{}") +a = ap.parse_args() +p = json.loads(a.params) + +if not a.input: + print("ERROR: no input clip/frame") + sys.exit(1) + +VENDOR = Path(__file__).resolve().parents[3] / "vendor" / "corridorkey" +CLI = VENDOR / ".venv" / "bin" / "corridorkey" +if not CLI.exists(): + print(f"ERROR: {CLI} missing — run the corridorkey install script on this node") + sys.exit(1) + +src = Path(a.input[0]) +outdir = Path(a.outdir) +clip_name = "ck_" + outdir.name # unique per job +clip_dir = VENDOR / "ClipsForInference" / clip_name +out_clip = VENDOR / "Output" / clip_name + +VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".mxf", ".m4v"} + + +def run(cmd): + print("+", " ".join(str(c) for c in cmd), flush=True) + res = subprocess.run(cmd, cwd=str(VENDOR), stdout=sys.stdout, stderr=subprocess.STDOUT) + if res.returncode != 0: + cleanup() + sys.exit(res.returncode) + + +def cleanup(): + shutil.rmtree(clip_dir, ignore_errors=True) + shutil.rmtree(out_clip, ignore_errors=True) + + +try: + clip_dir.mkdir(parents=True, exist_ok=True) + if src.suffix.lower() in VIDEO_EXTS: + shutil.copy(src, clip_dir / f"Input{src.suffix.lower()}") + else: # single frame → 1-frame sequence + (clip_dir / "Input").mkdir(exist_ok=True) + shutil.copy(src, clip_dir / "Input" / src.name) + + run([CLI, "generate-alphas"]) + hint = clip_dir / "AlphaHint" + if not hint.is_dir() or not any(hint.iterdir()): # generate-alphas exits 0 even when GVM fails + print("ERROR: no alpha hints generated — are the GVM weights installed? " + "(vendor/corridorkey: uv run hf download geyongtao/gvm --local-dir gvm_core/weights)") + cleanup() + sys.exit(1) + + cmd = [CLI, "run-inference", + "--backend", str(p.get("backend", "auto")), + "--srgb", # camera clips; EXR pipelines can re-run --linear + "--despill", str(int(p.get("despill", 5))), + "--despeckle", + "--refiner", str(float(p.get("refiner", 1.0))), + "--screen-color", str(p.get("screen_color", "auto")), + "--comp" if p.get("comp", True) else "--no-comp", + # cpu-post default: gpu post-processing dies with an MPSNDArray buffer assertion + # on both backends (mac, 2026-07). Opt back in with {"gpu_post": true} to retest. + "--gpu-post" if p.get("gpu_post") else "--cpu-post", + "--skip-existing"] + if p.get("max_frames"): + cmd += ["--max-frames", str(int(p["max_frames"]))] + if p.get("image_size"): + cmd += ["--image-size", str(int(p["image_size"]))] + run(cmd) + + if not out_clip.is_dir() or not any(out_clip.rglob("*")): + print("ERROR: inference produced no output") + cleanup() + sys.exit(1) + + zpath = outdir / f"{src.stem}_corridorkey.zip" + outputs = [] + with zipfile.ZipFile(zpath, "w", zipfile.ZIP_STORED) as z: # frames are already compressed + for f in sorted(out_clip.rglob("*")): + if f.is_file(): + z.write(f, f.relative_to(out_clip)) + outputs.append({"path": zpath.name, "meta": {"tool": "corridorkey-mrp-mlx"}}) + + previews = sorted(out_clip.rglob("*omp*.mp4")) or sorted(out_clip.rglob("*.mp4")) + if previews: # comp preview as its own asset + prev = outdir / f"{src.stem}_comp{previews[0].suffix}" + shutil.copy(previews[0], prev) + outputs.append({"path": prev.name, "meta": {"tool": "corridorkey-comp"}}) + + (outdir / "result.json").write_text(json.dumps({"outputs": outputs})) + print(f"done: {', '.join(o['path'] for o in outputs)}", flush=True) +finally: + cleanup()