File size: 17,726 Bytes
21665fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# SPDX-License-Identifier: Apache-2.0
"""Viggle Remake: re-render a video with the people (swap) or the scene (edit) changed.

    python inference/sample.py --mode swap --video source.mp4 --prompt prompt.txt \
        --ref ref1.png [--ref ref2.png ...] [--pin 0 [--pin 96 ...]] \
        --frames 124 --short-edge 480 --seed 0 \
        --base MiniMax-H3 --lora-repo Viggle-Remake --out out.mp4

`--video` supplies motion, camera, framing and everything the prompt does not change. Each
`--ref` is an image the prompt refers to as <Picture N>. `--pin` is optional and, when given,
appears once per `--ref`: it pins that ref as a keyframe at that real frame of the output for
the first 90% of the denoise (`--keyframe-until`), which is how a painted frame of the source
becomes an anchor rather than only a reference.

The target audio rows hold the source soundtrack (`--audio`, defaults to `--video`) as a clean
latent for the whole denoise, so mouths follow the real speech and the output carries that
soundtrack back through the audio VAE.

Memory order: the 64-layer Qwen3-VL conditioner (~59 GiB) is loaded, encodes the prompt once,
and is freed before the pipeline loads, so the two never share the card.

Runs on upstream diffusers d6726f3. The keyframe pin (`ref2va` references *plus* keyframes inside
the generated span, the layout the LoRAs are run with) is not in
upstream; the three small patches under "keyframes on a ref2va layout" below add it.
"""

import argparse
import gc
import subprocess
import tempfile
import time
from pathlib import Path

import av
import numpy as np
import torch
from PIL import Image
from transformers import AutoProcessor, AutoTokenizer, Qwen3VLForConditionalGeneration
from diffusers import MiniMaxH3Transformer3DModel, ModularPipeline, attention_backend
from diffusers.modular_pipelines.minimax_h3 import MiniMaxH3ImageReference, MiniMaxH3VideoReference
from diffusers.modular_pipelines.minimax_h3.before_denoise import (_ROPE_FRAME_RESCALE, MiniMaxH3Ref2VAPrepareLayoutStep,
                                                                   MiniMaxH3SetTimestepsStep, _frame_position_grid)
from diffusers.modular_pipelines.minimax_h3.denoise import MiniMaxH3LoopSchedulerStep, MiniMaxH3Ref2VALoopDenoiser
from diffusers.modular_pipelines.minimax_h3.encoders import (MiniMaxH3Ref2VAReferenceEncoderStep,
                                                             MiniMaxH3Ref2VATextEncoderStep, encode_vae_condition,
                                                             get_qwen3vl_prompt_embeds)
from diffusers.modular_pipelines.minimax_h3.modular_pipeline import audio_latent_num_frames
from diffusers.utils.export_utils import encode_video

# 6-step grids, in the video stream's sigma; each stream's own shift is applied below
SIGMAS = {"swap": [1, .990476, .965243, .923077, .8, .631579, 0],
          "edit": [1, .983607, .96, .923077, .857143, .705882, 0]}
TEXT_TAG = 1

parser = argparse.ArgumentParser()
parser.add_argument("--mode", required=True, choices=["swap", "edit"])
parser.add_argument("--video", required=True, help="the source clip")
parser.add_argument("--audio", default=None, help="the soundtrack pinned into the output; defaults to --video")
parser.add_argument("--prompt", required=True, help="text file with the six-section prompt, read verbatim")
parser.add_argument("--ref", action="append", required=True, help="reference image, <Picture N> in the prompt")
parser.add_argument("--pin", action="append", type=int, default=[],
                    help="output frame each --ref is pinned to as a keyframe; absent, or once per --ref")
parser.add_argument("--frames", type=int, default=124, help="17n+5 in 90..345, at 24 fps")
parser.add_argument("--short-edge", type=int, default=480, help="480 (832x480 for 16:9) or 736 (1280x736)")
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--base", required=True, help="local copy of MiniMaxAI/MiniMax-H3")
parser.add_argument("--lora-repo", required=True, help="local copy of Viggle/Viggle-Remake")
parser.add_argument("--rank", type=int, default=256, choices=[256, 128, 64],
                    help="256 loads lora/<mode>; 128 or 64 load the smaller lora_lowrank/<mode>-r<rank>")
parser.add_argument("--keyframe-until", type=float, default=0.9, help="drop the pinned keyframes once 1-t reaches this")
parser.add_argument("--attn-backend", default="_native_cudnn")
parser.add_argument("--out", required=True)
args = parser.parse_args()

nf, se, refs, pin = args.frames, args.short_edge, args.ref, args.pin
assert 90 <= nf <= 345 and (nf - 5) % 17 == 0, f"--frames must be 17n+5 in 90..345, got {nf}"
assert se % 32 == 0, f"--short-edge must be a multiple of 32, got {se}"
assert len(pin) in (0, len(refs)), "give --pin once per --ref, or not at all"
assert all(0 <= k < nf for k in pin), f"--pin must be in [0, {nf})"
prompt = Path(args.prompt).read_text()
sigmas = SIGMAS[args.mode]

# The source on its canvas. A clip already at the canvas (short edge == --short-edge, both sides multiples of 32) is used
# as is; anything else is cover-cropped onto the 16:9-class canvas the way our renderer does (832x480, 1280x736).
with av.open(args.video) as c:
    w, h = c.streams.video[0].codec_context.width, c.streams.video[0].codec_context.height
video_path = args.video
if min(w, h) != se or w % 32 or h % 32:
    long = se * 16 // 9 // 32 * 32
    w, h = (long, se) if w >= h else (se, long)
    video_path = str(Path(tempfile.mkdtemp()) / "cond.mp4")
    subprocess.run(["ffmpeg", "-v", "error", "-i", args.video, "-vf",
                    f"fps=24,scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}", "-frames:v", str(nf),
                    "-an", "-c:v", "libx264", "-crf", "12", "-pix_fmt", "yuv420p", video_path], check=True)
video = MiniMaxH3VideoReference.from_file(video_path)
video.audio, video.sample_rate = None, None  # the soundtrack goes in as the target audio pin, not as reference audio
height, width = video.frames.shape[1], video.frames.shape[2]

# --- prompt -> embeds. The presentation is text only: a label per 2 fps block of the source, one per ref, then the
# prompt, each piece tokenized on its own. H3 reads hidden_states[50] of the 64-layer conditioner.
t0 = time.time()
processor = AutoProcessor.from_pretrained(args.base, subfolder="processor")
tokenizer = AutoTokenizer.from_pretrained(args.base, subfolder="tokenizer")
text_encoder = Qwen3VLForConditionalGeneration.from_pretrained(args.base, subfolder="text_encoder",
                                                               dtype=torch.bfloat16).to("cuda")
t1 = time.time()
_, stamps = MiniMaxH3Ref2VATextEncoderStep._sample_video_condition_frames(
    np.zeros((nf, 1, 1, 3), dtype=np.uint8), 24.0, 2.0, processor.video_processor.temporal_patch_size)
segments = ["<Video 1>: ", *[f"<{s:.1f} seconds>" for s in stamps], *[f"<Picture {i + 1}>: " for i in range(len(refs))],
            prompt]
token_ids = [i for s in segments for i in tokenizer(s, add_special_tokens=False)["input_ids"]]
with torch.no_grad():
    prompt_embeds = get_qwen3vl_prompt_embeds(text_encoder, processor, token_ids, device=torch.device("cuda"),
                                              dtype=torch.bfloat16).cpu()
text_token_tags = torch.full((len(token_ids),), TEXT_TAG, dtype=torch.long)
t2 = time.time()
print(f"text encoder: load {t1 - t0:.0f}s, encode {t2 - t1:.1f}s, {len(token_ids)} tokens, "
      f"peak {torch.cuda.max_memory_allocated() / 2**30:.1f} GiB", flush=True)
del text_encoder
gc.collect()
torch.cuda.empty_cache()

# --- pipeline: the LoRA sits on the fl2va `transformer`, loaded into the ref2va slot
pipe = ModularPipeline.from_pretrained(args.base, workflow="ref2va")
type(pipe).min_duration = property(lambda self: 90 / 24)  # the adapters run from 90 frames; the base floor is 5 s
pipe.load_components(names=["vae", "audio_vae", "scheduler", "audio_scheduler"], pretrained_model_name_or_path=args.base,
                     dtype=torch.bfloat16)
pipe.transformer_ref = MiniMaxH3Transformer3DModel.from_pretrained(args.base, subfolder="transformer",
                                                                    torch_dtype=torch.bfloat16)
# prefix=None: the saved keys are bare module names; the default "transformer." filter loads nothing and only warns
lora = Path(args.lora_repo) / (f"lora/{args.mode}" if args.rank == 256 else f"lora_lowrank/{args.mode}-r{args.rank}")
pipe.transformer_ref.load_lora_adapter(str(lora),
                                       weight_name="pytorch_lora_weights.safetensors", prefix=None)
pipe.transformer_ref.set_attention_backend(args.attn_backend)  # process-wide: the audio encode below opts out
pipe.to("cuda")
pipe.register_to_config(canvas_short_edge=se, canvas_max_pixels=se * max(height, width), reference_image_short_edge=se)
sh = lambda u, s: s * u / (1 + (s - 1) * u)  # noqa: E731
for sched, s in ((pipe.scheduler, 12.0), (pipe.audio_scheduler, 3.0)):
    grid = [sh(x / (12 - 11 * x), s) for x in sigmas]  # video sigma -> u -> this stream's sigma
    sched.set_timesteps = (lambda f, g: lambda *a, **k: f(sigmas=g, device=k.get("device")))(sched.set_timesteps, grid)
t3 = time.time()
print(f"pipeline on cuda in {t3 - t2:.0f}s", flush=True)


def use_fixed_embeds(self, components, state):
    block_state = self.get_block_state(state)
    block_state.prompt_embeds = prompt_embeds.to(components._execution_device, torch.bfloat16)
    block_state.text_token_tags = text_token_tags
    self.set_block_state(state, block_state)
    return components, state


MiniMaxH3Ref2VATextEncoderStep.__call__ = use_fixed_embeds

# audio pin: the target audio rows are the source soundtrack as a clean latent (positional arg 6 = audio_timestep, and
# H3's t = 1 is clean), and only the video rows are stepped, so they stay clean
_build_row_timesteps = MiniMaxH3SetTimestepsStep.build_row_timesteps
MiniMaxH3SetTimestepsStep.build_row_timesteps = staticmethod(lambda *a: _build_row_timesteps(*a[:6], 1.0, *a[7:]))


@torch.no_grad()
def video_only_step(self, components, block_state, i, t):
    n = block_state.num_condition_video_rows
    block_state.latents[n:] = components.scheduler.step(
        block_state.noise_pred[0, n:].float(), t, block_state.latents[n:], return_dict=False)[0]
    return components, block_state


MiniMaxH3LoopSchedulerStep.__call__ = video_only_step

# --- keyframes on a ref2va layout. A keyframe block is packed after the references, right before the target audio, and
# is pinned *inside* the target's rotary span at its real frame index rather than advancing the clock like a reference.
KF = {"images": [], "anchors": list(pin)}
_encode_references = MiniMaxH3Ref2VAReferenceEncoderStep.__call__


def encode_references_then_keyframes(self, components, state):
    components, state = _encode_references(self, components, state)
    with torch.no_grad():
        kf = [encode_vae_condition(components.vae, torch.from_numpy(np.array(im)).to(components._execution_device)
                                   .permute(2, 0, 1)[None, :, None], components.pixel_mean, components.pixel_std,
                                   components.keyframe_encode_seed) for im in KF["images"]]
    state.set("condition_latents", list(state.get("condition_latents") or []) + kf)
    return components, state


MiniMaxH3Ref2VAReferenceEncoderStep.__call__ = encode_references_then_keyframes
_layout = MiniMaxH3Ref2VAPrepareLayoutStep.build_ref2va_packed_sequence


def layout_with_keyframes(text_token_tags, references, condition_latents, audio_condition_latents, num_latent_frames,
                          latent_height, latent_width, num_audio_latents, patch_size, audio_channels, audio_tag,
                          video_tag):
    k = len(KF["anchors"])
    pos, tags, vi, ai, ti, ncv, nca = _layout(text_token_tags, references, condition_latents[:len(condition_latents) - k],
                                              audio_condition_latents, num_latent_frames, latent_height, latent_width,
                                              num_audio_latents, patch_size, audio_channels, audio_tag, video_tag)
    if not k:
        return pos, tags, vi, ai, ti, ncv, nca
    s = text_token_tags.shape[0] + ncv + nca  # the first target audio row; its time is the target's rotary origin
    origin = float(pos[s, 0])
    blocks = []
    for anchor, lat in zip(KF["anchors"], condition_latents[-k:]):
        grid, _ = _frame_position_grid(lat.shape[3], lat.shape[4], patch_size[1], patch_size[2])
        p = torch.zeros(grid.shape[0], 3, dtype=torch.float64)
        p[:, 0] = origin + _ROPE_FRAME_RESCALE * float(anchor)
        p[:, 1:] = grid
        blocks.append(p)
    n = sum(b.shape[0] for b in blocks)
    pos = torch.cat([pos[:s], *blocks, pos[s:]])
    tags = torch.cat([tags[:s], torch.full((n,), video_tag, dtype=tags.dtype), tags[s:]])
    vi = torch.cat([vi[vi < s], torch.arange(s, s + n), vi[vi >= s] + n])
    ai = torch.where(ai >= s, ai + n, ai)
    return pos, tags, vi, ai, ti, ncv + n, nca


MiniMaxH3Ref2VAPrepareLayoutStep.build_ref2va_packed_sequence = staticmethod(layout_with_keyframes)

# --keyframe-until: once 1 - t reaches it the keyframe blocks are cut out of every per-row input, leaving the plain
# ref2va sequence; their velocity comes back as zeros, which the scheduler never reads (conditioning rows)
r, (_, ph, pw) = pipe.vae_spatial_compression_ratio, pipe.patch_size
nkf = len(pin) * (height // r // ph) * (width // r // pw)
_denoise = MiniMaxH3Ref2VALoopDenoiser.__call__


def kf_release(self, components, block_state, i, t):
    if not nkf or 1 - float(t) >= args.keyframe_until:
        return _denoise(self, components, block_state, i, t)
    ncv = block_state.num_condition_video_rows
    f = block_state.denoiser_input_fields
    vi, ai = f["video_indices"], f["audio_indices"]
    lo, hi = int(vi[ncv - nkf]), int(vi[ncv - 1]) + 1
    assert hi - lo == nkf and hi == int(ai[block_state.num_condition_audio_rows]), (lo, hi, nkf)
    keep = torch.ones(f["position_ids"].shape[0], dtype=torch.bool, device=vi.device)
    keep[lo:hi] = False
    plan = list(block_state.row_timestep_plan)
    plan[i] = (plan[i][0], plan[i][1][keep])
    full = block_state.latents, f, block_state.row_timestep_plan
    block_state.latents = torch.cat([block_state.latents[:ncv - nkf], block_state.latents[ncv:]])
    block_state.denoiser_input_fields = dict(
        f, position_ids=f["position_ids"][keep], token_tags=f["token_tags"][keep],
        video_indices=torch.cat([vi[:ncv - nkf], vi[ncv:] - nkf]), audio_indices=ai - nkf * (ai >= hi))
    block_state.row_timestep_plan = plan
    components, block_state = _denoise(self, components, block_state, i, t)
    block_state.latents, block_state.denoiser_input_fields, block_state.row_timestep_plan = full
    p = block_state.noise_pred
    block_state.noise_pred = torch.cat([p[:, :ncv - nkf], p.new_zeros(1, nkf, p.shape[2]), p[:, ncv - nkf:]], 1)
    return components, block_state


MiniMaxH3Ref2VALoopDenoiser.__call__ = kf_release

# keyframes onto the canvas the way our renderer does: the first one stretched, the rest cover-cropped (rounded
# size, (src - dst) // 2 centring -- not VaeImageProcessor's "crop" arithmetic, which differs by a pixel on some ratios)
if pin:
    for i, im in enumerate(Image.open(x).convert("RGB") for x in refs):
        if im.size != (width, height) and i == 0:
            im = pipe.image_processor.resize(im, height=height, width=width)
        elif im.size != (width, height):
            scale = max(width / im.size[0], height / im.size[1])
            size = (max(width, round(im.size[0] * scale)), max(height, round(im.size[1] * scale)))
            left, top = max(0, (size[0] - width) // 2), max(0, (size[1] - height) // 2)
            im = im.resize(size, Image.Resampling.LANCZOS).crop((left, top, left + width, top + height))
        KF["images"].append(im)

# --- the soundtrack as clean target audio latents
asr = pipe.audio_vae.config.sampling_rate
pcm = subprocess.run(["ffmpeg", "-loglevel", "error", "-i", args.audio or args.video, "-t", f"{nf / 24:.3f}", "-vn",
                      "-f", "f32le", "-ac", "2", "-ar", str(asr), "-"], capture_output=True, check=True).stdout
wav = torch.frombuffer(bytearray(pcm), dtype=torch.float32).view(-1, 2).T
n_samp = round((nf + 1) / 24 * asr)
wav = torch.nn.functional.pad(wav, (0, max(0, n_samp - wav.shape[1])))[:, :n_samp]
with attention_backend(), torch.no_grad():
    posterior = pipe.audio_vae.encode(wav[:, None].to("cuda", torch.bfloat16), return_dict=False)[0]
mean = torch.tensor(pipe.audio_vae.config.latents_mean).view(1, 1, -1)
std = torch.tensor(pipe.audio_vae.config.latents_std).view(1, 1, -1)
rows = (posterior.mode().float().cpu().transpose(1, 2)[:, :audio_latent_num_frames(nf)] - mean) / std
audio_latents = rows.permute(0, 2, 1).contiguous()

torch.cuda.reset_peak_memory_stats()
t4 = time.time()
result = pipe(prompt="".join(segments), references=[video, *[MiniMaxH3ImageReference.from_file(x) for x in refs]],
              num_frames=nf, height=height, width=width, num_inference_steps=len(sigmas) - 1,
              generator=torch.Generator().manual_seed(args.seed), audio_latents=audio_latents,
              output=["videos", "audio", "sampling_rate"])
t5 = time.time()
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
encode_video(result["videos"][0], fps=24, output_path=args.out, audio=result["audio"][0],
             audio_sample_rate=result["sampling_rate"])
print(f"render {t5 - t4:.1f}s at {width}x{height}x{nf}, peak {torch.cuda.max_memory_allocated() / 2**30:.1f} GiB "
      f"allocated / {torch.cuda.max_memory_reserved() / 2**30:.1f} GiB reserved -> {args.out}", flush=True)