Five CLIs around DaVinci Resolve music-video editing, all inference local on Apple Silicon (MPS/MLX): - vg-roto: SAM 2.1 + MatAnyone click-to-cutout -> ProRes 4444 alpha - vg-index / vg-find: PySceneDetect + mlx-whisper searchable clip library - vg-beats: librosa beat grid -> Resolve marker EDL - vg-transcode: legacy codecs -> ProRes LT, deinterlaced, resumable setup/setup_venvs.sh rebuilds venvs, tool clones, checkpoints and applies patches/matanyone-cv2-reader.patch (torchvision >= 0.23 removed read_video). Verified end-to-end on ultra 2026-08-24; setup/smoke_test.sh covers the lanes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
223 lines
9.8 KiB
Bash
Executable File
223 lines
9.8 KiB
Bash
Executable File
#!/bin/sh
|
|
"exec" "`dirname $0`/../venvs/roto/bin/python" "$0" "$@"
|
|
"""vg-roto — one-click actor cutout: SAM 2.1 + MatAnyone on Apple Silicon.
|
|
|
|
Usage:
|
|
vg-roto CLIP.mp4 --point 640,360 click on the actor (frame 0 coords)
|
|
vg-roto CLIP.mp4 --point 640,360 --point 700,200 multiple clicks refine the pick
|
|
vg-roto CLIP.mp4 --box 400,100,900,700 or a rough box around them
|
|
vg-roto CLIP.mp4 --point ... --neg 100,100 negative click excludes a region
|
|
vg-roto CLIP.mp4 --grab-frame just save frame 0 as a PNG so you
|
|
can find click coords, then exit
|
|
|
|
Options: --out DIR (default CLIP_roto/), --frame N (prompt+start on frame N),
|
|
--mode matte|mask (matte = soft alpha via MatAnyone, default;
|
|
mask = hard binary via SAM2 video propagation),
|
|
--max-size N (downscale min side for inference, e.g. 720 for speed)
|
|
|
|
Output in DIR:
|
|
CLIP_alpha.mov ProRes 4444 with real alpha -> drop straight into Resolve
|
|
CLIP_matte.mov the matte alone (grayscale)
|
|
CLIP_green.mp4 preview comp over green
|
|
"""
|
|
import argparse, os, shutil, subprocess, sys
|
|
from pathlib import Path
|
|
|
|
os.environ["PATH"] = "/opt/homebrew/bin:" + os.environ.get("PATH", "")
|
|
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
|
|
|
|
VG = Path(__file__).resolve().parent.parent
|
|
SAM2_CKPT = VG / "models/sam2.1_hiera_large.pt"
|
|
SAM2_CFG = "configs/sam2.1/sam2.1_hiera_l.yaml"
|
|
MATANYONE = VG / "tools/MatAnyone"
|
|
RPY = VG / "venvs/roto/bin/python"
|
|
|
|
|
|
def run(cmd, **kw):
|
|
r = subprocess.run([str(c) for c in cmd], **kw)
|
|
if r.returncode != 0:
|
|
sys.exit(f"command failed: {' '.join(str(c) for c in cmd)}")
|
|
return r
|
|
|
|
|
|
def probe_video(path):
|
|
out = subprocess.run(
|
|
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
|
"-show_entries", "stream=width,height,avg_frame_rate,nb_frames",
|
|
"-of", "csv=p=0", str(path)], capture_output=True, text=True).stdout.strip()
|
|
w, h, fr = out.split(",")[:3]
|
|
num, den = fr.split("/")
|
|
return int(w), int(h), (float(num) / float(den) if float(den) else 25.0)
|
|
|
|
|
|
def grab_frame(video, n, fps, dest):
|
|
run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
|
"-ss", f"{n / fps:.6f}", "-i", video, "-frames:v", "1", dest])
|
|
|
|
|
|
def sam2_first_frame_mask(frame_png, points, neg_points, box, mask_png):
|
|
import numpy as np, torch
|
|
from PIL import Image
|
|
from sam2.build_sam import build_sam2
|
|
from sam2.sam2_image_predictor import SAM2ImagePredictor
|
|
|
|
device = "mps" if torch.backends.mps.is_available() else "cpu"
|
|
print(f"SAM2: loading {SAM2_CKPT.name} on {device} ...")
|
|
model = build_sam2(SAM2_CFG, str(SAM2_CKPT), device=device)
|
|
pred = SAM2ImagePredictor(model)
|
|
img = np.array(Image.open(frame_png).convert("RGB"))
|
|
pred.set_image(img)
|
|
|
|
pc = pl = bx = None
|
|
if points or neg_points:
|
|
pc = np.array(points + neg_points, dtype=np.float32)
|
|
pl = np.array([1] * len(points) + [0] * len(neg_points), dtype=np.int32)
|
|
if box:
|
|
bx = np.array(box, dtype=np.float32)
|
|
masks, scores, _ = pred.predict(point_coords=pc, point_labels=pl, box=bx, multimask_output=True)
|
|
best = int(np.argmax(scores))
|
|
m = (masks[best] > 0).astype(np.uint8) * 255
|
|
Image.fromarray(m).save(mask_png)
|
|
cov = 100.0 * (m > 0).mean()
|
|
print(f"SAM2: mask score {scores[best]:.3f}, covers {cov:.1f}% of frame")
|
|
if cov < 0.05 or cov > 95:
|
|
print("WARNING: mask looks degenerate — check your click point (--grab-frame to inspect)")
|
|
return mask_png
|
|
|
|
|
|
def matanyone_matte(video, mask_png, outdir, max_size):
|
|
env = dict(os.environ)
|
|
cmd = [RPY, "inference_matanyone.py", "-i", str(video), "-m", str(mask_png),
|
|
"-o", str(outdir), "--max_size", str(max_size)]
|
|
print("MatAnyone: propagating matte (this is the slow part) ...")
|
|
r = subprocess.run([str(c) for c in cmd], cwd=MATANYONE, env=env)
|
|
if r.returncode != 0:
|
|
sys.exit("MatAnyone failed")
|
|
stem = Path(video).stem
|
|
pha = outdir / f"{stem}_pha.mp4"
|
|
if not pha.exists():
|
|
cands = sorted(outdir.glob("*_pha.mp4"))
|
|
if not cands:
|
|
sys.exit(f"MatAnyone produced no *_pha.mp4 in {outdir}")
|
|
pha = cands[-1]
|
|
return pha
|
|
|
|
|
|
def sam2_video_masks(video, points, neg_points, box, frame_n, workdir, fps):
|
|
import numpy as np, torch
|
|
from PIL import Image
|
|
from sam2.build_sam import build_sam2_video_predictor
|
|
|
|
frames = workdir / "frames"
|
|
frames.mkdir(parents=True, exist_ok=True)
|
|
if not list(frames.glob("*.jpg")):
|
|
run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", video,
|
|
"-q:v", "2", "-start_number", "0", frames / "%05d.jpg"])
|
|
device = "mps" if torch.backends.mps.is_available() else "cpu"
|
|
print(f"SAM2 video: loading on {device} ...")
|
|
pred = build_sam2_video_predictor(SAM2_CFG, str(SAM2_CKPT), device=device)
|
|
state = pred.init_state(video_path=str(frames), offload_video_to_cpu=True,
|
|
offload_state_to_cpu=True)
|
|
pc = pl = bx = None
|
|
if points or neg_points:
|
|
pc = np.array(points + neg_points, dtype=np.float32)
|
|
pl = np.array([1] * len(points) + [0] * len(neg_points), dtype=np.int32)
|
|
if box:
|
|
bx = np.array(box, dtype=np.float32)
|
|
pred.add_new_points_or_box(state, frame_idx=frame_n, obj_id=1,
|
|
points=pc, labels=pl, box=bx)
|
|
masks_dir = workdir / "masks"
|
|
masks_dir.mkdir(exist_ok=True)
|
|
n = 0
|
|
for fidx, _, logits in pred.propagate_in_video(state):
|
|
m = (logits[0] > 0).cpu().numpy().squeeze().astype(np.uint8) * 255
|
|
Image.fromarray(m).save(masks_dir / f"{fidx:05d}.png")
|
|
n += 1
|
|
if n % 50 == 0:
|
|
print(f" propagated {n} frames")
|
|
print(f"SAM2 video: {n} frames masked")
|
|
matte = workdir / "matte_raw.mp4"
|
|
run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-framerate", f"{fps}",
|
|
"-start_number", str(frame_n), "-i", masks_dir / "%05d.png",
|
|
"-c:v", "libx264", "-crf", "12", "-pix_fmt", "yuv420p", matte])
|
|
return matte
|
|
|
|
|
|
def compose_outputs(video, pha, outdir, stem, fps, w, h):
|
|
alpha_mov = outdir / f"{stem}_alpha.mov"
|
|
matte_mov = outdir / f"{stem}_matte.mov"
|
|
green_mp4 = outdir / f"{stem}_green.mp4"
|
|
scale = f"scale={w}:{h}:flags=bicubic,format=gray"
|
|
print("composing ProRes 4444 + previews ...")
|
|
run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", video, "-i", pha,
|
|
"-filter_complex", f"[1:v]{scale}[a];[0:v][a]alphamerge,format=yuva444p10le[out]",
|
|
"-map", "[out]", "-map", "0:a:0?", "-c:v", "prores_ks", "-profile:v", "4444",
|
|
"-c:a", "pcm_s16le", "-shortest", alpha_mov])
|
|
run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", pha,
|
|
"-vf", scale, "-c:v", "prores_ks", "-profile:v", "1", matte_mov])
|
|
run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", video, "-i", pha,
|
|
"-filter_complex",
|
|
f"color=0x00b140:size={w}x{h}:rate={fps}[bg];"
|
|
f"[1:v]{scale}[a];[0:v][a]alphamerge[fg];[bg][fg]overlay=shortest=1,format=yuv420p[out]",
|
|
"-map", "[out]", "-c:v", "libx264", "-crf", "18", green_mp4])
|
|
return alpha_mov, matte_mov, green_mp4
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("video")
|
|
ap.add_argument("--point", action="append", default=[], help="x,y positive click (repeatable)")
|
|
ap.add_argument("--neg", action="append", default=[], help="x,y negative click (repeatable)")
|
|
ap.add_argument("--box", help="x1,y1,x2,y2")
|
|
ap.add_argument("--frame", type=int, default=0, help="frame to prompt on / start from")
|
|
ap.add_argument("--mode", choices=["matte", "mask"], default="matte")
|
|
ap.add_argument("--max-size", type=int, default=-1, help="downscale min side for inference")
|
|
ap.add_argument("--out", default=None)
|
|
ap.add_argument("--grab-frame", action="store_true", help="save the prompt frame as PNG and exit")
|
|
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}_roto"
|
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
work = outdir / "work"
|
|
work.mkdir(exist_ok=True)
|
|
|
|
w, h, fps = probe_video(video)
|
|
frame_png = work / f"frame{args.frame:05d}.png"
|
|
grab_frame(video, args.frame, fps, frame_png)
|
|
if args.grab_frame:
|
|
print(f"prompt frame saved: {frame_png} ({w}x{h}) — open it, note x,y of your click")
|
|
return
|
|
|
|
points = [tuple(float(v) for v in p.split(",")) for p in args.point]
|
|
negs = [tuple(float(v) for v in p.split(",")) for p in args.neg]
|
|
box = [float(v) for v in args.box.split(",")] if args.box else None
|
|
if not points and not box:
|
|
sys.exit("give me --point x,y or --box x1,y1,x2,y2 (use --grab-frame to find coords)")
|
|
|
|
src = video
|
|
if args.frame > 0 and args.mode == "matte":
|
|
src = work / f"{stem}_from{args.frame}.mp4"
|
|
print(f"trimming from frame {args.frame} (MatAnyone propagates forward from its mask)")
|
|
run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
|
"-ss", f"{args.frame / fps:.6f}", "-i", video,
|
|
"-c:v", "libx264", "-crf", "12", "-c:a", "aac", src])
|
|
|
|
mask_png = work / "first_mask.png"
|
|
sam2_first_frame_mask(frame_png, points, negs, box, mask_png)
|
|
|
|
if args.mode == "matte":
|
|
pha = matanyone_matte(src, mask_png, work, args.max_size)
|
|
else:
|
|
pha = sam2_video_masks(src, points, negs, box, args.frame, work, fps)
|
|
|
|
alpha, matte, green = compose_outputs(src, pha, outdir, stem, fps, w, h)
|
|
print(f"\ndone:\n {alpha} <- ProRes 4444 with alpha, drop into Resolve\n {matte}\n {green} <- preview")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|