From 8c84243b61a1aeb2fcb440014346856c3bdd65a8 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 24 Aug 2026 15:42:52 +1000 Subject: [PATCH] phase 2: vg-remove (ProPainter), vg-interp (RIFE), vg-cutie, farm ops, zoo mirror - vg-remove: object/logo/watermark removal via ProPainter on MPS; static --box, SAM2-tracked --point for moving objects, or user --mask. Output always scaled back to source dims (imageio macro-block-pads ProPainter output). - vg-interp: RIFE frame interpolation via rife-ncnn-vulkan (universal binary, native Metal/MoltenVK, rife-v4.6); smooth (fps x N) or --slowmo. - vg-cutie: Cutie interactive segmentation GUI launcher (local GUI session). - setup/fetch_phase2.sh: idempotent clones + weights + deps + patches. - patches: propainter-cv2-reader (torchvision >= 0.23 removed read_video), cutie-device (get_default_model hard-coded .cuda(); now cuda->mps->cpu). - smoke_test.sh: adds the RIFE lane (skips when not fetched). - Farm: vidgod_roto/vidgod_index operators live in MODELBEAST (8965d22), verified from JING5; weights mirrored to NAS modelzoo/vidgod-weights. All lanes verified on ultra 2026-08-24: de-logo reconstruction eyeballed clean, 24->48fps interp, Cutie headless propagation PASS, smoke test 4/4. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 19 +++- README.md | 30 +++++- bin/vg-cutie | 15 +++ bin/vg-interp | 65 ++++++++++++ bin/vg-remove | 149 ++++++++++++++++++++++++++++ patches/cutie-device.patch | 19 ++++ patches/propainter-cv2-reader.patch | 25 +++++ setup/fetch_phase2.sh | 68 +++++++++++++ setup/smoke_test.sh | 11 ++ 9 files changed, 392 insertions(+), 9 deletions(-) create mode 100755 bin/vg-cutie create mode 100755 bin/vg-interp create mode 100755 bin/vg-remove create mode 100644 patches/cutie-device.patch create mode 100644 patches/propainter-cv2-reader.patch create mode 100755 setup/fetch_phase2.sh diff --git a/CLAUDE.md b/CLAUDE.md index 07fa293..07bd937 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,21 @@ Read README.md first for what the tools do. This file is the stuff an agent need it's what makes them run inside the right venv from any checkout location). - Verified 2026-08-24 on ultra: all five lanes, including a real SAM2+MatAnyone cutout (1917 trench demo clip, 2 clicks, matte held over 6s; ~3.4 fr/s at 1920×804 on MPS). -- The MatAnyone torchvision fix lives in `patches/matanyone-cv2-reader.patch`, applied by - setup step 6 (guard: grep CAP_PROP_FPS). Upstream may eventually fix this — if the patch - stops applying, check whether their `read_frame_from_videos` already avoids - `torchvision.io.read_video`, and drop the patch step if so. +- Three tracked patches, all applied idempotently by the setup scripts (grep guards): + `matanyone-cv2-reader.patch` + `propainter-cv2-reader.patch` (torchvision ≥0.23 removed + `read_video` — both repos read via cv2 now) and `cutie-device.patch` (get_default_model + hard-coded `.cuda()`; now cuda→mps→cpu like its own interactive_demo). If a patch stops + applying, upstream probably fixed it — check and drop the step. +- Phase 2 verified 2026-08-24 on ultra: vg-remove de-logo (ProPainter on MPS), + vg-interp 24→48fps (RIFE ncnn universal binary, native Metal/MoltenVK), Cutie headless + mask propagation on MPS (GUI click-test needs a local session — not SSH-verifiable). +- Farm ops `vidgod_roto` / `vidgod_index` live in the MODELBEAST repo (commit 8965d22): + stdlib wrappers calling this repo's bin CLIs; manifest `requires_path` gates the primary + from grabbing them (that gating patch is in MODELBEAST server/remote.py + runner.py); + allowlisted for node m1 (ultra) in nodes.json (primary-only, gitignored). Both lanes + verified end-to-end with jobs submitted from JING5. +- Weights mirror: `/Volumes/projects/modelzoo/vidgod-weights/` (NAS via ultra's SMB + mounts) — models/, propainter/, cutie/, rife/. Listed in modelzoo MANIFEST.md. - Test with `setup/smoke_test.sh` (fast) / `--roto` (full pipeline). Test media is synthesized (ffmpeg lavfi + macOS `say`) — nothing copyrighted is committed. - The clip library sqlite lives at `library/clips.sqlite` **per checkout** — the real library diff --git a/README.md b/README.md index 1c19c85..7de67af 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ instead of rotoscoping splines. | `bin/vg-index PATHS` | Shot-detect (PySceneDetect) + transcribe (mlx-whisper large-v3-turbo) + thumbnail everything into `library/clips.sqlite` (FTS5). Resumable. | | `bin/vg-find WORDS` | Full-text search the dialogue; shows the enclosing shot. `--cut DIR` exports each matching shot as a ProRes clip. `--shots VIDEO` lists a video's shots. | | `bin/vg-beats SONG` | Beat-track a music file (librosa) → `.beats.csv` + a **Resolve marker EDL** (import: right-click timeline in media pool → Timelines → Import → Timeline Markers from EDL). | +| `bin/vg-remove CLIP --box x1,y1,x2,y2` | Erase logos/watermarks/objects (ProPainter inpainting). `--point x,y` makes SAM2 track a *moving* object first; `--mask` takes your own. | +| `bin/vg-interp CLIP --factor 2` | RIFE frame interpolation (native Metal binary): smooth motion at fps×factor, or `--slowmo` for factor× slow motion. | +| `bin/vg-cutie --video CLIP` | Cutie interactive segmentation GUI — scribble on the actor, propagate, correct frame by frame; exports per-frame masks (run from a GUI session, not SSH). | ## Install @@ -24,6 +27,7 @@ Needs: Apple Silicon Mac, `brew install ffmpeg uv`, ~4GB disk for venvs + models ```bash git clone ssh://git@100.71.119.27:222/monster/vidgod.git && cd vidgod ./setup/setup_venvs.sh # venvs, SAM2 + MatAnyone clones, checkpoints, patches (~10 min) +./setup/fetch_phase2.sh # ProPainter + Cutie + RIFE: clones, weights, deps, patches ./setup/smoke_test.sh # fast lanes; add --roto for the full cutout pipeline ``` @@ -56,9 +60,25 @@ bin/vg-roto ~/mv/pulls/003_*.mov --point 970,220 # → ProRes 4444 alpha cuto - Rough speed on an M1 Ultra: MatAnyone ≈ 3.4 fr/s at 1920×804; whisper large-v3-turbo transcribes far faster than realtime; ProRes transcodes are ffmpeg-bound. -## Phase 2 (not built yet) +## Farm integration (MODELBEAST) -ProPainter (object/logo removal), RIFE interpolation, Cutie interactive segmentation GUI, -MODELBEAST farm operators for roto/index jobs, model mirror to NAS modelzoo. Upscaling -old footage already exists fleet-side (`seedvr2_upscale` on the farm); Resolve's Optical -Flow retime covers slow-mo. +Two operators live in the MODELBEAST repo (`server/operators/`): **`vidgod_roto`** +(gpu lane — video + click points → the three roto outputs as job assets) and +**`vidgod_index`** (cpu lane — index a clip into the node's library; the video is +copied to `library/ingest/` so entries persist). They're thin stdlib wrappers that +shell out to this repo's `bin/` CLIs, gated by manifest `requires_path` so only nodes +with `~/Documents/VIDGOD` set up will take the jobs (allowlisted on ultra). Submit +from any fleet box with its own MB token; outputs come back through the assets API. + +## Weights mirror + +All checkpoints (SAM2.1, MatAnyone, Cutie, ProPainter, the RIFE bundle) are mirrored +to the NAS at `/Volumes/projects/modelzoo/vidgod-weights/` — restore instead of +re-downloading when standing up a new box. + +## Maybe later + +Practical-RIFE v4.25+ (newer models than the ncnn bundle), Depth Anything V2 depth-matte +lane, DDColor colorization, a vg-roto mode that takes Cutie mask dirs end-to-end. +Upscaling already exists fleet-side (`seedvr2_upscale` on the farm); Resolve's Optical +Flow retime covers basic slow-mo without RIFE. diff --git a/bin/vg-cutie b/bin/vg-cutie new file mode 100755 index 0000000..24ad7f2 --- /dev/null +++ b/bin/vg-cutie @@ -0,0 +1,15 @@ +#!/bin/zsh +# vg-cutie — launch the Cutie interactive segmentation GUI (scribble/click on the +# actor, propagate, correct, export per-frame masks). +# +# Usage: vg-cutie --video CLIP.mp4 [--num_objects N] [--workspace DIR] +# vg-cutie --images FRAME_DIR +# +# Needs a GUI session on this Mac (run from Terminal, not plain SSH). +# Masks land in the workspace dir (default ./workspace//masks) — +# feed them to vg-remove via --mask, or use them as roto sources. +export PATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin +export PYTORCH_ENABLE_MPS_FALLBACK=1 +VG="$(cd "$(dirname "$0")/.." && pwd)" +cd "$VG/tools/Cutie" || exit 1 +exec "$VG/venvs/roto/bin/python" interactive_demo.py "$@" diff --git a/bin/vg-interp b/bin/vg-interp new file mode 100755 index 0000000..4c5fc41 --- /dev/null +++ b/bin/vg-interp @@ -0,0 +1,65 @@ +#!/bin/zsh +# vg-interp — RIFE frame interpolation (rife-ncnn-vulkan, Metal via MoltenVK). +# Usage: vg-interp CLIP [--factor 2|4] [--slowmo] [--out FILE] [--model rife-v4.6] +# default: smooth motion (fps × factor, audio kept) +# --slowmo: same fps, clip runs factor× longer (audio dropped) +# Output: ProRes LT .mov next to the input (CLIP_interp.mov / CLIP_slowmo.mov) +export PATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin +set -u +VG="$(cd "$(dirname "$0")/.." && pwd)" +RIFE="$VG/tools/rife/rife-ncnn-vulkan" +[[ -x "$RIFE" ]] || { echo "rife not installed — run setup/fetch_phase2.sh" >&2; exit 1; } + +IN="${1:?usage: vg-interp CLIP [--factor N] [--slowmo] [--out FILE] [--model NAME]}" +shift +FACTOR=2; SLOWMO=0; OUT=""; MODEL="rife-v4.6" +while [[ $# -gt 0 ]]; do + case "$1" in + --factor) FACTOR="$2"; shift 2 ;; + --slowmo) SLOWMO=1; shift ;; + --out) OUT="$2"; shift 2 ;; + --model) MODEL="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 1 ;; + esac +done +IN="${IN:A}" +[[ -f "$IN" ]] || { echo "no such file: $IN" >&2; exit 1; } + +fps=$(ffprobe -v error -select_streams v:0 -show_entries stream=avg_frame_rate -of csv=p=0 "$IN") +fps_num="${fps%%/*}"; fps_den="${fps##*/}" +in_fps=$(( fps_num * 1.0 / fps_den )) + +W=$(mktemp -d "${TMPDIR:-/tmp}/vginterp.XXXXXX") +trap "rm -rf $W" EXIT +mkdir -p "$W/in" "$W/out" + +echo "extracting frames ..." +ffmpeg -hide_banner -loglevel error -y -i "$IN" "$W/in/%08d.png" $n_out frames (${MODEL})" + +"$RIFE" -i "$W/in" -o "$W/out" -m "$VG/tools/rife/$MODEL" -n "$n_out" -f "%08d.png" || exit 1 +n_got=$(ls "$W/out" | wc -l | tr -d " ") +echo "rife produced $n_got frames" + +if [[ $SLOWMO -eq 1 ]]; then + out_fps=$in_fps + DEF="${IN%.*}_slowmo.mov" +else + out_fps=$(( in_fps * FACTOR )) + DEF="${IN%.*}_interp.mov" +fi +OUT="${OUT:-$DEF}" +out_fps=$(printf "%g" "$out_fps") + +echo "encoding ProRes at ${out_fps}fps ..." +if [[ $SLOWMO -eq 1 ]]; then + ffmpeg -hide_banner -loglevel error -y -framerate "$out_fps" -i "$W/out/%08d.png" \ + -c:v prores_ks -profile:v 1 -pix_fmt yuv422p10le "$OUT" 0).cpu().numpy().squeeze().astype(np.uint8) * 255 + Image.fromarray(m).save(masks_dir / f"{fidx:05d}.png") + n += 1 + print(f"SAM2: {n} frames tracked") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("video") + ap.add_argument("--box", help="x1,y1,x2,y2 static region to remove") + ap.add_argument("--point", action="append", default=[], help="x,y on the object (repeatable)") + ap.add_argument("--mask", help="mask PNG (white = remove) or dir of per-frame masks") + ap.add_argument("--frame", type=int, default=0) + ap.add_argument("--resize", type=float, default=1.0, help="inpaint at this scale (0.5 = half res)") + ap.add_argument("--dilate", type=int, default=4) + ap.add_argument("--sub-len", type=int, default=80) + ap.add_argument("--out", default=None) + args = ap.parse_args() + + video = Path(args.video).expanduser().resolve() + if not video.exists(): + sys.exit(f"no such file: {video}") + stem = video.stem + outdir = Path(args.out).expanduser() if args.out else video.parent / f"{stem}_removed" + work = outdir / "work" + work.mkdir(parents=True, exist_ok=True) + w, h, fps = probe_video(video) + + if args.mask: + mask = Path(args.mask).expanduser().resolve() + elif args.box: + mask = work / "box_mask.png" + box_mask(w, h, [float(v) for v in args.box.split(",")], mask) + print(f"static mask for box {args.box}") + elif args.point: + mask = work / "masks" + points = [tuple(float(v) for v in p.split(",")) for p in args.point] + sam2_track_masks(video, points, args.frame, mask) + else: + sys.exit("need --box, --point, or --mask") + + print("ProPainter: inpainting (the slow part) ...") + cmd = [RPY, "inference_propainter.py", "--video", str(video), "--mask", str(mask), + "--output", str(work), "--mask_dilation", str(args.dilate), + "--subvideo_length", str(args.sub_len), "--save_fps", str(round(fps))] + if args.resize != 1.0: + cmd += ["--resize_ratio", str(args.resize)] + r = subprocess.run([str(c) for c in cmd], cwd=PROPAINTER) + if r.returncode != 0: + sys.exit("ProPainter failed") + + results = sorted((work / stem).glob("inpaint_out.mp4")) or sorted(work.rglob("inpaint_out.mp4")) + if not results: + sys.exit(f"no inpaint_out.mp4 under {work}") + clean = results[-1] + + out_mov = outdir / f"{stem}_clean.mov" + out_prev = outdir / f"{stem}_clean_preview.mp4" + print("composing ProRes + preview ...") + # always scale back to source dims: imageio pads ProPainter's output to a + # macro-block multiple (e.g. 804 -> 816) even at native res + scale = f"scale={w}:{h}:flags=bicubic" + run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", clean, "-i", video, + "-filter_complex", f"[0:v]{scale},format=yuv422p10le[v]", + "-map", "[v]", "-map", "1:a:0?", "-c:v", "prores_ks", "-profile:v", "1", + "-c:a", "pcm_s16le", "-shortest", out_mov]) + run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", out_mov, + "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "aac", out_prev]) + print(f"\ndone:\n {out_mov}\n {out_prev} <- preview") + + +if __name__ == "__main__": + main() diff --git a/patches/cutie-device.patch b/patches/cutie-device.patch new file mode 100644 index 0000000..1671c2b --- /dev/null +++ b/patches/cutie-device.patch @@ -0,0 +1,19 @@ +diff --git a/cutie/utils/get_default_model.py b/cutie/utils/get_default_model.py +index 3fa5e63..bdf91d5 100644 +--- a/cutie/utils/get_default_model.py ++++ b/cutie/utils/get_default_model.py +@@ -20,9 +20,11 @@ def get_default_model() -> CUTIE: + cfg['weights'] = os.path.join(weight_dir, 'cutie-base-mega.pth') + get_dataset_cfg(cfg) + +- # Load the network weights +- cutie = CUTIE(cfg).cuda().eval() +- model_weights = torch.load(cfg.weights) ++ # Load the network weights (cuda -> mps -> cpu, like interactive_demo.py) ++ device = ('cuda' if torch.cuda.is_available() else ++ 'mps' if torch.backends.mps.is_available() else 'cpu') ++ cutie = CUTIE(cfg).to(device).eval() ++ model_weights = torch.load(cfg.weights, map_location=device) + cutie.load_weights(model_weights) + + return cutie diff --git a/patches/propainter-cv2-reader.patch b/patches/propainter-cv2-reader.patch new file mode 100644 index 0000000..8eec3a6 --- /dev/null +++ b/patches/propainter-cv2-reader.patch @@ -0,0 +1,25 @@ +diff --git a/inference_propainter.py b/inference_propainter.py +index 4d7f92f..b093954 100644 +--- a/inference_propainter.py ++++ b/inference_propainter.py +@@ -49,10 +49,16 @@ def resize_frames(frames, size=None): + def read_frame_from_videos(frame_root): + if frame_root.endswith(('mp4', 'mov', 'avi', 'MP4', 'MOV', 'AVI')): # input video path + video_name = os.path.basename(frame_root)[:-4] +- vframes, aframes, info = torchvision.io.read_video(filename=frame_root, pts_unit='sec') # RGB +- frames = list(vframes.numpy()) +- frames = [Image.fromarray(f) for f in frames] +- fps = info['video_fps'] ++ # torchvision.io.read_video was removed in torchvision >= 0.23 — read via cv2 ++ cap = cv2.VideoCapture(frame_root) ++ fps = cap.get(cv2.CAP_PROP_FPS) or 24 ++ frames = [] ++ while True: ++ ok, frame = cap.read() ++ if not ok: ++ break ++ frames.append(Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))) ++ cap.release() + else: + video_name = os.path.basename(frame_root) + frames = [] diff --git a/setup/fetch_phase2.sh b/setup/fetch_phase2.sh new file mode 100755 index 0000000..2b14452 --- /dev/null +++ b/setup/fetch_phase2.sh @@ -0,0 +1,68 @@ +#!/bin/zsh +# VIDGOD phase 2 — ProPainter (vg-remove), Cutie (vg-cutie), RIFE (vg-interp): +# clones, weights, deps, patches. Idempotent: re-run to resume. +# Heartbeat: ~/.jobs/vidgod-phase2.status +export PATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin +VG="$(cd "$(dirname "$0")/.." && pwd)" +JOB=vidgod-phase2 +hb(){ mkdir -p ~/.jobs; echo "$(date '+%F %T') | $1 | $2" > ~/.jobs/$JOB.status; } +die(){ hb "FAILED" "$1"; echo "FATAL: $1" >&2; exit 1; } +get(){ # get URL DEST + [ -s "$2" ] && return 0 + curl -sfL --retry 3 -o "$2.part" "$1" || return 1 + mv "$2.part" "$2" +} +RPY="$VG/venvs/roto/bin/python" +[ -x "$RPY" ] || die "run setup_venvs.sh first (no roto venv)" + +hb "1/8" "clone ProPainter" +[ -d "$VG/tools/ProPainter" ] || git clone --depth 1 https://github.com/sczhou/ProPainter "$VG/tools/ProPainter" || die "propainter clone" + +hb "2/8" "ProPainter weights (4 files, ~500MB)" +mkdir -p "$VG/tools/ProPainter/weights" +B=https://github.com/sczhou/ProPainter/releases/download/v0.1.0 +get $B/ProPainter.pth "$VG/tools/ProPainter/weights/ProPainter.pth" || die "ProPainter.pth" +get $B/recurrent_flow_completion.pth "$VG/tools/ProPainter/weights/recurrent_flow_completion.pth" || die "rfc.pth" +get $B/raft-things.pth "$VG/tools/ProPainter/weights/raft-things.pth" || die "raft.pth" +get $B/i3d_rgb_imagenet.pt "$VG/tools/ProPainter/weights/i3d_rgb_imagenet.pt" || echo "WARN: i3d optional weight failed" + +hb "3/8" "patch ProPainter video reader (torchvision >= 0.23 removed read_video)" +if ! grep -q "CAP_PROP_FPS" "$VG/tools/ProPainter/inference_propainter.py"; then + git -C "$VG/tools/ProPainter" apply "$VG/patches/propainter-cv2-reader.patch" || die "propainter patch" + echo "patch applied" +fi + +hb "4/8" "clone Cutie" +[ -d "$VG/tools/Cutie" ] || git clone --depth 1 https://github.com/hkchengrex/Cutie "$VG/tools/Cutie" || die "cutie clone" + +hb "5/8" "Cutie weights + device patch" +mkdir -p "$VG/tools/Cutie/weights" +get https://github.com/hkchengrex/Cutie/releases/download/v1.0/cutie-base-mega.pth "$VG/tools/Cutie/weights/cutie-base-mega.pth" || die "cutie weights" +if ! grep -q "mps" "$VG/tools/Cutie/cutie/utils/get_default_model.py"; then + git -C "$VG/tools/Cutie" apply "$VG/patches/cutie-device.patch" || die "cutie patch" + echo "patch applied" +fi + +hb "6/8" "RIFE ncnn macos build (universal binary + models, ~450MB zip)" +mkdir -p "$VG/tools/rife" +if [ ! -x "$VG/tools/rife/rife-ncnn-vulkan" ]; then + get https://github.com/nihui/rife-ncnn-vulkan/releases/download/20221029/rife-ncnn-vulkan-20221029-macos.zip /tmp/rife.zip || die "rife zip" + ditto -x -k /tmp/rife.zip "$VG/tools/rife-extract" || die "rife unzip" + inner=$(find "$VG/tools/rife-extract" -name "rife-ncnn-vulkan" -type f | head -1) + [ -n "$inner" ] || die "rife binary not in zip" + cp -R "$(dirname "$inner")"/* "$VG/tools/rife/" + chmod +x "$VG/tools/rife/rife-ncnn-vulkan" + rm -rf "$VG/tools/rife-extract" /tmp/rife.zip +fi + +hb "7/8" "python deps (ProPainter + Cutie) into roto venv" +uv pip install --python "$RPY" einops scipy av addict pyyaml future matplotlib \ + PySide6 easydict gitpython gdown hickle pycocotools charset-normalizer || die "deps" +uv pip install --python "$RPY" --no-deps -e "$VG/tools/Cutie" || die "cutie -e" + +hb "8/8" "smoke: imports + rife arch" +"$RPY" -c "import cutie, matplotlib, einops, scipy; print('phase2 imports ok')" || die "import smoke" +file "$VG/tools/rife/rife-ncnn-vulkan" | grep -q arm64 || echo "WARN: rife binary has no arm64 slice" + +hb "DONE" "phase2 tools + weights + patches ready" +echo "PHASE2 FETCH DONE — vg-remove / vg-interp / vg-cutie ready" diff --git a/setup/smoke_test.sh b/setup/smoke_test.sh index fc44f65..7882de8 100755 --- a/setup/smoke_test.sh +++ b/setup/smoke_test.sh @@ -39,6 +39,17 @@ else echo "SKIP: no 'say' binary for speech synthesis" fi +if [ -x "$VG/tools/rife/rife-ncnn-vulkan" ]; then + echo "== 4. vg-interp (RIFE 2x on a tiny clip) ==" + ffmpeg -hide_banner -loglevel error -y -f lavfi -i "testsrc2=duration=2:size=320x180:rate=25" -c:v libx264 -crf 20 "$T/interp_in.mp4" + "$VG/bin/vg-interp" "$T/interp_in.mp4" --factor 2 --out "$T/interp_out.mov" >/dev/null 2>&1 + frames=$(ffprobe -v error -select_streams v:0 -show_entries stream=nb_frames -of csv=p=0 "$T/interp_out.mov" 2>/dev/null) + [ -n "$frames" ] && [ "$frames" -ge 90 ] + check $? "vg-interp doubled the frame count ($frames frames)" +else + echo "SKIP: rife not installed (run setup/fetch_phase2.sh)" +fi + if [ "${1:-}" = "--roto" ]; then echo "== 4. vg-roto (bundled demo clip, full SAM2+MatAnyone) ==" DEMO="$VG/tools/MatAnyone/inputs/video/test-sample3.mp4"