# Copyright 2026 Viggle AI. Licensed under the Apache License, Version 2.0 (see LICENSE-CODE). # SPDX-License-Identifier: Apache-2.0 """Re-camera a video from the command line: source clip + an authored camera move -> the model's video. # 3-forward DMD turbo (the default: stock MiniMax-H3 + both of this repo's adapters): python inference/sample.py --video clip.mp4 --yaw 15 --sweep --out out/clip_yaw15 # 50-step teacher (the recam adapter alone): python inference/sample.py --video clip.mp4 --yaw 15 --sweep --lora teacher_lora --steps 50 --flow-shift 12 --out out/t The clip is letterboxed into a 1280x1280 frame (`FULL`), reconstructed by one VGGT-Omega pass, and re-rendered from a second camera rigidly attached to the source camera: `c2w_dst[t] = c2w_src[t] @ delta(t)`, where `delta` orbits about the pivot at frame 0's median depth (`--yaw`, degrees) and/or trucks sideways (`--truck`, in units of that depth). `--sweep` ramps `delta` from identity at frame 0 to its full value at the last frame, so a static source camera turns into a moving one. `--freeze F:N` is bullet time: the window is `start..F-1`, then source frame `F` held for `N` frames while `delta` ramps from identity to its full value, then `F+1..` for whatever is left of the window; the frozen frames share `F`'s geometry, so the render is a moving camera over a static cloud. The render uses the source's own per-frame intrinsics -- there is no target clip to take them from. `--video` may also be a still image (PNG), which with `--freeze 0:73` makes the whole window that one frame. Writes to `--out`: out.mp4, out_audio.mp4 (source soundtrack of the same window muxed back on; not for `--freeze`), render.mp4, source.mp4, grid.mp4 = [source | render | out] at the target canvas, last.png = out's final frame, and cams.npz with the source and target cameras. """ import argparse import json import math import os import subprocess import sys import time import av import numpy as np import torch from diffusers import AutoencoderKLMiniMaxH3, MiniMaxH3Transformer3DModel from diffusers.utils.export_utils import encode_video as write_mp4 from PIL import Image ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) import recam.geometry as geo # noqa: E402 from recam.geometry import FULL, LENGTHS, NUM_FRAMES, RES, reconstruct, resize_u8, to_input, vggt, warp # noqa: E402 from recam.h3 import FPS, bucket, decode_video, denoise, encode_video, pack # noqa: E402 from recam.path import plan_path # noqa: E402 ASSETS = f"{ROOT}/assets" parser = argparse.ArgumentParser() parser.add_argument("--video", required=True) parser.add_argument("--camera-path", help="JSON with a 'path' key: recam.path keys in frame-0 camera coordinates / pivot depth; real-time source only, overrides parametric camera offsets") parser.add_argument("--ckpt", default=None, help="a local diffusers `transformer/` dir; default is `transformer/` inside --model-dir. Meridian is the adapters, not a transformer -- this is stock MiniMax-H3") parser.add_argument("--model-dir", default="MiniMaxAI/MiniMax-H3", help="the base MiniMax-H3 repo or a local copy of it, for `vae/`") parser.add_argument("--start", type=int, default=0, help="first source frame of the 73-frame window") parser.add_argument("--yaw", type=float, default=0.0, help="orbit about the frame-0 median-depth pivot, degrees; + moves the camera left") parser.add_argument("--yaw-from", type=float, default=0.0, help="with --sweep/--freeze: ramp the yaw from this value instead of 0 (the lead-in holds it)") parser.add_argument("--truck", type=float, default=0.0, help="sideways camera shift in units of the pivot depth; + moves right") parser.add_argument("--sweep", action="store_true", help="ramp the offset linearly from 0 at frame 0 to its full value at the last frame; with --freeze the live lead-in and tail orbit too, at --live-speed") parser.add_argument("--freeze", default=None, help="F:N -- hold source frame F for N frames and ramp the offset over them (bullet time)") parser.add_argument("--live-speed", type=float, default=0.33, help="with --freeze --sweep: angular speed of the live lead-in and tail relative to the frozen frames") parser.add_argument("--swing", action="store_true", help="sine ramp 0 -> 1 -> 0 -> -1 -> 0: orbit to one side, back through the source camera, out to the other side and back") parser.add_argument("--ease", action="store_true", help="cosine ease-in-out on the ramp (start and end at rest)") parser.add_argument("--bounce", action="store_true", help="there-and-back ramp 0 -> 1 -> 0 (cosine), so the chunk starts and ends at the source camera") parser.add_argument("--dolly", type=float, default=1.0, help="orbit radius as a fraction of the pivot depth, reached along the ramp; focal scales with r so the pivot plane keeps its size (dolly zoom: closer and wider)") parser.add_argument("--zoom", type=float, default=0.0, help="final focal multiplier, reached along the ramp; overrides the dolly's automatic focal scaling. --dolly 0.6 --zoom 1 is a true push-in (parallax, subject grows); --dolly 1 --zoom 1.6 is a pure optical zoom") parser.add_argument("--boom", type=float, default=0.0, help="vertical camera shift in units of the pivot depth; + raises the camera (crane up)") parser.add_argument("--pivot", default=None, help="fx,fy -- put the orbit pivot at the depth seen there (fractions of the crop box, frame 0); default: median depth") parser.add_argument("--aim", action="store_true", help="after boom/truck/dolly, rotate the camera to put the pivot back where it was on screen (a crane that keeps looking at the subject); a no-op for pure --yaw with --pivot-lock") parser.add_argument("--pivot-to", default=None, help="fx,fy -- a second picked pixel: with --aim, the camera pans/tilts off --pivot and ends up looking AT this point (it lands in frame centre). Pure rotation; orbit/boom/truck still use --pivot") parser.add_argument("--pivot-lock", action="store_true", help="with --pivot: orbit about that 3D point instead of the optical axis, so an off-centre subject keeps its screen position") parser.add_argument("--follow", action="store_true", help="replay the input clip's OWN estimated camera path over the geometry of frame --start alone: one VGGT pass gives both, so the path and the cloud share a gauge. Ignores --yaw/--truck/--dolly/... -- the trajectory comes from the video") parser.add_argument("--smooth", type=float, default=8.0, help="with --follow: Gaussian sigma in frames used to low-pass VGGT's per-frame poses (translation, rotation and focal). VGGT estimates every frame independently, so the raw path jitters; 0 disables") parser.add_argument("--cull", action="store_true", help="drop splats the target camera sees from behind (depth-map normal oriented to the source camera), so a 180-degree view is a hole, not the mirrored front") parser.add_argument("--fast-back", type=float, default=1.0, help="K>1: sweep the middle half of the yaw range (the unobserved back) K times faster than the two observed quarters") parser.add_argument("--seed", type=int, default=1234) parser.add_argument("--gauge-only", action="store_true", help="stop after the geometry diagnostics (fly-through gauge, pivot depth, coverage) -- no VAE, no transformer, no render") parser.add_argument("--preview-only", action="store_true", help="export the geometry and source previews -- full-resolution, plus the cond_*.mp4 pair at the condition canvas a sampler elsewhere needs -- then stop before loading the VAE or transformer") parser.add_argument("--canvas", default="", help="render at an explicit WxH (multiples of 32) instead of the 768-class bucket. The rotary grid is normalised by sqrt(area), so the same aspect at a larger canvas keeps the position ids in distribution -- only the sampling density changes. Raise --full with it or the warp is upsampled from a 1280 source") parser.add_argument("--full", type=int, default=0, help="side of the square the source is letterboxed into, default 1280 (the corpus's). Raise it alongside --canvas so the point cloud is unprojected and resampled at the output resolution") parser.add_argument("--steps", type=int, default=4, help="scheduler timesteps: 4 = the student's 3 forwards") parser.add_argument("--lora", nargs="+", default=[f"{ROOT}/teacher_lora", f"{ROOT}/turbo_lora"], help="adapter dirs applied together on --ckpt: the recam teacher, then the DMD turbo. Both is the default and its grid is " "--steps 4 --flow-shift 3; the teacher alone is --lora teacher_lora --steps 50 --flow-shift 12") parser.add_argument("--no-lora", action="store_true", help="load no adapter at all: stock MiniMax-H3, which cannot re-camera") parser.add_argument("--flow-shift", type=float, default=3.0, help="video scheduler shift: 3 for the student, 12 (MiniMax-H3's) for the teacher") parser.add_argument("--frames", type=int, default=NUM_FRAMES, choices=LENGTHS, help="output length in frames (the lengths assets/ has a prompt embed for)") parser.add_argument("--attn-backend", default="_native_cudnn") parser.add_argument("--vggt", default=None, help="vggt_omega_1b_512.pt; default $VGGT_OMEGA_CKPT (see README)") parser.add_argument("--vggt-repo", default=None, help="a checkout of facebookresearch/vggt-omega; default $VGGT_OMEGA_DIR") parser.add_argument("--out", required=True) args = parser.parse_args() camera_path = None if args.camera_path: assert not args.freeze and not args.follow, "--camera-path requires advancing source geometry" with open(args.camera_path) as f: camera_path = json.load(f)["path"] assert all(k["src"] == args.start + k["t"] for k in camera_path), "--camera-path currently supports real-time source only" if args.full: # `warp` scales VGGT's intrinsics by the module global, so both names must move geo.FULL = FULL = args.full NUM_FRAMES = args.frames device = torch.device("cuda") torch.set_grad_enabled(False) os.makedirs(args.out, exist_ok=True) torch.manual_seed(args.seed) # --- the clip, letterboxed into the corpus' square frame ------------------------------------------- # `tmap[t]` is the source frame shown at output frame t; only the distinct frames are decoded and reconstructed. if args.freeze: fz, n = map(int, args.freeze.split(":")) tail = NUM_FRAMES - n - (fz - args.start) assert fz >= args.start and tail >= 0, f"freeze {fz}x{n} does not fit a {NUM_FRAMES}-frame window from {args.start}" tmap = list(range(args.start, fz)) + [fz] * n + list(range(fz + 1, fz + 1 + tail)) ramp = torch.cat([torch.zeros(fz - args.start), torch.linspace(0, 1, n), torch.ones(tail)]) if args.sweep: # the live lead-in and tail orbit too, --live-speed times slower than the frozen frames w = torch.tensor([args.live_speed] * (fz - args.start) + [1.0] * n + [args.live_speed] * tail) ramp = torch.cumsum(w, 0) - w[0] ramp = ramp / ramp[-1] else: tmap = list(range(args.start, args.start + NUM_FRAMES)) # --bounce/--swing shape a 0->1 ramp. on the constant `ones` ramp they collapse to # identically zero (cos 2pi = 1, sin 2pi = 0) and the camera never moves at all, so # they imply the linear base ramp -- there is no useful reading of the other combination. ramp = torch.linspace(0, 1, NUM_FRAMES) if args.sweep or args.bounce or args.swing else torch.ones(NUM_FRAMES) if args.ease: ramp = (1 - torch.cos(math.pi * ramp)) / 2 if args.bounce: ramp = (1 - torch.cos(2 * math.pi * ramp)) / 2 if args.swing: ramp = torch.sin(2 * math.pi * ramp) if args.fast_back > 1: # piecewise-linear time->angle map: speed v on the outer quarters, K*v on the middle half K, v = args.fast_back, 0.5 * (1 + 1 / args.fast_back) t1 = 0.25 / v ramp = torch.where(ramp < t1, v * ramp, torch.where(ramp < 1 - t1, 0.25 + K * v * (ramp - t1), 0.75 + v * (ramp - 1 + t1))) pf = tmap.index(fz) if args.freeze else 0 # the frame whose depth places the pivot c = av.open(args.video) frames = np.stack([f.to_ndarray(format="rgb24") for i, f in enumerate(c.decode(video=0)) if tmap[0] <= i <= tmap[-1]]) c.close() assert len(frames) == tmap[-1] - tmap[0] + 1, f"decoded {len(frames)} frames from {tmap[0]}, need {tmap[-1] - tmap[0] + 1}" frames = torch.from_numpy(frames).to(device) h, w = frames.shape[1:3] s = FULL / max(h, w) ch, cw = round(h * s), round(w * s) ox, oy = (FULL - cw) // 2, (FULL - ch) // 2 full = torch.zeros(len(frames), FULL, FULL, 3, dtype=torch.uint8, device=device) full[:, oy : oy + ch, ox : ox + cw] = resize_u8(frames, (ch, cw)) del frames # The 768-class canvas nearest the clip's aspect, and a crop box of exactly that aspect inside the content # (`crop_box`'s construction, centred instead of drawn): the resize factor `f` stays isotropic, as in training. canvas, cond_canvas = bucket(w, h) # `