Instructions to use Viggle/Meridian with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use Viggle/Meridian with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("Viggle/Meridian", dtype=torch.bfloat16, device_map="cuda") prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k" image = pipe(prompt).images[0] - Notebooks
- Google Colab
- Kaggle
| # 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) # `<Video 1>` stays at the 480 class whatever the target is, as in training | |
| if args.canvas: | |
| canvas = tuple(int(v) for v in args.canvas.split("x")) | |
| a = canvas[0] / canvas[1] | |
| bw, bh = (cw, round(cw / a)) if cw / ch <= a else (round(ch * a), ch) | |
| box = (ox + (cw - bw) // 2, oy + (ch - bh) // 2, bw, bh, canvas[0] / bw) | |
| x0, y0, bw, bh, _ = box | |
| print(f"{w}x{h} -> content {cw}x{ch} in {FULL}^2, box {box[:4]}, canvas {canvas}, cond {cond_canvas}", flush=True) | |
| # --- geometry: one solo pass, then the authored second camera in the same gauge ---------------------- | |
| geometry = vggt(args.vggt, args.vggt_repo, device) | |
| t0 = time.time() | |
| S = reconstruct(geometry, to_input(full)) | |
| del geometry | |
| idx = torch.tensor(tmap, device=device) - tmap[0] | |
| S = {k: v[idx] for k, v in S.items()} | |
| full = full[idx] | |
| if args.follow: # keep the clip's per-frame cameras, then pin every frame's geometry to the first | |
| c2w_f, intr_f = S["c2w"].clone(), S["intr"].clone() | |
| if args.smooth: # VGGT poses every frame independently, so the raw path jitters -- Gaussian low-pass it | |
| n = len(c2w_f) | |
| k = min(int(3 * args.smooth), n - 1) # point-reflect the ends (x[-j] = 2x[0] - x[j]): a plain | |
| pad = lambda v: torch.cat([2 * v[:1] - v[1:k + 1].flip(0), v, 2 * v[-1:] - v[-k - 1:-1].flip(0)]) | |
| ts = torch.arange(n + 2 * k, device=device, dtype=torch.float32) # truncated window would pull the | |
| w = torch.exp(-0.5 * ((ts[k:k + n, None] - ts[None, :]) / args.smooth) ** 2) # endpoints inward and | |
| w = w / w.sum(1, keepdim=True) # eat 7% of the travel at sigma 8; reflection keeps the end velocity | |
| sm = lambda v: torch.einsum("ij,jab->iab", w, pad(v)) | |
| jit = float((c2w_f[:, :3, 3] - sm(c2w_f)[:, :3, 3]).norm(dim=1).mean()) | |
| c2w_f, intr_f = sm(c2w_f), sm(intr_f) | |
| U, _, Vh = torch.linalg.svd(c2w_f[:, :3, :3]) # blurring leaves R off SO(3); project it back | |
| U[:, :, 2] *= torch.linalg.det(U @ Vh)[:, None] # never let the fit flip handedness | |
| c2w_f[:, :3, :3], c2w_f[:, 3] = U @ Vh, torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) | |
| print(f"smoothed follow path, sigma {args.smooth} frames, removed {jit:.4f} mean jitter", flush=True) | |
| z = [0] * NUM_FRAMES | |
| S = {k: v[z] for k, v in S.items()} | |
| full = full[z] | |
| if args.pivot and args.pivot != "none": # median depth in a +-5% window around the picked point, in VGGT's 512 grid | |
| fx, fy = map(float, args.pivot.split(",")) | |
| r = RES / FULL | |
| px, py, rw, rh = (x0 + fx * bw) * r, (y0 + fy * bh) * r, 0.05 * bw * r, 0.05 * bh * r | |
| win = (slice(round(py - rh), round(py + rh)), slice(round(px - rw), round(px + rw))) | |
| zm = float(S["depth"][pf][win][S["keep"][pf][win]].median()) | |
| else: | |
| zm = float(S["depth"][pf][S["keep"][pf]].median()) | |
| piv = torch.tensor([0.0, 0.0, zm], device=device) | |
| if args.pivot and args.pivot != "none" and args.pivot_lock: # unproject the picked pixel; orbit about it so it holds its screen position | |
| K = S["intr"][pf] | |
| piv = torch.tensor([(px - float(K[0, 2])) / float(K[0, 0]) * zm, (py - float(K[1, 2])) / float(K[1, 1]) * zm, zm], device=device) | |
| print(f"pivot locked at {piv.tolist()}", flush=True) | |
| piv_to = piv | |
| if args.pivot_to: # a second picked pixel; the aim target slides from `piv` to it along the ramp | |
| gx, gy = map(float, args.pivot_to.split(",")) | |
| g = RES / FULL | |
| qx, qy, qw, qh = (x0 + gx * bw) * g, (y0 + gy * bh) * g, 0.05 * bw * g, 0.05 * bh * g | |
| wn = (slice(round(qy - qh), round(qy + qh)), slice(round(qx - qw), round(qx + qw))) | |
| zt = float(S["depth"][pf][wn][S["keep"][pf][wn]].median()) | |
| K = S["intr"][pf] | |
| piv_to = torch.tensor([(qx - float(K[0, 2])) / float(K[0, 0]) * zt, (qy - float(K[1, 2])) / float(K[1, 1]) * zt, zt], device=device) | |
| print(f"reframe target at {piv_to.tolist()}", flush=True) | |
| c2w, intr_t = (c2w_f, intr_f) if args.follow else (S["c2w"].clone(), S["intr"].clone()) | |
| for ti in range(0 if args.follow or camera_path is not None else NUM_FRAMES): | |
| th = math.radians(args.yaw_from + (args.yaw - args.yaw_from) * float(ramp[ti])) | |
| r = 1 + (args.dolly - 1) * float(ramp[ti]) | |
| R = torch.tensor([[math.cos(th), 0, math.sin(th)], [0, 1, 0], [-math.sin(th), 0, math.cos(th)]], device=device) | |
| delta = torch.eye(4, device=device) | |
| delta[:3, :3] = R | |
| # orbit about `piv`: sit at r*|piv| from it along the rotated line of sight, then truck/boom in the rotated frame | |
| delta[:3, 3] = piv - R @ (r * piv) - R @ torch.tensor([-args.truck * zm * float(ramp[ti]), args.boom * zm * float(ramp[ti]), 0.0], device=device) | |
| if args.aim: # re-point at the pivot: boom/truck reframe the shot instead of sliding the subject out of frame | |
| a = piv / piv.norm() | |
| b = piv + (piv_to - piv) * float(ramp[ti]) - delta[:3, 3] | |
| b = b / b.norm() | |
| v, c = torch.cross(a, b, dim=0), float(a @ b) | |
| sn = float(v.norm()) | |
| K = torch.zeros(3, 3, device=device) | |
| K[0, 1], K[0, 2], K[1, 0], K[1, 2], K[2, 0], K[2, 1] = -v[2], v[1], v[2], -v[0], -v[1], v[0] | |
| delta[:3, :3] = torch.eye(3, device=device) + K + K @ K * ((1 - c) / sn ** 2) if sn > 1e-8 else torch.eye(3, device=device) | |
| c2w[ti] = S["c2w"][ti] @ delta | |
| f = 1 + (args.zoom - 1) * float(ramp[ti]) if args.zoom else r | |
| intr_t[ti, 0, 0] *= f | |
| intr_t[ti, 1, 1] *= f | |
| if camera_path is not None: | |
| poses, path_tmap, focal, _ = plan_path(camera_path, NUM_FRAMES, zm) | |
| assert path_tmap == tmap | |
| c2w = S["c2w"][0][None] @ torch.as_tensor(poses, dtype=c2w.dtype, device=device) | |
| focal = torch.as_tensor(focal, dtype=intr_t.dtype, device=device) | |
| intr_t[:, 0, 0] *= focal | |
| intr_t[:, 1, 1] *= focal | |
| if args.cull: # per frame: unproject the RES-grid depth, normal from finite differences, oriented toward the source camera | |
| yy, xx = torch.meshgrid(torch.arange(RES, device=device), torch.arange(RES, device=device), indexing="ij") | |
| uv1 = torch.stack([xx, yy, torch.ones_like(xx)], -1).float().reshape(-1, 3) | |
| for ti in range(NUM_FRAMES): | |
| Xc = (uv1 @ torch.linalg.inv(S["intr"][ti]).T).reshape(RES, RES, 3) * S["depth"][ti][..., None] | |
| X = Xc @ S["c2w"][ti][:3, :3].T + S["c2w"][ti][:3, 3] | |
| n = torch.cross(torch.roll(X, -1, 1) - X, torch.roll(X, -1, 0) - X, dim=-1) | |
| n = n * torch.sign((n * (S["c2w"][ti][:3, 3] - X)).sum(-1, keepdim=True)) | |
| S["keep"][ti] &= ((c2w[ti][:3, 3] - X) * n).sum(-1) > 0 | |
| print(f"cull: keep fraction per frame {S['keep'].float().mean((1, 2)).min():.2f}..{S['keep'].float().mean((1, 2)).max():.2f}", flush=True) | |
| w2c = torch.linalg.inv(c2w) | |
| render, cov = warp(S, w2c, intr_t, full, box, canvas) | |
| yy, xx = torch.meshgrid(torch.arange(RES, device=device), torch.arange(RES, device=device), indexing="ij") | |
| g1 = torch.stack([xx, yy, torch.ones_like(xx)], -1).float().reshape(-1, 3) | |
| near, behind, coll, ahead = [], [], [], [] | |
| for ti in range(NUM_FRAMES): # fly-through gauge: how much of the scene ends up near, behind, or (collide) within a 0.05-pivot-depth ball of the moved camera | |
| X = ((g1 @ torch.linalg.inv(S["intr"][ti]).T).reshape(RES, RES, 3) * S["depth"][ti][..., None]) @ S["c2w"][ti][:3, :3].T + S["c2w"][ti][:3, 3] | |
| zd = (X - c2w[ti][:3, 3]) @ c2w[ti][:3, 2] | |
| near.append(float((zd[S["keep"][ti]] < 0.1 * zm).float().mean())) | |
| behind.append(float((zd[S["keep"][ti]] < 0).float().mean())) | |
| coll.append(float(((X - c2w[ti][:3, 3]).norm(dim=-1)[S["keep"][ti]] < 0.05 * zm).float().mean())) | |
| cb = S["keep"][ti].clone(); cb[: RES // 4] = cb[-(RES // 4):] = False; cb[:, : RES // 4] = cb[:, -(RES // 4):] = False # central half of the source frame: the subject, not the floor | |
| ahead.append(float(torch.quantile(zd[cb], 0.05)) / zm if cb.any() else 9.0) | |
| print(f"fly-through gauge: scene within 0.1 pivot-depths of the moved camera, per-frame max {max(near):.3f} at frame {near.index(max(near))}; " | |
| f"behind the camera max {max(behind):.3f} at frame {behind.index(max(behind))}; " | |
| f"collide (within a 0.05-pivot-depth ball) max {max(coll):.4f} at frame {coll.index(max(coll))}; " | |
| f"central-subject 5th-pct depth ahead of the moved camera min {min(ahead):+.3f} pivot-depths at frame {ahead.index(min(ahead))}", flush=True) | |
| print(f"pivot depth {zm:.3f}, camera moved {float((c2w[-1, :3, 3] - S['c2w'][-1, :3, 3]).norm()) / zm:.3f} pivot-depths " | |
| f"by the last frame, coverage {float(cov.float().mean()):.3f}, geometry in {time.time() - t0:.0f}s", flush=True) | |
| if args.gauge_only: | |
| sys.exit(0) | |
| if args.preview_only: | |
| source = resize_u8(full[:, y0 : y0 + bh, x0 : x0 + bw], canvas[::-1]) | |
| write_mp4(render.cpu(), fps=int(FPS), output_path=f"{args.out}/render.mp4") | |
| write_mp4(source.cpu(), fps=int(FPS), output_path=f"{args.out}/source.mp4") | |
| # ... and the same pair at the condition canvas, which is what a sampler elsewhere has to be fed | |
| write_mp4(resize_u8(render, cond_canvas[::-1]).cpu(), fps=int(FPS), output_path=f"{args.out}/cond_render.mp4") | |
| write_mp4(resize_u8(full[:, y0 : y0 + bh, x0 : x0 + bw], cond_canvas[::-1]).cpu(), fps=int(FPS), | |
| output_path=f"{args.out}/cond_source.mp4") | |
| np.savez(f"{args.out}/requested_cameras.npz", | |
| c2w_src=S["c2w"].cpu().numpy(), c2w_dst=c2w.cpu().numpy(), | |
| intr_src=S["intr"].cpu().numpy(), intr_dst=intr_t.cpu().numpy(), | |
| piv=piv.cpu().numpy(), piv_to=piv_to.cpu().numpy(), zm=zm, | |
| box=box, canvas=canvas, fps=FPS, tmap=tmap, argv=" ".join(sys.argv[1:])) | |
| print(f"wrote geometry previews to {args.out}; no model video generated", flush=True) | |
| sys.exit(0) | |
| # --- encode exactly as `build` does; the target latent is a placeholder that only sets the layout -------- | |
| render = resize_u8(render, cond_canvas[::-1]) | |
| source = resize_u8(full[:, y0 : y0 + bh, x0 : x0 + bw], canvas[::-1]) | |
| cond = resize_u8(full[:, y0 : y0 + bh, x0 : x0 + bw], cond_canvas[::-1]) | |
| del full | |
| vae = AutoencoderKLMiniMaxH3.from_pretrained(args.model_dir, subfolder="vae").to(device).eval() | |
| d = {"cond": encode_video(vae, cond)[0], "render": encode_video(vae, render)[0], "target": encode_video(vae, source)[1]} | |
| embed = torch.load(f"{ASSETS}/fixed_embed_{NUM_FRAMES}.pt", weights_only=False) | |
| audio_x0 = torch.load(f"{ASSETS}/silence_audio_{NUM_FRAMES}.pt", weights_only=True)["audio_x0"].float() | |
| batch = pack(d["cond"], d["render"], d["target"], embed["prompt_embeds"][0], embed["text_token_tags"], audio_x0) | |
| # --- denoise ---------------------------------------------------------------------------------------- | |
| ckpt, sfold = (args.ckpt, None) if args.ckpt else (args.model_dir, "transformer") | |
| transformer = MiniMaxH3Transformer3DModel.from_pretrained(ckpt, subfolder=sfold, torch_dtype=torch.bfloat16) | |
| if not args.no_lora: | |
| # `prefix=None` + the safetensors name, or the loader looks for a `.bin` / filters for `transformer.` keys and | |
| # silently loads nothing. The assert is the guard against that silence. | |
| # Each dir gets its own adapter name; loading alone leaves only the last one active, so `set_adapters` is what | |
| # sums them at weight 1.0 each -- which is how the turbo was distilled (see the model card). | |
| names = [f"lora{i}" for i in range(len(args.lora))] | |
| for name, ldir in zip(names, args.lora): | |
| transformer.load_lora_adapter(ldir, weight_name="pytorch_lora_weights.safetensors", prefix=None, adapter_name=name) | |
| assert any("lora_" in n for n, _ in transformer.named_parameters()), "no LoRA weights landed" | |
| transformer.set_adapters(names, [1.0] * len(names)) | |
| print(f"lora: {len(names)} adapter(s) active {names}", flush=True) | |
| transformer.set_attention_backend(args.attn_backend) | |
| transformer.to(device).eval() | |
| t0 = time.time() | |
| torch.cuda.reset_peak_memory_stats() | |
| rows = denoise(transformer, batch, args.steps, args.flow_shift, device) | |
| print(f"denoised in {time.time() - t0:.0f}s, peak {torch.cuda.max_memory_allocated() / 2**30:.1f} GiB", flush=True) | |
| del transformer | |
| out = decode_video(vae, rows, d["target"].shape[1:]) | |
| # --- write ---------------------------------------------------------------------------------------- | |
| hw = canvas[::-1] | |
| for name, fr in ("out", out), ("render", render.cpu()), ("source", source.cpu()): | |
| write_mp4(fr, fps=int(FPS), output_path=f"{args.out}/{name}.mp4") | |
| write_mp4(torch.cat([source.cpu(), resize_u8(render.cpu(), hw), out], 2), fps=int(FPS), output_path=f"{args.out}/grid.mp4") | |
| Image.fromarray(out[-1].numpy()).save(f"{args.out}/last.png") # the next link of an autoregressive orbit | |
| if not args.freeze: # a frozen window has no soundtrack that lines up | |
| subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", f"{args.out}/out.mp4", "-ss", f"{args.start / FPS:.4f}", | |
| "-t", f"{NUM_FRAMES / FPS:.4f}", "-i", args.video, "-map", "0:v", "-map", "1:a?", "-c:v", "copy", | |
| "-c:a", "aac", "-shortest", f"{args.out}/out_audio.mp4"], check=True) | |
| # poses last: the mp4s are already on disk, so nothing here can cost a render | |
| _x0, _y0, _bw, _bh, _sx = box # intr_* live on the RES grid of the FULL^2 letterbox; *_px are in out.mp4 pixels | |
| _M = np.array([[FULL / RES * _sx, 0, -_x0 * _sx], [0, FULL / RES * canvas[1] / _bh, -_y0 * canvas[1] / _bh], [0, 0, 1]]) | |
| np.savez(f"{args.out}/cams.npz", c2w_src=S["c2w"].cpu().numpy(), c2w_dst=c2w.cpu().numpy(), | |
| intr_src=S["intr"].cpu().numpy(), intr_dst=intr_t.cpu().numpy(), | |
| intr_src_px=_M @ S["intr"].cpu().numpy(), intr_dst_px=_M @ intr_t.cpu().numpy(), | |
| piv=piv.cpu().numpy(), piv_to=piv_to.cpu().numpy(), zm=float(zm), | |
| box=np.asarray(box), canvas=np.asarray(canvas), fps=float(FPS), tmap=tmap, argv=" ".join(sys.argv[1:])) | |
| print(f"wrote {args.out}/{{out,render,source,grid}}.mp4 + last.png + cams.npz" + ("" if args.freeze else " + out_audio.mp4")) | |