"""Joint fixed-N multi-frame generation inference (world-model / x_to_N). Unlike `interleave_inference.py` (SigLIP-handoff autoregressive rollout — a prior frame re-enters the next step as a *clean SigLIP image*), multi-frame generation lays out **all N `` blocks in ONE sequence** and integrates them **jointly** with a single Euler ODE. A prior frame stays in context as its **VAE latent** (at the shared ODE timestep), exactly matching training, where the assistant turn held N latent blocks denoised together (per-frame independent timesteps + hybrid block attention: causal across frames, bidirectional within). So inference is: seq = prompt(+ViT obs) + "open text" + N×([LAT]*n) + EOS x_k ~ N(0,1) for k in 1..N for t in linspace(1,0,steps): # ONE shared schedule flow_embed_k = vae2llm(x_k) + time(t) + pos # k = 1..N, concatenated hidden = model(... flow_embeds @ N flow_positions ...) # ONE forward x_k -= dt * llm2vae(hidden @ block_k) # k = 1..N frame_k = VAE.decode(x_k) The N frames cohere because frame k attends to frames 1..k (cross-frame causal), all in the single forward pass — no per-frame autoregressive loop. Alignment with training (this is the load-bearing correspondence) ----------------------------------------------------------------- TRAINING (modeling_unified_mot._build_flow_embeds + flow_matching_modules.sample_timesteps): the N target frames are packed into one sequence; `sample_timesteps` draws ONE INDEPENDENT timestep per frame (`randn(len(shapes))`), each broadcast over that frame's tokens; `x_t=(1-t)·clean+t·noise` per frame; a SINGLE forward predicts the velocity for ALL N frames and the FM-MSE is summed over all N. So: every frame independently noised, one forward → N frames. INFERENCE (here): one forward PER Euler step injects all N frames' current x_t (each with its own `time_embedder(t_k)`) at the N flow_positions and reads the velocity for ALL N frames — structurally identical to the training forward (one forward, N frames, per-frame time embedding). We therefore do NOT loop per frame and do NOT re-encode prior frames via SigLIP. The only inference-time choice is the per-frame timestep SCHEDULE `t_k(step)`: - lockstep (default, validated ~28 dB): all frames share the same t each step, swept 1→0 together. This is the equal-t diagonal of the joint t-space that the independent-per-frame training already covers, so it is in-distribution. - Because training randomized t per frame (incl. cases where earlier frames are much cleaner than later ones), a staggered "diffusion-forcing" schedule (frame k offset so it lags frame k-1) is also in-distribution and tends to help long autoregressive rollouts — left as a future `--schedule` option. Either way each step is ONE forward over all N frames; we never collapse the model's per-frame timestep capability into N separate passes. Run: python multiframe_inference.py --ckpt --vae \ --frames obs0.jpg obs1.jpg --task "imagine the next frames" \ --num_frames 4 --num_steps 50 --out_dir multiframe_out """ from __future__ import annotations import argparse import os from typing import List, Optional import torch from PIL import Image from transformers.models.hunyuan_vl_mot import HunYuanVLMoTProcessor # Reuse the (obs frames + task) prompt constructor, ViT placeholder ids, and the # montage helper from the interleave inference module. from interleave_inference import ( build_conditioned_sequence, INPUT_IMAGE_PLACEHOLDER_IDS, _row, ) @torch.no_grad() def _ar_decode_opening_text( inner, processor, prompt_ids, pixel_values, image_grid_thw, image_start_id, eos_id, device, max_text_tokens, ): """Greedily decode the assistant opening text until the model emits . Mirrors the text loop in interleave_inference.generate_step_joint. Returns the decoded token ids (without the terminating /EOS).""" seq = list(prompt_ids) text_ids: List[int] = [] def logits_last(ids): seq_len = len(ids) inp = torch.tensor(ids, dtype=torch.long, device=device).unsqueeze(0) mod = torch.zeros(1, seq_len, dtype=torch.long, device=device) iim = torch.zeros(1, seq_len, dtype=torch.bool, device=device) for pid in INPUT_IMAGE_PLACEHOLDER_IDS: m = inp[0] == pid mod[0, m] = 1 iim[0, m] = True out = inner( input_ids=inp, inputs_embeds=None, attention_mask=None, position_ids=torch.arange(seq_len, device=device).unsqueeze(0), pixel_values=pixel_values, image_grid_thw=image_grid_thw, cu_seqlens=torch.tensor([0, seq_len], dtype=torch.int32, device=device), sample_ids=torch.zeros(1, seq_len, dtype=torch.int32, device=device), modality_mask=mod, input_image_mask=iim, flow_embeds=None, flow_positions=None, g_seqlens=torch.zeros((0, 2), dtype=torch.int32, device=device), ) return out.logits[0, -1] for _ in range(max_text_tokens): nxt = int(logits_last(seq).argmax().item()) if nxt == image_start_id or nxt == eos_id: break text_ids.append(nxt) seq.append(nxt) return text_ids @torch.no_grad() def generate_multiframe_joint( model, vae, processor, obs_frames: List[str], task_text: str, num_frames: int, height: int, width: int, num_steps: int, device, dtype, max_text_tokens: int = 96, opening_text: Optional[str] = None, ): """Jointly denoise N frames in one sequence. Returns (opening_text, [imgs]).""" from model.flow_matching_modules import unpatchify_latent from text2image_inference import get_2d_position_ids cfg = model.config eos = cfg.eos_token_id # Multi-frame uses a single wrapper instead of N×. video_start = cfg.video_start_token_id # 120122 video_end = cfg.video_end_token_id # 120123 latent_ph = cfg.flow_latent_placeholder_id inner = model.model p = model.latent_patch_size ds = cfg.vae_image_downsample h_lat, w_lat = height // ds, width // ds n_latent = h_lat * w_lat patch_dim = p * p * cfg.vae_z_channels # Append the multi-frame task instruction to the user turn (train==infer parity). # build_conditioned_sequence folds task_text into the prompt. from inference_utils import TASK_INSTRUCTION_MULTI_FRAME instr_text = (task_text + "\n" + TASK_INSTRUCTION_MULTI_FRAME) if task_text else TASK_INSTRUCTION_MULTI_FRAME prompt_ids, proc = build_conditioned_sequence(processor, obs_frames, instr_text) pixel_values = proc.get("pixel_values") image_grid_thw = proc.get("image_grid_thw") if pixel_values is not None: pixel_values = pixel_values.to(device=device, dtype=dtype) if image_grid_thw is not None: image_grid_thw = image_grid_thw.to(device=device) # ---- opening text: teacher-force from GT or AR-decode until