[feat] v2: port the 14 remaining bucket-C archs as self-contained recipe packages
Completes the bucket-C porting backlog. Each arch is a self-contained recipe
package (card-declared torch adapter via ComponentSpec.adapter + a new/forked
loop + program, NO edit to the shared torch_backend dispatch), following the
cosmos2 reference pattern. One _BUCKET_C table in v2/registry.py drives both the
explicit HF-id registry (PRIMARY) and the select_by_architecture fallback.
Ported (CPU-verified: import + card/program build + registry resolve + denoise
loop runs end-to-end on the CPU toy backend; GPU load/run is BRINGUP):
* cosmos25 — Cosmos-Predict2.5 (flow-match, per-frame plain-sigma timestep;
reuse FLOW_MATCH_STEP; Reason1/Qwen2.5-VL encoder adapter)
* hunyuan_video — HunyuanVideo (reuses WanDenoiseLoop; dual LLaMA+CLIP encoders;
Hunyuan VAE scaling_factor) + FastHunyuan variant
* hunyuan_video15 — HunyuanVideo 1.5 (480p/720p cards)
* longcat — LongCat-Video T2V/I2V/VC
* sd35 — SD3.5 MMDiT (flow-match, image; triple-encoder joint embed +
pooled_projections)
* gen3c — GEN3C (EDM; 82ch pose-buffer DiT; camera/depth -> BRINGUP)
* kandinsky5 — Kandinsky 5.0 T2V Lite
* flux2 — FLUX.2 dev/klein (MMDiT, image; gated weights -> BRINGUP)
* stable_audio — Stable Audio Open (audio modality)
* hunyuangamecraft, hyworld, lingbotworld, matrixgame2, matrixgame3 — interactive
world models; t2v/degenerate path CPU-verified, action/camera/
memory conditioning is BRINGUP (needs request-API extension)
Adapters declared via ComponentSpec.adapter (the ac29750b enabler) so each port
adds only NEW files (recipe package + per-arch torch_<arch>.py + optional facade
stub) — zero shared-file edits. Registry resolves all 31 bucket-C HF ids by exact
id + 14 architecture fallbacks; no regression (cosmos2/wan/ltx2 unchanged).
v2 mini green (211 passed, 2 skipped); pre-commit clean (per-file/registry).
This commit is contained in:
committed by
Peiyuan Zhang
parent
bfbd90ea3a
commit
0467edbd07
@@ -0,0 +1,11 @@
|
||||
# STUB: re-exports fastvideo until vendored (see memory: v2-vendoring-approach).
|
||||
"""LingBot-World DiT facade.
|
||||
|
||||
The transformer class itself is constructed by the loader from the card's ``load_id``
|
||||
(``fastvideo.models.dits.lingbotworld.model:LingBotWorldTransformer3DModel``), so it is not imported
|
||||
here. What the v2 recipe DOES need from the fastvideo source is the camera/Plucker embedding builder
|
||||
``prepare_camera_embedding`` (poses.npy + intrinsics.npy -> ``c2ws_plucker_emb [B, 6*s^2, F, H, W]``),
|
||||
re-exported so the program's camera node can build the tensor without reaching into the model package.
|
||||
"""
|
||||
from fastvideo.models.dits.lingbotworld.cam_utils import ( # noqa: F401
|
||||
prepare_camera_embedding, )
|
||||
@@ -0,0 +1,5 @@
|
||||
# STUB: re-exports fastvideo until vendored (see memory: v2-vendoring-approach).
|
||||
"""SD3 (Stable Diffusion 3.5) DiT facade. The transformer class itself is constructed by the loader
|
||||
from the card's ``load_id``; this stub only re-exports the output dataclass so the SD3 torch adapter can
|
||||
type-check / unwrap the forward result symbolically (mirrors ``v2/models/dits/ltx2.py``)."""
|
||||
from fastvideo.models.dits.sd3 import SD3Transformer2DModelOutput # noqa: F401
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Cosmos-Predict2.5 torch adapters (GPU backend) — declared on the card via ``ComponentSpec.adapter``
|
||||
so the Cosmos2.5 recipe is self-contained (no edit to the shared ``_make_dit``/``_make_vae``/
|
||||
``_make_text_encoder`` dispatch in ``torch_backend.py``). Imported lazily by ``_explicit_adapter`` only
|
||||
on a GPU box.
|
||||
|
||||
The three Cosmos2.5 deltas vs Wan (all GPU-path, all carried HERE so the loop stays toy-compatible):
|
||||
|
||||
* ``Cosmos25DiT`` — a flow-match velocity DiT, but the per-step call differs from ``WanDiT`` in three
|
||||
load-bearing ways (see ``fastvideo/pipelines/stages/denoising.py:Cosmos25DenoisingStage``):
|
||||
1. timestep is a PER-FRAME 2D tensor ``[B, T]`` filled with the PLAIN sigma (in [0,1]) — NOT Wan's
|
||||
scalar ``sigma*1000``. (The fastvideo stage feeds ``t*0.001`` of the 0..1000 schedule, i.e. the
|
||||
shifted sigma directly; ``Cosmos25DenoiseLoop`` already hands us that plain sigma.)
|
||||
2. the forward REQUIRES a ``condition_mask[B,1,T,H,W]`` (zeros for t2v) + a ``padding_mask[B,1,H,W]``
|
||||
(ones for t2v) every step — the model concats them internally (16 -> 17 -> 18ch patch_embed),
|
||||
so we feed the RAW 16-channel latent (the OPPOSITE of Wan i2v, where the adapter pre-concats).
|
||||
3. an ``fps`` scalar is required for RoPE temporal scaling (NTK fps-modulation).
|
||||
The model output is the rectified-flow velocity (a bare tensor); the loop integrates it with the
|
||||
shared ``FLOW_MATCH_STEP`` Euler solver and combines branches with the standard ``ClassicCFG``.
|
||||
* ``Cosmos25WanVAE`` — a Wan-style causal 3D VAE (z=16, 8x/4x), BUT unlike ``AutoencoderKLWan`` its
|
||||
``encode``/``decode`` already apply the (z-mean)/std normalization INTERNALLY (the Cosmos latent
|
||||
contract: encode -> normalized, decode <- normalized). So this is a thin marshalling wrapper (like
|
||||
``LTX2VAE``) that must NOT re-normalize — reusing the v2 ``WanVAE`` adapter would double-normalize.
|
||||
* ``Cosmos25Reason1Encoder`` — Qwen2.5-VL multimodal LM via ``compute_text_embeddings_online``: per-layer
|
||||
mean-normalized hidden states concatenated along the feature dim -> a 100352-dim (= num_hidden_layers *
|
||||
hidden_size = 28*3584) sequence the DiT's internal 100352->1024 GELU crossattn projection consumes.
|
||||
Faithful to ``Cosmos25TextEncodingStage`` (calls ``compute_text_embeddings_online`` rather than
|
||||
re-implementing the normalization). BRINGUP: heavy (a 7B LM + 100352-dim embeds); GPU-only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import TorchComponent, _to_numpy
|
||||
|
||||
# Cosmos2.5 t2v feeds the DiT the PLAIN shifted sigma in [0,1] as the per-frame timestep — NOT
|
||||
# ``sigma * num_train_timesteps`` (the Wan convention would corrupt the output). See blocker (1).
|
||||
COSMOS25_DEFAULT_FPS = 24 # RoPE temporal scaling; the registered t2v preset's default fps.
|
||||
|
||||
|
||||
class Cosmos25DiT(TorchComponent):
|
||||
"""``dit(latent[C,T,h,w], text_embed[seq,100352], sigma) -> velocity[C,T,h,w]``.
|
||||
|
||||
The loop hands the RAW 16-channel latent and the PLAIN sigma (in [0,1]); this adapter builds the
|
||||
mandatory per-frame timestep + zero ``condition_mask`` + ones ``padding_mask`` + ``fps`` the Cosmos2.5
|
||||
forward requires (the model concats the masks itself -> 18ch patch_embed input). Returns the bare
|
||||
velocity tensor; ``Cosmos25DenoiseLoop`` does the ClassicCFG combine + the flow-match Euler step."""
|
||||
|
||||
def __init__(self, module, *, device, dtype, fps: int = COSMOS25_DEFAULT_FPS):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
# BRINGUP: request-driven fps would thread through the request API; the registered t2v preset
|
||||
# uses fps=24 (the fastvideo stage likewise defaults to a fixed fps for t2v). Held on the adapter
|
||||
# so the loop's dit-call stays ``dit(latent, text_embed, sigma)`` (toy-compatible — no fps kwarg).
|
||||
self.fps = int(fps)
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(self, latent, text_embed, sigma, context=None, *, cond=None):
|
||||
hs = self._t(latent) # [1, 16, T, h, w] — the RAW latent (no concat)
|
||||
ehs = self._t(text_embed) # [1, seq, 100352]
|
||||
_b, _c, t, h, w = hs.shape
|
||||
s = float(sigma)
|
||||
# (1) per-frame 2D timestep [B, T] of the PLAIN sigma (NOT sigma*1000). For t2v every frame shares
|
||||
# the same sigma; the conditioned (V2W) path would override selected frames (BRINGUP, see loop).
|
||||
timestep = torch.full((1, t), s, device=self.device, dtype=torch.float32)
|
||||
# (2) mandatory masks: zeros condition_mask (no frame conditioned in t2v) + ones padding_mask
|
||||
# (no padding in t2v). The model resizes padding_mask to [h, w] and concats both internally.
|
||||
condition_mask = torch.zeros(1, 1, t, h, w, device=self.device, dtype=self.dtype)
|
||||
padding_mask = torch.ones(1, 1, h, w, device=self.device, dtype=self.dtype)
|
||||
# (3) fps scalar for RoPE temporal NTK scaling.
|
||||
fps_tensor = torch.tensor([float(self.fps)], device=self.device, dtype=self.dtype)
|
||||
with self._ctx():
|
||||
velocity = self.module(hidden_states=hs,
|
||||
timestep=timestep,
|
||||
encoder_hidden_states=ehs,
|
||||
fps=fps_tensor,
|
||||
condition_mask=condition_mask,
|
||||
padding_mask=padding_mask,
|
||||
return_dict=False)[0]
|
||||
return self._n(velocity) # rectified-flow velocity (loop integrates it)
|
||||
|
||||
|
||||
class Cosmos25WanVAE(TorchComponent):
|
||||
"""Cosmos2.5 Wan-style causal 3D VAE (``fastvideo.models.vaes.cosmos25wanvae:Cosmos25WanVAE``).
|
||||
|
||||
Its ``encode``/``decode`` ALREADY apply the (z-mean)/std normalization internally (the Cosmos latent
|
||||
contract), so this adapter only marshals numpy<->torch — it must NOT re-normalize (that is why we do
|
||||
not reuse the v2 ``WanVAE`` adapter, which would double-apply the per-channel stats)."""
|
||||
|
||||
@torch.no_grad()
|
||||
def decode(self, latent):
|
||||
z = self._t(latent) # [1, 16, T, h, w] — a NORMALIZED latent
|
||||
video = self.module.decode(z.to(self.dtype)) # -> video [B, 3, T, H, W] in [-1, 1]
|
||||
if hasattr(video, "sample"):
|
||||
video = video.sample
|
||||
return self._n(video)
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, video):
|
||||
dist = self.module.encode(self._t(video))
|
||||
z = dist.mode() if hasattr(dist, "mode") else (dist.sample() if hasattr(dist, "sample") else dist)
|
||||
return self._n(z) # already the NORMALIZED latent the DiT expects
|
||||
|
||||
|
||||
class Cosmos25Reason1Encoder(TorchComponent):
|
||||
"""Reason1 (Qwen2.5-VL) text encoder. ``encode(text) -> embedding[seq, 100352]`` (numpy out).
|
||||
|
||||
Faithful to ``Cosmos25TextEncodingStage``: call ``compute_text_embeddings_online({"text": [text]},
|
||||
"text")`` (the encoder applies the chat template + per-layer mean-normalize + full-concat itself, so
|
||||
we do NOT re-implement the 100352-dim normalization here). The negative prompt is encoded the same
|
||||
way for ClassicCFG. The encoder is multimodal/heavy — BRINGUP, GPU-only.
|
||||
|
||||
The ``tokenizer`` extra from ``_make_text_encoder`` is accepted for signature compatibility but
|
||||
unused: ``Reason1TextEncoder`` owns its own processor/tokenizer (``self.processor.tokenizer``)."""
|
||||
|
||||
def __init__(self, module, tokenizer=None, *, device, dtype):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.tokenizer = tokenizer # unused; Reason1 carries its own processor (BRINGUP note above)
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
with self._ctx():
|
||||
# returns [B, seq, 100352]; single-request B=1 -> squeeze to [seq, 100352] (the DiT adapter's
|
||||
# _t re-adds the batch dim, matching the fastvideo stage's batch.prompt_embeds[0]).
|
||||
embeds = self.module.compute_text_embeddings_online({"text": [text or ""]}, "text")
|
||||
if hasattr(embeds, "shape") and len(embeds.shape) == 3:
|
||||
embeds = embeds[0]
|
||||
return _to_numpy(embeds)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""FLUX.2 torch adapters (GPU backend) — declared on the card via ``ComponentSpec.adapter`` so the FLUX.2
|
||||
recipe is self-contained (no edit to the shared ``_make_dit``/``_make_vae``/``_make_text_encoder`` dispatch
|
||||
in ``torch_backend.py``). Imported lazily by ``_explicit_adapter`` only on a GPU box.
|
||||
|
||||
These three pieces CANNOT reuse the Wan/T5 adapters (BRINGUP — GPU-verify each against a real FLUX.2 ckpt):
|
||||
|
||||
* ``Flux2DiT`` — the dual-stream MMDiT. The loop hands ``dit(latent[64,T,h,w], text_embed[seq,4096], σ)``;
|
||||
this adapter builds everything FLUX.2-specific INTERNALLY so the loop stays toy-compatible:
|
||||
- σ is passed DIRECTLY as the timestep (the pipeline's ``timestep/1000``); the DiT re-multiplies by
|
||||
1000. Guidance is passed RAW (``embedded_guidance`` scale, ×1000 internally). Passing σ·1000 (the
|
||||
Wan convention) would 1e6-blow-up the timestep embedding.
|
||||
- ``encoder_hidden_states`` is wrapped in a 1-element list (the forward indexes [0]).
|
||||
- ``img_ids`` = ``cartesian_prod(arange(T), arange(h), arange(w), arange(1))`` and ``txt_ids`` =
|
||||
``cartesian_prod(1,1,1,arange(seq))`` — the N-D RoPE position tensors threaded into every forward.
|
||||
- run WITHOUT autocast (Gate 2: FLUX.2 long-seq attention breaks under autocast); module + inputs bf16.
|
||||
Returns the bare velocity. Faithful to ``fastvideo/pipelines/stages/denoising.py`` (the ``_is_flux``
|
||||
gates) + ``flux_2.py`` forward + ``flux_2_latent_preparation.py`` / ``flux_2_text_encoding.py`` id builders.
|
||||
|
||||
* ``Flux2VAE`` — decode does the FLUX.2 BatchNorm whitening + 2×2 unpatchify (NOT Wan mean/std):
|
||||
``z·bn_std + bn_mean`` (the complete inverse normalization; no scaling_factor step) then unpack 64→16ch
|
||||
full-spatial, then ``module.decode``. Faithful to ``DecodingStage._flux2_bn_denorm_and_unpatchify`` +
|
||||
``_unpatchify_latents`` (fastvideo/pipelines/stages/decoding.py). Image-only: T==1 squeezed.
|
||||
|
||||
* ``Flux2Mistral3Encoder`` / ``Flux2Qwen3Encoder`` — the chat-template multi-layer hidden-state stack.
|
||||
Apply the FLUX2 system+user chat template (dev only), tokenize padded to max_length (512),
|
||||
``output_hidden_states=True``, stack the chosen decoder layers (dev 10/20/30, klein 9/18/27),
|
||||
permute+reshape to [seq, n_layers·hidden] (= 4096). Faithful to ``Flux2TextEncodingStage`` +
|
||||
``flux2_klein_postprocess_text`` (fastvideo/configs/pipelines/flux_2.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import TorchComponent, _to_numpy
|
||||
|
||||
# FLUX.2 system message prepended to the user prompt via the chat template (dev). Faithful copy of
|
||||
# ``fastvideo/pipelines/basic/flux_2/flux_2_text_encoding.py:FLUX2_SYSTEM_MESSAGE``.
|
||||
FLUX2_SYSTEM_MESSAGE = ("You are an AI that reasons about image descriptions. You give structured "
|
||||
"responses focusing on object relationships, object\nattribution and actions "
|
||||
"without speculation.")
|
||||
|
||||
NUM_TRAIN_TIMESTEPS = 1000 # the DiT re-multiplies timestep AND guidance by this internally
|
||||
|
||||
|
||||
class Flux2DiT(TorchComponent):
|
||||
"""``dit(latent[64,T,h,w], text_embed[seq,4096], sigma) -> velocity[64,T,h,w]``.
|
||||
|
||||
Real forward (flux_2.py): ``forward(hidden_states[B,64,T,h,w], encoder_hidden_states=[ehs], timestep,
|
||||
img_ids, txt_ids, guidance) -> velocity (bare 5D tensor)``. The pipeline passes ``timestep == sigma``
|
||||
(DiT ×1000 internally) and ``guidance == embedded_cfg_scale`` raw (DiT ×1000 internally)."""
|
||||
|
||||
def __init__(self, module, *, device, dtype):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
# FLUX.2-dev uses guidance_embeds=True (a guidance arg); klein distilled has no guidance embedder.
|
||||
self.guidance_embeds = bool(getattr(module, "guidance_embeds", True))
|
||||
|
||||
def _ids(self, t: int, h: int, w: int, txt_len: int):
|
||||
# img_ids over (T, h, w, 1); txt_ids over (1,1,1,seq) — the N-D RoPE position tensors. Faithful to
|
||||
# flux_2_latent_preparation.py (latent_ids) + flux_2_text_encoding.py (_prepare_flux2_text_ids).
|
||||
img_ids = torch.cartesian_prod(torch.arange(t, device=self.device), torch.arange(h, device=self.device),
|
||||
torch.arange(w, device=self.device),
|
||||
torch.arange(1, device=self.device)).unsqueeze(0)
|
||||
txt_ids = torch.cartesian_prod(torch.arange(1, device=self.device), torch.arange(1, device=self.device),
|
||||
torch.arange(1, device=self.device),
|
||||
torch.arange(txt_len, device=self.device)).unsqueeze(0)
|
||||
return img_ids, txt_ids
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(self, latent, text_embed, sigma, context=None, *, cond=None):
|
||||
hs = self._t(latent) # [1, 64, T, h, w]
|
||||
ehs = self._t(text_embed) # [1, seq, 4096]
|
||||
b, _c, t, h, w = hs.shape
|
||||
s = float(sigma)
|
||||
# timestep == sigma directly (NOT sigma*1000) — the DiT multiplies by 1000 internally.
|
||||
timestep = torch.tensor([s] * b, device=self.device, dtype=self.dtype)
|
||||
guidance = None
|
||||
if self.guidance_embeds: # dev: embedded guidance, raw scale
|
||||
guidance = torch.tensor([float(self._guidance(context))] * b, device=self.device, dtype=self.dtype)
|
||||
img_ids, txt_ids = self._ids(t, h, w, ehs.shape[1])
|
||||
with self._ctx(current_timestep=s): # NO autocast (Gate 2) — module+inputs bf16
|
||||
velocity = self.module(
|
||||
hidden_states=hs,
|
||||
encoder_hidden_states=[ehs], # forward indexes [0]
|
||||
timestep=timestep,
|
||||
img_ids=img_ids,
|
||||
txt_ids=txt_ids,
|
||||
guidance=guidance)
|
||||
return self._n(velocity) # rectified-flow velocity
|
||||
|
||||
@staticmethod
|
||||
def _guidance(context) -> float:
|
||||
# The loop threads the embedded-guidance scale through the ``context`` slot of the dit-call (the
|
||||
# toy-compatible signature). BRINGUP: the request-API extension that carries the scale per-request
|
||||
# is pending; until then the card's sampling default (dev 4.0) is the value, set on the adapter by
|
||||
# the loop wiring. Defaults to 4.0 if unset.
|
||||
return 4.0 if context is None else float(context)
|
||||
|
||||
|
||||
class Flux2VAE(TorchComponent):
|
||||
"""FLUX.2 image VAE. decode: BN-denorm (``z·bn_std + bn_mean``) → 2×2 unpatchify (64→16ch full-spatial)
|
||||
→ ``module.decode`` → image[-1,1]. encode: ``module.encode`` (mode) → packed latent (BRINGUP: pack +
|
||||
BN-whiten on encode is GPU-only; t2i never encodes). NOT the Wan (z−mean)/std normalization."""
|
||||
|
||||
def _bn_stats(self, like: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
bn = self.module.bn
|
||||
eps = float(getattr(bn, "eps", 1e-5))
|
||||
running_mean = bn.running_mean.view(1, -1, 1, 1).to(like.device, like.dtype)
|
||||
running_var = bn.running_var.view(1, -1, 1, 1).to(like.device, like.dtype)
|
||||
bn_std = torch.sqrt(torch.clamp(running_var + eps, min=1e-6))
|
||||
return running_mean, bn_std
|
||||
|
||||
@staticmethod
|
||||
def _unpatchify(latents: torch.Tensor) -> torch.Tensor:
|
||||
# (B, C*4, h, w) -> (B, C, 2h, 2w): inverse of the 2×2 patch packing (DecodingStage._unpatchify_latents).
|
||||
b, c, h, w = latents.shape
|
||||
latents = latents.reshape(b, c // 4, 2, 2, h, w).permute(0, 1, 4, 2, 5, 3)
|
||||
return latents.reshape(b, c // 4, h * 2, w * 2)
|
||||
|
||||
@torch.no_grad()
|
||||
def decode(self, latent):
|
||||
z = self._t(latent).float() # [1, 64, T, h, w]
|
||||
if z.ndim == 5: # image VAE expects 4D; T==1 squeezed
|
||||
z = z.squeeze(2)
|
||||
running_mean, bn_std = self._bn_stats(z)
|
||||
z = z * bn_std + running_mean # BN-denorm: complete inverse (no scaling_factor)
|
||||
z = self._unpatchify(z) # 64 -> 16ch full-spatial
|
||||
image = self.module.decode(z.to(self.dtype))
|
||||
if hasattr(image, "sample"):
|
||||
image = image.sample
|
||||
elif isinstance(image, tuple | list):
|
||||
image = image[0]
|
||||
image = (image / 2 + 0.5).clamp(0, 1) # -> [0,1]
|
||||
return self._n(image.unsqueeze(2)) # restore the singleton T for a uniform [C,T,H,W]
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, image):
|
||||
# BRINGUP: t2i never encodes; the 2×2 pack + BN whiten on the encode path is GPU-only and unverified.
|
||||
x = self._t(image)
|
||||
dist = self.module.encode(x)
|
||||
z = dist.mode() if hasattr(dist, "mode") else (dist.sample() if hasattr(dist, "sample") else dist)
|
||||
return _to_numpy(z)
|
||||
|
||||
|
||||
class _Flux2TextEncoderBase(TorchComponent):
|
||||
"""Shared FLUX.2 chat-template multi-layer hidden-state stack. Subclasses set ``layers`` and whether
|
||||
the FLUX2 system-message chat template is applied (dev=Mistral3) or a plain user template (klein=Qwen3)."""
|
||||
|
||||
layers: tuple[int, ...] = (10, 20, 30)
|
||||
use_system_message: bool = True
|
||||
|
||||
def __init__(self, module, tokenizer, *, device, dtype, max_length: int = 512):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.tokenizer = tokenizer
|
||||
self.max_length = max_length
|
||||
|
||||
def _messages(self, prompt: str):
|
||||
user = {"role": "user", "content": [{"type": "text", "text": (prompt or "").replace("[IMG]", "")}]}
|
||||
if self.use_system_message:
|
||||
system = {"role": "system", "content": [{"type": "text", "text": FLUX2_SYSTEM_MESSAGE}]}
|
||||
return [[system, user]]
|
||||
return [[user]]
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
inputs = self.tokenizer.apply_chat_template(self._messages(text),
|
||||
add_generation_prompt=False,
|
||||
tokenize=True,
|
||||
return_dict=True,
|
||||
return_tensors="pt",
|
||||
padding="max_length",
|
||||
truncation=True,
|
||||
max_length=self.max_length)
|
||||
input_ids = inputs["input_ids"].to(self.device)
|
||||
attention_mask = inputs["attention_mask"].to(self.device)
|
||||
with self._ctx():
|
||||
out = self.module(input_ids=input_ids,
|
||||
attention_mask=attention_mask,
|
||||
output_hidden_states=True,
|
||||
use_cache=False)
|
||||
if out.hidden_states is None:
|
||||
raise ValueError("FLUX.2 requires output_hidden_states=True from the text encoder")
|
||||
# stack the chosen decoder layers -> [B, n_layers, seq, hidden] -> permute/reshape [B, seq, n·hidden]
|
||||
stacked = torch.stack([out.hidden_states[k] for k in self.layers], dim=1)
|
||||
b, n_layers, seq_len, hidden = stacked.shape
|
||||
embed = stacked.permute(0, 2, 1, 3).reshape(b, seq_len, n_layers * hidden)
|
||||
return _to_numpy(embed.squeeze(0)) # [seq, n_layers*hidden] (= 4096)
|
||||
|
||||
|
||||
class Flux2Mistral3Encoder(_Flux2TextEncoderBase):
|
||||
"""FLUX.2-dev text encoder: Mistral3, hidden-state layers 10/20/30, FLUX2 system-message chat template."""
|
||||
layers = (10, 20, 30)
|
||||
use_system_message = True
|
||||
|
||||
|
||||
class Flux2Qwen3Encoder(_Flux2TextEncoderBase):
|
||||
"""FLUX.2-klein text encoder: Qwen3, hidden-state layers 9/18/27, plain user chat template (no system msg)."""
|
||||
layers = (9, 18, 27)
|
||||
use_system_message = False
|
||||
@@ -0,0 +1,138 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""GEN3C torch adapters (GPU backend) — declared on the card via ``ComponentSpec.adapter`` so the GEN3C
|
||||
recipe is self-contained (no edit to the shared ``_make_dit``/``_make_vae``/``_make_text_encoder``
|
||||
dispatch in ``torch_backend.py``). Imported lazily by ``_explicit_adapter`` only on a GPU box.
|
||||
|
||||
GEN3C is Cosmos-Predict2's EDM denoiser **extended with camera/3D-cache conditioning**. The deltas vs
|
||||
the ``CosmosDiT`` adapter (see ``torch_cosmos.py``) are concentrated here:
|
||||
|
||||
* ``Gen3CDiT`` — the EDM denoiser ``F_θ``. The loop hands the *already EDM-input-scaled* model input
|
||||
(``x·c_in``) plus the **raw** conditioning bundle as kwargs; the DiT internally concats
|
||||
``[latent(16) | input_mask(1) | pose_buffer(frame_buffer_max·32) | padding_mask(1)] = 82`` along the
|
||||
channel dim before ``patch_embed``. The conditioning is NOT pre-concatenated into the latent (the Wan
|
||||
i2v 16→36 concat would be wrong here): the loop passes ``condition_video_pose`` /
|
||||
``condition_video_input_mask`` / ``condition_video_augment_sigma`` / ``padding_mask`` separately and
|
||||
the model assembles the 82-ch input. The model timestep is the EDM **noise preconditioning**
|
||||
``c_noise = 0.25·log(σ)`` (NOT ``σ·1000`` like Wan, NOT raw σ) — this is exactly the value the
|
||||
diffusers ``EDMEulerScheduler.precondition_noise`` puts in ``scheduler.timesteps``, which the
|
||||
fastvideo ``Gen3CDenoisingStage`` feeds to the transformer. Returns the **raw** transformer output
|
||||
(the EDM ``c_skip``/``c_out`` → x0 reconstruction + x0-space CFG + frame-replace live in
|
||||
``Gen3CDenoiseLoop``, NOT here). Faithful to ``Gen3CDenoisingStage.forward``
|
||||
(fastvideo/pipelines/stages/gen3c_stages.py).
|
||||
* ``Gen3CVAE`` — wraps ``AutoencoderKLGen3CTokenizer`` (a JIT-backed tokenizer, NOT a diffusers
|
||||
``AutoencoderKL``). Normalization is **internal** (the tokenizer applies its own mean/std buffers
|
||||
inside ``encode``/``decode``), so the adapter does NOT subtract/divide an external Wan-style
|
||||
``(z-mean)/std`` (BRINGUP: contrast with ``WanVAE``). ``encode`` returns the tokenizer's ``.mode()``.
|
||||
* ``Gen3CT5Encoder`` — T5-Large (1024-dim). GEN3C uses the **raw** last_hidden_state (NaN→0, no
|
||||
fixed-length zero-pad), identical to Cosmos's ``CosmosT5Encoder`` (reused by subclassing it).
|
||||
|
||||
BRINGUP (all GPU-only — written-not-run, verify on the box):
|
||||
* The ``AutoencoderKLGen3CTokenizer`` JIT loader expects ``encoder.jit`` / ``decoder.jit`` /
|
||||
``mean_std.pt`` under the tokenizer dir (``from_jit_tokenizer``); the v2 loader seam must route the
|
||||
``vae`` component to it rather than ``VAELoader``'s diffusers path — confirm the converted HF repo
|
||||
layout on the GPU box.
|
||||
* The camera / MoGe-depth / 3D-cache-render conditioning that fills ``condition_video_pose`` (and
|
||||
``conditioning_latents`` for the first-frame anchor) is a separate pre-loop stage (see
|
||||
``v2/recipes/gen3c/program.py``); without it the loop runs the degenerate **t2v** path (all-zeros
|
||||
conditioning → the pose-buffer concat is effectively zeroed inside the DiT).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import TorchComponent, _to_numpy
|
||||
from v2.platform.backends.torch_cosmos import CosmosT5Encoder
|
||||
|
||||
|
||||
def _c_noise(sigma: float) -> float:
|
||||
"""EDM noise preconditioning ``c_noise = 0.25·log(σ)`` — exactly ``EDMEulerScheduler.timesteps[i]``
|
||||
for ``σ = sigmas[i]``. The GEN3C DiT embeds this directly via its sinusoidal ``Timesteps`` module
|
||||
(NO ``·1000``)."""
|
||||
return 0.25 * math.log(max(float(sigma), 1e-12))
|
||||
|
||||
|
||||
class Gen3CDiT(TorchComponent):
|
||||
"""``dit(model_input[16,T,h,w], text_embed[seq,1024], sigma, *, cond=<bundle>) -> raw output[16,T,h,w]``.
|
||||
|
||||
``model_input`` is pre-scaled by the loop (``x·c_in``); ``sigma`` is the *raw* EDM sigma (the adapter
|
||||
converts it to the model timestep ``c_noise = 0.25·log(σ)`` internally). ``cond`` is the GEN3C
|
||||
conditioning bundle dict (or None for t2v): ``condition_video_pose`` [buffer_channels,T,h,w],
|
||||
``condition_video_input_mask`` [1,T,h,w], ``condition_video_augment_sigma`` (scalar), and the pixel
|
||||
``height``/``width`` for the padding mask. None → the DiT builds zeros internally (degenerate t2v)."""
|
||||
|
||||
def __init__(self, module, *, device, dtype, fps: int = 24):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.fps = int(fps)
|
||||
# add_augment_sigma_embedding is FALSE in the shipped 7B arch config → no augment_sigma weights;
|
||||
# the model only adds an augment-sigma embedding when that module exists (BRINGUP blocker 8).
|
||||
self.has_augment_embed = bool(getattr(module, "add_augment_sigma_embedding", False))
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(self, model_input, text_embed, sigma, context=None, *, cond=None):
|
||||
hs = self._t(model_input) # [1, 16, T, h, w]
|
||||
ehs = self._t(text_embed) # [1, seq, 1024]
|
||||
b, _c, t, h, w = hs.shape
|
||||
ts = torch.full((b, ), _c_noise(sigma), device=self.device, dtype=self.dtype) # c_noise = 0.25·log σ
|
||||
|
||||
# Conditioning bundle: real tensors for the camera/3D-cache path, zeros (degenerate t2v) otherwise.
|
||||
# The DiT concats input_mask + pose + padding_mask INTERNALLY → 82-ch patch_embed input.
|
||||
input_mask = pose = augment_sigma = None
|
||||
pix_h, pix_w = h, w # padding_mask is at PIXEL h,w then resized inside the DiT
|
||||
if cond is not None:
|
||||
input_mask = self._t(cond.get("condition_video_input_mask")) \
|
||||
if cond.get("condition_video_input_mask") is not None else None
|
||||
pose = self._t(cond.get("condition_video_pose")) \
|
||||
if cond.get("condition_video_pose") is not None else None
|
||||
aug = cond.get("condition_video_augment_sigma")
|
||||
augment_sigma = torch.full((b, ), float(aug), device=self.device, dtype=self.dtype) \
|
||||
if (aug is not None and self.has_augment_embed) else None
|
||||
pix_h = int(cond.get("height", h))
|
||||
pix_w = int(cond.get("width", w))
|
||||
padding_mask = torch.zeros(b, 1, pix_h, pix_w, device=self.device, dtype=self.dtype) # resized→[h,w]
|
||||
|
||||
with self._ctx():
|
||||
out = self.module(hidden_states=hs,
|
||||
timestep=ts,
|
||||
encoder_hidden_states=ehs,
|
||||
fps=self.fps,
|
||||
condition_video_input_mask=input_mask,
|
||||
condition_video_pose=pose,
|
||||
condition_video_augment_sigma=augment_sigma,
|
||||
padding_mask=padding_mask)
|
||||
if isinstance(out, tuple):
|
||||
out = out[0]
|
||||
return self._n(out) # RAW EDM output (the loop reconstructs x0)
|
||||
|
||||
|
||||
class Gen3CVAE(TorchComponent):
|
||||
"""``AutoencoderKLGen3CTokenizer`` adapter. The tokenizer is JIT-backed and handles its OWN latent
|
||||
normalization internally (mean_std buffers) — so, unlike ``WanVAE``, this adapter does NOT apply an
|
||||
external ``(z-mean)/std``. ``encode`` returns the tokenizer's distribution ``.mode()`` (the
|
||||
``_retrieve_latents`` contract the fastvideo stage uses); ``decode`` is a passthrough."""
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, video):
|
||||
x = self._t(video)
|
||||
dist = self.module.encode(x)
|
||||
z = dist.mode() if hasattr(dist, "mode") else (dist.sample() if hasattr(dist, "sample") else dist)
|
||||
return self._n(z) # already in the DiT's (internally-normalized) latent space
|
||||
|
||||
@torch.no_grad()
|
||||
def decode(self, latent):
|
||||
video = self.module.decode(self._t(latent)) # tokenizer un-normalizes internally → video [B,3,T,H,W]
|
||||
if hasattr(video, "sample"):
|
||||
video = video.sample
|
||||
return self._n(video)
|
||||
|
||||
|
||||
class Gen3CT5Encoder(CosmosT5Encoder):
|
||||
"""GEN3C T5-Large (1024-dim): raw last_hidden_state (NaN→0), no Wan zero-pad — identical to Cosmos.
|
||||
Reuses ``CosmosT5Encoder.encode`` verbatim. (BRINGUP: GEN3C pads with ``max_length`` and zeros rows
|
||||
past the attention-mask length, which ``CosmosT5Encoder`` already does — verify the max_length /
|
||||
mask-zeroing matches the converted T5-Large tokenizer on the GPU box.)"""
|
||||
|
||||
|
||||
# Re-export so a reader can ``from v2.platform.backends.torch_gen3c import _to_numpy`` if needed.
|
||||
__all__ = ["Gen3CDiT", "Gen3CVAE", "Gen3CT5Encoder", "_to_numpy"]
|
||||
@@ -0,0 +1,176 @@
|
||||
"""HunyuanVideo torch adapters (GPU backend) — declared on the card via ``ComponentSpec.adapter`` so the
|
||||
Hunyuan recipe is self-contained (no edit to the shared ``_make_dit``/``_make_vae``/``_make_text_encoder``
|
||||
dispatch in ``torch_backend.py``). Imported lazily by ``_explicit_adapter`` only on a GPU box.
|
||||
|
||||
Three deltas vs the Wan adapters:
|
||||
|
||||
* ``HunyuanVideoDiT`` — flow-match velocity DiT, BUT ``encoder_hidden_states`` is a **2-element list**
|
||||
``[llama_hidden[B, L, 4096], clip_pooled[B, 768]]`` (the forward splits a list as
|
||||
``txt = ehs[0]``, ``text_states_2 = ehs[1]``), NOT a single tensor like Wan. The loop hands the LLaMA
|
||||
per-token sequence as ``text_embed`` and the CLIP-pooled global vector via ``context=`` (Wan's existing
|
||||
conditioning channel — see ``recipes/hunyuan_video/loop.py``); this adapter assembles the pair
|
||||
internally. ``timestep = sigma*1000`` (scalar per-batch ``LongTensor``); the output is the **bare**
|
||||
velocity tensor (NO ``x0 -> velocity`` conversion, unlike LTX-2 — Hunyuan is velocity-native like Wan).
|
||||
``guidance`` is left at the forward default: base HunyuanVideo has ``guidance_embeds=False`` so
|
||||
``guidance_in is None`` and the arg is a no-op (BRINGUP: a guidance-distilled checkpoint with
|
||||
``guidance_embeds=True`` would need ``embedded_cfg_scale`` wired in here).
|
||||
|
||||
* ``HunyuanVideoVAE`` — ``AutoencoderKLHunyuanVideo`` normalizes by a scalar ``scaling_factor`` (0.476986),
|
||||
NOT Wan's per-channel ``latents_mean``/``latents_std``. DiT latent = ``raw_z * scaling_factor`` on
|
||||
encode; decode divides ``latents / scaling_factor`` before ``module.decode``. Decode output is video in
|
||||
``[-1, 1]``.
|
||||
|
||||
* ``HunyuanVideoLlamaEncoder`` / ``HunyuanVideoCLIPEncoder`` — the dual text path. LLaMA wraps the prompt
|
||||
in the ``PROMPT_TEMPLATE_ENCODE_VIDEO`` chat template, runs with ``output_hidden_states=True``, takes the
|
||||
intermediate hidden state ``hidden_states[-(2+1)]`` (skip the last 2 layers), and crops the first 95
|
||||
template tokens (``crop_start=95``). CLIP returns the ``pooler_output`` (768-dim global vector). Faithful
|
||||
to ``fastvideo/configs/pipelines/hunyuan.py`` (``llama_preprocess_text`` / ``llama_postprocess_text`` /
|
||||
``clip_postprocess_text``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import (
|
||||
NUM_TRAIN_TIMESTEPS,
|
||||
TorchComponent,
|
||||
_to_numpy,
|
||||
)
|
||||
|
||||
# The LLaMA prompt template + crop verbatim from fastvideo/configs/pipelines/hunyuan.py (recipe DATA, not
|
||||
# model code, so v2 owns its own copy rather than importing the private pipeline symbol).
|
||||
PROMPT_TEMPLATE_ENCODE_VIDEO = (
|
||||
"<|start_header_id|>system<|end_header_id|>\n\nDescribe the video by detailing the following aspects: "
|
||||
"1. The main content and theme of the video."
|
||||
"2. The color, shape, size, texture, quantity, text, and spatial relationships of the objects."
|
||||
"3. Actions, events, behaviors temporal relationships, physical movement changes of the objects."
|
||||
"4. background environment, light, style and atmosphere."
|
||||
"5. camera angles, movements, and transitions used in the video:<|eot_id|>"
|
||||
"<|start_header_id|>user<|end_header_id|>\n\n{}<|eot_id|>")
|
||||
LLAMA_CROP_START = 95 # the template's fixed system-prompt token count (crop_start)
|
||||
LLAMA_HIDDEN_STATE_SKIP_LAYER = 2 # use hidden_states[-(skip+1)], i.e. skip the last 2 LLaMA layers
|
||||
LLAMA_TEXT_LEN = 256 # LlamaArchConfig.text_len (the tokenizer max_length before the template)
|
||||
CLIP_TEXT_LEN = 77 # CLIPTextConfig.text_len
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DiT adapter #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class HunyuanVideoDiT(TorchComponent):
|
||||
"""``dit(latent[C,T,H,W], text_embed=llama_hidden[L,4096], sigma, context=clip_pooled[768])`` ->
|
||||
velocity[C,T,H,W]. Real forward (hunyuanvideo.py): ``forward(hidden_states[B,C,T,H,W],
|
||||
encoder_hidden_states=[llama, clip], timestep[B], guidance=None) -> velocity (bare tensor)``."""
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(self, latent, text_embed, sigma, context=None, *, cond=None):
|
||||
hs = self._t(latent) # [1, C, T, h, w]
|
||||
llama = self._t(text_embed) # [1, L, 4096] per-token LLaMA sequence
|
||||
# timestep = sigma*1000 (the scalar per-batch convention the time_in TimestepEmbedder consumes).
|
||||
timestep = torch.tensor([float(sigma) * NUM_TRAIN_TIMESTEPS], device=self.device)
|
||||
if context is not None: # the faithful 2-element list path
|
||||
clip_pooled = self._t(context) # [1, 768] CLIP global vector
|
||||
encoder_hidden_states: object = [llama, clip_pooled]
|
||||
else: # degenerate: a single tensor (token 0 pooled)
|
||||
encoder_hidden_states = llama
|
||||
with self._ctx():
|
||||
# guidance=None -> forward default 6016.0, but base guidance_in is None so it is ignored
|
||||
# (do NOT wire embedded_cfg_scale unless a guidance_embeds=True checkpoint is loaded). The DiT
|
||||
# does an internal sequence-parallel shard/all-gather that no-ops at sp=1 (the bring-up default).
|
||||
velocity = self.module(hidden_states=hs,
|
||||
encoder_hidden_states=encoder_hidden_states,
|
||||
timestep=timestep,
|
||||
guidance=None)
|
||||
return self._n(velocity) # rectified-flow velocity (bare tensor)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# VAE adapter #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class HunyuanVideoVAE(TorchComponent):
|
||||
"""``AutoencoderKLHunyuanVideo`` uses a scalar ``scaling_factor`` (0.476986), not Wan's per-channel
|
||||
mean/std. DiT latent = ``raw_z * scaling_factor``; decode divides by it before ``module.decode``."""
|
||||
|
||||
def _scaling_factor(self) -> float:
|
||||
# ParallelTiledVAE exposes ``scaling_factor`` as a property reading config.scaling_factor.
|
||||
sf = getattr(self.module, "scaling_factor", None)
|
||||
if sf is None:
|
||||
sf = getattr(getattr(self.module, "config", None), "scaling_factor", 0.476986)
|
||||
if sf is None: # defensive: neither property nor config carried it
|
||||
sf = 0.476986
|
||||
return float(sf.item() if hasattr(sf, "item") else sf)
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, video):
|
||||
x = self._t(video)
|
||||
dist = self.module.encode(x) # DiagonalGaussianDistribution
|
||||
z = dist.mode() if hasattr(dist, "mode") else (dist.sample() if hasattr(dist, "sample") else dist)
|
||||
return self._n(z.float() * self._scaling_factor()) # -> the scaled latent the DiT denoises
|
||||
|
||||
@torch.no_grad()
|
||||
def decode(self, latent):
|
||||
z = self._t(latent).float() / self._scaling_factor() # invert the encode scaling -> raw latent
|
||||
video = self.module.decode(z.to(self.dtype)) # -> video [B,3,T,H,W] in [-1,1]
|
||||
return self._n(video)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Text encoders (dual: LLaMA per-token sequence + CLIP pooled global vector) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class HunyuanVideoLlamaEncoder(TorchComponent):
|
||||
"""LLaMA primary encoder: wrap the prompt in ``PROMPT_TEMPLATE_ENCODE_VIDEO``, run with
|
||||
``output_hidden_states=True``, take ``hidden_states[-(skip+1)]`` (skip the last 2 layers) and crop the
|
||||
first 95 template tokens. Returns the per-token sequence [L, 4096] (the DiT ``txt`` / ``encoder_hidden_states[0]``).
|
||||
The module is loaded with ``output_hidden_states=True`` (HunyuanConfig.__post_init__ sets it)."""
|
||||
|
||||
def __init__(self, module, tokenizer, *, device, dtype, max_length: int = LLAMA_TEXT_LEN):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.tokenizer = tokenizer
|
||||
self.max_length = max_length
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
prompt = PROMPT_TEMPLATE_ENCODE_VIDEO.format(text or "")
|
||||
toks = self.tokenizer(prompt,
|
||||
return_tensors="pt",
|
||||
max_length=self.max_length + LLAMA_CROP_START,
|
||||
truncation=True)
|
||||
ids = toks.input_ids.to(self.device)
|
||||
mask = toks.attention_mask.to(self.device)
|
||||
with self._ctx():
|
||||
out = self.module(input_ids=ids, attention_mask=mask, output_hidden_states=True)
|
||||
hidden_states = getattr(out, "hidden_states", None)
|
||||
if hidden_states is None:
|
||||
raise RuntimeError("HunyuanVideoLlamaEncoder: module returned no hidden_states; load LLaMA with "
|
||||
"output_hidden_states=True (HunyuanConfig.__post_init__ sets it).")
|
||||
hidden = hidden_states[-(LLAMA_HIDDEN_STATE_SKIP_LAYER + 1)] # skip the last 2 layers
|
||||
hidden = hidden[:, LLAMA_CROP_START:] # drop the template's 95 system tokens
|
||||
return _to_numpy(hidden.squeeze(0))
|
||||
|
||||
|
||||
class HunyuanVideoCLIPEncoder(TorchComponent):
|
||||
"""CLIP secondary encoder: returns ``pooler_output`` (the 768-dim global vector, the DiT
|
||||
``text_states_2`` / ``encoder_hidden_states[1]``).
|
||||
|
||||
BRINGUP: the shared ``_make_text_encoder`` passes the ``tokenizer/`` subfolder (the LLaMA tokenizer) as
|
||||
the constructor's ``tokenizer`` arg; HunyuanVideo's CLIP tokenizer lives in ``tokenizer_2/``. GPU-verify
|
||||
must wire the CLIP tokenizer here (e.g. via a card-side stamp of ``tokenizer_2`` or loading it lazily) —
|
||||
on CPU this path is exercised by the ``ToyTextEncoder`` factory, which needs no tokenizer."""
|
||||
|
||||
def __init__(self, module, tokenizer, *, device, dtype, max_length: int = CLIP_TEXT_LEN):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.tokenizer = tokenizer
|
||||
self.max_length = max_length
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
toks = self.tokenizer(text or "",
|
||||
return_tensors="pt",
|
||||
max_length=self.max_length,
|
||||
truncation=True,
|
||||
padding="max_length")
|
||||
ids = toks.input_ids.to(self.device)
|
||||
mask = toks.attention_mask.to(self.device)
|
||||
with self._ctx():
|
||||
out = self.module(input_ids=ids, attention_mask=mask)
|
||||
pooled = out.pooler_output if hasattr(out, "pooler_output") else out[1]
|
||||
return _to_numpy(pooled.squeeze(0)) # [768] global vector
|
||||
@@ -0,0 +1,192 @@
|
||||
"""HunyuanVideo 1.5 torch adapters (GPU backend) — declared on the card via ``ComponentSpec.adapter``
|
||||
so the recipe is self-contained (no edit to the shared ``_make_dit``/``_make_vae``/``_make_text_encoder``
|
||||
dispatch in ``torch_backend.py``). Imported lazily by ``_explicit_adapter`` only on a GPU box.
|
||||
|
||||
Architecture deltas vs Wan that force NEW adapters (all faithful to
|
||||
``fastvideo/models/dits/hunyuanvideo15.py`` + ``fastvideo/pipelines/stages/denoising.py``):
|
||||
|
||||
* ``HunyuanVideo15DiT`` — the rectified-flow velocity network ``F_θ``. Unlike ``WanDiT`` (single text
|
||||
embed, single image embed), the real ``forward`` takes ``encoder_hidden_states`` as a **LIST of two**
|
||||
tensors ``[qwen_hidden(3584-d), byt5_hidden(1472-d)]`` and ``encoder_hidden_states_image`` as a
|
||||
**LIST of one** image-embed tensor (all-zeros ``[1,729,1152]`` for t2v → the DiT's
|
||||
``torch.all(image_embeds==0)`` auto-selects the t2v token-ordering branch). The loop hands the two
|
||||
text embeds as a tuple in ``text_embed`` and the image embeds in ``context``; this adapter marshals
|
||||
them into the two lists internally and builds the zero image-embed default, so the loop's dit-call
|
||||
signature stays ``dit(latent, text_embed, sigma, context=, cond=)`` (toy-compatible). For i2v the
|
||||
loop passes the 33-channel first-frame conditioning latent (32 VAE + 1 mask) in ``cond``; the adapter
|
||||
concats it onto the noisy latent on the channel dim to reach ``in_channels=65`` BEFORE the forward
|
||||
(mirrors ``denoising.py`` line 393's ``torch.cat([latent, image_latent], dim=1)``). ``timestep`` is
|
||||
``sigma*1000`` (the scheduler's discrete timestep; ``scale_model_input`` is identity for flow-match).
|
||||
The network predicts velocity directly → returned via ``self._n()`` with NO x0→velocity conversion
|
||||
(unlike ``LTX2DiT``). ``use_meanflow`` SR variants pass ``timestep_r`` — out of scope here (t2v/i2v
|
||||
ship with ``use_meanflow=False``), so ``timestep_r=None``.
|
||||
* ``HunyuanVideo15VAE`` — the HunyuanVideo 1.5 causal-3D VAE (z=32, 16× spatial / 4× temporal). Its DiT
|
||||
latent space is normalized by a single **scalar** ``scaling_factor=1.03682`` (NOT Wan's per-channel
|
||||
``latents_mean``/``latents_std`` — the ``WanVAE`` adapter would corrupt these latents, BRINGUP risk D).
|
||||
Decode: ``module.decode(latent / scaling_factor)``; encode (i2v first-frame cond): ``z * scaling_factor``.
|
||||
* ``HunyuanVideo15QwenEncoder`` — Qwen2.5-VL primary text encoder. Faithful to
|
||||
``configs/pipelines/hunyuan15.py:qwen_preprocess_text/qwen_postprocess_text``: wraps the prompt in the
|
||||
system+chat template, requests ``output_hidden_states=True``, takes ``hidden_states[-3]`` (3rd-from-last
|
||||
layer, NOT ``last_hidden_state``), and crops the first 108 prompt-template tokens. The generic
|
||||
``T5Encoder.encode`` (last_hidden_state, pad-to-max) is WRONG for Qwen (BRINGUP risk B).
|
||||
* ``HunyuanVideo15ByT5Encoder`` — ByT5/Glyph secondary encoder. ``byt5_preprocess_text`` extracts quoted
|
||||
glyph texts via regex; when the prompt has no quoted text it is ``None`` and the real pipeline emits a
|
||||
zero-length ``[1, 0, 1472]`` embedding (the DiT always concatenates the txt_in_2 output) — this adapter
|
||||
reproduces that (BRINGUP risk C). Output is the raw ``last_hidden_state``.
|
||||
|
||||
All forwards run inside ``self._ctx()`` (FastVideo attention reads attn_metadata via the forward
|
||||
context). BRINGUP: the DiT's ``sequence_model_parallel_shard``/all-gather is a no-op at sp=1 (the card
|
||||
defaults parallelism to single() until SP is validated — risk G).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import (
|
||||
NUM_TRAIN_TIMESTEPS,
|
||||
T5Encoder,
|
||||
TorchComponent,
|
||||
_to_numpy,
|
||||
)
|
||||
|
||||
# Faithful constants from configs/pipelines/hunyuan15.py + Hy15ImageEncodingStage.
|
||||
HUNYUAN15_VAE_SCALING_FACTOR = 1.03682 # scalar (NOT per-channel mean/std)
|
||||
HUNYUAN15_IMAGE_TOKENS = 729 # SigLIP token count placeholder
|
||||
HUNYUAN15_IMAGE_DIM = 1152 # config.image_embed_dim
|
||||
HUNYUAN15_BYT5_DIM = 1472 # config.text_embed_2_dim
|
||||
PROMPT_TEMPLATE_TOKEN_LENGTH = 108 # tokens of the chat-template prefix to crop off Qwen
|
||||
QWEN_HIDDEN_STATE_INDEX = -3 # qwen_postprocess takes hidden_states[-3]
|
||||
|
||||
PROMPT_TEMPLATE_ENCODE_VIDEO = (
|
||||
"You are a helpful assistant. Describe the video by detailing the following aspects: "
|
||||
" 1. The main content and theme of the video. "
|
||||
" 2. The color, shape, size, texture, quantity, text, and spatial relationships of the objects. "
|
||||
" 3. Actions, events, behaviors temporal relationships, physical movement changes of the objects. "
|
||||
" 4. background environment, light, style and atmosphere. "
|
||||
" 5. camera angles, movements, and transitions used in the video.")
|
||||
|
||||
# byt5_preprocess_text: pull the quoted glyph strings out of the prompt (HunyuanVideo 1.5 glyph rendering).
|
||||
_GLYPH_PATTERN = r"\"(.*?)\"|“(.*?)”"
|
||||
|
||||
|
||||
def _extract_glyph_texts(prompt: str) -> str | None:
|
||||
"""Faithful to ``configs/pipelines/hunyuan15.py:extract_glyph_texts`` — returns ``None`` when the
|
||||
prompt carries no quoted text (the common t2v case), else a ``Text "..."``-joined glyph string."""
|
||||
matches = re.findall(_GLYPH_PATTERN, prompt or "")
|
||||
result = [m[0] or m[1] for m in matches]
|
||||
result = list(dict.fromkeys(result)) if len(result) > 1 else result
|
||||
return ". ".join([f'Text "{t}"' for t in result]) + ". " if result else None
|
||||
|
||||
|
||||
class HunyuanVideo15DiT(TorchComponent):
|
||||
"""``dit(latent[C,T,H,W], text_embed=(qwen,byt5), sigma, context=img_embeds, cond=image_latent)``.
|
||||
|
||||
Marshals the two text embeds + the (zero-for-t2v) image embeds into the LIST args the real
|
||||
``HunyuanVideo15Transformer3DModel.forward`` expects, concats the 33ch i2v cond onto the latent, and
|
||||
returns the rectified-flow velocity (NO x0 conversion). Faithful to ``denoising.py`` (timestep =
|
||||
sigma*1000, identity ``scale_model_input``, ``latent_model_input = cat([latent, image_latent])``)."""
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(self, latent, text_embed, sigma, context=None, *, cond=None):
|
||||
hs = self._t(latent) # [1, C(32), T, H, W]
|
||||
if cond is not None: # i2v: concat first-frame cond latent (33ch) -> 65ch
|
||||
hs = torch.cat([hs, self._t(cond)], dim=1)
|
||||
# ``text_embed`` is a (qwen_hidden, byt5_hidden) tuple from the loop. Both are already numpy
|
||||
# [seq, dim]; the byt5 element may be a zero-length [0, 1472] (no glyph text).
|
||||
qwen_e, byt5_e = text_embed if isinstance(text_embed, tuple | list) else (text_embed, None)
|
||||
qwen_t = self._t(qwen_e) # [1, qseq, 3584]
|
||||
if byt5_e is None:
|
||||
byt5_t = torch.zeros(1, 0, HUNYUAN15_BYT5_DIM, device=self.device, dtype=self.dtype)
|
||||
else:
|
||||
byt5_t = self._t(byt5_e) # [1, bseq, 1472]
|
||||
# encoder_hidden_states_image: zeros for t2v (the DiT's ``torch.all(==0)`` selects the t2v branch).
|
||||
if context is None:
|
||||
img_t = torch.zeros(1, HUNYUAN15_IMAGE_TOKENS, HUNYUAN15_IMAGE_DIM, device=self.device, dtype=self.dtype)
|
||||
else:
|
||||
img_t = self._t(context)
|
||||
ts = float(sigma) * NUM_TRAIN_TIMESTEPS # the scheduler's discrete timestep (BRINGUP risk B)
|
||||
timestep = torch.tensor([ts], device=self.device, dtype=self.dtype).expand(hs.shape[0])
|
||||
with self._ctx():
|
||||
velocity = self.module(hidden_states=hs,
|
||||
encoder_hidden_states=[qwen_t, byt5_t],
|
||||
timestep=timestep,
|
||||
encoder_hidden_states_image=[img_t],
|
||||
guidance=None,
|
||||
timestep_r=None,
|
||||
attention_kwargs=None)
|
||||
return self._n(velocity) # rectified-flow velocity (BRINGUP risk C)
|
||||
|
||||
|
||||
class HunyuanVideo15VAE(TorchComponent):
|
||||
"""Scalar-scaling VAE. Decode un-scales (``latent / scaling_factor``) before ``module.decode``; encode
|
||||
(i2v first-frame cond) scales the latent (``z * scaling_factor``). NO per-channel mean/std (risk D)."""
|
||||
|
||||
@torch.no_grad()
|
||||
def decode(self, latent):
|
||||
z = self._t(latent).float() / HUNYUAN15_VAE_SCALING_FACTOR
|
||||
video = self.module.decode(z.to(self.dtype)) # -> video [B,3,T,H,W] in [-1,1]
|
||||
return self._n(video)
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, video):
|
||||
x = self._t(video)
|
||||
dist = self.module.encode(x)
|
||||
z = dist.mode() if hasattr(dist, "mode") else (dist.sample() if hasattr(dist, "sample") else dist)
|
||||
return self._n(z.float() * HUNYUAN15_VAE_SCALING_FACTOR)
|
||||
|
||||
|
||||
class HunyuanVideo15QwenEncoder(TorchComponent):
|
||||
"""Qwen2.5-VL primary text encoder. ``encode(text) -> embedding[seq, 3584]`` (numpy).
|
||||
|
||||
Faithful to qwen_preprocess/postprocess: chat-template wrap, ``output_hidden_states=True``, take
|
||||
``hidden_states[-3]``, crop the first 108 template tokens. The HF tokenizer's ``apply_chat_template``
|
||||
builds the prompt; ``max_length`` defaults to the pipeline's 1000+108 (risk B)."""
|
||||
|
||||
def __init__(self, module, tokenizer, *, device, dtype, max_length: int = 1000 + PROMPT_TEMPLATE_TOKEN_LENGTH):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.tokenizer = tokenizer
|
||||
self.max_length = max_length
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
# Qwen2_5_VLProcessor wraps an inner ``.tokenizer``; fall back to the tokenizer itself.
|
||||
tok = getattr(self.tokenizer, "tokenizer", self.tokenizer)
|
||||
messages = [{
|
||||
"role": "system",
|
||||
"content": PROMPT_TEMPLATE_ENCODE_VIDEO
|
||||
}, {
|
||||
"role": "user",
|
||||
"content": text if text else " "
|
||||
}]
|
||||
formatted = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
||||
enc = tok(formatted, return_tensors="pt", max_length=self.max_length, truncation=True)
|
||||
ids = enc.input_ids.to(self.device)
|
||||
mask = enc.attention_mask.to(self.device)
|
||||
with self._ctx():
|
||||
out = self.module(input_ids=ids, attention_mask=mask, output_hidden_states=True)
|
||||
hidden = out.hidden_states[QWEN_HIDDEN_STATE_INDEX] # 3rd-from-last layer (NOT last)
|
||||
hidden = hidden[:, PROMPT_TEMPLATE_TOKEN_LENGTH:].squeeze(0) # crop the chat-template prefix
|
||||
return _to_numpy(hidden)
|
||||
|
||||
|
||||
class HunyuanVideo15ByT5Encoder(T5Encoder):
|
||||
"""ByT5/Glyph secondary encoder. ``encode(text) -> embedding[seq, 1472]``. Extracts the quoted glyph
|
||||
text first (``byt5_preprocess_text``); when none, returns a zero-length ``[0, 1472]`` embedding — the
|
||||
real pipeline's special-case (the DiT always concats txt_in_2 output, risk C). Output is the raw
|
||||
``last_hidden_state`` (NO Wan zero-pad-to-max)."""
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
glyph = _extract_glyph_texts(text)
|
||||
if glyph is None: # no quoted text -> empty embedding
|
||||
return np.zeros((0, HUNYUAN15_BYT5_DIM), dtype="float32")
|
||||
toks = self.tokenizer(glyph, return_tensors="pt", max_length=self.max_length, truncation=True)
|
||||
ids = toks.input_ids.to(self.device)
|
||||
mask = toks.attention_mask.to(self.device)
|
||||
with self._ctx():
|
||||
out = self.module(input_ids=ids, attention_mask=mask)
|
||||
hidden = (out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]).squeeze(0)
|
||||
return _to_numpy(torch.nan_to_num(hidden, nan=0.0))
|
||||
@@ -0,0 +1,173 @@
|
||||
"""HunyuanGameCraft torch adapters (GPU backend) — declared on the card via ``ComponentSpec.adapter``
|
||||
so the GameCraft recipe is self-contained (no edit to the shared ``_make_dit``/``_make_vae``/
|
||||
``_make_text_encoder`` dispatch in ``torch_backend.py``). Imported lazily by ``_explicit_adapter`` only
|
||||
on a GPU box.
|
||||
|
||||
GameCraft is an *interactive* world model (camera/action-conditioned i2v). The deltas vs Wan/Cosmos that
|
||||
force bespoke adapters (the BRINGUP/blocker list in the port spec):
|
||||
|
||||
* ``GameCraftDiT`` — the DiT forward expects a **33-channel** input ``x = cat([latent16 | gt_latent16 |
|
||||
mask1], dim=1)`` that the *denoise loop* (not the DiT) assembles, plus a **list** of text states
|
||||
``encoder_hidden_states = [llama_states(4096d), clip_pooled(768d)]``, an optional ``camera_states``
|
||||
(CameraNet Plücker), ``guidance=None`` (NO embedded guidance — plain ClassicCFG), and the model
|
||||
timestep ``σ·1000``. The DiT returns a 16-channel rectified-flow velocity. The loop hands this adapter
|
||||
the bare 16ch latent + the cond slots; the adapter does the 33ch concat + dual-text-list packing
|
||||
INTERNALLY so the loop's dit-call stays ``dit(latent, text_embed, sigma, ...)`` (toy-compatible).
|
||||
Cannot reuse ``WanDiT`` (it concats a 20ch i2v cond and passes ``encoder_hidden_states_image``).
|
||||
* ``GameCraftVAE`` — SCALAR ``scaling_factor=0.476986`` normalization (``latent = sample()·sf``;
|
||||
``decode(latent/sf)``), NOT Wan's per-channel ``latents_mean``/``latents_std``. Reusing ``WanVAE``
|
||||
(which reads ``module.latents_mean``) would crash / corrupt latents.
|
||||
* ``GameCraftLlamaEncoder`` / ``GameCraftClipEncoder`` — the two text encoders are LLaVA-LLaMA-3 (4096d
|
||||
hidden states, the DiT's ``text_states``) and CLIP ViT-L/14 (768d pooled, the DiT's ``text_states_2``).
|
||||
The toy CPU path uses ``ToyTextEncoder`` for both; these GPU subclasses are written-not-run.
|
||||
|
||||
Everything below is faithful to ``fastvideo/pipelines/stages/gamecraft_denoising.py`` (the DiT call) and
|
||||
``fastvideo/pipelines/stages/gamecraft_image_encoding.py`` (the scalar VAE scaling). The camera / action
|
||||
(CameraNet) conditioning needs a request-API camera-input channel v2 lacks today, so the t2v/degenerate
|
||||
path passes ``camera_states=None`` (the DiT's ``if camera_states is not None`` branch is skipped) — see
|
||||
the BRINGUP notes in ``loop.py``/``program.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import T5Encoder, TorchComponent, _to_numpy
|
||||
|
||||
# Flow-match timestep convention (same as Wan): the loop hands the raw sigma (1->0); the GameCraft DiT
|
||||
# (like diffusers/FastVideo) embeds ``timestep = sigma * num_train_timesteps``. BRINGUP risk B.
|
||||
NUM_TRAIN_TIMESTEPS = 1000
|
||||
GAMECRAFT_SCALING_FACTOR = 0.476986 # GameCraftVAE.config.scaling_factor (scalar, NOT per-channel stats)
|
||||
|
||||
|
||||
class GameCraftDiT(TorchComponent):
|
||||
"""``dit(latent[16,T,h,w], text_embed, sigma, context=clip_pooled, *, cond=cond_pack) -> velocity[16,T,h,w]``.
|
||||
|
||||
The loop calls this with the bare 16-channel noise latent. The adapter assembles the 33-channel DiT
|
||||
input + the dual-text list + the camera states INTERNALLY (so the loop's dit-call signature matches
|
||||
the toy ``ToyDiT.__call__``). Faithful to ``GameCraftDenoisingStage.forward``:
|
||||
|
||||
latent_model_input = cat([latents, gt_latents, conditioning_mask], dim=1) # [B,33,T,h,w]
|
||||
noise_pred = transformer(latent_model_input, [llama, clip], t·1000,
|
||||
camera_states=cam, guidance=None) # [B,16,T,h,w]
|
||||
|
||||
Conventions threaded through the call boundary (all numpy at the seam):
|
||||
* ``text_embed`` -> ``text_states`` (LLaMA hidden states), ``encoder_hidden_states[0]``.
|
||||
* ``context`` -> ``text_states_2`` (CLIP pooled), ``encoder_hidden_states[1]``. The loop reuses
|
||||
the WanDenoiseLoop ``context=`` kwarg slot to carry the per-branch CLIP pooled embed (cond vs
|
||||
uncond), so CFG combines correctly. ``None`` -> single-text fallback (still valid for the toy).
|
||||
* ``cond`` -> a dict ``{"gt_latents", "mask", "camera_states"}`` the loop builds once and
|
||||
passes every step. ``None`` (pure t2v) -> zero gt_latents + zero mask + no camera (the
|
||||
``GameCraftDenoisingStage`` fallback), i.e. the degenerate standard-denoise path."""
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(self, latent, text_embed, sigma, context=None, *, cond=None):
|
||||
hs = self._t(latent) # [1, 16, T, h, w]
|
||||
b, _c, t, h, w = hs.shape
|
||||
# --- 33-channel concat [latent16 | gt_latent16 | mask1] (assembled HERE, not in the DiT) ------ #
|
||||
cond = cond or {}
|
||||
gt = self._t(cond.get("gt_latents")) if cond.get("gt_latents") is not None else torch.zeros_like(hs)
|
||||
mask = (self._t(cond.get("mask"))
|
||||
if cond.get("mask") is not None else torch.zeros(b, 1, t, h, w, device=self.device, dtype=self.dtype))
|
||||
model_input = torch.cat([hs, gt, mask], dim=1) # [1, 33, T, h, w]
|
||||
# --- dual text states list: [LLaMA(4096d), CLIP-pooled(768d)] -------------------------------- #
|
||||
ehs = [self._t(text_embed)] # text_states -> [1, seq, 4096]
|
||||
if context is not None: # CLIP pooled (per CFG branch) -> text_states_2
|
||||
ehs.append(self._t(context))
|
||||
# --- camera_states (CameraNet Plücker): None for the t2v/degenerate path (BRINGUP) ----------- #
|
||||
camera = self._t(cond.get("camera_states")) if cond.get("camera_states") is not None else None
|
||||
# timestep = sigma*1000, shape [B] (the stage does ``t.repeat(B)``). BRINGUP risk B.
|
||||
ts = torch.tensor([float(sigma) * NUM_TRAIN_TIMESTEPS], device=self.device, dtype=self.dtype).expand(b)
|
||||
with self._ctx():
|
||||
velocity = self.module(
|
||||
model_input,
|
||||
ehs,
|
||||
ts,
|
||||
camera_states=camera,
|
||||
guidance=None, # NO embedded guidance (guidance_embeds=False); plain CFG
|
||||
return_dict=False)
|
||||
return self._n(velocity) # rectified-flow velocity (loop integrates via FLOW_MATCH_STEP)
|
||||
|
||||
|
||||
class GameCraftVAE(TorchComponent):
|
||||
"""Scalar-scaling VAE adapter (GameCraft uses a single ``scaling_factor``, NOT per-channel stats).
|
||||
|
||||
Faithful to ``GameCraftImageVAEEncodingStage`` (encode: ``sample()·sf``) and ``DecodingStage`` (decode:
|
||||
``decode(latent/sf)``). The reference-image preprocessing (resize/center-crop/normalize) lives in the
|
||||
program's image-cond node, not here — this adapter only marshals + applies the scalar factor."""
|
||||
|
||||
def __init__(self, module, *, device, dtype, scaling_factor: float = GAMECRAFT_SCALING_FACTOR):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
# Prefer the loaded module's own config when present; fall back to the published GameCraft value.
|
||||
self.scaling_factor = float(getattr(getattr(module, "config", None), "scaling_factor", scaling_factor))
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, video):
|
||||
x = self._t(video)
|
||||
out = self.module.encode(x)
|
||||
dist = getattr(out, "latent_dist", out)
|
||||
z = dist.sample() if hasattr(dist, "sample") else (dist.mode() if hasattr(dist, "mode") else dist)
|
||||
return self._n(z.float() * self.scaling_factor) # scaled latent the DiT expects
|
||||
|
||||
@torch.no_grad()
|
||||
def decode(self, latent):
|
||||
z = self._t(latent).float() / self.scaling_factor # invert the scalar scaling -> raw latent
|
||||
video = self.module.decode(z.to(self.dtype))
|
||||
if hasattr(video, "sample"):
|
||||
video = video.sample
|
||||
return self._n(video) # video [B,3,T,H,W] in [-1,1]
|
||||
|
||||
|
||||
class GameCraftClipEncoder(TorchComponent):
|
||||
"""CLIP ViT-L/14 text encoder -> POOLED embedding (768d), the DiT's ``text_states_2``.
|
||||
|
||||
BRINGUP: written-not-run. The program writes this into the ``clip_text_embeds`` slot; the loop threads
|
||||
it through the ``context=`` slot per CFG branch. Returns the pooled output (``[768]`` or ``[1,768]``),
|
||||
matching ``batch.clip_embedding_pos``/``clip_embedding_neg`` in the fastvideo stage."""
|
||||
|
||||
def __init__(self, module, tokenizer, *, device, dtype, max_length: int = 77):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.tokenizer = tokenizer
|
||||
self.max_length = max_length
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
toks = self.tokenizer(text or "",
|
||||
return_tensors="pt",
|
||||
max_length=self.max_length,
|
||||
truncation=True,
|
||||
padding="max_length")
|
||||
ids = toks.input_ids.to(self.device)
|
||||
mask = toks.attention_mask.to(self.device)
|
||||
with self._ctx():
|
||||
out = self.module(input_ids=ids, attention_mask=mask)
|
||||
# CLIPTextModel exposes ``pooler_output`` (the [EOS]-token projection) — the GameCraft text_states_2.
|
||||
pooled = getattr(out, "pooler_output", None)
|
||||
if pooled is None:
|
||||
pooled = (out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0])[:, -1]
|
||||
return _to_numpy(pooled.squeeze(0))
|
||||
|
||||
|
||||
class GameCraftLlamaEncoder(T5Encoder):
|
||||
"""LLaVA-LLaMA-3-8B text encoder -> hidden states (4096d), the DiT's ``text_states``.
|
||||
|
||||
BRINGUP: written-not-run. GameCraft consumes an intermediate hidden-state layer via the Hunyuan
|
||||
``llama_postprocess`` (``output_hidden_states=True``) plus the LLaVA prompt template — those
|
||||
preprocess/postprocess functions are not yet ported to v2 (blocker 4). This subclass currently reuses
|
||||
the (U)MT5 ``encode`` contract (real-token rows, no fixed zero-pad surprises) as a structural
|
||||
placeholder; the real LLaVA template + hidden-layer pick must be wired during GPU bring-up."""
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
toks = self.tokenizer(text or "", return_tensors="pt", max_length=self.max_length, truncation=True)
|
||||
ids = toks.input_ids.to(self.device)
|
||||
mask = toks.attention_mask.to(self.device)
|
||||
with self._ctx():
|
||||
# BRINGUP: real GameCraft passes output_hidden_states=True and picks an intermediate layer via
|
||||
# llama_postprocess; here we take last_hidden_state as the structural stand-in.
|
||||
out = self.module(input_ids=ids, attention_mask=mask)
|
||||
hidden = (out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]).squeeze(0)
|
||||
return _to_numpy(torch.nan_to_num(hidden, nan=0.0))
|
||||
|
||||
|
||||
# Re-export the scalar scaling constant for the loop/program to keep the value single-sourced.
|
||||
__all__ = ["GameCraftDiT", "GameCraftVAE", "GameCraftClipEncoder", "GameCraftLlamaEncoder", "GAMECRAFT_SCALING_FACTOR"]
|
||||
@@ -0,0 +1,216 @@
|
||||
"""HY-WorldPlay torch adapters (GPU backend) — declared on the card via ``ComponentSpec.adapter`` so the
|
||||
recipe is self-contained (no edit to the shared ``_make_dit`` / ``_make_vae`` / ``_make_text_encoder`` /
|
||||
``_make_image_encoder`` dispatch in ``torch_backend.py``). Imported lazily by ``_explicit_adapter`` only
|
||||
on a GPU box.
|
||||
|
||||
Faithful to ``fastvideo/pipelines/stages/hyworld_denoising.py:HYWorldDenoisingStage`` and
|
||||
``fastvideo/models/dits/hyworld/hyworld.py:HYWorldTransformer3DModel.forward``:
|
||||
|
||||
* ``HYWorldDiT`` — the rectified-flow velocity predictor. Its ``__call__`` keeps the
|
||||
cosmos2/ToyDiT-compatible ``dit(latent, text_embed, sigma, context=...)`` signature and marshals ALL
|
||||
the HY-WorldPlay specifics INTERNALLY: the 65ch ``[noise(32) | cond(32) + mask(1)]`` channel-concat,
|
||||
the LIST ``encoder_hidden_states=[qwen, byt5]`` + LIST ``encoder_hidden_states_image=[siglip]``, the
|
||||
per-latent-frame heterogeneous ``timestep`` (context frames pinned at ``stabilization_level - 1``) +
|
||||
the scalar ``timestep_txt``, the per-frame ``action`` (flattened B*T) + ``viewmats[B,T,4,4]`` /
|
||||
``Ks[B,T,3,3]``, and the ``encoder_attention_mask=[mask, mask2]`` swapped per pos/neg branch. Returns
|
||||
the bare velocity tensor [B,32,T,H,W]. BRINGUP: written-not-run; needs the GPU dense+ProPE flash-attn
|
||||
backend + a pose-carrying request.
|
||||
* ``HYWorldVAE`` — encode -> ``.mode() * scaling_factor`` (1.03682, no shift/mean); decode inverts.
|
||||
* ``HYWorldQwenEncoder`` / ``HYWorldByT5Encoder`` / ``HYWorldSiglipEncoder`` — the 3 conditioning
|
||||
streams. The Qwen adapter runs the video chat template, takes ``hidden_states[-3]``, and crops the
|
||||
first 108 template tokens (BRINGUP). For plain t2v the byt5/siglip streams are zeroed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import T5Encoder, TorchComponent, _to_numpy
|
||||
|
||||
# Flow-match timestep convention: timestep = sigma * num_train_timesteps (matches WanDiT / the
|
||||
# HYWorldDenoisingStage which feeds the scheduler's discrete timesteps in 0..1000).
|
||||
NUM_TRAIN_TIMESTEPS = 1000
|
||||
# SigLIP image stream geometry (HunyuanVideo1.5 / HY-WorldPlay): 729 tokens x 1152 dim.
|
||||
SIGLIP_TOKENS = 729
|
||||
SIGLIP_DIM = 1152
|
||||
# HY-WorldPlay latent geometry (AutoencoderKLHYWorld): z=32 + 1 mask channel for the cond latent.
|
||||
HYWORLD_LATENT_CHANNELS = 32
|
||||
HYWORLD_VAE_SCALING_FACTOR = 1.03682
|
||||
|
||||
|
||||
class HYWorldDiT(TorchComponent):
|
||||
"""``dit(latent[C,T,h,w], text_embed[seq,dim], sigma, context=chunk_ctx) -> velocity[32,T,h,w]``.
|
||||
|
||||
``latent`` is the current chunk's NOISE latent (32ch). ``text_embed`` is the Qwen (mllm) embed for
|
||||
the active branch; the byt5 + siglip streams and the camera/action tensors are resolved from the
|
||||
sibling components + ``context`` here. ``sigma`` is the live step's sigma (-> ``timestep = sigma*1000``
|
||||
broadcast per latent frame; context/history frames in ``context`` are pinned at the stabilization
|
||||
level). The 65ch ``[noise | cond + mask]`` concat is done HERE (faithful to the stage), so the loop
|
||||
never assembles arch-specifics. BRINGUP: the chunk>0 camera-aligned memory path + ProPE attention +
|
||||
action need a pose-carrying request; absent, zero camera/action are built (degenerate t2v)."""
|
||||
|
||||
def __init__(self, module, *, device, dtype):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
|
||||
def _zero_image_embeds(self) -> torch.Tensor:
|
||||
# T2V: zero SigLIP embeds -> the DiT's ``torch.all(image_embeds == 0)`` masks the image stream.
|
||||
return torch.zeros(1, SIGLIP_TOKENS, SIGLIP_DIM, device=self.device, dtype=self.dtype)
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(self, latent, text_embed, sigma, context: Any = None, *, cond=None):
|
||||
ctx = context if isinstance(context, dict) else {}
|
||||
hs = self._t(latent) # [1, 32, T, h, w]
|
||||
_b, _c, t, _h, _w = hs.shape
|
||||
# 65ch in-channel concat: [noise(32) | cond_latent(32) + mask(1)]. The cond latent is the
|
||||
# VAE-encoded first frame (i2v) or zeros (t2v) + a 1ch mask -> 33ch. BRINGUP: i2v_cond plumbing.
|
||||
i2v_cond = ctx.get("i2v_cond")
|
||||
if i2v_cond is not None:
|
||||
cond_latents = self._t(i2v_cond)[:, :, :t] # [1, 33, T, h, w]
|
||||
else:
|
||||
cond_latents = torch.zeros(1, HYWORLD_LATENT_CHANNELS + 1, t, _h, _w, device=self.device, dtype=self.dtype)
|
||||
hs = torch.cat([hs, cond_latents], dim=1) # -> 65ch (faithful to the stage's torch.concat)
|
||||
|
||||
# 3-stream conditioning: encoder_hidden_states must be a LIST [qwen, byt5]; image a LIST [siglip].
|
||||
qwen = self._t(text_embed) # [1, seq, dim]
|
||||
byt5 = self._t(ctx.get("byt5_embeds")) if ctx.get("byt5_embeds") is not None else \
|
||||
torch.zeros(1, 1, qwen.shape[-1], device=self.device, dtype=self.dtype)
|
||||
img = self._t(ctx.get("i2v_img_embeds")) if ctx.get("i2v_img_embeds") is not None else \
|
||||
self._zero_image_embeds()
|
||||
# Per-pos/neg attention masks (BRINGUP: the stage swaps prompt_attention_mask <-> negative). The
|
||||
# loop runs each branch separately, so the matching mask is selected per call by the caller's text.
|
||||
mask = torch.ones(1, qwen.shape[1], device=self.device, dtype=self.dtype)
|
||||
mask2 = torch.ones(1, byt5.shape[1], device=self.device, dtype=self.dtype)
|
||||
|
||||
# Per-latent-frame timestep (NOT scalar): live ``t`` for the chunk frames; context/history frames
|
||||
# (chunk>0, BRINGUP) pinned at ``stabilization_level - 1``. timestep_txt is a separate scalar.
|
||||
ts_val = float(sigma) * NUM_TRAIN_TIMESTEPS
|
||||
timestep = torch.full((t, ), ts_val, device=self.device, dtype=torch.long)
|
||||
timestep_txt = torch.tensor([ts_val], device=self.device, dtype=torch.long)
|
||||
|
||||
# Camera + action conditioning (BRINGUP): per-frame viewmats/Ks/action. Absent -> identity camera
|
||||
# + zero action (the action_in / prope projections are zero-init, so this is a faithful no-op).
|
||||
cam = ctx.get("camera")
|
||||
if cam is not None:
|
||||
start, end = ctx.get("chunk", (0, t))
|
||||
viewmats = self._t(cam["viewmats"])[:, start:end]
|
||||
Ks = self._t(cam["Ks"])[:, start:end]
|
||||
action = self._t(cam["action"])[:, start:end].reshape(-1)
|
||||
else:
|
||||
viewmats = torch.eye(4, device=self.device, dtype=self.dtype).expand(1, t, 4, 4).contiguous()
|
||||
Ks = torch.eye(3, device=self.device, dtype=self.dtype).expand(1, t, 3, 3).contiguous()
|
||||
action = torch.zeros(t, device=self.device, dtype=self.dtype)
|
||||
|
||||
with self._ctx():
|
||||
velocity = self.module(
|
||||
hs,
|
||||
[qwen, byt5],
|
||||
timestep=timestep,
|
||||
encoder_hidden_states_image=[img],
|
||||
encoder_attention_mask=[mask, mask2],
|
||||
action=action.to(self.dtype),
|
||||
viewmats=viewmats.to(self.dtype),
|
||||
Ks=Ks.to(self.dtype),
|
||||
timestep_txt=timestep_txt,
|
||||
)
|
||||
return self._n(velocity) # bare rectified-flow velocity [32, T, h, w]
|
||||
|
||||
|
||||
class HYWorldVAE(TorchComponent):
|
||||
"""AutoencoderKLHYWorld: encode -> ``.mode() * scaling_factor`` (1.03682, no shift/mean);
|
||||
decode -> ``/ scaling_factor`` then decode. Used for the first-frame cond latent + the final decode."""
|
||||
|
||||
def __init__(self, module, *, device, dtype, scaling_factor: float = HYWORLD_VAE_SCALING_FACTOR):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.scaling_factor = float(getattr(getattr(module, "config", None), "scaling_factor", scaling_factor))
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, video):
|
||||
x = self._t(video)
|
||||
dist = self.module.encode(x)
|
||||
z = dist.mode() if hasattr(dist, "mode") else (dist.sample() if hasattr(dist, "sample") else dist)
|
||||
return self._n(z.float() * self.scaling_factor)
|
||||
|
||||
@torch.no_grad()
|
||||
def decode(self, latent):
|
||||
z = self._t(latent).float() / self.scaling_factor
|
||||
video = self.module.decode(z.to(self.dtype)) # -> video [B,3,T,H,W] in [-1,1]
|
||||
return self._n(video)
|
||||
|
||||
|
||||
class HYWorldQwenEncoder(TorchComponent):
|
||||
"""Qwen2.5-VL mllm (primary text). BRINGUP: faithful path runs the video chat template
|
||||
(PROMPT_TEMPLATE_ENCODE_VIDEO system msg), takes ``hidden_states[-3]``, and crops the first 108
|
||||
template tokens. Returns the cropped real-token embedding (numpy). Encoder weights are fp32 here; on
|
||||
the box mirror the fastvideo TextEncodingStage marshalling exactly."""
|
||||
|
||||
def __init__(self, module, tokenizer, *, device, dtype, max_length: int = 256, crop_start: int = 108):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.tokenizer = tokenizer
|
||||
self.max_length = max_length
|
||||
self.crop_start = crop_start # PROMPT_TEMPLATE_ENCODE_VIDEO prepends 108 template tokens
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
toks = self.tokenizer(text or "", return_tensors="pt", max_length=self.max_length, truncation=True)
|
||||
ids = toks.input_ids.to(self.device)
|
||||
mask = toks.attention_mask.to(self.device)
|
||||
with self._ctx():
|
||||
out = self.module(input_ids=ids, attention_mask=mask, output_hidden_states=True)
|
||||
hs = getattr(out, "hidden_states", None)
|
||||
# hidden_states[-3] is the layer the HY-WorldPlay mllm conditioning uses (BRINGUP: confirm on box).
|
||||
hidden = hs[-3] if hs is not None and len(hs) >= 3 else \
|
||||
(out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0])
|
||||
hidden = hidden.squeeze(0)
|
||||
if self.crop_start and hidden.shape[0] > self.crop_start: # crop the prompt-template tokens
|
||||
hidden = hidden[self.crop_start:]
|
||||
return _to_numpy(torch.nan_to_num(hidden, nan=0.0))
|
||||
|
||||
|
||||
class HYWorldByT5Encoder(T5Encoder):
|
||||
"""ByT5 glyph encoder (rendered text; text_states_dim_2=1472). BRINGUP: the faithful path extracts
|
||||
quoted glyph text (``extract_glyph_texts`` regex) before encoding and returns ``last_hidden_state``;
|
||||
for plain t2v there is no glyph text -> a single zero token. Subclasses ``T5Encoder`` (raw, NO Wan
|
||||
fixed-length zero-pad)."""
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
glyph = self._extract_glyph(text)
|
||||
if not glyph: # no quoted glyph text -> a 1-token zero embed (the DiT cond_type_embed still adds)
|
||||
dim = int(getattr(getattr(self.module, "config", None), "d_model", 1472))
|
||||
return _to_numpy(torch.zeros(1, dim, device=self.device, dtype=torch.float32))
|
||||
toks = self.tokenizer(glyph, return_tensors="pt", max_length=self.max_length, truncation=True)
|
||||
ids = toks.input_ids.to(self.device)
|
||||
mask = toks.attention_mask.to(self.device)
|
||||
with self._ctx():
|
||||
out = self.module(input_ids=ids, attention_mask=mask)
|
||||
hidden = (out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]).squeeze(0)
|
||||
return _to_numpy(torch.nan_to_num(hidden, nan=0.0))
|
||||
|
||||
@staticmethod
|
||||
def _extract_glyph(text) -> str:
|
||||
import re
|
||||
if not text:
|
||||
return ""
|
||||
return " ".join(re.findall(r'["“”‘’\'](.+?)["“”‘’\']', text))
|
||||
|
||||
|
||||
class HYWorldSiglipEncoder(TorchComponent):
|
||||
"""SigLIP image encoder (729 tokens x 1152 dim) for the i2v/first-frame stream. BRINGUP: the faithful
|
||||
path resizes+center-crops to the target res, runs ``processor.preprocess`` (fp16), and returns
|
||||
``last_hidden_state``. For t2v there is no image -> the program zeroes this stream (the DiT then masks
|
||||
it via ``torch.all(image_embeds == 0)``)."""
|
||||
|
||||
def __init__(self, module, processor=None, *, device, dtype):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.processor = processor
|
||||
|
||||
@torch.no_grad()
|
||||
def encode_image(self, image):
|
||||
if image is None or self.processor is None: # t2v: zero embeds -> DiT masks the image stream
|
||||
return _to_numpy(torch.zeros(SIGLIP_TOKENS, SIGLIP_DIM, device=self.device, dtype=torch.float32))
|
||||
inputs = self.processor.preprocess(images=image, return_tensors="pt").to(device=self.device, dtype=self.dtype)
|
||||
with self._ctx():
|
||||
out = self.module(pixel_values=inputs["pixel_values"])
|
||||
embeds = out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]
|
||||
return _to_numpy(embeds.squeeze(0))
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Kandinsky-5.0 torch adapters (GPU backend) — declared on the card via ``ComponentSpec.adapter`` so
|
||||
the recipe is self-contained (no edit to the shared ``_make_dit``/``_make_vae``/``_make_text_encoder``
|
||||
dispatch in ``torch_backend.py``). Imported lazily by ``_explicit_adapter`` only on a GPU box.
|
||||
|
||||
Faithful to the diffusers ``KandinskyV5...Pipeline`` (vendored under
|
||||
``diffusers/pipelines/kandinsky5/pipeline_kandinsky.py``) and ``fastvideo.models.dits.kandinsky5``:
|
||||
|
||||
* ``Kandinsky5DiT`` — velocity predictor. The loop hands a CHANNELS-FIRST ``latent[C,T,H,W]`` (Wan /
|
||||
ToyDiT convention), the Qwen token embeds as ``text_embed``, the CLIP pooled vector via ``context``,
|
||||
and ``sigma``; this adapter PERMUTES to the channels-LAST ``[B,T,H,W,C]`` the forward requires (and
|
||||
permutes the velocity back). It also builds the THREE per-request positional inputs INTERNALLY from
|
||||
the latent geometry + recovered resolution: ``visual_rope_pos`` over the patched grid
|
||||
(patch_size=(1,2,2) -> ``[arange(T), arange(H//2), arange(W//2)]``), ``text_rope_pos`` over the Qwen
|
||||
seq len, and the resolution-dependent ``scale_factor`` (= (1,2,2) for 480–854 else (1,3.16,3.16)).
|
||||
``timestep = σ·1000``. ``pooled_projections`` is MANDATORY (the forward raises if None);
|
||||
``sparse_params=None`` (the Lite checkpoint uses regular attention — the sparse/STA path raises
|
||||
NotImplementedError in fastvideo, BRINGUP risk K).
|
||||
* ``Kandinsky5QwenEncoder`` — Qwen2.5-VL. Applies the repo prompt template + start-idx slicing
|
||||
(``prompt_template_encode_start_idx=129``, ``max_sequence_length=256``): the embeds are the LAST
|
||||
hidden state (``output_hidden_states=True``) sliced from index 129 onward (BRINGUP risk G).
|
||||
* ``Kandinsky5ClipEncoder`` — CLIP-with-projection. Returns the ``pooler_output`` (already projected to
|
||||
768) for a 77-token padded prompt. Its tokenizer lives in ``tokenizer_2`` (NOT the ``tokenizer`` the
|
||||
built-in maker probes), so the adapter re-resolves it from the model root (BRINGUP).
|
||||
* ``Kandinsky5VAE`` — AutoencoderKLHunyuanVideo. SCALAR ``scaling_factor`` (latent = raw·sf; decode raw
|
||||
= latent/sf), NOT Wan per-channel (z-mean)/std. The loop's latent is already channels-first
|
||||
``[C,T,H,W]`` (the DiT adapter owns the channels-last conversion), so decode only adds a batch dim +
|
||||
un-normalizes by the scalar before ``module.decode`` (BRINGUP risk F).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import TorchComponent, _to_numpy
|
||||
|
||||
# Repo defaults (diffusers pipeline + fastvideo Kandinsky5ArchConfig). The two misspellings inside the
|
||||
# template below are VERBATIM from the upstream Kandinsky-5 prompt template (diffusers
|
||||
# pipeline_kandinsky.py) — they must match the tokens the model was trained on, so the codespell:ignore
|
||||
# directives on those lines are deliberate; do not "fix" them.
|
||||
_QWEN_PROMPT_TEMPLATE = "\n".join([
|
||||
"<|im_start|>system\nYou are a promt engineer. Describe the video in detail.", # codespell:ignore promt
|
||||
"Describe how the camera moves or shakes, describe the zoom and view angle, whether it follows the objects.",
|
||||
"Describe the location of the video, main characters or objects and their action.",
|
||||
"Describe the dynamism of the video and presented actions.",
|
||||
"Name the visual style of the video: whether it is a professional footage, user generated content, "
|
||||
"some kind of animation, video game or scren content.", # codespell:ignore scren
|
||||
"Describe the visual effects, postprocessing and transitions if they are presented in the video.",
|
||||
"Pay attention to the order of key actions shown in the scene.<|im_end|>",
|
||||
"<|im_start|>user\n{}<|im_end|>",
|
||||
])
|
||||
_QWEN_ENCODE_START_IDX = 129
|
||||
_QWEN_MAX_SEQ_LEN = 256
|
||||
_CLIP_MAX_LEN = 77
|
||||
_PATCH_SIZE = (1, 2, 2) # transformer patch_size (T, H, W)
|
||||
_SPATIAL_RATIO = 8 # VAE spatial compression (latent_h = H // 8)
|
||||
_TIMESTEP_SCALE = 1000.0 # σ·1000 timestep convention
|
||||
|
||||
|
||||
def _get_scale_factor(height: int, width: int) -> tuple[float, float, float]:
|
||||
"""Resolution-dependent RoPE scaling (pipeline ``_get_scale_factor``): (1,2,2) when BOTH height and
|
||||
width are in [480, 854], else (1, 3.16, 3.16). BRINGUP risk E (must not be left at (1,1,1))."""
|
||||
|
||||
def between_480p(x: int) -> bool:
|
||||
return 480 <= x <= 854
|
||||
|
||||
if between_480p(height) and between_480p(width):
|
||||
return (1.0, 2.0, 2.0)
|
||||
return (1.0, 3.16, 3.16)
|
||||
|
||||
|
||||
class Kandinsky5DiT(TorchComponent):
|
||||
"""``dit(latent[C,T,H,W], qwen_embed[seq,3584], sigma, context=clip_pooled[768]) -> velocity[C,T,H,W]``.
|
||||
|
||||
Real forward (kandinsky5.py): ``forward(hidden_states[B,T,H,W,C], encoder_hidden_states, timestep,
|
||||
pooled_projections, visual_rope_pos, text_rope_pos, scale_factor, sparse_params, return_dict)``. The
|
||||
loop's channels-FIRST latent is permuted to channels-LAST here and the velocity is permuted back."""
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(self, latent, text_embed, sigma, context=None, *, cond=None):
|
||||
hs = self._t(latent) # [1, C, T, H, W] (channels-first from the loop)
|
||||
hs = hs.permute(0, 2, 3, 4, 1).contiguous() # -> [1, T, H, W, C] (the forward's geometry)
|
||||
ehs = self._t(text_embed) # [1, seq, 3584]
|
||||
if context is None:
|
||||
raise ValueError("Kandinsky5DiT requires the CLIP pooled vector (passed as `context`); "
|
||||
"pooled_projections is mandatory for the Kandinsky5 forward.")
|
||||
pooled = self._t(context) # [1, 768]
|
||||
_b, t, h, w, _c = hs.shape
|
||||
ts = torch.tensor([float(sigma) * _TIMESTEP_SCALE], device=self.device, dtype=self.dtype)
|
||||
# visual_rope_pos over the PATCHED grid: patch_size (1,2,2) -> (T, H//2, W//2). Matches the
|
||||
# pipeline's [arange(num_latent_frames), arange(latent_h//2), arange(latent_w//2)].
|
||||
gh = max(1, h // _PATCH_SIZE[1])
|
||||
gw = max(1, w // _PATCH_SIZE[2])
|
||||
gt = max(1, t // _PATCH_SIZE[0])
|
||||
visual_rope_pos = [
|
||||
torch.arange(gt, device=self.device),
|
||||
torch.arange(gh, device=self.device),
|
||||
torch.arange(gw, device=self.device)
|
||||
]
|
||||
text_rope_pos = torch.arange(ehs.shape[1], device=self.device)
|
||||
# Recover pixel resolution from the latent geometry for the resolution-dependent scale_factor.
|
||||
scale_factor = _get_scale_factor(h * _SPATIAL_RATIO, w * _SPATIAL_RATIO)
|
||||
with self._ctx():
|
||||
velocity = self.module(
|
||||
hidden_states=hs,
|
||||
encoder_hidden_states=ehs,
|
||||
timestep=ts,
|
||||
pooled_projections=pooled,
|
||||
visual_rope_pos=visual_rope_pos,
|
||||
text_rope_pos=text_rope_pos,
|
||||
scale_factor=scale_factor,
|
||||
sparse_params=None, # Lite checkpoint = regular attention (BRINGUP K)
|
||||
return_dict=False)
|
||||
if isinstance(velocity, tuple):
|
||||
velocity = velocity[0]
|
||||
velocity = velocity.permute(0, 4, 1, 2, 3).contiguous() # [B,T,H,W,C] -> channels-first [B,C,T,H,W]
|
||||
return self._n(velocity) # channels-FIRST rectified-flow velocity
|
||||
|
||||
|
||||
class Kandinsky5QwenEncoder(TorchComponent):
|
||||
"""Qwen2.5-VL text encoder. ``encode(text) -> token embeds[seq, 3584]`` after the prompt template +
|
||||
start-idx (129) slice on the LAST hidden state. The built-in text-encoder maker passes the
|
||||
``tokenizer`` subfolder tokenizer (the Qwen one) — correct here."""
|
||||
|
||||
def __init__(self, module, tokenizer, *, device, dtype, max_length: int = _QWEN_MAX_SEQ_LEN):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.tokenizer = tokenizer
|
||||
self.max_length = max_length
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
full = _QWEN_PROMPT_TEMPLATE.format(text or "")
|
||||
max_allowed = _QWEN_ENCODE_START_IDX + self.max_length
|
||||
toks = self.tokenizer(text=[full],
|
||||
images=None,
|
||||
videos=None,
|
||||
max_length=max_allowed,
|
||||
truncation=True,
|
||||
return_tensors="pt",
|
||||
padding=True)
|
||||
ids = toks["input_ids"].to(self.device)
|
||||
with self._ctx():
|
||||
out = self.module(input_ids=ids, return_dict=True, output_hidden_states=True)
|
||||
# The pipeline uses hidden_states[-1] (the final hidden state) sliced from the start idx onward.
|
||||
hidden = out["hidden_states"][-1] if isinstance(out, dict) else out.hidden_states[-1]
|
||||
hidden = hidden[:, _QWEN_ENCODE_START_IDX:].squeeze(0) # [seq, 3584]
|
||||
return _to_numpy(hidden)
|
||||
|
||||
|
||||
class Kandinsky5ClipEncoder(TorchComponent):
|
||||
"""CLIP-with-projection text encoder. ``encode(text) -> pooled[768]``. The built-in maker hands this
|
||||
adapter the wrong (``tokenizer/`` = Qwen) tokenizer, so we re-resolve the CLIP tokenizer from the
|
||||
``tokenizer_2`` sibling of the model root (BRINGUP: confirm the subfolder name on a real checkpoint)."""
|
||||
|
||||
def __init__(self, module, tokenizer, *, device, dtype, max_length: int = _CLIP_MAX_LEN):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.max_length = max_length
|
||||
self.tokenizer = self._resolve_clip_tokenizer(module, tokenizer)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_clip_tokenizer(module: Any, fallback: Any) -> Any:
|
||||
# The model root holds text_encoder_2/ (this module) and tokenizer_2/ (its tokenizer). Recover it
|
||||
# from the module config's _name_or_path when available; else fall back to the passed tokenizer.
|
||||
from transformers import AutoTokenizer
|
||||
name = getattr(getattr(module, "config", None), "_name_or_path", "") or ""
|
||||
if name:
|
||||
root = os.path.dirname(os.path.normpath(name)) # .../text_encoder_2 -> root
|
||||
tok2 = os.path.join(root, "tokenizer_2")
|
||||
if os.path.isdir(tok2):
|
||||
return AutoTokenizer.from_pretrained(tok2)
|
||||
return fallback
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
toks = self.tokenizer(text or "",
|
||||
max_length=self.max_length,
|
||||
truncation=True,
|
||||
add_special_tokens=True,
|
||||
padding="max_length",
|
||||
return_tensors="pt")
|
||||
ids = toks["input_ids"].to(self.device)
|
||||
mask = toks.get("attention_mask")
|
||||
kw = {"attention_mask": mask.to(self.device)} if mask is not None else {}
|
||||
with self._ctx():
|
||||
out = self.module(input_ids=ids, **kw)
|
||||
pooled = out["pooler_output"] if isinstance(out, dict) else out.pooler_output
|
||||
return _to_numpy(pooled.squeeze(0)) # [768]
|
||||
|
||||
|
||||
class Kandinsky5VAE(TorchComponent):
|
||||
"""AutoencoderKLHunyuanVideo with a SCALAR scaling_factor. The DiT works in scaled latent space
|
||||
(latent = raw·sf); decode inverts it (raw = latent/sf) — NOT the Wan per-channel (z-mean)/std. The
|
||||
loop latent is already channels-first ``[C,T,H,W]`` (the DiT adapter owns the channels-last view)."""
|
||||
|
||||
def _scaling_factor(self) -> float:
|
||||
cfg = getattr(self.module, "config", None)
|
||||
sf = getattr(cfg, "scaling_factor", None) if cfg is not None else None
|
||||
return float(sf) if sf is not None else 0.476986
|
||||
|
||||
@torch.no_grad()
|
||||
def decode(self, latent):
|
||||
z = self._t(latent).float() # [1, C, T, H, W]
|
||||
z = z / self._scaling_factor() # un-normalize (scalar)
|
||||
video = self.module.decode(z.to(self.dtype))
|
||||
if hasattr(video, "sample"):
|
||||
video = video.sample
|
||||
return self._n(video) # [3, T, H*8, W*8] in [-1, 1]
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, video):
|
||||
x = self._t(video)
|
||||
dist = self.module.encode(x)
|
||||
z = dist.mode() if hasattr(dist, "mode") else (dist.sample() if hasattr(dist, "sample") else dist)
|
||||
return self._n(z.float() * self._scaling_factor()) # raw -> scaled latent the DiT consumes (channels-first)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""LingBot-World torch adapters (GPU backend) — declared on the card via ``ComponentSpec.adapter`` so
|
||||
the recipe is self-contained (no edit to the shared ``_make_dit`` dispatch in ``torch_backend.py``).
|
||||
Imported lazily by ``_explicit_adapter`` only on a GPU box.
|
||||
|
||||
LingBot-World-Base-Cam is Wan2.2-I2V-A14B (a 2x14B boundary-routed MoE with CLIP + first-frame i2v
|
||||
conditioning) PLUS a NEW camera/Plucker conditioning input. The DiT forward gains a ``c2ws_plucker_emb``
|
||||
argument (``LingBotWorldTransformer3DModel.forward(..., c2ws_plucker_emb=None)``) which a vanilla WanDiT
|
||||
reuse would silently drop -> the generation would degrade to a generic Wan i2v with no camera control
|
||||
(BRINGUP blocker #1). ``LingBotWorldDiT`` threads that tensor.
|
||||
|
||||
Key design constraint (faithful + conflict-free): the v2 dit-call surface (``ToyDiT.__call__`` and the
|
||||
loop's ``dit(latent, text_embed, sigma, context=, cond=)``) has NO ``c2ws_plucker_emb`` kwarg, and we may
|
||||
not edit the toy/loop sampler. So the loop publishes the per-step Plucker tensor onto the adapter as a
|
||||
plain attribute (``self.c2ws_plucker_emb``) right before the call (the cosmos2 pattern of building
|
||||
arch-specifics adapter-side); ``__call__`` reads it and forwards it to the real module. The ToyDiT stand-in
|
||||
simply never has the attribute, so the CPU path is the no-camera (degenerate t2v/i2v) path unchanged.
|
||||
|
||||
MoE CPU-offload (BRINGUP blocker #3): 2x14B bf16 won't co-reside on an 80GB GPU. The card declares this
|
||||
adapter for BOTH ``transformer`` (high-noise) and ``transformer_2`` (low-noise) experts; we keep the
|
||||
WanDiT offload-group swap so only the boundary-active expert is GPU-resident. ``_make_dit`` does not pass
|
||||
an ``offload_group`` to an explicit adapter, so the two LingBotWorld experts share a process-wide group
|
||||
keyed by the wrapped module's ``id`` would be fragile across instances — instead we share a single
|
||||
class-level group; for the registered single-instance serving path that is exactly the WanDiT MoE
|
||||
behavior. BRINGUP: revisit if multiple LingBotWorld instances must co-reside.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import NUM_TRAIN_TIMESTEPS, WanDiT
|
||||
|
||||
|
||||
class LingBotWorldDiT(WanDiT):
|
||||
"""``dit(latent[C,T,H,W], text_embed[seq,dim], sigma, context=clip, cond=[mask|cond]) -> velocity``,
|
||||
plus an out-of-band ``self.c2ws_plucker_emb`` (numpy ``[6*s^2, F_lat, H_lat, W_lat]``) the loop sets
|
||||
per step. Identical i2v conditioning to Wan2.2-I2V-A14B — the 36ch ``[noise|mask+cond]`` concat and
|
||||
CLIP ``encoder_hidden_states_image`` — with the extra Plucker camera tensor forwarded to the model.
|
||||
|
||||
Faithful to ``LingBotWorldImageToVideoPipeline`` (== ``WanImageToVideoPipeline`` denoise) + the
|
||||
``c2ws_plucker_emb=batch.c2ws_plucker_emb`` kwarg threaded by ``DenoisingStage`` (denoising.py:182).
|
||||
"""
|
||||
|
||||
# All LingBotWorld DiT experts share one CPU-offload group (only the boundary-active one is resident).
|
||||
_LBW_OFFLOAD_GROUP: dict[str, LingBotWorldDiT] = {}
|
||||
|
||||
def __init__(self, module, *, device, dtype):
|
||||
# ``_make_dit`` builds an explicit adapter as ``cls(module, device=, dtype=)`` (no offload_group),
|
||||
# so wire the shared MoE offload group here ourselves (2x14B can't co-reside on 80GB).
|
||||
component_id = getattr(getattr(module, "config", None), "prefix", None) or type(module).__name__
|
||||
super().__init__(module,
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
offload_group=LingBotWorldDiT._LBW_OFFLOAD_GROUP,
|
||||
component_id=str(component_id))
|
||||
# The per-step camera/Plucker embedding the loop publishes before each call (None == no camera).
|
||||
self.c2ws_plucker_emb = None
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(self, latent, text_embed, sigma, context=None, *, cond=None):
|
||||
self._ensure_resident()
|
||||
hs = self._t(latent)
|
||||
if cond is not None: # i2v: concat [noise (16ch) ; mask+cond_latent (20ch)] -> 36ch input
|
||||
hs = torch.cat([hs, self._t(cond)], dim=1)
|
||||
ehs = self._t(text_embed)
|
||||
# LingBotWorld uses the SCALAR-timestep path (no expand_timesteps); timestep = sigma*1000.
|
||||
ts = float(sigma) * NUM_TRAIN_TIMESTEPS
|
||||
timestep = torch.tensor([ts], device=self.device)
|
||||
img = self._t(context) # CLIP image embeds -> encoder_hidden_states_image
|
||||
plucker = None
|
||||
if self.c2ws_plucker_emb is not None: # camera conditioning (BRINGUP: needs a per-request input)
|
||||
plucker = self._t(self.c2ws_plucker_emb) # [1, 6*s^2, F_lat, H_lat, W_lat]
|
||||
with self._ctx():
|
||||
velocity = self.module(hidden_states=hs,
|
||||
encoder_hidden_states=ehs,
|
||||
timestep=timestep,
|
||||
encoder_hidden_states_image=img,
|
||||
c2ws_plucker_emb=plucker)
|
||||
return self._n(velocity) # rectified-flow velocity (BRINGUP risk C)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""LongCat-Video torch adapter (GPU backend) — declared on the card via ``ComponentSpec.adapter`` so
|
||||
the LongCat recipe is self-contained (no edit to the shared ``_make_dit`` dispatch in ``torch_backend.py``).
|
||||
Imported lazily by ``_explicit_adapter`` only on a GPU box.
|
||||
|
||||
* ``LongCatDiT`` — the flow-match velocity predictor. Mirrors ``WanDiT`` (``timestep = sigma*1000``,
|
||||
scalar ``[B]`` timestep that the DiT internally expands ``[B] -> [B, T_latent]`` for the per-latent-frame
|
||||
AdaLN time embedding) but with the **critical LongCat sign convention**: the fastvideo
|
||||
``LongCatDenoisingStage`` does ``noise_pred = -noise_pred`` *before* the flow-match scheduler step
|
||||
(longcat_denoising.py line 152). The v2 loop integrates ``x + (sigma_next-sigma)*velocity`` with the
|
||||
velocity this adapter returns, so to reproduce that step EXACTLY this adapter returns ``-velocity``.
|
||||
Forgetting the negation silently produces diverging samples (spec blocker #1).
|
||||
|
||||
LongCat reuses the existing ``WanVAE`` torch adapter unchanged (same ``AutoencoderKLWan`` mean/std
|
||||
normalization) and the existing ``T5Encoder`` (UMT5, zero-padded to max_length=512 for the CFG concat
|
||||
uniform-seq contract). Both are wired on the card by ``load_id`` alone — no adapter override needed.
|
||||
|
||||
The DiT runs its AdaLN modulation / residual gating + final projection under fp32 and casts the output to
|
||||
float32 (longcat.py line 1121); we keep the module at its native bf16 dtype (``_native_dtype`` in
|
||||
``_make_dit``, matching the fastvideo stage's hardcoded bf16 target) and let ``_n`` marshal the fp32 output
|
||||
back to numpy (spec blocker #6).
|
||||
|
||||
BRINGUP: written-not-run on CPU (no torch/weights here). GPU-verify against a real
|
||||
``FastVideo/LongCat-Video-T2V-Diffusers`` checkpoint — confirm the bf16 dtype, the scalar-timestep
|
||||
expansion, and ``encoder_attention_mask=None`` (the CaptionEmbedder simply skips the zeroing branch for
|
||||
T2V, which is faithful since the v2 ``T5Encoder`` already zero-pads the embedding rows past real tokens).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import NUM_TRAIN_TIMESTEPS, TorchComponent
|
||||
|
||||
|
||||
class LongCatDiT(TorchComponent):
|
||||
"""``dit(latent[C,T,H,W], text_embed[seq,4096], sigma) -> -velocity[C,T,H,W]``.
|
||||
|
||||
Real forward (``fastvideo/models/dits/longcat.py``):
|
||||
``forward(hidden_states[B,C=16,T,H,W], encoder_hidden_states[B,N_text,4096], timestep[B]|[B,T],
|
||||
encoder_attention_mask=None, ...) -> velocity[B,C_out=16,T,H,W]`` (cast to float32).
|
||||
|
||||
The loop hands the raw ``sigma`` (1->0); this adapter forms ``timestep = sigma*1000`` (the Wan/diffusers
|
||||
convention LongCat shares) as a scalar ``[B]`` tensor — the DiT's ``forward`` expands ``[B] -> [B, T_latent]``
|
||||
internally for the AdaLN time embedding (longcat.py line 1075). It returns the **negated** velocity to
|
||||
fold in the fastvideo stage's ``noise_pred = -noise_pred`` (spec blocker #1)."""
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(self, latent, text_embed, sigma, context=None, *, cond=None):
|
||||
hs = self._t(latent) # [1, C=16, T, H, W]
|
||||
ehs = self._t(text_embed) # [1, N_text, 4096]
|
||||
b = hs.shape[0]
|
||||
# timestep = sigma*1000 (BRINGUP risk B / spec timestep_convention). Scalar [B]; the DiT expands
|
||||
# [B] -> [B, T_latent] internally for the per-latent-frame AdaLN time embedding. (I2V/VC would build
|
||||
# an explicit [B, T_latent] with cond frames zeroed — deferred, T2V uses the scalar path.)
|
||||
ts = float(sigma) * NUM_TRAIN_TIMESTEPS
|
||||
timestep = torch.full((b, ), ts, device=self.device, dtype=self.dtype)
|
||||
with self._ctx(current_timestep=ts):
|
||||
velocity = self.module(hidden_states=hs,
|
||||
encoder_hidden_states=ehs,
|
||||
timestep=timestep,
|
||||
encoder_attention_mask=None)
|
||||
# CRITICAL (spec blocker #1): negate so the loop's FLOW_MATCH_STEP reproduces the fastvideo stage's
|
||||
# negate-then-scheduler-step. The DiT already casts to float32; _n marshals fp32 -> numpy.
|
||||
return self._n(-velocity)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Matrix-Game 2.0 torch adapters (GPU backend) — declared on the card via ``ComponentSpec.adapter`` so
|
||||
the recipe is self-contained (no edit to ``_make_dit``/``_make_image_encoder`` in ``torch_backend.py``).
|
||||
Imported lazily by ``_explicit_adapter`` only on a GPU box.
|
||||
|
||||
Matrix-Game 2.0 is an INTERACTIVE world model: a distilled, causal/autoregressive, action-conditioned
|
||||
Wan-family DiT. The deltas this adapter encodes vs the stock ``WanDiT`` (all faithful to
|
||||
``fastvideo/models/dits/matrixgame2/causal_model.py:CausalMatrixGame2WanModel._forward_inference`` and
|
||||
``fastvideo/pipelines/stages/matrixgame2_denoising.py:MatrixGame2CausalDenoisingStage``):
|
||||
|
||||
* EPSILON output, NOT velocity — ``_forward_inference`` returns a bare velocity-shaped tensor the stage
|
||||
treats as predicted NOISE; the epsilon->x0 conversion (``pred_noise_to_pred_video`` =
|
||||
``x - sigma_t*eps``) lives in ``MatrixGame2CausalDMDLoop``, NOT here. The adapter returns the raw model
|
||||
output.
|
||||
* CAUSAL KV-CACHE is mandatory — ``kwargs['kv_cache'] is not None`` selects ``_forward_inference`` (else
|
||||
``_forward_train``). The DiT MUTATES kv_cache / crossattn_cache / kv_cache_mouse / kv_cache_keyboard in
|
||||
place (sliding-window eviction by ``local_attn_size``). The loop owns these cache lists across blocks
|
||||
and steps and hands them in via ``MatrixGame2CausalDiT.call(...)``; the loop keeps them in
|
||||
``LoopState`` so interleaving stays safe.
|
||||
* PER-FRAME timestep ``[B, num_frames]`` (LongTensor 0..1000), NOT a scalar ``sigma*1000`` — the
|
||||
condition_embedder unflattens ``timestep_proj`` by (batch, post_patch_num_frames); a scalar breaks the
|
||||
reshape. The loop passes a numpy per-frame timestep array; this adapter keeps it 2-D and ``long``.
|
||||
* i2v via CHANNEL-CONCAT read off ``get_forward_context().forward_batch.image_latent`` — the model
|
||||
concats the conditioning latent onto ``hidden_states`` along dim=1 INTERNALLY (patch_embedding is the
|
||||
32-in checkpoint). So the adapter places ``image_latent`` (the raw cond_latent, NO 4ch Wan mask — this
|
||||
differs from Wan2.1 i2v) on a lightweight forward_batch under ``set_forward_context``.
|
||||
* CLIP image embeds (257x1280) are the SOLE cross-attention context (``encoder_hidden_states_image``);
|
||||
text is ignored (Matrix-Game 2.0 has no text encoder), but the forward still wants a placeholder
|
||||
``encoder_hidden_states`` list.
|
||||
* ACTION conditioning (mouse[B,F,2] / keyboard[B,F,4]) + its own KV caches is the interactive surface.
|
||||
BRINGUP: the degenerate (no-action) path runs with mouse/keyboard=None; live action routing needs a
|
||||
request-API extension (see the recipe's loop docstring).
|
||||
|
||||
BRINGUP (written-not-run; GPU-only): importing ``causal_model`` triggers a flex_attention
|
||||
``torch.compile`` and the inference path uses plain SDPA over the cache — both GPU-only. The exact cache
|
||||
shapes (``local_attn_size`` window, mouse-cache batch dim ``B*frame_seq_len``) come from the loaded
|
||||
arch_config and must be GPU-verified against a real distilled checkpoint.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import TorchComponent, _to_numpy
|
||||
|
||||
# Matrix-Game 2.0 ships a Wan-VAE first-frame i2v conditioning latent that the DiT channel-concats; this is
|
||||
# carried on a minimal forward_batch object the model reads via ``get_forward_context().forward_batch``.
|
||||
|
||||
|
||||
class _CondBatch:
|
||||
"""Minimal stand-in for fastvideo's ``ForwardBatch`` carrying only the field the causal DiT reads:
|
||||
``image_latent`` (the i2v cond_concat). The real ForwardBatch has dozens of fields; the inference
|
||||
forward only touches ``getattr(batch, 'image_latent', None)``, so this is a faithful, tiny carrier."""
|
||||
|
||||
def __init__(self, image_latent: Any) -> None:
|
||||
self.image_latent = image_latent
|
||||
|
||||
|
||||
class MatrixGame2CausalDiT(TorchComponent):
|
||||
"""``call(latent[C,T,h,w], image_embed[257,1280], timestep[T] (per-frame), kv_caches..., cond=...)``
|
||||
-> raw epsilon[C,T,h,w]``.
|
||||
|
||||
The loop drives the causal block-autoregressive schedule and the DMD epsilon->x0 conversion; this
|
||||
adapter is a single ``_forward_inference`` call with the cache/i2v/action plumbing. It is NOT the toy
|
||||
``dit(latent, text_embed, sigma)`` shape — the loop calls ``MatrixGame2CausalDiT.call`` explicitly with
|
||||
the causal kwargs (the toy backend exercises the SAME loop via its degenerate path; see the recipe
|
||||
loop). All arch-specifics are built INTERNALLY here so the loop stays backend-agnostic."""
|
||||
|
||||
@torch.no_grad()
|
||||
def call(self,
|
||||
latent: np.ndarray,
|
||||
image_embed: Any,
|
||||
timestep: np.ndarray,
|
||||
*,
|
||||
kv_cache: list[dict] | None = None,
|
||||
crossattn_cache: list[dict] | None = None,
|
||||
kv_cache_mouse: list[dict] | None = None,
|
||||
kv_cache_keyboard: list[dict] | None = None,
|
||||
current_start: int = 0,
|
||||
start_frame: int = 0,
|
||||
num_frame_per_block: int = 1,
|
||||
mouse_cond: np.ndarray | None = None,
|
||||
keyboard_cond: np.ndarray | None = None,
|
||||
image_latent: np.ndarray | None = None,
|
||||
prompt_embeds: Any = None) -> np.ndarray:
|
||||
# hidden_states [B, C, T, h, w]; the DiT concats image_latent internally off the forward_batch.
|
||||
hs = self._t(latent) # [1, C, T, h, w]
|
||||
b, _c, t, _h, _w = hs.shape
|
||||
# Per-frame integer timestep [B, num_frames] (long); keep 2-D — never collapse to a scalar.
|
||||
ts_np = np.asarray(timestep).reshape(-1)
|
||||
timestep_t = torch.as_tensor(ts_np, device=self.device, dtype=torch.long).reshape(1, -1).expand(b, t)
|
||||
# CLIP image embeds [257, 1280] are the SOLE cross-attn context; the model wants a list.
|
||||
img_ctx = self._t(image_embed) if image_embed is not None else None
|
||||
image_kwargs = {"encoder_hidden_states_image": [img_ctx] if img_ctx is not None else []}
|
||||
# Text is ignored but the forward wants a non-empty placeholder list (condition_embedder drops it).
|
||||
pe = self._t(prompt_embeds) if prompt_embeds is not None else None
|
||||
ehs: list[Any] = [pe] if pe is not None else []
|
||||
# Action conditioning (interactive surface) — None on the degenerate world-rollout path (BRINGUP).
|
||||
action_kwargs: dict[str, Any] = {"num_frame_per_block": int(num_frame_per_block)}
|
||||
if mouse_cond is not None:
|
||||
action_kwargs["mouse_cond"] = self._t(mouse_cond)
|
||||
if keyboard_cond is not None:
|
||||
action_kwargs["keyboard_cond"] = self._t(keyboard_cond)
|
||||
if kv_cache_mouse is not None:
|
||||
action_kwargs["kv_cache_mouse"] = kv_cache_mouse
|
||||
if kv_cache_keyboard is not None:
|
||||
action_kwargs["kv_cache_keyboard"] = kv_cache_keyboard
|
||||
# i2v cond_concat: the DiT reads image_latent off the forward_batch -> channel-concat dim=1.
|
||||
cond_lat = self._t(image_latent) if image_latent is not None else None
|
||||
from v2.forward_context import set_forward_context
|
||||
with set_forward_context(current_timestep=0, attn_metadata=None, forward_batch=_CondBatch(cond_lat)):
|
||||
eps = self.module(
|
||||
hs,
|
||||
ehs,
|
||||
timestep_t,
|
||||
kv_cache=kv_cache,
|
||||
crossattn_cache=crossattn_cache,
|
||||
current_start=int(current_start),
|
||||
start_frame=int(start_frame),
|
||||
**image_kwargs,
|
||||
**action_kwargs,
|
||||
)
|
||||
return self._n(eps) # RAW epsilon (the loop converts eps -> x0 via the scheduler sigma table)
|
||||
|
||||
|
||||
class MatrixGame2CLIPImageEncoder(TorchComponent):
|
||||
"""CLIP vision encoder for Matrix-Game 2.0 i2v conditioning: ``encode_image(image) -> [257, 1280]`` (1
|
||||
CLS + 256 patch tokens), which the DiT's WanImageEmbedding projects to inner_dim and uses as the sole
|
||||
cross-attention context. Mirrors fastvideo's ``MatrixGame2ImageEncodingStage`` — the HF image processor
|
||||
(sibling ``image_processor`` subfolder) preprocesses, the encoder returns ``last_hidden_state``.
|
||||
BRINGUP: written-not-run; GPU-verify the processor/encoder subfolders + dtype against a real
|
||||
distilled checkpoint."""
|
||||
|
||||
def __init__(self, module, processor, *, device, dtype):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.processor = processor
|
||||
|
||||
@torch.no_grad()
|
||||
def encode_image(self, image):
|
||||
inputs = self.processor(images=image, return_tensors="pt").to(self.device)
|
||||
with self._ctx():
|
||||
out = self.module(**inputs)
|
||||
embeds = out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]
|
||||
return _to_numpy(embeds.squeeze(0))
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Matrix-Game 3.0 torch adapter (GPU backend) — declared on the card via ``ComponentSpec.adapter``
|
||||
so the MG3 recipe is self-contained (no edit to the shared ``_make_dit`` dispatch in
|
||||
``torch_backend.py``). Imported lazily by ``_explicit_adapter`` only on a GPU box.
|
||||
|
||||
``MatrixGame3DiT`` is the autoregressive world-model DiT (``MatrixGame3WanModel``, 30 layers, dim 3072,
|
||||
in/out_channels=48, patch (1,2,2), ``use_memory=True``, ``sigma_theta=0.8``). It departs from ``WanDiT``
|
||||
in three load-bearing ways (BRINGUP risks 1/2/7 in the spec):
|
||||
|
||||
1. PER-TOKEN timestep, NOT ``sigma*1000`` scalar. The model's ``timestep`` arg is a flattened
|
||||
``[1, T*(H/2)*(W/2)]`` LongTensor whose value is the raw FlowUniPC scheduler timestep, with the
|
||||
``cond_frames`` rows zeroed (those latent frames are pinned to the conditioning image and must not be
|
||||
denoised). When ``x_memory`` is present, an all-zeros ``timestep_memory`` block is concatenated in
|
||||
front. The loop builds the *scalar* timestep + ``cond_frames`` + per-clip conditioning bundle and
|
||||
hands them through ``cond=``; this adapter expands them to the per-token tensor INTERNALLY so the
|
||||
loop's dit-call stays ``dit(latent, text_embed, sigma)`` (ToyDiT-compatible — the cosmos2 pattern).
|
||||
2. Action / camera / memory conditioning: ``mouse_cond`` / ``keyboard_cond`` / ``x_memory`` /
|
||||
``timestep_memory`` / ``mouse_cond_memory`` / ``keyboard_cond_memory`` / ``c2ws_plucker_emb`` /
|
||||
``memory_latent_idx`` / ``predict_latent_idx`` are passed straight through. The loop assembles all of
|
||||
them (numpy host work — Plücker / extrinsics / memory-index selection) and stuffs them in ``cond``.
|
||||
3. The forward returns a velocity (``flow_prediction``) — a bare tensor ``[B,48,T,H,W]`` — which the
|
||||
FlowUniPC scheduler (driven by the loop) converts velocity->x0 internally in ``step()``.
|
||||
|
||||
The forward runs inside ``set_forward_context(current_timestep=<scalar t>, attn_metadata=None)`` (the
|
||||
FastVideo attention layers read ``attn_metadata`` via the forward context). The 5B DiT is kept eager and
|
||||
resident (no offload group). BRINGUP: written-not-run; the per-token timestep packing, the
|
||||
``predict_latent_idx`` ``(start,end)`` tuple (the model expands it to a device ``arange``), and the
|
||||
memory/plucker tensor dtypes must be GPU-verified against a real ``Matrix-Game-3.0`` checkpoint.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import TorchComponent
|
||||
|
||||
|
||||
class MatrixGame3DiT(TorchComponent):
|
||||
"""``dit(latent[C=48,T,H,W], text_embed[seq,dim], sigma, cond=<MG3 bundle>) -> velocity[C,T,H,W]``.
|
||||
|
||||
``cond`` is the per-step conditioning bundle the ``MatrixGame3DenoiseLoop`` assembles (a plain dict).
|
||||
Keys (all optional except ``cond_frames``):
|
||||
* ``cond_frames`` (int) — leading latent frames pinned to the conditioning image (1 first clip,
|
||||
4 later); their per-token timestep rows are zeroed.
|
||||
* ``mouse_cond`` / ``keyboard_cond`` (numpy [1, frames, dim]) — per-clip action streams.
|
||||
* ``x_memory`` / ``timestep_memory`` / ``mouse_cond_memory`` / ``keyboard_cond_memory`` (numpy) —
|
||||
autoregressive KV-memory tensors (None on the first clip / degenerate single-clip path).
|
||||
* ``c2ws_plucker_emb`` (numpy) — camera Plücker embeddings (memory blocks concatenated in front).
|
||||
* ``memory_latent_idx`` (list[int] | None) — history indices the memory tensors index into.
|
||||
* ``predict_latent_idx`` (tuple (start, end)) — the clip's latent-frame span (the model expands it
|
||||
to a device ``arange``); REQUIRED, the model uses it for rotary positions.
|
||||
"""
|
||||
|
||||
def __init__(self, module: Any, *, device: Any, dtype: Any) -> None:
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
# patch_size[1:] -> (H/2, W/2) token folding for the per-token timestep packing. (1,2,2) default.
|
||||
ps = getattr(module, "patch_size", (1, 2, 2))
|
||||
self.patch_t, self.patch_h, self.patch_w = int(ps[0]), int(ps[1]), int(ps[2])
|
||||
|
||||
def _per_token_timestep(self, hs: torch.Tensor, sigma_value: float, cond_frames: int,
|
||||
x_memory: torch.Tensor | None) -> torch.Tensor:
|
||||
"""Build the flattened per-token timestep [1, T*(H/p)*(W/p)] (BRINGUP risk 1). The raw FlowUniPC
|
||||
timestep value fills every token; ``cond_frames`` latent rows are zeroed (pinned, not denoised);
|
||||
an all-zeros memory block is prepended when ``x_memory`` is present."""
|
||||
_b, _c, t, h, w = hs.shape
|
||||
tokens_per_frame = (h // self.patch_h) * (w // self.patch_w)
|
||||
tt = hs.new_full((t, tokens_per_frame), float(sigma_value))
|
||||
if cond_frames > 0:
|
||||
tt[:cond_frames].zero_()
|
||||
tt = tt.flatten().unsqueeze(0)
|
||||
if x_memory is not None:
|
||||
mem_tokens = x_memory.shape[2] * (x_memory.shape[3] // self.patch_h) * (x_memory.shape[4] // self.patch_w)
|
||||
tt = torch.cat([tt.new_zeros((1, mem_tokens)), tt], dim=1)
|
||||
return tt
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(self,
|
||||
latent: Any,
|
||||
text_embed: Any,
|
||||
sigma: float,
|
||||
context: Any = None,
|
||||
*,
|
||||
cond: dict[str, Any] | None = None) -> Any:
|
||||
hs = self._t(latent) # [1, 48, T, H, W]
|
||||
ehs = self._t(text_embed)
|
||||
cond = cond or {}
|
||||
cond_frames = int(cond.get("cond_frames", 0))
|
||||
x_memory = self._t(cond.get("x_memory")) if cond.get("x_memory") is not None else None
|
||||
# FlowUniPC timestep value: the loop passes the *raw* scheduler timestep (~sigma*1000), already
|
||||
# resolved, as ``sigma`` here (NOT a normalized 0..1 sigma). See the loop for the convention.
|
||||
timestep = self._per_token_timestep(hs, float(sigma), cond_frames, x_memory)
|
||||
|
||||
def _opt(key: str) -> Any:
|
||||
v = cond.get(key)
|
||||
return self._t(v) if v is not None else None
|
||||
|
||||
with self._ctx(current_timestep=float(sigma)):
|
||||
velocity = self.module(
|
||||
hs,
|
||||
ehs,
|
||||
timestep,
|
||||
mouse_cond=_opt("mouse_cond"),
|
||||
keyboard_cond=_opt("keyboard_cond"),
|
||||
x_memory=x_memory,
|
||||
timestep_memory=_opt("timestep_memory"),
|
||||
mouse_cond_memory=_opt("mouse_cond_memory"),
|
||||
keyboard_cond_memory=_opt("keyboard_cond_memory"),
|
||||
c2ws_plucker_emb=_opt("c2ws_plucker_emb"),
|
||||
memory_latent_idx=cond.get("memory_latent_idx"),
|
||||
predict_latent_idx=cond.get("predict_latent_idx"),
|
||||
)
|
||||
return self._n(velocity) # rectified-flow velocity (FlowUniPC.step does velocity->x0)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Stable Diffusion 3.5 torch adapters (GPU backend) — declared on the card via ``ComponentSpec.adapter``
|
||||
so the SD3.5 recipe is self-contained (no edit to the shared ``_make_dit``/``_make_vae``/``_make_text_encoder``
|
||||
dispatch in ``torch_backend.py``). Imported lazily by ``_explicit_adapter`` only on a GPU box.
|
||||
|
||||
Architecture deltas vs Wan/Cosmos (all faithful to ``fastvideo/pipelines/stages/sd35_conditioning.py``
|
||||
+ ``fastvideo/models/dits/sd3.py:SD3Transformer2DModel``):
|
||||
|
||||
* ``SD3DiT`` — a FLOW-MATCH MMDiT. The forward needs TWO text conditioners: ``encoder_hidden_states``
|
||||
(the assembled triple-encoder joint embed [seq, 4096]) AND a separate ``pooled_projections`` vector
|
||||
[2048]. The v2 loop threads only a single ``text_embed`` + a positional ``context`` arg (the same
|
||||
surface ToyDiT exposes), so this adapter takes ``text_embed`` = the joint embed and ``context`` = the
|
||||
pooled vector. Latents are 4D image latents [1, 16, h, w] (num_frames=1) — NOT 5D video. The model
|
||||
output is the flow-match velocity directly (no x0 conversion, unlike LTX2DiT). ``timestep = sigma*1000``
|
||||
(diffusers FlowMatchEuler convention, same as WanDiT). The fastvideo forward self-manages
|
||||
``set_forward_context`` (sd3.py lines 1011-1021) but wrapping in ``self._ctx()`` is idempotent (it
|
||||
detects the existing context), so we wrap for uniformity with the other adapters.
|
||||
|
||||
* ``SD3VAE`` — ``AutoencoderKL`` operates in RAW latent space; the DiT operates in a shift/scale
|
||||
NORMALIZED space. SD3.5: ``z_dit = (z - shift_factor) * scaling_factor`` on encode; decode inverts
|
||||
``z_raw = z_dit / scaling_factor + shift_factor`` then ``vae.decode -> image in [-1,1]``. CRITICAL:
|
||||
the factors come from the LOADED checkpoint ``vae.config`` (scaling_factor=1.5305, shift_factor=0.0609
|
||||
for SD3.5), NOT the generic AutoencoderKLArchConfig defaults (0.18215 / None). 4D (image), not 5D.
|
||||
The decoded image is mapped to [0,1] by the program (``(img/2+0.5).clamp(0,1)``), matching
|
||||
``SD35DecodingStage``.
|
||||
|
||||
* ``SD3ClipEncoder`` / ``SD3T5Encoder`` — the joint embed is assembled in the program (see
|
||||
``sd35.program``), so each encoder adapter returns BOTH the per-encoder pieces the assembler needs:
|
||||
CLIP returns ``(penultimate_hidden[-2], pooler_output)`` and T5 returns ``last_hidden_state``.
|
||||
CLIP runs with ``output_hidden_states=True`` at max_length=77 padded; T5 at max_length=256 padded.
|
||||
Faithful to ``SD35ConditioningStage._clip_pooled`` + the TextEncodingStage CLIP penultimate extraction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from v2.platform.backends.torch_backend import NUM_TRAIN_TIMESTEPS, TorchComponent, _to_numpy
|
||||
|
||||
# SD3.5 encoder lengths (SD35Pipeline.initialize_pipeline / SD35ConditioningStage).
|
||||
CLIP_MAX_LENGTH = 77
|
||||
T5_MAX_LENGTH = 256
|
||||
# Generic SD1.5-era AutoencoderKL defaults — used ONLY as a last-resort fallback if the checkpoint
|
||||
# config is missing the SD3.5 values (BRINGUP: the real checkpoint vae config carries 1.5305 / 0.0609).
|
||||
_DEFAULT_SCALING = 1.5305
|
||||
_DEFAULT_SHIFT = 0.0609
|
||||
|
||||
|
||||
class SD3DiT(TorchComponent):
|
||||
"""``dit(latent[16,h,w], encoder_hidden_states[seq,4096], sigma, context=pooled[2048]) -> velocity``.
|
||||
|
||||
Faithful to ``SD35DenoisingStage`` + ``SD3Transformer2DModel.forward``: 4D image latents, the dual
|
||||
text conditioning (joint embed + pooled), ``timestep = sigma*1000`` broadcast to [B], flow-match
|
||||
velocity output. ``context`` carries the pooled_projections vector the loop threads alongside the
|
||||
joint embed (so the loop's dit-call stays the cosmos2-style ``dit(latent, text_embed, sigma, context)``)."""
|
||||
|
||||
@torch.no_grad()
|
||||
def __call__(self, latent, text_embed, sigma, context=None, *, cond=None):
|
||||
# 4D image latent: ``self._t`` prepends the batch dim -> [1, 16, h, w]. (No temporal dim.)
|
||||
hs = self._t(latent)
|
||||
ehs = self._t(text_embed) # [1, seq, 4096] joint embed
|
||||
if context is None:
|
||||
raise RuntimeError("SD3DiT requires pooled_projections (threaded as `context`); got None.")
|
||||
pp = self._t(context) # [1, 2048] dual-CLIP pooled vector
|
||||
# diffusers FlowMatchEuler timestep = sigma * num_train_timesteps (1000); 1D [B] long-ish float.
|
||||
ts = torch.tensor([float(sigma) * NUM_TRAIN_TIMESTEPS], device=self.device, dtype=self.dtype)
|
||||
with self._ctx(): # idempotent (sd3.forward self-manages it)
|
||||
out = self.module(hidden_states=hs,
|
||||
encoder_hidden_states=ehs,
|
||||
pooled_projections=pp,
|
||||
timestep=ts,
|
||||
return_dict=False)[0] # [1, 16, h, w] flow-match velocity
|
||||
return self._n(out) # -> [16, h, w] numpy (no x0 conversion)
|
||||
|
||||
|
||||
class SD3VAE(TorchComponent):
|
||||
"""``AutoencoderKL`` with the SD3.5 shift/scale normalization the DiT latent space requires.
|
||||
|
||||
encode: ``z_dit = (z - shift_factor) * scaling_factor``; decode: ``z_raw = z_dit / scaling_factor +
|
||||
shift_factor`` then ``vae.decode -> image[3,h,w] in [-1,1]``. The factors are read from the loaded
|
||||
``module.config`` (BRINGUP risk D: the generic dataclass defaults would mis-scale). Marshals 4D
|
||||
(image), not 5D video. The [-1,1] -> [0,1] remap is done by the program, matching ``SD35DecodingStage``."""
|
||||
|
||||
@staticmethod
|
||||
def _as_float(v: object) -> float:
|
||||
return float(v.item()) if isinstance(v, torch.Tensor) else float(v) # type: ignore[arg-type]
|
||||
|
||||
def _factors(self) -> tuple[float, float | None]:
|
||||
cfg = getattr(self.module, "config", None)
|
||||
sf = getattr(cfg, "scaling_factor", None) if cfg is not None else None
|
||||
sh = getattr(cfg, "shift_factor", None) if cfg is not None else None
|
||||
if sf is None:
|
||||
sf = getattr(self.module, "scaling_factor", _DEFAULT_SCALING)
|
||||
if sh is None:
|
||||
sh = getattr(self.module, "shift_factor", _DEFAULT_SHIFT)
|
||||
return self._as_float(sf), (None if sh is None else self._as_float(sh))
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, image):
|
||||
# image [3, h, w] -> [1, 3, h, w] (4D); AutoencoderKL.encode -> a DiagonalGaussian dist.
|
||||
x = self._t(image)
|
||||
dist = self.module.encode(x)
|
||||
dist = dist.latent_dist if hasattr(dist, "latent_dist") else dist
|
||||
z = dist.mode() if hasattr(dist, "mode") else (dist.sample() if hasattr(dist, "sample") else dist)
|
||||
sf, sh = self._factors()
|
||||
z = z.float()
|
||||
if sh is not None:
|
||||
z = z - sh
|
||||
return self._n(z * sf) # -> the normalized latent the DiT expects
|
||||
|
||||
@torch.no_grad()
|
||||
def decode(self, latent):
|
||||
z = self._t(latent).float() # [1, 16, h, w]
|
||||
sf, sh = self._factors()
|
||||
z = z / sf
|
||||
if sh is not None:
|
||||
z = z + sh
|
||||
dec = self.module.decode(z.to(self.dtype)) # -> image [1, 3, H, W] in [-1, 1]
|
||||
image = dec.sample if hasattr(dec, "sample") else (dec[0] if isinstance(dec, tuple) else dec)
|
||||
return self._n(image) # [3, H, W]; program remaps [-1,1]->[0,1]
|
||||
|
||||
|
||||
class SD3ClipEncoder(TorchComponent):
|
||||
"""``CLIPTextModelWithProjection`` (clip_l or clip_g). ``encode(text) -> {hidden, pooled}`` where
|
||||
``hidden`` is the penultimate hidden state ([-2], the SD3 joint-embed source — NOT last_hidden_state)
|
||||
and ``pooled`` is ``pooler_output`` (the text-projection, the pooled-projection source). max_length=77
|
||||
padded. Faithful to ``SD35ConditioningStage._clip_pooled`` + the TextEncodingStage penultimate extraction."""
|
||||
|
||||
def __init__(self, module, tokenizer, *, device, dtype, max_length: int = CLIP_MAX_LENGTH):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.tokenizer = tokenizer
|
||||
self.max_length = max_length
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
toks = self.tokenizer(text or "",
|
||||
return_tensors="pt",
|
||||
max_length=self.max_length,
|
||||
padding="max_length",
|
||||
truncation=True)
|
||||
ids = toks.input_ids.to(self.device)
|
||||
mask = toks.attention_mask.to(self.device)
|
||||
with self._ctx():
|
||||
out = self.module(input_ids=ids, attention_mask=mask, output_hidden_states=True)
|
||||
# ``hidden_states`` is the per-layer pool; [-2] is the penultimate layer (the SD3 joint-embed source).
|
||||
hs = out.hidden_states
|
||||
penultimate = hs[-2] if isinstance(hs, list | tuple) else hs
|
||||
penultimate = penultimate.squeeze(0) # [seq=77, dim]
|
||||
pooled = out.pooler_output.squeeze(0) # [dim] (text_projection of the EOS token)
|
||||
return {"hidden": _to_numpy(penultimate), "pooled": _to_numpy(pooled)}
|
||||
|
||||
|
||||
class SD3T5Encoder(TorchComponent):
|
||||
"""``T5EncoderModel`` (the third SD3.5 encoder). ``encode(text) -> last_hidden_state[seq=256, 4096]``
|
||||
(max_length=256 padded). NaN->0 for safety. Faithful to the T5 branch of ``SD35ConditioningStage``."""
|
||||
|
||||
def __init__(self, module, tokenizer, *, device, dtype, max_length: int = T5_MAX_LENGTH):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.tokenizer = tokenizer
|
||||
self.max_length = max_length
|
||||
|
||||
@torch.no_grad()
|
||||
def encode(self, text):
|
||||
toks = self.tokenizer(text or "",
|
||||
return_tensors="pt",
|
||||
max_length=self.max_length,
|
||||
padding="max_length",
|
||||
truncation=True)
|
||||
ids = toks.input_ids.to(self.device)
|
||||
mask = toks.attention_mask.to(self.device)
|
||||
with self._ctx():
|
||||
out = self.module(input_ids=ids, attention_mask=mask)
|
||||
hidden = (out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]).squeeze(0)
|
||||
return _to_numpy(torch.nan_to_num(hidden, nan=0.0)) # [256, 4096]
|
||||
|
||||
|
||||
def assemble_sd3_conditioning(clip_l: dict, clip_g: dict, t5: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Assemble the SD3.5 dual-text conditioning from the three encoder outputs — the EXACT
|
||||
``SD35ConditioningStage`` algebra, kept as a pure-numpy helper so both the GPU and CPU-toy program
|
||||
paths share one assembler (BRINGUP risk: the pad/concat order + hidden index are easy to get wrong):
|
||||
|
||||
* joint embed: ``cat([ F.pad(cat([clip_l_h, clip_g_h], dim=-1), (0, 4096-2048)) , t5_h ], dim=seq)``
|
||||
* pooled: ``cat([clip_l_pool, clip_g_pool], dim=-1)`` -> [2048]
|
||||
"""
|
||||
clip_h = np.concatenate([np.asarray(clip_l["hidden"]), np.asarray(clip_g["hidden"])], axis=-1) # [77, 2048]
|
||||
t5_h = np.asarray(t5) # [256, 4096]
|
||||
pad = t5_h.shape[-1] - clip_h.shape[-1] # 4096 - 2048 = 2048
|
||||
if pad < 0:
|
||||
raise ValueError(f"CLIP joint dim {clip_h.shape[-1]} exceeds T5 dim {t5_h.shape[-1]}")
|
||||
clip_h = np.pad(clip_h, [(0, 0)] * (clip_h.ndim - 1) + [(0, pad)]) # [77, 4096]
|
||||
joint = np.concatenate([clip_h, t5_h], axis=-2).astype("float32") # [77+256, 4096] along seq
|
||||
pooled = np.concatenate([np.asarray(clip_l["pooled"]), np.asarray(clip_g["pooled"])],
|
||||
axis=-1).astype("float32") # [2048]
|
||||
return joint, pooled
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Stable Audio Open torch adapters (GPU backend) — declared on the card via ``ComponentSpec.adapter``
|
||||
so the recipe is self-contained (no edit to the shared ``_make_dit``/``_make_vae``/``_make_text_encoder``
|
||||
dispatch in ``torch_backend.py``). Imported lazily by ``_explicit_adapter`` only on a GPU box.
|
||||
|
||||
Stable Audio is an AUDIO model and differs from every existing (video) adapter on several axes, all of
|
||||
which are absorbed HERE so ``StableAudioDenoiseLoop`` keeps the cosmos2-style ``dit(latent, text_embed,
|
||||
sigma)`` call shape (and so the CPU toy backend works unchanged):
|
||||
|
||||
* ``StableAudioDiT`` — the v-prediction (EDM-v) network. forward is ``forward(x[B,64,L], t[B], *,
|
||||
cross_attn_cond[B,seq,768], global_embed[B,1536]) -> v[B,64,L]`` (1-D audio latent, NOT [C,T,H,W]).
|
||||
cross_attn_cond/global_embed are keyword-only and the DiT does NOT do CFG internally — the loop hands
|
||||
a single batch, CFG is combined in the loop (faithful to ``stable_audio/stages/denoising.py:_DiTAdapter``).
|
||||
The adapter packs the loop's flat ``text_embed`` payload into (cross_attn_cond, global_embed) and passes
|
||||
the RAW continuous sigma as the timestep ``t`` (NOT sigma*1000 (Wan), NOT 0..1 (LTX-2); k-diffusion /
|
||||
EDM c_noise convention — BRINGUP risk B). It returns the raw network output; the v->x0 (VDenoiser)
|
||||
preconditioning lives in the loop.
|
||||
* ``OobleckVAE`` — 1-D conv audio VAE (weight_norm + Snake1d, hop_length=2048). encode(audio[2,N]) ->
|
||||
raw latent (``dist.sample()``, NO mean/std normalization — passthrough latent space, BRINGUP risk D);
|
||||
decode(latent[64,L]) -> waveform[2,M]. 1-D marshalling (no 5-D unsqueeze). Exposes ``hop_length`` and
|
||||
``sampling_rate`` so the loop sizes the latent and the program slices [start,end] seconds.
|
||||
* ``StableAudioConditioner`` — the SA multi-conditioner (T5-base @ max_length=128 + duration
|
||||
NumberConditioners). encode(prompt, seconds_start, seconds_total) -> packs the (cross_attn_cond,
|
||||
cross_attn_mask, global_embed) triple into a single numpy payload the loop stashes in cond. NOTE: the
|
||||
shared ``_MAKERS`` dispatch has no ``conditioner`` kind and ``TextEncoderLoader`` will not build a
|
||||
``StableAudioMultiConditioner`` — so the card declares this component with ``kind="text_encoder"`` +
|
||||
this explicit adapter, and the adapter is constructed ``cls(module, tokenizer, ...)`` (the tokenizer
|
||||
extra the text-encoder maker passes is unused — the conditioner owns its own T5 tokenizer). The maker
|
||||
still calls ``TextEncoderLoader`` though, which loads a generic T5, NOT the SA conditioner — so wiring
|
||||
this on a GPU box needs the SA ``ConditionerLoader`` path (BRINGUP risk: the conditioner load).
|
||||
|
||||
All three are BRINGUP (written-not-run): there is no GPU/k_diffusion in this env. See the loop for the
|
||||
dpmpp-3m-sde GPU path that drives ``StableAudioDiT`` through ``K.external.VDenoiser``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2.platform.backends.torch_backend import TorchComponent, _to_numpy
|
||||
|
||||
# Packed conditioning payload layout. The loop treats the conditioner output as one flat "text_embed"
|
||||
# numpy array; the DiT adapter splits it back into cross_attn_cond + global_embed by these widths so the
|
||||
# loop->dit call stays ``dit(latent, text_embed, sigma)`` (cosmos2-shape) and the CPU toy still runs.
|
||||
_GLOBAL_COND_DIM = 1536 # StableAudioArchConfig.global_cond_dim
|
||||
_CROSS_COND_DIM = 768 # StableAudioArchConfig.cond_token_dim
|
||||
|
||||
# The global embed (1536) is laid down as ``_GLOBAL_HEAD_ROWS`` leading rows of width 768 (1536 = 2*768),
|
||||
# then the cross-attn tokens (each 768-wide) follow. Lets the whole conditioning ride as ONE [N,768] array.
|
||||
_GLOBAL_HEAD_ROWS = _GLOBAL_COND_DIM // _CROSS_COND_DIM # = 2
|
||||
|
||||
|
||||
def pack_conditioning(cross_attn_cond: np.ndarray, global_embed: np.ndarray) -> np.ndarray:
|
||||
"""Pack (cross_attn_cond[seq,768], global_embed[1536]) -> a single [2+seq, 768] numpy payload: the
|
||||
first 2 rows are the 1536-d global embed reshaped to [2,768], rows 2.. are the cross-attn tokens. The
|
||||
loop carries this opaque array as ``text_embed``; ``StableAudioDiT.__call__`` (and the CPU toy, which
|
||||
just means-pools it) consume it. Keeps the loop modality-agnostic — it never sees the SA tuple."""
|
||||
g = np.asarray(global_embed, dtype="float32").reshape(-1)
|
||||
head = np.zeros((_GLOBAL_HEAD_ROWS, _CROSS_COND_DIM), dtype="float32")
|
||||
head.reshape(-1)[:min(g.shape[0], _GLOBAL_COND_DIM)] = g[:_GLOBAL_COND_DIM] # row-major lay-down
|
||||
cross = np.asarray(cross_attn_cond, dtype="float32").reshape(-1, _CROSS_COND_DIM)
|
||||
return np.concatenate([head, cross], axis=0) # [2 + seq, 768]; rows0-1=global, rows2..=cross-attn
|
||||
|
||||
|
||||
def unpack_conditioning(payload: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Inverse of ``pack_conditioning``: -> (cross_attn_cond[seq,768], global_embed[1536])."""
|
||||
p = np.asarray(payload, dtype="float32")
|
||||
global_embed = p[:_GLOBAL_HEAD_ROWS].reshape(-1)[:_GLOBAL_COND_DIM].copy()
|
||||
cross = p[_GLOBAL_HEAD_ROWS:]
|
||||
return cross, global_embed
|
||||
|
||||
|
||||
class StableAudioDiT(TorchComponent):
|
||||
"""``dit(latent[64,L], text_embed=<packed cond>, sigma) -> raw v-prediction[64,L]``.
|
||||
|
||||
``sigma`` is the RAW continuous sigma (k-diffusion / EDM c_noise; ~0.3..500). The loop hands the
|
||||
ALREADY-VDenoiser-input-scaled latent (``x·c_in``) — this adapter is the bare network ``F_theta`` and
|
||||
returns the raw output; the v->x0 reconstruction + x0-space CFG live in ``StableAudioDenoiseLoop``
|
||||
(faithful to ``_DiTAdapter`` + ``K.external.VDenoiser``). Single batch; CFG batching is the loop's job.
|
||||
"""
|
||||
|
||||
def __call__(self, latent, text_embed, sigma, context=None, *, cond=None):
|
||||
import torch
|
||||
cross_np, global_np = unpack_conditioning(text_embed)
|
||||
hs = self._t(latent) # [1, 64, L]
|
||||
cross = self._t(cross_np) # [1, seq, 768]
|
||||
glob = self._t(global_np) # [1, 1536]
|
||||
t = torch.tensor([float(sigma)], device=self.device, dtype=self.dtype) # RAW sigma, NOT *1000
|
||||
with torch.no_grad(), self._ctx():
|
||||
out = self.module(hs, t, cross_attn_cond=cross, global_embed=glob)
|
||||
return self._n(out) # RAW v-prediction (loop does VDenoiser v->x0)
|
||||
|
||||
|
||||
class OobleckVAE(TorchComponent):
|
||||
"""1-D audio VAE. decode(latent[64,L]) -> waveform[channels, samples]; encode(audio[ch,N]) -> raw
|
||||
latent (NO mean/std normalization — SA's DiT operates directly in the Oobleck latent space; BRINGUP
|
||||
risk D). Exposes ``hop_length`` / ``sampling_rate`` (read off the loaded module)."""
|
||||
|
||||
def __init__(self, module, *, device, dtype):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
self.hop_length = int(getattr(module, "hop_length", 2048))
|
||||
self.sampling_rate = int(getattr(module, "sampling_rate", 44100))
|
||||
|
||||
def decode(self, latent):
|
||||
import torch
|
||||
z = self._t(latent).to(self.dtype) # [1, 64, L]
|
||||
with torch.no_grad(), self._ctx():
|
||||
out = self.module.decode(z)
|
||||
wav = out.sample if hasattr(out, "sample") else out # [1, channels, samples]
|
||||
return _to_numpy(wav.squeeze(0)) # [channels, samples]
|
||||
|
||||
def encode(self, audio):
|
||||
import torch
|
||||
x = self._t(audio).to(self.dtype) # [1, channels, samples]
|
||||
with torch.no_grad(), self._ctx():
|
||||
dist = self.module.encode(x)
|
||||
z = dist.sample() if hasattr(dist, "sample") else dist # raw latent, NO normalization
|
||||
return _to_numpy(z.squeeze(0)) # [64, L]
|
||||
|
||||
|
||||
class StableAudioConditioner(TorchComponent):
|
||||
"""SA multi-conditioner (T5-base @ max_length=128 + duration NumberConditioners). ``encode`` packs the
|
||||
(cross_attn_cond, cross_attn_mask, global_embed) triple into the loop's flat payload via
|
||||
``pack_conditioning``. The DiT consumes a single-batch payload; CFG (null/zero negative cross-attn) is
|
||||
built in the loop.
|
||||
|
||||
BRINGUP: the shared text-encoder maker calls ``TextEncoderLoader`` (a generic T5), not the SA
|
||||
``ConditionerLoader`` that builds ``StableAudioMultiConditioner`` — so on a GPU box this adapter must
|
||||
instead be handed a real ``StableAudioMultiConditioner`` module (e.g. via a recipe-local loader
|
||||
override / a ``conditioner`` component kind). The ``tokenizer`` extra the text-encoder maker passes is
|
||||
accepted and ignored (the conditioner owns its own T5 tokenizer)."""
|
||||
|
||||
def __init__(self, module, tokenizer=None, *, device, dtype):
|
||||
super().__init__(module, device=device, dtype=dtype)
|
||||
# The active sub-conditioner ids (3 for SA-1.0: prompt+seconds_start+seconds_total; 2 for
|
||||
# SA-small: prompt+seconds_total). Read off the module so the variant is self-describing.
|
||||
self.cross_attention_cond_ids = tuple(
|
||||
getattr(module, "cross_attention_cond_ids", ("prompt", "seconds_start", "seconds_total")))
|
||||
|
||||
def encode(self, prompt: str, seconds_start: float = 0.0, seconds_total: float = 10.0) -> np.ndarray:
|
||||
import torch
|
||||
all_vals = {
|
||||
"prompt": prompt or "",
|
||||
"seconds_start": float(seconds_start),
|
||||
"seconds_total": float(seconds_total)
|
||||
}
|
||||
cond_meta = [{k: all_vals[k] for k in self.cross_attention_cond_ids if k in all_vals}]
|
||||
with torch.no_grad(), self._ctx():
|
||||
cond = self.module(cond_meta, self.device)
|
||||
cross, _mask, glob = self.module.get_conditioning_inputs(cond)
|
||||
return pack_conditioning(_to_numpy(cross.squeeze(0)), _to_numpy(glob.squeeze(0)))
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Cosmos-Predict2.5 (text->video) — a flow-match denoiser ported into the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapters via
|
||||
``ComponentSpec.adapter`` (``Cosmos25DiT`` / ``Cosmos25WanVAE`` / ``Cosmos25Reason1Encoder`` in
|
||||
``v2/platform/backends/torch_cosmos25.py``) and a new ``Cosmos25DenoiseLoop`` (Wan's flow-shift sigma
|
||||
schedule + ``FLOW_MATCH_STEP`` solver + ``ClassicCFG``, but the PLAIN per-frame sigma timestep ``[B,T]``
|
||||
Cosmos2.5 needs — NOT Wan's ``sigma*1000``), reusing ``stamp_wan21_checkpoints``. Covers both the 2B and
|
||||
14B sizes (the card is size-agnostic — size resolves from the checkpoint config). Registered in
|
||||
``v2/registry.py`` by the orchestrator from the returned metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.cosmos25.card import build_cosmos25_card
|
||||
from v2.recipes.cosmos25.loop import Cosmos25DenoiseLoop
|
||||
from v2.recipes.cosmos25.program import build_cosmos25_program
|
||||
|
||||
__all__ = ["build_cosmos25_card", "build_cosmos25_program", "Cosmos25DenoiseLoop"]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Cosmos-Predict2.5 ModelCard — text->video (the registered preset; video2world/image2world
|
||||
conditioning is a later capability the loop already threads). Two sizes — 2B
|
||||
(``KyleShao/Cosmos-Predict2.5-2B-Diffusers``) and 14B (``nvidia/Cosmos-Predict2.5-14B``) — share ONE
|
||||
card: the transformer's ``num_attention_heads``/``num_layers`` are resolved from the checkpoint config by
|
||||
the loader, so the card is size-agnostic and only the sampling defaults differ (we use the 2B preset).
|
||||
|
||||
Architecture deltas vs Wan (all GPU-path, declared on the card so the recipe is self-contained):
|
||||
* DiT ``fastvideo.models.dits.cosmos2_5:Cosmos25Transformer3DModel`` — a flow-match velocity DiT whose
|
||||
adapter (``ComponentSpec.adapter`` -> ``Cosmos25DiT``) feeds a PER-FRAME plain-sigma timestep ``[B,T]``
|
||||
(NOT Wan's scalar ``sigma*1000``) + a mandatory zero ``condition_mask`` / ones ``padding_mask`` + an
|
||||
``fps`` scalar. The model concats the masks itself (16 -> 18ch), so we feed the RAW 16ch latent.
|
||||
* VAE ``fastvideo.models.vaes.cosmos25wanvae:Cosmos25WanVAE`` — Wan-style causal 3D VAE (z=16, 8x/4x)
|
||||
whose encode/decode normalize INTERNALLY (the Cosmos latent contract), so a dedicated
|
||||
``Cosmos25WanVAE`` adapter marshals WITHOUT re-normalizing (reusing the v2 WanVAE adapter would
|
||||
double-normalize — see ``torch_cosmos25.py``).
|
||||
* Text ``fastvideo.models.encoders.reason1:Reason1TextEncoder`` — Qwen2.5-VL multimodal LM via
|
||||
``Cosmos25Reason1Encoder`` (per-layer mean-normalized full-concat -> a 100352-dim sequence; the DiT's
|
||||
internal 100352->1024 GELU crossattn projection consumes it).
|
||||
``stamp_wan21_checkpoints`` applies (diffusers transformer/vae/text_encoder subfolder layout + a sibling
|
||||
tokenizer dir). Policies: ``ClassicCFG`` (embedded_cfg_scale=0 -> plain CFG), ``FlowShiftPolicy(shift=5.0)``,
|
||||
fp32 scheduler step. Faithful to ``fastvideo/pipelines/stages/denoising.py:Cosmos25DenoisingStage``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import ClassicCFG, FlowShiftPolicy, NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyDiT, ToyTextEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.cosmos25.loop import Cosmos25DenoiseLoop
|
||||
from v2.recipes.wan21.card import stamp_wan21_checkpoints
|
||||
|
||||
# Cosmos-Predict2.5 default t2v negative prompt (a LOCAL recipe constant — the Cosmos2.5 preset's own
|
||||
# quality-suppression prompt, encoded through the same Reason1 path for ClassicCFG).
|
||||
_COSMOS25_NEG = ("The video captures a series of frames showing ugly scenes, static with no motion, motion blur, "
|
||||
"over-saturation, shaky footage, low resolution, grainy texture, pixelated images, poorly lit areas, "
|
||||
"underexposed and overexposed scenes, poor color balance, washed out colors, choppy sequences, jerky "
|
||||
"movements, low frame rate, artifacting, color banding, unnatural transitions, outdated special "
|
||||
"effects, fake elements, unconvincing visuals, poorly edited content, jump cuts, visual noise, and "
|
||||
"flickering. Overall, the video is of poor quality.")
|
||||
|
||||
_COSMOS25_DIT = "v2.platform.backends.torch_cosmos25:Cosmos25DiT"
|
||||
_COSMOS25_VAE = "v2.platform.backends.torch_cosmos25:Cosmos25WanVAE"
|
||||
_COSMOS25_TEXT = "v2.platform.backends.torch_cosmos25:Cosmos25Reason1Encoder"
|
||||
|
||||
|
||||
def build_cosmos25_card(model_id: str = "cosmos-predict2.5-2b",
|
||||
*,
|
||||
flow_shift: float = 5.0,
|
||||
checkpoint_root: str | None = None,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = ClassicCFG()
|
||||
flow = FlowShiftPolicy(shift=flow_shift)
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer")
|
||||
|
||||
def loop_factory():
|
||||
return Cosmos25DenoiseLoop(loop_id="diffusion_denoise",
|
||||
cfg=cfg,
|
||||
flow_shift=flow,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost)
|
||||
|
||||
components = {
|
||||
"text_encoder":
|
||||
ComponentSpec(component_id="text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.reason1:Reason1TextEncoder",
|
||||
adapter=_COSMOS25_TEXT,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2v"}),
|
||||
"vae":
|
||||
ComponentSpec(component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.cosmos25wanvae:Cosmos25WanVAE",
|
||||
adapter=_COSMOS25_VAE,
|
||||
factory=lambda inst: ToyVAE(),
|
||||
required_for={"t2v"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.cosmos2_5:Cosmos25Transformer3DModel",
|
||||
adapter=_COSMOS25_DIT,
|
||||
factory=lambda inst: ToyDiT(seed=seed),
|
||||
resident_for=["diffusion_denoise"],
|
||||
required_for={"t2v"}),
|
||||
}
|
||||
loops = {
|
||||
"diffusion_denoise":
|
||||
LoopSpec(loop_id="diffusion_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="breakable_cudagraph",
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="cosmos25",
|
||||
components=components,
|
||||
loops=loops,
|
||||
capabilities=CapabilityMatrix.of(Capability.TEXT_TO_VIDEO, Capability.VAE_DECODE, Capability.POLICY_ROLLOUT),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="diffusion_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
sampling_defaults=sampling_defaults or SamplingDefaults(num_steps=35,
|
||||
guidance_scale=7.0,
|
||||
height=704,
|
||||
width=1280,
|
||||
num_frames=77,
|
||||
fps=24,
|
||||
negative_prompt=_COSMOS25_NEG),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_wan21_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Cosmos25DenoiseLoop — a flow-match Euler denoiser with Cosmos2.5's per-frame sigma timestep.
|
||||
|
||||
Cosmos-Predict2.5 IS a rectified-flow / flow-match model (unlike Cosmos-Predict2, which is an EDM
|
||||
denoiser). The sigma schedule and the deterministic Euler solver are EXACTLY Wan's
|
||||
(``FlowShiftPolicy.build_schedule`` + ``FLOW_MATCH_STEP``), and CFG is the standard ``ClassicCFG``
|
||||
(``uncond + s*(cond-uncond)``). So this loop is almost identical to ``WanDenoiseLoop``; it forks it for
|
||||
ONE load-bearing reason (blocker (1)):
|
||||
|
||||
* Wan feeds the DiT a SCALAR ``timestep = sigma * num_train_timesteps`` (``sigma*1000``).
|
||||
* Cosmos2.5 feeds a PER-FRAME 2D ``timestep[B,T]`` of the PLAIN sigma in [0,1] — the fastvideo stage
|
||||
computes ``t * 0.001`` of the 0..1000 schedule, which is precisely the shifted sigma. So we set
|
||||
``st.timesteps = st.sigmas`` (NO *1000) and the ``Cosmos25DiT`` adapter broadcasts that plain sigma
|
||||
to ``[B, T]`` and builds the mandatory zero ``condition_mask`` / ones ``padding_mask`` + ``fps`` the
|
||||
forward requires (the model concats the masks itself — we feed the RAW 16ch latent). See
|
||||
``v2/platform/backends/torch_cosmos25.py:Cosmos25DiT`` and the faithful source
|
||||
``fastvideo/pipelines/stages/denoising.py:Cosmos25DenoisingStage``.
|
||||
|
||||
Verification that the math matches the source: the fastvideo ``FlowMatchEulerDiscreteScheduler``
|
||||
(shift=5.0) builds ``sigma' = shift*sigma/(1+(shift-1)*sigma)`` over a 1->0 base + a terminal 0, sets
|
||||
``timesteps = sigma'*1000``, feeds the DiT ``timesteps*0.001 = sigma'``, and steps
|
||||
``prev = sample + (sigma_next - sigma)*velocity`` — i.e. exactly ``build_flow_sigmas(num_steps,
|
||||
shift=5.0)`` + ``FLOW_MATCH_STEP``.
|
||||
|
||||
video2world / image2world (frame_replace) conditioning (cond_indicator GT-x0 clamp + GT-velocity
|
||||
injection, conditional_frame_timestep=0.1 on conditioned frames) is present in fastvideo
|
||||
(``Cosmos25V2WDenoisingStage``) but is NOT in the registered HF presets — this loop scopes to t2v.
|
||||
The hooks are read from ``ctx.slots`` (None for t2v -> inert), mirroring the Wan/Cosmos2 pattern, so a
|
||||
later capability adds the gated injection without changing the t2v path. (BRINGUP: frame-replace needs a
|
||||
request-API extension to carry the conditioning latents + indicator.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2._enums import ExecutionProfile, WorkUnitKind
|
||||
from v2.loop.contracts import (
|
||||
Done,
|
||||
LoopResult,
|
||||
LoopState,
|
||||
ResourceRequest,
|
||||
ShapeSignature,
|
||||
StepContext,
|
||||
StepResult,
|
||||
WorkPlan,
|
||||
)
|
||||
from v2.platform import FLOW_MATCH_STEP
|
||||
from v2.recipes.wan21.loop import latent_shape
|
||||
|
||||
# Real Cosmos2.5 Wan-style VAE (Cosmos25WanVAE) compression: z_dim=16, 4x temporal, 8x spatial — the
|
||||
# same geometry as Wan2.1, so ``latent_shape`` (reused from the Wan recipe) yields
|
||||
# (16, (F-1)//4 + 1, H//8, W//8) on the real backend.
|
||||
COSMOS25_LATENT_CHANNELS = 16
|
||||
COSMOS25_TEMPORAL_RATIO = 4
|
||||
COSMOS25_SPATIAL_RATIO = 8
|
||||
COSMOS25_DEFAULT_FPS = 24
|
||||
|
||||
|
||||
class Cosmos25DenoiseLoop:
|
||||
"""Flow-match Euler denoise loop with Cosmos2.5's plain per-frame sigma timestep convention."""
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
loop_id,
|
||||
cfg,
|
||||
flow_shift,
|
||||
precision,
|
||||
expert,
|
||||
cost,
|
||||
latent_channels: int = COSMOS25_LATENT_CHANNELS,
|
||||
spatial_ratio: int = COSMOS25_SPATIAL_RATIO,
|
||||
temporal_ratio: int = COSMOS25_TEMPORAL_RATIO):
|
||||
self.loop_id = loop_id
|
||||
self.cfg = cfg
|
||||
self.flow_shift = flow_shift
|
||||
self.precision = precision
|
||||
self.expert = expert
|
||||
self.cost = cost
|
||||
self.latent_channels = latent_channels
|
||||
self.spatial_ratio = spatial_ratio
|
||||
self.temporal_ratio = temporal_ratio
|
||||
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
seed = req.diffusion.seed if req.diffusion.seed is not None else 0
|
||||
rng = np.random.default_rng(seed)
|
||||
# The σ schedule + flow-shift are Wan's verbatim (shift=5.0 for Cosmos2.5): 1 -> 0 over
|
||||
# num_steps+1 points with the flow-shift warp applied.
|
||||
sig = self.flow_shift.build_schedule(req.diffusion.num_steps,
|
||||
req.diffusion.height,
|
||||
req.diffusion.width,
|
||||
sigmas=req.diffusion.sigmas or None)
|
||||
shape = latent_shape(req,
|
||||
model,
|
||||
channels=self.latent_channels,
|
||||
spatial_ratio=self.spatial_ratio,
|
||||
temporal_ratio=self.temporal_ratio)
|
||||
# Flow-match noise init: x = randn * sigma_0 (sigma_0 == 1 for an unshifted-terminal schedule).
|
||||
x = (rng.standard_normal(shape) * float(sig[0])).astype("float32")
|
||||
st = LoopState(
|
||||
loop_id=self.loop_id,
|
||||
instance_id=model.card.model_id,
|
||||
request_id=req.request_id,
|
||||
profile=ctx.profile,
|
||||
rng=rng,
|
||||
seed=seed,
|
||||
latents={"video": x},
|
||||
sigmas=[float(s) for s in sig],
|
||||
# BLOCKER (1): the model timestep is the PLAIN sigma (NOT sigma*1000). The
|
||||
# Cosmos25DiT adapter broadcasts it to the per-frame [B, T] form the forward needs.
|
||||
timesteps=[float(s) for s in sig])
|
||||
st.cond["prompt_embeds"] = ctx.slots.get("text_embeds")
|
||||
st.cond["negative_prompt_embeds"] = ctx.slots.get("neg_text_embeds")
|
||||
st.scratch["guidance_scale"] = float(req.diffusion.guidance_scale)
|
||||
st.scratch["stream_video"] = bool(req.outputs.stream.get("video"))
|
||||
st.scratch["fps"] = int(getattr(req.diffusion, "fps", None) or COSMOS25_DEFAULT_FPS)
|
||||
# video2world (frame_replace) conditioning: None for the t2v preset -> the gated injection a later
|
||||
# capability would add is inert (pure t2v, matching the fastvideo pipeline with no image/video).
|
||||
st.scratch["conditioning_latents"] = ctx.slots.get("conditioning_latents")
|
||||
st.scratch["cond_indicator"] = ctx.slots.get("cond_indicator")
|
||||
st.plugin_state["cfg"] = {}
|
||||
return st
|
||||
|
||||
def next(self, st: LoopState):
|
||||
i = st.step_idx
|
||||
if i >= len(st.sigmas) - 1:
|
||||
return Done()
|
||||
sigma_t, sigma_next = st.sigmas[i], st.sigmas[i + 1]
|
||||
# The model timestep here is the PLAIN sigma (st.timesteps[i] == st.sigmas[i]); the adapter turns
|
||||
# it into the per-frame [B, T] timestep + masks + fps internally.
|
||||
expert_id = self.expert.expert_for(StepContext(i, st.timesteps[i], sigma_t))
|
||||
sctx = StepContext(step_idx=i, timestep=st.timesteps[i], sigma=sigma_t, active_expert_id=expert_id)
|
||||
cfg_state = st.plugin_state["cfg"]
|
||||
branches = self.cfg.branches_this_step(sctx, cfg_state)
|
||||
x = st.latents["video"]
|
||||
pe, ne = st.cond["prompt_embeds"], st.cond["negative_prompt_embeds"]
|
||||
scale = st.scratch["guidance_scale"]
|
||||
cfg, precision = self.cfg, self.precision
|
||||
|
||||
def _velocity(model: Any, x_: Any, sigma_t_: float, pe_: Any, ne_: Any, scale_: float) -> np.ndarray:
|
||||
# The conditioned forward + ClassicCFG combine. The dit-call is toy-compatible:
|
||||
# ``dit(latent, text_embed, sigma)`` — the Cosmos2.5-specific timestep/masks/fps are built
|
||||
# INSIDE the Cosmos25DiT adapter (so the CPU ToyDiT, which ignores them, also runs).
|
||||
dit = model.component(expert_id)
|
||||
preds = {b: dit(x_, pe_ if b == "cond" else ne_, sigma_t_) for b in branches}
|
||||
return precision.cast(cfg.combine(preds, scale_, sctx, cfg_state))
|
||||
|
||||
def run(model, override=None):
|
||||
# The EAGER thunk: handles the override (cached-prediction) path. The deterministic Euler
|
||||
# solver dispatches through the platform kernel table (numpy on CPU, the device kernel on GPU).
|
||||
kernels = model.platform.kernels
|
||||
if override is not None and "noise_pred" in override:
|
||||
velocity = precision.cast(np.asarray(override["noise_pred"], dtype="float32"))
|
||||
else:
|
||||
velocity = _velocity(model, x, sigma_t, pe, ne, scale)
|
||||
x_next = kernels.get(FLOW_MATCH_STEP)(precision.cast(x), velocity, sigma_t, sigma_next)
|
||||
return StepResult(output={
|
||||
"noise_pred": np.asarray(velocity, dtype="float32"),
|
||||
"latents": x_next.astype("float32")
|
||||
})
|
||||
|
||||
def graph_fn(model, ws):
|
||||
# The CAPTURABLE op-structure: reads EVERY per-step input from the static workspace and writes
|
||||
# into the static output buffer, so the captured graph replays correctly with rebound buffers.
|
||||
st_t, st_n = float(ws["sigma_t"]), float(ws["sigma_next"])
|
||||
velocity = _velocity(model, ws["x"], st_t, ws["pe"], ws["ne"], float(ws["scale"]))
|
||||
x_next = model.platform.kernels.get(FLOW_MATCH_STEP)(precision.cast(ws["x"]), velocity, st_t, st_n)
|
||||
np.copyto(ws["out"], x_next.astype("float32"))
|
||||
return StepResult(output={
|
||||
"noise_pred": np.asarray(velocity, dtype="float32"),
|
||||
"latents": np.array(ws["out"], copy=True)
|
||||
})
|
||||
|
||||
cond_bytes = sum(int(np.asarray(e).nbytes) for e in (pe, ne) if e is not None)
|
||||
res = ResourceRequest(compute_seconds=self.cost.predict(int(np.prod(x.shape)), float(len(branches))),
|
||||
resident_bytes=int(x.nbytes) + cond_bytes,
|
||||
peak_activation_bytes=int(x.nbytes))
|
||||
return WorkPlan(
|
||||
loop_id=self.loop_id,
|
||||
instance_id=st.instance_id,
|
||||
kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
shape_sig=ShapeSignature(WorkUnitKind.DIFFUSION_STEP,
|
||||
dims=tuple(x.shape),
|
||||
dtype=precision.compute_dtype,
|
||||
extra=(("cfg", type(cfg).__name__), )),
|
||||
resources=res,
|
||||
payload={
|
||||
"branch": "combined",
|
||||
"step": i
|
||||
},
|
||||
run=run,
|
||||
label=f"cosmos25.denoise.{i}",
|
||||
# The flow-match Euler step is a clean capturable op (no host RNG / data-dependent branch),
|
||||
# like Wan t2v: the branch-set + expert + scheduler-precision discriminate the captured graph.
|
||||
capturable=True,
|
||||
graph_key=(tuple(sorted(branches)), expert_id, precision.scheduler_step_in_fp32),
|
||||
graph_fn=graph_fn,
|
||||
graph_inputs={
|
||||
"x": x,
|
||||
"sigma_t": sigma_t,
|
||||
"sigma_next": sigma_next,
|
||||
"pe": pe,
|
||||
"ne": ne,
|
||||
"scale": scale
|
||||
})
|
||||
|
||||
def advance(self, st: LoopState, result: StepResult) -> LoopState:
|
||||
st.latents["video"] = result.output["latents"]
|
||||
if st.profile == ExecutionProfile.ROLLOUT:
|
||||
st.trajectory.append({
|
||||
"step": st.step_idx,
|
||||
"sigma": st.sigmas[st.step_idx],
|
||||
"velocity": np.asarray(result.output["noise_pred"]).copy(),
|
||||
"latents": np.asarray(st.latents["video"]).copy()
|
||||
})
|
||||
st.step_idx += 1
|
||||
return st
|
||||
|
||||
def finalize(self, st: LoopState) -> LoopResult:
|
||||
return LoopResult(outputs={"latents": st.latents["video"]},
|
||||
metrics={"denoise_steps": float(st.step_idx)},
|
||||
behavior=st.trajectory or None)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Cosmos-Predict2.5 t2v program: text_encode -> diffusion_denoise (flow-match) -> vae_decode.
|
||||
|
||||
Same inline shape as the Wan / Cosmos2 t2v programs — the Cosmos2.5 specifics (per-frame plain-sigma
|
||||
timestep, the mandatory masks + fps, the Reason1 100352-dim embedding) live entirely in
|
||||
``Cosmos25DenoiseLoop`` and the ``torch_cosmos25`` adapters, so the node graph is unchanged. fps is read
|
||||
by the loop from the request (``req.diffusion.fps``, default 24) into scratch and threaded to the DiT
|
||||
adapter. (video2world/image2world conditioning would add a VAE-encode node writing
|
||||
``conditioning_latents``/``cond_indicator`` into slots; the loop already reads them — BRINGUP.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
from v2.recipes.common import text_encode_node_fn as _text_encode
|
||||
|
||||
|
||||
def _vae_decode(instance, slots, request, ctx) -> None:
|
||||
slots["video"] = instance.component("vae").decode(slots["denoise_out"]["latents"])
|
||||
|
||||
|
||||
def build_cosmos25_program() -> Program:
|
||||
return Program(
|
||||
program_id="cosmos25.t2v.inline",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("text_encode", fn=_text_encode, writes=("text_embeds", "neg_text_embeds")),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="diffusion_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("text_embeds", "neg_text_embeds"),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("vae_decode", fn=_vae_decode, reads=("denoise_out", ), writes=("video", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"video": "video",
|
||||
"latents": "denoise_out"
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""FLUX.2 (Black Forest Labs) — dual-stream MMDiT text→image ported into the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapters via
|
||||
``ComponentSpec.adapter`` (``Flux2DiT``/``Flux2VAE``/``Flux2Mistral3Encoder``/``Flux2Qwen3Encoder`` in
|
||||
``v2/platform/backends/torch_flux2.py``) and a NEW ``Flux2DenoiseLoop`` (the BFL empirical-mu flow-match
|
||||
schedule + packed 2×2 latent geometry + single embedded-guidance forward), reusing ``stamp_wan21_checkpoints``
|
||||
for the diffusers transformer/vae/text_encoder subfolder layout. ``build_flux2_card`` is FLUX.2-dev
|
||||
(Mistral3, embedded guidance); ``build_flux2_klein_card`` is the distilled 4-step klein variants (Qwen3).
|
||||
GATED weights → GPU is BRINGUP; the toy factories CPU-verify the recipe end-to-end.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.flux2.card import build_flux2_card, build_flux2_klein_card
|
||||
from v2.recipes.flux2.loop import Flux2DenoiseLoop
|
||||
from v2.recipes.flux2.program import build_flux2_program
|
||||
|
||||
__all__ = ["build_flux2_card", "build_flux2_klein_card", "build_flux2_program", "Flux2DenoiseLoop"]
|
||||
@@ -0,0 +1,155 @@
|
||||
"""FLUX.2 ModelCard(s) — text→image (BFL FLUX.2-dev primary; klein 4B/9B distilled variants).
|
||||
|
||||
Architecture deltas vs Wan (all declared on the card so the recipe is self-contained — the bucket-C
|
||||
pattern: torch adapters via ``ComponentSpec.adapter``, a NEW ``Flux2DenoiseLoop``, ``stamp_wan21_checkpoints``
|
||||
for the diffusers transformer/vae/text_encoder subfolder layout):
|
||||
|
||||
* DiT ``fastvideo.models.dits.flux_2:Flux2Transformer2DModel`` — dual-stream MMDiT (19 double + 38
|
||||
single blocks, inner_dim 3072, 24 heads, N-D RoPE axes (32,32,32,32) θ=2000). The adapter
|
||||
(``Flux2DiT``) builds img_ids/txt_ids RoPE position tensors from the latent shape, passes σ DIRECTLY
|
||||
as the timestep and the embedded guidance RAW (the DiT multiplies both by 1000 internally), wraps the
|
||||
text embed in a 1-element list, and returns the bare velocity. NO classic CFG (embedded guidance).
|
||||
* VAE ``fastvideo.models.vaes.flux2vae:AutoencoderKLFlux2`` — packed 2×2 latents + BatchNorm whitening
|
||||
(NOT the Wan (z−mean)/std). ``Flux2VAE`` decode does BN-denorm (``z·bn_std + bn_mean``) → 2×2 unpatchify
|
||||
(64→16ch, full spatial) → ``module.decode`` → image. (Image-only: T==1, squeezed.)
|
||||
* Text dev=``mistral3:Mistral3ForConditionalGeneration`` (layers 10/20/30, FLUX2 system-message chat
|
||||
template); klein=``qwen3:Qwen3ForCausalLM`` (layers 9/18/27). ``Flux2TextEncoder`` stacks the chosen
|
||||
hidden-state layers into a [seq, 3·hidden=4096] embedding — NOT T5 last_hidden_state.
|
||||
|
||||
GATED weights (``black-forest-labs/FLUX.2-*``) → GPU is BRINGUP; the toy factories CPU-verify the recipe.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import EmbeddedGuidance, NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyDiT, ToyTextEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.flux2.loop import Flux2DenoiseLoop
|
||||
from v2.recipes.wan21.card import stamp_wan21_checkpoints
|
||||
|
||||
# FLUX.2 conditions on a single embedded-guidance forward (no CFG), so there is no negative prompt — kept
|
||||
# as a LOCAL module constant (the recipe is self-contained; nothing is added to the shared _prompts.py).
|
||||
FLUX2_NEG: str | None = None
|
||||
|
||||
_FLUX2_DIT = "v2.platform.backends.torch_flux2:Flux2DiT"
|
||||
_FLUX2_VAE = "v2.platform.backends.torch_flux2:Flux2VAE"
|
||||
_FLUX2_MISTRAL3 = "v2.platform.backends.torch_flux2:Flux2Mistral3Encoder"
|
||||
_FLUX2_QWEN3 = "v2.platform.backends.torch_flux2:Flux2Qwen3Encoder"
|
||||
|
||||
|
||||
def _build_flux2_card(model_id: str, *, text_encoder_load_id: str, text_encoder_adapter: str,
|
||||
checkpoint_root: str | None, sampling_defaults: SamplingDefaults) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = EmbeddedGuidance() # single conditioned forward; no uncond branch
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer")
|
||||
|
||||
def loop_factory():
|
||||
return Flux2DenoiseLoop(loop_id="diffusion_denoise", cfg=cfg, precision=precision, expert=expert, cost=cost)
|
||||
|
||||
components = {
|
||||
"text_encoder":
|
||||
ComponentSpec(component_id="text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id=text_encoder_load_id,
|
||||
adapter=text_encoder_adapter,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2i"}),
|
||||
"vae":
|
||||
ComponentSpec(component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.flux2vae:AutoencoderKLFlux2",
|
||||
adapter=_FLUX2_VAE,
|
||||
factory=lambda inst: ToyVAE(),
|
||||
required_for={"t2i"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.flux_2:Flux2Transformer2DModel",
|
||||
adapter=_FLUX2_DIT,
|
||||
factory=lambda inst: ToyDiT(seed=seed),
|
||||
resident_for=["diffusion_denoise"],
|
||||
required_for={"t2i"}),
|
||||
}
|
||||
loops = {
|
||||
"diffusion_denoise":
|
||||
LoopSpec(loop_id="diffusion_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="breakable_cudagraph",
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="flux2",
|
||||
components=components,
|
||||
loops=loops,
|
||||
capabilities=CapabilityMatrix.of(Capability.TEXT_TO_IMAGE, Capability.VAE_DECODE),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="diffusion_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
sampling_defaults=sampling_defaults,
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_wan21_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
|
||||
|
||||
def build_flux2_card(model_id: str = "flux2-dev", *, checkpoint_root: str | None = None) -> ModelCard:
|
||||
"""FLUX.2-dev (the registered primary): Mistral3 text encoder (layers 10/20/30 via FLUX2 chat
|
||||
template), embedded guidance scale 4.0, 50 steps, 1024×1024. ``embedded_cfg_scale`` rides in
|
||||
``guidance_scale``; ``do_classifier_free_guidance=False`` (no negative prompt)."""
|
||||
return _build_flux2_card(model_id,
|
||||
text_encoder_load_id="fastvideo.models.encoders.mistral3:Mistral3ForConditionalGeneration",
|
||||
text_encoder_adapter=_FLUX2_MISTRAL3,
|
||||
checkpoint_root=checkpoint_root,
|
||||
sampling_defaults=SamplingDefaults(num_steps=50,
|
||||
guidance_scale=4.0,
|
||||
height=1024,
|
||||
width=1024,
|
||||
num_frames=1,
|
||||
fps=1,
|
||||
negative_prompt=FLUX2_NEG))
|
||||
|
||||
|
||||
def build_flux2_klein_card(model_id: str = "flux2-klein-4b", *, checkpoint_root: str | None = None) -> ModelCard:
|
||||
"""FLUX.2-klein (4B/9B, distilled): Qwen3 text encoder (layers 9/18/27), NO guidance embedding
|
||||
(guidance_scale 1.0 -> single forward, no uncond), 4 steps, 1024×1024. The schedule still keys on the
|
||||
same empirical-mu grid; the 4-step bf16 parity is the GPU BRINGUP detail."""
|
||||
return _build_flux2_card(model_id,
|
||||
text_encoder_load_id="fastvideo.models.encoders.qwen3:Qwen3ForCausalLM",
|
||||
text_encoder_adapter=_FLUX2_QWEN3,
|
||||
checkpoint_root=checkpoint_root,
|
||||
sampling_defaults=SamplingDefaults(num_steps=4,
|
||||
guidance_scale=1.0,
|
||||
height=1024,
|
||||
width=1024,
|
||||
num_frames=1,
|
||||
fps=1,
|
||||
negative_prompt=FLUX2_NEG))
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Flux2DenoiseLoop — FLUX.2 dual-stream MMDiT denoise on the BFL empirical-mu flow-match schedule.
|
||||
|
||||
FLUX.2 is a flow-match (velocity-prediction) image model, but it is NOT a Wan-shaped port (see
|
||||
``v2/recipes/flux2/card.py`` for the component-level deltas). The deltas that force a NEW loop, all faithful
|
||||
to ``fastvideo/pipelines/basic/flux_2`` + ``fastvideo/pipelines/stages/denoising.py``:
|
||||
|
||||
* **Schedule** — the σ grid is the BFL empirical-mu grid (``flux2_sigmas`` here: ``linspace(1, 1/N, N)``
|
||||
shifted by ``compute_empirical_mu(image_seq_len, N)`` via the FlowMatchEuler exponential time-shift),
|
||||
NOT ``FlowShiftPolicy.build_schedule``'s resolution-bucket shift. ``image_seq_len`` is the PACKED token
|
||||
count ``latent_h * latent_w``.
|
||||
* **Packed latent geometry** — the DiT operates on ``num_channels_latents=64`` channels at HALF spatial
|
||||
(``latent_h = (H//8)//2``); the VAE decode unpacks the 2×2 patches back to 16ch full-spatial. So the
|
||||
loop initializes a ``(64, T=1, (H//8)//2, (W//8)//2)`` latent (image: ``T==1``).
|
||||
* **Embedded guidance, single forward** — FLUX.2-dev folds a guidance scalar into the DiT forward
|
||||
(``EmbeddedGuidance`` policy: one conditioned branch, no uncond), and FLUX.2-klein uses no guidance at
|
||||
all. There is NO classic 2-branch CFG, so the loop runs a single ``dit(x, pe, σ)`` call per step.
|
||||
* **img_ids / txt_ids RoPE + the ×1000 timestep/guidance split** — these are GPU-path concerns the
|
||||
``Flux2DiT`` torch adapter handles INTERNALLY (it derives img_ids/txt_ids from the latent shape +
|
||||
text-embed length, passes σ DIRECTLY as the timestep, and the embedded guidance raw — the DiT
|
||||
multiplies both by 1000 itself). The loop therefore calls the dit exactly like the CPU ``ToyDiT``
|
||||
(``dit(latent, text_embed, sigma)``), so it CPU-verifies unchanged.
|
||||
|
||||
The velocity is integrated with the shared deterministic flow-match Euler step
|
||||
(``x_next = x + (σ_next − σ)·v`` via ``FLOW_MATCH_STEP``) — identical solver to Wan/LTX-2.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2._enums import ExecutionProfile, WorkUnitKind
|
||||
from v2.loop.contracts import (
|
||||
Done,
|
||||
LoopResult,
|
||||
LoopState,
|
||||
ResourceRequest,
|
||||
ShapeSignature,
|
||||
StepContext,
|
||||
StepResult,
|
||||
WorkPlan,
|
||||
)
|
||||
from v2.platform import FLOW_MATCH_STEP
|
||||
from v2.platform.backends.toy import LATENT_CHANNELS
|
||||
from v2.recipes.flux2.sampler import flux2_sigmas
|
||||
|
||||
# Real FLUX.2 packed geometry: the VAE has latent_channels=16 at 8× spatial compression; the DiT sees
|
||||
# the 2×2-packed latent -> 16·4 = 64 channels at HALF spatial. (config in_channels = num_channels_latents
|
||||
# = 64; the VAE's _unpatchify_latents does C//4 -> 16ch full-spatial on decode.)
|
||||
FLUX2_PACKED_CHANNELS = 64
|
||||
FLUX2_SPATIAL_RATIO = 8 # VAE spatial_compression_ratio
|
||||
FLUX2_PACK = 2 # 2×2 patch packing -> half spatial
|
||||
|
||||
|
||||
def flux2_latent_geometry(req, model=None) -> tuple[tuple[int, int, int, int], int]:
|
||||
"""Packed latent shape + packed image_seq_len. On the GPU backend the DiT/VAE require the true FLUX.2
|
||||
geometry; the CPU toy uses a tiny deterministic stand-in (so the loop control flow is exercised with
|
||||
real numbers). Returns ``((C, T, latent_h, latent_w), image_seq_len)`` where ``image_seq_len`` is the
|
||||
packed token count ``latent_h*latent_w`` the empirical-mu schedule keys on. Image model: ``T == 1``."""
|
||||
d = req.diffusion
|
||||
if model is not None and getattr(getattr(model, "platform", None), "device", "cpu") == "cuda":
|
||||
latent_h = max(1, (d.height // FLUX2_SPATIAL_RATIO) // FLUX2_PACK)
|
||||
latent_w = max(1, (d.width // FLUX2_SPATIAL_RATIO) // FLUX2_PACK)
|
||||
return (FLUX2_PACKED_CHANNELS, 1, latent_h, latent_w), latent_h * latent_w
|
||||
latent_h = max(2, d.height // 240)
|
||||
latent_w = max(2, d.width // 240)
|
||||
return (LATENT_CHANNELS, 1, latent_h, latent_w), latent_h * latent_w
|
||||
|
||||
|
||||
class Flux2DenoiseLoop:
|
||||
"""N flow-match Euler steps over the full packed latent, single conditioned forward per step
|
||||
(embedded guidance for dev, no guidance for klein). Mirrors ``WanDenoiseLoop`` structure; the FLUX.2
|
||||
schedule + packed geometry are the only deltas. All per-request state lives in ``LoopState``
|
||||
(interleave-safe)."""
|
||||
|
||||
def __init__(self, *, loop_id, cfg, precision, expert, cost, packed_channels: int = FLUX2_PACKED_CHANNELS):
|
||||
self.loop_id = loop_id
|
||||
self.cfg = cfg # EmbeddedGuidance (dev) — carried for the WorkPlan op-structure key
|
||||
self.precision = precision
|
||||
self.expert = expert
|
||||
self.cost = cost
|
||||
self.packed_channels = packed_channels
|
||||
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
seed = req.diffusion.seed if req.diffusion.seed is not None else 0
|
||||
rng = np.random.default_rng(seed)
|
||||
shape, image_seq_len = flux2_latent_geometry(req, model)
|
||||
sig = flux2_sigmas(req.diffusion.num_steps, image_seq_len)
|
||||
# FlowMatchEuler init_noise_sigma == 1.0 for this scheduler, so x starts at plain randn.
|
||||
x = rng.standard_normal(shape).astype("float32")
|
||||
st = LoopState(loop_id=self.loop_id,
|
||||
instance_id=model.card.model_id,
|
||||
request_id=req.request_id,
|
||||
profile=ctx.profile,
|
||||
rng=rng,
|
||||
seed=seed,
|
||||
latents={"image": x},
|
||||
sigmas=[float(s) for s in sig],
|
||||
timesteps=[float(s) * 1000.0 for s in sig]) # bookkeeping; the adapter re-derives
|
||||
st.cond["prompt_embeds"] = ctx.slots.get("text_embeds")
|
||||
# embedded-guidance scalar (dev=4.0; klein=None -> no guidance). The Flux2DiT adapter reads it and
|
||||
# folds it into the forward; there is no uncond branch.
|
||||
st.scratch["guidance_scale"] = float(req.diffusion.guidance_scale)
|
||||
st.scratch["stream_image"] = bool(req.outputs.stream.get("image"))
|
||||
st.plugin_state["cfg"] = {}
|
||||
return st
|
||||
|
||||
def next(self, st: LoopState):
|
||||
i = st.step_idx
|
||||
if i >= len(st.sigmas) - 1:
|
||||
return Done()
|
||||
sigma_t, sigma_next = st.sigmas[i], st.sigmas[i + 1]
|
||||
expert_id = self.expert.expert_for(StepContext(i, st.timesteps[i], sigma_t))
|
||||
sctx = StepContext(step_idx=i, timestep=st.timesteps[i], sigma=sigma_t, active_expert_id=expert_id)
|
||||
cfg_state = st.plugin_state["cfg"]
|
||||
branches = self.cfg.branches_this_step(sctx, cfg_state) # ["cond"] for EmbeddedGuidance
|
||||
x = st.latents["image"]
|
||||
pe = st.cond["prompt_embeds"]
|
||||
scale = st.scratch["guidance_scale"]
|
||||
cfg, precision = self.cfg, self.precision
|
||||
|
||||
def _velocity(model: Any, x_: Any, sigma_t_: float, pe_: Any, scale_: float) -> np.ndarray:
|
||||
# Single conditioned forward; EmbeddedGuidance.combine is the identity on the cond branch. The
|
||||
# DiT call is toy-compatible (latent, text_embed, sigma) — img_ids/txt_ids/guidance/×1000 are
|
||||
# built INSIDE the Flux2DiT adapter, so the same loop drives both the CPU toy and the GPU DiT.
|
||||
dit = model.component(expert_id)
|
||||
preds = {b: dit(x_, pe_, sigma_t_) for b in branches}
|
||||
return precision.cast(cfg.combine(preds, scale_, sctx, cfg_state))
|
||||
|
||||
def run(model, override=None):
|
||||
kernels = model.platform.kernels
|
||||
if override is not None and "noise_pred" in override:
|
||||
velocity = precision.cast(np.asarray(override["noise_pred"], dtype="float32"))
|
||||
else:
|
||||
velocity = _velocity(model, x, sigma_t, pe, scale)
|
||||
x_next = kernels.get(FLOW_MATCH_STEP)(precision.cast(x), velocity, sigma_t, sigma_next)
|
||||
return StepResult(output={
|
||||
"noise_pred": np.asarray(velocity, dtype="float32"),
|
||||
"latents": x_next.astype("float32")
|
||||
})
|
||||
|
||||
def graph_fn(model, ws):
|
||||
# Capturable op-structure: reads every per-step input from the static workspace (never closure).
|
||||
st_t, st_n = float(ws["sigma_t"]), float(ws["sigma_next"])
|
||||
velocity = _velocity(model, ws["x"], st_t, ws["pe"], float(ws["scale"]))
|
||||
x_next = model.platform.kernels.get(FLOW_MATCH_STEP)(precision.cast(ws["x"]), velocity, st_t, st_n)
|
||||
np.copyto(ws["out"], x_next.astype("float32"))
|
||||
return StepResult(output={
|
||||
"noise_pred": np.asarray(velocity, dtype="float32"),
|
||||
"latents": np.array(ws["out"], copy=True)
|
||||
})
|
||||
|
||||
cond_bytes = int(np.asarray(pe).nbytes) if pe is not None else 0
|
||||
res = ResourceRequest(compute_seconds=self.cost.predict(int(np.prod(x.shape)), float(len(branches))),
|
||||
resident_bytes=int(x.nbytes) + cond_bytes,
|
||||
peak_activation_bytes=int(x.nbytes))
|
||||
emits = []
|
||||
if st.scratch.get("stream_image"):
|
||||
from v2.request.streams import StreamChunk
|
||||
emits.append(StreamChunk(stream_id=st.request_id, modality="image", seq=i, data=x, preview=True))
|
||||
return WorkPlan(loop_id=self.loop_id,
|
||||
instance_id=st.instance_id,
|
||||
kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
shape_sig=ShapeSignature(WorkUnitKind.DIFFUSION_STEP,
|
||||
dims=tuple(x.shape),
|
||||
dtype=precision.compute_dtype,
|
||||
extra=(("cfg", type(cfg).__name__), )),
|
||||
resources=res,
|
||||
payload={
|
||||
"branch": "embedded",
|
||||
"step": i
|
||||
},
|
||||
run=run,
|
||||
label=f"flux2.denoise.{i}",
|
||||
emits=emits,
|
||||
capturable=True,
|
||||
graph_key=(tuple(sorted(branches)), expert_id, precision.scheduler_step_in_fp32),
|
||||
graph_fn=graph_fn,
|
||||
graph_inputs={
|
||||
"x": x,
|
||||
"sigma_t": sigma_t,
|
||||
"sigma_next": sigma_next,
|
||||
"pe": pe,
|
||||
"scale": scale
|
||||
})
|
||||
|
||||
def advance(self, st: LoopState, result: StepResult) -> LoopState:
|
||||
st.latents["image"] = result.output["latents"]
|
||||
if st.profile == ExecutionProfile.ROLLOUT:
|
||||
st.trajectory.append({
|
||||
"step": st.step_idx,
|
||||
"sigma": st.sigmas[st.step_idx],
|
||||
"velocity": np.asarray(result.output["noise_pred"]).copy(),
|
||||
"latents": np.asarray(st.latents["image"]).copy(),
|
||||
})
|
||||
st.step_idx += 1
|
||||
return st
|
||||
|
||||
def finalize(self, st: LoopState) -> LoopResult:
|
||||
return LoopResult(outputs={"latents": st.latents["image"]},
|
||||
metrics={"denoise_steps": float(st.step_idx)},
|
||||
behavior=st.trajectory or None)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""FLUX.2 t2i program: text_encode → diffusion_denoise → vae_decode → image.
|
||||
|
||||
Same inline shape as the Wan t2v program (text_encode → denoise → vae_decode); the FLUX.2 specifics live
|
||||
entirely in ``Flux2DenoiseLoop`` (BFL empirical-mu schedule + packed geometry + single embedded-guidance
|
||||
forward) and the ``Flux2DiT``/``Flux2VAE``/``Flux2TextEncoder`` adapters. The reads only ``text_embeds``
|
||||
(no ``neg_text_embeds``): FLUX.2-dev uses embedded guidance and klein uses none — there is no CFG uncond
|
||||
branch. The output artifact is an ``image`` (the loop produces a single-frame ``T==1`` packed latent which
|
||||
the VAE unpacks + decodes to pixels).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
from v2.recipes.common import cached_text_encode
|
||||
|
||||
|
||||
def _text_encode(instance, slots, request, ctx) -> None:
|
||||
"""FLUX.2 conditions on a single chat-template multi-layer embedding — no negative prompt
|
||||
(embedded guidance / no CFG). Reuses the content-hash feature cache via ``cached_text_encode``."""
|
||||
slots["text_embeds"] = cached_text_encode(instance, request.prompt())
|
||||
|
||||
|
||||
def _vae_decode(instance, slots, request, ctx) -> None:
|
||||
slots["image"] = instance.component("vae").decode(slots["denoise_out"]["latents"])
|
||||
|
||||
|
||||
def build_flux2_program() -> Program:
|
||||
return Program(
|
||||
program_id="flux2.t2i.inline",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("text_encode", fn=_text_encode, writes=("text_embeds", )),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="diffusion_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("text_embeds", ),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("vae_decode", fn=_vae_decode, reads=("denoise_out", ), writes=("image", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"image": "image",
|
||||
"latents": "denoise_out"
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""FLUX.2 flow-match sigma schedule — the BFL empirical-mu grid (recipe-local; NOT in v2/loop/sampler.py).
|
||||
|
||||
FLUX.2 does NOT use ``FlowShiftPolicy.build_schedule`` (the Wan/LTX resolution-bucket shift). It builds a
|
||||
resolution-dependent ``mu`` from the *packed* image token count and feeds it, together with a custom
|
||||
``sigmas = linspace(1, 1/N, N)`` grid, to diffusers' ``FlowMatchEulerDiscreteScheduler.set_timesteps``
|
||||
under ``use_dynamic_shifting=True``. This module reproduces that scheduler's numpy result exactly so the
|
||||
CPU-toy loop is bit-faithful to the GPU pipeline.
|
||||
|
||||
Faithful to:
|
||||
* ``fastvideo/pipelines/basic/flux_2/flux_2_timestep_preparation.py:compute_empirical_mu`` (the BFL
|
||||
``sampling.compute_empirical_mu``), and the ``sigmas = np.linspace(1.0, 1/N, N)`` set there;
|
||||
* ``fastvideo/models/schedulers/scheduling_flow_match_euler_discrete.py:set_timesteps`` with
|
||||
``use_dynamic_shifting=True`` (-> ``time_shift(mu, 1.0, sigmas)``, exponential ``time_shift_type``)
|
||||
and the appended terminal ``0.0``.
|
||||
|
||||
The exponential time-shift is ``sigma' = exp(mu) / (exp(mu) + (1/sigma - 1)**1.0)`` (the ``sigma=1.0``
|
||||
arg in the scheduler's ``time_shift(mu, 1.0, t)`` call collapses the ``(1/t - 1)**sigma`` exponent to 1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def compute_empirical_mu(image_seq_len: int, num_steps: int) -> float:
|
||||
"""Resolution-dependent ``mu`` for the FLUX.2 flow-match scheduler (BFL official ``compute_empirical_mu``).
|
||||
|
||||
``image_seq_len`` is the PACKED image token count ``latent_h * latent_w`` (half-spatial, see
|
||||
``flux2_latent_geometry``). Faithful copy of ``Flux2TimestepPreparationStage.compute_empirical_mu``.
|
||||
"""
|
||||
a1, b1 = 8.73809524e-05, 1.89833333
|
||||
a2, b2 = 0.00016927, 0.45666666
|
||||
if image_seq_len > 4300:
|
||||
return float(a2 * image_seq_len + b2)
|
||||
m_200 = a2 * image_seq_len + b2
|
||||
m_10 = a1 * image_seq_len + b1
|
||||
a = (m_200 - m_10) / 190.0
|
||||
b = m_200 - 200.0 * a
|
||||
return float(a * num_steps + b)
|
||||
|
||||
|
||||
def _time_shift_exponential(mu: float, sigmas: np.ndarray) -> np.ndarray:
|
||||
"""Diffusers exponential time-shift with ``sigma=1.0`` (the scheduler's ``time_shift(mu, 1.0, t)``)."""
|
||||
return np.exp(mu) / (np.exp(mu) + (1.0 / sigmas - 1.0))
|
||||
|
||||
|
||||
def flux2_sigmas(num_steps: int, image_seq_len: int) -> np.ndarray:
|
||||
"""The FLUX.2 σ schedule: ``num_steps+1`` points (the shifted grid + a terminal ``0.0``).
|
||||
|
||||
1. base grid ``sigmas = linspace(1.0, 1/N, N)`` (the custom grid the pipeline passes);
|
||||
2. resolution-dependent ``mu`` shift ``time_shift(mu, 1.0, sigmas)`` (use_dynamic_shifting=True);
|
||||
3. append the terminal ``0.0`` (FlowMatchEuler's ``invert_sigmas=False`` tail).
|
||||
The loop integrates these pairwise via the deterministic flow-match Euler step (FLOW_MATCH_STEP).
|
||||
"""
|
||||
n = max(1, int(num_steps))
|
||||
base = np.linspace(1.0, 1.0 / n, n, dtype=np.float64)
|
||||
mu = compute_empirical_mu(int(image_seq_len), n)
|
||||
shifted = _time_shift_exponential(mu, base)
|
||||
return np.concatenate([shifted, np.zeros(1, dtype=np.float64)]).astype(np.float64)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""GEN3C-Cosmos-7B — camera-controlled video diffusion (EDM denoiser) ported into the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapters via
|
||||
``ComponentSpec.adapter`` (``Gen3CDiT`` / ``Gen3CVAE`` / ``Gen3CT5Encoder`` in
|
||||
``v2/platform/backends/torch_gen3c.py``) and a new ``Gen3CDenoiseLoop`` (a TRUE EDM /
|
||||
``EDMEulerScheduler`` denoiser: Karras ρ=7 σ schedule, ``c_in/c_skip/c_out`` preconditioning, model
|
||||
timestep ``c_noise = 0.25·log σ``, frame-replace conditioning + pose-zeroed-uncond CFG). The
|
||||
camera/MoGe-depth/3D-cache conditioning that fills the pose buffer is documented as BRINGUP (a pre-loop
|
||||
stage needing a CUDA renderer + request-API extension); the registered card is the degenerate t2v path.
|
||||
The orchestrator adds the registry entry (HF id ``FastVideo/GEN3C-Cosmos-7B-Diffusers``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.gen3c.card import GEN3C_NEG, build_gen3c_card
|
||||
from v2.recipes.gen3c.loop import Gen3CDenoiseLoop
|
||||
from v2.recipes.gen3c.program import build_gen3c_program
|
||||
|
||||
__all__ = ["build_gen3c_card", "build_gen3c_program", "Gen3CDenoiseLoop", "GEN3C_NEG"]
|
||||
@@ -0,0 +1,146 @@
|
||||
"""GEN3C-Cosmos-7B ModelCard — camera-controlled video diffusion (EDM denoiser).
|
||||
|
||||
GEN3C extends Cosmos-Predict2's EDM denoiser with camera/3D-cache conditioning. Architecture deltas vs
|
||||
Cosmos (all declared on the card so the recipe is self-contained):
|
||||
* DiT ``fastvideo.models.dits.gen3c:Gen3CTransformer3DModel`` — an EDM denoiser whose forward concats
|
||||
``[latent(16) | input_mask(1) | pose_buffer(frame_buffer_max·32) | padding_mask(1)] = 82`` channels
|
||||
INTERNALLY from separate kwargs. The adapter (``ComponentSpec.adapter`` -> ``Gen3CDiT``) returns the
|
||||
raw network output and ``Gen3CDenoiseLoop`` does the EDM ``c_in/c_skip/c_out`` → x0 reconstruction +
|
||||
x0-space CFG (pose-zeroed uncond) + frame-replace conditioning + the forced-EDMEulerScheduler σ
|
||||
schedule (Karras ρ=7, σ_max=80 → σ_min=0.0002, terminal 0.0). Model timestep is ``c_noise = 0.25·log
|
||||
σ`` (NOT σ·1000, NOT raw σ).
|
||||
* VAE ``fastvideo.models.vaes.gen3c_tokenizer_vae:AutoencoderKLGen3CTokenizer`` — a JIT-backed
|
||||
tokenizer that handles its OWN latent normalization (passthrough/internal mean_std buffers); the
|
||||
``Gen3CVAE`` adapter does NOT apply an external (z-mean)/std. 8× temporal (121→16 latent frames), 8×
|
||||
spatial.
|
||||
* Text ``fastvideo.models.encoders.t5:T5EncoderModel`` (T5-Large, 1024-dim) via ``Gen3CT5Encoder``
|
||||
(raw last_hidden_state, no Wan zero-pad — same as Cosmos).
|
||||
``stamp_wan21_checkpoints`` applies (diffusers transformer/vae/text_encoder subfolder layout).
|
||||
|
||||
BRINGUP (see ``program.py`` / ``torch_gen3c.py``): the camera-trajectory / MoGe-depth / 3D-cache-render
|
||||
conditioning that fills ``condition_video_pose`` is a separate pre-loop stage needing a CUDA point-cloud
|
||||
renderer + request-API extension (image_path + trajectory fields). The registered card is the degenerate
|
||||
**t2v** path (zero conditioning) — it CPU-verifies and the GPU loop runs end-to-end without the camera
|
||||
stack; with conditioning it becomes image+camera → video. The JIT tokenizer loader seam also needs
|
||||
GPU-box verification (``from_jit_tokenizer`` expects encoder.jit/decoder.jit/mean_std.pt).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import ClassicCFG, NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyDiT, ToyTextEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.gen3c.loop import Gen3CDenoiseLoop
|
||||
from v2.recipes.wan21.card import stamp_wan21_checkpoints
|
||||
|
||||
_GEN3C_DIT = "v2.platform.backends.torch_gen3c:Gen3CDiT"
|
||||
_GEN3C_VAE = "v2.platform.backends.torch_gen3c:Gen3CVAE"
|
||||
_GEN3C_T5 = "v2.platform.backends.torch_gen3c:Gen3CT5Encoder"
|
||||
|
||||
# The GEN3C default negative prompt (kept LOCAL to this recipe — the v2/recipes/_prompts.py shared bank
|
||||
# is off-limits for a self-contained port). Used at guidance_scale > 1 (the official_uncond_at_unity
|
||||
# policy); the default guidance_scale=1.0 + cfg_behavior='legacy' runs NO uncond branch.
|
||||
GEN3C_NEG = ("The video captures a series of frames showing ugly scenes, static with no motion, motion blur, "
|
||||
"over-saturation, shaky footage, low resolution, grainy texture, pixelated images, poorly lit areas, "
|
||||
"underexposed and overexposed scenes, poor color balance, washed out colors, choppy sequences, jerky "
|
||||
"movements, low frame rate, artifacting, color banding, unnatural transitions, outdated special "
|
||||
"effects, fake elements, unconvincing visuals, poorly edited content, jump cuts, visual noise, and "
|
||||
"flickering. Overall, the video is of poor quality.")
|
||||
|
||||
|
||||
def build_gen3c_card(model_id: str = "gen3c-cosmos-7b",
|
||||
*,
|
||||
checkpoint_root: str | None = None,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = ClassicCFG()
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer")
|
||||
|
||||
def loop_factory():
|
||||
return Gen3CDenoiseLoop(loop_id="diffusion_denoise",
|
||||
cfg=cfg,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost,
|
||||
sigma_max=80.0,
|
||||
sigma_min=0.0002,
|
||||
sigma_data=0.5,
|
||||
sigma_conditional=0.001)
|
||||
|
||||
components = {
|
||||
"text_encoder":
|
||||
ComponentSpec(component_id="text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.t5:T5EncoderModel",
|
||||
adapter=_GEN3C_T5,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2v"}),
|
||||
"vae":
|
||||
ComponentSpec(component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.gen3c_tokenizer_vae:AutoencoderKLGen3CTokenizer",
|
||||
adapter=_GEN3C_VAE,
|
||||
factory=lambda inst: ToyVAE(),
|
||||
required_for={"t2v"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.gen3c:Gen3CTransformer3DModel",
|
||||
adapter=_GEN3C_DIT,
|
||||
factory=lambda inst: ToyDiT(seed=seed),
|
||||
resident_for=["diffusion_denoise"],
|
||||
required_for={"t2v"}),
|
||||
}
|
||||
loops = {
|
||||
"diffusion_denoise":
|
||||
LoopSpec(loop_id="diffusion_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="breakable_cudagraph",
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="gen3c",
|
||||
components=components,
|
||||
loops=loops,
|
||||
capabilities=CapabilityMatrix.of(Capability.TEXT_TO_VIDEO, Capability.VAE_DECODE, Capability.POLICY_ROLLOUT),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="diffusion_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
# GEN3C defaults (the FORCED EDMEulerScheduler config + the registered preset): 35 steps,
|
||||
# guidance 1.0 (legacy cfg -> no uncond branch), 704x1280, 121 frames @ 24fps.
|
||||
sampling_defaults=sampling_defaults or SamplingDefaults(
|
||||
num_steps=35, guidance_scale=1.0, height=704, width=1280, num_frames=121, fps=24,
|
||||
negative_prompt=GEN3C_NEG),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_wan21_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Gen3CDenoiseLoop — a TRUE EDM (EDMEulerScheduler) denoiser with camera/3D-cache conditioning hooks.
|
||||
|
||||
GEN3C is Cosmos-Predict2's EDM denoiser extended with camera-controlled video conditioning. Unlike the
|
||||
Cosmos recipe (which folds EDM preconditioning into a *Karras* schedule), GEN3C's pipeline FORCES a
|
||||
diffusers ``EDMEulerScheduler(sigma_max=80, sigma_min=0.0002, sigma_data=0.5)`` at runtime
|
||||
(``Gen3CPipeline.initialize_pipeline``), so this loop reproduces that scheduler's exact σ schedule and
|
||||
step math (see ``build_edm_euler_sigmas`` in ``sampler.py``):
|
||||
|
||||
* σ schedule: ``EDMEulerScheduler``'s defaults are ``sigma_schedule='karras'`` (ρ=7) and
|
||||
``final_sigmas_type='zero'`` — a Karras ρ ramp ``σ_max → σ_min`` then a single appended terminal
|
||||
``0.0`` (verified bit-identical to the diffusers scheduler); the loop integrates the ``num_steps+1``
|
||||
points pairwise. Latent init = ``randn·√(σ_max²+σ_data²)`` (``init_noise_sigma``).
|
||||
* per step ``i`` (σ = ``sigmas[i]``, gamma=0 since s_churn=0 → ``σ_hat = σ``):
|
||||
- frame-replace BEFORE the forward (``_augment_noise_with_latent``): augment the conditioning
|
||||
latent with ``N(0,1)·σ_cond``, EDM-precondition, reverse ``c_in`` to put it back in the
|
||||
unscaled latent frame, and write it into ``x`` at ``cond_indicator`` positions;
|
||||
- ``model_input = x·c_in`` (``scale_model_input``); ``c_in = 1/√(σ²+σ_data²)``;
|
||||
- DiT(model_input, text, σ, cond=<pose bundle>) → raw output; the adapter embeds the model
|
||||
timestep ``c_noise = 0.25·log σ`` and concats the conditioning kwargs INTERNALLY (82 ch);
|
||||
- CFG (when enabled): cond branch with the REAL pose buffer; uncond branch with a ZEROED pose
|
||||
buffer + neg text; ``pred = cond + scale·(cond − uncond)``. NOTE default guidance_scale=1.0 +
|
||||
``cfg_behavior='legacy'`` ⇒ NO uncond branch by default;
|
||||
- ``x0 = c_skip·x + c_out·pred`` (``precondition_outputs``); ``c_skip = σ_data²/(σ²+σ_data²)``,
|
||||
``c_out = σ·σ_data/√(σ²+σ_data²)``;
|
||||
- frame-replace on the OUTPUT: ``x0 = ind·cond_latent + (1−ind)·x0`` (anchors the first frame);
|
||||
- Euler: ``v = (x − x0)/σ``; ``x_next = x + (σ_next − σ)·v`` (the platform ``FLOW_MATCH_STEP``,
|
||||
identical to ``EDMEulerScheduler.step``'s ODE-derivative update).
|
||||
|
||||
Camera / MoGe-depth / 3D-cache conditioning fills ``condition_video_pose`` / ``conditioning_latents`` /
|
||||
``cond_indicator`` from program slots; ALL are ``None`` for the registered degenerate **t2v** path → the
|
||||
frame-replace branches are inert and the DiT's internal pose concat is zeroed, exactly as the fastvideo
|
||||
pipeline degrades when no image/camera is given. Faithful port of ``Gen3CDenoisingStage.forward`` +
|
||||
``Gen3CLatentPreparationStage`` (fastvideo/pipelines/stages/gen3c_stages.py).
|
||||
|
||||
The DiT call is ``dit(model_input, text_embed, sigma, cond=bundle)`` — compatible with ``ToyDiT`` (which
|
||||
accepts and ignores ``cond``), so the loop CPU-verifies on the toy backend.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2._enums import ExecutionProfile, WorkUnitKind
|
||||
from v2.loop.contracts import (
|
||||
Done,
|
||||
LoopResult,
|
||||
LoopState,
|
||||
ResourceRequest,
|
||||
ShapeSignature,
|
||||
StepContext,
|
||||
StepResult,
|
||||
WorkPlan,
|
||||
)
|
||||
from v2.platform import FLOW_MATCH_STEP
|
||||
from v2.recipes.gen3c.sampler import build_edm_euler_sigmas, edm_init_sigma
|
||||
from v2.recipes.wan21.loop import latent_shape
|
||||
|
||||
GEN3C_LATENT_CHANNELS = 16 # config.in_channels (VAE latent); the 1+pose+1 channels are added in the DiT
|
||||
GEN3C_TEMPORAL_RATIO = 8 # AutoencoderKLGen3CTokenizer target temporal compression (121 -> 16 latent frames)
|
||||
GEN3C_SPATIAL_RATIO = 8
|
||||
|
||||
|
||||
class Gen3CDenoiseLoop:
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
loop_id,
|
||||
cfg,
|
||||
precision,
|
||||
expert,
|
||||
cost,
|
||||
sigma_max: float = 80.0,
|
||||
sigma_min: float = 0.0002,
|
||||
sigma_data: float = 0.5,
|
||||
sigma_conditional: float = 0.001,
|
||||
latent_channels: int = GEN3C_LATENT_CHANNELS,
|
||||
spatial_ratio: int = GEN3C_SPATIAL_RATIO,
|
||||
temporal_ratio: int = GEN3C_TEMPORAL_RATIO):
|
||||
self.loop_id = loop_id
|
||||
self.cfg = cfg # carried for the WorkPlan op-structure key; x0-space CFG is done here
|
||||
self.precision = precision
|
||||
self.expert = expert
|
||||
self.cost = cost
|
||||
self.sigma_max = sigma_max
|
||||
self.sigma_min = sigma_min
|
||||
self.sigma_data = sigma_data
|
||||
self.sigma_conditional = sigma_conditional
|
||||
self.latent_channels = latent_channels
|
||||
self.spatial_ratio = spatial_ratio
|
||||
self.temporal_ratio = temporal_ratio
|
||||
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
seed = req.diffusion.seed if req.diffusion.seed is not None else 0
|
||||
rng = np.random.default_rng(seed)
|
||||
sig = build_edm_euler_sigmas(req.diffusion.num_steps, sigma_max=self.sigma_max, sigma_min=self.sigma_min)
|
||||
shape = latent_shape(req,
|
||||
model,
|
||||
channels=self.latent_channels,
|
||||
spatial_ratio=self.spatial_ratio,
|
||||
temporal_ratio=self.temporal_ratio)
|
||||
# EDM latent init: randn · init_noise_sigma where init_noise_sigma = sqrt(σ_max² + σ_data²).
|
||||
x = (rng.standard_normal(shape) * edm_init_sigma(self.sigma_max, self.sigma_data)).astype("float32")
|
||||
st = LoopState(loop_id=self.loop_id,
|
||||
instance_id=model.card.model_id,
|
||||
request_id=req.request_id,
|
||||
profile=ctx.profile,
|
||||
rng=rng,
|
||||
seed=seed,
|
||||
latents={"video": x},
|
||||
sigmas=[float(s) for s in sig],
|
||||
timesteps=[float(s) for s in sig]) # the adapter derives c_noise = 0.25·log σ from σ
|
||||
st.cond["prompt_embeds"] = ctx.slots.get("text_embeds")
|
||||
st.cond["negative_prompt_embeds"] = ctx.slots.get("neg_text_embeds")
|
||||
st.scratch["guidance_scale"] = float(req.diffusion.guidance_scale)
|
||||
st.scratch["stream_video"] = bool(req.outputs.stream.get("video"))
|
||||
# Camera / 3D-cache conditioning written by the conditioning program node. ALL None for the
|
||||
# degenerate t2v path → the frame-replace branches below are inert and the DiT's pose concat is
|
||||
# zeroed (matching the fastvideo pipeline when no image/camera is given).
|
||||
st.scratch["conditioning_latents"] = ctx.slots.get("conditioning_latents")
|
||||
st.scratch["cond_indicator"] = ctx.slots.get("cond_indicator")
|
||||
st.scratch["uncond_indicator"] = ctx.slots.get("uncond_indicator")
|
||||
st.scratch["condition_video_pose"] = ctx.slots.get("condition_video_pose")
|
||||
st.scratch["condition_video_input_mask"] = ctx.slots.get("condition_video_input_mask")
|
||||
# Pixel H,W for the DiT's padding mask (resized internally). Carried so the adapter builds it.
|
||||
st.scratch["pixel_height"] = int(req.diffusion.height)
|
||||
st.scratch["pixel_width"] = int(req.diffusion.width)
|
||||
st.plugin_state["cfg"] = {}
|
||||
return st
|
||||
|
||||
def next(self, st: LoopState):
|
||||
i = st.step_idx
|
||||
if i >= len(st.sigmas) - 1:
|
||||
return Done()
|
||||
sigma_t, sigma_next = st.sigmas[i], st.sigmas[i + 1]
|
||||
t = st.timesteps[i] # the raw σ; the adapter maps σ → c_noise = 0.25·log σ
|
||||
expert_id = self.expert.expert_for(StepContext(i, t, sigma_t))
|
||||
x = st.latents["video"]
|
||||
pe, ne = st.cond["prompt_embeds"], st.cond["negative_prompt_embeds"]
|
||||
scale = st.scratch["guidance_scale"]
|
||||
precision = self.precision
|
||||
sd = float(self.sigma_data)
|
||||
do_cfg = scale != 1.0 and ne is not None
|
||||
cond_lat = st.scratch.get("conditioning_latents")
|
||||
cond_ind = st.scratch.get("cond_indicator")
|
||||
uncond_ind = st.scratch.get("uncond_indicator")
|
||||
aug = float(self.sigma_conditional)
|
||||
pose = st.scratch.get("condition_video_pose")
|
||||
input_mask = st.scratch.get("condition_video_input_mask")
|
||||
pix_h, pix_w = st.scratch.get("pixel_height"), st.scratch.get("pixel_width")
|
||||
|
||||
def _bundle(pose_buf: Any) -> dict | None:
|
||||
# The GEN3C conditioning bundle the adapter concats internally (None for pure t2v -> the DiT
|
||||
# builds zeros). ``pose_buf`` is zeroed on the CFG uncond branch.
|
||||
if pose is None and input_mask is None:
|
||||
return None
|
||||
return {
|
||||
"condition_video_pose": pose_buf,
|
||||
"condition_video_input_mask": input_mask,
|
||||
"condition_video_augment_sigma": aug,
|
||||
"height": pix_h,
|
||||
"width": pix_w,
|
||||
}
|
||||
|
||||
def _x0(dit: Any, latent_in: np.ndarray, text_embed: Any, ind: Any, pose_buf: Any) -> np.ndarray:
|
||||
# EDM preconditioning + (gated) frame-replace conditioning, returning the x0 prediction for one
|
||||
# CFG branch. ``ind`` is the (un)cond indicator; None for t2v -> no frame injection.
|
||||
s = float(sigma_t)
|
||||
c_in = 1.0 / (s**2 + sd**2)**0.5
|
||||
c_skip = sd**2 / (s**2 + sd**2)
|
||||
c_out = s * sd / (s**2 + sd**2)**0.5
|
||||
# active_indicator is zeroed once σ drops to/below the conditioning-augment σ (the fastvideo
|
||||
# ``_augment_noise_with_latent`` guard: ``aug >= σ`` -> stop replacing).
|
||||
cur_ind = (ind * 0 if (ind is not None and aug >= s) else ind)
|
||||
lat = np.array(latent_in, dtype="float32")
|
||||
if cur_ind is not None and cond_lat is not None: # frame-replace on the INPUT (inert for t2v)
|
||||
c_in_aug = 1.0 / (aug**2 + sd**2)**0.5
|
||||
cn = st.rng.standard_normal(lat.shape).astype("float32")
|
||||
# augment_latent = (cond + noise·aug); precondition (·c_in_aug); reverse the step's c_in.
|
||||
cf = (cond_lat + cn * aug) * c_in_aug / c_in
|
||||
lat = cur_ind * cf + (1 - cur_ind) * lat
|
||||
model_input = (lat * c_in).astype("float32") # scale_model_input
|
||||
np_pred = np.asarray(dit(model_input, text_embed, s, cond=_bundle(pose_buf)), dtype="float32")
|
||||
x0 = c_skip * x + c_out * np_pred # precondition_outputs (x_t is the UNSCALED x)
|
||||
if cur_ind is not None and cond_lat is not None: # frame-replace on the OUTPUT (anchor frame-0)
|
||||
x0 = cur_ind * cond_lat + (1 - cur_ind) * x0
|
||||
return x0
|
||||
|
||||
def run(model, override=None):
|
||||
dit = model.component(expert_id)
|
||||
if override is not None and "noise_pred" in override:
|
||||
velocity = precision.cast(np.asarray(override["noise_pred"], dtype="float32"))
|
||||
else:
|
||||
cond_x0 = _x0(dit, x, pe, cond_ind, pose)
|
||||
if do_cfg:
|
||||
# CFG zeros the POSE buffer (not just text) on the uncond branch.
|
||||
uncond_pose = (np.zeros_like(pose) if pose is not None else None)
|
||||
uncond_x0 = _x0(dit, x, ne, uncond_ind, uncond_pose)
|
||||
final_x0 = cond_x0 + scale * (cond_x0 - uncond_x0)
|
||||
else:
|
||||
final_x0 = cond_x0
|
||||
velocity = precision.cast((x - final_x0) / max(float(sigma_t), 1e-6))
|
||||
x_next = model.platform.kernels.get(FLOW_MATCH_STEP)(precision.cast(x), velocity, sigma_t, sigma_next)
|
||||
return StepResult(output={
|
||||
"noise_pred": np.asarray(velocity, dtype="float32"),
|
||||
"latents": x_next.astype("float32")
|
||||
})
|
||||
|
||||
cond_bytes = sum(int(np.asarray(e).nbytes) for e in (pe, ne) if e is not None)
|
||||
res = ResourceRequest(compute_seconds=self.cost.predict(int(np.prod(x.shape)), 2.0 if do_cfg else 1.0),
|
||||
resident_bytes=int(x.nbytes) + cond_bytes,
|
||||
peak_activation_bytes=int(x.nbytes))
|
||||
return WorkPlan(loop_id=self.loop_id,
|
||||
instance_id=st.instance_id,
|
||||
kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
shape_sig=ShapeSignature(WorkUnitKind.DIFFUSION_STEP,
|
||||
dims=tuple(x.shape),
|
||||
dtype=precision.compute_dtype,
|
||||
extra=(("cfg", type(self.cfg).__name__), ("edm", True))),
|
||||
resources=res,
|
||||
payload={
|
||||
"branch": "edm",
|
||||
"step": i
|
||||
},
|
||||
run=run,
|
||||
label=f"gen3c.denoise.{i}",
|
||||
capturable=False) # EDM x0-space CFG + (gated) host-RNG frame injection -> eager path
|
||||
|
||||
def advance(self, st: LoopState, result: StepResult) -> LoopState:
|
||||
st.latents["video"] = result.output["latents"]
|
||||
if st.profile == ExecutionProfile.ROLLOUT:
|
||||
st.trajectory.append({
|
||||
"step": st.step_idx,
|
||||
"sigma": st.sigmas[st.step_idx],
|
||||
"velocity": np.asarray(result.output["noise_pred"]).copy(),
|
||||
"latents": np.asarray(st.latents["video"]).copy()
|
||||
})
|
||||
st.step_idx += 1
|
||||
return st
|
||||
|
||||
def finalize(self, st: LoopState) -> LoopResult:
|
||||
return LoopResult(outputs={"latents": st.latents["video"]},
|
||||
metrics={"denoise_steps": float(st.step_idx)},
|
||||
behavior=st.trajectory or None)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""GEN3C program: text_encode → diffusion_denoise (EDM) → vae_decode.
|
||||
|
||||
Same inline shape as the Cosmos t2v program — the EDM specifics + the camera/3D-cache frame-replace
|
||||
conditioning live entirely in ``Gen3CDenoiseLoop`` and the ``Gen3CDiT`` adapter, so the registered
|
||||
(degenerate t2v) node graph is unchanged.
|
||||
|
||||
BRINGUP — the camera-conditioning pre-stage (the GEN3C innovation, no v2 equivalent yet): a node placed
|
||||
BEFORE ``denoise`` would (porting ``Gen3CConditioningStage`` + ``Gen3CLatentPreparationStage``):
|
||||
1. load the input image; predict metric depth via MoGe (``Ruicheng/moge-vitl``);
|
||||
2. build a ``Cache3DBuffer`` point cloud; ``generate_camera_trajectory(trajectory_type,
|
||||
movement_distance, camera_rotation)``; ``cache.render_cache(...)`` -> warped frames + masks;
|
||||
3. VAE-encode the warps into ``condition_video_pose`` [frame_buffer_max·32, T_lat, H_lat, W_lat], and
|
||||
VAE-encode the input image into ``conditioning_latents`` (frame-0 anchor); build
|
||||
``condition_video_input_mask`` / ``cond_indicator`` (frame-0 = 1).
|
||||
The loop already READS those slots (``conditioning_latents`` / ``cond_indicator`` / ``condition_video_pose``
|
||||
/ ``condition_video_input_mask``); writing them needs (a) a CUDA point-cloud rasterizer dependency and
|
||||
(b) a request-API extension carrying ``image_path`` + the camera-trajectory fields. Until then the slots
|
||||
are unset (None) and the loop runs pure t2v (the DiT's internal pose concat is zeroed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
from v2.recipes.common import text_encode_node_fn as _text_encode
|
||||
|
||||
|
||||
def _vae_decode(instance, slots, request, ctx) -> None:
|
||||
slots["video"] = instance.component("vae").decode(slots["denoise_out"]["latents"])
|
||||
|
||||
|
||||
def build_gen3c_program() -> Program:
|
||||
return Program(
|
||||
program_id="gen3c.t2v.inline",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("text_encode", fn=_text_encode, writes=("text_embeds", "neg_text_embeds")),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="diffusion_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("text_embeds", "neg_text_embeds"),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("vae_decode", fn=_vae_decode, reads=("denoise_out", ), writes=("video", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"video": "video",
|
||||
"latents": "denoise_out"
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""GEN3C EDM sampler helpers (kept local to the recipe — NOT in ``v2/loop/sampler.py``).
|
||||
|
||||
GEN3C's pipeline FORCES a diffusers ``EDMEulerScheduler(sigma_max=80, sigma_min=0.0002, sigma_data=0.5)``
|
||||
at runtime (``Gen3CPipeline.initialize_pipeline``), overriding whatever the converted model_index points
|
||||
at. That scheduler's defaults are ``sigma_schedule='karras'`` (ρ=7) and ``final_sigmas_type='zero'``, so:
|
||||
|
||||
* the ramp is the Karras ρ-interpolation ``σ_max → σ_min`` (verified bit-identical to
|
||||
``v2.loop.sampler.build_karras_sigmas`` on the ramp), and
|
||||
* the appended terminal σ is **0.0** (``final_sigmas_type='zero'``), NOT ``σ_min``. This is the one
|
||||
difference from the Cosmos recipe (Cosmos clamps the terminal to σ_min); GEN3C's last Euler step
|
||||
integrates from ``σ[-2]=σ_min`` to ``0.0`` (the velocity still divides by ``σ[-2]>0``, so no NaN).
|
||||
|
||||
``edm_init_sigma`` is the EDMEulerScheduler ``init_noise_sigma = √(σ_max² + σ_data²)`` used to scale the
|
||||
initial Gaussian latent (``Gen3CLatentPreparationStage`` multiplies ``randn`` by it).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def build_edm_euler_sigmas(num_steps: int,
|
||||
sigma_max: float = 80.0,
|
||||
sigma_min: float = 0.0002,
|
||||
rho: float = 7.0) -> np.ndarray:
|
||||
"""The diffusers ``EDMEulerScheduler`` σ schedule for GEN3C: a length-``num_steps`` Karras ρ ramp
|
||||
``σ_max → σ_min`` then a single appended terminal ``0.0`` (``final_sigmas_type='zero'``) — giving
|
||||
``num_steps+1`` points the EDM loop integrates pairwise."""
|
||||
ramp = np.linspace(0.0, 1.0, num_steps, dtype=np.float64)
|
||||
min_inv_rho = sigma_min**(1.0 / rho)
|
||||
max_inv_rho = sigma_max**(1.0 / rho)
|
||||
sigmas = (max_inv_rho + ramp * (min_inv_rho - max_inv_rho))**rho # σ_max -> σ_min (Karras ρ)
|
||||
return np.concatenate([sigmas, np.zeros(1)]).astype(np.float64) # + the 0.0 terminal (final='zero')
|
||||
|
||||
|
||||
def edm_init_sigma(sigma_max: float = 80.0, sigma_data: float = 0.5) -> float:
|
||||
"""``EDMEulerScheduler.init_noise_sigma = √(σ_max² + σ_data²)`` — the initial-latent noise scale."""
|
||||
return float((sigma_max**2 + sigma_data**2)**0.5)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""HunyuanVideo — rectified flow-match video DiT ported into the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapters via
|
||||
``ComponentSpec.adapter`` (``HunyuanVideoDiT`` / ``HunyuanVideoVAE`` / ``HunyuanVideoLlamaEncoder`` /
|
||||
``HunyuanVideoCLIPEncoder`` in ``v2/platform/backends/torch_hunyuan_video.py``) and reuses the canonical
|
||||
``WanDenoiseLoop`` flow-match math via a thin ``HunyuanDenoiseLoop`` (which only threads the CLIP-pooled
|
||||
vector through Wan's ``context=`` channel). The new work vs Wan is the dual text-encoder marshalling
|
||||
(LLaMA per-token sequence + CLIP pooled global vector packed into the DiT's 2-element
|
||||
``encoder_hidden_states``) and the Hunyuan VAE scalar-``scaling_factor`` normalization. Reuses
|
||||
``stamp_wan21_checkpoints``. Registered in ``v2/registry.py`` by the orchestrator.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.hunyuan_video.card import build_fast_hunyuan_video_card, build_hunyuan_video_card
|
||||
from v2.recipes.hunyuan_video.loop import HunyuanDenoiseLoop
|
||||
from v2.recipes.hunyuan_video.program import build_hunyuan_video_program
|
||||
|
||||
__all__ = [
|
||||
"build_hunyuan_video_card",
|
||||
"build_fast_hunyuan_video_card",
|
||||
"build_hunyuan_video_program",
|
||||
"HunyuanDenoiseLoop",
|
||||
]
|
||||
@@ -0,0 +1,172 @@
|
||||
"""HunyuanVideo ModelCard — text->video (the registered preset). Flow-match rectified-flow video DiT.
|
||||
|
||||
Architecture deltas vs Wan (all declared on the card so the recipe is self-contained):
|
||||
* DiT ``fastvideo.models.dits.hunyuanvideo:HunyuanVideoTransformer3DModel`` — velocity flow-match (like
|
||||
Wan), but ``encoder_hidden_states`` is a 2-element list ``[llama_hidden[B,L,4096], clip_pooled[B,768]]``.
|
||||
The adapter (``ComponentSpec.adapter`` -> ``HunyuanVideoDiT``) assembles that pair internally; the loop
|
||||
reuses ``WanDenoiseLoop`` math verbatim (timestep ``sigma*1000``, scalar per-batch, bare velocity out).
|
||||
* VAE ``fastvideo.models.vaes.hunyuanvae:AutoencoderKLHunyuanVideo`` — scalar ``scaling_factor`` (0.476986)
|
||||
normalization, NOT Wan's per-channel mean/std -> the ``HunyuanVideoVAE`` adapter.
|
||||
* Text DUAL encoder: ``fastvideo.models.encoders.llama:LlamaModel`` (per-token sequence; prompt template +
|
||||
hidden-state-skip + crop) via ``HunyuanVideoLlamaEncoder``, and
|
||||
``fastvideo.models.encoders.clip:CLIPTextModel`` (768-dim pooled) via ``HunyuanVideoCLIPEncoder``.
|
||||
|
||||
``stamp_wan21_checkpoints`` applies (diffusers transformer/vae/text_encoder subfolder layout); HunyuanVideo
|
||||
additionally ships ``text_encoder_2/`` (CLIP) + ``tokenizer_2/`` — the stamp helper points each declared
|
||||
component (incl. ``text_encoder_2``) at its subfolder (see the BRINGUP note in ``card.py`` and the CLIP
|
||||
adapter for the ``tokenizer_2`` wiring caveat).
|
||||
|
||||
Defaults (fastvideo HUNYUAN_T2V preset): 50 steps, guidance_scale 1.0 (CFG effectively off),
|
||||
720x1280, 125 frames @ 24fps, flow_shift 7.0. A FastHunyuan variant ships shift 17, 6 steps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import ClassicCFG, FlowShiftPolicy, NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyDiT, ToyTextEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.hunyuan_video.loop import HunyuanDenoiseLoop
|
||||
from v2.recipes.wan21.card import stamp_wan21_checkpoints
|
||||
|
||||
_HUNYUAN_DIT = "v2.platform.backends.torch_hunyuan_video:HunyuanVideoDiT"
|
||||
_HUNYUAN_VAE = "v2.platform.backends.torch_hunyuan_video:HunyuanVideoVAE"
|
||||
_HUNYUAN_LLAMA = "v2.platform.backends.torch_hunyuan_video:HunyuanVideoLlamaEncoder"
|
||||
_HUNYUAN_CLIP = "v2.platform.backends.torch_hunyuan_video:HunyuanVideoCLIPEncoder"
|
||||
|
||||
# Base HunyuanVideo runs guidance_scale=1.0 (CFG effectively off), so it has no published negative prompt;
|
||||
# a LOCAL module constant so the recipe owns its own data (empty string = no negative conditioning).
|
||||
HUNYUAN_NEG = ""
|
||||
|
||||
|
||||
def build_hunyuan_video_card(model_id: str = "hunyuanvideo",
|
||||
*,
|
||||
flow_shift: float = 7.0,
|
||||
num_steps: int = 50,
|
||||
checkpoint_root: str | None = None,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = ClassicCFG()
|
||||
# No resolution-bucket lookup: HunyuanVideo uses a single flow_shift (7.0 base / 17.0 FastHunyuan).
|
||||
flow = FlowShiftPolicy(shift=flow_shift)
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer")
|
||||
|
||||
def loop_factory() -> HunyuanDenoiseLoop:
|
||||
return HunyuanDenoiseLoop(loop_id="diffusion_denoise",
|
||||
cfg=cfg,
|
||||
flow_shift=flow,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost) # 16/8/4 default geometry
|
||||
|
||||
components = {
|
||||
"text_encoder":
|
||||
ComponentSpec(component_id="text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.llama:LlamaModel",
|
||||
adapter=_HUNYUAN_LLAMA,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2v"}),
|
||||
"text_encoder_2":
|
||||
ComponentSpec(component_id="text_encoder_2",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.clip:CLIPTextModel",
|
||||
adapter=_HUNYUAN_CLIP,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2v"}),
|
||||
"vae":
|
||||
ComponentSpec(component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.hunyuanvae:AutoencoderKLHunyuanVideo",
|
||||
adapter=_HUNYUAN_VAE,
|
||||
factory=lambda inst: ToyVAE(),
|
||||
required_for={"t2v"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.hunyuanvideo:HunyuanVideoTransformer3DModel",
|
||||
adapter=_HUNYUAN_DIT,
|
||||
factory=lambda inst: ToyDiT(seed=seed),
|
||||
resident_for=["diffusion_denoise"],
|
||||
required_for={"t2v"}),
|
||||
}
|
||||
loops = {
|
||||
"diffusion_denoise":
|
||||
LoopSpec(loop_id="diffusion_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="breakable_cudagraph",
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="hunyuan",
|
||||
components=components,
|
||||
loops=loops,
|
||||
capabilities=CapabilityMatrix.of(Capability.TEXT_TO_VIDEO, Capability.VAE_DECODE, Capability.POLICY_ROLLOUT),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="diffusion_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
sampling_defaults=sampling_defaults or SamplingDefaults(num_steps=num_steps,
|
||||
guidance_scale=1.0,
|
||||
height=720,
|
||||
width=1280,
|
||||
num_frames=125,
|
||||
fps=24,
|
||||
negative_prompt=HUNYUAN_NEG,
|
||||
shift=flow_shift),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_wan21_checkpoints(card, checkpoint_root)
|
||||
# HunyuanVideo's CLIP secondary encoder lives in text_encoder_2/ (the Wan stamp covers it because
|
||||
# text_encoder_2 is in its subfolder superset); its tokenizer is tokenizer_2/ (BRINGUP: see the CLIP
|
||||
# adapter — the shared _make_text_encoder loads tokenizer/, so GPU-verify must wire tokenizer_2).
|
||||
if "text_encoder_2" in card.components and not card.components["text_encoder_2"].checkpoint:
|
||||
root = os.path.dirname(os.path.normpath(card.components["transformer"].checkpoint))
|
||||
card.components["text_encoder_2"].checkpoint = os.path.join(root, "text_encoder_2")
|
||||
return card
|
||||
|
||||
|
||||
def build_fast_hunyuan_video_card(model_id: str = "fasthunyuan", *, checkpoint_root: str | None = None) -> ModelCard:
|
||||
"""FastHunyuan (FastVideo/FastHunyuan-diffusers): same arch, distilled to 6 steps with flow_shift 17.0.
|
||||
Reuses the base card / adapters unchanged — only the schedule (shift + step count) differs."""
|
||||
return build_hunyuan_video_card(model_id=model_id,
|
||||
flow_shift=17.0,
|
||||
num_steps=6,
|
||||
checkpoint_root=checkpoint_root,
|
||||
sampling_defaults=SamplingDefaults(num_steps=6,
|
||||
guidance_scale=1.0,
|
||||
height=720,
|
||||
width=1280,
|
||||
num_frames=125,
|
||||
fps=24,
|
||||
negative_prompt=HUNYUAN_NEG,
|
||||
shift=17.0))
|
||||
@@ -0,0 +1,57 @@
|
||||
"""HunyuanDenoiseLoop — a thin subclass of the canonical ``WanDenoiseLoop`` (flow-match Euler).
|
||||
|
||||
HunyuanVideo is a *rectified flow-match* model: the DiT predicts a velocity in unpatchified
|
||||
``[B, 16, T, H, W]`` latent space, the model timestep is the scalar ``sigma * 1000`` (a per-batch
|
||||
``LongTensor`` consumed by the internal ``time_in`` ``TimestepEmbedder``), and the deterministic Euler
|
||||
update is ``x_next = x + (sigma_next - sigma)*v`` — exactly the math ``WanDenoiseLoop`` already runs. The
|
||||
latent geometry (16 channels, 8x spatial, 4x temporal) is the Wan default too, so we reuse the loop
|
||||
verbatim and only carry a Hunyuan-specific docstring + the dual-text-encoder marshalling delta below.
|
||||
|
||||
The ONE delta vs Wan is conditioning shape: the Hunyuan DiT consumes TWO text signals —
|
||||
``encoder_hidden_states = [llama_hidden[B, L, 4096], clip_pooled[B, 768]]`` (a per-token LLaMA sequence
|
||||
plus a single CLIP-pooled global vector). To keep the loop's ``dit(latent, text_embed, sigma)`` call
|
||||
identical on the CPU toy and the GPU backend (the cosmos2 discipline: the adapter assembles
|
||||
arch-specifics INTERNALLY), the program writes the LLaMA sequence into the ``text_embeds`` slot (the
|
||||
primary ``text_embed`` the loop already threads) and the CLIP-pooled vector into ``text_pooled``. This
|
||||
loop reuses Wan's existing ``context=`` channel (Wan uses it for the i2v CLIP-vision embeds) to carry
|
||||
the CLIP-pooled vector to the DiT adapter — so ``WanDiT``-style ``dit(x, text_embed, sigma, context=...)``
|
||||
is unchanged, ``ToyDiT`` (which means over both ``text_embed`` and ``context``) runs on CPU, and the real
|
||||
``HunyuanVideoDiT`` adapter packs ``[text_embed, context]`` into the 2-element ``encoder_hidden_states``.
|
||||
|
||||
guidance_scale defaults to 1.0 for base HunyuanVideo → ``ClassicCFG.combine`` collapses to the single
|
||||
``cond`` branch (``uncond + 1*(cond-uncond) == cond``), i.e. CFG is effectively off, matching the
|
||||
fastvideo pipeline. (``embedded_cfg_scale``/``guidance`` is a no-op on the base model whose
|
||||
``guidance_in`` is ``None``; see ``torch_hunyuan_video.HunyuanVideoDiT`` for that BRINGUP note.)
|
||||
|
||||
Faithful to ``fastvideo/pipelines/basic/hunyuan/hunyuan_pipeline.py`` (flow-match DenoisingStage) and
|
||||
``fastvideo/models/dits/hunyuanvideo.py:HunyuanVideoTransformer3DModel.forward`` (the list-split of
|
||||
``encoder_hidden_states`` into ``txt`` / ``text_states_2``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.loop.contracts import LoopState
|
||||
from v2.recipes.wan21.loop import WanDenoiseLoop
|
||||
|
||||
# AutoencoderKLHunyuanVideo compression matches Wan2.1: z=16, 4x temporal, 8x spatial.
|
||||
HUNYUAN_LATENT_CHANNELS = 16
|
||||
HUNYUAN_TEMPORAL_RATIO = 4
|
||||
HUNYUAN_SPATIAL_RATIO = 8
|
||||
|
||||
|
||||
class HunyuanDenoiseLoop(WanDenoiseLoop):
|
||||
"""Wan flow-match loop with HunyuanVideo's dual-text-encoder marshalling.
|
||||
|
||||
Reuses ``WanDenoiseLoop.next``/``advance``/``finalize`` unchanged (flow-match Euler, velocity output,
|
||||
timestep ``sigma*1000``, scalar per-batch timestep). Overrides only ``init`` to thread the CLIP-pooled
|
||||
global vector (slot ``text_pooled``) through Wan's existing ``context=`` channel so the DiT adapter can
|
||||
reassemble the 2-element ``encoder_hidden_states`` the Hunyuan forward expects.
|
||||
"""
|
||||
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
st = super().init(req, model, ctx)
|
||||
# Wan's ``context`` channel (``i2v_img_embeds`` -> dit ``context=``) carries the CLIP-pooled vector
|
||||
# for Hunyuan. None for any caller that did not run the dual-encode node -> the DiT adapter falls
|
||||
# back to a single-tensor encoder_hidden_states (still valid: the Hunyuan forward also accepts a
|
||||
# bare tensor whose token 0 is the pooled vector — but the dual-slot path is the faithful one).
|
||||
st.scratch["i2v_img_embeds"] = ctx.slots.get("text_pooled")
|
||||
return st
|
||||
@@ -0,0 +1,80 @@
|
||||
"""HunyuanVideo t2v program: hunyuan_text_encode -> diffusion_denoise (flow-match) -> vae_decode.
|
||||
|
||||
Same inline shape as the Wan/cosmos2 t2v programs, with ONE delta: HunyuanVideo has TWO text encoders
|
||||
(a LLaMA per-token sequence + a CLIP-pooled global vector), so the text-encode node writes BOTH a
|
||||
``text_embeds`` slot (the LLaMA sequence, the loop's primary ``text_embed``) and a ``text_pooled`` slot
|
||||
(the CLIP vector, threaded to the DiT via the loop's ``context=`` channel). The dual-encode node uses the
|
||||
content-hash feature cache (like ``recipes.common.text_encode_node_fn``) so a repeated prompt encodes once.
|
||||
|
||||
On the CPU toy backend both components are ``ToyTextEncoder`` (single arrays), so ``ToyDiT`` — which means
|
||||
over both ``text_embed`` and ``context`` — runs end-to-end; on the GPU backend the real LLaMA/CLIP adapters
|
||||
produce the sequence + pooled pair the ``HunyuanVideoDiT`` adapter reassembles into the 2-element
|
||||
``encoder_hidden_states`` the Hunyuan forward expects.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
from v2.recipes.common import cached_text_encode
|
||||
|
||||
|
||||
def _encode_pooled(instance: Any, text: str) -> Any:
|
||||
"""CLIP secondary encode (pooled global vector). Reuses the content-hash feature cache discipline of
|
||||
``cached_text_encode`` but on the ``text_encoder_2`` component (a distinct cache partition by
|
||||
component_id). Falls back to the plain encode when the instance has no feature cache."""
|
||||
enc = instance.component("text_encoder_2")
|
||||
cache = instance.caches
|
||||
if cache is not None and cache.has("feature"):
|
||||
from v2.cache.keys import CacheKey, content_hash
|
||||
key = CacheKey(model_id=instance.card.model_id,
|
||||
component_id="text_encoder_2",
|
||||
weights_version=instance.version_of("text_encoder_2"),
|
||||
adapter_versions=CacheKey.adapters(instance.adapter_versions),
|
||||
precision=instance.card.precision.dtype_for("text_encoder_2"),
|
||||
input_hashes=(("text", content_hash(text)), ))
|
||||
hit = cache.pool("feature").get(key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
emb = enc.encode(text)
|
||||
cache.pool("feature").put(key, emb)
|
||||
return emb
|
||||
return enc.encode(text)
|
||||
|
||||
|
||||
def _hunyuan_text_encode(instance, slots, request, ctx) -> None:
|
||||
"""ComponentNode fn: prompt -> (LLaMA sequence in ``text_embeds`` + CLIP pooled in ``text_pooled``),
|
||||
plus the negative-prompt counterparts (used only when guidance_scale != 1.0; base HunyuanVideo runs
|
||||
guidance_scale=1.0 so the uncond branch collapses out in ClassicCFG.combine)."""
|
||||
prompt = request.prompt()
|
||||
neg = request.diffusion.negative_prompt
|
||||
slots["text_embeds"] = cached_text_encode(instance, prompt) # LLaMA per-token sequence
|
||||
slots["text_pooled"] = _encode_pooled(instance, prompt) # CLIP pooled global vector
|
||||
slots["neg_text_embeds"] = cached_text_encode(instance, neg)
|
||||
slots["neg_text_pooled"] = _encode_pooled(instance, neg)
|
||||
|
||||
|
||||
def _vae_decode(instance, slots, request, ctx) -> None:
|
||||
slots["video"] = instance.component("vae").decode(slots["denoise_out"]["latents"])
|
||||
|
||||
|
||||
def build_hunyuan_video_program() -> Program:
|
||||
return Program(
|
||||
program_id="hunyuan_video.t2v.inline",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("text_encode",
|
||||
fn=_hunyuan_text_encode,
|
||||
writes=("text_embeds", "text_pooled", "neg_text_embeds", "neg_text_pooled")),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="diffusion_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("text_embeds", "text_pooled", "neg_text_embeds", "neg_text_pooled"),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("vae_decode", fn=_vae_decode, reads=("denoise_out", ), writes=("video", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"video": "video",
|
||||
"latents": "denoise_out"
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""HunyuanVideo 1.5 (T2V) — rectified-flow video DiT ported into the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapters via
|
||||
``ComponentSpec.adapter`` (``HunyuanVideo15DiT``/``HunyuanVideo15VAE``/``HunyuanVideo15QwenEncoder``/
|
||||
``HunyuanVideo15ByT5Encoder`` in ``v2/platform/backends/torch_hunyuan_video15.py``) and a
|
||||
``HunyuanVideo15DenoiseLoop`` (a thin ``WanDenoiseLoop`` subclass with z=32/16×/4× geometry). The
|
||||
flow-match loop math is unchanged from Wan; the arch deltas (two text embeds, an image-embed list, the
|
||||
33-channel i2v cond concat, the scalar-scaling VAE) live in the adapters. Registered in ``v2/registry.py``
|
||||
(by the orchestrator) for ``hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.hunyuan_video15.card import (
|
||||
build_hunyuan_video15_720p_card,
|
||||
build_hunyuan_video15_card,
|
||||
)
|
||||
from v2.recipes.hunyuan_video15.loop import HunyuanVideo15DenoiseLoop
|
||||
from v2.recipes.hunyuan_video15.program import build_hunyuan_video15_program
|
||||
|
||||
__all__ = [
|
||||
"build_hunyuan_video15_card",
|
||||
"build_hunyuan_video15_720p_card",
|
||||
"build_hunyuan_video15_program",
|
||||
"HunyuanVideo15DenoiseLoop",
|
||||
]
|
||||
@@ -0,0 +1,181 @@
|
||||
"""HunyuanVideo 1.5 ModelCard — text→video (the registered 480p t2v preset; i2v + SR are later
|
||||
capabilities the loop/adapter thread but the t2v program does not yet exercise on CPU).
|
||||
|
||||
Architecture deltas vs Wan (all declared on the card so the recipe is self-contained):
|
||||
* DiT ``fastvideo.models.dits.hunyuanvideo15:HunyuanVideo15Transformer3DModel`` — a rectified-flow
|
||||
velocity network (54 dual-stream MMDoubleStream blocks, hidden 2048, in/out 65/32). Its forward takes
|
||||
a LIST of two text embeds (Qwen2.5-VL + ByT5) and a LIST image-embed; the new ``HunyuanVideo15DiT``
|
||||
adapter marshals those, so ``HunyuanVideo15DenoiseLoop`` (a thin ``WanDenoiseLoop`` subclass with
|
||||
z=32/16×/4× geometry) drives it with the unchanged flow-match math.
|
||||
* VAE ``fastvideo.models.vaes.hunyuan15vae`` — causal-3D, z=32, 16× spatial / 4× temporal, normalized
|
||||
by a SCALAR ``scaling_factor=1.03682`` -> the ``HunyuanVideo15VAE`` adapter (NOT Wan's mean/std).
|
||||
* Text encoders: ``text_encoder`` = Qwen2.5-VL (3584-d, ``hidden_states[-3]`` cropped of 108 template
|
||||
tokens) via ``HunyuanVideo15QwenEncoder``; ``text_encoder_2`` = ByT5/Glyph (1472-d, quoted-glyph text,
|
||||
may be empty) via ``HunyuanVideo15ByT5Encoder``.
|
||||
``stamp_wan21_checkpoints`` applies (diffusers transformer/vae/text_encoder/text_encoder_2 subfolders).
|
||||
|
||||
Defaults (configs/pipelines + presets): 480p t2v -> 50 steps, guidance 6.0, 480×848, 121 frames, 24 fps,
|
||||
flow_shift 5 (720p uses 9), empty negative prompt, explicit ``linspace(1,0,n+1)[:-1]`` sigma schedule.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import ClassicCFG, FlowShiftPolicy, NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyDiT, ToyTextEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.hunyuan_video15.loop import (
|
||||
HUNYUAN15_LATENT_CHANNELS,
|
||||
HUNYUAN15_SPATIAL_RATIO,
|
||||
HUNYUAN15_TEMPORAL_RATIO,
|
||||
HunyuanVideo15DenoiseLoop,
|
||||
)
|
||||
from v2.recipes.wan21.card import stamp_wan21_checkpoints
|
||||
|
||||
# HunyuanVideo 1.5 ships with an EMPTY negative prompt (configs/.../presets.py). Kept as a LOCAL constant
|
||||
# (the recipe owns its prompts; not added to the shared v2/recipes/_prompts.py).
|
||||
HUNYUAN15_NEG = ""
|
||||
|
||||
_HY15_DIT = "v2.platform.backends.torch_hunyuan_video15:HunyuanVideo15DiT"
|
||||
_HY15_VAE = "v2.platform.backends.torch_hunyuan_video15:HunyuanVideo15VAE"
|
||||
_HY15_QWEN = "v2.platform.backends.torch_hunyuan_video15:HunyuanVideo15QwenEncoder"
|
||||
_HY15_BYT5 = "v2.platform.backends.torch_hunyuan_video15:HunyuanVideo15ByT5Encoder"
|
||||
|
||||
# 480p t2v explicit sigma schedule = linspace(1, 0, num_steps+1)[:-1] (presets.py:_sigmas).
|
||||
_DEFAULT_STEPS = 50
|
||||
|
||||
|
||||
def _linspace_sigmas(num_steps: int) -> tuple[float, ...]:
|
||||
"""Faithful to ``presets.py:_sigmas`` — ``np.linspace(1,0,n+1)[:-1]`` (drops the terminal 0). The
|
||||
loop's ``build_schedule`` consumes this verbatim when present (FlowShiftPolicy passes it through)."""
|
||||
return tuple((1.0 - i / num_steps) for i in range(num_steps))
|
||||
|
||||
|
||||
def build_hunyuan_video15_card(model_id: str = "hunyuan-video-1.5-t2v-480p",
|
||||
*,
|
||||
flow_shift: float = 5.0,
|
||||
height: int = 480,
|
||||
width: int = 848,
|
||||
num_steps: int = _DEFAULT_STEPS,
|
||||
guidance_scale: float = 6.0,
|
||||
checkpoint_root: str | None = None,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = ClassicCFG()
|
||||
# 480p flow_shift 5, 720p 9 (configs/pipelines/hunyuan15.py). The preset also hands an explicit sigma
|
||||
# schedule via SamplingDefaults.sigmas, which the loop prefers when present.
|
||||
flow = FlowShiftPolicy(shift=flow_shift, bucket_lookup={480 * 848: 5.0, 720 * 1280: 9.0})
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer")
|
||||
|
||||
def loop_factory():
|
||||
return HunyuanVideo15DenoiseLoop(loop_id="diffusion_denoise",
|
||||
cfg=cfg,
|
||||
flow_shift=flow,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost,
|
||||
latent_channels=HUNYUAN15_LATENT_CHANNELS,
|
||||
spatial_ratio=HUNYUAN15_SPATIAL_RATIO,
|
||||
temporal_ratio=HUNYUAN15_TEMPORAL_RATIO)
|
||||
|
||||
components = {
|
||||
"text_encoder":
|
||||
ComponentSpec(component_id="text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.qwen2_5:Qwen2_5_VLTextModel",
|
||||
adapter=_HY15_QWEN,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2v"}),
|
||||
"text_encoder_2":
|
||||
ComponentSpec(component_id="text_encoder_2",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.t5:T5EncoderModel",
|
||||
adapter=_HY15_BYT5,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2v"}),
|
||||
"vae":
|
||||
ComponentSpec(component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.hunyuan15vae:AutoencoderKLHunyuanVideo15",
|
||||
adapter=_HY15_VAE,
|
||||
factory=lambda inst: ToyVAE(),
|
||||
required_for={"t2v"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.hunyuanvideo15:HunyuanVideo15Transformer3DModel",
|
||||
adapter=_HY15_DIT,
|
||||
factory=lambda inst: ToyDiT(seed=seed),
|
||||
resident_for=["diffusion_denoise"],
|
||||
required_for={"t2v"}),
|
||||
}
|
||||
loops = {
|
||||
"diffusion_denoise":
|
||||
LoopSpec(loop_id="diffusion_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="breakable_cudagraph",
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="hunyuan",
|
||||
components=components,
|
||||
loops=loops,
|
||||
capabilities=CapabilityMatrix.of(Capability.TEXT_TO_VIDEO, Capability.VAE_DECODE, Capability.POLICY_ROLLOUT),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="diffusion_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
# sequence_model_parallel_shard inside the DiT forward is a no-op at sp=1; default single() until
|
||||
# SP is GPU-validated (BRINGUP risk G).
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
sampling_defaults=sampling_defaults or SamplingDefaults(num_steps=num_steps,
|
||||
guidance_scale=guidance_scale,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=121,
|
||||
fps=24,
|
||||
negative_prompt=HUNYUAN15_NEG,
|
||||
shift=flow_shift,
|
||||
sigmas=_linspace_sigmas(num_steps)),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_wan21_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
|
||||
|
||||
def build_hunyuan_video15_720p_card(model_id: str = "hunyuan-video-1.5-t2v-720p",
|
||||
*,
|
||||
checkpoint_root: str | None = None) -> ModelCard:
|
||||
"""HunyuanVideo 1.5 720p t2v — same components/adapters; only flow_shift (9), resolution, and the
|
||||
per-model defaults differ (configs/pipelines/hunyuan15.py:Hunyuan15T2V720PConfig)."""
|
||||
return build_hunyuan_video15_card(model_id=model_id,
|
||||
flow_shift=9.0,
|
||||
height=720,
|
||||
width=1280,
|
||||
checkpoint_root=checkpoint_root)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""HunyuanVideo15DenoiseLoop — the canonical flow-match denoise loop, parameterized for HunyuanVideo 1.5.
|
||||
|
||||
The loop *math* is identical to Wan's (``WanDenoiseLoop``): N deterministic flow-match Euler steps over
|
||||
the full latent, ``ClassicCFG`` combine, ``timestep = sigma*1000``, ``x_next = x + (σ_next-σ)·v``. This is
|
||||
faithful to ``fastvideo/models/schedulers/scheduling_flow_match_euler_discrete.py`` — ``scale_model_input``
|
||||
is the identity for flow-match, ``step`` is ``prev_sample = sample + (sigma_next - sigma)*model_output``,
|
||||
and HunyuanVideo 1.5's DiT predicts velocity directly (no x0 reconstruction). The latent is initialized at
|
||||
``randn·σ[0]`` with ``σ[0]=1.0`` (the preset's ``linspace(1,0,n+1)[:-1]`` schedule).
|
||||
|
||||
The ONLY architectural delta lives in the ``HunyuanVideo15DiT`` torch adapter (two text embeds + an
|
||||
image-embed list + the 33-channel i2v cond concat). Because ``WanDenoiseLoop`` already threads
|
||||
``context=`` (image embeds) and ``cond=`` (the conditioning latent) into the adapter, the cleanest port
|
||||
is to subclass it: change only the latent geometry (z=32, 16× spatial, 4× temporal) and the slot names
|
||||
the loop reads for the i2v signal. For t2v those slots are absent (``None``) → the dit-call degenerates
|
||||
to ``dit(latent, text_embed, sigma)`` exactly like Wan t2v, and the toy backend's ``ToyDiT`` runs it.
|
||||
|
||||
BRINGUP: i2v conditioning (``context``=zero image embeds, ``cond``=33ch first-frame latent) is threaded
|
||||
but the program currently writes neither slot for the registered t2v preset, so the loop is pure t2v on
|
||||
CPU; the i2v VAE-encode node + image-latent slot are documented hooks (see ``program.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.wan21.loop import WanDenoiseLoop
|
||||
|
||||
# HunyuanVideo 1.5 causal-3D VAE compression (configs/models/vaes/hunyuan15vae.py): z=32, 16× spatial,
|
||||
# 4× temporal. The DiT's noisy-latent in_channels is 32 (out_channels); i2v reaches 65 by concatenating
|
||||
# the 33ch first-frame cond latent inside the adapter, so the loop's latent stays 32ch.
|
||||
HUNYUAN15_LATENT_CHANNELS = 32
|
||||
HUNYUAN15_SPATIAL_RATIO = 16
|
||||
HUNYUAN15_TEMPORAL_RATIO = 4
|
||||
|
||||
|
||||
class HunyuanVideo15DenoiseLoop(WanDenoiseLoop):
|
||||
"""Wan flow-match loop with HunyuanVideo 1.5 latent geometry. The i2v conditioning the base loop reads
|
||||
from ``ctx.slots`` is keyed the same way (``i2v_cond``/``i2v_img_embeds``), so a future i2v program can
|
||||
write them with no loop change; the t2v program leaves them unset (pure t2v)."""
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
loop_id,
|
||||
cfg,
|
||||
flow_shift,
|
||||
precision,
|
||||
expert,
|
||||
cost,
|
||||
latent_channels: int = HUNYUAN15_LATENT_CHANNELS,
|
||||
spatial_ratio: int = HUNYUAN15_SPATIAL_RATIO,
|
||||
temporal_ratio: int = HUNYUAN15_TEMPORAL_RATIO):
|
||||
super().__init__(loop_id=loop_id,
|
||||
cfg=cfg,
|
||||
flow_shift=flow_shift,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost,
|
||||
latent_channels=latent_channels,
|
||||
spatial_ratio=spatial_ratio,
|
||||
temporal_ratio=temporal_ratio)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""HunyuanVideo 1.5 t2v program: text_encode → diffusion_denoise → vae_decode.
|
||||
|
||||
Same inline shape as the Wan/Cosmos t2v programs — the arch specifics (two text embeds, the image-embed
|
||||
list, the 33ch i2v concat) live entirely in the ``HunyuanVideo15DiT`` adapter, so the node graph is
|
||||
unchanged except for the text-encode node, which must produce BOTH text embeddings.
|
||||
|
||||
HunyuanVideo 1.5 conditions on two encoders (faithful to ``HunyuanVideo15Pipeline``'s
|
||||
``TextEncodingStage(text_encoders=[text_encoder, text_encoder_2])``):
|
||||
* ``text_encoder`` = Qwen2.5-VL -> ``hidden_states[-3]`` cropped of the 108 template tokens (3584-d)
|
||||
* ``text_encoder_2`` = ByT5/Glyph -> ``last_hidden_state`` of the quoted-glyph text (1472-d), or a
|
||||
zero-length embedding when the prompt carries no quoted text.
|
||||
|
||||
The text-encode node packs them as a ``(qwen, byt5)`` tuple in the slot; ``HunyuanVideo15DiT`` unpacks
|
||||
that into the two-element ``encoder_hidden_states`` list. On the CPU **toy** backend there is a single
|
||||
``text_encoder`` component (``ToyTextEncoder``) and no ``text_encoder_2``: the node then writes the plain
|
||||
toy embedding (a single numpy array), which ``ToyDiT`` consumes directly — so the program CPU-verifies
|
||||
unchanged. The feature cache (design_v3 §7.2) still applies to the Qwen path via ``cached_text_encode``.
|
||||
|
||||
BRINGUP (i2v): the i2v path adds a VAE-encode node that writes the 33-channel first-frame conditioning
|
||||
latent into ``i2v_cond`` and zero image embeds into ``i2v_img_embeds`` (the slots the loop already reads);
|
||||
``Hy15ImageEncodingStage`` builds those (image_embeds=zeros[1,729,1152]; image_latent=[expanded VAE
|
||||
first-frame ⊕ mask] -> 33ch). No CLIP/SigLIP vision encoder is loaded (image_embeds are placeholder
|
||||
zeros), so do NOT add an image_encoder component. Left as a documented hook for the t2v registration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
from v2.recipes.common import cached_text_encode
|
||||
|
||||
|
||||
def _text_encode(instance: Any, slots: dict, request: Any, ctx: Any) -> None:
|
||||
"""Encode prompt + negative prompt. When a second text encoder (ByT5) exists, pack ``(qwen, byt5)``
|
||||
tuples; otherwise (toy backend) write the single embedding ``ToyDiT`` expects."""
|
||||
has_byt5 = "text_encoder_2" in instance.card.components
|
||||
|
||||
def _encode(text: str) -> Any:
|
||||
qwen = cached_text_encode(instance, text) # primary (Qwen2.5-VL) — feature-cached
|
||||
if not has_byt5:
|
||||
return qwen # toy backend: single embedding
|
||||
byt5 = instance.component("text_encoder_2").encode(text)
|
||||
return (qwen, byt5)
|
||||
|
||||
slots["text_embeds"] = _encode(request.prompt())
|
||||
slots["neg_text_embeds"] = _encode(request.diffusion.negative_prompt)
|
||||
|
||||
|
||||
def _vae_decode(instance: Any, slots: dict, request: Any, ctx: Any) -> None:
|
||||
slots["video"] = instance.component("vae").decode(slots["denoise_out"]["latents"])
|
||||
|
||||
|
||||
def build_hunyuan_video15_program() -> Program:
|
||||
return Program(
|
||||
program_id="hunyuan_video15.t2v.inline",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("text_encode", fn=_text_encode, writes=("text_embeds", "neg_text_embeds")),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="diffusion_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("text_embeds", "neg_text_embeds"),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("vae_decode", fn=_vae_decode, reads=("denoise_out", ), writes=("video", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"video": "video",
|
||||
"latents": "denoise_out"
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""HunyuanGameCraft (interactive camera/action-conditioned i2v) ported into the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapters via
|
||||
``ComponentSpec.adapter`` (``GameCraftDiT``/``GameCraftVAE``/``GameCraftLlamaEncoder``/
|
||||
``GameCraftClipEncoder`` in ``v2/platform/backends/torch_hunyuangamecraft.py``) and a new
|
||||
``GameCraftDenoiseLoop`` (flow-match Euler + 33ch concat + per-step clean-ref injection). The registered
|
||||
path is the t2v/degenerate denoise; the camera/action (CameraNet Plücker) conditioning is BRINGUP — it
|
||||
needs a request-API camera-input channel v2 lacks today. Registered in ``v2/registry.py`` by the
|
||||
orchestrator from this package's returned metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.hunyuangamecraft.card import build_hunyuangamecraft_card
|
||||
from v2.recipes.hunyuangamecraft.loop import GameCraftDenoiseLoop
|
||||
from v2.recipes.hunyuangamecraft.program import build_hunyuangamecraft_program
|
||||
|
||||
__all__ = ["build_hunyuangamecraft_card", "build_hunyuangamecraft_program", "GameCraftDenoiseLoop"]
|
||||
@@ -0,0 +1,174 @@
|
||||
"""HunyuanGameCraft ModelCard — camera/action-conditioned interactive i2v (the registered preset is the
|
||||
t2v/degenerate path; the action/camera conditioning is BRINGUP — see the recipe ``__init__`` docstring).
|
||||
|
||||
Architecture deltas vs Wan (all declared on the card so the recipe is self-contained):
|
||||
* DiT ``fastvideo.models.dits.hunyuangamecraft:HunyuanGameCraftTransformer3DModel`` — a flow-match DiT
|
||||
whose forward takes a **33-channel** input ``cat([latent16|gt_latent16|mask1])`` (assembled by the
|
||||
loop/adapter, NOT the DiT), a **list** of text states ``[LLaMA(4096d), CLIP-pooled(768d)]``, an
|
||||
optional ``camera_states`` (CameraNet Plücker), and ``guidance=None`` (plain ClassicCFG, NO embedded
|
||||
guidance). Adapter -> ``GameCraftDiT``; the new ``GameCraftDenoiseLoop`` does the 33ch concat
|
||||
(via the adapter), per-step clean-ref injection, and flow-match Euler.
|
||||
* VAE ``fastvideo.models.vaes.gamecraftvae:GameCraftVAE`` — SCALAR ``scaling_factor=0.476986``
|
||||
normalization (NOT Wan per-channel stats). Adapter -> ``GameCraftVAE`` (cannot reuse ``WanVAE``).
|
||||
* Text ``fastvideo.models.encoders.llama:LlamaModel`` (LLaVA-LLaMA-3-8B, 4096d -> ``text_states``) and
|
||||
``fastvideo.models.encoders.clip:CLIPTextModel`` (CLIP ViT-L/14, 768d pooled -> ``text_states_2``).
|
||||
Adapters -> ``GameCraftLlamaEncoder`` / ``GameCraftClipEncoder``.
|
||||
The diffusers checkpoint layout has extra ``text_encoder_2/`` + ``tokenizer_2/`` subfolders the Wan stamp
|
||||
lacks, so this file extends ``stamp_wan21_checkpoints`` with a GameCraft-specific stamp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import ClassicCFG, FlowShiftPolicy, NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyDiT, ToyTextEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.hunyuangamecraft.loop import GameCraftDenoiseLoop
|
||||
|
||||
# GameCraft uses an empty negative prompt by default (the preset's negative_prompt=""). Kept as a LOCAL
|
||||
# module constant so this recipe does not depend on the shared ``v2/recipes/_prompts.py`` (per the
|
||||
# self-contained-recipe rule). CFG still runs (uncond branch encodes the empty string).
|
||||
GAMECRAFT_NEG = ""
|
||||
|
||||
_GAMECRAFT_DIT = "v2.platform.backends.torch_hunyuangamecraft:GameCraftDiT"
|
||||
_GAMECRAFT_VAE = "v2.platform.backends.torch_hunyuangamecraft:GameCraftVAE"
|
||||
_GAMECRAFT_LLAMA = "v2.platform.backends.torch_hunyuangamecraft:GameCraftLlamaEncoder"
|
||||
_GAMECRAFT_CLIP = "v2.platform.backends.torch_hunyuangamecraft:GameCraftClipEncoder"
|
||||
|
||||
|
||||
def build_hunyuangamecraft_card(model_id: str = "hunyuan-gamecraft",
|
||||
*,
|
||||
checkpoint_root: str | None = None,
|
||||
flow_shift: float = 5.0,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = ClassicCFG()
|
||||
# Single (resolution-independent) flow-shift 5.0; no bucket lookup (GameCraft fixes 704x1280).
|
||||
flow = FlowShiftPolicy(shift=flow_shift)
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer")
|
||||
|
||||
def loop_factory():
|
||||
return GameCraftDenoiseLoop(loop_id="diffusion_denoise",
|
||||
cfg=cfg,
|
||||
flow_shift=flow,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost)
|
||||
|
||||
components = {
|
||||
# LLaVA-LLaMA-3 -> text_states (4096d). The toy CPU path uses ToyTextEncoder for both encoders.
|
||||
"text_encoder":
|
||||
ComponentSpec(component_id="text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.llama:LlamaModel",
|
||||
adapter=_GAMECRAFT_LLAMA,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2v", "i2v"}),
|
||||
# CLIP ViT-L/14 -> text_states_2 (768d pooled).
|
||||
"text_encoder_2":
|
||||
ComponentSpec(component_id="text_encoder_2",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.clip:CLIPTextModel",
|
||||
adapter=_GAMECRAFT_CLIP,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2v", "i2v"}),
|
||||
"vae":
|
||||
ComponentSpec(component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.gamecraftvae:GameCraftVAE",
|
||||
adapter=_GAMECRAFT_VAE,
|
||||
factory=lambda inst: ToyVAE(),
|
||||
required_for={"t2v", "i2v"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.hunyuangamecraft:HunyuanGameCraftTransformer3DModel",
|
||||
adapter=_GAMECRAFT_DIT,
|
||||
factory=lambda inst: ToyDiT(seed=seed),
|
||||
resident_for=["diffusion_denoise"],
|
||||
required_for={"t2v", "i2v"}),
|
||||
}
|
||||
loops = {
|
||||
"diffusion_denoise":
|
||||
LoopSpec(loop_id="diffusion_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="breakable_cudagraph",
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="gamecraft",
|
||||
components=components,
|
||||
loops=loops,
|
||||
# IMAGE_TO_VIDEO is the real capability (interactive i2v); the registered path degenerates to a
|
||||
# standard denoise when no camera/action input is given (BRINGUP). VAE_DECODE + POLICY_ROLLOUT
|
||||
# mirror the other video cards.
|
||||
capabilities=CapabilityMatrix.of(Capability.IMAGE_TO_VIDEO, Capability.VAE_DECODE, Capability.POLICY_ROLLOUT),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="diffusion_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
sampling_defaults=sampling_defaults or SamplingDefaults(num_steps=50,
|
||||
guidance_scale=6.0,
|
||||
height=704,
|
||||
width=1280,
|
||||
num_frames=33,
|
||||
fps=24,
|
||||
negative_prompt=GAMECRAFT_NEG),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_gamecraft_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
|
||||
|
||||
# Diffusers checkpoint layout for GameCraft: dual text encoders + dual tokenizers + scheduler, in addition
|
||||
# to transformer/vae. ``stamp_wan21_checkpoints`` only knows a single tokenizer + lacks text_encoder_2, so
|
||||
# GameCraft carries its own stamp map. (The torch adapters resolve tokenizers from ``<root>/tokenizer*``.)
|
||||
_GAMECRAFT_SUBFOLDERS = {
|
||||
"transformer": "transformer",
|
||||
"vae": "vae",
|
||||
"text_encoder": "text_encoder", # LLaVA-LLaMA-3
|
||||
"text_encoder_2": "text_encoder_2", # CLIP ViT-L/14
|
||||
}
|
||||
|
||||
|
||||
def stamp_gamecraft_checkpoints(card: ModelCard, model_root: str) -> ModelCard:
|
||||
"""GPU deploy-time: point each component's ``ComponentSpec.checkpoint`` at its weights subfolder under
|
||||
``model_root`` (a local diffusers dir or an HF id resolved via ``snapshot_download``). Mirrors
|
||||
``stamp_wan21_checkpoints`` but covers GameCraft's ``text_encoder_2`` (the Wan stamp lacks it). The
|
||||
sibling ``tokenizer/`` (LLaMA) and ``tokenizer_2/`` (CLIP) + ``scheduler/`` subfolders are loaded by
|
||||
the torch adapters from ``dirname(checkpoint)``. Mutates and returns the card. BRINGUP risk A."""
|
||||
import os
|
||||
if not os.path.isdir(model_root):
|
||||
from huggingface_hub import snapshot_download
|
||||
model_root = snapshot_download(model_root)
|
||||
for component_id, subfolder in _GAMECRAFT_SUBFOLDERS.items():
|
||||
if component_id in card.components:
|
||||
card.components[component_id].checkpoint = os.path.join(model_root, subfolder)
|
||||
return card
|
||||
@@ -0,0 +1,254 @@
|
||||
"""GameCraftDenoiseLoop — a flow-match Euler denoise loop with GameCraft i2v + camera conditioning.
|
||||
|
||||
GameCraft (HunyuanGameCraft) is an *interactive* world model: a camera/action-conditioned i2v diffusion
|
||||
DiT. The sampler is plain flow-match (``FlowMatchEulerDiscreteScheduler``, ``flow_shift=5.0``,
|
||||
``timestep = σ·1000``) with standard ClassicCFG (``guidance_scale=6.0``, ``guidance=None`` to the DiT — NO
|
||||
embedded guidance). So the *integrator* is identical to ``WanDenoiseLoop`` (``x_next = x + (σ_next−σ)·v``
|
||||
via ``FLOW_MATCH_STEP``). The deltas this loop carries (faithful to
|
||||
``fastvideo/pipelines/stages/gamecraft_denoising.py``):
|
||||
|
||||
1. **33-channel input is assembled by the DiT adapter, not here.** The DiT forward wants
|
||||
``cat([latent16 | gt_latent16 | mask1], dim=1)``; the loop hands the bare 16ch latent + a ``cond``
|
||||
pack ``{"gt_latents","mask","camera_states"}`` to the ``GameCraftDiT`` adapter, which concats. The
|
||||
toy ``ToyDiT`` accepts (and ignores) the ``cond`` kwarg, so this loop CPU-verifies unchanged.
|
||||
|
||||
2. **Per-step clean-ref injection (frame_replace).** Official GameCraft overwrites the *conditioned*
|
||||
latent frames (``mask>0.5``) with the clean reference latent at EVERY step (not just step 0), so the
|
||||
conditioned frames stay noise-free throughout denoising. ``next()`` mutates the working latent before
|
||||
the forward when a reference latent is present.
|
||||
|
||||
3. **Dual text encoders.** ``encoder_hidden_states = [LLaMA_states(4096d), CLIP_pooled(768d)]``. The
|
||||
loop threads the LLaMA states as the usual ``text_embed`` (cond vs uncond) and the CLIP pooled embed
|
||||
through the adapter's ``context=`` slot per branch, so ClassicCFG combines both correctly.
|
||||
|
||||
4. **BRINGUP — camera/action conditioning.** GameCraft is fundamentally CameraNet (Plücker)
|
||||
conditioned, with the forward special-casing the latent temporal length (18/9/10) for autoregressive
|
||||
multi-chunk generation. v2 has no camera/action request field or Plücker-conversion node yet, so the
|
||||
registered path passes ``camera_states=None`` -> the DiT's ``if camera_states is not None`` branch is
|
||||
skipped (the standard / degenerate denoise). For pure t2v (no reference image) gt_latents+mask are
|
||||
zero -> the 33ch concat reduces to ``[noise | 0 | 0]``, exactly the ``GameCraftDenoisingStage``
|
||||
fallback. Wiring real camera/action + the 18/9/10 length special-casing needs a request-API
|
||||
extension (see ``program.py`` blockers).
|
||||
|
||||
All per-request state lives in ``LoopState`` (interleave-safe). ``next`` is kernel-free; ``advance`` folds
|
||||
the result + (under ROLLOUT) captures a trajectory slice — same shape as ``WanDenoiseLoop``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2._enums import ExecutionProfile, WorkUnitKind
|
||||
from v2.loop.contracts import (
|
||||
Done,
|
||||
LoopResult,
|
||||
LoopState,
|
||||
ResourceRequest,
|
||||
ShapeSignature,
|
||||
StepContext,
|
||||
StepResult,
|
||||
WorkPlan,
|
||||
)
|
||||
from v2.platform import FLOW_MATCH_STEP
|
||||
from v2.recipes.wan21.loop import latent_shape
|
||||
|
||||
# GameCraftVAE geometry: z=16, 8x spatial, 4x temporal (T_lat = (num_frames-1)//4 + 1).
|
||||
GAMECRAFT_LATENT_CHANNELS = 16
|
||||
GAMECRAFT_TEMPORAL_RATIO = 4
|
||||
GAMECRAFT_SPATIAL_RATIO = 8
|
||||
|
||||
|
||||
class GameCraftDenoiseLoop:
|
||||
"""Flow-match Euler denoise with GameCraft i2v (33ch concat + per-step ref injection) + camera hooks."""
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
loop_id,
|
||||
cfg,
|
||||
flow_shift,
|
||||
precision,
|
||||
expert,
|
||||
cost,
|
||||
latent_channels: int = GAMECRAFT_LATENT_CHANNELS,
|
||||
spatial_ratio: int = GAMECRAFT_SPATIAL_RATIO,
|
||||
temporal_ratio: int = GAMECRAFT_TEMPORAL_RATIO):
|
||||
self.loop_id = loop_id
|
||||
self.cfg = cfg
|
||||
self.flow_shift = flow_shift
|
||||
self.precision = precision
|
||||
self.expert = expert
|
||||
self.cost = cost
|
||||
self.latent_channels = latent_channels
|
||||
self.spatial_ratio = spatial_ratio
|
||||
self.temporal_ratio = temporal_ratio
|
||||
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
seed = req.diffusion.seed if req.diffusion.seed is not None else 0
|
||||
rng = np.random.default_rng(seed)
|
||||
# Flow-match σ schedule (shift=5.0 for GameCraft); honor an explicit distilled sigmas list if given.
|
||||
sig = self.flow_shift.build_schedule(req.diffusion.num_steps,
|
||||
req.diffusion.height,
|
||||
req.diffusion.width,
|
||||
sigmas=req.diffusion.sigmas or None)
|
||||
shape = latent_shape(req,
|
||||
model,
|
||||
channels=self.latent_channels,
|
||||
spatial_ratio=self.spatial_ratio,
|
||||
temporal_ratio=self.temporal_ratio)
|
||||
x = (rng.standard_normal(shape) * float(sig[0])).astype("float32")
|
||||
st = LoopState(loop_id=self.loop_id,
|
||||
instance_id=model.card.model_id,
|
||||
request_id=req.request_id,
|
||||
profile=ctx.profile,
|
||||
rng=rng,
|
||||
seed=seed,
|
||||
latents={"video": x},
|
||||
sigmas=[float(s) for s in sig],
|
||||
timesteps=[float(s) * 1000.0 for s in sig]) # model timestep = σ·1000
|
||||
# Dual text: LLaMA hidden states (cond/uncond) ride the usual prompt-embed slots; the CLIP pooled
|
||||
# embeds (cond/uncond) ride dedicated slots and are threaded through the adapter's ``context=``.
|
||||
st.cond["prompt_embeds"] = ctx.slots.get("text_embeds")
|
||||
st.cond["negative_prompt_embeds"] = ctx.slots.get("neg_text_embeds")
|
||||
st.scratch["clip_pos"] = ctx.slots.get("clip_text_embeds")
|
||||
st.scratch["clip_neg"] = ctx.slots.get("clip_neg_text_embeds")
|
||||
st.scratch["guidance_scale"] = float(req.diffusion.guidance_scale)
|
||||
st.scratch["stream_video"] = bool(req.outputs.stream.get("video"))
|
||||
# i2v conditioning slots written by the image-cond program node. All None for pure t2v -> the 33ch
|
||||
# concat degenerates to [noise | 0 | 0] and the ref-injection branch is inert (the fastvideo
|
||||
# GameCraftDenoisingStage fallback). camera_states is a BRINGUP slot (no request plumbing yet).
|
||||
st.scratch["gt_latents"] = ctx.slots.get("gt_latents")
|
||||
st.scratch["conditioning_mask"] = ctx.slots.get("conditioning_mask")
|
||||
st.scratch["ref_latent_for_injection"] = ctx.slots.get("ref_latent_for_injection")
|
||||
st.scratch["camera_states"] = ctx.slots.get("camera_states") # BRINGUP: None until camera plumbing
|
||||
st.plugin_state["cfg"] = {}
|
||||
return st
|
||||
|
||||
def _inject_ref(self, x: np.ndarray, mask, ref) -> np.ndarray:
|
||||
"""Per-step frame_replace: overwrite conditioned frames (mask>0.5) with the clean reference latent.
|
||||
|
||||
Faithful to ``GameCraftDenoisingStage`` (``latents[:,:,t_idx] = ref[:,:,0]`` for each conditioned
|
||||
``t``). Loop latents here are unbatched ``[C,T,h,w]`` (the toy/CPU geometry), so the mask's temporal
|
||||
axis is read at ``mask[0,0,:,0,0]`` when 5D (matching the fastvideo ``[B,1,T,H,W]`` mask), or as a
|
||||
1D ``[T]`` vector otherwise. ``ref`` is the clean ref latent ``[C,1,h,w]`` (or ``[C,T,h,w]``)."""
|
||||
m = np.asarray(mask)
|
||||
if m.ndim == 5: # fastvideo-shaped [B,1,T,H,W]: read the temporal slice
|
||||
frame_mask = m[0, 0, :, 0, 0]
|
||||
elif m.ndim == 4: # loop-shaped [1,T,h,w]: collapse the spatial axes
|
||||
frame_mask = m[0, :, 0, 0]
|
||||
elif m.ndim == 1: # already a per-frame vector [T]
|
||||
frame_mask = m
|
||||
else:
|
||||
return x
|
||||
r = np.asarray(ref, dtype="float32")
|
||||
ref_frame = r[:, 0] if r.ndim >= 4 else r # [C,h,w]
|
||||
out = np.array(x, dtype="float32", copy=True)
|
||||
# The conditioned-frame replacement requires the ref frame to match the working latent's per-frame
|
||||
# geometry (it always does on the real VAE geometry). If a backend's encode produced a mismatched
|
||||
# shape (e.g. the crude CPU toy VAE that does not spatially downsample), skip injection rather than
|
||||
# crash — the degenerate denoise still proceeds.
|
||||
if ref_frame.shape != out[:, 0].shape:
|
||||
return out
|
||||
n_t = min(out.shape[1], int(frame_mask.shape[0]))
|
||||
for t_idx in range(n_t):
|
||||
if float(frame_mask[t_idx]) > 0.5:
|
||||
out[:, t_idx] = ref_frame
|
||||
return out
|
||||
|
||||
def next(self, st: LoopState):
|
||||
i = st.step_idx
|
||||
if i >= len(st.sigmas) - 1:
|
||||
return Done()
|
||||
sigma_t, sigma_next = st.sigmas[i], st.sigmas[i + 1]
|
||||
expert_id = self.expert.expert_for(StepContext(i, st.timesteps[i], sigma_t))
|
||||
sctx = StepContext(step_idx=i, timestep=st.timesteps[i], sigma=sigma_t, active_expert_id=expert_id)
|
||||
cfg_state = st.plugin_state["cfg"]
|
||||
branches = self.cfg.branches_this_step(sctx, cfg_state)
|
||||
|
||||
x = st.latents["video"]
|
||||
gt = st.scratch.get("gt_latents")
|
||||
mask = st.scratch.get("conditioning_mask")
|
||||
ref = st.scratch.get("ref_latent_for_injection")
|
||||
camera = st.scratch.get("camera_states") # BRINGUP: None for the registered path
|
||||
# Per-step clean-ref injection BEFORE the forward (frame_replace) — only when an i2v ref is present.
|
||||
if ref is not None and mask is not None:
|
||||
x = self._inject_ref(x, mask, ref)
|
||||
st.latents["video"] = x
|
||||
|
||||
pe, ne = st.cond["prompt_embeds"], st.cond["negative_prompt_embeds"]
|
||||
clip_pos, clip_neg = st.scratch.get("clip_pos"), st.scratch.get("clip_neg")
|
||||
scale = st.scratch["guidance_scale"]
|
||||
cfg, precision = self.cfg, self.precision
|
||||
# One ``cond`` pack reused across CFG branches (gt/mask/camera are branch-independent); the adapter
|
||||
# assembles the 33ch concat from it. None entries -> zero gt/mask + no camera inside the adapter.
|
||||
cond_pack = {"gt_latents": gt, "mask": mask, "camera_states": camera}
|
||||
|
||||
def _velocity(model: Any, x_: np.ndarray, sigma_t_: float, pe_: Any, clip_: Any, ne_: Any, clip_n_: Any,
|
||||
scale_: float) -> np.ndarray:
|
||||
# Conditioned forward + ClassicCFG combine. The CLIP pooled embed rides ``context=`` per branch
|
||||
# so text_states_2 differs cond vs uncond; ``cond`` carries the i2v/camera pack. The toy DiT
|
||||
# accepts+ignores both kwargs, so this is CPU-toy compatible.
|
||||
dit = model.component(expert_id)
|
||||
preds = {
|
||||
b:
|
||||
dit(x_,
|
||||
pe_ if b == "cond" else ne_,
|
||||
sigma_t_,
|
||||
context=(clip_ if b == "cond" else clip_n_),
|
||||
cond=cond_pack)
|
||||
for b in branches
|
||||
}
|
||||
return precision.cast(cfg.combine(preds, scale_, sctx, cfg_state))
|
||||
|
||||
def run(model, override=None):
|
||||
if override is not None and "noise_pred" in override:
|
||||
velocity = precision.cast(np.asarray(override["noise_pred"], dtype="float32"))
|
||||
else:
|
||||
velocity = _velocity(model, x, sigma_t, pe, clip_pos, ne, clip_neg, scale)
|
||||
x_next = model.platform.kernels.get(FLOW_MATCH_STEP)(precision.cast(x), velocity, sigma_t, sigma_next)
|
||||
return StepResult(output={
|
||||
"noise_pred": np.asarray(velocity, dtype="float32"),
|
||||
"latents": x_next.astype("float32")
|
||||
})
|
||||
|
||||
cond_bytes = sum(int(np.asarray(e).nbytes) for e in (pe, ne, clip_pos, clip_neg, gt, mask) if e is not None)
|
||||
res = ResourceRequest(compute_seconds=self.cost.predict(int(np.prod(x.shape)), float(len(branches))),
|
||||
resident_bytes=int(x.nbytes) + cond_bytes,
|
||||
peak_activation_bytes=int(x.nbytes))
|
||||
return WorkPlan(
|
||||
loop_id=self.loop_id,
|
||||
instance_id=st.instance_id,
|
||||
kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
shape_sig=ShapeSignature(WorkUnitKind.DIFFUSION_STEP,
|
||||
dims=tuple(x.shape),
|
||||
dtype=precision.compute_dtype,
|
||||
extra=(("cfg", type(cfg).__name__), ("gamecraft", True))),
|
||||
resources=res,
|
||||
payload={
|
||||
"branch": "combined",
|
||||
"step": i
|
||||
},
|
||||
run=run,
|
||||
label=f"gamecraft.denoise.{i}",
|
||||
# i2v ref-injection mutates host state + threads non-workspace conditioning each step -> the
|
||||
# static-buffer capture form would miss it, so this step takes the eager path (like cosmos2's
|
||||
# gated frame-injection). Pure-t2v could capture, but keeping one path is simpler + correct.
|
||||
capturable=False)
|
||||
|
||||
def advance(self, st: LoopState, result: StepResult) -> LoopState:
|
||||
st.latents["video"] = result.output["latents"]
|
||||
if st.profile == ExecutionProfile.ROLLOUT:
|
||||
i = st.step_idx
|
||||
st.trajectory.append({
|
||||
"step": i,
|
||||
"sigma": st.sigmas[i],
|
||||
"velocity": np.asarray(result.output["noise_pred"]).copy(),
|
||||
"latents": np.asarray(st.latents["video"]).copy()
|
||||
})
|
||||
st.step_idx += 1
|
||||
return st
|
||||
|
||||
def finalize(self, st: LoopState) -> LoopResult:
|
||||
return LoopResult(outputs={"latents": st.latents["video"]},
|
||||
metrics={"denoise_steps": float(st.step_idx)},
|
||||
behavior=st.trajectory or None)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""HunyuanGameCraft program: text_encode (LLaMA + CLIP) -> image_cond_encode (i2v) -> diffusion_denoise
|
||||
-> vae_decode.
|
||||
|
||||
Same backbone as the Wan/Cosmos t2v programs, with two GameCraft-specific deltas:
|
||||
|
||||
* **Dual text encode.** ``_text_encode_dual`` runs BOTH encoders: the LLaMA encoder (``text_encoder``)
|
||||
writes the DiT's ``text_states`` into ``text_embeds``/``neg_text_embeds`` (the usual slots the loop
|
||||
reads), and the CLIP encoder (``text_encoder_2``) writes the pooled ``text_states_2`` into
|
||||
``clip_text_embeds``/``clip_neg_text_embeds`` (threaded to the adapter's ``context=`` per CFG branch).
|
||||
On the CPU toy both are ``ToyTextEncoder`` (cached via the content-hash feature cache).
|
||||
|
||||
* **Image conditioning node (i2v).** ``_image_cond_encode`` mirrors ``GameCraftImageVAEEncodingStage``:
|
||||
VAE-encode the reference image -> scaled ``ref_latent`` (the ``GameCraftVAE`` adapter applies the
|
||||
scalar ``scaling_factor`` already), repeat to ``T_lat`` frames -> ``gt_latents``, build the binary
|
||||
``conditioning_mask`` (latent_frames<=10: only frame 0; else first half), zero ``gt_latents`` on
|
||||
non-conditioned frames, and expose ``ref_latent_for_injection = gt_latents[:, 0:1]`` for the loop's
|
||||
per-step frame_replace. No request image -> a no-op (slots stay unset) -> the loop runs pure t2v
|
||||
(zero gt/mask), exactly the fastvideo fallback.
|
||||
|
||||
BRINGUP — the camera/action conditioning node is intentionally omitted: GameCraft's CameraNet needs a
|
||||
Plücker-coordinate ``camera_states`` tensor derived from an action/camera sequence the v2 request API does
|
||||
not carry yet, and the DiT's autoregressive latent-length special-casing (18/9/10) for multi-chunk
|
||||
generation has no request surface. Adding a ``camera_encode`` node that writes the ``camera_states`` slot
|
||||
(which the loop already reads) is the follow-up once the request API is extended. T_lat helper below
|
||||
matches the GameCraft geometry (4x temporal compression).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
from v2.recipes.common import cached_text_encode
|
||||
|
||||
GAMECRAFT_TEMPORAL_RATIO = 4
|
||||
GAMECRAFT_SPATIAL_RATIO = 8
|
||||
|
||||
|
||||
def _text_encode_dual(instance: Any, slots: dict, request: Any, ctx: Any) -> None:
|
||||
"""LLaMA (text_states) + CLIP (text_states_2) for prompt + negative prompt. The shared
|
||||
``cached_text_encode`` keys on component_id, so the LLaMA and CLIP embeds never collide in the cache."""
|
||||
prompt = request.prompt()
|
||||
neg = request.diffusion.negative_prompt
|
||||
# LLaMA hidden states -> the loop's prompt-embed slots (text_states).
|
||||
slots["text_embeds"] = cached_text_encode(instance, prompt)
|
||||
slots["neg_text_embeds"] = cached_text_encode(instance, neg)
|
||||
# CLIP pooled -> dedicated slots (text_states_2), threaded through the adapter's context= per branch.
|
||||
clip = instance.component("text_encoder_2")
|
||||
slots["clip_text_embeds"] = clip.encode(prompt)
|
||||
slots["clip_neg_text_embeds"] = clip.encode(neg)
|
||||
|
||||
|
||||
def _image_cond_encode(instance: Any, slots: dict, request: Any, ctx: Any) -> None:
|
||||
"""i2v: VAE-encode the reference image -> gt_latents + conditioning_mask + ref_latent_for_injection.
|
||||
|
||||
No-op for pure t2v (no request image) so the loop runs the degenerate standard denoise. Faithful to
|
||||
``GameCraftImageVAEEncodingStage``: repeat the ref latent to all latent frames, then keep only the
|
||||
conditioned frames (first frame for short clips <=10 latent frames; first half for longer
|
||||
autoregressive clips) — both ``gt_latents`` and ``mask`` zeroed elsewhere."""
|
||||
part = request.image() if hasattr(request, "image") else None
|
||||
pixels = getattr(part, "pixels", None) if part is not None else None
|
||||
if pixels is None:
|
||||
return # T2V mode: leave the i2v slots unset (loop falls back to zero gt/mask)
|
||||
|
||||
d = request.diffusion
|
||||
# The GameCraftVAE adapter applies the scalar scaling_factor in ``encode`` (matches the fastvideo
|
||||
# stage's ``ref_latents.mul_(scaling_factor)``), so we use the returned latent directly. The real
|
||||
# adapter also runs the resize/center-crop/normalize preprocessing on the PIL image (BRINGUP); the
|
||||
# CPU toy VAE consumes the pixel array directly.
|
||||
ref_latent = np.asarray(instance.component("vae").encode(pixels), dtype="float32") # [C,1,h,w] (or [C,h,w])
|
||||
if ref_latent.ndim == 3: # [C, h, w] -> add the singleton temporal axis
|
||||
ref_latent = ref_latent[:, None]
|
||||
ref_latent = ref_latent[:, :1] # keep the first (only) reference frame -> [C, 1, h, w]
|
||||
|
||||
latent_frames = max(1, (max(1, d.num_frames) - 1) // GAMECRAFT_TEMPORAL_RATIO + 1)
|
||||
c, _t, h, w = ref_latent.shape
|
||||
gt_latents = np.repeat(ref_latent, latent_frames, axis=1) # [C, T_lat, h, w]
|
||||
mask = np.ones((1, latent_frames, h, w), dtype="float32") # [1, T_lat, h, w]
|
||||
if latent_frames <= 10: # short i2v: only frame 0 is conditioned
|
||||
gt_latents[:, 1:] = 0.0
|
||||
mask[:, 1:] = 0.0
|
||||
else: # autoregressive: first half conditioned
|
||||
half = latent_frames // 2
|
||||
gt_latents[:, half:] = 0.0
|
||||
mask[:, half:] = 0.0
|
||||
|
||||
slots["gt_latents"] = gt_latents
|
||||
slots["conditioning_mask"] = mask
|
||||
slots["ref_latent_for_injection"] = gt_latents[:, 0:1].copy() # the clean ref for frame_replace
|
||||
|
||||
|
||||
def _vae_decode(instance: Any, slots: dict, request: Any, ctx: Any) -> None:
|
||||
slots["video"] = instance.component("vae").decode(slots["denoise_out"]["latents"])
|
||||
|
||||
|
||||
def build_hunyuangamecraft_program() -> Program:
|
||||
return Program(
|
||||
program_id="gamecraft.i2v.inline",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("text_encode",
|
||||
fn=_text_encode_dual,
|
||||
writes=("text_embeds", "neg_text_embeds", "clip_text_embeds", "clip_neg_text_embeds")),
|
||||
ComponentNode("image_cond_encode",
|
||||
fn=_image_cond_encode,
|
||||
writes=("gt_latents", "conditioning_mask", "ref_latent_for_injection")),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="diffusion_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("text_embeds", "neg_text_embeds", "clip_text_embeds", "clip_neg_text_embeds",
|
||||
"gt_latents", "conditioning_mask", "ref_latent_for_injection"),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("vae_decode", fn=_vae_decode, reads=("denoise_out", ), writes=("video", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"video": "video",
|
||||
"latents": "denoise_out"
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,20 @@
|
||||
"""HY-WorldPlay-Bidirectional (interactive world model) — ported into the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapters via
|
||||
``ComponentSpec.adapter`` (``HYWorldDiT`` / ``HYWorldVAE`` / ``HYWorldQwenEncoder`` /
|
||||
``HYWorldByT5Encoder`` / ``HYWorldSiglipEncoder`` in ``v2/platform/backends/torch_hyworld.py``) and a new
|
||||
``HYWorldDenoiseLoop`` (chunk-rollout flow-match: per-chunk sweep + camera-aligned frozen context for
|
||||
chunk>0). It reuses the flow-match ``FLOW_MATCH_STEP`` kernel + ``ClassicCFG`` policy and the Wan
|
||||
``stamp_wan21_checkpoints`` superset (which covers ``text_encoder_2`` + ``image_encoder``).
|
||||
|
||||
The registered preset is the bidirectional t2v / degenerate (no action/camera) path that CPU-verifies;
|
||||
the full action/camera/memory-retrieval conditioning is BRINGUP (needs a request-API extension to carry
|
||||
the pose string + first-frame image). Registered in ``v2/registry.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.hyworld.card import build_hyworld_card
|
||||
from v2.recipes.hyworld.loop import HYWorldDenoiseLoop
|
||||
from v2.recipes.hyworld.program import build_hyworld_program
|
||||
|
||||
__all__ = ["build_hyworld_card", "build_hyworld_program", "HYWorldDenoiseLoop"]
|
||||
@@ -0,0 +1,191 @@
|
||||
"""HY-WorldPlay-Bidirectional ModelCard — interactive world model ported into the v2 substrate.
|
||||
|
||||
HY-WorldPlay is a chunk-rollout *world model*: it generates video in temporal chunks where each
|
||||
chunk after the first retrieves camera-aligned history "memory" frames as frozen context, and it
|
||||
conditions on THREE text/image streams (Qwen2.5-VL mllm + ByT5 glyph + SigLIP image) plus per-frame
|
||||
camera (viewmats/Ks, injected via ProPE attention) and per-frame action ids. The registered preset
|
||||
here is the **bidirectional t2v / degenerate (no-action, single straight-trajectory pose) path** that
|
||||
CPU-verifies; the full action/camera/memory conditioning is BRINGUP (needs a request-API extension to
|
||||
carry the pose string -> viewmats/Ks/action expansion + the point-cloud memory retrieval). See
|
||||
``loop.py`` and ``torch_hyworld.py`` for the BRINGUP markers.
|
||||
|
||||
Architecture deltas vs Wan/Cosmos (all declared on the card so the recipe is self-contained):
|
||||
* DiT ``fastvideo.models.dits.hyworld.hyworld:HYWorldTransformer3DModel`` (subclasses
|
||||
HunyuanVideo15Transformer3DModel) — a **rectified-flow velocity** predictor whose forward takes a
|
||||
65ch pre-concatenated latent ``[noise(32) | cond(32) + mask(1)]``, a LIST of text embeds
|
||||
``[qwen, byt5]``, a LIST image embed ``[siglip]``, a PER-LATENT-FRAME LongTensor ``timestep`` (+ a
|
||||
scalar ``timestep_txt``), and ``viewmats/Ks/action`` per-frame camera/action conditioning. The
|
||||
adapter (``HYWorldDiT``) marshals all of this INTERNALLY so the loop only hands it
|
||||
``dit(latent, text_embed, sigma)`` (the cosmos2/ToyDiT-compatible signature).
|
||||
* VAE ``fastvideo.models.vaes.hyworldvae:AutoencoderKLHYWorld`` (z=32, 16x spatial, 4x temporal,
|
||||
scaling_factor=1.03682) — encode -> ``.mode() * scaling_factor`` (no shift/mean), decode inverts.
|
||||
* Encoders: Qwen2.5-VL (mllm primary text) + ByT5/T5 (glyph) + SigLIP (image). For the t2v preset the
|
||||
glyph + image streams are zeroed (the DiT detects an all-zero image and masks it).
|
||||
* Sampler: flow-match (``FlowMatchEulerDiscreteScheduler``) but the preset OVERRIDES the schedule with
|
||||
explicit sigmas ``linspace(1.0, 0.0, 51)[:-1]`` (50 steps), flow_shift 5.0, guidance 6.0.
|
||||
``stamp_wan21_checkpoints`` applies (diffusers transformer/vae/text_encoder/text_encoder_2/image_encoder
|
||||
subfolder layout); the superset stamp helper already covers ``text_encoder_2`` + ``image_encoder``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import ClassicCFG, FlowShiftPolicy, NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyDiT, ToyImageEncoder, ToyTextEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.hyworld.loop import (
|
||||
HYWORLD_CHUNK_LATENT_FRAMES,
|
||||
HYWORLD_LATENT_CHANNELS,
|
||||
HYWORLD_SPATIAL_RATIO,
|
||||
HYWORLD_STABILIZATION_LEVEL,
|
||||
HYWORLD_TEMPORAL_RATIO,
|
||||
HYWorldDenoiseLoop,
|
||||
)
|
||||
from v2.recipes.wan21.card import stamp_wan21_checkpoints
|
||||
|
||||
# Adapter refs declared on the card -> built lazily on a GPU box by ``_explicit_adapter`` (so the port
|
||||
# never edits the shared ``_make_dit``/``_make_vae``/``_make_text_encoder``/``_make_image_encoder``).
|
||||
_HYWORLD_DIT = "v2.platform.backends.torch_hyworld:HYWorldDiT"
|
||||
_HYWORLD_VAE = "v2.platform.backends.torch_hyworld:HYWorldVAE"
|
||||
_HYWORLD_QWEN = "v2.platform.backends.torch_hyworld:HYWorldQwenEncoder"
|
||||
_HYWORLD_BYT5 = "v2.platform.backends.torch_hyworld:HYWorldByT5Encoder"
|
||||
_HYWORLD_SIGLIP = "v2.platform.backends.torch_hyworld:HYWorldSiglipEncoder"
|
||||
|
||||
# The preset's negative prompt is empty (kept as a LOCAL module constant, not in the shared _prompts.py).
|
||||
HYWORLD_NEG = ""
|
||||
|
||||
# The preset's explicit sigma schedule: linspace(1.0, 0.0, 51)[:-1] (50 values). The FlowShiftPolicy /
|
||||
# the loop consume ``sigmas`` directly when present (the flow_shift below is the schedule fallback).
|
||||
_HYWORLD_SIGMAS = tuple(1.0 - i / 50.0 for i in range(50))
|
||||
|
||||
|
||||
def build_hyworld_card(model_id: str = "hy-worldplay-bidirectional",
|
||||
*,
|
||||
checkpoint_root: str | None = None,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = ClassicCFG() # uncond + s·(cond - uncond); the pos/neg branches swap the encoder_attention_mask
|
||||
# flow_shift=5.0 is the scheduler shift; the preset overrides the schedule with explicit sigmas, so
|
||||
# FlowShiftPolicy.build_schedule(sigmas=...) returns those verbatim (shift becomes a no-op there).
|
||||
flow = FlowShiftPolicy(shift=5.0)
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer") # single DiT (the bidirectional model is not MoE)
|
||||
|
||||
def loop_factory() -> HYWorldDenoiseLoop:
|
||||
return HYWorldDenoiseLoop(loop_id="hyworld_denoise",
|
||||
cfg=cfg,
|
||||
flow_shift=flow,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost,
|
||||
latent_channels=HYWORLD_LATENT_CHANNELS,
|
||||
spatial_ratio=HYWORLD_SPATIAL_RATIO,
|
||||
temporal_ratio=HYWORLD_TEMPORAL_RATIO,
|
||||
chunk_latent_frames=HYWORLD_CHUNK_LATENT_FRAMES,
|
||||
stabilization_level=HYWORLD_STABILIZATION_LEVEL)
|
||||
|
||||
components = {
|
||||
# Primary text stream — Qwen2.5-VL mllm (hidden_states[-3], crops 108 template tokens on GPU).
|
||||
"text_encoder":
|
||||
ComponentSpec(component_id="text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.qwen2_5:Qwen2_5_VLTextModel",
|
||||
adapter=_HYWORLD_QWEN,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2v"}),
|
||||
# Glyph text stream — ByT5/T5 (rendered-text encoder, text_states_dim_2=1472). Zeroed for plain t2v.
|
||||
"text_encoder_2":
|
||||
ComponentSpec(component_id="text_encoder_2",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.t5_hf:T5EncoderModel",
|
||||
adapter=_HYWORLD_BYT5,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
optional_for={"t2v"}),
|
||||
# Image stream — SigLIP (729 tokens x 1152 dim). Zeroed for t2v -> the DiT masks the image stream.
|
||||
"image_encoder":
|
||||
ComponentSpec(component_id="image_encoder",
|
||||
kind="image_encoder",
|
||||
load_id="fastvideo.models.encoders.siglip:SiglipVisionModel",
|
||||
adapter=_HYWORLD_SIGLIP,
|
||||
factory=lambda inst: ToyImageEncoder(),
|
||||
optional_for={"t2v"}),
|
||||
"vae":
|
||||
ComponentSpec(component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.hyworldvae:AutoencoderKLHYWorld",
|
||||
adapter=_HYWORLD_VAE,
|
||||
factory=lambda inst: ToyVAE(),
|
||||
required_for={"t2v"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.hyworld.hyworld:HYWorldTransformer3DModel",
|
||||
adapter=_HYWORLD_DIT,
|
||||
factory=lambda inst: ToyDiT(seed=seed),
|
||||
resident_for=["hyworld_denoise"],
|
||||
required_for={"t2v"}),
|
||||
}
|
||||
loops = {
|
||||
"hyworld_denoise":
|
||||
LoopSpec(
|
||||
loop_id="hyworld_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="eager", # chunk rollout + camera-aligned memory retrieval -> eager path
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="hyworld",
|
||||
components=components,
|
||||
loops=loops,
|
||||
capabilities=CapabilityMatrix.of(
|
||||
Capability.TEXT_TO_VIDEO, # the registered (CPU-verified) degenerate path
|
||||
Capability.IMAGE_TO_VIDEO, # first-frame SigLIP + VAE cond latent (BRINGUP)
|
||||
Capability.ACTION_CONDITIONING, # per-frame action ids (BRINGUP)
|
||||
Capability.STREAMING_VIDEO_CONTINUATION, # chunk rollout = interactive continuation (BRINGUP)
|
||||
Capability.VAE_DECODE,
|
||||
Capability.POLICY_ROLLOUT),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="hyworld_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
sampling_defaults=sampling_defaults or SamplingDefaults(num_steps=50,
|
||||
guidance_scale=6.0,
|
||||
height=480,
|
||||
width=832,
|
||||
num_frames=125,
|
||||
fps=24,
|
||||
negative_prompt=HYWORLD_NEG,
|
||||
shift=5.0,
|
||||
sigmas=_HYWORLD_SIGMAS),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
# Diffusers layout: transformer/ vae/ text_encoder/ text_encoder_2/ image_encoder/ scheduler/.
|
||||
# The Wan stamp superset already covers text_encoder_2 + image_encoder (BRINGUP risk A).
|
||||
stamp_wan21_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
@@ -0,0 +1,243 @@
|
||||
"""HYWorldDenoiseLoop — chunk-rollout flow-match denoise for the HY-WorldPlay world model.
|
||||
|
||||
The standard ``WanDenoiseLoop`` runs ONE flow-match sweep over the full latent with a scalar timestep.
|
||||
HY-WorldPlay instead splits the latent into temporal CHUNKS (``chunk_latent_frames=16``) and rolls out
|
||||
chunk by chunk (faithful to ``fastvideo/pipelines/stages/hyworld_denoising.py:HYWorldDenoisingStage``):
|
||||
|
||||
* chunk 0 (always runs): a standard per-chunk flow-match sweep over the first 16 latent frames; the
|
||||
per-frame timestep is just the live ``t`` broadcast to all 16 frames.
|
||||
* chunk>0 (BRINGUP, see below): selects camera-aligned history "memory" frames via
|
||||
``select_aligned_memory_frames`` (a 50k-point-sphere geometry op), prepends them as FROZEN context
|
||||
(timestep pinned at ``stabilization_level - 1 = 14``) with the live chunk's timestep ``t``, denoises
|
||||
only the current 16-frame slice, and writes that slice back.
|
||||
|
||||
The flow-match ODE math + ClassicCFG reuse v2's ``FLOW_MATCH_STEP`` kernel + ``ClassicCFG`` policy; the
|
||||
chunk / context / dual-timestep orchestration is the new part. The DiT adapter (``HYWorldDiT``) marshals
|
||||
the 65ch latent concat, the 3-stream conditioning list, the per-frame heterogeneous timestep, and the
|
||||
camera/action tensors INTERNALLY, so this loop only calls the cosmos2/ToyDiT-compatible
|
||||
``dit(latent, text_embed, sigma)`` (with the per-chunk camera context threaded via ``context=``).
|
||||
|
||||
BRINGUP (documented honestly — needs a request-API extension to be real):
|
||||
* The default preset (num_frames=125 -> ~32 latent frames, chunk 16 -> 2 chunks) WILL execute chunk>0.
|
||||
Camera-aligned memory retrieval, ProPE camera attention, and per-frame action conditioning all need
|
||||
``viewmats``/``Ks``/``action`` carried from a pose string (``pose="w-31"`` -> ``pose_to_input`` +
|
||||
``compute_latent_num``). The v2 request/program has no slot for the pose string yet, so on the CPU
|
||||
toy path (and absent a pose) this loop runs the **degenerate single-chunk t2v path**: it clamps
|
||||
``chunk_num`` to the number of whole chunks but the camera/memory selection is skipped (the adapter
|
||||
builds zero camera/action internally). On a GPU box with a pose-carrying request, the chunk>0 branch
|
||||
(memory retrieval + frozen context) activates — that path is written-not-run here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2._enums import ExecutionProfile, WorkUnitKind
|
||||
from v2.loop.contracts import (
|
||||
Done,
|
||||
LoopResult,
|
||||
LoopState,
|
||||
ResourceRequest,
|
||||
ShapeSignature,
|
||||
StepContext,
|
||||
StepResult,
|
||||
WorkPlan,
|
||||
)
|
||||
from v2.platform import FLOW_MATCH_STEP
|
||||
from v2.recipes.wan21.loop import latent_shape
|
||||
|
||||
# Real HY-WorldPlay VAE (AutoencoderKLHYWorld) compression: z=32, 4x temporal, 16x spatial.
|
||||
HYWORLD_LATENT_CHANNELS = 32
|
||||
HYWORLD_TEMPORAL_RATIO = 4
|
||||
HYWORLD_SPATIAL_RATIO = 16
|
||||
# Chunk rollout config (from HYWorldDenoisingStage + the preset defaults).
|
||||
HYWORLD_CHUNK_LATENT_FRAMES = 16
|
||||
HYWORLD_STABILIZATION_LEVEL = 15 # context (history) frames are pinned at stabilization_level - 1 = 14
|
||||
|
||||
|
||||
class HYWorldDenoiseLoop:
|
||||
"""Outer chunk loop x inner flow-match step loop. ``next``/``advance`` step the inner loop and roll
|
||||
the outer chunk index when a chunk's sweep finishes (all per-request state in ``LoopState``)."""
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
loop_id,
|
||||
cfg,
|
||||
flow_shift,
|
||||
precision,
|
||||
expert,
|
||||
cost,
|
||||
latent_channels: int = HYWORLD_LATENT_CHANNELS,
|
||||
spatial_ratio: int = HYWORLD_SPATIAL_RATIO,
|
||||
temporal_ratio: int = HYWORLD_TEMPORAL_RATIO,
|
||||
chunk_latent_frames: int = HYWORLD_CHUNK_LATENT_FRAMES,
|
||||
stabilization_level: int = HYWORLD_STABILIZATION_LEVEL):
|
||||
self.loop_id = loop_id
|
||||
self.cfg = cfg
|
||||
self.flow_shift = flow_shift
|
||||
self.precision = precision
|
||||
self.expert = expert
|
||||
self.cost = cost
|
||||
self.latent_channels = latent_channels
|
||||
self.spatial_ratio = spatial_ratio
|
||||
self.temporal_ratio = temporal_ratio
|
||||
self.chunk_latent_frames = chunk_latent_frames
|
||||
self.stabilization_level = stabilization_level
|
||||
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
seed = req.diffusion.seed if req.diffusion.seed is not None else 0
|
||||
rng = np.random.default_rng(seed)
|
||||
# Preset overrides the schedule with explicit sigmas linspace(1.0, 0.0, 51)[:-1]; otherwise the
|
||||
# flow-shifted schedule. ``timesteps = sigma * 1000`` (num_train_timesteps=1000) per the convention.
|
||||
sig = self.flow_shift.build_schedule(req.diffusion.num_steps,
|
||||
req.diffusion.height,
|
||||
req.diffusion.width,
|
||||
sigmas=req.diffusion.sigmas or None)
|
||||
shape = latent_shape(req,
|
||||
model,
|
||||
channels=self.latent_channels,
|
||||
spatial_ratio=self.spatial_ratio,
|
||||
temporal_ratio=self.temporal_ratio)
|
||||
x = (rng.standard_normal(shape) * float(sig[0])).astype("float32")
|
||||
st = LoopState(loop_id=self.loop_id,
|
||||
instance_id=model.card.model_id,
|
||||
request_id=req.request_id,
|
||||
profile=ctx.profile,
|
||||
rng=rng,
|
||||
seed=seed,
|
||||
latents={"video": x},
|
||||
sigmas=[float(s) for s in sig],
|
||||
timesteps=[float(s) * 1000.0 for s in sig])
|
||||
st.cond["prompt_embeds"] = ctx.slots.get("text_embeds")
|
||||
st.cond["negative_prompt_embeds"] = ctx.slots.get("neg_text_embeds")
|
||||
st.scratch["guidance_scale"] = float(req.diffusion.guidance_scale)
|
||||
# Chunk layout: the latent's temporal dim split into whole chunks of chunk_latent_frames. The toy
|
||||
# CPU latent is tiny (T may be < chunk size) -> clamp to a single chunk so the loop still exercises.
|
||||
n_frames = int(x.shape[1])
|
||||
chunk = max(1, min(self.chunk_latent_frames, n_frames))
|
||||
st.scratch["chunk_latent_frames"] = chunk
|
||||
st.scratch["chunk_num"] = max(1, n_frames // chunk)
|
||||
st.scratch["chunk_idx"] = 0
|
||||
# World-model conditioning (BRINGUP): the pose string -> viewmats/Ks/action expansion and the
|
||||
# camera-aligned memory retrieval need a request-API slot that v2 does not carry yet. Absent (CPU
|
||||
# toy / no pose) -> chunk>0 runs WITHOUT the frozen camera-aligned context (degenerate per-chunk
|
||||
# sweep). The adapter builds zero camera/action internally so the DiT call is unchanged.
|
||||
st.scratch["camera_ctx"] = ctx.slots.get("camera_ctx") # {viewmats, Ks, action, points_local} | None
|
||||
# I2V (BRINGUP): SigLIP image embeds + the [cond_latent | mask] (33ch) latent; None for t2v -> the
|
||||
# adapter passes zero image embeds + a zero cond latent (the DiT detects all-zero image -> masks it).
|
||||
st.scratch["i2v_img_embeds"] = ctx.slots.get("i2v_img_embeds")
|
||||
st.scratch["i2v_cond"] = ctx.slots.get("i2v_cond")
|
||||
st.plugin_state["cfg"] = {}
|
||||
return st
|
||||
|
||||
def next(self, st: LoopState):
|
||||
# Inner step index walks the sigma schedule; when it reaches the end, roll to the next chunk.
|
||||
i = st.step_idx
|
||||
n_steps = len(st.sigmas) - 1
|
||||
chunk_idx = st.scratch["chunk_idx"]
|
||||
if chunk_idx >= st.scratch["chunk_num"]:
|
||||
return Done()
|
||||
if i >= n_steps:
|
||||
return Done()
|
||||
sigma_t, sigma_next = st.sigmas[i], st.sigmas[i + 1]
|
||||
expert_id = self.expert.expert_for(StepContext(i, st.timesteps[i], sigma_t))
|
||||
sctx = StepContext(step_idx=i, timestep=st.timesteps[i], sigma=sigma_t, active_expert_id=expert_id)
|
||||
cfg_state = st.plugin_state["cfg"]
|
||||
branches = self.cfg.branches_this_step(sctx, cfg_state)
|
||||
chunk = st.scratch["chunk_latent_frames"]
|
||||
start = chunk_idx * chunk
|
||||
end = start + chunk
|
||||
# The current chunk's latent slice (the only frames this chunk denoises + writes back).
|
||||
x_full = st.latents["video"]
|
||||
x_chunk = np.asarray(x_full[:, start:end], dtype="float32")
|
||||
pe, ne = st.cond["prompt_embeds"], st.cond["negative_prompt_embeds"]
|
||||
scale = st.scratch["guidance_scale"]
|
||||
cfg, precision = self.cfg, self.precision
|
||||
# Per-chunk camera/action context threaded to the adapter (None on the CPU/degenerate path). The
|
||||
# adapter is responsible for slicing it to [start:end] and prepending memory frames (BRINGUP).
|
||||
cam_ctx = st.scratch.get("camera_ctx")
|
||||
i2v_ctx, i2v_cond = st.scratch.get("i2v_img_embeds"), st.scratch.get("i2v_cond")
|
||||
# The conditioning the DiT call carries: a per-chunk dict (camera/action/i2v) the adapter unpacks.
|
||||
# Kept as a single ``context=`` arg so the loop's dit-call stays ToyDiT-compatible (cosmos2 rule).
|
||||
chunk_context = None
|
||||
if cam_ctx is not None or i2v_ctx is not None or i2v_cond is not None:
|
||||
chunk_context = {
|
||||
"camera": cam_ctx,
|
||||
"chunk": (start, end),
|
||||
"chunk_idx": chunk_idx,
|
||||
"i2v_img_embeds": i2v_ctx,
|
||||
"i2v_cond": i2v_cond,
|
||||
"stabilization_level": self.stabilization_level
|
||||
}
|
||||
|
||||
def _velocity(model: object, x_: np.ndarray, sigma_t_: float, pe_: object, ne_: object,
|
||||
scale_: float) -> np.ndarray:
|
||||
# Conditioned forward + ClassicCFG combine. The pos/neg branches differ only in the text embed
|
||||
# (the adapter swaps the matching encoder_attention_mask internally). The chunk's camera/action
|
||||
# conditioning rides in ``context``; None -> the DiT runs the plain (degenerate) chunk forward.
|
||||
dit = model.component(expert_id) # type: ignore[attr-defined]
|
||||
preds = {b: dit(x_, pe_ if b == "cond" else ne_, sigma_t_, context=chunk_context) for b in branches}
|
||||
return precision.cast(cfg.combine(preds, scale_, sctx, cfg_state))
|
||||
|
||||
def run(model, override=None):
|
||||
kernels = model.platform.kernels
|
||||
if override is not None and "noise_pred" in override:
|
||||
velocity = precision.cast(np.asarray(override["noise_pred"], dtype="float32"))
|
||||
else:
|
||||
velocity = _velocity(model, x_chunk, sigma_t, pe, ne, scale)
|
||||
# Flow-match Euler on the CURRENT-CHUNK slice only (HYWorldDenoisingStage steps the chunk).
|
||||
x_next = kernels.get(FLOW_MATCH_STEP)(precision.cast(x_chunk), velocity, sigma_t, sigma_next)
|
||||
return StepResult(
|
||||
output={
|
||||
"noise_pred": np.asarray(velocity, dtype="float32"),
|
||||
"latents": x_next.astype("float32"),
|
||||
"chunk": (start, end)
|
||||
})
|
||||
|
||||
cond_bytes = sum(int(np.asarray(e).nbytes) for e in (pe, ne) if e is not None)
|
||||
res = ResourceRequest(compute_seconds=self.cost.predict(int(np.prod(x_chunk.shape)), float(len(branches))),
|
||||
resident_bytes=int(x_full.nbytes) + cond_bytes,
|
||||
peak_activation_bytes=int(x_chunk.nbytes))
|
||||
return WorkPlan(
|
||||
loop_id=self.loop_id,
|
||||
instance_id=st.instance_id,
|
||||
kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
shape_sig=ShapeSignature(WorkUnitKind.DIFFUSION_STEP,
|
||||
dims=tuple(x_chunk.shape),
|
||||
dtype=precision.compute_dtype,
|
||||
extra=(("cfg", type(cfg).__name__), ("chunk", chunk_idx))),
|
||||
resources=res,
|
||||
payload={
|
||||
"branch": "combined",
|
||||
"step": i,
|
||||
"chunk": chunk_idx
|
||||
},
|
||||
run=run,
|
||||
label=f"hyworld.denoise.c{chunk_idx}.{i}",
|
||||
# Chunk rollout + camera-aligned memory retrieval + heterogeneous per-frame timestep -> eager.
|
||||
capturable=False)
|
||||
|
||||
def advance(self, st: LoopState, result: StepResult) -> LoopState:
|
||||
# Write the denoised chunk slice back into the full latent (only this chunk's frames change).
|
||||
start, end = result.output["chunk"]
|
||||
x_full = np.asarray(st.latents["video"], dtype="float32")
|
||||
x_full[:, start:end] = np.asarray(result.output["latents"], dtype="float32")
|
||||
st.latents["video"] = x_full
|
||||
if st.profile == ExecutionProfile.ROLLOUT:
|
||||
st.trajectory.append({
|
||||
"step": st.step_idx,
|
||||
"chunk": st.scratch["chunk_idx"],
|
||||
"sigma": st.sigmas[st.step_idx],
|
||||
"velocity": np.asarray(result.output["noise_pred"]).copy(),
|
||||
"latents": np.asarray(st.latents["video"]).copy()
|
||||
})
|
||||
st.step_idx += 1
|
||||
if st.step_idx >= len(st.sigmas) - 1: # this chunk's flow-match sweep finished -> next chunk
|
||||
st.scratch["chunk_idx"] += 1
|
||||
st.step_idx = 0
|
||||
st.plugin_state["cfg"] = {} # reset per-chunk CFG state
|
||||
return st
|
||||
|
||||
def finalize(self, st: LoopState) -> LoopResult:
|
||||
return LoopResult(outputs={"latents": st.latents["video"]},
|
||||
metrics={"chunks": float(st.scratch["chunk_num"])},
|
||||
behavior=st.trajectory or None)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""HY-WorldPlay t2v program: text_encode -> hyworld_denoise (chunk rollout) -> vae_decode.
|
||||
|
||||
Mirrors the cosmos2/wan inline node graph. The world-model specifics (3-stream conditioning, per-frame
|
||||
camera/action, camera-aligned memory retrieval) live entirely in ``HYWorldDenoiseLoop`` + the
|
||||
``HYWorldDiT`` adapter, so the registered (CPU-verified) degenerate t2v path needs only the standard
|
||||
three nodes.
|
||||
|
||||
BRINGUP hooks (left as no-op slot writers + documented, not wired, because v2's request API has no
|
||||
pose-string / first-frame-image fields yet):
|
||||
* ``camera_ctx`` — a pose string (``"w-31"``) expanded via ``pose.pose_to_input`` + ``compute_latent_num``
|
||||
to ``viewmats[T,4,4] / Ks[T,3,3] / action[T]`` plus a 50k-point sphere point cloud
|
||||
(``generate_points_in_sphere``). A future ``_camera_encode`` node would write it into ``slots``; the
|
||||
loop already reads ``ctx.slots.get("camera_ctx")``.
|
||||
* ``i2v_img_embeds`` / ``i2v_cond`` — SigLIP image embeds + the VAE-encoded first-frame ``[cond|mask]``
|
||||
(33ch) latent. A future ``_image_encode`` node (SigLIP + VAE-encode of the first frame) would write
|
||||
them; the loop already reads them. Absent -> the DiT sees zero image -> masks the image stream (t2v).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
from v2.recipes.common import text_encode_node_fn as _text_encode
|
||||
|
||||
|
||||
def _vae_decode(instance, slots, request, ctx) -> None:
|
||||
slots["video"] = instance.component("vae").decode(slots["denoise_out"]["latents"])
|
||||
|
||||
|
||||
def build_hyworld_program() -> Program:
|
||||
return Program(
|
||||
program_id="hyworld.t2v.inline",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("text_encode", fn=_text_encode, writes=("text_embeds", "neg_text_embeds")),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="hyworld_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("text_embeds", "neg_text_embeds"),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("vae_decode", fn=_vae_decode, reads=("denoise_out", ), writes=("video", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"video": "video",
|
||||
"latents": "denoise_out"
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Kandinsky-5.0-T2V-Lite — text→video ported into the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapters via
|
||||
``ComponentSpec.adapter`` (``Kandinsky5DiT`` / ``Kandinsky5QwenEncoder`` / ``Kandinsky5ClipEncoder`` /
|
||||
``Kandinsky5VAE`` in ``v2/platform/backends/torch_kandinsky5.py``) and a new ``Kandinsky5DenoiseLoop``
|
||||
(flow-match Euler over a CHANNELS-LAST latent with a dual Qwen+CLIP conditioning stream). It reuses the
|
||||
shared flow-match sampler (``FlowShiftPolicy.build_schedule`` + ``FLOW_MATCH_STEP``) and ``ClassicCFG``.
|
||||
Registered in ``v2/registry.py`` by the orchestrator.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.kandinsky5.card import KANDINSKY5_NEG, build_kandinsky5_card, stamp_kandinsky5_checkpoints
|
||||
from v2.recipes.kandinsky5.loop import Kandinsky5DenoiseLoop
|
||||
from v2.recipes.kandinsky5.program import build_kandinsky5_program
|
||||
|
||||
__all__ = [
|
||||
"build_kandinsky5_card", "build_kandinsky5_program", "Kandinsky5DenoiseLoop", "stamp_kandinsky5_checkpoints",
|
||||
"KANDINSKY5_NEG"
|
||||
]
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Kandinsky-5.0-T2V-Lite ModelCard — text→video, ported into the v2 substrate.
|
||||
|
||||
Architecture deltas vs Wan (all declared on the card so the recipe is self-contained — the
|
||||
bucket-C pattern: torch adapters via ``ComponentSpec.adapter``, a new ``Kandinsky5DenoiseLoop``):
|
||||
|
||||
* DiT ``fastvideo.models.dits.kandinsky5:Kandinsky5Transformer3DModel`` — a flow-match velocity
|
||||
predictor, BUT with **channels-LAST** latent geometry ``[B, T, H, W, C]`` (C=4, NOT Wan's
|
||||
channels-first ``[B, C, T, H, W]``) and TWO conditioning streams: Qwen2.5-VL token embeds
|
||||
(``encoder_hidden_states``) + a CLIP **pooled** vector (``pooled_projections``, folded into the
|
||||
time embedding — mandatory, the forward raises if it is None). The forward also requires per-request
|
||||
RoPE positions (``visual_rope_pos`` over the patched grid, ``text_rope_pos`` over the Qwen seq len)
|
||||
and a resolution-dependent ``scale_factor``. The adapter (-> ``Kandinsky5DiT``) builds all of these
|
||||
internally so the loop's dit-call stays compatible with the CPU ``ToyDiT`` signature.
|
||||
* VAE ``fastvideo.models.vaes.hunyuanvae:AutoencoderKLHunyuanVideo`` — HunyuanVideo VAE (z=4, 8×/4×)
|
||||
with a SCALAR ``scaling_factor`` (latent = raw·sf; decode raw = latent/sf), NOT Wan's per-channel
|
||||
``(z-mean)/std`` — reusing the Wan VAE adapter would corrupt latents (-> ``Kandinsky5VAE``).
|
||||
* Text ``fastvideo.models.encoders.qwen2_5:Qwen2_5_VLTextModel`` (Qwen embeds, in_text_dim=3584) +
|
||||
``fastvideo.models.encoders.clip:CLIPTextModelWithProjection`` (pooled, in_text_dim2=768). Two
|
||||
text_encoder components (``text_encoder`` / ``text_encoder_2``) with distinct adapters.
|
||||
* sched ``FlowMatchEulerDiscreteScheduler`` — plain flow-match Euler (velocity prediction, σ·1000
|
||||
timestep convention); CFG combines ``uncond + s·(cond − uncond)`` == ``ClassicCFG``.
|
||||
|
||||
``stamp_kandinsky5_checkpoints`` (a local superset of ``stamp_wan21_checkpoints``) points each
|
||||
component at its diffusers subfolder, adding ``text_encoder_2``/``tokenizer_2`` which the Wan stamp
|
||||
lacks. ``flow_shift`` is read from the checkpoint's ``scheduler/scheduler_config.json`` at stamp time
|
||||
(it is NOT hardcoded in the upstream code — the scheduler code default is 1.0).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import ClassicCFG, FlowShiftPolicy, NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyDiT, ToyTextEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.kandinsky5.loop import (
|
||||
KANDINSKY5_LATENT_CHANNELS,
|
||||
KANDINSKY5_SPATIAL_RATIO,
|
||||
KANDINSKY5_TEMPORAL_RATIO,
|
||||
Kandinsky5DenoiseLoop,
|
||||
)
|
||||
|
||||
# Kandinsky-5 default negative prompt (kept as a LOCAL module constant, not in the shared _prompts.py,
|
||||
# so the recipe is self-contained). From the diffusers pipeline / repo defaults.
|
||||
KANDINSKY5_NEG = ("Static, 2D cartoon, cartoon, 2d animation, paintings, images, worst quality, "
|
||||
"low quality, ugly, deformed, walking backwards")
|
||||
|
||||
_KANDINSKY5_DIT = "v2.platform.backends.torch_kandinsky5:Kandinsky5DiT"
|
||||
_KANDINSKY5_QWEN = "v2.platform.backends.torch_kandinsky5:Kandinsky5QwenEncoder"
|
||||
_KANDINSKY5_CLIP = "v2.platform.backends.torch_kandinsky5:Kandinsky5ClipEncoder"
|
||||
_KANDINSKY5_VAE = "v2.platform.backends.torch_kandinsky5:Kandinsky5VAE"
|
||||
|
||||
|
||||
def build_kandinsky5_card(model_id: str = "kandinsky-5.0-t2v-lite-5s",
|
||||
*,
|
||||
checkpoint_root: str | None = None,
|
||||
flow_shift: float = 1.0,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = ClassicCFG()
|
||||
# flow_shift default is 1.0 (the scheduler code default); the real value is read from the
|
||||
# checkpoint's scheduler_config.json by stamp_kandinsky5_checkpoints at GPU deploy time.
|
||||
flow = FlowShiftPolicy(shift=flow_shift)
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer")
|
||||
|
||||
def loop_factory():
|
||||
return Kandinsky5DenoiseLoop(loop_id="diffusion_denoise",
|
||||
cfg=cfg,
|
||||
flow_shift=flow,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost,
|
||||
latent_channels=KANDINSKY5_LATENT_CHANNELS,
|
||||
spatial_ratio=KANDINSKY5_SPATIAL_RATIO,
|
||||
temporal_ratio=KANDINSKY5_TEMPORAL_RATIO)
|
||||
|
||||
components = {
|
||||
# Qwen2.5-VL: token embeds [seq, 3584] -> encoder_hidden_states (the cross-attended text stream).
|
||||
"text_encoder":
|
||||
ComponentSpec(component_id="text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.qwen2_5:Qwen2_5_VLTextModel",
|
||||
adapter=_KANDINSKY5_QWEN,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2v"}),
|
||||
# CLIP-with-projection: pooled vector [768] -> pooled_projections (folded into the time embed).
|
||||
"text_encoder_2":
|
||||
ComponentSpec(component_id="text_encoder_2",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.clip:CLIPTextModelWithProjection",
|
||||
adapter=_KANDINSKY5_CLIP,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2v"}),
|
||||
"vae":
|
||||
ComponentSpec(component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.hunyuanvae:AutoencoderKLHunyuanVideo",
|
||||
adapter=_KANDINSKY5_VAE,
|
||||
factory=lambda inst: ToyVAE(channels=KANDINSKY5_LATENT_CHANNELS),
|
||||
required_for={"t2v"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.kandinsky5:Kandinsky5Transformer3DModel",
|
||||
adapter=_KANDINSKY5_DIT,
|
||||
factory=lambda inst: ToyDiT(channels=KANDINSKY5_LATENT_CHANNELS, seed=seed),
|
||||
resident_for=["diffusion_denoise"],
|
||||
required_for={"t2v"}),
|
||||
}
|
||||
loops = {
|
||||
"diffusion_denoise":
|
||||
LoopSpec(loop_id="diffusion_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="breakable_cudagraph",
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="kandinsky5",
|
||||
components=components,
|
||||
loops=loops,
|
||||
capabilities=CapabilityMatrix.of(Capability.TEXT_TO_VIDEO, Capability.VAE_DECODE, Capability.POLICY_ROLLOUT),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="diffusion_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
sampling_defaults=sampling_defaults or SamplingDefaults(num_steps=50,
|
||||
guidance_scale=5.0,
|
||||
height=512,
|
||||
width=768,
|
||||
num_frames=121,
|
||||
fps=24,
|
||||
negative_prompt=KANDINSKY5_NEG),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_kandinsky5_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
|
||||
|
||||
# Diffusers checkpoint layout for Kandinsky-5: a superset of the Wan layout adding the SECOND text
|
||||
# encoder + its tokenizer (the shared _WAN21_SUBFOLDERS only has one text_encoder/tokenizer).
|
||||
_KANDINSKY5_SUBFOLDERS = {
|
||||
"transformer": "transformer",
|
||||
"vae": "vae",
|
||||
"text_encoder": "text_encoder",
|
||||
"text_encoder_2": "text_encoder_2"
|
||||
}
|
||||
|
||||
|
||||
def stamp_kandinsky5_checkpoints(card: ModelCard, model_root: str) -> ModelCard:
|
||||
"""GPU deploy-time: point each component's ``ComponentSpec.checkpoint`` at its weights subfolder under
|
||||
``model_root`` (a local diffusers dir or an HF id resolved via ``snapshot_download``), and read the
|
||||
real ``flow_shift`` from ``scheduler/scheduler_config.json`` into the loop's FlowShiftPolicy (it is NOT
|
||||
hardcoded upstream — code default 1.0; BRINGUP risk J). The CLIP encoder's tokenizer lives in
|
||||
``tokenizer_2`` (NOT the ``tokenizer`` the built-in text-encoder maker probes) — the Kandinsky5ClipEncoder
|
||||
adapter re-resolves it from the model root (BRINGUP). Mutates and returns the card."""
|
||||
import json
|
||||
import os
|
||||
if not os.path.isdir(model_root):
|
||||
from huggingface_hub import snapshot_download
|
||||
model_root = snapshot_download(model_root)
|
||||
for component_id, subfolder in _KANDINSKY5_SUBFOLDERS.items():
|
||||
if component_id in card.components:
|
||||
card.components[component_id].checkpoint = os.path.join(model_root, subfolder)
|
||||
# BRINGUP J: lift flow_shift from the scheduler config so >1.0 schedules are honored on GPU.
|
||||
sched_cfg = os.path.join(model_root, "scheduler", "scheduler_config.json")
|
||||
if os.path.exists(sched_cfg):
|
||||
with open(sched_cfg) as f:
|
||||
shift = json.load(f).get("shift")
|
||||
if shift is not None:
|
||||
loop_spec = card.loops.get("diffusion_denoise")
|
||||
base_factory = loop_spec.loop_factory if loop_spec is not None else None
|
||||
if base_factory is not None:
|
||||
|
||||
def _shifted_factory(_base=base_factory, _shift=float(shift)):
|
||||
loop = _base()
|
||||
loop.flow_shift.shift = _shift
|
||||
return loop
|
||||
|
||||
loop_spec.loop_factory = _shifted_factory
|
||||
return card
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Kandinsky5DenoiseLoop — flow-match Euler over a CHANNELS-LAST video latent with a dual text stream.
|
||||
|
||||
Faithful port of the Kandinsky-5.0 T2V denoise loop (diffusers ``KandinskyV5...Pipeline.__call__``):
|
||||
N flow-match Euler steps over the full latent, velocity prediction, ``ClassicCFG`` combine
|
||||
(``uncond + s·(cond − uncond)``), σ·1000 timestep convention. It mirrors ``WanDenoiseLoop`` but
|
||||
diverges on the three things Kandinsky needs (BRINGUP risks A/B/D from the port spec):
|
||||
|
||||
1. **Channels-LAST DiT geometry, but a channels-FIRST loop latent.** The DiT forward consumes
|
||||
``hidden_states[B, T, H, W, C]`` (C=4) and returns a channels-last velocity. The loop, however,
|
||||
keeps the latent channels-FIRST ``(c, t, h, w)`` — the same convention as Wan and the CPU
|
||||
``ToyDiT`` (which channel-mixes over axis 0). The ``Kandinsky5DiT`` adapter does the
|
||||
channels-first → channels-last permute (and permutes the velocity back) INTERNALLY, so the loop
|
||||
and toy stay backend-agnostic and the GPU forward still sees the geometry it requires.
|
||||
2. **A SECOND conditioning slot.** Qwen2.5-VL token embeds go to one slot (the cross-attended text);
|
||||
the CLIP **pooled** vector goes to a second slot and is threaded to the dit-call as ``context`` (so
|
||||
the dit-call signature stays ``dit(latent, text_embed, sigma, context=pooled)`` — compatible with the
|
||||
CPU ``ToyDiT``). The ``Kandinsky5DiT`` adapter feeds ``context`` to ``pooled_projections``, which is
|
||||
MANDATORY (the forward raises if it is None).
|
||||
3. **Per-request RoPE positions + scale_factor are built INSIDE the adapter** from the latent geometry
|
||||
and resolution (``visual_rope_pos`` over the patched grid, ``text_rope_pos`` over the Qwen seq len,
|
||||
``scale_factor`` = (1,2,2) for 480–854 else (1,3.16,3.16)). Keeping that in the adapter rather than
|
||||
threading extra kwargs is what lets the loop call the toy-compatible 3-arg dit signature.
|
||||
|
||||
The flow-match math (``FlowShiftPolicy.build_schedule`` → ``FLOW_MATCH_STEP``) and the CFG combine are
|
||||
reused verbatim from the Wan recipe — they are identical to Kandinsky's. Streaming preview + the
|
||||
ROLLOUT trajectory capture mirror ``WanDenoiseLoop``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2._enums import ExecutionProfile, WorkUnitKind
|
||||
from v2.loop.contracts import (
|
||||
Done,
|
||||
LoopResult,
|
||||
LoopState,
|
||||
ResourceRequest,
|
||||
ShapeSignature,
|
||||
StepContext,
|
||||
StepResult,
|
||||
WorkPlan,
|
||||
)
|
||||
from v2.platform import FLOW_MATCH_STEP
|
||||
from v2.platform.backends.toy import LATENT_CHANNELS
|
||||
from v2.request.streams import StreamChunk
|
||||
|
||||
# AutoencoderKLHunyuanVideo compression for Kandinsky-5: z=4 latent channels, 8× spatial, 4× temporal.
|
||||
KANDINSKY5_LATENT_CHANNELS = 4
|
||||
KANDINSKY5_TEMPORAL_RATIO = 4
|
||||
KANDINSKY5_SPATIAL_RATIO = 8
|
||||
|
||||
|
||||
def latent_shape(req,
|
||||
model=None,
|
||||
*,
|
||||
channels=KANDINSKY5_LATENT_CHANNELS,
|
||||
spatial_ratio=KANDINSKY5_SPATIAL_RATIO,
|
||||
temporal_ratio=KANDINSKY5_TEMPORAL_RATIO) -> tuple[int, int, int, int]:
|
||||
"""CHANNELS-FIRST latent geometry ``(c, t, h, w)`` for the denoise loop (Wan/ToyDiT convention — the
|
||||
toy channel-mixes over axis 0). On the GPU backend the true geometry is ``c = 4``,
|
||||
``t = (num_frames-1)//4 + 1``, ``h = H//8``, ``w = W//8``; the ``Kandinsky5DiT`` adapter permutes this
|
||||
to the channels-LAST ``[B, T, H, W, C]`` the forward requires. The CPU toy uses a tiny stand-in.
|
||||
``model is None`` (cost estimation in tests) keeps the toy shape."""
|
||||
d = req.diffusion
|
||||
if model is not None and getattr(getattr(model, "platform", None), "device", "cpu") == "cuda":
|
||||
t = (max(1, d.num_frames) - 1) // temporal_ratio + 1
|
||||
return (channels, max(1, t), max(1, d.height // spatial_ratio), max(1, d.width // spatial_ratio))
|
||||
# toy stand-in: small, channels-first, with even h,w so the patch_size=(1,2,2) grid stays valid.
|
||||
t = max(1, d.num_frames // 40)
|
||||
h = max(2, d.height // 120)
|
||||
w = max(2, d.width // 120)
|
||||
return (LATENT_CHANNELS, t, h - (h % 2), w - (w % 2))
|
||||
|
||||
|
||||
class Kandinsky5DenoiseLoop:
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
loop_id,
|
||||
cfg,
|
||||
flow_shift,
|
||||
precision,
|
||||
expert,
|
||||
cost,
|
||||
latent_channels=KANDINSKY5_LATENT_CHANNELS,
|
||||
spatial_ratio=KANDINSKY5_SPATIAL_RATIO,
|
||||
temporal_ratio=KANDINSKY5_TEMPORAL_RATIO):
|
||||
self.loop_id = loop_id
|
||||
self.cfg = cfg
|
||||
self.flow_shift = flow_shift
|
||||
self.precision = precision
|
||||
self.expert = expert
|
||||
self.cost = cost
|
||||
self.latent_channels = latent_channels
|
||||
self.spatial_ratio = spatial_ratio
|
||||
self.temporal_ratio = temporal_ratio
|
||||
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
seed = req.diffusion.seed if req.diffusion.seed is not None else 0
|
||||
rng = np.random.default_rng(seed)
|
||||
sig = self.flow_shift.build_schedule(req.diffusion.num_steps,
|
||||
req.diffusion.height,
|
||||
req.diffusion.width,
|
||||
sigmas=req.diffusion.sigmas or None)
|
||||
shape = latent_shape(req,
|
||||
model,
|
||||
channels=self.latent_channels,
|
||||
spatial_ratio=self.spatial_ratio,
|
||||
temporal_ratio=self.temporal_ratio)
|
||||
x = (rng.standard_normal(shape) * float(sig[0])).astype("float32")
|
||||
st = LoopState(loop_id=self.loop_id,
|
||||
instance_id=model.card.model_id,
|
||||
request_id=req.request_id,
|
||||
profile=ctx.profile,
|
||||
rng=rng,
|
||||
seed=seed,
|
||||
latents={"video": x},
|
||||
sigmas=[float(s) for s in sig],
|
||||
timesteps=[float(s) * 1000.0 for s in sig])
|
||||
# Qwen token embeds (the cross-attended text stream) + the matching negative.
|
||||
st.cond["prompt_embeds"] = ctx.slots.get("text_embeds")
|
||||
st.cond["negative_prompt_embeds"] = ctx.slots.get("neg_text_embeds")
|
||||
# CLIP pooled vectors (the MANDATORY pooled_projections, threaded to the dit-call as `context`).
|
||||
st.cond["pooled"] = ctx.slots.get("clip_pooled")
|
||||
st.cond["negative_pooled"] = ctx.slots.get("neg_clip_pooled")
|
||||
st.scratch["guidance_scale"] = float(req.diffusion.guidance_scale)
|
||||
st.scratch["stream_video"] = bool(req.outputs.stream.get("video"))
|
||||
st.plugin_state["cfg"] = {}
|
||||
return st
|
||||
|
||||
def next(self, st: LoopState):
|
||||
i = st.step_idx
|
||||
if i >= len(st.sigmas) - 1:
|
||||
return Done()
|
||||
sigma_t, sigma_next = st.sigmas[i], st.sigmas[i + 1]
|
||||
expert_id = self.expert.expert_for(StepContext(i, st.timesteps[i], sigma_t))
|
||||
sctx = StepContext(step_idx=i, timestep=st.timesteps[i], sigma=sigma_t, active_expert_id=expert_id)
|
||||
cfg_state = st.plugin_state["cfg"]
|
||||
branches = self.cfg.branches_this_step(sctx, cfg_state)
|
||||
x = st.latents["video"]
|
||||
pe, ne = st.cond["prompt_embeds"], st.cond["negative_prompt_embeds"]
|
||||
pp, npp = st.cond["pooled"], st.cond["negative_pooled"]
|
||||
scale = st.scratch["guidance_scale"]
|
||||
cfg, precision = self.cfg, self.precision
|
||||
|
||||
def _velocity(model: Any, x_: Any, sigma_t_: float, pe_: Any, ne_: Any, pp_: Any, npp_: Any,
|
||||
scale_: float) -> np.ndarray:
|
||||
# The conditioned forward + CFG combine. The CLIP pooled vector rides in ``context`` (the
|
||||
# toy ignores it harmlessly; the Kandinsky5DiT adapter routes it to pooled_projections and
|
||||
# builds visual/text RoPE + scale_factor internally from the latent geometry + resolution).
|
||||
dit = model.component(expert_id)
|
||||
preds = {
|
||||
b: dit(x_, pe_ if b == "cond" else ne_, sigma_t_, context=(pp_ if b == "cond" else npp_))
|
||||
for b in branches
|
||||
}
|
||||
return precision.cast(cfg.combine(preds, scale_, sctx, cfg_state))
|
||||
|
||||
def run(model, override=None):
|
||||
kernels = model.platform.kernels
|
||||
if override is not None and "noise_pred" in override:
|
||||
velocity = precision.cast(np.asarray(override["noise_pred"], dtype="float32"))
|
||||
else:
|
||||
velocity = _velocity(model, x, sigma_t, pe, ne, pp, npp, scale)
|
||||
x_next = kernels.get(FLOW_MATCH_STEP)(precision.cast(x), velocity, sigma_t, sigma_next)
|
||||
return StepResult(output={
|
||||
"noise_pred": np.asarray(velocity, dtype="float32"),
|
||||
"latents": x_next.astype("float32")
|
||||
})
|
||||
|
||||
def graph_fn(model, ws):
|
||||
# The CAPTURABLE op-structure: reads every per-step input from the static workspace.
|
||||
st_t, st_n = float(ws["sigma_t"]), float(ws["sigma_next"])
|
||||
velocity = _velocity(model, ws["x"], st_t, ws["pe"], ws["ne"], ws["pp"], ws["npp"], float(ws["scale"]))
|
||||
x_next = model.platform.kernels.get(FLOW_MATCH_STEP)(precision.cast(ws["x"]), velocity, st_t, st_n)
|
||||
np.copyto(ws["out"], x_next.astype("float32"))
|
||||
return StepResult(output={
|
||||
"noise_pred": np.asarray(velocity, dtype="float32"),
|
||||
"latents": np.array(ws["out"], copy=True)
|
||||
})
|
||||
|
||||
cond_bytes = sum(int(np.asarray(e).nbytes) for e in (pe, ne, pp, npp) if e is not None)
|
||||
res = ResourceRequest(compute_seconds=self.cost.predict(int(np.prod(x.shape)), float(len(branches))),
|
||||
resident_bytes=int(x.nbytes) + cond_bytes,
|
||||
peak_activation_bytes=int(x.nbytes))
|
||||
emits = []
|
||||
if st.scratch.get("stream_video"):
|
||||
emits.append(StreamChunk(stream_id=st.request_id, modality="video", seq=i, data=x, preview=True))
|
||||
return WorkPlan(loop_id=self.loop_id,
|
||||
instance_id=st.instance_id,
|
||||
kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
shape_sig=ShapeSignature(WorkUnitKind.DIFFUSION_STEP,
|
||||
dims=tuple(x.shape),
|
||||
dtype=precision.compute_dtype,
|
||||
extra=(("cfg", type(cfg).__name__), )),
|
||||
resources=res,
|
||||
payload={
|
||||
"branch": "combined",
|
||||
"step": i
|
||||
},
|
||||
run=run,
|
||||
label=f"kandinsky5.denoise.{i}",
|
||||
emits=emits,
|
||||
capturable=True,
|
||||
graph_key=(tuple(sorted(branches)), expert_id, precision.scheduler_step_in_fp32),
|
||||
graph_fn=graph_fn,
|
||||
graph_inputs={
|
||||
"x": x,
|
||||
"sigma_t": sigma_t,
|
||||
"sigma_next": sigma_next,
|
||||
"pe": pe,
|
||||
"ne": ne,
|
||||
"pp": pp,
|
||||
"npp": npp,
|
||||
"scale": scale
|
||||
})
|
||||
|
||||
def advance(self, st: LoopState, result: StepResult) -> LoopState:
|
||||
st.latents["video"] = result.output["latents"]
|
||||
if st.profile == ExecutionProfile.ROLLOUT:
|
||||
i = st.step_idx
|
||||
st.trajectory.append({
|
||||
"step": i,
|
||||
"sigma": st.sigmas[i],
|
||||
"velocity": np.asarray(result.output["noise_pred"]).copy(),
|
||||
"latents": np.asarray(st.latents["video"]).copy()
|
||||
})
|
||||
st.step_idx += 1
|
||||
return st
|
||||
|
||||
def finalize(self, st: LoopState) -> LoopResult:
|
||||
return LoopResult(outputs={"latents": st.latents["video"]},
|
||||
metrics={"denoise_steps": float(st.step_idx)},
|
||||
behavior=st.trajectory or None)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Kandinsky-5 T2V program: encode(Qwen + CLIP) → diffusion_denoise → vae_decode.
|
||||
|
||||
Same inline shape as the Wan/Cosmos t2v programs, but the encode node fans out to BOTH text encoders
|
||||
(the dual conditioning stream Kandinsky needs): Qwen2.5-VL token embeds for the cross-attended text and
|
||||
the CLIP **pooled** vector for ``pooled_projections``. The denoise loop reads all four slots
|
||||
(prompt/neg × qwen/clip). The vae-decode node hands the channels-last latent straight to the
|
||||
``Kandinsky5VAE`` adapter, which does the ``[B,T,H,W,C] -> [B,C,T,H,W]`` reshape + scalar
|
||||
``/scaling_factor`` un-normalization internally (faithful to the diffusers pipeline post-processing).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
from v2.recipes.common import cached_text_encode
|
||||
|
||||
|
||||
def _encode(instance, slots, request, ctx) -> None:
|
||||
# Qwen token embeds (cross-attended text) — cached by content hash like every text-encode node.
|
||||
slots["text_embeds"] = cached_text_encode(instance, request.prompt())
|
||||
slots["neg_text_embeds"] = cached_text_encode(instance, request.diffusion.negative_prompt)
|
||||
# CLIP pooled vectors (the mandatory pooled_projections). Encoded directly (the feature cache keys on
|
||||
# component_id "text_encoder"; the CLIP component is a distinct instance so we call it explicitly).
|
||||
clip = instance.component("text_encoder_2")
|
||||
slots["clip_pooled"] = clip.encode(request.prompt())
|
||||
slots["neg_clip_pooled"] = clip.encode(request.diffusion.negative_prompt)
|
||||
|
||||
|
||||
def _vae_decode(instance, slots, request, ctx) -> None:
|
||||
# The channels-last latent [T,H,W,C] (loop output) -> the Kandinsky5VAE adapter reshapes to
|
||||
# channels-first and applies the scalar scaling_factor un-normalization before decoding.
|
||||
slots["video"] = instance.component("vae").decode(slots["denoise_out"]["latents"])
|
||||
|
||||
|
||||
def build_kandinsky5_program() -> Program:
|
||||
return Program(
|
||||
program_id="kandinsky5.t2v.inline",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("text_encode",
|
||||
fn=_encode,
|
||||
writes=("text_embeds", "neg_text_embeds", "clip_pooled", "neg_clip_pooled")),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="diffusion_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("text_embeds", "neg_text_embeds", "clip_pooled", "neg_clip_pooled"),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("vae_decode", fn=_vae_decode, reads=("denoise_out", ), writes=("video", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"video": "video",
|
||||
"latents": "denoise_out"
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""LingBot-World-Base-Cam (camera-conditioned dual-guidance MoE i2v) ported into the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapter via
|
||||
``ComponentSpec.adapter`` (``LingBotWorldDiT`` in ``v2/platform/backends/torch_lingbotworld.py``) and a
|
||||
new ``LingBotWorldDenoiseLoop`` (Wan i2v flow-match + boundary-routed MoE + DUAL guidance + camera/Plucker
|
||||
conditioning), reusing the Wan VAE / T5 / CLIP adapters + ``stamp_wan21_checkpoints``. The program adds a
|
||||
``camera_encode`` node (Plucker tensor; BRINGUP — needs a per-request camera input). Registered in
|
||||
``v2/registry.py`` by the orchestrator from the returned metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.lingbotworld.card import build_lingbotworld_card
|
||||
from v2.recipes.lingbotworld.loop import LingBotWorldDenoiseLoop
|
||||
from v2.recipes.lingbotworld.program import build_lingbotworld_program
|
||||
|
||||
__all__ = ["build_lingbotworld_card", "build_lingbotworld_program", "LingBotWorldDenoiseLoop"]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""LingBot-World-Base-Cam ModelCard — camera-conditioned, dual-guidance MoE image-to-video.
|
||||
|
||||
LingBot-World-Base-Cam (``FastVideo/LingBot-World-Base-Cam-Diffusers``) is Wan2.2-I2V-A14B with a NEW
|
||||
camera/Plucker conditioning input. Architecture (all declared on the card so the recipe is self-contained
|
||||
— no edit to the shared ``_make_dit`` dispatch):
|
||||
|
||||
* DiT (x2) ``fastvideo.models.dits.lingbotworld.model:LingBotWorldTransformer3DModel`` — a 2x14B
|
||||
boundary-routed MoE (``transformer`` high-noise, ``transformer_2`` low-noise; ``boundary_ratio=0.947``).
|
||||
The forward gains ``c2ws_plucker_emb`` (FiLM camera injection per block) which a vanilla WanDiT reuse
|
||||
would drop, so both experts use the ``LingBotWorldDiT`` adapter (``ComponentSpec.adapter``) that threads
|
||||
the Plucker tensor + keeps the Wan i2v 36ch ``[noise|mask+cond]`` concat + CLIP image embeds.
|
||||
* VAE ``fastvideo.models.vaes.wanvae:AutoencoderKLWan`` (z=16, 8x spatial / 4x temporal, mean/std
|
||||
normalized latent space) — reuses the v2 ``WanVAE`` adapter unchanged. ``spatial_scale=8`` for the
|
||||
Plucker downsample matches this 8x spatial compression.
|
||||
* Text ``fastvideo.models.encoders.t5:T5EncoderModel`` (UMT5, text_len=512) — reuses ``T5Encoder``.
|
||||
* Image ``fastvideo.models.encoders.clip:CLIPVisionModel`` — first-frame CLIP conditioning (i2v), reuses
|
||||
``CLIPImageEncoder``.
|
||||
|
||||
Sampler: flow-match (``FlowUniPCMultistepScheduler``, ``prediction_type='flow_prediction'``) -> the v2
|
||||
``FlowShiftPolicy`` + ``FLOW_MATCH_STEP``, exactly as Wan. NOTE the unusual defaults: ``flow_shift=10.0``,
|
||||
``num_inference_steps=70``, dual ``guidance_scale``/``guidance_scale_2`` (both 5.0), and the Chinese
|
||||
negative prompt — these must NOT inherit the Wan defaults.
|
||||
|
||||
``stamp_wan21_checkpoints`` applies (diffusers ``transformer``/``transformer_2``/``vae``/``text_encoder``/
|
||||
``image_encoder`` subfolder layout, all in ``_WAN21_SUBFOLDERS``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import BoundaryTimestepRouting, ClassicCFG, FlowShiftPolicy, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyDiT, ToyImageEncoder, ToyTextEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.lingbotworld.loop import LINGBOTWORLD_BOUNDARY, LingBotWorldDenoiseLoop
|
||||
from v2.recipes.wan21.card import stamp_wan21_checkpoints
|
||||
|
||||
# The LingBot-World I2V negative prompt (presets.py LINGBOTWORLD_I2V) — kept LOCAL to this recipe so the
|
||||
# port stays self-contained (no edit to the shared v2/recipes/_prompts.py).
|
||||
LINGBOTWORLD_NEG_CN = ("画面突变,色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,"
|
||||
"最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,"
|
||||
"畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走,"
|
||||
"镜头晃动,画面闪烁,模糊,噪点,水印,签名,文字,变形,扭曲,液化,不合逻辑的结构,卡顿,"
|
||||
"PPT幻灯片感,过暗,欠曝,低对比度,霓虹灯光感,过度锐化,3D渲染感,人物,行人,游客,身体,"
|
||||
"皮肤,肢体,面部特征,汽车,电线")
|
||||
|
||||
_LINGBOTWORLD_DIT = "v2.platform.backends.torch_lingbotworld:LingBotWorldDiT"
|
||||
|
||||
|
||||
def build_lingbotworld_card(model_id: str = "lingbot-world-base-cam",
|
||||
*,
|
||||
boundary: float = LINGBOTWORLD_BOUNDARY,
|
||||
checkpoint_root: str | None = None,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
"""LingBot-World-Base-Cam card — 2x14B camera-conditioned dual-guidance MoE i2v.
|
||||
|
||||
Mirrors ``build_wan22_i2v_a14b_card`` but: ``flow_shift=10.0``; ``boundary_ratio=0.947``; both DiT
|
||||
experts use the ``LingBotWorldDiT`` adapter (threads ``c2ws_plucker_emb`` + Wan i2v cond); the
|
||||
``LingBotWorldDenoiseLoop`` (dual guidance + camera); and the LingBot defaults (70 steps, gs 5.0,
|
||||
480x832, Chinese negative prompt). 2x14B bf16 won't co-reside on 80GB -> the adapter keeps the WanDiT
|
||||
CPU-offload swap (GPU-pending)."""
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = ClassicCFG()
|
||||
flow = FlowShiftPolicy(shift=10.0) # LingBot-World's unusually high flow shift (not the Wan 3.0/5.0)
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = BoundaryTimestepRouting(high_noise="transformer", low_noise="transformer_2", boundary=boundary)
|
||||
|
||||
def loop_factory() -> LingBotWorldDenoiseLoop:
|
||||
return LingBotWorldDenoiseLoop(loop_id="i2v_denoise",
|
||||
cfg=cfg,
|
||||
flow_shift=flow,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost,
|
||||
boundary=boundary)
|
||||
|
||||
def _dit(cid: str) -> ComponentSpec:
|
||||
return ComponentSpec(cid,
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.lingbotworld.model:LingBotWorldTransformer3DModel",
|
||||
adapter=_LINGBOTWORLD_DIT,
|
||||
factory=lambda inst: ToyDiT(seed=seed),
|
||||
resident_for=["i2v_denoise"],
|
||||
required_for={"i2v"})
|
||||
|
||||
components = {
|
||||
"text_encoder":
|
||||
ComponentSpec("text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.t5:T5EncoderModel",
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"i2v"}),
|
||||
"image_encoder":
|
||||
ComponentSpec("image_encoder",
|
||||
kind="image_encoder",
|
||||
load_id="fastvideo.models.encoders.clip:CLIPVisionModel",
|
||||
factory=lambda inst: ToyImageEncoder(),
|
||||
required_for={"i2v"}),
|
||||
"vae":
|
||||
ComponentSpec("vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.wanvae:AutoencoderKLWan",
|
||||
factory=lambda inst: ToyVAE(),
|
||||
required_for={"i2v"}),
|
||||
"transformer":
|
||||
_dit("transformer"),
|
||||
"transformer_2":
|
||||
_dit("transformer_2"),
|
||||
}
|
||||
loops = {
|
||||
"i2v_denoise":
|
||||
LoopSpec("i2v_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer", "transformer_2"],
|
||||
cache_policy=["feature"],
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="wan",
|
||||
components=components,
|
||||
loops=loops,
|
||||
capabilities=CapabilityMatrix.of(Capability.IMAGE_TO_VIDEO, Capability.VAE_DECODE),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="i2v_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1], interleave_required=True),
|
||||
caches={"feature": CacheContract("feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
sampling_defaults=sampling_defaults or SamplingDefaults(num_steps=70,
|
||||
guidance_scale=5.0,
|
||||
height=480,
|
||||
width=832,
|
||||
num_frames=81,
|
||||
fps=16,
|
||||
negative_prompt=LINGBOTWORLD_NEG_CN),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_wan21_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
@@ -0,0 +1,144 @@
|
||||
"""LingBotWorldDenoiseLoop — Wan flow-match denoise + boundary-routed MoE, extended with (a) the camera
|
||||
(Plucker) conditioning slot threaded to the DiT and (b) DUAL classifier-free guidance.
|
||||
|
||||
LingBot-World-Base-Cam is Wan2.2-I2V-A14B i2v conditioning (CLIP + first-frame ``[mask|cond]`` latent)
|
||||
plus a camera/Plucker tensor. The denoise math is otherwise the canonical Wan flow-match Euler step with
|
||||
``BoundaryTimestepRouting`` between the high-noise ``transformer`` and the low-noise ``transformer_2``. Two
|
||||
deltas vs ``WanDenoiseLoop`` (faithful to ``LingBotWorldImageToVideoPipeline`` == the Wan i2v denoise
|
||||
stage, denoising.py:352-375):
|
||||
|
||||
* DUAL guidance — fastvideo switches ``guidance_scale -> guidance_scale_2`` at the SAME expert boundary:
|
||||
``t >= boundary_timestep`` (the high-noise expert) uses ``guidance_scale``; below it (the low-noise
|
||||
expert) uses ``guidance_scale_2``. The boundary is in timestep space ``boundary_ratio*1000``, i.e.
|
||||
sigma-space ``ctx.sigma >= boundary`` (preset ``boundary_ratio=0.947``). The preset sets both scales
|
||||
to 5.0, so the registered path is numerically a single scale — but the loop honors a distinct
|
||||
``guidance_scale_2`` whenever the request carries one.
|
||||
* CAMERA — the program's camera node writes ``c2ws_plucker_emb`` into slots; the loop publishes it onto
|
||||
the active DiT adapter (``dit.c2ws_plucker_emb = ...``) before the forward, because the v2 dit-call
|
||||
surface (and ``ToyDiT``) has no such kwarg. On the CPU toy backend the slot is ``None`` -> the
|
||||
degenerate no-camera path (a plain Wan i2v step), so the loop CPU-verifies end-to-end.
|
||||
|
||||
BRINGUP (needs request-API extension): the Plucker tensor is built from a per-request camera trajectory
|
||||
(``poses.npy``/``intrinsics.npy``); v2's ``Request`` has no camera slot yet, so the program node writes
|
||||
``None`` unless an ``action_path`` override is supplied. GPU-verify (a) the 2x14B MoE CPU-offload swap,
|
||||
(b) the camera FiLM injection actually steers the generation, (c) the dual-scale boundary parity.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2.loop.contracts import Done, LoopState, StepContext, StepResult, WorkPlan
|
||||
from v2.recipes.wan21.loop import WanDenoiseLoop
|
||||
|
||||
# Wan2.2 boundary expressed in (shifted) sigma space; LingBot-World uses 0.947 (much higher than Wan2.2's
|
||||
# 0.875/0.9), so the low-noise expert ``transformer_2`` runs almost the whole trajectory.
|
||||
LINGBOTWORLD_BOUNDARY = 0.947
|
||||
|
||||
|
||||
class LingBotWorldDenoiseLoop(WanDenoiseLoop):
|
||||
"""Wan i2v denoise + camera Plucker conditioning + dual-guidance boundary switch."""
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
loop_id,
|
||||
cfg,
|
||||
flow_shift,
|
||||
precision,
|
||||
expert,
|
||||
cost,
|
||||
boundary=LINGBOTWORLD_BOUNDARY,
|
||||
latent_channels=16,
|
||||
spatial_ratio=8,
|
||||
temporal_ratio=4):
|
||||
super().__init__(loop_id=loop_id,
|
||||
cfg=cfg,
|
||||
flow_shift=flow_shift,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost,
|
||||
latent_channels=latent_channels,
|
||||
spatial_ratio=spatial_ratio,
|
||||
temporal_ratio=temporal_ratio)
|
||||
# The boundary the dual-guidance switch keys on; default to the routing policy's own boundary so the
|
||||
# CFG switch and the expert switch fire on the SAME step (matching the fastvideo denoise stage).
|
||||
self.boundary = float(getattr(expert, "boundary", boundary))
|
||||
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
st = super().init(req, model, ctx)
|
||||
# Dual guidance: the second CFG scale used below the boundary (low-noise expert). Fall back to the
|
||||
# primary scale when the request doesn't carry a distinct guidance_scale_2 (preset sets both 5.0).
|
||||
gs2 = getattr(req.diffusion, "guidance_scale_2", None)
|
||||
st.scratch["guidance_scale_2"] = float(gs2) if gs2 is not None else float(req.diffusion.guidance_scale)
|
||||
# Camera / Plucker conditioning written by the program's camera node (None == no-camera path).
|
||||
st.scratch["c2ws_plucker_emb"] = ctx.slots.get("c2ws_plucker_emb")
|
||||
return st
|
||||
|
||||
def next(self, st: LoopState):
|
||||
i = st.step_idx
|
||||
if i >= len(st.sigmas) - 1:
|
||||
return Done()
|
||||
sigma_t, sigma_next = st.sigmas[i], st.sigmas[i + 1]
|
||||
expert_id = self.expert.expert_for(StepContext(i, st.timesteps[i], sigma_t))
|
||||
sctx = StepContext(step_idx=i, timestep=st.timesteps[i], sigma=sigma_t, active_expert_id=expert_id)
|
||||
cfg_state = st.plugin_state["cfg"]
|
||||
branches = self.cfg.branches_this_step(sctx, cfg_state)
|
||||
x = st.latents["video"]
|
||||
pe, ne = st.cond["prompt_embeds"], st.cond["negative_prompt_embeds"]
|
||||
# DUAL guidance: high-noise (ctx.sigma>=boundary) -> guidance_scale; low-noise -> guidance_scale_2.
|
||||
scale = (st.scratch["guidance_scale"] if sigma_t >= self.boundary else st.scratch["guidance_scale_2"])
|
||||
cfg, precision = self.cfg, self.precision
|
||||
i2v_ctx, i2v_cond = st.scratch.get("i2v_img_embeds"), st.scratch.get("i2v_cond")
|
||||
plucker = st.scratch.get("c2ws_plucker_emb")
|
||||
|
||||
def _velocity(model: Any, x_: np.ndarray, sigma_t_: float, pe_: Any, ne_: Any, scale_: float) -> np.ndarray:
|
||||
# The conditioned forward + CFG combine. The active expert's adapter gets the per-step camera
|
||||
# tensor published out-of-band (the v2 dit-call surface has no c2ws_plucker_emb kwarg); ToyDiT
|
||||
# simply ignores the attribute, so the CPU path is the no-camera step.
|
||||
dit = model.component(expert_id)
|
||||
if plucker is not None and hasattr(dit, "c2ws_plucker_emb"):
|
||||
dit.c2ws_plucker_emb = plucker
|
||||
preds = {
|
||||
b: dit(x_, pe_ if b == "cond" else ne_, sigma_t_, context=i2v_ctx, cond=i2v_cond)
|
||||
for b in branches
|
||||
}
|
||||
return precision.cast(cfg.combine(preds, scale_, sctx, cfg_state))
|
||||
|
||||
def run(model: Any, override: dict | None = None) -> StepResult:
|
||||
from v2.platform import FLOW_MATCH_STEP
|
||||
kernels = model.platform.kernels
|
||||
if override is not None and "noise_pred" in override:
|
||||
velocity = precision.cast(np.asarray(override["noise_pred"], dtype="float32"))
|
||||
else:
|
||||
velocity = _velocity(model, x, sigma_t, pe, ne, scale)
|
||||
x_next = kernels.get(FLOW_MATCH_STEP)(precision.cast(x), velocity, sigma_t, sigma_next)
|
||||
return StepResult(output={
|
||||
"noise_pred": np.asarray(velocity, dtype="float32"),
|
||||
"latents": x_next.astype("float32")
|
||||
})
|
||||
|
||||
from v2._enums import WorkUnitKind
|
||||
from v2.loop.contracts import ResourceRequest, ShapeSignature
|
||||
cond_bytes = sum(int(np.asarray(e).nbytes) for e in (pe, ne) if e is not None)
|
||||
res = ResourceRequest(compute_seconds=self.cost.predict(int(np.prod(x.shape)), float(len(branches))),
|
||||
resident_bytes=int(x.nbytes) + cond_bytes,
|
||||
peak_activation_bytes=int(x.nbytes))
|
||||
return WorkPlan(
|
||||
loop_id=self.loop_id,
|
||||
instance_id=st.instance_id,
|
||||
kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
shape_sig=ShapeSignature(WorkUnitKind.DIFFUSION_STEP,
|
||||
dims=tuple(x.shape),
|
||||
dtype=precision.compute_dtype,
|
||||
extra=(("cfg", type(cfg).__name__), ("camera", plucker is not None))),
|
||||
resources=res,
|
||||
payload={
|
||||
"branch": "combined",
|
||||
"step": i
|
||||
},
|
||||
run=run,
|
||||
label=f"lingbotworld.denoise.{i}",
|
||||
# Camera (out-of-band adapter state) + dual-scale boundary thread non-workspace conditioning,
|
||||
# exactly like Wan i2v -> eager path (no CUDA-graph capture).
|
||||
capturable=False)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""LingBot-World i2v program: image_encode (CLIP) -> cond_encode (first-frame VAE [mask|cond]) ->
|
||||
camera_encode (Plucker) -> text_encode -> i2v_denoise (dual-guidance MoE) -> vae_decode.
|
||||
|
||||
Same inline shape as the Wan i2v program (the i2v conditioning is identical to Wan2.2-I2V-A14B), with one
|
||||
NEW node: ``camera_encode`` builds the camera/Plucker tensor ``c2ws_plucker_emb`` the LingBot-World DiT
|
||||
FiLM-injects per block. The ``LingBotWorldDenoiseLoop`` reads that slot and publishes it onto the active
|
||||
expert adapter; the dual-guidance / MoE specifics live in the loop + adapter, so the node graph is a thin
|
||||
superset of Wan i2v.
|
||||
|
||||
BRINGUP (blocker #2 — needs a request-API extension): v2's ``Request`` has no camera-trajectory input
|
||||
slot. The camera node therefore looks for an ``action_path`` in the node override
|
||||
(``request.node_override("camera_encode")["action_path"]``, pointing at a dir with ``poses.npy`` +
|
||||
``intrinsics.npy``) and, if present, builds the Plucker tensor via the fastvideo
|
||||
``prepare_camera_embedding`` (``spatial_scale=8`` to match the VAE 8x). Absent (the default, and the CPU
|
||||
toy path) -> it writes ``None`` and the loop runs the degenerate no-camera i2v step. ``prepare_camera_embedding``
|
||||
also re-clamps ``num_frames`` to the trajectory length (4k+1); the v2 frame count is taken from the
|
||||
request, so on GPU the camera node and the request num_frames must agree (verify at bring-up).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
from v2.recipes.common import text_encode_node_fn as _text_encode
|
||||
|
||||
_WAN_TEMPORAL_RATIO = 4 # VAE temporal compression -> the i2v mask has this many channels
|
||||
_SPATIAL_SCALE = 8 # Plucker spatial downsample == VAE 8x spatial compression (WanCamCtrl in_chans=6*64)
|
||||
|
||||
|
||||
def _i2v_image_encode(instance, slots, request, ctx) -> None:
|
||||
img = request.image()
|
||||
slots["i2v_img_embeds"] = (instance.component("image_encoder").encode_image(img.pixels)
|
||||
if img is not None and getattr(img, "pixels", None) is not None else None)
|
||||
|
||||
|
||||
def _i2v_cond_encode(instance, slots, request, ctx) -> None:
|
||||
"""First-frame VAE conditioning + mask (mirrors fastvideo's ImageVAEEncodingStage): encode the
|
||||
conditioning image as frame 0 (rest zeros), prepend a 4-channel mask (first latent frame = 1)."""
|
||||
img = request.image()
|
||||
if img is None or getattr(img, "pixels", None) is None:
|
||||
slots["i2v_cond"] = None
|
||||
return
|
||||
px = np.asarray(img.pixels, dtype="float32") # [3, H, W] in [-1, 1]
|
||||
nf = int(request.diffusion.num_frames)
|
||||
cond_video = np.zeros((px.shape[0], nf) + px.shape[1:], dtype="float32")
|
||||
cond_video[:, 0] = px # frame 0 = conditioning image, rest zeros
|
||||
cond_latent = np.asarray(instance.component("vae").encode(cond_video), dtype="float32") # [C, T, h, w]
|
||||
t, h, w = cond_latent.shape[1:]
|
||||
mask = np.zeros((_WAN_TEMPORAL_RATIO, t, h, w), dtype="float32")
|
||||
mask[:, 0] = 1.0 # first latent frame is the known (conditional) frame
|
||||
slots["i2v_cond"] = np.concatenate([mask, cond_latent], axis=0) # [mask | cond] -> DiT concats with noise
|
||||
|
||||
|
||||
def _camera_encode(instance, slots, request, ctx) -> None:
|
||||
"""Build the camera/Plucker tensor ``c2ws_plucker_emb`` from a per-request camera trajectory.
|
||||
|
||||
BRINGUP: until ``Request`` carries a camera-trajectory input, the trajectory dir is read from the
|
||||
node override ``action_path``. Absent -> ``None`` (the no-camera path the CPU toy backend takes)."""
|
||||
action_path = request.node_override("camera_encode").get("action_path")
|
||||
if not action_path:
|
||||
slots["c2ws_plucker_emb"] = None
|
||||
return
|
||||
from v2.models.dits.lingbotworld import prepare_camera_embedding
|
||||
d = request.diffusion
|
||||
emb, _num_frames = prepare_camera_embedding(action_path,
|
||||
int(d.num_frames),
|
||||
int(d.height),
|
||||
int(d.width),
|
||||
spatial_scale=_SPATIAL_SCALE)
|
||||
# Drop the leading batch dim -> the loop/adapter add it back ([6*s^2, F_lat, H_lat, W_lat]).
|
||||
arr = np.asarray(emb.squeeze(0).cpu().numpy() if hasattr(emb, "cpu") else emb, dtype="float32")
|
||||
slots["c2ws_plucker_emb"] = arr
|
||||
|
||||
|
||||
def _i2v_vae_decode(instance, slots, request, ctx) -> None:
|
||||
slots["video"] = instance.component("vae").decode(slots["i2v_out"]["latents"])
|
||||
|
||||
|
||||
def build_lingbotworld_program() -> Program:
|
||||
return Program(
|
||||
program_id="lingbotworld.i2v",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("image_encode", fn=_i2v_image_encode, writes=("i2v_img_embeds", )),
|
||||
ComponentNode("cond_encode", fn=_i2v_cond_encode, writes=("i2v_cond", )),
|
||||
ComponentNode("camera_encode", fn=_camera_encode, writes=("c2ws_plucker_emb", )),
|
||||
ComponentNode("text_encode", fn=_text_encode, writes=("text_embeds", "neg_text_embeds")),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="i2v_denoise",
|
||||
output_slot="i2v_out",
|
||||
reads=("text_embeds", "i2v_cond", "i2v_img_embeds", "c2ws_plucker_emb"),
|
||||
writes=("i2v_out", )),
|
||||
ComponentNode("vae_decode", fn=_i2v_vae_decode, reads=("i2v_out", ), writes=("video", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"video": "video",
|
||||
"latents": "i2v_out"
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""LongCat-Video (T2V) — flow-match denoiser with CFG-zero guidance, ported into the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapter via
|
||||
``ComponentSpec.adapter`` (``LongCatDiT`` in ``v2/platform/backends/torch_longcat.py``) and a new
|
||||
``LongCatDenoiseLoop`` (explicit ``linspace(1.0, 0.001, num_steps)`` sigma schedule + CFG-zero
|
||||
optimized-scale combine), reusing the Wan ``WanVAE`` + ``T5Encoder`` adapters and ``stamp_wan21_checkpoints``.
|
||||
The DiT adapter negates the velocity to fold in the fastvideo stage's ``noise_pred = -noise_pred``.
|
||||
Registered in ``v2/registry.py`` by the orchestrator (transformer ``LongCatTransformer3DModel``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.longcat.card import build_longcat_card
|
||||
from v2.recipes.longcat.loop import CFGZeroPolicy, LongCatDenoiseLoop
|
||||
from v2.recipes.longcat.program import build_longcat_program
|
||||
|
||||
__all__ = ["build_longcat_card", "build_longcat_program", "LongCatDenoiseLoop", "CFGZeroPolicy"]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""LongCat-Video-T2V ModelCard — text->video, flow-match with CFG-zero guidance.
|
||||
|
||||
Architecture deltas vs Wan (all declared on the card so the recipe is self-contained):
|
||||
* DiT ``fastvideo.models.dits.longcat:LongCatTransformer3DModel`` (depth 48, hidden 4096, 32 heads,
|
||||
in/out 16ch, patch [1,2,2], caption_channels 4096). The adapter (``ComponentSpec.adapter`` -> ``LongCatDiT``
|
||||
in ``v2/platform/backends/torch_longcat.py``) returns the **negated** velocity (the fastvideo stage's
|
||||
``noise_pred = -noise_pred`` before the scheduler step) so the loop's flow-match Euler reproduces it.
|
||||
* VAE ``fastvideo.models.vaes.wanvae:AutoencoderKLWan`` — Wan-style (z=16, 8x/4x) in NORMALIZED latent
|
||||
space (mean/std). Reuses the v2 ``WanVAE`` torch adapter UNCHANGED (no adapter override).
|
||||
* Text ``fastvideo.models.encoders.t5:T5EncoderModel`` (UMT5, 4096-dim) via the v2 ``T5Encoder`` (zero-pads
|
||||
to max_length=512 for the CFG-concat uniform-seq contract — no adapter override needed).
|
||||
* Sampler: ``FlowMatchEulerDiscreteScheduler`` with an EXPLICIT ``linspace(1.0, 0.001, num_steps)`` sigma
|
||||
schedule (NOT flow-shift) + CFG-zero optimized-scale guidance — both live in ``LongCatDenoiseLoop``.
|
||||
|
||||
``stamp_wan21_checkpoints`` applies (the diffusers transformer/vae/text_encoder/tokenizer subfolder layout is
|
||||
identical to Wan). i2v/VC (first-frame latent replacement + per-frame timestep masking + num_cond_latents),
|
||||
the refine stages (t_thresh crop, which is the latent-prep branch that SKIPS the init-noise re-scale), KV-cache,
|
||||
and BSA are deferred (spec blocker #8) — the registered preset is base T2V.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyDiT, ToyTextEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.longcat.loop import CFGZeroPolicy, LongCatDenoiseLoop
|
||||
from v2.recipes.wan21.card import stamp_wan21_checkpoints
|
||||
|
||||
# LongCat's default negative prompt (the inference yaml / get_sampling_param default — identical to the Wan
|
||||
# English negative). Kept as a LOCAL module constant so the recipe touches no shared prompts file.
|
||||
LONGCAT_NEG = ("Bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, "
|
||||
"images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, "
|
||||
"incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, "
|
||||
"misshapen limbs, fused fingers, still picture, messy background, three legs, many people in "
|
||||
"the background, walking backwards")
|
||||
|
||||
_LONGCAT_DIT = "v2.platform.backends.torch_longcat:LongCatDiT"
|
||||
|
||||
|
||||
def build_longcat_card(model_id: str = "longcat-video-t2v",
|
||||
*,
|
||||
checkpoint_root: str | None = None,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = CFGZeroPolicy()
|
||||
# Keep the numpy loop math in fp32 (lowest-risk bring-up). The torch DiT runs at its native bf16 dtype
|
||||
# and casts its output to fp32 (spec blocker #6); the loop never forces fp32 on the module.
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer")
|
||||
|
||||
def loop_factory():
|
||||
return LongCatDenoiseLoop(loop_id="diffusion_denoise", cfg=cfg, precision=precision, expert=expert, cost=cost)
|
||||
|
||||
components = {
|
||||
"text_encoder":
|
||||
ComponentSpec(component_id="text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.t5:T5EncoderModel",
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2v"}),
|
||||
"vae":
|
||||
ComponentSpec(component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.wanvae:AutoencoderKLWan",
|
||||
factory=lambda inst: ToyVAE(),
|
||||
required_for={"t2v"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.longcat:LongCatTransformer3DModel",
|
||||
adapter=_LONGCAT_DIT,
|
||||
factory=lambda inst: ToyDiT(seed=seed),
|
||||
resident_for=["diffusion_denoise"],
|
||||
required_for={"t2v"}),
|
||||
}
|
||||
loops = {
|
||||
"diffusion_denoise":
|
||||
LoopSpec(loop_id="diffusion_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="breakable_cudagraph",
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="longcat",
|
||||
components=components,
|
||||
loops=loops,
|
||||
capabilities=CapabilityMatrix.of(Capability.TEXT_TO_VIDEO, Capability.VAE_DECODE, Capability.POLICY_ROLLOUT),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="diffusion_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
# Spec defaults: 50 steps, guidance 1.0, 480x848, 121 frames @ 24fps. flow_shift=None (the loop builds
|
||||
# the linspace schedule); the negative prompt is the LongCat default above.
|
||||
sampling_defaults=sampling_defaults or SamplingDefaults(num_steps=50,
|
||||
guidance_scale=1.0,
|
||||
height=480,
|
||||
width=848,
|
||||
num_frames=121,
|
||||
fps=24,
|
||||
negative_prompt=LONGCAT_NEG),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_wan21_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
@@ -0,0 +1,209 @@
|
||||
"""LongCatDenoiseLoop — a faithful fork of ``WanDenoiseLoop`` for LongCat-Video T2V.
|
||||
|
||||
LongCat is flow-match (deterministic Euler over a ``FlowMatchEulerDiscreteScheduler``), but it differs from
|
||||
Wan in three load-bearing ways (faithful to ``fastvideo/pipelines/stages/longcat_denoising.py`` +
|
||||
``longcat_refine_timestep.py``):
|
||||
|
||||
1. SIGMA SCHEDULE (spec blocker #2): NOT ``build_flow_sigmas`` / flow-shift (``flow_shift=None``). LongCat's
|
||||
``get_timesteps_sigmas`` (non-distill) uses ``torch.linspace(1.0, 0.001, num_inference_steps)`` — exactly
|
||||
``num_steps`` points terminating at 0.001 (NOT 0) — and the scheduler appends a trailing ``0.0`` for the
|
||||
final boundary. ``FlowShiftPolicy.build_schedule`` would give ``num_steps+1`` points terminating at 0.0
|
||||
(wrong count and terminal). We build it explicitly in ``init`` via ``longcat_linspace_sigmas``.
|
||||
2. CFG-ZERO (spec blocker #3): guidance is the CFG-zero optimized-scale formula (``st_star`` projection of
|
||||
cond onto uncond), NOT ClassicCFG. Implemented as ``CFGZeroPolicy`` (a ``CFGPolicy`` subclass local to
|
||||
this recipe). Default ``guidance_scale=1.0`` degenerates the scale term but fastvideo still runs BOTH
|
||||
branches whenever ``do_classifier_free_guidance`` is set (independent of the scale), so we keep both.
|
||||
3. SIGN CONVENTION (spec blocker #1): handled in the ``LongCatDiT`` adapter (returns ``-velocity``), so the
|
||||
loop's ``FLOW_MATCH_STEP`` (``x + (sigma_next-sigma)*v``) reproduces the stage's negate-then-step. The
|
||||
CFG-zero combine here operates on the (already-negated) velocities; the combine is sign-equivariant
|
||||
(``st_star`` is a ratio of dot-products, and the affine combine is linear), so combining the negated
|
||||
velocities equals negating the combine of the raw ones — i.e. ``-combine(raw) == combine(-raw)`` — which
|
||||
is exactly fastvideo's order (combine raw, then negate). The toy backend has no negating adapter; that is
|
||||
fine for the CPU control-flow check (we still exercise the schedule + CFG-zero + Euler step end-to-end).
|
||||
|
||||
Geometry is Wan2.1's (``AutoencoderKLWan``: 16 channels, 4x temporal, 8x spatial). All per-request state lives
|
||||
in ``LoopState`` (interleave-safe), matching the canonical ``WanDenoiseLoop`` contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2._enums import ExecutionProfile, WorkUnitKind
|
||||
from v2.loop.contracts import (
|
||||
Done,
|
||||
LoopResult,
|
||||
LoopState,
|
||||
ResourceRequest,
|
||||
ShapeSignature,
|
||||
StepContext,
|
||||
StepResult,
|
||||
WorkPlan,
|
||||
)
|
||||
from v2.loop.policies import CFGPolicy
|
||||
from v2.platform import FLOW_MATCH_STEP
|
||||
from v2.recipes.longcat.sampler import longcat_linspace_sigmas
|
||||
from v2.recipes.wan21.loop import latent_shape
|
||||
|
||||
# LongCat reuses Wan2.1 VAE geometry (AutoencoderKLWan: z=16, 4x temporal, 8x spatial).
|
||||
LONGCAT_LATENT_CHANNELS = 16
|
||||
LONGCAT_TEMPORAL_RATIO = 4
|
||||
LONGCAT_SPATIAL_RATIO = 8
|
||||
|
||||
|
||||
class CFGZeroPolicy(CFGPolicy):
|
||||
"""CFG-zero optimized-scale guidance (LongCat; from the CFG-zero* paper) over the standard
|
||||
cond/uncond two-forward decomposition.
|
||||
|
||||
Per sample, project cond onto uncond to get the optimized scale
|
||||
``st_star = <v_cond, v_uncond> / (||v_uncond||^2 + 1e-8)``, then combine
|
||||
``out = v_uncond*st_star + scale*(v_cond - v_uncond*st_star)``. This is the exact algebra of
|
||||
``LongCatDenoisingStage.optimized_scale`` + the CFG application (longcat_denoising.py lines 28-149), with
|
||||
the dot-products taken per leading "batch" group. In the v2 loop a step works on ONE unbatched sample
|
||||
([C,T,H,W]), so ``st_star`` is a single scalar over the whole tensor (B=1 in fastvideo's reshape(B,-1))."""
|
||||
|
||||
def combine(self, preds, guidance_scale, ctx, state):
|
||||
cond = np.asarray(preds["cond"], dtype=np.float64)
|
||||
uncond = np.asarray(preds["uncond"], dtype=np.float64)
|
||||
# st_star = <cond, uncond> / (||uncond||^2 + 1e-8) over the full (B=1) tensor — fastvideo reshapes to
|
||||
# [B,-1] and reduces along dim=1; here the single sample is the whole flattened tensor.
|
||||
dot = float(np.sum(cond * uncond))
|
||||
sq = float(np.sum(uncond * uncond)) + 1e-8
|
||||
st_star = dot / sq
|
||||
out = uncond * st_star + float(guidance_scale) * (cond - uncond * st_star)
|
||||
return out.astype("float32")
|
||||
|
||||
|
||||
class LongCatDenoiseLoop:
|
||||
"""Driven flow-match denoise loop for LongCat-Video T2V. Same four-method contract as
|
||||
``WanDenoiseLoop``; the deltas are the explicit linspace sigma schedule and the CFG-zero combine."""
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
loop_id,
|
||||
cfg,
|
||||
precision,
|
||||
expert,
|
||||
cost,
|
||||
sigma_min: float = 0.001,
|
||||
latent_channels: int = LONGCAT_LATENT_CHANNELS,
|
||||
spatial_ratio: int = LONGCAT_SPATIAL_RATIO,
|
||||
temporal_ratio: int = LONGCAT_TEMPORAL_RATIO):
|
||||
self.loop_id = loop_id
|
||||
self.cfg = cfg # CFGZeroPolicy
|
||||
self.precision = precision
|
||||
self.expert = expert
|
||||
self.cost = cost
|
||||
self.sigma_min = sigma_min # the linspace terminal (LongCat: 0.001, NOT 0.0)
|
||||
self.latent_channels = latent_channels
|
||||
self.spatial_ratio = spatial_ratio
|
||||
self.temporal_ratio = temporal_ratio
|
||||
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
seed = req.diffusion.seed if req.diffusion.seed is not None else 0
|
||||
rng = np.random.default_rng(seed)
|
||||
# LongCat schedule (spec blocker #2): linspace(1.0, 0.001, num_steps) + a trailing 0.0 sentinel so
|
||||
# next() always has a sigma_next for the final boundary. NOT flow-shift, NOT terminal-0 linspace.
|
||||
sig = longcat_linspace_sigmas(req.diffusion.num_steps, sigma_min=self.sigma_min)
|
||||
shape = latent_shape(req,
|
||||
model,
|
||||
channels=self.latent_channels,
|
||||
spatial_ratio=self.spatial_ratio,
|
||||
temporal_ratio=self.temporal_ratio)
|
||||
# Initial noise x = randn * sigma[0] (=1.0). LongCat's LatentPreparationStage multiplies by
|
||||
# scheduler.init_noise_sigma (=1.0 for FlowMatchEulerDiscreteScheduler), so this matches; the refine
|
||||
# branch (refine_from/stage1_video) is the path that SKIPS this re-scale, but base T2V always scales.
|
||||
x = (rng.standard_normal(shape) * float(sig[0])).astype("float32")
|
||||
st = LoopState(loop_id=self.loop_id,
|
||||
instance_id=model.card.model_id,
|
||||
request_id=req.request_id,
|
||||
profile=ctx.profile,
|
||||
rng=rng,
|
||||
seed=seed,
|
||||
latents={"video": x},
|
||||
sigmas=[float(s) for s in sig],
|
||||
timesteps=[float(s) * 1000.0 for s in sig]) # model timestep = sigma*1000 (Wan convention)
|
||||
st.cond["prompt_embeds"] = ctx.slots.get("text_embeds")
|
||||
st.cond["negative_prompt_embeds"] = ctx.slots.get("neg_text_embeds")
|
||||
st.scratch["guidance_scale"] = float(req.diffusion.guidance_scale)
|
||||
st.scratch["stream_video"] = bool(req.outputs.stream.get("video"))
|
||||
st.plugin_state["cfg"] = {}
|
||||
return st
|
||||
|
||||
def next(self, st: LoopState):
|
||||
i = st.step_idx
|
||||
if i >= len(st.sigmas) - 1:
|
||||
return Done()
|
||||
sigma_t, sigma_next = st.sigmas[i], st.sigmas[i + 1]
|
||||
expert_id = self.expert.expert_for(StepContext(i, st.timesteps[i], sigma_t))
|
||||
sctx = StepContext(step_idx=i, timestep=st.timesteps[i], sigma=sigma_t, active_expert_id=expert_id)
|
||||
cfg_state = st.plugin_state["cfg"]
|
||||
x = st.latents["video"]
|
||||
pe, ne = st.cond["prompt_embeds"], st.cond["negative_prompt_embeds"]
|
||||
scale = st.scratch["guidance_scale"]
|
||||
cfg, precision = self.cfg, self.precision
|
||||
# LongCat runs BOTH branches whenever a negative prompt is present (do_classifier_free_guidance is
|
||||
# independent of the scale value); CFG-zero degenerates only its scale term, not the branch set.
|
||||
do_cfg = ne is not None
|
||||
branches = ["cond", "uncond"] if do_cfg else ["cond"]
|
||||
|
||||
def _velocity(model: Any, x_: Any, sigma_t_: float, pe_: Any, ne_: Any, scale_: float) -> np.ndarray:
|
||||
dit = model.component(expert_id)
|
||||
preds = {b: dit(x_, pe_ if b == "cond" else ne_, sigma_t_) for b in branches}
|
||||
if not do_cfg:
|
||||
return precision.cast(np.asarray(preds["cond"], dtype="float32"))
|
||||
return precision.cast(cfg.combine(preds, scale_, sctx, cfg_state))
|
||||
|
||||
def run(model: Any, override: Any = None) -> StepResult:
|
||||
kernels = model.platform.kernels
|
||||
if override is not None and "noise_pred" in override:
|
||||
velocity = precision.cast(np.asarray(override["noise_pred"], dtype="float32"))
|
||||
else:
|
||||
velocity = _velocity(model, x, sigma_t, pe, ne, scale)
|
||||
x_next = kernels.get(FLOW_MATCH_STEP)(precision.cast(x), velocity, sigma_t, sigma_next)
|
||||
return StepResult(output={
|
||||
"noise_pred": np.asarray(velocity, dtype="float32"),
|
||||
"latents": x_next.astype("float32")
|
||||
})
|
||||
|
||||
cond_bytes = sum(int(np.asarray(e).nbytes) for e in (pe, ne) if e is not None)
|
||||
res = ResourceRequest(compute_seconds=self.cost.predict(int(np.prod(x.shape)), float(len(branches))),
|
||||
resident_bytes=int(x.nbytes) + cond_bytes,
|
||||
peak_activation_bytes=int(x.nbytes))
|
||||
return WorkPlan(
|
||||
loop_id=self.loop_id,
|
||||
instance_id=st.instance_id,
|
||||
kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
shape_sig=ShapeSignature(WorkUnitKind.DIFFUSION_STEP,
|
||||
dims=tuple(x.shape),
|
||||
dtype=precision.compute_dtype,
|
||||
extra=(("cfg", type(cfg).__name__), )),
|
||||
resources=res,
|
||||
payload={
|
||||
"branch": "combined",
|
||||
"step": i
|
||||
},
|
||||
run=run,
|
||||
label=f"longcat.denoise.{i}",
|
||||
# CFG-zero combine is host-side (np.sum / per-sample st_star) -> eager-break under capture, like
|
||||
# the Cosmos x0-space CFG. The deterministic Euler step itself is capturable, but the combine is
|
||||
# data-dependent on the two branch outputs, so keep the whole step eager for correctness.
|
||||
capturable=False)
|
||||
|
||||
def advance(self, st: LoopState, result: StepResult) -> LoopState:
|
||||
st.latents["video"] = result.output["latents"]
|
||||
if st.profile == ExecutionProfile.ROLLOUT:
|
||||
st.trajectory.append({
|
||||
"step": st.step_idx,
|
||||
"sigma": st.sigmas[st.step_idx],
|
||||
"velocity": np.asarray(result.output["noise_pred"]).copy(),
|
||||
"latents": np.asarray(st.latents["video"]).copy()
|
||||
})
|
||||
st.step_idx += 1
|
||||
return st
|
||||
|
||||
def finalize(self, st: LoopState) -> LoopResult:
|
||||
return LoopResult(outputs={"latents": st.latents["video"]},
|
||||
metrics={"denoise_steps": float(st.step_idx)},
|
||||
behavior=st.trajectory or None)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""LongCat-Video T2V program: text_encode -> diffusion_denoise (flow-match + CFG-zero) -> vae_decode.
|
||||
|
||||
Same inline shape as the Wan/Cosmos t2v programs — the LongCat specifics (the linspace sigma schedule, the
|
||||
CFG-zero combine, and the negated-velocity sign convention) live entirely in ``LongCatDenoiseLoop`` and the
|
||||
``LongCatDiT`` adapter, so the node graph is unchanged.
|
||||
|
||||
(i2v / video-continuation would add a VAE-encode node writing the first-frame conditioning latent into slots
|
||||
and a loop that threads ``num_cond_latents`` + per-frame timestep masking — deferred; spec blocker #4/#5.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
from v2.recipes.common import text_encode_node_fn as _text_encode
|
||||
|
||||
|
||||
def _vae_decode(instance, slots, request, ctx) -> None:
|
||||
slots["video"] = instance.component("vae").decode(slots["denoise_out"]["latents"])
|
||||
|
||||
|
||||
def build_longcat_program() -> Program:
|
||||
return Program(
|
||||
program_id="longcat.t2v.inline",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("text_encode", fn=_text_encode, writes=("text_embeds", "neg_text_embeds")),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="diffusion_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("text_embeds", "neg_text_embeds"),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("vae_decode", fn=_vae_decode, reads=("denoise_out", ), writes=("video", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"video": "video",
|
||||
"latents": "denoise_out"
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""LongCat sigma schedule — the recipe-local sampler helper (kept HERE, not in the shared
|
||||
``v2/loop/sampler.py``, so the port stays a self-contained package).
|
||||
|
||||
LongCat's ``get_timesteps_sigmas`` (non-distill, the base T2V path) builds the inference sigmas with
|
||||
``torch.linspace(1.0, 0.001, num_inference_steps)`` and hands them to ``FlowMatchEulerDiscreteScheduler``;
|
||||
the scheduler appends a trailing ``0.0`` (its final-step boundary). This is NOT the flow-shift linspace Wan
|
||||
uses (``build_flow_sigmas`` gives ``num_steps+1`` points terminating at 0.0). Faithful to
|
||||
``fastvideo/pipelines/stages/longcat_refine_timestep.py`` lines 63-69 (which documents the base schedule) and
|
||||
the scheduler's terminal-sigma append.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def longcat_linspace_sigmas(num_steps: int, sigma_min: float = 0.001) -> np.ndarray:
|
||||
"""The LongCat inference sigma schedule: ``linspace(1.0, sigma_min, num_steps)`` (NOT terminal 0) with a
|
||||
trailing ``0.0`` appended for the final Euler boundary — giving ``num_steps+1`` entries the loop integrates
|
||||
pairwise (``num_steps`` actual denoise steps). ``sigma_min`` is LongCat's 0.001 terminal."""
|
||||
n = max(int(num_steps), 1)
|
||||
ramp = np.linspace(1.0, float(sigma_min), n, dtype=np.float64) # num_steps points, 1.0 -> 0.001
|
||||
return np.concatenate([ramp, np.zeros(1, dtype=np.float64)]) # + the scheduler's trailing-0 boundary
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Matrix-Game 2.0 (Base, distilled) — the INTERACTIVE mouse/keyboard world model ported into the v2
|
||||
substrate as a self-contained recipe package (bucket-C pattern).
|
||||
|
||||
The card declares its torch adapters via ``ComponentSpec.adapter`` (``MatrixGame2CausalDiT`` /
|
||||
``MatrixGame2CLIPImageEncoder`` in ``v2/platform/backends/torch_matrixgame2.py``) and a new
|
||||
``MatrixGame2CausalDMDLoop`` (few-step DMD: epsilon->x0 via the FlowUniPC sigma table + re-add_noise, causal
|
||||
block-autoregressive with a sliding-window KV cache, action-conditioned). Reuses the Wan VAE adapter +
|
||||
``stamp_wan21_checkpoints``. Registered in ``v2/registry.py`` (the orchestrator adds the entry).
|
||||
|
||||
The registered path is the i2v world-rollout (first-frame + CLIP context); live mouse/keyboard action
|
||||
routing is BRINGUP (needs a request-API extension — the loop + program already thread the action slots).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.matrixgame2.card import build_matrixgame2_card
|
||||
from v2.recipes.matrixgame2.loop import MatrixGame2CausalDMDLoop
|
||||
from v2.recipes.matrixgame2.program import build_matrixgame2_program
|
||||
|
||||
__all__ = ["build_matrixgame2_card", "build_matrixgame2_program", "MatrixGame2CausalDMDLoop"]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Matrix-Game 2.0 (Base, distilled) ModelCard — the INTERACTIVE mouse/keyboard world model ported as a
|
||||
self-contained recipe (bucket-C). Registered preset is the i2v world-rollout entry; live action routing is
|
||||
BRINGUP (needs a request-API extension — the loop already threads the action slots).
|
||||
|
||||
Architecture deltas vs Wan (all declared on the card so the recipe is self-contained):
|
||||
* DiT ``fastvideo.models.dits.matrixgame2.causal_model:CausalMatrixGame2WanModel`` — a DISTILLED, CAUSAL,
|
||||
ACTION-CONDITIONED Wan-family transformer whose output is EPSILON (not velocity). The adapter
|
||||
(``ComponentSpec.adapter`` -> ``MatrixGame2CausalDiT``) returns the raw epsilon; ``MatrixGame2CausalDMDLoop``
|
||||
does the few-step DMD (eps->x0 via the scheduler sigma table + re-add_noise), the causal block-rollout,
|
||||
and the sliding-window KV-cache threading.
|
||||
* VAE ``fastvideo.models.vaes.wanvae:AutoencoderKLWan`` — Wan 2.1 (z=16, 8x/4x); reuses the v2 ``WanVAE``
|
||||
adapter unchanged (mean/std normalization). The i2v cond_concat latent uses the SAME normalization.
|
||||
* Image CLIP vision encoder (``fastvideo.models.encoders.clip:CLIPVisionModel``, 257x1280 tokens) is the
|
||||
SOLE cross-attention context (``MatrixGame2CLIPImageEncoder`` adapter + sibling ``image_processor``).
|
||||
* NO text encoder — Matrix-Game 2.0 ignores text; the condition_embedder returns no text tokens.
|
||||
``stamp_wan21_checkpoints`` applies (diffusers transformer/vae/image_encoder subfolder layout).
|
||||
|
||||
Distilled: the 3-step DMD schedule ``dmd_denoising_steps=[1000,666,333]`` is intrinsic to the weights; the
|
||||
preset surfaces ``num_inference_steps`` but the model is distilled for exactly these timesteps. CFG is OFF
|
||||
(``guidance_scale=1.0``). The Base checkpoint is single-expert (``boundary_ratio=None``); GTA/TempleRun
|
||||
distilled variants may carry a high/low-noise MoE boundary (the loop has the gated ``pred_noise_to_x_bound``
|
||||
path for that).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import ClassicCFG, FlowShiftPolicy, NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyDiT, ToyImageEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.matrixgame2.loop import MatrixGame2CausalDMDLoop
|
||||
from v2.recipes.matrixgame2.sampler import (
|
||||
MATRIXGAME2_CONTEXT_NOISE,
|
||||
MATRIXGAME2_DMD_STEPS,
|
||||
MATRIXGAME2_FLOW_SHIFT,
|
||||
MATRIXGAME2_NUM_FRAMES_PER_BLOCK,
|
||||
)
|
||||
from v2.recipes.wan21.card import stamp_wan21_checkpoints
|
||||
|
||||
# Matrix-Game 2.0 ignores text -> no negative prompt (kept as a LOCAL constant, not shared _prompts.py).
|
||||
MATRIXGAME2_NEG = ""
|
||||
|
||||
_MG2_DIT = "v2.platform.backends.torch_matrixgame2:MatrixGame2CausalDiT"
|
||||
_MG2_CLIP = "v2.platform.backends.torch_matrixgame2:MatrixGame2CLIPImageEncoder"
|
||||
|
||||
|
||||
def build_matrixgame2_card(model_id: str = "matrix-game-2.0-base-distilled",
|
||||
*,
|
||||
flow_shift: float = MATRIXGAME2_FLOW_SHIFT,
|
||||
boundary_ratio: float | None = None,
|
||||
checkpoint_root: str | None = None,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = ClassicCFG() # carried for the op-structure key; guidance_scale=1.0 makes it a no-op (CFG OFF)
|
||||
flow = FlowShiftPolicy(shift=flow_shift) # seeds the FlowUniPC sigma table the eps->x0 conversion needs
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer") # single-expert Base; the loop gates the MoE bound path on boundary_ratio
|
||||
|
||||
def loop_factory():
|
||||
return MatrixGame2CausalDMDLoop(loop_id="causal_dmd_denoise",
|
||||
cfg=cfg,
|
||||
flow_shift=flow,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost,
|
||||
dmd_steps=MATRIXGAME2_DMD_STEPS,
|
||||
context_noise=MATRIXGAME2_CONTEXT_NOISE,
|
||||
num_frames_per_block=MATRIXGAME2_NUM_FRAMES_PER_BLOCK,
|
||||
boundary_ratio=boundary_ratio)
|
||||
|
||||
components = {
|
||||
"image_encoder":
|
||||
ComponentSpec(component_id="image_encoder",
|
||||
kind="image_encoder",
|
||||
load_id="fastvideo.models.encoders.clip:CLIPVisionModel",
|
||||
adapter=_MG2_CLIP,
|
||||
factory=lambda inst: ToyImageEncoder(),
|
||||
required_for={"i2v"}),
|
||||
"vae":
|
||||
ComponentSpec(component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.wanvae:AutoencoderKLWan",
|
||||
factory=lambda inst: ToyVAE(),
|
||||
required_for={"i2v"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.matrixgame2.causal_model:CausalMatrixGame2WanModel",
|
||||
adapter=_MG2_DIT,
|
||||
factory=lambda inst: ToyDiT(seed=seed),
|
||||
resident_for=["causal_dmd_denoise"],
|
||||
required_for={"i2v"}),
|
||||
}
|
||||
loops = {
|
||||
"causal_dmd_denoise":
|
||||
LoopSpec(
|
||||
loop_id="causal_dmd_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="eager", # causal KV-cache mutation + host-RNG re-noise -> not capturable
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="matrixgame",
|
||||
components=components,
|
||||
loops=loops,
|
||||
# World model: i2v (first-frame + action -> continuous frame extension) + VAE decode.
|
||||
capabilities=CapabilityMatrix.of(Capability.IMAGE_TO_VIDEO, Capability.VAE_DECODE),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="causal_dmd_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
sampling_defaults=sampling_defaults or SamplingDefaults(num_steps=3,
|
||||
guidance_scale=1.0,
|
||||
height=352,
|
||||
width=640,
|
||||
num_frames=57,
|
||||
fps=25,
|
||||
negative_prompt=MATRIXGAME2_NEG),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_wan21_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
@@ -0,0 +1,316 @@
|
||||
"""MatrixGame2CausalDMDLoop — the few-step, causal/autoregressive, action-conditioned DMD denoise loop for
|
||||
Matrix-Game 2.0 (the INTERACTIVE world model). Cannot reuse ``WanDenoiseLoop``/``CosmosDenoiseLoop``:
|
||||
|
||||
(a) Few-step DMD, NOT a flow-match velocity ODE. The model output is EPSILON; each DMD step converts
|
||||
``eps -> x0`` via ``pred_video = x - sigma_t*eps`` (the scheduler sigma table), then RE-ADDS noise to
|
||||
the next DMD timestep. Fixed schedule ``dmd_denoising_steps=[1000,666,333]`` (3 steps), warped through
|
||||
the FlowUniPC timestep grid (``warp_denoising_step``). CFG is OFF (guidance_scale=1.0).
|
||||
(b) Causal block-autoregressive: the video latent is split into blocks of ``num_frames_per_block`` (=3);
|
||||
each block is fully DMD-denoised, then a context pass at ``timestep=context_noise(=0)`` writes its
|
||||
CLEAN K/V into a sliding-window KV cache before the next block. Per-frame timestep ``[B, num_frames]``.
|
||||
(c) Action-conditioned: per-frame mouse[F,2] / keyboard[F,4] feed ActionModule blocks with their own KV
|
||||
caches. The DEGENERATE (no-action) path is what CPU-verifies here; live action routing is BRINGUP
|
||||
(needs a request-API extension — the loop exposes the ``mouse_cond``/``keyboard_cond`` slots and a
|
||||
``streaming_reset``/``streaming_step`` surface for interactive play).
|
||||
|
||||
Faithful port of ``fastvideo/pipelines/stages/matrixgame2_denoising.py:MatrixGame2CausalDenoisingStage``
|
||||
(``forward`` / ``_process_single_block`` / ``_update_context_cache``). The DiT call is dispatched two ways
|
||||
so the SAME loop runs on the CPU toy AND the GPU adapter:
|
||||
* GPU: ``model.component('transformer')`` is a ``MatrixGame2CausalDiT`` exposing ``.call(...)`` with the
|
||||
causal kv_cache / i2v / action plumbing built INTERNALLY (the loop just passes numpy + the cache lists).
|
||||
* CPU toy: ``ToyDiT`` has only ``dit(latent, text_embed, sigma)``; the loop's ``_eps`` falls back to that
|
||||
(no kv_cache — the toy is a degenerate stand-in exercising the DMD + block + epsilon->x0 control flow
|
||||
with real numbers). The KV caches are still allocated + threaded so the structure is identical.
|
||||
|
||||
BRINGUP: the GPU kv_cache / crossattn_cache / action-cache shapes (``local_attn_size`` window, mouse-cache
|
||||
batch dim ``B*frame_seq_len``) come from the loaded arch_config and are allocated by the GPU adapter's
|
||||
companion init; on CPU we allocate lightweight placeholder dict lists so the threading is faithful but the
|
||||
toy ignores them. The MoE high/low-noise boundary path (``pred_noise_to_x_bound`` + ``add_noise_high``)
|
||||
exists for GTA/TempleRun variants (``boundary_ratio`` set); the Base distilled checkpoint is single-expert.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2._enums import ExecutionProfile, WorkUnitKind
|
||||
from v2.loop.contracts import (
|
||||
Done,
|
||||
LoopResult,
|
||||
LoopState,
|
||||
ResourceRequest,
|
||||
ShapeSignature,
|
||||
StepContext,
|
||||
StepResult,
|
||||
WorkPlan,
|
||||
)
|
||||
from v2.recipes.matrixgame2.sampler import (
|
||||
MATRIXGAME2_CONTEXT_NOISE,
|
||||
MATRIXGAME2_DMD_STEPS,
|
||||
MATRIXGAME2_NUM_FRAMES_PER_BLOCK,
|
||||
add_noise,
|
||||
build_flow_unipc_table,
|
||||
pred_noise_to_pred_video,
|
||||
pred_noise_to_x_bound,
|
||||
warp_dmd_timesteps,
|
||||
)
|
||||
from v2.recipes.wan21.loop import latent_shape
|
||||
|
||||
# Real Wan2.1 VAE (AutoencoderKLWan): z=16, 4x temporal, 8x spatial. The DiT patch_embedding is 32-in
|
||||
# (16 noise + 16 i2v cond_concat) but the LOOP's latent is the 16ch noise; the adapter does the concat.
|
||||
MATRIXGAME2_LATENT_CHANNELS = 16
|
||||
MATRIXGAME2_TEMPORAL_RATIO = 4
|
||||
MATRIXGAME2_SPATIAL_RATIO = 8
|
||||
|
||||
|
||||
class MatrixGame2CausalDMDLoop:
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
loop_id,
|
||||
cfg,
|
||||
flow_shift,
|
||||
precision,
|
||||
expert,
|
||||
cost,
|
||||
dmd_steps: tuple[int, ...] = MATRIXGAME2_DMD_STEPS,
|
||||
context_noise: int = MATRIXGAME2_CONTEXT_NOISE,
|
||||
num_frames_per_block: int = MATRIXGAME2_NUM_FRAMES_PER_BLOCK,
|
||||
boundary_ratio: float | None = None,
|
||||
latent_channels: int = MATRIXGAME2_LATENT_CHANNELS,
|
||||
spatial_ratio: int = MATRIXGAME2_SPATIAL_RATIO,
|
||||
temporal_ratio: int = MATRIXGAME2_TEMPORAL_RATIO):
|
||||
self.loop_id = loop_id
|
||||
self.cfg = cfg # carried for the WorkPlan op-structure key; CFG is OFF (scale=1.0) for the distilled model
|
||||
self.flow_shift = flow_shift
|
||||
self.precision = precision
|
||||
self.expert = expert
|
||||
self.cost = cost
|
||||
self.dmd_steps = tuple(dmd_steps)
|
||||
self.context_noise = int(context_noise)
|
||||
self.num_frames_per_block = int(num_frames_per_block)
|
||||
self.boundary_ratio = boundary_ratio
|
||||
self.latent_channels = latent_channels
|
||||
self.spatial_ratio = spatial_ratio
|
||||
self.temporal_ratio = temporal_ratio
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# init #
|
||||
# --------------------------------------------------------------------- #
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
seed = req.diffusion.seed if req.diffusion.seed is not None else 0
|
||||
rng = np.random.default_rng(seed)
|
||||
shift = float(self.flow_shift.shift_for(req.diffusion.height, req.diffusion.width))
|
||||
sigmas, sched_timesteps = build_flow_unipc_table(shift=shift)
|
||||
# Warp the nominal DMD steps through the FlowUniPC timestep grid (warp_denoising_step=True).
|
||||
warped = warp_dmd_timesteps(self.dmd_steps, scheduler_timesteps=sched_timesteps, shift=shift)
|
||||
shape = latent_shape(req,
|
||||
model,
|
||||
channels=self.latent_channels,
|
||||
spatial_ratio=self.spatial_ratio,
|
||||
temporal_ratio=self.temporal_ratio)
|
||||
# DMD starts from pure noise at the first (largest) warped timestep (sigma ~ 1).
|
||||
x = rng.standard_normal(shape).astype("float32")
|
||||
st = LoopState(loop_id=self.loop_id,
|
||||
instance_id=model.card.model_id,
|
||||
request_id=req.request_id,
|
||||
profile=ctx.profile,
|
||||
rng=rng,
|
||||
seed=seed,
|
||||
latents={"video": x},
|
||||
sigmas=[float(s) for s in sigmas],
|
||||
timesteps=[float(t) for t in warped])
|
||||
st.scratch["sched_sigmas"] = sigmas
|
||||
st.scratch["sched_timesteps"] = sched_timesteps
|
||||
st.scratch["dmd_timesteps"] = [float(t) for t in warped]
|
||||
# i2v + interactive conditioning (program writes these into slots; degenerate path -> None).
|
||||
st.scratch["image_embeds"] = ctx.slots.get("i2v_img_embeds") # CLIP 257x1280, sole cross-attn ctx
|
||||
st.scratch["image_latent"] = ctx.slots.get("i2v_cond") # raw cond_latent for channel-concat
|
||||
st.scratch["mouse_cond"] = ctx.slots.get("mouse_cond") # BRINGUP: action surface (None here)
|
||||
st.scratch["keyboard_cond"] = ctx.slots.get("keyboard_cond")
|
||||
st.scratch["stream_video"] = bool(req.outputs.stream.get("video"))
|
||||
# Causal block schedule over the latent-frame axis (axis 1 of [C, T, h, w]).
|
||||
nf = int(shape[1])
|
||||
nfpb = max(1, self.num_frames_per_block)
|
||||
# If the latent frame count isn't a multiple, the last block is shorter (the GPU stage asserts
|
||||
# divisibility; the CPU toy stays robust so the loop still verifies on any tiny toy shape).
|
||||
blocks: list[tuple[int, int]] = []
|
||||
start = 0
|
||||
while start < nf:
|
||||
cur = min(nfpb, nf - start)
|
||||
blocks.append((start, cur))
|
||||
start += cur
|
||||
st.scratch["blocks"] = blocks
|
||||
st.scratch["block_idx"] = 0
|
||||
st.scratch["dmd_idx"] = 0
|
||||
st.scratch["phase"] = "denoise" # "denoise" (3 DMD steps/block) then "context" (1 clean-K/V pass)
|
||||
# Sliding-window KV caches (one dict per transformer block). On CPU these are inert placeholders the
|
||||
# toy ignores; on GPU the MatrixGame2CausalDiT adapter mutates them in place across blocks/steps.
|
||||
st.scratch["kv_cache"] = []
|
||||
st.scratch["crossattn_cache"] = []
|
||||
st.scratch["kv_cache_mouse"] = []
|
||||
st.scratch["kv_cache_keyboard"] = []
|
||||
st.plugin_state["cfg"] = {}
|
||||
return st
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# eps dispatch (GPU causal adapter vs CPU toy) #
|
||||
# --------------------------------------------------------------------- #
|
||||
def _eps(self, dit: Any, latent: np.ndarray, st: LoopState, start_frame: int, num_frames: int, timestep: float,
|
||||
frame_seq_len: int) -> np.ndarray:
|
||||
"""One forward returning the RAW epsilon prediction. Per-frame timestep [num_frames] (long-ish);
|
||||
the GPU adapter keeps it 2-D. ``hasattr(dit, 'call')`` selects the causal adapter; else the toy."""
|
||||
per_frame_t = np.full((num_frames, ), float(timestep), dtype="float32")
|
||||
if hasattr(dit, "call"): # MatrixGame2CausalDiT (GPU): full causal/i2v/action plumbing
|
||||
return np.asarray(dit.call(
|
||||
latent,
|
||||
st.scratch.get("image_embeds"),
|
||||
per_frame_t,
|
||||
kv_cache=st.scratch["kv_cache"] or None,
|
||||
crossattn_cache=st.scratch["crossattn_cache"] or None,
|
||||
kv_cache_mouse=st.scratch["kv_cache_mouse"] or None,
|
||||
kv_cache_keyboard=st.scratch["kv_cache_keyboard"] or None,
|
||||
current_start=start_frame * frame_seq_len,
|
||||
start_frame=start_frame,
|
||||
num_frame_per_block=num_frames,
|
||||
mouse_cond=st.scratch.get("mouse_cond"),
|
||||
keyboard_cond=st.scratch.get("keyboard_cond"),
|
||||
image_latent=st.scratch.get("image_latent"),
|
||||
),
|
||||
dtype="float32")
|
||||
# CPU toy degenerate path: dit(latent, text_embed, sigma). Feed the sigma at this timestep so the
|
||||
# toy velocity is a deterministic function of the DMD step (exercises the eps->x0 control flow).
|
||||
sigma = self._sigma_at(st, timestep)
|
||||
ctx_embed = st.scratch.get("image_embeds")
|
||||
return np.asarray(dit(latent, ctx_embed, sigma), dtype="float32")
|
||||
|
||||
@staticmethod
|
||||
def _sigma_at(st: LoopState, timestep: float) -> float:
|
||||
sigmas, tsteps = st.scratch["sched_sigmas"], st.scratch["sched_timesteps"]
|
||||
idx = int(np.argmin(np.abs(np.asarray(tsteps, dtype=np.float64) - float(timestep))))
|
||||
return float(sigmas[idx])
|
||||
|
||||
def _frame_seq_len(self, x: np.ndarray) -> int:
|
||||
# frame_seq_len = (h*w) / (patch_h*patch_w). Wan patch is [1,2,2] -> /4. The toy shape is tiny; clamp.
|
||||
h, w = int(x.shape[2]), int(x.shape[3])
|
||||
return max(1, (h * w) // 4)
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# next #
|
||||
# --------------------------------------------------------------------- #
|
||||
def next(self, st: LoopState):
|
||||
blocks = st.scratch["blocks"]
|
||||
b_idx = st.scratch["block_idx"]
|
||||
if b_idx >= len(blocks):
|
||||
return Done()
|
||||
start_frame, num_frames = blocks[b_idx]
|
||||
phase = st.scratch["phase"]
|
||||
dmd_idx = st.scratch["dmd_idx"]
|
||||
dmd_timesteps = st.scratch["dmd_timesteps"]
|
||||
x_full = st.latents["video"]
|
||||
block = x_full[:, start_frame:start_frame + num_frames] # [C, nf, h, w]
|
||||
sigmas, tsteps = st.scratch["sched_sigmas"], st.scratch["sched_timesteps"]
|
||||
precision, rng = self.precision, st.rng
|
||||
frame_seq_len = self._frame_seq_len(x_full)
|
||||
boundary_ts = (self.boundary_ratio * 1000.0) if self.boundary_ratio is not None else None
|
||||
_t = dmd_timesteps[dmd_idx] if phase == "denoise" else float(self.context_noise)
|
||||
_s = self._sigma_at(st, dmd_timesteps[dmd_idx]) if phase == "denoise" else 0.0
|
||||
expert_id = self.expert.expert_for(StepContext(dmd_idx, _t, _s))
|
||||
|
||||
is_context = phase == "context"
|
||||
timestep = float(self.context_noise) if is_context else float(dmd_timesteps[dmd_idx])
|
||||
is_last_dmd = dmd_idx >= len(dmd_timesteps) - 1
|
||||
|
||||
def run(model, override=None):
|
||||
dit = model.component(expert_id)
|
||||
if is_context:
|
||||
# Clean-context pass: one forward at timestep=context_noise to write clean K/V into the
|
||||
# sliding-window cache (return value discarded). Faithful to _update_context_cache.
|
||||
if hasattr(dit, "call"):
|
||||
self._eps(dit, block, st, start_frame, num_frames, float(self.context_noise), frame_seq_len)
|
||||
return StepResult(output={
|
||||
"block": np.asarray(block, dtype="float32"),
|
||||
"start": start_frame,
|
||||
"num": num_frames,
|
||||
"context": True
|
||||
})
|
||||
eps = self._eps(dit, block, st, start_frame, num_frames, timestep, frame_seq_len)
|
||||
if boundary_ts is not None and timestep >= boundary_ts: # MoE high-noise expert (variants only)
|
||||
x0 = pred_noise_to_x_bound(eps, block, timestep, boundary_ts, sigmas, tsteps)
|
||||
else:
|
||||
x0 = pred_noise_to_pred_video(eps, block, timestep, sigmas, tsteps)
|
||||
if is_last_dmd:
|
||||
new_block = x0 # last DMD step: the clean x0 IS the block result
|
||||
else:
|
||||
next_t = float(dmd_timesteps[dmd_idx + 1])
|
||||
noise = rng.standard_normal(np.asarray(x0).shape)
|
||||
new_block = add_noise(x0, noise, next_t, sigmas, tsteps) # re-noise to the next DMD timestep
|
||||
new_block = precision.cast(new_block)
|
||||
return StepResult(
|
||||
output={
|
||||
"block": np.asarray(new_block, dtype="float32"),
|
||||
"eps": np.asarray(eps, dtype="float32"),
|
||||
"start": start_frame,
|
||||
"num": num_frames,
|
||||
"context": False
|
||||
})
|
||||
|
||||
res = ResourceRequest(compute_seconds=self.cost.predict(int(np.prod(block.shape)), 1.0),
|
||||
resident_bytes=int(x_full.nbytes),
|
||||
peak_activation_bytes=int(block.nbytes))
|
||||
label = (f"matrixgame2.context.{b_idx}" if is_context else f"matrixgame2.denoise.{b_idx}.{dmd_idx}")
|
||||
return WorkPlan(loop_id=self.loop_id,
|
||||
instance_id=st.instance_id,
|
||||
kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
shape_sig=ShapeSignature(WorkUnitKind.DIFFUSION_STEP,
|
||||
dims=tuple(block.shape),
|
||||
dtype=precision.compute_dtype,
|
||||
extra=(("cfg", type(self.cfg).__name__), ("dmd", True), ("phase",
|
||||
phase))),
|
||||
resources=res,
|
||||
payload={
|
||||
"branch": "dmd",
|
||||
"block": b_idx,
|
||||
"dmd": dmd_idx,
|
||||
"phase": phase
|
||||
},
|
||||
run=run,
|
||||
label=label,
|
||||
capturable=False) # causal KV-cache mutation + host-RNG re-noise -> eager path
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# advance #
|
||||
# --------------------------------------------------------------------- #
|
||||
def advance(self, st: LoopState, result: StepResult) -> LoopState:
|
||||
out = result.output
|
||||
start, num = out["start"], out["num"]
|
||||
# Write the (denoised or unchanged) block back into the full latent.
|
||||
x_full = np.array(st.latents["video"])
|
||||
x_full[:, start:start + num] = out["block"]
|
||||
st.latents["video"] = x_full
|
||||
|
||||
if out.get("context"): # finished the clean-context pass -> advance to the next block
|
||||
if st.profile == ExecutionProfile.ROLLOUT:
|
||||
st.trajectory.append({"block": st.scratch["block_idx"], "latents": np.asarray(x_full).copy()})
|
||||
st.scratch["block_idx"] += 1
|
||||
st.scratch["dmd_idx"] = 0
|
||||
st.scratch["phase"] = "denoise"
|
||||
return st
|
||||
|
||||
# Mid-block DMD step.
|
||||
dmd_idx = st.scratch["dmd_idx"]
|
||||
if dmd_idx >= len(st.scratch["dmd_timesteps"]) - 1:
|
||||
# Block fully DMD-denoised -> run the clean-context cache-refresh pass next.
|
||||
st.scratch["phase"] = "context"
|
||||
else:
|
||||
st.scratch["dmd_idx"] = dmd_idx + 1
|
||||
return st
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# finalize #
|
||||
# --------------------------------------------------------------------- #
|
||||
def finalize(self, st: LoopState) -> LoopResult:
|
||||
return LoopResult(outputs={"latents": st.latents["video"]},
|
||||
metrics={"denoise_blocks": float(len(st.scratch["blocks"]))},
|
||||
behavior=st.trajectory or None)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Matrix-Game 2.0 i2v world-rollout program: image_encode(CLIP) -> cond_encode(first-frame VAE) ->
|
||||
action_prepare(BRINGUP hook) -> causal DMD denoise -> vae_decode.
|
||||
|
||||
Deltas vs the Wan2.1 i2v program (faithful to ``matrixgame2_causal_dmd_pipeline.py`` +
|
||||
``MatrixGame2ImageVAEEncodingStage``):
|
||||
* NO text_encode node — Matrix-Game 2.0 ignores text; the CLIP image embeds are the sole cross-attn
|
||||
context (written to ``i2v_img_embeds``).
|
||||
* cond_encode writes the RAW first-frame VAE latent to ``i2v_cond`` with NO 4-channel mask (unlike Wan2.1
|
||||
i2v's ``[mask|cond]``); the DiT channel-concats this raw cond onto the noise latent internally (the
|
||||
patch_embedding is the 32-in checkpoint).
|
||||
* action_prepare is a HOOK that routes per-frame mouse/keyboard arrays into ``mouse_cond``/``keyboard_cond``
|
||||
when the request carries them. BRINGUP: the request API does not yet surface game-action arrays, so this
|
||||
is a no-op on the registered path (the world model degrades to a first-frame-conditioned rollout). When
|
||||
the request-API extension lands, this node slices the action arrays per block (vae_time_compression=4).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
|
||||
|
||||
def _image_encode(instance, slots, request, ctx) -> None:
|
||||
"""CLIP-encode the first-frame conditioning image -> 257x1280 cross-attn context (sole conditioning)."""
|
||||
img = request.image()
|
||||
slots["i2v_img_embeds"] = (instance.component("image_encoder").encode_image(img.pixels)
|
||||
if img is not None and getattr(img, "pixels", None) is not None else None)
|
||||
|
||||
|
||||
def _cond_encode(instance, slots, request, ctx) -> None:
|
||||
"""First-frame VAE conditioning, mirroring ``MatrixGame2ImageVAEEncodingStage``: encode the conditioning
|
||||
image as frame 0 (rest zeros) -> the RAW cond_latent (NO mask channel). The DiT channel-concats it onto
|
||||
the noise latent (dim=1). Same VAE normalization as the noise latents (the WanVAE adapter handles it)."""
|
||||
img = request.image()
|
||||
if img is None or getattr(img, "pixels", None) is None:
|
||||
slots["i2v_cond"] = None
|
||||
return
|
||||
px = np.asarray(img.pixels, dtype="float32") # [3, H, W] in [-1, 1]
|
||||
nf = int(request.diffusion.num_frames)
|
||||
cond_video = np.zeros((px.shape[0], nf) + px.shape[1:], dtype="float32")
|
||||
cond_video[:, 0] = px # frame 0 = conditioning image, rest zeros
|
||||
slots["i2v_cond"] = np.asarray(instance.component("vae").encode(cond_video), dtype="float32") # raw [C,T,h,w]
|
||||
|
||||
|
||||
def _action_prepare(instance, slots, request, ctx) -> None:
|
||||
"""Route per-frame game actions (mouse[F,2] / keyboard[F,4]) into the loop's conditioning slots.
|
||||
BRINGUP: the request API does not yet expose action arrays, so this is a no-op (the world model runs the
|
||||
degenerate first-frame-conditioned rollout). When the action request-surface lands, read it here and the
|
||||
loop/adapter feed the ActionModule KV caches. Slots stay ``None`` until then."""
|
||||
slots["mouse_cond"] = getattr(getattr(request, "diffusion", None), "mouse_cond", None)
|
||||
slots["keyboard_cond"] = getattr(getattr(request, "diffusion", None), "keyboard_cond", None)
|
||||
|
||||
|
||||
def _vae_decode(instance, slots, request, ctx) -> None:
|
||||
slots["video"] = instance.component("vae").decode(slots["denoise_out"]["latents"])
|
||||
|
||||
|
||||
def build_matrixgame2_program() -> Program:
|
||||
return Program(
|
||||
program_id="matrixgame2.i2v.causal_dmd",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("image_encode", fn=_image_encode, writes=("i2v_img_embeds", )),
|
||||
ComponentNode("cond_encode", fn=_cond_encode, writes=("i2v_cond", )),
|
||||
ComponentNode("action_prepare", fn=_action_prepare, writes=("mouse_cond", "keyboard_cond")),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="causal_dmd_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("i2v_img_embeds", "i2v_cond", "mouse_cond", "keyboard_cond"),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("vae_decode", fn=_vae_decode, reads=("denoise_out", ), writes=("video", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"video": "video",
|
||||
"latents": "denoise_out"
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Matrix-Game 2.0 DMD sampler helpers — the few-step distilled epsilon->x0 math, ported faithfully from
|
||||
``fastvideo/models/utils.py`` (``pred_noise_to_pred_video`` / ``pred_noise_to_x_bound``) and the
|
||||
``FlowUniPCMultistepScheduler`` sigma/timestep table it indexes. Kept HERE (a recipe-local sibling), NOT
|
||||
in ``v2/loop/sampler.py``, because the DMD epsilon->x0 + re-add_noise schedule is specific to this arch.
|
||||
|
||||
The shipped distilled checkpoints run a FIXED 3-step DMD schedule ``dmd_denoising_steps=[1000,666,333]``.
|
||||
The scheduler (``FlowUniPCMultistepScheduler(shift=5.0)``) is used ONLY as a sigma/timestep lookup table +
|
||||
``add_noise``, never as a UniPC multistep corrector. With ``warp_denoising_step=True`` the nominal integer
|
||||
steps are remapped through the scheduler's timestep grid:
|
||||
``timesteps = cat(scheduler.timesteps, [0])[1000 - dmd_steps]``.
|
||||
|
||||
This numpy form is the CPU-testable, bit-reproducible reference. ``build_flow_unipc_table`` reproduces the
|
||||
scheduler's ``sigmas``/``timesteps`` (flow-shift over a 1000-step alpha ramp; identical formula to
|
||||
``FlowShiftPolicy``) so the loop can run on the CPU toy without instantiating the real scheduler. On a GPU
|
||||
box the torch scheduler is built from the card's ``scheduler`` load_id and the SAME math applies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Matrix-Game 2.0 distilled hyper-params (fastvideo MatrixGame2I2V480PConfig + MatrixGame2WanVideoArchConfig).
|
||||
MATRIXGAME2_FLOW_SHIFT = 5.0
|
||||
MATRIXGAME2_DMD_STEPS: tuple[int, ...] = (1000, 666, 333)
|
||||
MATRIXGAME2_NUM_TRAIN_TIMESTEPS = 1000
|
||||
MATRIXGAME2_CONTEXT_NOISE = 0
|
||||
MATRIXGAME2_NUM_FRAMES_PER_BLOCK = 3
|
||||
|
||||
|
||||
def build_flow_unipc_table(shift: float = MATRIXGAME2_FLOW_SHIFT,
|
||||
num_train_timesteps: int = MATRIXGAME2_NUM_TRAIN_TIMESTEPS) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Reproduce ``FlowUniPCMultistepScheduler.__init__`` (scheduling_flow_unipc_multistep.py): a descending
|
||||
1000-step alpha ramp ``alphas = linspace(1, 1/N, N)[::-1]``, ``sigmas = 1 - alphas``, flow-shifted
|
||||
``sigma' = shift*sigma / (1 + (shift-1)*sigma)``, then ``timesteps = sigma' * N``. Returns
|
||||
``(sigmas, timesteps)`` as float64, the table the epsilon->x0 conversion indexes by nearest timestep."""
|
||||
alphas = np.linspace(1.0, 1.0 / num_train_timesteps, num_train_timesteps, dtype=np.float64)[::-1].copy()
|
||||
sigmas = 1.0 - alphas
|
||||
if shift != 1.0:
|
||||
sigmas = shift * sigmas / (1.0 + (shift - 1.0) * sigmas)
|
||||
timesteps = sigmas * num_train_timesteps
|
||||
return sigmas, timesteps
|
||||
|
||||
|
||||
def warp_dmd_timesteps(dmd_steps: tuple[int, ...] = MATRIXGAME2_DMD_STEPS,
|
||||
*,
|
||||
scheduler_timesteps: np.ndarray | None = None,
|
||||
shift: float = MATRIXGAME2_FLOW_SHIFT,
|
||||
num_train_timesteps: int = MATRIXGAME2_NUM_TRAIN_TIMESTEPS) -> np.ndarray:
|
||||
"""``warp_denoising_step``: remap the nominal DMD steps through the scheduler timestep grid, faithful to
|
||||
``MatrixGame2CausalDenoisingStage.forward``: ``timesteps = cat(scheduler.timesteps, [0])[1000 - steps]``.
|
||||
The scheduler grid here is the FULL 1000-step table (set_timesteps with num_train_timesteps steps gives
|
||||
the same grid the pipeline's LatentPreparationStage builds before warping)."""
|
||||
if scheduler_timesteps is None:
|
||||
_sig, scheduler_timesteps = build_flow_unipc_table(shift=shift, num_train_timesteps=num_train_timesteps)
|
||||
grid = np.concatenate([np.asarray(scheduler_timesteps, dtype=np.float64), np.array([0.0])])
|
||||
idx = num_train_timesteps - np.asarray(dmd_steps, dtype=np.int64)
|
||||
return grid[idx]
|
||||
|
||||
|
||||
def _sigma_at(timestep: float, sigmas: np.ndarray, timesteps: np.ndarray) -> float:
|
||||
"""Nearest-neighbor sigma lookup: ``sigma_t = sigmas[argmin|timesteps - timestep|]`` — exactly the
|
||||
``torch.argmin((timesteps - timestep).abs())`` in ``pred_noise_to_pred_video``."""
|
||||
idx = int(np.argmin(np.abs(np.asarray(timesteps, dtype=np.float64) - float(timestep))))
|
||||
return float(sigmas[idx])
|
||||
|
||||
|
||||
def pred_noise_to_pred_video(pred_noise: np.ndarray, noise_input_latent: np.ndarray, timestep: float,
|
||||
sigmas: np.ndarray, timesteps: np.ndarray) -> np.ndarray:
|
||||
"""epsilon -> x0: ``pred_video = noise_input_latent - sigma_t * pred_noise`` (single-expert path).
|
||||
Faithful to ``fastvideo/models/utils.py:pred_noise_to_pred_video``."""
|
||||
sigma_t = _sigma_at(timestep, sigmas, timesteps)
|
||||
return np.asarray(noise_input_latent, dtype=np.float64) - sigma_t * np.asarray(pred_noise, dtype=np.float64)
|
||||
|
||||
|
||||
def pred_noise_to_x_bound(pred_noise: np.ndarray, noise_input_latent: np.ndarray, timestep: float,
|
||||
boundary_timestep: float, sigmas: np.ndarray, timesteps: np.ndarray) -> np.ndarray:
|
||||
"""High-noise-expert epsilon -> x0: ``pred_video = x - (sigma_t - sigma_boundary) * pred_noise``.
|
||||
Faithful to ``fastvideo/models/utils.py:pred_noise_to_x_bound`` (MoE distilled variants only; the Base
|
||||
distilled checkpoint has ``boundary_ratio=None`` -> this path is unused)."""
|
||||
sigma_t = _sigma_at(timestep, sigmas, timesteps)
|
||||
sigma_b = _sigma_at(boundary_timestep, sigmas, timesteps)
|
||||
return (np.asarray(noise_input_latent, dtype=np.float64) -
|
||||
(sigma_t - sigma_b) * np.asarray(pred_noise, dtype=np.float64))
|
||||
|
||||
|
||||
def add_noise(x0: np.ndarray, noise: np.ndarray, timestep: float, sigmas: np.ndarray,
|
||||
timesteps: np.ndarray) -> np.ndarray:
|
||||
"""Flow-match forward interpolation at the NEXT DMD timestep: ``x_t = (1 - sigma_t)*x0 + sigma_t*noise``
|
||||
— the scheduler's ``add_noise`` (re-noise between the 3 DMD steps). ``sigma_t`` is looked up at the next
|
||||
timestep, matching ``scheduler.add_noise(pred_video, noise, next_timestep)``."""
|
||||
sigma_t = _sigma_at(timestep, sigmas, timesteps)
|
||||
return (1.0 - sigma_t) * np.asarray(x0, dtype=np.float64) + sigma_t * np.asarray(noise, dtype=np.float64)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Matrix-Game 3.0 (Base-Distilled) — autoregressive multi-clip world model ported into the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapter via
|
||||
``ComponentSpec.adapter`` (``MatrixGame3DiT`` in ``v2/platform/backends/torch_matrixgame3.py``) and a new
|
||||
``MatrixGame3DenoiseLoop`` (the autoregressive multi-clip loop: per-token timesteps + first-frame pinning;
|
||||
action/camera/KV-memory are BRINGUP), reusing the Wan VAE adapter + T5 + ``stamp_wan21_checkpoints``. The
|
||||
orchestrator registers it in ``v2/registry.py`` (the ``MatrixGame3WanModel`` transformer fallback).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.matrixgame3.card import build_matrixgame3_card
|
||||
from v2.recipes.matrixgame3.loop import MatrixGame3DenoiseLoop
|
||||
from v2.recipes.matrixgame3.program import build_matrixgame3_program
|
||||
|
||||
__all__ = ["build_matrixgame3_card", "build_matrixgame3_program", "MatrixGame3DenoiseLoop"]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Matrix-Game 3.0 ModelCard — image+action+camera -> video autoregressive world model.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern, mirroring cosmos2): the card declares its torch
|
||||
adapter via ``ComponentSpec.adapter`` (``MatrixGame3DiT`` in ``v2/platform/backends/torch_matrixgame3.py``)
|
||||
and a new ``MatrixGame3DenoiseLoop`` (the autoregressive multi-clip loop), reusing the Wan VAE adapter +
|
||||
T5 + ``stamp_wan21_checkpoints``.
|
||||
|
||||
Architecture deltas vs Wan (all GPU-path, declared on the card so the recipe is self-contained):
|
||||
* DiT ``fastvideo.models.dits.matrixgame3.model:MatrixGame3WanModel`` — a 5B autoregressive
|
||||
world-model DiT (30 layers, dim 24*128=3072, ffn 14336, patch (1,2,2), in/out_channels=48,
|
||||
``use_memory=True``, ``sigma_theta=0.8``, action_config on the first 15 blocks). Predicts a velocity
|
||||
(``flow_prediction``). The adapter (``ComponentSpec.adapter`` -> ``MatrixGame3DiT``) takes a PER-TOKEN
|
||||
timestep (cond-frame rows zeroed) + the action/camera/KV-memory bundle — built INTERNALLY so the
|
||||
loop's dit-call stays ``dit(latent, text_embed, sigma)``-shaped.
|
||||
* VAE ``fastvideo.models.vaes.wanvae:AutoencoderKLWan`` — the ``light_vae`` variant (z_dim=48, 16x
|
||||
spatial / 4x temporal, Wan2.2-TI2V geometry); reuses the v2 ``WanVAE`` adapter unchanged (its
|
||||
``_mean_invstd`` normalization path is the MG3 normalized-latent convention).
|
||||
* Text ``fastvideo.models.encoders.t5:T5EncoderModel`` (T5, fp32, text_len 512) via the default
|
||||
``T5Encoder`` adapter (Wan zero-pad-to-512 convention). NO image/CLIP encoder — the conditioning
|
||||
image goes only through the VAE (BRINGUP risk 6).
|
||||
|
||||
Sampler: flow-match via ``FlowUniPCMultistepScheduler`` (``flow_prediction``, flow_shift=5.0); distilled
|
||||
3-step student, guidance_scale=1.0 (CFG effectively off unless ``use_base_model=True``).
|
||||
``stamp_wan21_checkpoints`` applies (diffusers transformer/vae/text_encoder + tokenizer subfolders).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import ClassicCFG, FlowShiftPolicy, NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyDiT, ToyTextEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.matrixgame3.loop import (
|
||||
MG3_LATENT_CHANNELS,
|
||||
MG3_SPATIAL_RATIO,
|
||||
MG3_TEMPORAL_RATIO,
|
||||
MatrixGame3DenoiseLoop,
|
||||
)
|
||||
from v2.recipes.wan21.card import stamp_wan21_checkpoints
|
||||
|
||||
# Matrix-Game 3.0 uses an EMPTY negative prompt by default (kept as a LOCAL module constant per the
|
||||
# self-contained-recipe rule — not added to the shared v2/recipes/_prompts.py).
|
||||
MG3_NEG = ""
|
||||
|
||||
_MG3_DIT = "v2.platform.backends.torch_matrixgame3:MatrixGame3DiT"
|
||||
|
||||
|
||||
def build_matrixgame3_card(model_id: str = "matrixgame3-i2v",
|
||||
*,
|
||||
flow_shift: float = 5.0,
|
||||
checkpoint_root: str | None = None,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = ClassicCFG()
|
||||
# MG3 720p uses flow_shift=5.0 (its 480P-derived pipeline config). The bucket lookup keeps the
|
||||
# 720p/480p shift table consistent with the Wan flow-shift convention.
|
||||
flow = FlowShiftPolicy(shift=flow_shift, bucket_lookup={480 * 832: 3.0, 720 * 1280: 5.0})
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer")
|
||||
|
||||
def loop_factory():
|
||||
return MatrixGame3DenoiseLoop(loop_id="mg3_denoise",
|
||||
cfg=cfg,
|
||||
flow_shift=flow,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost,
|
||||
latent_channels=MG3_LATENT_CHANNELS,
|
||||
spatial_ratio=MG3_SPATIAL_RATIO,
|
||||
temporal_ratio=MG3_TEMPORAL_RATIO)
|
||||
|
||||
components = {
|
||||
"text_encoder":
|
||||
ComponentSpec(component_id="text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.t5:T5EncoderModel",
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"i2v"}),
|
||||
"vae":
|
||||
ComponentSpec(
|
||||
component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.wanvae:AutoencoderKLWan", # light_vae (z=48, 16x/4x)
|
||||
factory=lambda inst: ToyVAE(),
|
||||
required_for={"i2v"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.matrixgame3.model:MatrixGame3WanModel",
|
||||
adapter=_MG3_DIT,
|
||||
factory=lambda inst: ToyDiT(seed=seed),
|
||||
resident_for=["mg3_denoise"],
|
||||
required_for={"i2v"}),
|
||||
}
|
||||
loops = {
|
||||
"mg3_denoise":
|
||||
LoopSpec(loop_id="mg3_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="breakable_cudagraph",
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="matrixgame",
|
||||
components=components,
|
||||
loops=loops,
|
||||
capabilities=CapabilityMatrix.of(Capability.IMAGE_TO_VIDEO, Capability.VAE_DECODE),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="mg3_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
sampling_defaults=sampling_defaults or SamplingDefaults(
|
||||
num_steps=3, guidance_scale=1.0, height=720, width=1280, num_frames=57, fps=25, negative_prompt=MG3_NEG),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_wan21_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
@@ -0,0 +1,302 @@
|
||||
"""MatrixGame3DenoiseLoop — the autoregressive multi-clip world-model denoise loop.
|
||||
|
||||
Matrix-Game 3.0 is NOT a single bidirectional flow-match pass (that is ``WanDenoiseLoop``). It is an
|
||||
OUTER clip loop wrapping an INNER few-step flow-match denoise, with cross-clip frame/KV memory. Faithful
|
||||
port of ``fastvideo/pipelines/stages/matrixgame3_denoising.py:MatrixGame3DenoisingStage.forward``:
|
||||
|
||||
num_iterations = 1 (for num_frames <= 57; the registered preset is num_frames=57)
|
||||
= 1 + ceil((num_frames-57)/40) (longer rollouts)
|
||||
for clip_idx in range(num_iterations):
|
||||
* draw fresh noise for this clip's latent frames; paste the first ``cond_frames`` (1 first clip, 4
|
||||
later) from the previous clip's last-4 latents (``img_cond``);
|
||||
* (clip>0) build the per-clip action(mouse/keyboard) + camera(Plücker) + KV-memory bundle
|
||||
(``x_memory``/``timestep_memory``/``memory_latent_idx``/``predict_latent_idx``) — host numpy work;
|
||||
* INNER loop: for each of the ``num_steps`` (distilled=3) FlowUniPC timesteps, call the DiT with a
|
||||
PER-TOKEN timestep (cond rows zeroed — built INSIDE the adapter), take a scheduler step, then
|
||||
RE-PASTE ``img_cond`` onto the ``cond_frames`` (so the known frames never drift);
|
||||
* after the clip: ``img_cond`` <- last-4 latents; append the denoised tail (full first clip,
|
||||
last-10 thereafter) to history.
|
||||
concat history on the time dim -> the final latent.
|
||||
|
||||
THIS PORT — degenerate single-clip path is built so it CPU-verifies on the toy backend:
|
||||
* The inner flow-match step uses the platform's numpy ``FLOW_MATCH_STEP`` kernel (the CPU-testable
|
||||
stand-in). On a GPU box the *real* ``FlowUniPCMultistepScheduler`` must drive ``step()`` — it is a
|
||||
stateful MULTISTEP solver and substituting plain Euler breaks the 3-step distilled trajectory
|
||||
(BRINGUP risk 3). The ``self.use_unipc`` hook documents where the real scheduler plugs in.
|
||||
* The dit-call is ``dit(latent, text_embed, sigma, cond=<MG3 bundle>)`` — ToyDiT accepts and ignores
|
||||
``cond`` (image/action/camera conditioning is a GPU-path concern), so the toy denoises the noise
|
||||
channels exactly like the cosmos2 video2world degradation.
|
||||
* Action / camera / KV-memory (mouse_cond/keyboard_cond/plucker/x_memory) are BRINGUP: they need a
|
||||
request-API extension to carry action streams + camera trajectories, plus the vendored host helpers
|
||||
(``matrixgame3.utils`` + ``lingbotworld.cam_utils``). The single-clip no-action path is the t2v/i2v
|
||||
degenerate the loop CPU-verifies; the multi-clip rollout hooks are present + documented.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2._enums import ExecutionProfile, WorkUnitKind
|
||||
from v2.loop.contracts import (
|
||||
Done,
|
||||
LoopResult,
|
||||
LoopState,
|
||||
ResourceRequest,
|
||||
ShapeSignature,
|
||||
StepContext,
|
||||
StepResult,
|
||||
WorkPlan,
|
||||
)
|
||||
from v2.platform import FLOW_MATCH_STEP
|
||||
|
||||
# Matrix-Game 3.0 light_vae (Wan2.2-TI2V geometry): z_dim=48, 16x spatial, 4x temporal compression.
|
||||
MG3_LATENT_CHANNELS = 48
|
||||
MG3_SPATIAL_RATIO = 16
|
||||
MG3_TEMPORAL_RATIO = 4
|
||||
MG3_NUM_TRAIN_TIMESTEPS = 1000 # FlowUniPC timestep ~ sigma * num_train_timesteps
|
||||
|
||||
# Clip geometry (hardcoded in the fastvideo denoising stage). first_clip = 57 frames -> 15 latent frames.
|
||||
MG3_CLIP_FRAME = 56
|
||||
MG3_FIRST_CLIP_FRAME = MG3_CLIP_FRAME + 1 # 57
|
||||
MG3_PAST_FRAME = 16
|
||||
|
||||
|
||||
def _toy_latent_shape(req) -> tuple[int, int, int, int]:
|
||||
"""Tiny deterministic CPU-toy latent geometry (matches the WanDenoiseLoop toy stand-in), so the loop
|
||||
control flow + per-token-timestep/cond-frame/re-paste logic exercise on numbers without a GPU."""
|
||||
d = req.diffusion
|
||||
from v2.platform.backends.toy import LATENT_CHANNELS
|
||||
t = max(2, d.num_frames // 40)
|
||||
h = max(2, d.height // 120)
|
||||
w = max(2, d.width // 120)
|
||||
return (LATENT_CHANNELS, t, h, w)
|
||||
|
||||
|
||||
def _gpu_latent_shape(req, channels: int, spatial_ratio: int, temporal_ratio: int) -> tuple[int, int, int, int]:
|
||||
d = req.diffusion
|
||||
t = (max(1, d.num_frames) - 1) // temporal_ratio + 1
|
||||
return (channels, max(1, t), max(1, d.height // spatial_ratio), max(1, d.width // spatial_ratio))
|
||||
|
||||
|
||||
def _cond_compatible(latent: np.ndarray, img_cond: np.ndarray) -> bool:
|
||||
"""The conditioning latent can be pasted onto the leading frames iff its channel + trailing spatial
|
||||
dims match the noise latent (true on the GPU 48-channel light_vae; false for the toy VAE stand-in)."""
|
||||
return (latent.shape[0] == img_cond.shape[0] and latent.shape[2:] == img_cond.shape[2:])
|
||||
|
||||
|
||||
class MatrixGame3DenoiseLoop:
|
||||
"""Autoregressive multi-clip world-model denoise. ``next``/``advance`` drive the INNER step; the OUTER
|
||||
clip advance happens in ``advance`` when the inner schedule is exhausted (the loop carries the clip
|
||||
index + history in ``LoopState.scratch``). On the registered single-clip preset there is exactly one
|
||||
clip, so the loop reduces to an N-step flow-match denoise over one latent with first-frame pinning."""
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
loop_id,
|
||||
cfg,
|
||||
flow_shift,
|
||||
precision,
|
||||
expert,
|
||||
cost,
|
||||
latent_channels: int = MG3_LATENT_CHANNELS,
|
||||
spatial_ratio: int = MG3_SPATIAL_RATIO,
|
||||
temporal_ratio: int = MG3_TEMPORAL_RATIO,
|
||||
use_unipc: bool = True):
|
||||
self.loop_id = loop_id
|
||||
self.cfg = cfg # carried for the WorkPlan op-structure key; CFG is off by default
|
||||
self.flow_shift = flow_shift
|
||||
self.precision = precision
|
||||
self.expert = expert
|
||||
self.cost = cost
|
||||
self.latent_channels = latent_channels
|
||||
self.spatial_ratio = spatial_ratio
|
||||
self.temporal_ratio = temporal_ratio
|
||||
# GPU path: drive the real FlowUniPCMultistepScheduler.step (stateful multistep) instead of the
|
||||
# numpy Euler stand-in (BRINGUP risk 3). The CPU toy always uses FLOW_MATCH_STEP.
|
||||
self.use_unipc = use_unipc
|
||||
|
||||
# --- clip-count + per-clip geometry (faithful to MatrixGame3DenoisingStage) ------------------- #
|
||||
@staticmethod
|
||||
def _num_iterations(num_frames: int) -> int:
|
||||
if isinstance(num_frames, int) and num_frames > MG3_FIRST_CLIP_FRAME:
|
||||
return 1 + max(0, (num_frames - MG3_FIRST_CLIP_FRAME + 39) // 40)
|
||||
return 1
|
||||
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
seed = req.diffusion.seed if req.diffusion.seed is not None else 0
|
||||
rng = np.random.default_rng(seed)
|
||||
is_gpu = getattr(getattr(model, "platform", None), "device", "cpu") == "cuda"
|
||||
if is_gpu:
|
||||
shape = _gpu_latent_shape(req, self.latent_channels, self.spatial_ratio, self.temporal_ratio)
|
||||
else:
|
||||
shape = _toy_latent_shape(req)
|
||||
# Flow-match sigma schedule (1 -> 0) with flow_shift; the FlowUniPC scheduler uses the same shift.
|
||||
# ``req.diffusion.sigmas`` (a distilled explicit schedule) overrides when provided.
|
||||
sig = self.flow_shift.build_schedule(req.diffusion.num_steps,
|
||||
req.diffusion.height,
|
||||
req.diffusion.width,
|
||||
sigmas=req.diffusion.sigmas or None)
|
||||
x = (rng.standard_normal(shape) * float(sig[0])).astype("float32")
|
||||
|
||||
# First-frame conditioning latent (image VAE-encoded by the program). On the degenerate path it is
|
||||
# absent -> no pinning, pure flow-match. cond_frames=1 on the first clip (and is clamped to the
|
||||
# available conditioning frames). On the GPU path the cond latent shares the noise latent's
|
||||
# [C=48, T, h, w] geometry, so the leading frames are pasted in (and re-pasted every step). The
|
||||
# CPU toy VAE is only a geometry-preserving stand-in for the channel dims, so ``_cond_compatible``
|
||||
# guards the pin: when the trailing dims don't match (toy), it stays the degenerate flow-match.
|
||||
img_cond = ctx.slots.get("image_latent")
|
||||
cond_frames = 0
|
||||
if img_cond is not None:
|
||||
img_cond = np.asarray(img_cond, dtype="float32")
|
||||
if _cond_compatible(x, img_cond):
|
||||
cond_frames = min(1, img_cond.shape[1]) # first clip pins 1 frame
|
||||
x[:, :cond_frames] = img_cond[:, :cond_frames]
|
||||
else:
|
||||
img_cond = None # toy: incompatible geometry -> no pinning
|
||||
|
||||
st = LoopState(
|
||||
loop_id=self.loop_id,
|
||||
instance_id=model.card.model_id,
|
||||
request_id=req.request_id,
|
||||
profile=ctx.profile,
|
||||
rng=rng,
|
||||
seed=seed,
|
||||
latents={"video": x},
|
||||
sigmas=[float(s) for s in sig],
|
||||
# FlowUniPC model timestep = sigma * num_train_timesteps (the adapter fills the
|
||||
# per-token tensor with this value; cond rows zeroed inside the adapter).
|
||||
timesteps=[float(s) * MG3_NUM_TRAIN_TIMESTEPS for s in sig])
|
||||
st.cond["prompt_embeds"] = ctx.slots.get("text_embeds")
|
||||
st.cond["negative_prompt_embeds"] = ctx.slots.get("neg_text_embeds")
|
||||
st.scratch["guidance_scale"] = float(req.diffusion.guidance_scale)
|
||||
# use_base_model=False + guidance_scale=1.0 (the distilled default) -> the CFG/uncond branch is
|
||||
# skipped (BRINGUP risk 8). do_cfg only fires for the base (non-distilled) model.
|
||||
st.scratch["use_base_model"] = bool(getattr(req.diffusion, "use_base_model", False))
|
||||
st.scratch["img_cond"] = img_cond
|
||||
st.scratch["cond_frames"] = cond_frames
|
||||
# Latent-frame span the model uses for rotary positions; single-clip span is the whole latent.
|
||||
st.scratch["predict_latent_idx"] = (0, int(x.shape[1]))
|
||||
# Autoregressive rollout (BRINGUP): num_iterations>1 + the action/camera/memory bundle. The
|
||||
# single-clip preset (num_frames<=57) keeps these inert.
|
||||
st.scratch["num_iterations"] = self._num_iterations(int(req.diffusion.num_frames))
|
||||
st.scratch["clip_idx"] = 0
|
||||
st.scratch["history"] = [] # appended denoised clip tails (rollout)
|
||||
# Per-clip action/camera/memory conditioning — None on the degenerate path; assembled per clip on
|
||||
# the GPU rollout path (needs a request-API extension to carry action streams).
|
||||
st.scratch["mg3_cond"] = self._build_clip_cond(st)
|
||||
st.scratch["stream_video"] = bool(req.outputs.stream.get("video"))
|
||||
st.plugin_state["cfg"] = {}
|
||||
return st
|
||||
|
||||
def _build_clip_cond(self, st: LoopState) -> dict:
|
||||
"""Assemble the per-step MG3 conditioning bundle the adapter consumes. On the degenerate
|
||||
single-clip / no-action path this is just ``cond_frames`` + ``predict_latent_idx`` (the action /
|
||||
camera / KV-memory slots stay None). BRINGUP: the multi-clip rollout fills ``mouse_cond`` /
|
||||
``keyboard_cond`` / ``c2ws_plucker_emb`` / ``x_memory`` / ``timestep_memory`` /
|
||||
``memory_latent_idx`` here from the vendored host helpers; that path needs a request-API
|
||||
extension to carry per-frame action streams + a camera trajectory."""
|
||||
return {
|
||||
"cond_frames": int(st.scratch.get("cond_frames", 0)),
|
||||
"predict_latent_idx": st.scratch.get("predict_latent_idx", (0, 0)),
|
||||
"mouse_cond": None,
|
||||
"keyboard_cond": None,
|
||||
"x_memory": None,
|
||||
"timestep_memory": None,
|
||||
"mouse_cond_memory": None,
|
||||
"keyboard_cond_memory": None,
|
||||
"c2ws_plucker_emb": None,
|
||||
"memory_latent_idx": None,
|
||||
}
|
||||
|
||||
def next(self, st: LoopState):
|
||||
i = st.step_idx
|
||||
if i >= len(st.sigmas) - 1:
|
||||
return Done() # inner schedule exhausted (single-clip preset -> done)
|
||||
sigma_t, sigma_next = st.sigmas[i], st.sigmas[i + 1]
|
||||
t = st.timesteps[i] # FlowUniPC model timestep (~sigma*1000) -> per-token value
|
||||
expert_id = self.expert.expert_for(StepContext(i, t, sigma_t))
|
||||
x = st.latents["video"]
|
||||
pe, ne = st.cond["prompt_embeds"], st.cond["negative_prompt_embeds"]
|
||||
scale = st.scratch["guidance_scale"]
|
||||
precision = self.precision
|
||||
do_cfg = st.scratch.get("use_base_model", False) and scale != 1.0 and ne is not None
|
||||
mg3_cond = st.scratch["mg3_cond"]
|
||||
img_cond = st.scratch.get("img_cond")
|
||||
cond_frames = int(st.scratch.get("cond_frames", 0))
|
||||
|
||||
def _velocity(model: Any, x_: Any, pe_: Any, ne_: Any) -> Any:
|
||||
# The conditioned forward + (optional) CFG combine. The adapter builds the per-token timestep
|
||||
# + packs the MG3 conditioning bundle internally; the loop hands the raw timestep ``t`` as the
|
||||
# ``sigma`` arg (the FlowUniPC convention) so the per-token tensor carries the right value.
|
||||
dit = model.component(expert_id)
|
||||
cond_pred = dit(x_, pe_, t, cond=mg3_cond)
|
||||
if do_cfg: # base model only (distilled student skips this)
|
||||
uncond_pred = dit(x_, ne_, t, cond=mg3_cond)
|
||||
combined = np.asarray(uncond_pred) + scale * (np.asarray(cond_pred) - np.asarray(uncond_pred))
|
||||
return precision.cast(combined)
|
||||
return precision.cast(np.asarray(cond_pred))
|
||||
|
||||
def run(model: Any, override: Any = None) -> StepResult:
|
||||
# EAGER thunk. FLOW_MATCH_STEP is the CPU stand-in for FlowUniPC.step (BRINGUP risk 3: the GPU
|
||||
# path must drive the real stateful multistep scheduler). After the step, RE-PASTE img_cond on
|
||||
# the cond_frames so the known frames never drift (BRINGUP risk 7).
|
||||
kernels = model.platform.kernels
|
||||
if override is not None and "noise_pred" in override:
|
||||
velocity = precision.cast(np.asarray(override["noise_pred"], dtype="float32"))
|
||||
else:
|
||||
velocity = _velocity(model, x, pe, ne)
|
||||
x_next = kernels.get(FLOW_MATCH_STEP)(precision.cast(x), velocity, sigma_t, sigma_next)
|
||||
x_next = np.asarray(x_next, dtype="float32")
|
||||
if img_cond is not None and cond_frames > 0:
|
||||
x_next[:, :cond_frames] = img_cond[:, :cond_frames] # re-pin the conditioning frames
|
||||
return StepResult(output={"noise_pred": np.asarray(velocity, dtype="float32"), "latents": x_next})
|
||||
|
||||
cond_bytes = sum(int(np.asarray(e).nbytes) for e in (pe, ne) if e is not None)
|
||||
res = ResourceRequest(compute_seconds=self.cost.predict(int(np.prod(x.shape)), 2.0 if do_cfg else 1.0),
|
||||
resident_bytes=int(x.nbytes) + cond_bytes,
|
||||
peak_activation_bytes=int(x.nbytes))
|
||||
return WorkPlan(
|
||||
loop_id=self.loop_id,
|
||||
instance_id=st.instance_id,
|
||||
kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
shape_sig=ShapeSignature(WorkUnitKind.DIFFUSION_STEP,
|
||||
dims=tuple(x.shape),
|
||||
dtype=precision.compute_dtype,
|
||||
extra=(("cfg", type(self.cfg).__name__), ("ar_world_model", True))),
|
||||
resources=res,
|
||||
payload={
|
||||
"branch": "mg3",
|
||||
"step": i,
|
||||
"clip": st.scratch.get("clip_idx", 0)
|
||||
},
|
||||
run=run,
|
||||
label=f"mg3.denoise.c{st.scratch.get('clip_idx', 0)}.{i}",
|
||||
# per-token timestep packing + first-frame re-paste + (rollout) host-built conditioning ->
|
||||
# the eager path (no static-buffer capture; the cosmos2 video2world precedent).
|
||||
capturable=False)
|
||||
|
||||
def advance(self, st: LoopState, result: StepResult) -> LoopState:
|
||||
st.latents["video"] = result.output["latents"]
|
||||
if st.profile == ExecutionProfile.ROLLOUT:
|
||||
st.trajectory.append({
|
||||
"step": st.step_idx,
|
||||
"sigma": st.sigmas[st.step_idx],
|
||||
"velocity": np.asarray(result.output["noise_pred"]).copy(),
|
||||
"latents": np.asarray(st.latents["video"]).copy()
|
||||
})
|
||||
st.step_idx += 1
|
||||
# OUTER clip advance (BRINGUP rollout): when the inner schedule is done and more clips remain,
|
||||
# snapshot the clip tail into history, slide img_cond -> last-4 latents, and reset the inner
|
||||
# step counter + draw fresh noise for the next clip. The single-clip preset never enters this.
|
||||
return st
|
||||
|
||||
def finalize(self, st: LoopState) -> LoopResult:
|
||||
# Single-clip preset: the final latent is the denoised clip. Multi-clip rollout would concat the
|
||||
# history tails on the time dim (BRINGUP).
|
||||
return LoopResult(outputs={"latents": st.latents["video"]},
|
||||
metrics={
|
||||
"denoise_steps": float(st.step_idx),
|
||||
"clips": float(st.scratch.get("clip_idx", 0) + 1)
|
||||
},
|
||||
behavior=st.trajectory or None)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Matrix-Game 3.0 program: text_encode -> image VAE-encode (first frame) -> mg3_denoise -> vae_decode.
|
||||
|
||||
Mirrors the cosmos2 / Wan i2v node graph. The MG3 specifics (per-token timestep, first-frame pinning,
|
||||
autoregressive clip loop) live entirely in ``MatrixGame3DenoiseLoop`` + the ``MatrixGame3DiT`` adapter, so
|
||||
the node graph is the standard i2v shape:
|
||||
|
||||
* ``text_encode`` — T5 prompt + (empty) negative prompt -> ``text_embeds`` / ``neg_text_embeds``.
|
||||
* ``image_encode`` — VAE-encode the conditioning image as the first frame -> ``image_latent`` (the
|
||||
``MatrixGame3ImageVAEEncodingStage`` normalization: ``(z - latents_mean)/latents_std``, which the
|
||||
reused ``WanVAE`` adapter applies inside ``encode``). The loop pins the first ``cond_frames`` latent
|
||||
frames to this and re-pastes after every scheduler step. Absent (no image) -> ``image_latent=None``
|
||||
-> the loop runs the degenerate flow-match denoise (the CPU-toy path).
|
||||
* ``denoise`` — ``MatrixGame3DenoiseLoop`` reads ``text_embeds`` + ``image_latent`` from slots.
|
||||
* ``vae_decode`` — light_vae decode -> video.
|
||||
|
||||
BRINGUP: the action (mouse/keyboard) + camera (Plücker) conditioning would default to
|
||||
``build_matrixgame3_action_preset(total_video_frames, seed)`` + a derived camera trajectory and be
|
||||
written into slots here; that needs a request-API extension to carry the action streams + camera path
|
||||
(see the loop's ``_build_clip_cond`` hook). The single-clip no-action path is what the registered preset
|
||||
+ CPU verification exercise.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
from v2.recipes.common import text_encode_node_fn as _text_encode
|
||||
|
||||
|
||||
def _mg3_image_encode(instance, slots, request, ctx) -> None:
|
||||
"""VAE-encode the conditioning image as the first frame (rest zeros) -> ``image_latent`` (normalized
|
||||
by the WanVAE adapter's ``_mean_invstd`` path, matching ``MatrixGame3ImageVAEEncodingStage``). No
|
||||
image -> ``image_latent`` stays None and the loop runs the degenerate flow-match denoise."""
|
||||
img = request.image()
|
||||
if img is None or getattr(img, "pixels", None) is None:
|
||||
slots["image_latent"] = None
|
||||
return
|
||||
px = np.asarray(img.pixels, dtype="float32") # [3, H, W] in [-1, 1]
|
||||
# A single-frame "video" tensor [3, 1, H, W] -> the VAE encodes one latent frame the loop pins.
|
||||
cond_video = px[:, None]
|
||||
slots["image_latent"] = np.asarray(instance.component("vae").encode(cond_video), dtype="float32")
|
||||
|
||||
|
||||
def _mg3_vae_decode(instance, slots, request, ctx) -> None:
|
||||
slots["video"] = instance.component("vae").decode(slots["denoise_out"]["latents"])
|
||||
|
||||
|
||||
def build_matrixgame3_program() -> Program:
|
||||
return Program(
|
||||
program_id="matrixgame3.i2v.inline",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("text_encode", fn=_text_encode, writes=("text_embeds", "neg_text_embeds")),
|
||||
ComponentNode("image_encode", fn=_mg3_image_encode, writes=("image_latent", )),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="mg3_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("text_embeds", "neg_text_embeds", "image_latent"),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("vae_decode", fn=_mg3_vae_decode, reads=("denoise_out", ), writes=("video", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"video": "video",
|
||||
"latents": "denoise_out"
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Stable Diffusion 3.5 Medium (text→image) — a FLOW-MATCH MMDiT ported into the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapters via
|
||||
``ComponentSpec.adapter`` (``SD3DiT``/``SD3VAE``/``SD3ClipEncoder``/``SD3T5Encoder`` in
|
||||
``v2/platform/backends/torch_sd35.py``) and a new ``SD3DenoiseLoop`` (flow-match Euler over 4D image
|
||||
latents with dual text conditioning: the triple-encoder joint embed + the dual-CLIP pooled vector). It
|
||||
reuses the ``FLOW_MATCH_STEP`` kernel + ``FlowShiftPolicy`` + ``ClassicCFG``. Registered in ``v2/registry.py``
|
||||
by the orchestrator (transformer ``_class_name`` = ``SD3Transformer2DModel`` for the arch fallback).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.sd35.card import build_sd35_card
|
||||
from v2.recipes.sd35.loop import SD3DenoiseLoop
|
||||
from v2.recipes.sd35.program import build_sd35_program
|
||||
|
||||
__all__ = ["build_sd35_card", "build_sd35_program", "SD3DenoiseLoop"]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Stable Diffusion 3.5 Medium ModelCard — text→image (the registered preset). Image is treated as a
|
||||
1-frame video (num_frames=1, 4D latents) throughout the v2 substrate.
|
||||
|
||||
Architecture deltas vs Wan (all declared on the card so the recipe is self-contained):
|
||||
* DiT ``fastvideo.models.dits.sd3:SD3Transformer2DModel`` — a FLOW-MATCH MMDiT (reuses the
|
||||
flow-match step), but the forward needs TWO text conditioners: the assembled triple-encoder joint
|
||||
embed (``encoder_hidden_states``) AND a separate ``pooled_projections`` vector. The adapter
|
||||
(``ComponentSpec.adapter`` -> ``SD3DiT``) takes both (joint embed as ``text_embed``, pooled as the
|
||||
positional ``context`` arg) and ``SD3DenoiseLoop`` threads them per CFG branch.
|
||||
* VAE ``fastvideo.models.vaes.autoencoder_kl:AutoencoderKL`` — SD3.5 shift/scale normalization
|
||||
(``scaling_factor=1.5305``, ``shift_factor=0.0609`` read from the checkpoint vae config); the
|
||||
``SD3VAE`` adapter normalizes/denormalizes around the raw AutoencoderKL space (4D image, not 5D video).
|
||||
* Text x3: ``CLIPTextModelWithProjection`` (clip_l + clip_g, penultimate-hidden + pooled) +
|
||||
``T5EncoderModel`` (last_hidden_state). The program assembles the joint embed + dual-CLIP pooled.
|
||||
|
||||
Schedule: plain flow-match linspace with the diffusers ``FlowMatchEulerDiscreteScheduler`` config
|
||||
``shift`` (3.0 for SD3.5-medium). The scheduler's optional ``use_dynamic_shifting`` (resolution-dependent
|
||||
``mu``) is NOT applied here — if a checkpoint's scheduler config enables it, the static shift will mismatch
|
||||
the reference schedule (BRINGUP blocker 6). CFG is the classic ``uncond + s·(cond − uncond)``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import ClassicCFG, FlowShiftPolicy, NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyTextEncoder, ToyVAE, _seed_from
|
||||
from v2.recipes.sd35.loop import SD3DenoiseLoop
|
||||
|
||||
# SD3.5's default negative prompt is empty (LOCAL module constant — the spec's defaults.negative_prompt).
|
||||
SD35_NEG = ""
|
||||
|
||||
# SD3.5-medium diffusers FlowMatchEulerDiscreteScheduler static flow shift (BRINGUP blocker 8: read the
|
||||
# real scheduler config at deploy time; SD35Config.flow_shift is None, the shift lives in the scheduler).
|
||||
SD35_FLOW_SHIFT = 3.0
|
||||
|
||||
_SD3_DIT = "v2.platform.backends.torch_sd35:SD3DiT"
|
||||
_SD3_VAE = "v2.platform.backends.torch_sd35:SD3VAE"
|
||||
_SD3_CLIP = "v2.platform.backends.torch_sd35:SD3ClipEncoder"
|
||||
_SD3_T5 = "v2.platform.backends.torch_sd35:SD3T5Encoder"
|
||||
|
||||
|
||||
def build_sd35_card(model_id: str = "stable-diffusion-3.5-medium",
|
||||
*,
|
||||
checkpoint_root: str | None = None,
|
||||
flow_shift: float = SD35_FLOW_SHIFT,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = ClassicCFG()
|
||||
flow = FlowShiftPolicy(shift=flow_shift) # static shift (no resolution-bucket lookup for SD3.5)
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer")
|
||||
|
||||
def loop_factory():
|
||||
return SD3DenoiseLoop(loop_id="diffusion_denoise",
|
||||
cfg=cfg,
|
||||
flow_shift=flow,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost)
|
||||
|
||||
components = {
|
||||
# The two CLIP encoders (clip_l, clip_g) — penultimate-hidden + pooled. The third is T5.
|
||||
"text_encoder":
|
||||
ComponentSpec(component_id="text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.clip:CLIPTextModelWithProjection",
|
||||
adapter=_SD3_CLIP,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2i"}),
|
||||
"text_encoder_2":
|
||||
ComponentSpec(component_id="text_encoder_2",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.clip:CLIPTextModelWithProjection",
|
||||
adapter=_SD3_CLIP,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2i"}),
|
||||
"text_encoder_3":
|
||||
ComponentSpec(component_id="text_encoder_3",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.t5:T5EncoderModel",
|
||||
adapter=_SD3_T5,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2i"}),
|
||||
"vae":
|
||||
ComponentSpec(component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.autoencoder_kl:AutoencoderKL",
|
||||
adapter=_SD3_VAE,
|
||||
factory=lambda inst: ToyVAE(),
|
||||
required_for={"t2i"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.sd3:SD3Transformer2DModel",
|
||||
adapter=_SD3_DIT,
|
||||
factory=lambda inst: _toy_dit(seed),
|
||||
resident_for=["diffusion_denoise"],
|
||||
required_for={"t2i"}),
|
||||
}
|
||||
loops = {
|
||||
"diffusion_denoise":
|
||||
LoopSpec(loop_id="diffusion_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="eager",
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="sd3",
|
||||
components=components,
|
||||
loops=loops,
|
||||
capabilities=CapabilityMatrix.of(Capability.TEXT_TO_IMAGE, Capability.VAE_DECODE, Capability.POLICY_ROLLOUT),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="diffusion_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
sampling_defaults=sampling_defaults or SamplingDefaults(
|
||||
num_steps=28, guidance_scale=6.0, height=512, width=512, num_frames=1, fps=1, negative_prompt=SD35_NEG),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_sd35_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
|
||||
|
||||
def _toy_dit(seed: int):
|
||||
"""CPU toy DiT for the transformer component. ToyDiT.__call__ accepts the positional ``context`` arg
|
||||
(it mean-pools it harmlessly), so the dual-conditioning ``dit(latent, joint_embed, sigma, context=pooled)``
|
||||
call CPU-verifies without any SD3-specific stand-in."""
|
||||
from v2.platform.backends.toy import ToyDiT
|
||||
return ToyDiT(seed=seed)
|
||||
|
||||
|
||||
# Diffusers SD3.5 checkpoint layout: transformer/ + vae/ + text_encoder{,_2,_3}/ + tokenizer{,_2,_3}/.
|
||||
# Distinct from ``_WAN21_SUBFOLDERS`` (one text_encoder) — SD3 carries THREE text encoders. NOTE: the
|
||||
# torch backend's ``_make_text_encoder`` loads the tokenizer from a FIXED ``<root>/tokenizer`` sibling, so
|
||||
# text_encoder_2/_3 would load the wrong tokenizer (BRINGUP blocker: needs the per-encoder tokenizer
|
||||
# subfolder threaded into the maker, or a small adapter-side override at GPU bring-up).
|
||||
_SD35_SUBFOLDERS = {
|
||||
"transformer": "transformer",
|
||||
"vae": "vae",
|
||||
"text_encoder": "text_encoder",
|
||||
"text_encoder_2": "text_encoder_2",
|
||||
"text_encoder_3": "text_encoder_3",
|
||||
}
|
||||
|
||||
|
||||
def stamp_sd35_checkpoints(card: ModelCard, model_root: str) -> ModelCard:
|
||||
"""GPU deploy-time: point each component's ``ComponentSpec.checkpoint`` at its weights subfolder under
|
||||
``model_root`` (a local diffusers dir, or an HF id resolved via ``snapshot_download``). Mirrors
|
||||
``stamp_wan21_checkpoints`` but covers the THREE SD3.5 text encoders. Mutates and returns the card."""
|
||||
if not os.path.isdir(model_root):
|
||||
from huggingface_hub import snapshot_download
|
||||
model_root = snapshot_download(model_root)
|
||||
for component_id, subfolder in _SD35_SUBFOLDERS.items():
|
||||
if component_id in card.components:
|
||||
card.components[component_id].checkpoint = os.path.join(model_root, subfolder)
|
||||
return card
|
||||
@@ -0,0 +1,189 @@
|
||||
"""SD3DenoiseLoop — a thin flow-match Euler denoise loop for Stable Diffusion 3.5 (image / 4D latents).
|
||||
|
||||
SD3.5 is a plain FLOW-MATCH MMDiT, so this loop reuses the canonical pieces (``FLOW_MATCH_STEP`` kernel,
|
||||
``FlowShiftPolicy.build_schedule``, ``ClassicCFG``) — the only deltas vs ``WanDenoiseLoop`` are:
|
||||
|
||||
* IMAGE modality / 4D latents: the latent is ``[16, h//8, w//8]`` (num_frames=1), with NO temporal
|
||||
dim. The fastvideo stage carries a fake T dim in its 5D ForwardBatch (unsqueeze(2)/squeeze(2)) but
|
||||
the DiT forward itself is strictly 4D — so we drop the T dim here (BRINGUP blocker 3).
|
||||
* DUAL DiT conditioning (BRINGUP blocker 1): the SD3 forward needs BOTH the assembled triple-encoder
|
||||
joint embed (``encoder_hidden_states``) AND a separate pooled vector (``pooled_projections``). The
|
||||
program writes both into slots; this loop threads the joint embed as ``text_embed`` and the pooled
|
||||
vector as the positional ``context`` arg — so the dit-call stays the cosmos2-style
|
||||
``dit(latent, text_embed, sigma, context)`` that ToyDiT accepts (CPU-verifiable) and SD3DiT unpacks.
|
||||
* VELOCITY output: SD3DiT returns the flow-match velocity directly (no x0 reconstruction), so the
|
||||
Euler update is the plain ``x_next = x + (sigma_next - sigma)*v``.
|
||||
|
||||
Faithful to ``fastvideo/pipelines/stages/sd35_conditioning.py:SD35DenoisingStage`` (uncond + s·(cond −
|
||||
uncond) CFG over the FlowMatchEuler schedule). The σ schedule is the flow-shift linspace; ``shift`` comes
|
||||
from the diffusers ``FlowMatchEulerDiscreteScheduler`` config (3.0 for SD3.5-medium) — see ``card.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2._enums import ExecutionProfile, WorkUnitKind
|
||||
from v2.loop.contracts import (
|
||||
Done,
|
||||
LoopResult,
|
||||
LoopState,
|
||||
ResourceRequest,
|
||||
ShapeSignature,
|
||||
StepContext,
|
||||
StepResult,
|
||||
WorkPlan,
|
||||
)
|
||||
from v2.platform import FLOW_MATCH_STEP
|
||||
from v2.platform.backends.toy import LATENT_CHANNELS
|
||||
|
||||
# Real SD3.5 AutoencoderKL: z=16, 8x spatial compression, IMAGE (no temporal dim).
|
||||
SD3_LATENT_CHANNELS = 16
|
||||
SD3_SPATIAL_RATIO = 8
|
||||
|
||||
|
||||
def sd3_latent_shape(req,
|
||||
model=None,
|
||||
*,
|
||||
channels: int = SD3_LATENT_CHANNELS,
|
||||
spatial_ratio: int = SD3_SPATIAL_RATIO) -> tuple[int, int, int]:
|
||||
"""4D image latent geometry ``[C, h, w]`` (the leading batch dim is added by the adapter). On the GPU
|
||||
backend the true SD3.5 geometry is required; the CPU toy uses a tiny deterministic stand-in (matching
|
||||
``v2.recipes.wan21.loop.latent_shape`` but with NO temporal axis — image, not video)."""
|
||||
d = req.diffusion
|
||||
if model is not None and getattr(getattr(model, "platform", None), "device", "cpu") == "cuda":
|
||||
return (channels, max(1, d.height // spatial_ratio), max(1, d.width // spatial_ratio))
|
||||
h = max(2, d.height // 120)
|
||||
w = max(2, d.width // 120)
|
||||
return (LATENT_CHANNELS, h, w)
|
||||
|
||||
|
||||
class SD3DenoiseLoop:
|
||||
"""Flow-match Euler denoise for SD3.5 (image / 4D latents, dual text conditioning)."""
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
loop_id,
|
||||
cfg,
|
||||
flow_shift,
|
||||
precision,
|
||||
expert,
|
||||
cost,
|
||||
latent_channels: int = SD3_LATENT_CHANNELS,
|
||||
spatial_ratio: int = SD3_SPATIAL_RATIO):
|
||||
self.loop_id = loop_id
|
||||
self.cfg = cfg
|
||||
self.flow_shift = flow_shift
|
||||
self.precision = precision
|
||||
self.expert = expert
|
||||
self.cost = cost
|
||||
self.latent_channels = latent_channels
|
||||
self.spatial_ratio = spatial_ratio
|
||||
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
seed = req.diffusion.seed if req.diffusion.seed is not None else 0
|
||||
rng = np.random.default_rng(seed)
|
||||
sig = self.flow_shift.build_schedule(req.diffusion.num_steps,
|
||||
req.diffusion.height,
|
||||
req.diffusion.width,
|
||||
sigmas=req.diffusion.sigmas or None)
|
||||
shape = sd3_latent_shape(req, model, channels=self.latent_channels, spatial_ratio=self.spatial_ratio)
|
||||
x = (rng.standard_normal(shape) * float(sig[0])).astype("float32")
|
||||
st = LoopState(loop_id=self.loop_id,
|
||||
instance_id=model.card.model_id,
|
||||
request_id=req.request_id,
|
||||
profile=ctx.profile,
|
||||
rng=rng,
|
||||
seed=seed,
|
||||
latents={"image": x},
|
||||
sigmas=[float(s) for s in sig],
|
||||
timesteps=[float(s) * 1000.0 for s in sig])
|
||||
# Dual text conditioning (BRINGUP blocker 1): the program's SD3 text-encode node writes the
|
||||
# assembled joint embed AND the pooled vector for both the cond and uncond branches into slots.
|
||||
st.cond["prompt_embeds"] = ctx.slots.get("text_embeds")
|
||||
st.cond["negative_prompt_embeds"] = ctx.slots.get("neg_text_embeds")
|
||||
st.cond["pooled"] = ctx.slots.get("pooled_projections")
|
||||
st.cond["neg_pooled"] = ctx.slots.get("neg_pooled_projections")
|
||||
st.scratch["guidance_scale"] = float(req.diffusion.guidance_scale)
|
||||
st.scratch["stream_image"] = bool(req.outputs.stream.get("image") or req.outputs.stream.get("video"))
|
||||
st.plugin_state["cfg"] = {}
|
||||
return st
|
||||
|
||||
def next(self, st: LoopState):
|
||||
i = st.step_idx
|
||||
if i >= len(st.sigmas) - 1:
|
||||
return Done()
|
||||
sigma_t, sigma_next = st.sigmas[i], st.sigmas[i + 1]
|
||||
expert_id = self.expert.expert_for(StepContext(i, st.timesteps[i], sigma_t))
|
||||
sctx = StepContext(step_idx=i, timestep=st.timesteps[i], sigma=sigma_t, active_expert_id=expert_id)
|
||||
cfg_state = st.plugin_state["cfg"]
|
||||
branches = self.cfg.branches_this_step(sctx, cfg_state)
|
||||
x = st.latents["image"]
|
||||
pe, ne = st.cond["prompt_embeds"], st.cond["negative_prompt_embeds"]
|
||||
pp, npp = st.cond["pooled"], st.cond["neg_pooled"]
|
||||
scale = st.scratch["guidance_scale"]
|
||||
cfg, precision = self.cfg, self.precision
|
||||
|
||||
def _velocity(model: Any) -> np.ndarray:
|
||||
# The conditioned forward + CFG combine. Each branch threads its OWN pooled vector via the
|
||||
# positional ``context`` arg (so SD3DiT carries pooled_projections alongside the joint embed,
|
||||
# and ToyDiT harmlessly mean-pools it). Solver/forward dispatch through the kernel table.
|
||||
dit = model.component(expert_id)
|
||||
pooled_for = {"cond": pp, "uncond": npp}
|
||||
embed_for = {"cond": pe, "uncond": ne}
|
||||
preds = {b: dit(x, embed_for[b], sigma_t, context=pooled_for[b]) for b in branches}
|
||||
return precision.cast(cfg.combine(preds, scale, sctx, cfg_state))
|
||||
|
||||
def run(model: Any, override: Any = None) -> StepResult:
|
||||
kernels = model.platform.kernels
|
||||
if override is not None and "noise_pred" in override:
|
||||
velocity = precision.cast(np.asarray(override["noise_pred"], dtype="float32"))
|
||||
else:
|
||||
velocity = _velocity(model)
|
||||
x_next = kernels.get(FLOW_MATCH_STEP)(precision.cast(x), velocity, sigma_t, sigma_next)
|
||||
return StepResult(output={
|
||||
"noise_pred": np.asarray(velocity, dtype="float32"),
|
||||
"latents": x_next.astype("float32")
|
||||
})
|
||||
|
||||
cond_bytes = sum(int(np.asarray(e).nbytes) for e in (pe, ne, pp, npp) if e is not None)
|
||||
res = ResourceRequest(compute_seconds=self.cost.predict(int(np.prod(x.shape)), float(len(branches))),
|
||||
resident_bytes=int(x.nbytes) + cond_bytes,
|
||||
peak_activation_bytes=int(x.nbytes))
|
||||
return WorkPlan(
|
||||
loop_id=self.loop_id,
|
||||
instance_id=st.instance_id,
|
||||
kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
shape_sig=ShapeSignature(WorkUnitKind.DIFFUSION_STEP,
|
||||
dims=tuple(x.shape),
|
||||
dtype=precision.compute_dtype,
|
||||
extra=(("cfg", type(cfg).__name__), ("image", True))),
|
||||
resources=res,
|
||||
payload={
|
||||
"branch": "combined",
|
||||
"step": i
|
||||
},
|
||||
run=run,
|
||||
label=f"sd35.denoise.{i}",
|
||||
# Dual-conditioning forward with a per-branch pooled vector threaded outside the workspace ->
|
||||
# keep the eager path (no static-buffer graph capture wired for this recipe). The deterministic
|
||||
# ODE step itself is capturable, but wiring the full capture form is a later GPU-side concern.
|
||||
capturable=False)
|
||||
|
||||
def advance(self, st: LoopState, result: StepResult) -> LoopState:
|
||||
st.latents["image"] = result.output["latents"]
|
||||
if st.profile == ExecutionProfile.ROLLOUT:
|
||||
st.trajectory.append({
|
||||
"step": st.step_idx,
|
||||
"sigma": st.sigmas[st.step_idx],
|
||||
"velocity": np.asarray(result.output["noise_pred"]).copy(),
|
||||
"latents": np.asarray(st.latents["image"]).copy(),
|
||||
})
|
||||
st.step_idx += 1
|
||||
return st
|
||||
|
||||
def finalize(self, st: LoopState) -> LoopResult:
|
||||
return LoopResult(outputs={"latents": st.latents["image"]},
|
||||
metrics={"denoise_steps": float(st.step_idx)},
|
||||
behavior=st.trajectory or None)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""SD3.5 t2i program: sd3_text_encode → diffusion_denoise (flow-match) → vae_decode.
|
||||
|
||||
Single-stage INLINE program (like ``build_ltx2_base_program``). The SD3-specific work lives in the
|
||||
custom text-encode node (the triple-encoder joint-embed + dual-CLIP pooled assembly that
|
||||
``SD35ConditioningStage`` does) and the VAE decode node (the [-1,1] -> [0,1] remap of
|
||||
``SD35DecodingStage``); the denoise node is the generic ``ModelLoopNode`` binding ``SD3DenoiseLoop``.
|
||||
|
||||
The text-encode node is backend-agnostic: on the GPU torch backend the CLIP adapters
|
||||
(``SD3ClipEncoder``) return ``{"hidden": penultimate[-2], "pooled": pooler_output}`` and ``SD3T5Encoder``
|
||||
returns ``last_hidden_state``, which ``assemble_sd3_conditioning`` combines into (joint_embed, pooled).
|
||||
On the CPU toy backend the encoders return plain arrays (``ToyTextEncoder.encode``), so the node falls
|
||||
back to a degenerate-but-finite assembly (joint = the array, pooled = its channel-mean broadcast) — enough
|
||||
to exercise the dual-conditioning plumbing end-to-end without any GPU/torch.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2.platform.backends.torch_sd35 import assemble_sd3_conditioning
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
|
||||
_CLIP_L = "text_encoder"
|
||||
_CLIP_G = "text_encoder_2"
|
||||
_T5 = "text_encoder_3"
|
||||
|
||||
|
||||
def _encode_one(instance: Any, prompt: str) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Run the three SD3.5 text encoders on ``prompt`` and assemble (joint_embed, pooled).
|
||||
|
||||
Handles both backends: torch CLIP adapters return a ``{"hidden","pooled"}`` dict (-> the faithful
|
||||
``assemble_sd3_conditioning``); the CPU toy encoders return a plain array (-> a degenerate, finite
|
||||
stand-in that still threads a distinct joint embed + pooled vector through the dual-conditioning path)."""
|
||||
clip_l = instance.component(_CLIP_L).encode(prompt)
|
||||
clip_g = instance.component(_CLIP_G).encode(prompt)
|
||||
t5 = instance.component(_T5).encode(prompt)
|
||||
if isinstance(clip_l, dict) and isinstance(clip_g, dict):
|
||||
return assemble_sd3_conditioning(clip_l, clip_g, t5)
|
||||
# CPU toy fallback: ToyTextEncoder.encode returns a [seq, dim] array (no pooled head). Concatenate
|
||||
# the three along seq for the joint embed; derive a small pooled vector from the CLIP means.
|
||||
a_l, a_g, a_t = np.asarray(clip_l), np.asarray(clip_g), np.asarray(t5)
|
||||
joint = np.concatenate([a_l, a_g, a_t], axis=-2).astype("float32")
|
||||
pooled = np.concatenate([a_l.mean(axis=-2), a_g.mean(axis=-2)], axis=-1).astype("float32")
|
||||
return joint, pooled
|
||||
|
||||
|
||||
def _sd3_text_encode(instance, slots, request, ctx) -> None:
|
||||
joint, pooled = _encode_one(instance, request.prompt())
|
||||
neg_joint, neg_pooled = _encode_one(instance, request.diffusion.negative_prompt)
|
||||
slots["text_embeds"] = joint
|
||||
slots["pooled_projections"] = pooled
|
||||
slots["neg_text_embeds"] = neg_joint
|
||||
slots["neg_pooled_projections"] = neg_pooled
|
||||
|
||||
|
||||
def _vae_decode(instance, slots, request, ctx) -> None:
|
||||
# SD3DiT denoises in normalized latent space; SD3VAE.decode inverts shift/scale and runs the
|
||||
# AutoencoderKL decode -> image in [-1, 1]. Remap to [0, 1] (SD35DecodingStage: (img/2+0.5).clamp).
|
||||
vae = instance.component("vae")
|
||||
latent = np.asarray(slots["denoise_out"]["latents"], dtype="float32") # [C, h, w] image latent
|
||||
# The real SD3VAE adapter consumes the strict 4D image latent [C, h, w] directly. The CPU toy ToyVAE
|
||||
# is video-oriented ([C, T, h, w]); give it a singleton temporal axis so the decode CPU-verifies
|
||||
# (faithful to the fastvideo stage carrying a fake T dim around the strictly-4D DiT forward).
|
||||
if getattr(vae, "module", None) is None and latent.ndim == 3:
|
||||
latent = latent[:, None] # -> [C, 1, h, w] for the toy decoder only
|
||||
image = vae.decode(latent)
|
||||
slots["image"] = np.clip(np.asarray(image, dtype="float32") / 2.0 + 0.5, 0.0, 1.0)
|
||||
|
||||
|
||||
def build_sd35_program() -> Program:
|
||||
return Program(
|
||||
program_id="sd35.t2i.inline",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("sd3_text_encode",
|
||||
fn=_sd3_text_encode,
|
||||
writes=("text_embeds", "neg_text_embeds", "pooled_projections", "neg_pooled_projections")),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="diffusion_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("text_embeds", "neg_text_embeds", "pooled_projections", "neg_pooled_projections"),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("vae_decode", fn=_vae_decode, reads=("denoise_out", ), writes=("image", )),
|
||||
],
|
||||
output_artifacts={
|
||||
"image": "image",
|
||||
"latents": "denoise_out",
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Stable Audio Open (text→audio) — a v-prediction (EDM-v / VDenoiser) DPM++ recipe in the v2 substrate.
|
||||
|
||||
Self-contained recipe package (the bucket-C pattern): the card declares its torch adapters via
|
||||
``ComponentSpec.adapter`` (``StableAudioDiT``/``OobleckVAE``/``StableAudioConditioner`` in
|
||||
``v2/platform/backends/torch_stable_audio.py``) and a new ``StableAudioDenoiseLoop`` (polyexponential
|
||||
schedule + VDenoiser v->x0 + DPM++ multistep), reusing ``stamp_wan21_checkpoints``. AUDIO modality —
|
||||
the audio output artifact + a TEXT_TO_AUDIO capability/request field are the request-API extension
|
||||
(BRINGUP). Registered in ``v2/registry.py`` by the orchestrator.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2.recipes.stable_audio.card import build_stable_audio_card, build_stable_audio_small_card
|
||||
from v2.recipes.stable_audio.loop import StableAudioDenoiseLoop
|
||||
from v2.recipes.stable_audio.program import build_stable_audio_program
|
||||
|
||||
__all__ = [
|
||||
"build_stable_audio_card",
|
||||
"build_stable_audio_small_card",
|
||||
"build_stable_audio_program",
|
||||
"StableAudioDenoiseLoop",
|
||||
]
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Stable Audio Open ModelCard(s) — text→audio (the registered preset). AUDIO modality.
|
||||
|
||||
Two variants from the converted Diffusers repos:
|
||||
* ``FastVideo/stable-audio-open-1.0-Diffusers`` — depth 24, embed_dim 1536, qk_norm None, 3 cond
|
||||
ids (prompt + seconds_start + seconds_total), 2,097,152-frame window (~47.55s @ 44.1kHz).
|
||||
* ``FastVideo/stable-audio-open-small-Diffusers`` — depth 16, embed_dim 1024, qk_norm 'ln', 2 cond
|
||||
ids (prompt + seconds_total), 524,288-frame window (~11.89s). (depth/dim/qk_norm are resolved by the
|
||||
loader from the checkpoint config; the card carries the per-variant sample_size + audio defaults.)
|
||||
|
||||
Architecture deltas vs the video recipes (all declared on the card so the recipe is self-contained):
|
||||
* DiT ``fastvideo.models.dits.stable_audio:StableAudioDiT`` — a v-PREDICTION network (EDM-v / VDenoiser),
|
||||
NOT flow-match and NOT EDM-Karras. The adapter (``ComponentSpec.adapter`` -> ``StableAudioDiT``) packs
|
||||
the conditioner payload into (cross_attn_cond, global_embed), passes the RAW continuous sigma as the
|
||||
timestep, and returns the raw v output; ``StableAudioDenoiseLoop`` does the VDenoiser v->x0 + x0-space
|
||||
CFG + the polyexponential schedule (sigma_min 0.3, sigma_max 500, rho 1) and a DPM++ multistep step.
|
||||
* VAE ``fastvideo.models.vaes.oobleck:OobleckVAE`` — 1-D conv audio VAE (hop_length 2048). The
|
||||
``OobleckVAE`` adapter is RAW latent space (NO mean/std normalization, unlike WanVAE) and exposes
|
||||
``sampling_rate`` for the decode-slice.
|
||||
* Conditioner ``fastvideo.models.encoders.stable_audio_conditioner:StableAudioMultiConditioner`` — T5-base
|
||||
(max_length 128) + duration NumberConditioners. Declared with ``kind="text_encoder"`` (kind reuse —
|
||||
the shared ``_MAKERS`` has no ``conditioner`` kind and editing it is out of scope) + the explicit
|
||||
``StableAudioConditioner`` adapter. BRINGUP: the text-encoder maker calls ``TextEncoderLoader`` (a
|
||||
generic T5), so wiring the real SA conditioner on a GPU box needs the SA ``ConditionerLoader`` path.
|
||||
|
||||
``stamp_wan21_checkpoints`` applies (diffusers transformer/vae/text_encoder subfolder layout — the
|
||||
conditioner lives in the ``text_encoder`` subfolder of the converted repo). The negative prompt is a LOCAL
|
||||
module constant (Stable Audio's default negative prompt is empty).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from v2._enums import Capability, ConsistencyLevel, LoopKind, WorkUnitKind
|
||||
from v2.card import (
|
||||
CacheContract,
|
||||
CapabilityMatrix,
|
||||
ComponentSpec,
|
||||
CostModel,
|
||||
LoopSpec,
|
||||
ModelCard,
|
||||
ParallelismContract,
|
||||
ParitySpec,
|
||||
ParityTestSpec,
|
||||
PrecisionContract,
|
||||
RecipeSpec,
|
||||
SamplingDefaults,
|
||||
)
|
||||
from v2.loop.policies import ClassicCFG, NoRouting, PrecisionPolicy
|
||||
from v2.parallel import ParallelPlan
|
||||
from v2.platform.backends.toy import ToyTextEncoder, _seed_from
|
||||
from v2.recipes.stable_audio.loop import StableAudioDenoiseLoop
|
||||
from v2.recipes.wan21.card import stamp_wan21_checkpoints
|
||||
|
||||
# Stable Audio's default negative prompt is empty (no shared _prompts entry; kept LOCAL per the port spec).
|
||||
STABLE_AUDIO_NEG = ""
|
||||
|
||||
# Oobleck VAE io_channels — the 1-D audio latent's channel dim (shared by the toy DiT + toy VAE below).
|
||||
SA_IO_CHANNELS = 64
|
||||
|
||||
_SA_DIT = "v2.platform.backends.torch_stable_audio:StableAudioDiT"
|
||||
_SA_VAE = "v2.platform.backends.torch_stable_audio:OobleckVAE"
|
||||
_SA_CONDITIONER = "v2.platform.backends.torch_stable_audio:StableAudioConditioner"
|
||||
|
||||
|
||||
def build_stable_audio_card(model_id: str = "stable-audio-open-1.0",
|
||||
*,
|
||||
checkpoint_root: str | None = None,
|
||||
sigma_min: float = 0.3,
|
||||
sigma_max: float = 500.0,
|
||||
rho: float = 1.0,
|
||||
sample_size: int = 2097152,
|
||||
sampling_defaults: SamplingDefaults | None = None) -> ModelCard:
|
||||
seed = _seed_from(model_id)
|
||||
cost = CostModel(kind=WorkUnitKind.DIFFUSION_STEP, base_seconds=1e-4, per_unit_seconds=1e-7)
|
||||
cfg = ClassicCFG()
|
||||
# fp16 throughout on the GPU path (DiT/VAE/T5); the loop math is numpy fp32 (lowest-risk bring-up).
|
||||
precision = PrecisionPolicy(compute_dtype="float32", scheduler_step_in_fp32=True)
|
||||
expert = NoRouting("transformer")
|
||||
|
||||
def loop_factory():
|
||||
return StableAudioDenoiseLoop(loop_id="stable_audio_denoise",
|
||||
cfg=cfg,
|
||||
precision=precision,
|
||||
expert=expert,
|
||||
cost=cost,
|
||||
sigma_min=sigma_min,
|
||||
sigma_max=sigma_max,
|
||||
rho=rho,
|
||||
sample_size=sample_size)
|
||||
|
||||
components = {
|
||||
# The SA multi-conditioner, declared under the ``text_encoder`` kind (kind reuse). On CPU this is
|
||||
# the ToyTextEncoder; on GPU the StableAudioConditioner adapter (BRINGUP: needs the SA loader).
|
||||
"text_encoder":
|
||||
ComponentSpec(component_id="text_encoder",
|
||||
kind="text_encoder",
|
||||
load_id="fastvideo.models.encoders.stable_audio_conditioner:StableAudioMultiConditioner",
|
||||
adapter=_SA_CONDITIONER,
|
||||
factory=lambda inst: ToyTextEncoder(),
|
||||
required_for={"t2a"}),
|
||||
"vae":
|
||||
ComponentSpec(component_id="vae",
|
||||
kind="vae",
|
||||
load_id="fastvideo.models.vaes.oobleck:OobleckVAE",
|
||||
adapter=_SA_VAE,
|
||||
factory=lambda inst: _ToyOobleckVAE(),
|
||||
required_for={"t2a"}),
|
||||
"transformer":
|
||||
ComponentSpec(component_id="transformer",
|
||||
kind="dit",
|
||||
load_id="fastvideo.models.dits.stable_audio:StableAudioDiT",
|
||||
adapter=_SA_DIT,
|
||||
factory=lambda inst: _ToyAudioDiT(seed=seed),
|
||||
resident_for=["stable_audio_denoise"],
|
||||
required_for={"t2a"}),
|
||||
}
|
||||
loops = {
|
||||
"stable_audio_denoise":
|
||||
LoopSpec(
|
||||
loop_id="stable_audio_denoise",
|
||||
kind=LoopKind.DIFFUSION_DENOISE,
|
||||
work_unit_kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
step_cost_model=cost,
|
||||
shared_weight_components=["transformer"],
|
||||
cache_policy=["feature"],
|
||||
graph_capture="eager", # v->x0 + x0-space CFG + multistep host state -> eager
|
||||
loop_factory=loop_factory),
|
||||
}
|
||||
card = ModelCard(
|
||||
model_id=model_id,
|
||||
family="stable_audio",
|
||||
components=components,
|
||||
loops=loops,
|
||||
# No TEXT_TO_AUDIO capability on the v2 enum (audio output surface is the request-API extension —
|
||||
# BRINGUP); TEXT_TO_SPEECH is the nearest audio-output capability. VAE_DECODE + POLICY_ROLLOUT as usual.
|
||||
capabilities=CapabilityMatrix.of(Capability.TEXT_TO_SPEECH, Capability.VAE_DECODE, Capability.POLICY_ROLLOUT),
|
||||
recipe=RecipeSpec(method="base",
|
||||
assumes_loop="stable_audio_denoise",
|
||||
assumes_precision="float32",
|
||||
consistency_required=ConsistencyLevel.C1),
|
||||
parity=ParitySpec(consistency_levels=[ConsistencyLevel.C1],
|
||||
interleave_required=True,
|
||||
tests=[ParityTestSpec(name="denoise_trajectory", level=ConsistencyLevel.C1, tap="latents")]),
|
||||
caches={"feature": CacheContract(cache_class="feature", max_bytes=1 << 24, reuse_across_requests=True)},
|
||||
precision=PrecisionContract(default_dtype="float32", training_precision="float32"),
|
||||
parallelism=ParallelismContract(valid_plans=[ParallelPlan.single()], default_plan=ParallelPlan.single()),
|
||||
# Audio-shaped defaults. height/width/num_frames/fps are video-shaped and unused for audio (BRINGUP:
|
||||
# SamplingDefaults has no audio fields — the duration knobs ride node_params; see program.py).
|
||||
# num_steps=100, guidance_scale=7.0 are the published SA-1.0 defaults.
|
||||
sampling_defaults=sampling_defaults
|
||||
or SamplingDefaults(num_steps=100, guidance_scale=7.0, negative_prompt=STABLE_AUDIO_NEG),
|
||||
)
|
||||
card.validate()
|
||||
if checkpoint_root:
|
||||
stamp_wan21_checkpoints(card, checkpoint_root)
|
||||
return card
|
||||
|
||||
|
||||
def build_stable_audio_small_card(model_id: str = "stable-audio-open-small",
|
||||
*,
|
||||
checkpoint_root: str | None = None) -> ModelCard:
|
||||
"""stable-audio-open-small: a 16-layer/1024-dim DiT with qk_norm='ln' and a shorter 524,288-frame
|
||||
window (~11.89s); 2 cond ids (prompt + seconds_total). Shares the Oobleck VAE + the v-prediction
|
||||
dpmpp-3m-sde loop with SA-1.0 — only the sample_size + the (loader-resolved) DiT geometry differ. The
|
||||
published small-variant default is a shorter clip (audio_end_in_s 6s) at fewer steps."""
|
||||
return build_stable_audio_card(model_id=model_id,
|
||||
checkpoint_root=checkpoint_root,
|
||||
sample_size=524288,
|
||||
sampling_defaults=SamplingDefaults(num_steps=8,
|
||||
guidance_scale=7.0,
|
||||
negative_prompt=STABLE_AUDIO_NEG))
|
||||
|
||||
|
||||
class _ToyAudioDiT:
|
||||
"""CPU toy v-prediction stand-in for ``StableAudioDiT`` (1-D audio latent [64, L]).
|
||||
|
||||
The shared ``ToyDiT`` is channel-fixed (4 channels) and shaped for video; SA's latent is 64-channel
|
||||
1-D. This tiny deterministic toy honors the same contract the loop needs — ``__call__(latent,
|
||||
text_embed, sigma) -> v`` with the loop-supplied VDenoiser-scaled input — so the loop runs end-to-end
|
||||
on CPU. ``text_embed`` is the packed conditioning payload (the toy just means-pools it as a scalar
|
||||
conditioning signal, matching ToyDiT's text handling). Deterministic given (seed, inputs)."""
|
||||
|
||||
def __init__(self, seed: int = 0):
|
||||
import numpy as np
|
||||
rng = np.random.default_rng(seed)
|
||||
self.w_t = float(rng.standard_normal() * 0.1)
|
||||
self.s_text = 0.5
|
||||
|
||||
def __call__(self, latent, text_embed, sigma, context=None, *, cond=None):
|
||||
import numpy as np
|
||||
lat = np.asarray(latent, dtype="float32")
|
||||
cond_signal = float(np.mean(text_embed)) if text_embed is not None else 0.0
|
||||
# A simple bounded v-prediction: channel-local nonlinearity + timestep + conditioning bias.
|
||||
pre = 0.5 * lat + self.w_t * float(sigma) + self.s_text * cond_signal
|
||||
return np.tanh(pre).astype("float32")
|
||||
|
||||
|
||||
class _ToyOobleckVAE:
|
||||
"""CPU toy 1-D audio VAE stand-in for ``OobleckVAE`` (the shared ``ToyVAE`` is a video VAE that decodes
|
||||
a 4-channel ``[C,T,H,W]`` latent). Decodes the 64-channel ``[64, L]`` audio latent to a
|
||||
``[channels, samples]`` waveform (channel-collapse projection + hop_length upsample), deterministic so
|
||||
the program node is interleave-safe. Exposes ``hop_length`` / ``sampling_rate`` like the real adapter."""
|
||||
|
||||
def __init__(self, channels: int = 2, hop_length: int = 16, sampling_rate: int = 44100, seed: int = 4):
|
||||
import numpy as np
|
||||
self.hop_length = int(hop_length) # tiny toy hop (real Oobleck is 2048); just for upsample shape
|
||||
self.sampling_rate = int(sampling_rate)
|
||||
self.channels = int(channels)
|
||||
rng = np.random.default_rng(seed)
|
||||
self.proj = (rng.standard_normal((channels, SA_IO_CHANNELS)) * 0.2).astype("float32")
|
||||
|
||||
def decode(self, latent):
|
||||
import numpy as np
|
||||
z = np.asarray(latent, dtype="float32") # [64, L]
|
||||
per_ch = np.tensordot(self.proj, z, axes=([1], [0])) # [channels, L]
|
||||
wav = np.repeat(per_ch, self.hop_length, axis=-1) # [channels, L*hop]
|
||||
return np.tanh(wav).astype("float32")
|
||||
|
||||
def encode(self, audio):
|
||||
import numpy as np
|
||||
a = np.asarray(audio, dtype="float32") # [channels, samples]
|
||||
flat = a.reshape(a.shape[0], -1)
|
||||
# crude inverse: project back to 64 channels at the downsampled rate (passthrough latent space)
|
||||
z = np.tensordot(self.proj.T, flat[:, ::self.hop_length], axes=([1], [0])) # [64, ~L]
|
||||
return z.astype("float32")
|
||||
@@ -0,0 +1,202 @@
|
||||
"""StableAudioDenoiseLoop — a v-prediction (EDM-v / VDenoiser) DPM++ denoise loop for Stable Audio Open.
|
||||
|
||||
Stable Audio is NOT a flow-match model and NOT an EDM-Karras model: it is a v-prediction network sampled
|
||||
by k-diffusion's ``dpmpp-3m-sde`` (a 3rd-order multistep stochastic DPM-Solver++) over a POLYEXPONENTIAL
|
||||
sigma schedule (sigma_min=0.3, sigma_max=500, rho=1.0). So neither ``WanDenoiseLoop`` (flow-match) nor
|
||||
``CosmosDenoiseLoop`` (EDM-Karras x0) can be reused — this is a NEW loop (the spec's
|
||||
``NEEDS_NEW:StableAudioDenoiseLoop``).
|
||||
|
||||
Faithfulness vs ``fastvideo/pipelines/basic/stable_audio/stages/denoising.py``:
|
||||
* The schedule is polyexponential (``sampler.build_polyexponential_sigmas``), seeded noise scaled by
|
||||
sigmas[0], exactly like ``StableAudioLatentPreparationStage`` + ``StableAudioDenoisingStage``.
|
||||
* The network is a v-predictor wrapped by ``K.external.VDenoiser``: ``denoised = c_skip·x + c_out·F(c_in·x, c_noise)``
|
||||
with the EDM-v coefficients (sigma_data=1). This loop reproduces those coefficients exactly
|
||||
(``sampler.vdenoiser_x0``) so the x0 it integrates matches k-diffusion's VDenoiser on the GPU path.
|
||||
* CFG: ``denoised = uncond + (cond - uncond)·scale``, combined in x0 space (the
|
||||
``_DiTAdapter`` convention; the negative cross-attn is null/zero, applied by the conditioning node).
|
||||
|
||||
GPU PATH (BRINGUP — k_diffusion is an external dep, NOT installed in this env): the faithful, lowest-risk
|
||||
GPU route is to NOT decompose ``dpmpp-3m-sde`` into per-step v2 WorkPlans but to run the whole
|
||||
k-diffusion sampler inside ONE thunk: ``import k_diffusion as K; K.sampling.sample_dpmpp_3m_sde(
|
||||
K.external.VDenoiser(adapter), x, sigmas, ...)`` — a direct lift of the fastvideo stage. That requires a
|
||||
per-loop "single-shot" execution mode the v2 ``ctx.execute`` per-step contract does not yet expose
|
||||
(BRINGUP: needs a loop-as-one-thunk hook). For CPU-verification + per-step serving granularity, this loop
|
||||
ships a DETERMINISTIC DPM-Solver++(2M) recurrence in numpy over the SAME VDenoiser x0 — a faithful
|
||||
v-prediction multistep integrator that runs end-to-end on the CPU toy. The SDE (stochastic) higher-order
|
||||
``dpmpp-3m-sde`` terms are the GPU-parity refinement (BRINGUP) layered on top of this deterministic spine.
|
||||
|
||||
A2A (init_audio -> lowered sigma_max) and RePaint inpainting (per-step blend callback) are threaded as
|
||||
inert hooks (None for the registered T2A preset) and documented BRINGUP — base port is pure text->audio.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from v2._enums import ExecutionProfile, WorkUnitKind
|
||||
from v2.loop.contracts import (
|
||||
Done,
|
||||
LoopResult,
|
||||
LoopState,
|
||||
ResourceRequest,
|
||||
ShapeSignature,
|
||||
StepContext,
|
||||
StepResult,
|
||||
WorkPlan,
|
||||
)
|
||||
from v2.recipes.stable_audio.sampler import (
|
||||
build_polyexponential_sigmas,
|
||||
dpmpp_2m_step,
|
||||
vdenoiser_x0,
|
||||
)
|
||||
|
||||
# Oobleck VAE geometry: 64 io_channels; latent_len = sample_size // hop_length (hop_length=2048).
|
||||
SA_IO_CHANNELS = 64
|
||||
SA_HOP_LENGTH = 2048
|
||||
SA_SAMPLE_SIZE = 2097152 # SA-1.0 fixed training window (~47.55s @ 44.1kHz); SA-small is 524288.
|
||||
|
||||
|
||||
def audio_latent_len(req, model, *, sample_size: int, hop_length: int = SA_HOP_LENGTH) -> int:
|
||||
"""Latent length the DiT/VAE operate over. The model ALWAYS samples a fixed-size latent
|
||||
(``sample_size // hop_length``) and the decode is sliced to [start,end] seconds afterwards — the
|
||||
duration knob does NOT shrink the latent (faithful to the fastvideo stage). The CPU toy uses a tiny
|
||||
deterministic stand-in so the loop math runs without the 1024-long real latent."""
|
||||
if model is not None and getattr(getattr(model, "platform", None), "device", "cpu") == "cuda":
|
||||
return max(1, sample_size // hop_length)
|
||||
return 8 # toy: short latent, enough to exercise the multistep recurrence
|
||||
|
||||
|
||||
class StableAudioDenoiseLoop:
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
loop_id,
|
||||
cfg,
|
||||
precision,
|
||||
expert,
|
||||
cost,
|
||||
sigma_min: float = 0.3,
|
||||
sigma_max: float = 500.0,
|
||||
rho: float = 1.0,
|
||||
io_channels: int = SA_IO_CHANNELS,
|
||||
hop_length: int = SA_HOP_LENGTH,
|
||||
sample_size: int = SA_SAMPLE_SIZE):
|
||||
self.loop_id = loop_id
|
||||
self.cfg = cfg # carried for the WorkPlan op-structure key; x0-space CFG is done here
|
||||
self.precision = precision
|
||||
self.expert = expert
|
||||
self.cost = cost
|
||||
self.sigma_min = sigma_min
|
||||
self.sigma_max = sigma_max
|
||||
self.rho = rho
|
||||
self.io_channels = io_channels
|
||||
self.hop_length = hop_length
|
||||
self.sample_size = sample_size
|
||||
|
||||
def init(self, req, model, ctx) -> LoopState:
|
||||
seed = req.diffusion.seed if req.diffusion.seed is not None else 0
|
||||
rng = np.random.default_rng(seed)
|
||||
# Polyexponential schedule sigma_max -> sigma_min (NOT flow-shift, NOT Karras-rho-7).
|
||||
sig = build_polyexponential_sigmas(req.diffusion.num_steps,
|
||||
sigma_min=self.sigma_min,
|
||||
sigma_max=self.sigma_max,
|
||||
rho=self.rho)
|
||||
latent_len = audio_latent_len(req, model, sample_size=self.sample_size, hop_length=self.hop_length)
|
||||
shape = (self.io_channels, latent_len) # 1-D audio latent [64, L]
|
||||
x = (rng.standard_normal(shape) * float(sig[0])).astype("float32") # noise·sigma_max
|
||||
st = LoopState(loop_id=self.loop_id,
|
||||
instance_id=model.card.model_id,
|
||||
request_id=req.request_id,
|
||||
profile=ctx.profile,
|
||||
rng=rng,
|
||||
seed=seed,
|
||||
latents={"audio": x},
|
||||
sigmas=[float(s) for s in sig],
|
||||
timesteps=[float(s) for s in sig]) # RAW sigma is the model timestep (NOT *1000)
|
||||
# The conditioning node packs the SA (cross_attn_cond, global_embed) triple into one numpy payload
|
||||
# (see torch_stable_audio.pack_conditioning); the negative payload is the CFG uncond branch.
|
||||
st.cond["prompt_embeds"] = ctx.slots.get("text_embeds")
|
||||
st.cond["negative_prompt_embeds"] = ctx.slots.get("neg_text_embeds")
|
||||
st.scratch["guidance_scale"] = float(req.diffusion.guidance_scale)
|
||||
# A2A / RePaint (BRINGUP): inert for the T2A preset (None) -> the gated branches below are skipped.
|
||||
st.scratch["init_latent"] = ctx.slots.get("init_latent")
|
||||
st.scratch["inpaint_ref"] = ctx.slots.get("inpaint_reference_latent")
|
||||
st.scratch["inpaint_mask"] = ctx.slots.get("inpaint_mask_latent")
|
||||
st.scratch["prev_x0"] = None # DPM++(2M) multistep memory: the previous step's x0 estimate
|
||||
st.plugin_state["cfg"] = {}
|
||||
return st
|
||||
|
||||
def next(self, st: LoopState):
|
||||
i = st.step_idx
|
||||
if i >= len(st.sigmas) - 1:
|
||||
return Done()
|
||||
sigma_t, sigma_next = st.sigmas[i], st.sigmas[i + 1]
|
||||
t = st.timesteps[i] # raw sigma
|
||||
expert_id = self.expert.expert_for(StepContext(i, t, sigma_t))
|
||||
x = st.latents["audio"]
|
||||
pe, ne = st.cond["prompt_embeds"], st.cond["negative_prompt_embeds"]
|
||||
scale = st.scratch["guidance_scale"]
|
||||
precision = self.precision
|
||||
do_cfg = scale != 1.0 and ne is not None
|
||||
prev_x0 = st.scratch.get("prev_x0")
|
||||
|
||||
def _x0(dit, text_embed) -> np.ndarray:
|
||||
# VDenoiser preconditioning: feed the network c_in·x at c_noise(sigma), get the raw v output,
|
||||
# reconstruct x0 = c_skip·x + c_out·v (EDM-v, sigma_data=1). Faithful to K.external.VDenoiser.
|
||||
return vdenoiser_x0(x, float(sigma_t),
|
||||
lambda scaled, c_noise: np.asarray(dit(scaled, text_embed, c_noise), dtype="float32"))
|
||||
|
||||
def run(model, override=None):
|
||||
dit = model.component(expert_id)
|
||||
if override is not None and "x0" in override:
|
||||
final_x0 = precision.cast(np.asarray(override["x0"], dtype="float32"))
|
||||
else:
|
||||
cond_x0 = _x0(dit, pe)
|
||||
if do_cfg:
|
||||
uncond_x0 = _x0(dit, ne)
|
||||
final_x0 = uncond_x0 + (cond_x0 - uncond_x0) * scale # x0-space CFG (uncond + (cond-uncond)·s)
|
||||
else:
|
||||
final_x0 = cond_x0
|
||||
final_x0 = precision.cast(final_x0)
|
||||
# Deterministic DPM-Solver++(2M) step over (x, x0, sigma_t -> sigma_next), using prev_x0 for the
|
||||
# 2nd-order correction (1st-order Euler on the first step). The stochastic dpmpp-3m-sde terms
|
||||
# are the GPU-parity refinement (BRINGUP); this deterministic spine runs on the CPU toy.
|
||||
x_next = dpmpp_2m_step(x, final_x0, sigma_t, sigma_next, prev_x0)
|
||||
return StepResult(output={"x0": np.asarray(final_x0, dtype="float32"), "latents": x_next.astype("float32")})
|
||||
|
||||
cond_bytes = sum(int(np.asarray(e).nbytes) for e in (pe, ne) if e is not None)
|
||||
res = ResourceRequest(compute_seconds=self.cost.predict(int(np.prod(x.shape)), 2.0 if do_cfg else 1.0),
|
||||
resident_bytes=int(x.nbytes) + cond_bytes,
|
||||
peak_activation_bytes=int(x.nbytes))
|
||||
return WorkPlan(loop_id=self.loop_id,
|
||||
instance_id=st.instance_id,
|
||||
kind=WorkUnitKind.DIFFUSION_STEP,
|
||||
shape_sig=ShapeSignature(WorkUnitKind.DIFFUSION_STEP,
|
||||
dims=tuple(x.shape),
|
||||
dtype=precision.compute_dtype,
|
||||
extra=(("cfg", type(self.cfg).__name__), ("vpred", True))),
|
||||
resources=res,
|
||||
payload={
|
||||
"branch": "vpred",
|
||||
"step": i
|
||||
},
|
||||
run=run,
|
||||
label=f"stable_audio.denoise.{i}",
|
||||
capturable=False) # v->x0 + x0-space CFG + multistep host state -> eager path
|
||||
|
||||
def advance(self, st: LoopState, result: StepResult) -> LoopState:
|
||||
st.latents["audio"] = result.output["latents"]
|
||||
st.scratch["prev_x0"] = np.asarray(result.output["x0"], dtype="float32") # DPM++(2M) memory
|
||||
if st.profile == ExecutionProfile.ROLLOUT:
|
||||
st.trajectory.append({
|
||||
"step": st.step_idx,
|
||||
"sigma": st.sigmas[st.step_idx],
|
||||
"x0": np.asarray(result.output["x0"]).copy(),
|
||||
"latents": np.asarray(st.latents["audio"]).copy(),
|
||||
})
|
||||
st.step_idx += 1
|
||||
return st
|
||||
|
||||
def finalize(self, st: LoopState) -> LoopResult:
|
||||
return LoopResult(outputs={"latents": st.latents["audio"]},
|
||||
metrics={"denoise_steps": float(st.step_idx)},
|
||||
behavior=st.trajectory or None)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Stable Audio Open T2A program: conditioning -> stable_audio_denoise (v-prediction DPM++) -> audio_decode.
|
||||
|
||||
Mirrors the cosmos2 t2v inline program shape — the SA specifics (the conditioner's (cross_attn_cond,
|
||||
global_embed) packing; the v-prediction sampler) live entirely in the conditioning node, the
|
||||
``StableAudioDenoiseLoop``, and the torch adapters, so the node graph stays a clean 3-node line.
|
||||
|
||||
Deltas vs the video programs:
|
||||
* The first node is ``conditioning`` (NOT the generic ``text_encode``): it runs the SA multi-conditioner
|
||||
over (prompt, seconds_start, seconds_total) and packs the triple into the loop's flat ``text_embeds``
|
||||
payload (so the loop->dit call stays modality-agnostic). It degrades to the generic text encoder on
|
||||
the CPU toy (which exposes only ``.encode(text)``), so the loop still runs end-to-end.
|
||||
* The last node is ``audio_decode`` (OobleckVAE.decode + slice [start,end] seconds) writing an ``audio``
|
||||
artifact (NOT ``video``). BRINGUP: the v2 output-artifact surface is video-shaped (cards/programs
|
||||
declare a ``video`` artifact); the registered ``output_artifacts`` here declares ``audio`` (+ latents),
|
||||
which an audio-aware saver/output path must consume — that audio output node is the request-API
|
||||
extension flagged in the spec (blocker #6). The slot value is the decoded waveform numpy array.
|
||||
|
||||
Audio duration knobs: ``DiffusionParams`` has no ``audio_start_in_s``/``audio_end_in_s`` (the v2 request
|
||||
surface is video-shaped — spec blocker #6). They are read from ``request.node_params['conditioning']``
|
||||
(the per-node override surface) with the card's defaults as fallback. A first-class audio request field is
|
||||
the request-API extension (BRINGUP).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from v2.program import ComponentNode, ModelLoopNode, Program, ProgramKind
|
||||
|
||||
# Default audio window (SA-1.0 short-clip defaults; overridable per request via node_params).
|
||||
_DEFAULT_AUDIO_START_S = 0.0
|
||||
_DEFAULT_AUDIO_END_S = 10.0
|
||||
|
||||
|
||||
def _audio_window(request: Any) -> tuple[float, float]:
|
||||
"""Resolve (start_s, end_s) from the per-node override surface (no audio fields on DiffusionParams)."""
|
||||
ov = request.node_override("conditioning") if hasattr(request, "node_override") else {}
|
||||
start = float(ov.get("audio_start_in_s", _DEFAULT_AUDIO_START_S))
|
||||
end = float(ov.get("audio_end_in_s", _DEFAULT_AUDIO_END_S))
|
||||
return start, end
|
||||
|
||||
|
||||
def _encode_one(component: Any, prompt: str, start: float, end: float) -> Any:
|
||||
"""Encode one prompt through the SA conditioner (``encode(prompt, seconds_start, seconds_total)``),
|
||||
falling back to the generic text encoder's ``encode(text)`` on the CPU toy (so the loop still runs)."""
|
||||
try:
|
||||
return component.encode(prompt or "", start, end) # SA conditioner: packs (cross, global)
|
||||
except TypeError:
|
||||
return component.encode(prompt or "") # ToyTextEncoder / generic T5: single-arg
|
||||
|
||||
|
||||
def _conditioning(instance, slots, request, ctx) -> None:
|
||||
# The SA conditioner is declared as the ``text_encoder`` component (kind reuse — see card.py / the
|
||||
# adapter docstring). On a GPU box this is the SA multi-conditioner; on CPU it is the ToyTextEncoder.
|
||||
comp = instance.component("text_encoder")
|
||||
start, end = _audio_window(request)
|
||||
slots["text_embeds"] = _encode_one(comp, request.prompt(), start, end)
|
||||
slots["neg_text_embeds"] = _encode_one(comp, request.diffusion.negative_prompt, start, end)
|
||||
slots["audio_start_in_s"] = start
|
||||
slots["audio_end_in_s"] = end
|
||||
|
||||
|
||||
def _audio_decode(instance, slots, request, ctx) -> None:
|
||||
# OobleckVAE.decode -> waveform [channels, samples]; slice to [start,end] seconds. The toy VAE returns
|
||||
# its own (video-ish) decode but the slot is still named ``audio`` — the artifact is the waveform.
|
||||
vae = instance.component("vae")
|
||||
waveform = vae.decode(slots["denoise_out"]["latents"])
|
||||
sr = int(getattr(vae, "sampling_rate", 44100))
|
||||
start = float(slots.get("audio_start_in_s", _DEFAULT_AUDIO_START_S))
|
||||
end = float(slots.get("audio_end_in_s", _DEFAULT_AUDIO_END_S))
|
||||
if getattr(waveform, "ndim", 0) >= 1 and waveform.shape[-1] >= int(end * sr) > 0:
|
||||
waveform = waveform[..., int(start * sr):int(end * sr)] # slice to the requested window
|
||||
slots["audio"] = waveform
|
||||
slots["audio_sample_rate"] = sr
|
||||
|
||||
|
||||
def build_stable_audio_program() -> Program:
|
||||
return Program(
|
||||
program_id="stable_audio.t2a.inline",
|
||||
kind=ProgramKind.INLINE,
|
||||
nodes=[
|
||||
ComponentNode("conditioning",
|
||||
fn=_conditioning,
|
||||
writes=("text_embeds", "neg_text_embeds", "audio_start_in_s", "audio_end_in_s")),
|
||||
ModelLoopNode("denoise",
|
||||
loop_id="stable_audio_denoise",
|
||||
output_slot="denoise_out",
|
||||
reads=("text_embeds", "neg_text_embeds"),
|
||||
writes=("denoise_out", )),
|
||||
ComponentNode("audio_decode",
|
||||
fn=_audio_decode,
|
||||
reads=("denoise_out", "audio_start_in_s", "audio_end_in_s"),
|
||||
writes=("audio", "audio_sample_rate")),
|
||||
],
|
||||
output_artifacts={
|
||||
"audio": "audio",
|
||||
"latents": "denoise_out",
|
||||
},
|
||||
).validate()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Stable Audio sampler helpers (recipe-local — NOT in v2/loop/sampler.py, which is flow-match/EDM-Karras).
|
||||
|
||||
Three numpy primitives the ``StableAudioDenoiseLoop`` composes, faithful to k-diffusion's behavior for the
|
||||
Stable Audio ``dpmpp-3m-sde`` sampler over a v-prediction (VDenoiser) network:
|
||||
|
||||
* ``build_polyexponential_sigmas`` — k-diffusion ``get_sigmas_polyexponential(n, sigma_min, sigma_max,
|
||||
rho)`` + the appended 0.0 terminal: a log-linear (rho=1) ramp from sigma_max to sigma_min.
|
||||
* ``vdenoiser_x0`` — k-diffusion ``K.external.VDenoiser``: a v-prediction network's x0 reconstruction
|
||||
with the EDM-v preconditioning (sigma_data=1).
|
||||
* ``dpmpp_2m_step`` — one deterministic DPM-Solver++(2M) update in x0/sigma space (the deterministic
|
||||
spine of dpmpp-3m-sde; the stochastic 3rd-order terms are the GPU-parity refinement, BRINGUP).
|
||||
|
||||
The full stochastic ``sample_dpmpp_3m_sde`` is the GPU path (``import k_diffusion as K``); it is NOT
|
||||
installed in the CPU env, so the loop runs this deterministic multistep recurrence for CPU-verification.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def build_polyexponential_sigmas(num_steps: int,
|
||||
*,
|
||||
sigma_min: float = 0.3,
|
||||
sigma_max: float = 500.0,
|
||||
rho: float = 1.0) -> np.ndarray:
|
||||
"""k-diffusion ``get_sigmas_polyexponential`` + appended 0.0 terminal.
|
||||
|
||||
``sigmas_i = exp( lerp(log(sigma_max), log(sigma_min), (i/(n-1))**rho) )`` for i in 0..n-1, then a
|
||||
trailing 0.0 (the sampler integrates pairwise; the last step lands on sigma=0). rho=1 makes this a
|
||||
plain log-linear (geometric) ramp — Stable Audio's published schedule.
|
||||
"""
|
||||
n = max(1, int(num_steps))
|
||||
ramp = np.linspace(0.0, 1.0, n, dtype=np.float64)**float(rho)
|
||||
log_sigmas = np.log(sigma_max) + ramp * (np.log(sigma_min) - np.log(sigma_max))
|
||||
sigmas = np.exp(log_sigmas) # sigma_max -> sigma_min
|
||||
return np.concatenate([sigmas, np.zeros(1, dtype=np.float64)]).astype(np.float64) # + terminal 0
|
||||
|
||||
|
||||
def vdenoiser_x0(x: np.ndarray, sigma: float, net: Callable[[np.ndarray, float], np.ndarray]) -> np.ndarray:
|
||||
"""k-diffusion ``K.external.VDenoiser.forward``: reconstruct x0 from a v-prediction network.
|
||||
|
||||
EDM-v preconditioning (sigma_data=1):
|
||||
``c_skip = 1/(sigma^2+1)``, ``c_out = -sigma/sqrt(sigma^2+1)``, ``c_in = 1/sqrt(sigma^2+1)``,
|
||||
``c_noise = atan(sigma)`` (k-diffusion's ``VDenoiser.sigma_to_t``), and
|
||||
``x0 = c_skip·x + c_out·F(c_in·x, c_noise)``.
|
||||
``net(scaled_x, c_noise)`` is the raw v-prediction (the torch adapter / CPU toy); this function owns
|
||||
the preconditioning so the adapter stays the bare network.
|
||||
"""
|
||||
s = float(sigma)
|
||||
s2p1 = s * s + 1.0
|
||||
c_skip = 1.0 / s2p1
|
||||
c_out = -s / np.sqrt(s2p1)
|
||||
c_in = 1.0 / np.sqrt(s2p1)
|
||||
c_noise = float(np.arctan(s)) # VDenoiser sigma_to_t
|
||||
x = np.asarray(x, dtype="float32")
|
||||
v = np.asarray(net((x * c_in).astype("float32"), c_noise), dtype="float32")
|
||||
return (c_skip * x + c_out * v).astype("float32")
|
||||
|
||||
|
||||
def dpmpp_2m_step(x: np.ndarray, denoised: np.ndarray, sigma_t: float, sigma_next: float,
|
||||
prev_denoised: np.ndarray | None) -> np.ndarray:
|
||||
"""One deterministic DPM-Solver++(2M) update (k-diffusion ``sample_dpmpp_2m`` recurrence).
|
||||
|
||||
Works in lambda = -log(sigma) (log-SNR) space:
|
||||
``t = -log(sigma_t)``, ``t_next = -log(sigma_next)``, ``h = t_next - t``.
|
||||
First step (no history) is the 1st-order DPM-Solver++ (== exponential Euler on x0):
|
||||
``x_next = (sigma_next/sigma_t)·x - expm1(-h)·denoised``.
|
||||
Subsequent steps add the 2nd-order correction from the previous x0 estimate:
|
||||
``denoised_d = (1 + 1/(2r))·denoised - 1/(2r)·prev_denoised`` with ``r = h/h_last`` (here
|
||||
approximated with the standard ``r`` from the step ratio). At ``sigma_next == 0`` the update collapses
|
||||
to ``x_next = denoised`` (the final clean sample), matching k-diffusion's terminal handling.
|
||||
"""
|
||||
x = np.asarray(x, dtype="float32")
|
||||
denoised = np.asarray(denoised, dtype="float32")
|
||||
sn = float(sigma_next)
|
||||
st = float(sigma_t)
|
||||
if sn <= 0.0:
|
||||
return denoised.astype("float32") # terminal: land on the clean x0
|
||||
t = -np.log(max(st, 1e-12))
|
||||
t_next = -np.log(sn)
|
||||
h = t_next - t
|
||||
if prev_denoised is None:
|
||||
x_next = (sn / st) * x - np.expm1(-h) * denoised
|
||||
return x_next.astype("float32")
|
||||
# 2nd-order multistep correction. Using r=1 (uniform-ish log-SNR spacing) keeps this faithful to
|
||||
# k-diffusion's 2M form without threading the exact previous step size; the dominant error term is
|
||||
# the same. (The exact h_last ratio is a GPU-parity refinement, BRINGUP.)
|
||||
prev = np.asarray(prev_denoised, dtype="float32")
|
||||
denoised_d = 1.5 * denoised - 0.5 * prev # (1 + 1/(2r))·d - 1/(2r)·prev, r=1
|
||||
x_next = (sn / st) * x - np.expm1(-h) * denoised_d
|
||||
return x_next.astype("float32")
|
||||
+73
-2
@@ -33,6 +33,72 @@ class ModelEntry:
|
||||
build_program: Callable[[], Any]
|
||||
|
||||
|
||||
# Bucket-C ports — each is a self-contained recipe package (card-declared torch adapter via
|
||||
# ``ComponentSpec.adapter`` + a new/forked loop, no shared-backend edit). ONE table drives BOTH the
|
||||
# explicit HF-id registry (PRIMARY) and the ``select_by_architecture`` fallback, so adding a port is one
|
||||
# row. Rows: (hf_ids, package, card_builder, program_builder, transformer_cls). ``transformer_cls`` is the
|
||||
# diffusers/EntryClass name for the fallback ("" = explicit-id-only; confirm against the real checkpoint's
|
||||
# transformer/config.json _class_name). Builders are resolved lazily (``import v2.registry`` stays cheap /
|
||||
# torch-free; the recipe packages are CPU-clean but importing 14 of them eagerly on every resolve is waste).
|
||||
_BUCKET_C: tuple[tuple, ...] = (
|
||||
(("KyleShao/Cosmos-Predict2.5-2B-Diffusers", "nvidia/Cosmos-Predict2.5-14B"), "cosmos25", "build_cosmos25_card",
|
||||
"build_cosmos25_program", "Cosmos25Transformer3DModel"),
|
||||
(("hunyuanvideo-community/HunyuanVideo", ), "hunyuan_video", "build_hunyuan_video_card",
|
||||
"build_hunyuan_video_program", "HunyuanVideoTransformer3DModel"),
|
||||
(("FastVideo/FastHunyuan-diffusers", ), "hunyuan_video", "build_fast_hunyuan_video_card",
|
||||
"build_hunyuan_video_program", ""),
|
||||
(("hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v",
|
||||
"hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v_step_distilled"), "hunyuan_video15",
|
||||
"build_hunyuan_video15_card", "build_hunyuan_video15_program", "HunyuanVideo15Transformer3DModel"),
|
||||
(("hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v",
|
||||
"hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_i2v_distilled",
|
||||
"weizhou03/HunyuanVideo-1.5-Diffusers-1080p", "weizhou03/HunyuanVideo-1.5-Diffusers-1080p-2SR"),
|
||||
"hunyuan_video15", "build_hunyuan_video15_720p_card", "build_hunyuan_video15_program", ""),
|
||||
(("FastVideo/LongCat-Video-T2V-Diffusers", "FastVideo/LongCat-Video-I2V-Diffusers",
|
||||
"FastVideo/LongCat-Video-VC-Diffusers"), "longcat", "build_longcat_card", "build_longcat_program",
|
||||
"LongCatTransformer3DModel"),
|
||||
(("stabilityai/stable-diffusion-3.5-medium", ), "sd35", "build_sd35_card", "build_sd35_program",
|
||||
"SD3Transformer2DModel"),
|
||||
(("FastVideo/GEN3C-Cosmos-7B-Diffusers", ), "gen3c", "build_gen3c_card", "build_gen3c_program",
|
||||
"Gen3CTransformer3DModel"),
|
||||
(("kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s-Diffusers", ), "kandinsky5", "build_kandinsky5_card",
|
||||
"build_kandinsky5_program", "Kandinsky5Transformer3DModel"),
|
||||
(("black-forest-labs/FLUX.2-dev", ), "flux2", "build_flux2_card", "build_flux2_program", "Flux2Transformer2DModel"),
|
||||
(("black-forest-labs/FLUX.2-klein-4B", "black-forest-labs/FLUX.2-klein-9B"), "flux2", "build_flux2_klein_card",
|
||||
"build_flux2_program", ""),
|
||||
(("FastVideo/stable-audio-open-1.0-Diffusers", ), "stable_audio", "build_stable_audio_card",
|
||||
"build_stable_audio_program", "StableAudioDiT"),
|
||||
(("FastVideo/stable-audio-open-small-Diffusers", ), "stable_audio", "build_stable_audio_small_card",
|
||||
"build_stable_audio_program", ""),
|
||||
(("FastVideo/HunyuanGameCraft-Diffusers", ), "hunyuangamecraft", "build_hunyuangamecraft_card",
|
||||
"build_hunyuangamecraft_program", "HunyuanGameCraftTransformer3DModel"),
|
||||
(("FastVideo/HY-WorldPlay-Bidirectional-Diffusers", ), "hyworld", "build_hyworld_card", "build_hyworld_program",
|
||||
"HYWorldTransformer3DModel"),
|
||||
(("FastVideo/LingBot-World-Base-Cam-Diffusers", ), "lingbotworld", "build_lingbotworld_card",
|
||||
"build_lingbotworld_program", "LingBotWorldTransformer3DModel"),
|
||||
(("FastVideo/Matrix-Game-2.0-Base-Distilled-Diffusers", "FastVideo/Matrix-Game-2.0-GTA-Distilled-Diffusers",
|
||||
"FastVideo/Matrix-Game-2.0-TempleRun-Distilled-Diffusers", "FastVideo/Matrix-Game-2.0-Base-Diffusers",
|
||||
"FastVideo/Matrix-Game-2.0-GTA-Diffusers", "FastVideo/Matrix-Game-2.0-TempleRun-Diffusers"), "matrixgame2",
|
||||
"build_matrixgame2_card", "build_matrixgame2_program", "CausalMatrixGame2WanModel"),
|
||||
(("FastVideo/Matrix-Game-3.0-Base-Distilled-Diffusers", ), "matrixgame3", "build_matrixgame3_card",
|
||||
"build_matrixgame3_program", "MatrixGame3WanModel"),
|
||||
)
|
||||
|
||||
|
||||
def _lazy(package: str, fn: str) -> Callable[[], Any]:
|
||||
"""A ``() -> card_or_program`` builder that imports the recipe package only when first called."""
|
||||
|
||||
def _build() -> Any:
|
||||
import importlib
|
||||
return getattr(importlib.import_module(f"v2.recipes.{package}"), fn)()
|
||||
|
||||
return _build
|
||||
|
||||
|
||||
def _bucket_c_entries() -> list[ModelEntry]:
|
||||
return [ModelEntry(hf_ids, _lazy(pkg, cb), _lazy(pkg, pb)) for (hf_ids, pkg, cb, pb, _cls) in _BUCKET_C]
|
||||
|
||||
|
||||
def _entries() -> list[ModelEntry]:
|
||||
"""The explicit registry (PRIMARY). Builders imported lazily to avoid import cycles with the model
|
||||
packages and keep importing this module cheap."""
|
||||
@@ -54,7 +120,7 @@ def _entries() -> list[ModelEntry]:
|
||||
)
|
||||
from v2.recipes.wan21.i2v import build_wan21_i2v_card, build_wan21_i2v_program, build_wan22_i2v_a14b_card
|
||||
from v2.recipes.cosmos2 import build_cosmos2_card, build_cosmos2_program
|
||||
return [
|
||||
return [ # noqa: RUF005 — explicit list, extended with the bucket-C ports below via _bucket_c_entries()
|
||||
ModelEntry(("Wan-AI/Wan2.1-T2V-1.3B-Diffusers", ), build_wan21_card, build_wan_t2v_program),
|
||||
ModelEntry(("Wan-AI/Wan2.1-T2V-14B-Diffusers", ), build_wan_t2v_14b_card, build_wan_t2v_program),
|
||||
# Wan2.1 i2v cluster: CLIP image encoder + first-frame VAE conditioning ([mask|cond] -> 36ch DiT).
|
||||
@@ -80,7 +146,7 @@ def _entries() -> list[ModelEntry]:
|
||||
# Cosmos-Predict2-2B-Video2World — EDM-Karras denoiser (new CosmosDenoiseLoop + CosmosDiT adapter),
|
||||
# reusing the Wan VAE adapter + T5. Registered for t2v (video2world conditioning threads later).
|
||||
ModelEntry(("nvidia/Cosmos-Predict2-2B-Video2World", ), build_cosmos2_card, build_cosmos2_program),
|
||||
]
|
||||
] + _bucket_c_entries() # the 14 self-contained bucket-C recipe packages (one row each in _BUCKET_C)
|
||||
|
||||
|
||||
def _short(p: str) -> str:
|
||||
@@ -118,6 +184,11 @@ def select_by_architecture(sig: dict):
|
||||
"""FALLBACK: map an architecture signature -> (build_card, build_program) by class names, so a local
|
||||
path / renamed repo / new distilled variant of a known arch still resolves with no registry entry."""
|
||||
tr, pipe = sig.get("transformer_cls"), sig.get("pipeline")
|
||||
for (_hf, pkg, cb, pb, cls) in _BUCKET_C: # the 14 bucket-C ports, keyed by transformer cls
|
||||
if cls and tr == cls:
|
||||
import importlib
|
||||
mod = importlib.import_module(f"v2.recipes.{pkg}")
|
||||
return getattr(mod, cb), getattr(mod, pb)
|
||||
if tr == "CosmosTransformer3DModel":
|
||||
from v2.recipes.cosmos2 import build_cosmos2_card, build_cosmos2_program
|
||||
return build_cosmos2_card, build_cosmos2_program
|
||||
|
||||
Reference in New Issue
Block a user