Compare commits

..
Author SHA1 Message Date
William Lin d03ffcb5f7 [misc] update wechat group link 2026-02-13 01:18:55 -08:00
56 changed files with 116 additions and 6644 deletions
@@ -67,9 +67,6 @@ jobs:
- torch-version: '2.9.1'
cuda-version: '12.8.0'
torch-cuda-short: 'cu128'
# - torch-version: '2.10.0'
# cuda-version: '12.8.0'
# torch-cuda-short: 'cu128'
steps:
- name: Free up disk space
@@ -223,6 +220,3 @@ jobs:
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: fastvideo-kernel/dist/
# PyPI does not allow replacing an existing file with the same name.
# This makes re-runs idempotent by skipping files already uploaded.
skip-existing: true
-1
View File
@@ -18,7 +18,6 @@ venv/
.venv/
runs/
samples/
Miniconda3-latest-Linux-x86_64.sh
*validation/
data/
outputs/
-119
View File
@@ -1,119 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Basic inference script for HunyuanGameCraft video generation.
HunyuanGameCraft generates game-like videos with camera/action control.
It takes an optional image input and generates video with camera motion
based on simple action commands (forward, left, right, backward, rotations).
Available actions:
- forward (w): Move camera forward
- backward (s): Move camera backward
- left (a): Move camera left (strafe)
- right (d): Move camera right (strafe)
- left_rot: Rotate camera left (pan)
- right_rot: Rotate camera right (pan)
- up_rot: Rotate camera up (tilt)
- down_rot: Rotate camera down (tilt)
T2V vs I2V:
- Default: I2V (uses a default reference image). Set GAMECRAFT_I2V_IMAGE to a
URL or path to use a different image.
- T2V only (no reference image): run with GAMECRAFT_I2V_IMAGE= (empty).
"""
import os
import torch
from fastvideo import VideoGenerator
from fastvideo.models.camera import create_camera_trajectory
# Model configuration (use GAMECRAFT_MODEL_PATH for local weights)
MODEL_PATH = os.environ.get("GAMECRAFT_MODEL_PATH", "FastVideo/HunyuanGameCraft-Diffusers")
# Default prompts for demo
DEFAULT_PROMPTS = {
"village": "A charming medieval village with cobblestone streets, thatched-roof houses, and vibrant flower gardens under a bright blue sky.",
"temple": "A majestic ancient temple stands under a clear blue sky, its grandeur highlighted by towering Doric columns and intricate architectural details.",
"forest": "A lush green forest with tall trees, dappled sunlight filtering through the leaves, and a winding dirt path.",
"beach": "A tropical beach with crystal clear turquoise water, white sand, and palm trees swaying in the breeze.",
}
# I2V: default reference image (URL). Can override with a local path.
DEFAULT_I2V_IMAGE_URL = (
"https://huggingface.co/datasets/huggingface/documentation-images/"
"resolve/main/diffusers/astronaut.jpg"
)
DEFAULT_I2V_PROMPT = (
"An astronaut hatching from an egg, on the surface of the moon, "
"the darkness and depth of space realised in the background."
)
OUTPUT_PATH = "video_samples_gamecraft"
def main():
# Initialize generator
# FastVideo will automatically download weights from HuggingFace
generator = VideoGenerator.from_pretrained(
MODEL_PATH,
num_gpus=1,
use_fsdp_inference=True,
dit_cpu_offload=True,
vae_cpu_offload=True,
text_encoder_cpu_offload=True,
pin_cpu_memory=True,
)
# Video parameters
height = 704
width = 1280
num_frames = 33
action = "forward"
action_speed = 0.2
# Create camera trajectory (Plücker coordinates)
camera_states = create_camera_trajectory(
action=action,
height=height,
width=width,
num_frames=num_frames,
action_speed=action_speed,
dtype=torch.bfloat16,
)
print(f"Camera states shape: {camera_states.shape}")
# I2V vs T2V: unset GAMECRAFT_I2V_IMAGE -> I2V (default image). Set to "" -> T2V.
env_image = os.environ.get("GAMECRAFT_I2V_IMAGE")
if env_image is None:
image_path = DEFAULT_I2V_IMAGE_URL # default: I2V
elif env_image.strip() == "":
image_path = None # T2V
else:
image_path = env_image.strip() # I2V with given URL/path
is_i2v = image_path is not None
prompt = DEFAULT_I2V_PROMPT if is_i2v else DEFAULT_PROMPTS["temple"]
print(f"Mode: {'I2V' if is_i2v else 'T2V'}, prompt: {prompt[:60]}...")
gen_kw = dict(
prompt=prompt,
negative_prompt="",
camera_states=camera_states,
height=height,
width=width,
num_frames=num_frames,
num_inference_steps=50,
guidance_scale=6.0,
seed=42,
fps=24,
output_path=OUTPUT_PATH,
save_video=True,
)
if is_i2v:
gen_kw["image_path"] = image_path
generator.generate_video(**gen_kw)
if __name__ == "__main__":
main()
+2 -8
View File
@@ -20,10 +20,10 @@ def main() -> None:
# Uses FastVideo default sampling settings for LTX2 base.
generator = VideoGenerator.from_pretrained(
"Davids048/LTX2-Base-Diffusers",
num_gpus=8,
num_gpus=1,
)
output_path = "outputs_video/ltx2_basic/output_ltx2_base_t2v_1088_1920_1.4.mp4"
output_path = "outputs_video/ltx2_basic/output_ltx2_base_t2v_1088_1920_1.1.mp4"
generator.generate_video(
prompt=PROMPT,
output_path=output_path,
@@ -31,12 +31,6 @@ def main() -> None:
num_frames=121,
height=1088,
width=1920,
# LTX2 uses these parameters for multi-modal CFG instead of guidance_scale
# ltx2_cfg_scale_video=3.0,
# ltx2_cfg_scale_audio=7.0,
# ltx2_modality_scale_video=3.0,
# ltx2_modality_scale_audio=3.0,
# ltx2_rescale_scale=0.7,
)
generator.shutdown()
+1 -1
View File
@@ -9,7 +9,7 @@ build-backend = "scikit_build_core.build"
[project]
name = "fastvideo-kernel"
version = "0.2.6"
version = "0.2.5"
description = "Unified CUDA kernels for FastVideo"
readme = "README.md"
requires-python = ">=3.10"
@@ -287,8 +287,9 @@ def block_sparse_attn(
block_sparse_fwd, block_sparse_bwd = _get_sm90_ops()
if (not _force_triton()) and _is_sm90() and (block_sparse_fwd is not None) and (block_sparse_bwd is not None):
return block_sparse_attn_sm90(q, k, v, block_map, variable_block_sizes)
# Triton path: supports q_seq_len != kv_seq_len as long as both are padded
# to a multiple of the block size (64 tokens).
# Triton path: generally assumes q/k/v share the same padded length
if q.shape[2] != k.shape[2] or q.shape[2] != v.shape[2]:
raise RuntimeError("Triton fallback requires q/k/v to have the same padded length.")
return block_sparse_attn_triton(q, k, v, block_map, variable_block_sizes)
@@ -141,6 +141,12 @@ def video_sparse_attn(
# Use autograd-enabled wrapper so backward works (and still uses SM90 kernel when available)
out_s = block_sparse_attn(q, k, v, mask, variable_block_sizes)[0]
else:
if q_seq_len != kv_seq_len:
raise RuntimeError(
"q/k have different lengths, but the compiled CUDA kernel (block_sparse_fwd) "
"is not available. The Triton fallback currently requires q and k/v to have "
"the same padded length."
)
# Triton-only forward (kept for environments without the wrapper deps)
out_s, _ = triton_block_sparse_attn_forward(q, k, v, idx, num, variable_block_sizes)
@@ -29,7 +29,7 @@ configs = [
# ──────────────────────────── SPARSE ADDITION BEGIN ───────────────────────────
@triton.autotune(configs, key=["N_CTX_Q", "HEAD_DIM"])
@triton.autotune(configs, key=["N_CTX", "HEAD_DIM"])
@triton.jit
def _attn_fwd_sparse(
Q,
@@ -60,8 +60,7 @@ def _attn_fwd_sparse(
stride_on,
Z,
H,
N_CTX_Q, #
N_CTX_KV, #
N_CTX, #
HEAD_DIM: tl.constexpr, #
BLOCK_M: tl.constexpr,
BLOCK_N: tl.constexpr,
@@ -76,29 +75,24 @@ def _attn_fwd_sparse(
off_hz = tl.program_id(1) # fused (batch, head)
b = off_hz // H
h = off_hz % H
q_tiles = N_CTX_Q // BLOCK_M
q_tiles = N_CTX // BLOCK_M
meta_base = ((b * H + h) * q_tiles + q_blk)
kv_blocks = tl.load(q2k_num + meta_base) # int32
kv_ptr = q2k_index + meta_base * max_kv_blks # ptr to list
# ----- base pointers -----
# Note: when q and kv have different sequence lengths, their per-(batch,head)
# strides differ, so we must compute separate base offsets.
q_off = (b.to(tl.int64) * stride_qz + h.to(tl.int64) * stride_qh)
k_off = (b.to(tl.int64) * stride_kz + h.to(tl.int64) * stride_kh)
v_off = (b.to(tl.int64) * stride_vz + h.to(tl.int64) * stride_vh)
o_off = (b.to(tl.int64) * stride_oz + h.to(tl.int64) * stride_oh)
qvk_off = (b.to(tl.int64) * stride_qz + h.to(tl.int64) * stride_qh)
Q_ptr = tl.make_block_ptr(base=Q + q_off,
shape=(N_CTX_Q, HEAD_DIM),
Q_ptr = tl.make_block_ptr(base=Q + qvk_off,
shape=(N_CTX, HEAD_DIM),
strides=(stride_qm, stride_qk),
offsets=(q_blk * BLOCK_M, 0),
block_shape=(BLOCK_M, HEAD_DIM),
order=(1, 0))
K_base = tl.make_block_ptr(base=K + k_off,
shape=(HEAD_DIM, N_CTX_KV),
K_base = tl.make_block_ptr(base=K + qvk_off,
shape=(HEAD_DIM, N_CTX),
strides=(stride_kk, stride_kn),
offsets=(0, 0),
block_shape=(HEAD_DIM, BLOCK_N),
@@ -106,15 +100,15 @@ def _attn_fwd_sparse(
v_order: tl.constexpr = (0, 1) if V.dtype.element_ty == tl.float8e5 else (1,
0)
V_base = tl.make_block_ptr(base=V + v_off,
shape=(N_CTX_KV, HEAD_DIM),
V_base = tl.make_block_ptr(base=V + qvk_off,
shape=(N_CTX, HEAD_DIM),
strides=(stride_vk, stride_vn),
offsets=(0, 0),
block_shape=(BLOCK_N, HEAD_DIM),
order=v_order)
O_ptr = tl.make_block_ptr(base=Out + o_off,
shape=(N_CTX_Q, HEAD_DIM),
O_ptr = tl.make_block_ptr(base=Out + qvk_off,
shape=(N_CTX, HEAD_DIM),
strides=(stride_om, stride_on),
offsets=(q_blk * BLOCK_M, 0),
block_shape=(BLOCK_M, HEAD_DIM),
@@ -156,7 +150,7 @@ def _attn_fwd_sparse(
# ----- epilogue -----
m_i += tl.math.log2(l_i)
acc = acc / l_i[:, None]
tl.store(M + off_hz * N_CTX_Q + offs_m, m_i)
tl.store(M + off_hz * N_CTX + offs_m, m_i)
tl.store(O_ptr, acc.to(Out.type.element_ty))
@@ -207,7 +201,7 @@ def _attn_bwd_dkdv(
stride_tok,
stride_d, #
H,
N_CTX_KV,
N_CTX,
BLOCK_M1: tl.constexpr, #
BLOCK_N1: tl.constexpr, #
HEAD_DIM: tl.constexpr, #
@@ -227,8 +221,8 @@ def _attn_bwd_dkdv(
off_hz = tl.program_id(2) # fused (batch, head)
b = off_hz // H
h = off_hz % H
kv_tiles = N_CTX_KV // BLOCK_N1
meta_base = ((b * H + h) * kv_tiles + kv_blk)
q_tiles = N_CTX // BLOCK_N1
meta_base = ((b * H + h) * q_tiles + kv_blk)
q_blocks = tl.load(k2q_num + meta_base) # int32
q_ptr = k2q_index + meta_base * max_q_blks # ptr to list
@@ -308,21 +302,16 @@ def _attn_bwd_dq(
kv_blocks = tl.load(q2k_num + meta_base) # int32
kv_ptr = q2k_index + meta_base * max_kv_blks # ptr to list
block_size = tl.load(variable_block_sizes + q_blk)
for blk_idx in range(kv_blocks * 2):
kv_idx = tl.load(kv_ptr + blk_idx // 2).to(tl.int32)
# variable_block_sizes is defined per KV block (tile). Mask must therefore
# use kv_idx (not q_blk). Also, because we split each 64-token block into
# two 32-token halves, the mask must account for the half-block offset.
block_size = tl.load(variable_block_sizes + kv_idx).to(tl.int32)
half = (blk_idx % 2).to(tl.int32)
block_sparse_offset = (kv_idx * 2 + half) * step_n * stride_tok
block_sparse_offset = (tl.load(kv_ptr + blk_idx // 2).to(tl.int32) * 2 +
blk_idx % 2) * step_n * stride_tok
kT = tl.load(kT_ptrs + block_sparse_offset)
vT = tl.load(vT_ptrs + block_sparse_offset)
qk = tl.dot(q, kT)
p = tl.math.exp2(qk - m)
offs_in_block = half * step_n + tl.arange(0, BLOCK_N2)
mask = offs_in_block < block_size
mask = tl.arange(0, BLOCK_N2) < block_size.to(tl.int32)
p = tl.where(mask[None, :], p, 0.0)
# Compute dP and dS.
dp = tl.dot(do, vT).to(tl.float32)
@@ -478,235 +467,19 @@ def _attn_bwd(
tl.store(dq_ptrs, dq)
@triton.jit
def _attn_bwd_dkdv_kernel(
Q,
K,
V,
sm_scale, #
DO, #
DK,
DV, #
M,
D,
k2q_index,
k2q_num,
max_q_blks,
variable_block_sizes,
# shared token/dim strides (assumed contiguous along token and dim)
stride_tok,
stride_d, #
# batch/head strides (may differ between Q and KV)
stride_qz,
stride_qh,
stride_kz,
stride_kh,
stride_vz,
stride_vh,
stride_doz,
stride_doh,
stride_dkz,
stride_dkh,
stride_dvz,
stride_dvh,
H,
N_CTX_Q,
N_CTX_KV,
BLOCK_M1: tl.constexpr, #
BLOCK_N1: tl.constexpr, #
HEAD_DIM: tl.constexpr):
"""
Backward kernel that computes dK and dV for each KV block (64 tokens).
Grid:
pid0: kv_blk in [0, N_CTX_KV/BLOCK_N1)
pid2: fused (batch, head) in [0, B*H)
"""
bhid = tl.program_id(2)
b = bhid // H
h = bhid % H
kv_blk = tl.program_id(0)
q_adj = (b.to(tl.int64) * stride_qz + h.to(tl.int64) * stride_qh)
kv_adj_k = (b.to(tl.int64) * stride_kz + h.to(tl.int64) * stride_kh)
kv_adj_v = (b.to(tl.int64) * stride_vz + h.to(tl.int64) * stride_vh)
do_adj = (b.to(tl.int64) * stride_doz + h.to(tl.int64) * stride_doh)
dk_adj = (b.to(tl.int64) * stride_dkz + h.to(tl.int64) * stride_dkh)
dv_adj = (b.to(tl.int64) * stride_dvz + h.to(tl.int64) * stride_dvh)
Q = Q + q_adj
K = K + kv_adj_k
V = V + kv_adj_v
DO = DO + do_adj
DK = DK + dk_adj
DV = DV + dv_adj
# M and D (delta) are always sized by Q length.
M = M + (bhid * N_CTX_Q).to(tl.int64)
D = D + (bhid * N_CTX_Q).to(tl.int64)
offs_k = tl.arange(0, HEAD_DIM)
start_n = kv_blk * BLOCK_N1
offs_n = start_n + tl.arange(0, BLOCK_N1)
# load K and V: they stay in SRAM throughout the inner loop.
k = tl.load(K + offs_n[:, None] * stride_tok + offs_k[None, :] * stride_d)
v = tl.load(V + offs_n[:, None] * stride_tok + offs_k[None, :] * stride_d)
dv_acc = tl.zeros([BLOCK_N1, HEAD_DIM], dtype=tl.float32)
dk_acc = tl.zeros([BLOCK_N1, HEAD_DIM], dtype=tl.float32)
num_steps = N_CTX_Q // BLOCK_M1
dk_acc, dv_acc = _attn_bwd_dkdv(
dk_acc,
dv_acc,
Q,
k,
v,
sm_scale,
DO,
M,
D,
k2q_index,
k2q_num,
max_q_blks,
variable_block_sizes,
stride_tok,
stride_d,
H,
N_CTX_KV,
BLOCK_M1=BLOCK_M1,
BLOCK_N1=BLOCK_N1,
HEAD_DIM=HEAD_DIM,
start_n=start_n,
start_m=0,
num_steps=num_steps,
)
dv_ptrs = DV + offs_n[:, None] * stride_tok + offs_k[None, :] * stride_d
tl.store(dv_ptrs, dv_acc)
dk_acc *= sm_scale
dk_ptrs = DK + offs_n[:, None] * stride_tok + offs_k[None, :] * stride_d
tl.store(dk_ptrs, dk_acc)
@triton.jit
def _attn_bwd_dq_kernel(
Q,
K,
V,
DO, #
DQ,
M,
D,
q2k_index,
q2k_num,
max_kv_blks,
variable_block_sizes,
# shared token/dim strides (assumed contiguous along token and dim)
stride_tok,
stride_d, #
# batch/head strides (may differ between Q and KV)
stride_qz,
stride_qh,
stride_kz,
stride_kh,
stride_vz,
stride_vh,
stride_doz,
stride_doh,
stride_dqz,
stride_dqh,
H,
N_CTX_Q,
BLOCK_M2: tl.constexpr, #
BLOCK_N2: tl.constexpr, #
HEAD_DIM: tl.constexpr):
"""
Backward kernel that computes dQ for each Q block (64 tokens).
Grid:
pid0: q_blk in [0, N_CTX_Q/BLOCK_M2)
pid2: fused (batch, head) in [0, B*H)
"""
LN2 = 0.6931471824645996 # = ln(2)
bhid = tl.program_id(2)
b = bhid // H
h = bhid % H
q_blk = tl.program_id(0)
q_adj = (b.to(tl.int64) * stride_qz + h.to(tl.int64) * stride_qh)
kv_adj_k = (b.to(tl.int64) * stride_kz + h.to(tl.int64) * stride_kh)
kv_adj_v = (b.to(tl.int64) * stride_vz + h.to(tl.int64) * stride_vh)
do_adj = (b.to(tl.int64) * stride_doz + h.to(tl.int64) * stride_doh)
dq_adj = (b.to(tl.int64) * stride_dqz + h.to(tl.int64) * stride_dqh)
Q = Q + q_adj
K = K + kv_adj_k
V = V + kv_adj_v
DO = DO + do_adj
DQ = DQ + dq_adj
M = M + (bhid * N_CTX_Q).to(tl.int64)
D = D + (bhid * N_CTX_Q).to(tl.int64)
offs_k = tl.arange(0, HEAD_DIM)
start_m = q_blk * BLOCK_M2
offs_m = start_m + tl.arange(0, BLOCK_M2)
q = tl.load(Q + offs_m[:, None] * stride_tok + offs_k[None, :] * stride_d)
do = tl.load(DO + offs_m[:, None] * stride_tok + offs_k[None, :] * stride_d)
m = tl.load(M + offs_m)[:, None]
dq_acc = tl.zeros([BLOCK_M2, HEAD_DIM], dtype=tl.float32)
num_steps = 0 # unused in _attn_bwd_dq
dq_acc = _attn_bwd_dq(
dq_acc,
q,
K,
V,
do,
m,
D,
q2k_index,
q2k_num,
max_kv_blks,
variable_block_sizes,
stride_tok,
stride_d,
H,
N_CTX_Q,
BLOCK_M2=BLOCK_M2,
BLOCK_N2=BLOCK_N2,
HEAD_DIM=HEAD_DIM,
start_m=start_m,
start_n=0,
num_steps=num_steps,
)
dq_ptrs = DQ + offs_m[:, None] * stride_tok + offs_k[None, :] * stride_d
dq_acc *= LN2
tl.store(dq_ptrs, dq_acc)
# ──────────────────────────── SPARSE ADDITION BEGIN ───────────────────────────
def triton_block_sparse_attn_forward(q, k, v, q2k_index, q2k_num,
variable_block_sizes):
B, H, Tq, D = q.shape
Tkv = k.shape[2]
B, H, T, D = q.shape
sm_scale = 1.0 / math.sqrt(D)
max_kv_blks = q2k_index.shape[-1]
assert Tq % 64 == 0, f"q length must be a multiple of 64, but got {Tq}"
assert Tkv % 64 == 0, f"kv length must be a multiple of 64, but got {Tkv}"
assert T % 64 == 0, f"T must be a multiple of 64, but got {T}"
assert q2k_num.shape[
-1] == Tq // 64, f"shape mismatch, Tq // 64 = {Tq // 64}, q2k_num.shape[-2] = {q2k_num.shape[-2]}"
assert variable_block_sizes.numel() == Tkv // 64, (
f"shape mismatch, variable_block_sizes must have length {Tkv // 64}, "
f"got {variable_block_sizes.numel()}"
)
-1] == T // 64, f"shape mismatch, T // 64 = {T // 64}, q2k_num.shape[-2] = {q2k_num.shape[-2]}"
o = torch.empty_like(q)
M = torch.empty((B, H, Tq), dtype=torch.float32, device=q.device)
M = torch.empty((B, H, T), dtype=torch.float32, device=q.device)
grid = lambda _: (triton.cdiv(Tq, 64), B * H, 1)
grid = lambda _: (triton.cdiv(T, 64), B * H, 1)
_attn_fwd_sparse[grid](q,
k,
v,
@@ -735,8 +508,7 @@ def triton_block_sparse_attn_forward(q, k, v, q2k_index, q2k_num,
o.stride(3),
B,
H,
Tq,
Tkv,
T,
HEAD_DIM=D,
STAGE=3)
@@ -746,21 +518,21 @@ def triton_block_sparse_attn_forward(q, k, v, q2k_index, q2k_num,
def triton_block_sparse_attn_backward(do, q, k, v, o, M, q2k_index, q2k_num,
k2q_index, k2q_num, variable_block_sizes):
assert do.is_contiguous()
assert q.stride() == k.stride() == v.stride() == o.stride() == do.stride()
B, H, Tq, D = q.shape
Tkv = k.shape[2]
B, H, T, D = q.shape
sm_scale = 1.0 / math.sqrt(D)
dq = torch.empty_like(q)
dk = torch.empty_like(k)
dv = torch.empty_like(v)
BATCH, N_HEAD = q.shape[:2]
BATCH, N_HEAD, N_CTX = q.shape[:3]
BLOCK_M1, BLOCK_N1, BLOCK_M2, BLOCK_N2 = 32, 64, 64, 32
RCP_LN2 = 1.4426950408889634 # = 1.0 / ln(2)
arg_k = k
arg_k = arg_k * (sm_scale * RCP_LN2)
PRE_BLOCK = 64
assert Tq % PRE_BLOCK == 0
pre_grid = (Tq // PRE_BLOCK, BATCH * N_HEAD)
assert N_CTX % PRE_BLOCK == 0
pre_grid = (N_CTX // PRE_BLOCK, BATCH * N_HEAD)
delta = torch.empty_like(M)
_attn_bwd_preprocess[pre_grid](
o,
@@ -768,7 +540,7 @@ def triton_block_sparse_attn_backward(do, q, k, v, o, M, q2k_index, q2k_num,
delta, #
BATCH,
N_HEAD,
Tq, #
N_CTX, #
BLOCK_M=PRE_BLOCK,
HEAD_DIM=D #
)
@@ -776,75 +548,36 @@ def triton_block_sparse_attn_backward(do, q, k, v, o, M, q2k_index, q2k_num,
max_q_blks = k2q_index.shape[-1]
max_kv_blks = q2k_index.shape[-1]
# dK/dV kernel: grid over KV blocks
grid_kv = (Tkv // BLOCK_N1, 1, BATCH * N_HEAD)
_attn_bwd_dkdv_kernel[grid_kv](
grid = (N_CTX // BLOCK_N1, 1, BATCH * N_HEAD)
_attn_bwd[grid](
q,
arg_k,
v,
sm_scale,
do,
dq,
dk,
dv,
dv, #
M,
delta,
delta, #
q2k_index,
q2k_num,
max_kv_blks,
k2q_index,
k2q_num,
max_q_blks,
variable_block_sizes,
q.stride(2),
q.stride(3),
q.stride(0),
q.stride(1),
arg_k.stride(0),
arg_k.stride(1),
v.stride(0),
v.stride(1),
do.stride(0),
do.stride(1),
dk.stride(0),
dk.stride(1),
dv.stride(0),
dv.stride(1),
q.stride(2),
q.stride(3), #
N_HEAD,
Tq,
Tkv,
N_CTX, #
BLOCK_M1=BLOCK_M1,
BLOCK_N1=BLOCK_N1,
HEAD_DIM=D,
)
# dQ kernel: grid over Q blocks
grid_q = (Tq // BLOCK_M2, 1, BATCH * N_HEAD)
_attn_bwd_dq_kernel[grid_q](
q,
arg_k,
v,
do,
dq,
M,
delta,
q2k_index,
q2k_num,
max_kv_blks,
variable_block_sizes,
q.stride(2),
q.stride(3),
q.stride(0),
q.stride(1),
arg_k.stride(0),
arg_k.stride(1),
v.stride(0),
v.stride(1),
do.stride(0),
do.stride(1),
dq.stride(0),
dq.stride(1),
N_HEAD,
Tq,
BLOCK_N1=BLOCK_N1, #
BLOCK_M2=BLOCK_M2,
BLOCK_N2=BLOCK_N2,
HEAD_DIM=D,
BLOCK_N2=BLOCK_N2, #
HEAD_DIM=D #
)
return dq, dk, dv
@@ -1 +1 @@
__version__ = "0.2.6"
__version__ = "0.2.5"
+5 -5
View File
@@ -1,8 +1,8 @@
from fastvideo.configs.models.dits.cosmos import CosmosVideoConfig
from fastvideo.configs.models.dits.cosmos2_5 import Cosmos25VideoConfig
from fastvideo.configs.models.dits.hunyuangamecraft import HunyuanGameCraftConfig
from fastvideo.configs.models.dits.hunyuanvideo import HunyuanVideoConfig
from fastvideo.configs.models.dits.hunyuanvideo15 import HunyuanVideo15Config
from fastvideo.configs.models.dits.lingbotworld import LingBotWorldVideoConfig
from fastvideo.configs.models.dits.longcat import LongCatVideoConfig
from fastvideo.configs.models.dits.ltx2 import LTX2VideoConfig
from fastvideo.configs.models.dits.stepvideo import StepVideoConfig
@@ -10,8 +10,8 @@ from fastvideo.configs.models.dits.wanvideo import WanVideoConfig
from fastvideo.configs.models.dits.hyworld import HYWorldConfig
__all__ = [
"HunyuanVideoConfig", "HunyuanVideo15Config", "HunyuanGameCraftConfig",
"WanVideoConfig", "StepVideoConfig", "CosmosVideoConfig",
"Cosmos25VideoConfig", "LongCatVideoConfig", "LTX2VideoConfig",
"HYWorldConfig"
"HunyuanVideoConfig", "HunyuanVideo15Config", "WanVideoConfig",
"StepVideoConfig", "CosmosVideoConfig", "Cosmos25VideoConfig",
"LongCatVideoConfig", "LTX2VideoConfig", "HYWorldConfig",
"LingBotWorldVideoConfig"
]
@@ -1,163 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Configuration for HunyuanGameCraft transformer model.
HunyuanGameCraft extends HunyuanVideo with:
1. CameraNet for camera/action conditioning
2. 33 input channels (16 latent + 16 gt_latent + 1 mask)
3. Mask-based conditioning for autoregressive generation
"""
from dataclasses import dataclass, field
import torch
from fastvideo.configs.models.dits.base import DiTArchConfig, DiTConfig
def is_double_block(n: str, m) -> bool:
return "double" in n and str.isdigit(n.split(".")[-1])
def is_single_block(n: str, m) -> bool:
return "single" in n and str.isdigit(n.split(".")[-1])
def is_refiner_block(n: str, m) -> bool:
return "refiner" in n and str.isdigit(n.split(".")[-1])
def is_txt_in(n: str, m) -> bool:
return n.split(".")[-1] == "txt_in"
def is_camera_net(n: str, m) -> bool:
return "camera_net" in n
@dataclass
class HunyuanGameCraftArchConfig(DiTArchConfig):
"""Architecture config for HunyuanGameCraft transformer."""
# Version field for compatibility with saved config.json
_fastvideo_version: str = "0.1.0"
# Camera net flag (for config.json compatibility)
camera_net: bool = True
_fsdp_shard_conditions: list = field(
default_factory=lambda:
[is_double_block, is_single_block, is_refiner_block, is_camera_net])
_compile_conditions: list = field(
default_factory=lambda: [is_double_block, is_single_block, is_txt_in])
# Parameter names mapping from official checkpoint to FastVideo naming
# GameCraft weights are already close to FastVideo format with minor adjustments
param_names_mapping: dict = field(
default_factory=lambda: {
# MLP naming: fc1 -> fc_in, fc2 -> fc_out
r"^(.*)\.img_mlp\.fc1\.(.*)$":
r"\1.img_mlp.fc_in.\2",
r"^(.*)\.img_mlp\.fc2\.(.*)$":
r"\1.img_mlp.fc_out.\2",
r"^(.*)\.txt_mlp\.fc1\.(.*)$":
r"\1.txt_mlp.fc_in.\2",
r"^(.*)\.txt_mlp\.fc2\.(.*)$":
r"\1.txt_mlp.fc_out.\2",
# Single block MLP naming
r"^single_blocks\.(\d+)\.mlp\.fc1\.(.*)$":
r"single_blocks.\1.mlp.fc_in.\2",
r"^single_blocks\.(\d+)\.mlp\.fc2\.(.*)$":
r"single_blocks.\1.mlp.fc_out.\2",
# Token refiner naming
r"^txt_in\.individual_token_refiner\.blocks\.(\d+)\.(.*)$":
r"txt_in.refiner_blocks.\1.\2",
# Vector in naming
r"^vector_in\.in_layer\.(.*)$":
r"vector_in.fc_in.\1",
r"^vector_in\.out_layer\.(.*)$":
r"vector_in.fc_out.\1",
# Time embedder naming
r"^time_in\.mlp\.0\.(.*)$":
r"time_in.mlp.fc_in.\1",
r"^time_in\.mlp\.2\.(.*)$":
r"time_in.mlp.fc_out.\1",
# Guidance embedder naming (if present)
r"^guidance_in\.mlp\.0\.(.*)$":
r"guidance_in.mlp.fc_in.\1",
r"^guidance_in\.mlp\.2\.(.*)$":
r"guidance_in.mlp.fc_out.\1",
# Final layer adaLN modulation
r"^final_layer\.adaLN_modulation\.1\.(.*)$":
r"final_layer.adaLN_modulation.linear.\1",
# Refiner block MLP naming
r"^txt_in\.refiner_blocks\.(\d+)\.mlp\.fc1\.(.*)$":
r"txt_in.refiner_blocks.\1.mlp.fc_in.\2",
r"^txt_in\.refiner_blocks\.(\d+)\.mlp\.fc2\.(.*)$":
r"txt_in.refiner_blocks.\1.mlp.fc_out.\2",
# Camera net weights are already correctly named
})
# Reverse mapping for saving checkpoints
reverse_param_names_mapping: dict = field(default_factory=lambda: {})
# Model architecture parameters
# patch_size can be int or tuple - if tuple, it's [T, H, W]
patch_size: int | tuple[int, int, int] = 2
patch_size_t: int = 1
in_channels: int = 33 # 16 latent + 16 gt_latent + 1 mask
out_channels: int = 16
num_attention_heads: int = 24
attention_head_dim: int = 128
mlp_ratio: float = 4.0
num_layers: int = 20 # Double stream blocks
num_single_layers: int = 40 # Single stream blocks
num_refiner_layers: int = 2
rope_axes_dim: tuple[int, int, int] = (16, 56, 56)
guidance_embeds: bool = False # GameCraft doesn't use guidance
dtype: torch.dtype | None = None
text_embed_dim: int = 4096 # LLaMA-3 hidden size
pooled_projection_dim: int = 768 # CLIP pooled output dim
rope_theta: int = 256
qk_norm: str = "rms_norm"
# Camera net parameters
camera_in_channels: int = 6 # Plücker coordinates
camera_downscale_coef: int = 8
camera_out_channels: int = 16
# Layers to exclude from LoRA
exclude_lora_layers: list[str] = field(
default_factory=lambda:
["img_in", "txt_in", "time_in", "vector_in", "camera_net"])
def __post_init__(self):
super().__post_init__()
self.hidden_size: int = self.attention_head_dim * self.num_attention_heads
self.num_channels_latents: int = 16 # Output is 16 channels
# Convert patch_size list to tuple if needed (from JSON deserialization)
if isinstance(self.patch_size, list):
self.patch_size = tuple(self.patch_size)
# Convert rope_axes_dim list to tuple if needed
if isinstance(self.rope_axes_dim, list):
self.rope_axes_dim = tuple(self.rope_axes_dim)
@dataclass
class HunyuanGameCraftConfig(DiTConfig):
"""Full config for HunyuanGameCraft model."""
arch_config: DiTArchConfig = field(
default_factory=HunyuanGameCraftArchConfig)
prefix: str = "HunyuanGameCraft"
@@ -1,6 +1,5 @@
from fastvideo.configs.models.vaes.cosmosvae import CosmosVAEConfig
from fastvideo.configs.models.vaes.cosmos2_5vae import Cosmos25VAEConfig
from fastvideo.configs.models.vaes.gamecraftvae import GameCraftVAEConfig
from fastvideo.configs.models.vaes.hunyuanvae import HunyuanVAEConfig
from fastvideo.configs.models.vaes.hunyuan15vae import Hunyuan15VAEConfig
from fastvideo.configs.models.vaes.ltx2vae import LTX2VAEConfig
@@ -8,7 +7,6 @@ from fastvideo.configs.models.vaes.stepvideovae import StepVideoVAEConfig
from fastvideo.configs.models.vaes.wanvae import WanVAEConfig
__all__ = [
"GameCraftVAEConfig",
"HunyuanVAEConfig",
"WanVAEConfig",
"StepVideoVAEConfig",
@@ -1,50 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
GameCraft VAE config - matches official config.json from Hunyuan-GameCraft-1.0.
"""
from dataclasses import dataclass, field
from fastvideo.configs.models.vaes.base import VAEArchConfig, VAEConfig
@dataclass
class GameCraftVAEArchConfig(VAEArchConfig):
"""Architecture config matching official AutoencoderKLCausal3D config.json."""
in_channels: int = 3
out_channels: int = 3
latent_channels: int = 16
down_block_types: tuple[str, ...] = (
"DownEncoderBlockCausal3D",
"DownEncoderBlockCausal3D",
"DownEncoderBlockCausal3D",
"DownEncoderBlockCausal3D",
)
up_block_types: tuple[str, ...] = (
"UpDecoderBlockCausal3D",
"UpDecoderBlockCausal3D",
"UpDecoderBlockCausal3D",
"UpDecoderBlockCausal3D",
)
block_out_channels: tuple[int, ...] = (128, 256, 512, 512)
layers_per_block: int = 2
act_fn: str = "silu"
norm_num_groups: int = 32
scaling_factor: float = 0.476986
spatial_compression_ratio: int = 8
temporal_compression_ratio: int = 4
time_compression_ratio: int = 4 # alias for DecoderCausal3D
mid_block_add_attention: bool = True
mid_block_causal_attn: bool = True
sample_size: int = 256 # from config.json
sample_tsize: int = 64 # from config.json
def __post_init__(self):
self.spatial_compression_ratio = 2**(len(self.block_out_channels) - 1)
@dataclass
class GameCraftVAEConfig(VAEConfig):
"""Full config for GameCraft VAE."""
arch_config: VAEArchConfig = field(default_factory=GameCraftVAEArchConfig)
+9 -7
View File
@@ -4,9 +4,10 @@ from fastvideo.configs.pipelines.cosmos import CosmosConfig
from fastvideo.configs.pipelines.cosmos2_5 import Cosmos25Config
from fastvideo.configs.pipelines.hunyuan import FastHunyuanConfig, HunyuanConfig
from fastvideo.configs.pipelines.hunyuan15 import Hunyuan15T2V480PConfig, Hunyuan15T2V720PConfig
from fastvideo.configs.pipelines.hunyuangamecraft import HunyuanGameCraftPipelineConfig
from fastvideo.configs.pipelines.hyworld import HYWorldConfig
from fastvideo.configs.pipelines.lingbotworld import LingBotWorldI2V480PConfig
from fastvideo.configs.pipelines.ltx2 import LTX2T2VConfig
from fastvideo.configs.pipelines.sd35 import SD35Config
from fastvideo.registry import get_pipeline_config_cls_from_name
from fastvideo.configs.pipelines.stepvideo import StepVideoT2VConfig
from fastvideo.configs.pipelines.wan import (SelfForcingWanT2V480PConfig,
@@ -14,10 +15,11 @@ from fastvideo.configs.pipelines.wan import (SelfForcingWanT2V480PConfig,
WanT2V480PConfig, WanT2V720PConfig)
__all__ = [
"HunyuanConfig", "FastHunyuanConfig", "HunyuanGameCraftPipelineConfig",
"PipelineConfig", "Hunyuan15T2V480PConfig", "Hunyuan15T2V720PConfig",
"SlidingTileAttnConfig", "WanT2V480PConfig", "WanI2V480PConfig",
"WanT2V720PConfig", "WanI2V720PConfig", "StepVideoT2VConfig",
"SelfForcingWanT2V480PConfig", "CosmosConfig", "Cosmos25Config",
"LTX2T2VConfig", "HYWorldConfig", "get_pipeline_config_cls_from_name"
"HunyuanConfig", "FastHunyuanConfig", "PipelineConfig",
"Hunyuan15T2V480PConfig", "Hunyuan15T2V720PConfig", "SlidingTileAttnConfig",
"WanT2V480PConfig", "WanI2V480PConfig", "WanT2V720PConfig",
"WanI2V720PConfig", "StepVideoT2VConfig", "SelfForcingWanT2V480PConfig",
"CosmosConfig", "Cosmos25Config", "LTX2T2VConfig", "HYWorldConfig",
"SD35Config", "LingBotWorldI2V480PConfig",
"get_pipeline_config_cls_from_name"
]
@@ -1,122 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Pipeline configuration for HunyuanGameCraft.
HunyuanGameCraft extends HunyuanVideo with:
1. CameraNet for camera/action conditioning (Plücker coordinates)
2. Mask-based conditioning for autoregressive generation
3. 33 input channels (16 latent + 16 gt_latent + 1 mask)
Text encoders are the same as HunyuanVideo:
- LLaVA-LLaMA-3-8B for primary text encoding (4096 dim)
- CLIP ViT-L/14 for secondary pooled embeddings (768 dim)
"""
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TypedDict
import torch
from fastvideo.configs.models import DiTConfig, EncoderConfig, VAEConfig
from fastvideo.configs.models.dits import HunyuanGameCraftConfig
from fastvideo.configs.models.encoders import (
BaseEncoderOutput,
CLIPTextConfig,
LlamaConfig,
)
from fastvideo.configs.models.vaes import GameCraftVAEConfig
from fastvideo.configs.pipelines.base import PipelineConfig
# GameCraft uses the same prompt template as HunyuanVideo
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|>")
class PromptTemplate(TypedDict):
template: str
crop_start: int
prompt_template_video: PromptTemplate = {
"template": PROMPT_TEMPLATE_ENCODE_VIDEO,
"crop_start": 95,
}
def llama_preprocess_text(prompt: str) -> str:
"""Apply prompt template for LLaMA encoder."""
return prompt_template_video["template"].format(prompt)
def llama_postprocess_text(outputs: BaseEncoderOutput) -> torch.Tensor:
"""Extract hidden states from LLaMA output, skipping instruction tokens."""
hidden_state_skip_layer = 2
assert outputs.hidden_states is not None
hidden_states: tuple[torch.Tensor, ...] = outputs.hidden_states
last_hidden_state: torch.Tensor = hidden_states[-(hidden_state_skip_layer +
1)]
crop_start = prompt_template_video.get("crop_start", -1)
last_hidden_state = last_hidden_state[:, crop_start:]
return last_hidden_state
def clip_preprocess_text(prompt: str) -> str:
"""No preprocessing for CLIP encoder."""
return prompt
def clip_postprocess_text(outputs: BaseEncoderOutput) -> torch.Tensor:
"""Extract pooled output from CLIP encoder."""
pooler_output: torch.Tensor = outputs.pooler_output
return pooler_output
@dataclass
class HunyuanGameCraftPipelineConfig(PipelineConfig):
"""Configuration for HunyuanGameCraft pipeline.
Inherits text encoding from HunyuanVideo but uses:
- GameCraft DiT with CameraNet
- Same VAE (HunyuanVAE)
- Same text encoders (LLaMA + CLIP)
"""
# DiT config - uses GameCraft config (33 input channels)
dit_config: DiTConfig = field(default_factory=HunyuanGameCraftConfig)
# VAE config - GameCraft VAE (mid_block_causal_attn=True, etc.)
vae_config: VAEConfig = field(default_factory=GameCraftVAEConfig)
# Denoising parameters
# Official GameCraft does NOT use embedded guidance (passes guidance=None)
# It uses standard CFG with guidance_scale=6.0 instead
embedded_cfg_scale = None
flow_shift: int = 5 # Official GameCraft uses flow_shift=5.0
# Text encoding stage - same as HunyuanVideo
# Uses LLaMA-3-8B (via LLaVA) + CLIP
text_encoder_configs: tuple[EncoderConfig, ...] = field(
default_factory=lambda: (LlamaConfig(), CLIPTextConfig()))
preprocess_text_funcs: tuple[Callable[[str], str], ...] = field(
default_factory=lambda: (llama_preprocess_text, clip_preprocess_text))
postprocess_text_funcs: tuple[
Callable[[BaseEncoderOutput], torch.Tensor],
...] = field(default_factory=lambda:
(llama_postprocess_text, clip_postprocess_text))
# Precision for each component
dit_precision: str = "bf16"
vae_precision: str = "fp16"
text_encoder_precisions: tuple[str, ...] = field(
default_factory=lambda: ("fp16", "fp16"))
def __post_init__(self):
# VAE only needs decoder for inference
self.vae_config.load_encoder = False
self.vae_config.load_decoder = True
+1 -11
View File
@@ -1,13 +1,3 @@
from fastvideo.configs.sample.base import SamplingParam
from fastvideo.configs.sample.hunyuangamecraft import (
HunyuanGameCraftSamplingParam,
HunyuanGameCraft65FrameSamplingParam,
HunyuanGameCraft129FrameSamplingParam,
)
__all__ = [
"SamplingParam",
"HunyuanGameCraftSamplingParam",
"HunyuanGameCraft65FrameSamplingParam",
"HunyuanGameCraft129FrameSamplingParam",
]
__all__ = ["SamplingParam"]
@@ -1,104 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Sampling parameters for HunyuanGameCraft video generation.
GameCraft generates game-like videos with camera/action control.
Default parameters are based on the official implementation.
"""
from dataclasses import dataclass, field
from typing import Any
from fastvideo.configs.sample.base import SamplingParam
from fastvideo.configs.sample.teacache import TeaCacheParams
@dataclass
class HunyuanGameCraftSamplingParam(SamplingParam):
"""Sampling parameters for HunyuanGameCraft video generation.
Supports camera/action conditioning via:
- camera_trajectory: Plücker coordinates for camera motion
- action_list: List of actions (e.g., ["forward", "left", "right"])
- action_speed_list: Speed multipliers for each action
Default resolution is 704x1280 (same as HunyuanVideo).
Default frame count is 33 video frames -> 9 latent frames.
"""
# Number of denoising steps
num_inference_steps: int = 50
# Video dimensions
# 33 video frames -> 9 latent frames (4x temporal compression)
num_frames: int = 33
height: int = 704
width: int = 1280
fps: int = 24
# Guidance scale - official GameCraft uses CFG with guidance_scale=6.0
guidance_scale: float = 6.0
# Negative prompt for CFG (empty string = unconditional)
negative_prompt: str = ""
# Camera/Action conditioning
# Camera states as Plücker coordinates [B, T_video, 6, H, W]
camera_states: Any | None = None
# Camera trajectory file/identifier (alternative to camera_states)
camera_trajectory: str | None = None
# Action list for camera motion (e.g., ["forward", "left"])
action_list: list[str] | None = None
# Speed multipliers for each action
action_speed_list: list[float] | None = None
# History frame conditioning (for autoregressive generation)
# Ground truth latents for conditioning [B, 16, T, H, W]
gt_latents: Any | None = None
# Mask for conditioning (1=use gt, 0=generate) [B, 1, T, H, W]
conditioning_mask: Any | None = None
# Number of conditioning frames (for autoregressive) - maps to num_cond_frames
num_cond_frames: int = 0
# TeaCache parameters (if enabled)
teacache_params: TeaCacheParams = field(
default_factory=lambda: TeaCacheParams(
teacache_thresh=0.15,
coefficients=[
7.33226126e+02, -4.01131952e+02, 6.75869174e+01,
-3.14987800e+00, 9.61237896e-02
],
))
def __post_init__(self) -> None:
super().__post_init__()
# Validate action lists
if (self.action_list is not None and self.action_speed_list is not None
and len(self.action_list) != len(self.action_speed_list)):
raise ValueError(
f"action_list length ({len(self.action_list)}) must match "
f"action_speed_list length ({len(self.action_speed_list)})")
@dataclass
class HunyuanGameCraft65FrameSamplingParam(HunyuanGameCraftSamplingParam):
"""Sampling parameters for 65-frame GameCraft generation.
65 video frames -> 17 latent frames (with first frame as key frame).
This is useful for longer video generation.
"""
num_frames: int = 65
@dataclass
class HunyuanGameCraft129FrameSamplingParam(HunyuanGameCraftSamplingParam):
"""Sampling parameters for 129-frame GameCraft generation.
129 video frames -> 33 latent frames.
This is the maximum supported by the official implementation.
"""
num_frames: int = 129
-7
View File
@@ -9,7 +9,6 @@ class LTX2BaseSamplingParam(SamplingParam):
"""Default sampling parameters for LTX-2 base one-stage T2V.
Values follow the official LTX-2 one-stage defaults.
Multi-modal CFG params are read by ``LTX2DenoisingStage``.
"""
seed: int = 10
@@ -38,12 +37,6 @@ class LTX2BaseSamplingParam(SamplingParam):
"awkward pauses, incorrect timing, unnatural transitions, "
"inconsistent framing, tilted camera, flat lighting, inconsistent "
"tone, cinematic oversaturation, stylized filters, or AI artifacts.")
# Official LTX-2 multi-modal CFG defaults.
ltx2_cfg_scale_video: float = 3.0
ltx2_cfg_scale_audio: float = 7.0
ltx2_modality_scale_video: float = 3.0
ltx2_modality_scale_audio: float = 3.0
ltx2_rescale_scale: float = 0.7
@dataclass
+22 -61
View File
@@ -109,45 +109,26 @@ def _apply_rotary_emb(
"""
Args:
x: [num_tokens, num_heads, head_size]
cos: [num_tokens, head_size] or [num_tokens, head_size // 2]
sin: [num_tokens, head_size] or [num_tokens, head_size // 2]
cos: [num_tokens, head_size // 2]
sin: [num_tokens, head_size // 2]
is_neox_style: Whether to use the Neox-style or GPT-J-style rotary
positional embeddings.
The function auto-detects whether cos/sin are full or half head_size:
- If cos/sin have head_size: use rotate_half style (for HunyuanVideo/GameCraft)
- If cos/sin have head_size // 2: use Neox/GPT-J style
"""
head_size = x.shape[-1]
rope_dim = cos.shape[-1]
# Check if cos/sin are full head_dim (rotate_half style) or half (traditional style)
if rope_dim == head_size:
# Full head_dim - use rotate_half style (HunyuanVideo, GameCraft)
# x * cos + rotate_half(x) * sin
cos = cos.unsqueeze(-2) # [num_tokens, 1, head_size]
sin = sin.unsqueeze(-2) # [num_tokens, 1, head_size]
# rotate_half: split into pairs, negate and swap
x_real, x_imag = x.float().reshape(*x.shape[:-1], -1,
2).unbind(-1) # [B, H, D//2] each
x_rotated = torch.stack([-x_imag, x_real],
dim=-1).flatten(-2) # [B, H, D]
return (x.float() * cos + x_rotated * sin).type_as(x)
# cos = cos.unsqueeze(-2).to(x.dtype)
# sin = sin.unsqueeze(-2).to(x.dtype)
cos = cos.unsqueeze(-2)
sin = sin.unsqueeze(-2)
if is_neox_style:
x1, x2 = torch.chunk(x, 2, dim=-1)
else:
# Half head_dim - use traditional Neox/GPT-J style
cos = cos.unsqueeze(-2)
sin = sin.unsqueeze(-2)
if is_neox_style:
x1, x2 = torch.chunk(x, 2, dim=-1)
else:
x1 = x[..., ::2]
x2 = x[..., 1::2]
o1 = (x1.float() * cos - x2.float() * sin).type_as(x)
o2 = (x2.float() * cos + x1.float() * sin).type_as(x)
if is_neox_style:
return torch.cat((o1, o2), dim=-1)
else:
return torch.stack((o1, o2), dim=-1).flatten(-2)
x1 = x[..., ::2]
x2 = x[..., 1::2]
o1 = (x1.float() * cos - x2.float() * sin).type_as(x)
o2 = (x2.float() * cos + x1.float() * sin).type_as(x)
if is_neox_style:
return torch.cat((o1, o2), dim=-1)
else:
return torch.stack((o1, o2), dim=-1).flatten(-2)
@CustomOp.register("rotary_embedding")
@@ -297,7 +278,6 @@ def get_1d_rotary_pos_embed(
theta_rescale_factor: float = 1.0,
interpolation_factor: float = 1.0,
dtype: torch.dtype = torch.float32,
use_real: bool = True,
) -> tuple[torch.Tensor, torch.Tensor]:
"""
Precompute the frequency tensor for complex exponential (cis) with given dimensions.
@@ -312,12 +292,9 @@ def get_1d_rotary_pos_embed(
theta (float, optional): Scaling factor for frequency computation. Defaults to 10000.0.
theta_rescale_factor (float, optional): Rescale factor for theta. Defaults to 1.0.
interpolation_factor (float, optional): Factor to scale positions. Defaults to 1.0.
use_real (bool, optional): If True, output full head_dim with repeated cos/sin for
rotate_half style RoPE. If False, output half head_dim for complex style. Defaults to True.
Returns:
freqs_cos, freqs_sin: Precomputed frequency tensor with real and imaginary parts separately.
Shape is [S, D] if use_real=True, [S, D/2] if use_real=False.
freqs_cos, freqs_sin: Precomputed frequency tensor with real and imaginary parts separately. [S, D]
"""
if isinstance(pos, int):
pos = torch.arange(pos).float()
@@ -332,16 +309,6 @@ def get_1d_rotary_pos_embed(
freqs = torch.outer(pos * interpolation_factor, freqs) # [S, D/2]
freqs_cos = freqs.cos() # [S, D/2]
freqs_sin = freqs.sin() # [S, D/2]
if use_real:
# For rotate_half style RoPE (used by HunyuanVideo, GameCraft),
# we need to expand cos/sin to full head_dim using repeat_interleave.
# The rotate_half operation works on consecutive PAIRS: (x0,x1), (x2,x3)...
# so cos/sin must be interleaved: [c0,c0,c1,c1,...] to match the pairing.
# Using torch.cat would produce [c0,c1,...,c0,c1,...] which is WRONG.
freqs_cos = freqs_cos.repeat_interleave(2, dim=-1) # [S, D]
freqs_sin = freqs_sin.repeat_interleave(2, dim=-1) # [S, D]
return freqs_cos, freqs_sin
@@ -357,7 +324,6 @@ def get_nd_rotary_pos_embed(
sp_world_size: int = 1,
dtype: torch.dtype = torch.float32,
start_frame: int = 0,
use_real: bool = True,
) -> tuple[torch.Tensor, torch.Tensor]:
"""
This is a n-d version of precompute_freqs_cis, which is a RoPE for tokens with n-d structure.
@@ -375,10 +341,9 @@ def get_nd_rotary_pos_embed(
shard_dim (int): Which dimension to shard for sequence parallelism. Defaults to 0.
sp_rank (int): Rank in the sequence parallel group. Defaults to 0.
sp_world_size (int): World size of the sequence parallel group. Defaults to 1.
use_real (bool): If True, output full head_dim for rotate_half style. Defaults to True.
Returns:
Tuple[torch.Tensor, torch.Tensor]: (cos, sin) tensors of shape [HW, D] if use_real, [HW, D/2] otherwise
Tuple[torch.Tensor, torch.Tensor]: (cos, sin) tensors of shape [HW, D/2]
"""
# Get the full grid
full_grid = get_meshgrid_nd(
@@ -447,12 +412,11 @@ def get_nd_rotary_pos_embed(
theta_rescale_factor=theta_rescale_factor[i],
interpolation_factor=interpolation_factor[i],
dtype=dtype,
use_real=use_real,
) # 2 x [WHD, rope_dim_list[i]] or 2 x [WHD, rope_dim_list[i]*2] if use_real
) # 2 x [WHD, rope_dim_list[i]]
embs.append(emb)
cos = torch.cat([emb[0] for emb in embs], dim=1) # (WHD, D) or (WHD, D/2)
sin = torch.cat([emb[1] for emb in embs], dim=1) # (WHD, D) or (WHD, D/2)
cos = torch.cat([emb[0] for emb in embs], dim=1) # (WHD, D/2)
sin = torch.cat([emb[1] for emb in embs], dim=1) # (WHD, D/2)
return cos, sin
@@ -468,7 +432,6 @@ def get_rotary_pos_embed(
do_sp_sharding: bool = False,
dtype: torch.dtype = torch.float32,
start_frame: int = 0,
use_real: bool = True,
) -> tuple[torch.Tensor, torch.Tensor]:
"""
Generate rotary positional embeddings for the given sizes.
@@ -483,10 +446,9 @@ def get_rotary_pos_embed(
interpolation_factor: Factor to scale positions. Defaults to 1.0
shard_dim: Which dimension to shard for sequence parallelism. Defaults to 0.
do_sp_sharding: Whether to shard the positional embeddings for sequence parallelism. Defaults to False.
use_real: If True, output full head_dim for rotate_half style RoPE. Defaults to True.
Returns:
Tuple of (cos, sin) tensors for rotary embeddings. Shape [S, D] if use_real, [S, D/2] otherwise.
Tuple of (cos, sin) tensors for rotary embeddings
"""
target_ndim = 3
@@ -519,7 +481,6 @@ def get_rotary_pos_embed(
sp_world_size=sp_world_size,
dtype=dtype,
start_frame=start_frame,
use_real=use_real,
)
return freqs_cos, freqs_sin
-6
View File
@@ -1,6 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Camera trajectory utilities for video generation models."""
from fastvideo.models.camera.trajectory import create_camera_trajectory
__all__ = ["create_camera_trajectory"]
-395
View File
@@ -1,395 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Camera trajectory generation for HunyuanGameCraft.
Generates Plücker coordinate embeddings from simple action commands
(forward, backward, left, right, rotations) for camera conditioning.
This is a self-contained implementation that does not depend on the
official Hunyuan-GameCraft-1.0 repository.
"""
import math
import numpy as np
import torch
from packaging import version as pver
# Action name -> motion type mapping
ACTION_DICT = {
"w": "forward",
"a": "left",
"d": "right",
"s": "backward",
"forward": "forward",
"backward": "backward",
"left": "left",
"right": "right",
"left_rot": "left_rot",
"right_rot": "right_rot",
"up_rot": "up_rot",
"down_rot": "down_rot",
}
def _custom_meshgrid(*args):
"""Torch meshgrid with consistent indexing."""
if pver.parse(torch.__version__) < pver.parse("1.10"):
return torch.meshgrid(*args)
else:
return torch.meshgrid(*args, indexing="ij")
def _generate_motion_segment(
current_pose: dict,
motion_type: str,
value: float,
duration: int = 30,
) -> tuple[list, list, dict]:
"""
Generate camera motion segment.
Args:
current_pose: Dict with 'position' (xyz) and 'rotation' (pitch, yaw, roll).
motion_type: One of 'forward', 'backward', 'left', 'right',
'left_rot', 'right_rot', 'up_rot', 'down_rot'.
value: Translation (meters) or rotation (degrees).
duration: Number of frames.
Returns:
positions: List of position arrays.
rotations: List of rotation arrays.
current_pose: Updated pose dict.
"""
positions = []
rotations = []
if motion_type in ["forward", "backward"]:
yaw_rad = np.radians(current_pose["rotation"][1])
pitch_rad = np.radians(current_pose["rotation"][0])
forward_vec = np.array([
-math.sin(yaw_rad) * math.cos(pitch_rad),
math.sin(pitch_rad),
-math.cos(yaw_rad) * math.cos(pitch_rad),
])
direction = 1 if motion_type == "forward" else -1
total_move = forward_vec * value * direction
step = total_move / duration
for i in range(1, duration + 1):
new_pos = current_pose["position"] + step * i
positions.append(new_pos.copy())
rotations.append(current_pose["rotation"].copy())
current_pose["position"] = positions[-1]
elif motion_type in ["left", "right"]:
yaw_rad = np.radians(current_pose["rotation"][1])
right_vec = np.array([math.cos(yaw_rad), 0, -math.sin(yaw_rad)])
direction = -1 if motion_type == "right" else 1
total_move = right_vec * value * direction
step = total_move / duration
for i in range(1, duration + 1):
new_pos = current_pose["position"] + step * i
positions.append(new_pos.copy())
rotations.append(current_pose["rotation"].copy())
current_pose["position"] = positions[-1]
elif motion_type.endswith("rot"):
axis = motion_type.split("_")[0]
total_rotation = np.zeros(3)
if axis == "left":
total_rotation[0] = value
elif axis == "right":
total_rotation[0] = -value
elif axis == "up":
total_rotation[2] = -value
elif axis == "down":
total_rotation[2] = value
step = total_rotation / duration
for i in range(1, duration + 1):
positions.append(current_pose["position"].copy())
new_rot = current_pose["rotation"] + step * i
rotations.append(new_rot.copy())
current_pose["rotation"] = rotations[-1]
return positions, rotations, current_pose
def _euler_to_quaternion(angles: np.ndarray) -> list[float]:
"""Convert Euler angles (pitch, yaw, roll in degrees) to quaternion."""
pitch, yaw, roll = np.radians(angles)
cy = math.cos(yaw * 0.5)
sy = math.sin(yaw * 0.5)
cp = math.cos(pitch * 0.5)
sp = math.sin(pitch * 0.5)
cr = math.cos(roll * 0.5)
sr = math.sin(roll * 0.5)
qw = cy * cp * cr + sy * sp * sr
qx = cy * cp * sr - sy * sp * cr
qy = sy * cp * sr + cy * sp * cr
qz = sy * cp * cr - cy * sp * sr
return [qw, qx, qy, qz]
def _quaternion_to_rotation_matrix(q: list[float]) -> np.ndarray:
"""Convert quaternion to 3x3 rotation matrix."""
qw, qx, qy, qz = q
return np.array([
[1 - 2 * (qy**2 + qz**2), 2 * (qx * qy - qw * qz), 2 * (qx * qz + qw * qy)],
[2 * (qx * qy + qw * qz), 1 - 2 * (qx**2 + qz**2), 2 * (qy * qz - qw * qx)],
[2 * (qx * qz - qw * qy), 2 * (qy * qz + qw * qx), 1 - 2 * (qx**2 + qy**2)],
])
def _action_to_pose_list(action_id: str, value: float = 0.2, duration: int = 33) -> list[str]:
"""
Convert an action ID to a list of pose strings.
Args:
action_id: Action identifier (e.g., 'w', 'forward', 'left_rot').
value: Motion magnitude (translation in meters, rotation in degrees).
duration: Number of frames.
Returns:
List of pose strings in the official GameCraft format.
"""
all_positions = []
all_rotations = []
current_pose = {
"position": np.array([0.0, 0.0, 0.0]),
"rotation": np.array([0.0, 0.0, 0.0]),
}
intrinsic = [0.50505, 0.8979, 0.5, 0.5]
motion_type = ACTION_DICT.get(action_id, action_id)
positions, rotations, current_pose = _generate_motion_segment(
current_pose, motion_type, value, duration
)
all_positions.extend(positions)
all_rotations.extend(rotations)
pose_list = []
# First frame: identity pose
row = [0] + intrinsic + [0, 0] + [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0]
first_row = " ".join(map(str, row))
pose_list.append(first_row)
for i, (pos, rot) in enumerate(zip(all_positions, all_rotations)):
quat = _euler_to_quaternion(rot)
R = _quaternion_to_rotation_matrix(quat)
extrinsic = np.hstack([R, pos.reshape(3, 1)])
row = [i] + intrinsic + [0, 0] + extrinsic.flatten().tolist()
pose_list.append(" ".join(map(str, row)))
return pose_list
class _Camera:
"""Camera parameters from a pose string."""
def __init__(self, entry: list[float]):
fx, fy, cx, cy = entry[1:5]
self.fx = fx
self.fy = fy
self.cx = cx
self.cy = cy
w2c_mat = np.array(entry[7:]).reshape(3, 4)
w2c_mat_4x4 = np.eye(4)
w2c_mat_4x4[:3, :] = w2c_mat
self.w2c_mat = w2c_mat_4x4
self.c2w_mat = np.linalg.inv(w2c_mat_4x4)
def _get_relative_pose(cam_params: list[_Camera]) -> np.ndarray:
"""Convert camera parameters to relative poses."""
abs_w2cs = [cam_param.w2c_mat for cam_param in cam_params]
abs_c2ws = [cam_param.c2w_mat for cam_param in cam_params]
target_cam_c2w = np.array([
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1],
])
abs2rel = target_cam_c2w @ abs_w2cs[0]
ret_poses = [target_cam_c2w] + [abs2rel @ abs_c2w for abs_c2w in abs_c2ws[1:]]
for pose in ret_poses:
pose[:3, -1:] *= 10
ret_poses = np.array(ret_poses, dtype=np.float32)
return ret_poses
def _get_c2w(w2cs: list[np.ndarray], transform_matrix: np.ndarray) -> np.ndarray:
"""Convert w2c matrices to c2w with relative transform."""
target_cam_c2w = np.array([
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1],
])
abs2rel = target_cam_c2w @ w2cs[0]
ret_poses = [target_cam_c2w] + [abs2rel @ np.linalg.inv(w2c) for w2c in w2cs[1:]]
for pose in ret_poses:
pose[:3, -1:] *= 2
ret_poses = [transform_matrix @ x for x in ret_poses]
return np.array(ret_poses, dtype=np.float32)
def _ray_condition(
K: torch.Tensor,
c2w: torch.Tensor,
H: int,
W: int,
device: str | torch.device = "cpu",
flip_flag: torch.Tensor | None = None,
) -> torch.Tensor:
"""
Compute Plücker coordinates from camera intrinsics and extrinsics.
Args:
K: Intrinsics [B, V, 4] (fx, fy, cx, cy).
c2w: Camera-to-world matrices [B, V, 4, 4].
H: Image height.
W: Image width.
device: Torch device.
flip_flag: Optional flip flags [V].
Returns:
Plücker coordinates [B, V, H, W, 6].
"""
B, V = K.shape[:2]
j, i = _custom_meshgrid(
torch.linspace(0, H - 1, H, device=device, dtype=c2w.dtype),
torch.linspace(0, W - 1, W, device=device, dtype=c2w.dtype),
)
i = i.reshape([1, 1, H * W]).expand([B, V, H * W]) + 0.5
j = j.reshape([1, 1, H * W]).expand([B, V, H * W]) + 0.5
n_flip = torch.sum(flip_flag).item() if flip_flag is not None else 0
if n_flip > 0:
j_flip, i_flip = _custom_meshgrid(
torch.linspace(0, H - 1, H, device=device, dtype=c2w.dtype),
torch.linspace(W - 1, 0, W, device=device, dtype=c2w.dtype),
)
i_flip = i_flip.reshape([1, 1, H * W]).expand(B, 1, H * W) + 0.5
j_flip = j_flip.reshape([1, 1, H * W]).expand(B, 1, H * W) + 0.5
i[:, flip_flag, ...] = i_flip
j[:, flip_flag, ...] = j_flip
fx, fy, cx, cy = K.chunk(4, dim=-1)
zs = torch.ones_like(i)
xs = (i - cx) / fx * zs
ys = (j - cy) / fy * zs
zs = zs.expand_as(ys)
directions = torch.stack((xs, ys, zs), dim=-1)
directions = directions / directions.norm(dim=-1, keepdim=True)
rays_d = directions @ c2w[..., :3, :3].transpose(-1, -2)
rays_o = c2w[..., :3, 3]
rays_o = rays_o[:, :, None].expand_as(rays_d)
rays_dxo = torch.linalg.cross(rays_o, rays_d)
plucker = torch.cat([rays_dxo, rays_d], dim=-1)
plucker = plucker.reshape(B, c2w.shape[1], H, W, 6)
return plucker
def create_camera_trajectory(
action: str,
height: int,
width: int,
num_frames: int,
action_speed: float = 0.2,
device: torch.device | str = "cpu",
dtype: torch.dtype = torch.bfloat16,
) -> torch.Tensor:
"""
Create Plücker coordinate embeddings from an action command.
Args:
action: One of 'forward', 'backward', 'left', 'right',
'left_rot', 'right_rot', 'up_rot', 'down_rot'
(or shorthand 'w', 'a', 's', 'd').
height: Video height in pixels.
width: Video width in pixels.
num_frames: Number of video frames.
action_speed: Speed of motion (default 0.2).
device: Torch device for output tensor.
dtype: Torch dtype for output tensor.
Returns:
camera_states: [1, num_frames, 6, height, width] Plücker embeddings.
"""
# Generate pose list from action
poses = _action_to_pose_list(action, value=action_speed, duration=num_frames)
# Parse poses
poses_parsed = [pose.split(" ") for pose in poses]
start_idx = 0
sample_id = [start_idx + i for i in range(num_frames)]
poses_parsed = [poses_parsed[i] for i in sample_id]
# Convert to w2c matrices
w2cs = [np.asarray([float(p) for p in pose[7:]]).reshape(3, 4) for pose in poses_parsed]
transform_matrix = np.asarray(
[[1, 0, 0, 0], [0, 0, 1, 0], [0, -1, 0, 0], [0, 0, 0, 1]]
).reshape(4, 4)
last_row = np.zeros((1, 4))
last_row[0, -1] = 1.0
w2cs = [np.concatenate((w2c, last_row), axis=0) for w2c in w2cs]
c2ws = _get_c2w(w2cs, transform_matrix)
# Parse camera parameters
cam_params = [[float(x) for x in pose] for pose in poses_parsed]
assert len(cam_params) == num_frames
cam_params = [_Camera(cam_param) for cam_param in cam_params]
# Compute scaled intrinsics
monst3r_w = cam_params[0].cx * 2
monst3r_h = cam_params[0].cy * 2
ratio_w, ratio_h = width / monst3r_w, height / monst3r_h
intrinsics = np.asarray(
[
[
cam_param.fx * ratio_w,
cam_param.fy * ratio_h,
cam_param.cx * ratio_w,
cam_param.cy * ratio_h,
]
for cam_param in cam_params
],
dtype=np.float32,
)
intrinsics = torch.as_tensor(intrinsics)[None] # [1, n_frame, 4]
# Get relative poses
c2w_poses = _get_relative_pose(cam_params)
c2w = torch.as_tensor(c2w_poses)[None] # [1, n_frame, 4, 4]
# Compute Plücker embeddings
flip_flag = torch.zeros(num_frames, dtype=torch.bool, device="cpu")
plucker_embedding = _ray_condition(intrinsics, c2w, height, width, device="cpu", flip_flag=flip_flag)
# [1, n_frame, H, W, 6] -> [1, n_frame, 6, H, W]
plucker_embedding = plucker_embedding[0].permute(0, 3, 1, 2).contiguous()
# Add batch dim and convert to target dtype/device
# Shape: [n_frame, 6, H, W] -> [1, n_frame, 6, H, W]
camera_states = plucker_embedding.unsqueeze(0).to(device=device, dtype=dtype)
return camera_states
-363
View File
@@ -1,363 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
HunyuanGameCraft Transformer model for FastVideo.
Ported from official Hunyuan-GameCraft-1.0 implementation.
"""
from typing import Any, List, Optional
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange
from fastvideo.configs.models.dits.hunyuangamecraft import (
HunyuanGameCraftArchConfig,
HunyuanGameCraftConfig,
)
from fastvideo.layers.linear import ReplicatedLinear
from fastvideo.layers.mlp import MLP
from fastvideo.layers.rotary_embedding import get_rotary_pos_embed
from fastvideo.layers.visual_embedding import ModulateProjection, PatchEmbed, TimestepEmbedder, unpatchify
from fastvideo.models.dits.base import CachableDiT
from fastvideo.models.dits.hunyuanvideo import (
MMDoubleStreamBlock,
MMSingleStreamBlock,
SingleTokenRefiner,
)
class GameCraftFinalLayer(nn.Module):
"""
GameCraft-specific FinalLayer with correct shift/scale order.
The official GameCraft implementation uses shift, scale order (not scale, shift).
This differs from the HunyuanVideo FinalLayer.
"""
def __init__(self,
hidden_size,
patch_size,
out_channels,
dtype=None,
prefix: str = "") -> None:
super().__init__()
self.norm_final = nn.LayerNorm(hidden_size,
eps=1e-6,
elementwise_affine=False,
dtype=dtype)
output_dim = patch_size[0] * patch_size[1] * patch_size[2] * out_channels
self.linear = ReplicatedLinear(hidden_size,
output_dim,
bias=True,
params_dtype=dtype,
prefix=f"{prefix}.linear")
self.adaLN_modulation = ModulateProjection(
hidden_size,
factor=2,
act_layer="silu",
dtype=dtype,
prefix=f"{prefix}.adaLN_modulation")
def forward(self, x, c):
# GameCraft uses shift, scale order (verified against official implementation)
shift, scale = self.adaLN_modulation(c).chunk(2, dim=-1)
x = self.norm_final(x) * (1.0 + scale.unsqueeze(1)) + shift.unsqueeze(1)
x, _ = self.linear(x)
return x
class CameraNet(nn.Module):
"""
Camera state encoding network - ported from official GameCraft.
Processes camera parameters (Plücker coordinates) into feature embeddings.
"""
def __init__(
self,
in_channels: int = 6,
downscale_coef: int = 8,
out_channels: int = 16,
patch_size: List[int] = [1, 2, 2],
hidden_size: int = 3072,
dtype: Optional[torch.dtype] = None,
prefix: str = "",
):
super().__init__()
_ = prefix # Unused
start_channels = in_channels * (downscale_coef ** 2)
input_channels = [start_channels, start_channels // 2, start_channels // 4]
self.input_channels = input_channels
self.unshuffle = nn.PixelUnshuffle(downscale_coef)
self.encode_first = nn.Sequential(
nn.Conv2d(input_channels[0], input_channels[1], kernel_size=1, stride=1, padding=0),
nn.GroupNorm(2, input_channels[1]),
nn.ReLU(),
)
self._initialize_weights(self.encode_first)
self.encode_second = nn.Sequential(
nn.Conv2d(input_channels[1], input_channels[2], kernel_size=1, stride=1, padding=0),
nn.GroupNorm(2, input_channels[2]),
nn.ReLU(),
)
self._initialize_weights(self.encode_second)
self.final_proj = nn.Conv2d(input_channels[2], out_channels, kernel_size=1)
self._zeros_init_linear(self.final_proj)
self.scale = nn.Parameter(torch.ones(1))
self.camera_in = PatchEmbed(
patch_size=patch_size,
in_chans=out_channels,
embed_dim=hidden_size,
)
def _zeros_init_linear(self, linear):
if hasattr(linear, "weight"):
nn.init.zeros_(linear.weight)
if hasattr(linear, "bias") and linear.bias is not None:
nn.init.zeros_(linear.bias)
def _initialize_weights(self, block):
for m in block:
if isinstance(m, nn.Conv2d):
n = m.kernel_size[0] * m.kernel_size[1] * m.in_channels
nn.init.normal_(m.weight, mean=0.0, std=np.sqrt(2.0 / n))
if m.bias is not None:
nn.init.zeros_(m.bias)
def compress_time(self, x: torch.Tensor, num_frames: int) -> torch.Tensor:
x = rearrange(x, '(b f) c h w -> b f c h w', f=num_frames)
batch_size, frames, channels, height, width = x.shape
x = rearrange(x, 'b f c h w -> (b h w) c f')
if x.shape[-1] == 66 or x.shape[-1] == 34:
x_len = x.shape[-1]
x_clip1 = x[..., :x_len // 2]
x_clip1_first = x_clip1[..., 0].unsqueeze(-1)
x_clip1_rest = F.avg_pool1d(x_clip1[..., 1:], kernel_size=2, stride=2)
x_clip2 = x[..., x_len // 2:]
x_clip2_first = x_clip2[..., 0].unsqueeze(-1)
x_clip2_rest = F.avg_pool1d(x_clip2[..., 1:], kernel_size=2, stride=2)
x = torch.cat([x_clip1_first, x_clip1_rest, x_clip2_first, x_clip2_rest], dim=-1)
elif x.shape[-1] % 2 == 1:
x_first = x[..., 0]
x_rest = x[..., 1:]
if x_rest.shape[-1] > 0:
x_rest = F.avg_pool1d(x_rest, kernel_size=2, stride=2)
x = torch.cat([x_first[..., None], x_rest], dim=-1)
else:
x = F.avg_pool1d(x, kernel_size=2, stride=2)
x = rearrange(x, '(b h w) c f -> (b f) c h w', b=batch_size, h=height, w=width)
return x
def forward(self, camera_states: torch.Tensor) -> torch.Tensor:
batch_size, num_frames, channels, height, width = camera_states.shape
camera_states = rearrange(camera_states, 'b f c h w -> (b f) c h w')
camera_states = self.unshuffle(camera_states)
camera_states = self.encode_first(camera_states)
camera_states = self.compress_time(camera_states, num_frames=num_frames)
num_frames = camera_states.shape[0] // batch_size
camera_states = self.encode_second(camera_states)
camera_states = self.compress_time(camera_states, num_frames=num_frames)
camera_states = self.final_proj(camera_states)
camera_states = rearrange(camera_states, "(b f) c h w -> b c f h w", b=batch_size)
camera_states = self.camera_in(camera_states)
return camera_states * self.scale
class HunyuanGameCraftTransformer3DModel(CachableDiT):
"""
HunyuanGameCraft Transformer - ported from official implementation.
"""
_fsdp_shard_conditions = HunyuanGameCraftArchConfig()._fsdp_shard_conditions
_compile_conditions = HunyuanGameCraftArchConfig()._compile_conditions
_supported_attention_backends = HunyuanGameCraftArchConfig()._supported_attention_backends
param_names_mapping = HunyuanGameCraftConfig().param_names_mapping
reverse_param_names_mapping = HunyuanGameCraftConfig().reverse_param_names_mapping
def __init__(self, config: HunyuanGameCraftConfig, hf_config: dict[str, Any]):
super().__init__(config=config, hf_config=hf_config)
arch = config.arch_config
if isinstance(arch.patch_size, (list, tuple)):
self.patch_size = list(arch.patch_size)
else:
self.patch_size = [arch.patch_size_t, arch.patch_size, arch.patch_size]
self.in_channels = arch.in_channels
self.out_channels = arch.out_channels
self.unpatchify_channels = self.out_channels
self.num_channels_latents = self.out_channels # Alias for latent_preparation stage
self.hidden_size = arch.hidden_size
self.num_heads = arch.num_attention_heads
self.num_attention_heads = arch.num_attention_heads # Alias for compatibility
self.guidance_embeds = arch.guidance_embeds
self.rope_dim_list = list(arch.rope_axes_dim)
self.rope_theta = arch.rope_theta
self.text_states_dim = arch.text_embed_dim
self.text_states_dim_2 = arch.pooled_projection_dim
self.dtype = arch.dtype
pe_dim = self.hidden_size // self.num_heads
if sum(self.rope_dim_list) != pe_dim:
raise ValueError(f"rope_axes_dim sum {sum(self.rope_dim_list)} != {pe_dim}")
factory_kwargs = {'dtype': self.dtype}
self.img_in = PatchEmbed(
patch_size=self.patch_size,
in_chans=self.in_channels,
embed_dim=self.hidden_size,
**factory_kwargs,
)
self.txt_in = SingleTokenRefiner(
self.text_states_dim,
self.hidden_size,
self.num_heads,
depth=arch.num_refiner_layers,
**factory_kwargs,
)
self.time_in = TimestepEmbedder(self.hidden_size, **factory_kwargs)
self.vector_in = MLP(
self.text_states_dim_2,
self.hidden_size,
self.hidden_size,
act_type="silu",
**factory_kwargs,
)
self.guidance_in = (
TimestepEmbedder(self.hidden_size, **factory_kwargs)
if self.guidance_embeds else None
)
self.double_blocks = nn.ModuleList([
MMDoubleStreamBlock(
hidden_size=self.hidden_size,
num_attention_heads=self.num_heads,
mlp_ratio=arch.mlp_ratio,
supported_attention_backends=self._supported_attention_backends,
**factory_kwargs,
)
for _ in range(arch.num_layers)
])
self.single_blocks = nn.ModuleList([
MMSingleStreamBlock(
hidden_size=self.hidden_size,
num_attention_heads=self.num_heads,
mlp_ratio=arch.mlp_ratio,
supported_attention_backends=self._supported_attention_backends,
**factory_kwargs,
)
for _ in range(arch.num_single_layers)
])
self.final_layer = GameCraftFinalLayer(
self.hidden_size,
self.patch_size,
self.out_channels,
**factory_kwargs,
)
self.camera_net = CameraNet(
in_channels=arch.camera_in_channels,
out_channels=16,
downscale_coef=arch.camera_downscale_coef,
patch_size=self.patch_size,
hidden_size=self.hidden_size,
)
def forward(
self,
x: torch.Tensor,
encoder_hidden_states: List[torch.Tensor],
timestep: torch.Tensor,
camera_states: Optional[torch.Tensor] = None,
encoder_attention_mask: Optional[List[torch.Tensor]] = None,
guidance: Optional[torch.Tensor] = None,
return_dict: bool = False,
) -> torch.Tensor:
img = x
_, _, ot, oh, ow = x.shape
tt = ot // self.patch_size[0]
th = oh // self.patch_size[1]
tw = ow // self.patch_size[2]
text_states = encoder_hidden_states[0]
text_states_2 = encoder_hidden_states[1] if len(encoder_hidden_states) > 1 else None
text_mask = encoder_attention_mask[0] if encoder_attention_mask else None
vec = self.time_in(timestep)
if text_states_2 is not None:
vec = vec + self.vector_in(text_states_2)
if self.guidance_in is not None and guidance is not None:
vec = vec + self.guidance_in(guidance)
img = self.img_in(img)
if camera_states is not None:
latent_len = ot
if latent_len == 18:
camera_latents = torch.cat([
self.camera_net(torch.zeros_like(camera_states)),
self.camera_net(camera_states)
], dim=1)
elif latent_len == 9:
camera_latents = self.camera_net(camera_states)
elif latent_len == 10:
camera_latents = torch.cat([
self.camera_net(torch.zeros_like(camera_states[:, 0:4, :, :, :])),
self.camera_net(camera_states)
], dim=1)
else:
camera_latents = self.camera_net(camera_states)
img = img + camera_latents
txt = self.txt_in(text_states, timestep)
txt_seq_len = txt.shape[1]
freqs_cos, freqs_sin = get_rotary_pos_embed(
(tt, th, tw),
self.hidden_size,
self.num_heads,
self.rope_dim_list,
self.rope_theta,
)
freqs_cos = freqs_cos.to(device=img.device, dtype=img.dtype)
freqs_sin = freqs_sin.to(device=img.device, dtype=img.dtype)
freqs_cis = (freqs_cos, freqs_sin)
for block in self.double_blocks:
img, txt = block(img, txt, vec, freqs_cis)
x = torch.cat([img, txt], dim=1)
for block in self.single_blocks:
x = block(x, vec, txt_seq_len, freqs_cis)
img = x[:, :-txt_seq_len, ...]
img = self.final_layer(img, vec)
img = unpatchify(img, tt, th, tw, self.patch_size, self.out_channels)
return img
+1 -13
View File
@@ -1534,7 +1534,6 @@ class BasicAVTransformerBlock(torch.nn.Module):
audio: TransformerArgs | None,
video_attention_mask: torch.Tensor | None = None,
audio_attention_mask: torch.Tensor | None = None,
skip_cross_modal_attn: bool = False,
) -> tuple[TransformerArgs | None, TransformerArgs | None]:
"""Forward pass for transformer block.
@@ -1543,8 +1542,6 @@ class BasicAVTransformerBlock(torch.nn.Module):
audio: Audio transformer args
video_attention_mask: SP padding attention mask for video [B, padded_seq_len]
audio_attention_mask: SP padding attention mask for audio [B, padded_seq_len]
skip_cross_modal_attn: If True, skip A2V and V2A cross-modal
attention (used for the modality-isolated CFG pass).
"""
vx = video.x if video is not None else None
ax = audio.x if audio is not None else None
@@ -1588,7 +1585,7 @@ class BasicAVTransformerBlock(torch.nn.Module):
mask=audio.context_mask,
)
if (run_a2v or run_v2a) and not skip_cross_modal_attn:
if run_a2v or run_v2a:
vx_norm3 = torch.nn.functional.rms_norm(vx, (vx.shape[-1],), eps=self.norm_eps)
ax_norm3 = torch.nn.functional.rms_norm(ax, (ax.shape[-1],), eps=self.norm_eps)
@@ -2009,7 +2006,6 @@ class LTXModel(torch.nn.Module):
audio: TransformerArgs | None,
video_attention_mask: torch.Tensor | None = None,
audio_attention_mask: torch.Tensor | None = None,
skip_cross_modal_attn: bool = False,
) -> tuple[TransformerArgs | None, TransformerArgs | None]:
for block in self.transformer_blocks:
video, audio = block(
@@ -2017,7 +2013,6 @@ class LTXModel(torch.nn.Module):
audio=audio,
video_attention_mask=video_attention_mask,
audio_attention_mask=audio_attention_mask,
skip_cross_modal_attn=skip_cross_modal_attn,
)
return video, audio
@@ -2044,7 +2039,6 @@ class LTXModel(torch.nn.Module):
audio: Modality | None,
video_attention_mask: torch.Tensor | None = None,
audio_attention_mask: torch.Tensor | None = None,
skip_cross_modal_attn: bool = False,
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
"""Forward pass through the LTX model.
@@ -2053,9 +2047,6 @@ class LTXModel(torch.nn.Module):
audio: Audio modality input
video_attention_mask: SP padding attention mask for video [B, padded_seq_len]
audio_attention_mask: SP padding attention mask for audio [B, padded_seq_len]
skip_cross_modal_attn: If True, skip A2V and V2A cross-modal
attention in all transformer blocks (modality-isolated
CFG pass).
"""
if os.getenv("LTX2_PIPELINE_DEBUG_LOG", "0") == "1":
_debug_block_log_line(
@@ -2079,7 +2070,6 @@ class LTXModel(torch.nn.Module):
audio_args,
video_attention_mask=video_attention_mask,
audio_attention_mask=audio_attention_mask,
skip_cross_modal_attn=skip_cross_modal_attn,
)
vx = (
@@ -2248,7 +2238,6 @@ class LTX2Transformer3DModel(CachableDiT):
audio_encoder_hidden_states: torch.Tensor | None = None,
audio_timestep: torch.Tensor | None = None,
audio_encoder_attention_mask: torch.Tensor | None = None,
skip_cross_modal_attn: bool = False,
**kwargs,
) -> torch.Tensor:
if isinstance(encoder_hidden_states, list):
@@ -2405,7 +2394,6 @@ class LTX2Transformer3DModel(CachableDiT):
audio=audio_modality,
video_attention_mask=video_attention_mask,
audio_attention_mask=audio_attention_mask,
skip_cross_modal_attn=skip_cross_modal_attn,
)
# Denoised prediction
+1 -4
View File
@@ -25,8 +25,6 @@ logger = init_logger(__name__)
_TEXT_TO_VIDEO_DIT_MODELS = {
"HunyuanVideoTransformer3DModel":
("dits", "hunyuanvideo", "HunyuanVideoTransformer3DModel"),
"HunyuanGameCraftTransformer3DModel":
("dits", "hunyuangamecraft", "HunyuanGameCraftTransformer3DModel"),
"HunyuanVideo15Transformer3DModel":
("dits", "hunyuanvideo15", "HunyuanVideo15Transformer3DModel"),
"HYWorldTransformer3DModel":
@@ -77,7 +75,6 @@ _IMAGE_ENCODER_MODELS: dict[str, tuple] = {
_VAE_MODELS = {
"AutoencoderKLHunyuanVideo":
("vaes", "hunyuanvae", "AutoencoderKLHunyuanVideo"),
"AutoencoderKLCausal3D": ("vaes", "gamecraftvae", "GameCraftVAE"),
"AutoencoderKLHYWorld": ("vaes", "hyworldvae", "AutoencoderKLHYWorld"),
"AutoencoderKLHunyuanVideo15": ("vaes", "hunyuan15vae", "AutoencoderKLHunyuanVideo15"),
"AutoencoderKLWan": ("vaes", "wanvae", "AutoencoderKLWan"),
@@ -454,4 +451,4 @@ ModelRegistry = _ModelRegistry({
)
for model_arch, (component_name, mod_relname,
cls_name) in _FAST_VIDEO_MODELS.items()
})
})
-462
View File
@@ -1,462 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
GameCraft VAE - ported from official Hunyuan-GameCraft-1.0/hymm_sp/vae/.
Matches the official AutoencoderKLCausal3D structure exactly for weight loading.
"""
from dataclasses import dataclass
from typing import Optional, Tuple
import numpy as np
import torch
import torch.nn as nn
from fastvideo.configs.models.vaes.gamecraftvae import GameCraftVAEConfig
from fastvideo.models.vaes.common import DiagonalGaussianDistribution
from fastvideo.models.vaes.gamecraftvae_blocks import (
CausalConv3d,
DownEncoderBlockCausal3D,
UpDecoderBlockCausal3D,
UNetMidBlockCausal3D,
)
@dataclass
class AutoencoderKLOutput:
"""Matches official AutoencoderKLOutput interface."""
latent_dist: DiagonalGaussianDistribution
@dataclass
class DecoderOutput:
"""Matches official DecoderOutput interface."""
sample: torch.Tensor
class EncoderCausal3D(nn.Module):
"""Encoder - ported from official vae.py. Structure matches for weight loading."""
def __init__(
self,
in_channels: int = 3,
out_channels: int = 16,
down_block_types: Tuple[str, ...] = ("DownEncoderBlockCausal3D",),
block_out_channels: Tuple[int, ...] = (128, 256, 512, 512),
layers_per_block: int = 2,
norm_num_groups: int = 32,
act_fn: str = "silu",
double_z: bool = True,
mid_block_add_attention: bool = True,
time_compression_ratio: int = 4,
spatial_compression_ratio: int = 8,
disable_causal: bool = False,
mid_block_causal_attn: bool = False,
):
super().__init__()
self.layers_per_block = layers_per_block
self.conv_in = CausalConv3d(
in_channels, block_out_channels[0], kernel_size=3, stride=1, disable_causal=disable_causal
)
self.down_blocks = nn.ModuleList([])
output_channel = block_out_channels[0]
for i, _ in enumerate(down_block_types):
input_channel = output_channel
output_channel = block_out_channels[i]
is_final_block = i == len(block_out_channels) - 1
num_spatial = int(np.log2(spatial_compression_ratio))
num_time = int(np.log2(time_compression_ratio))
if time_compression_ratio == 4:
add_spatial = bool(i < num_spatial)
add_time = bool(
i >= (len(block_out_channels) - 1 - num_time) and not is_final_block
)
elif time_compression_ratio == 8:
add_spatial = bool(i < num_spatial)
add_time = bool(i < num_time)
else:
raise ValueError(f"Unsupported time_compression_ratio: {time_compression_ratio}")
downsample_stride_HW = (2, 2) if add_spatial else (1, 1)
downsample_stride_T = (2,) if add_time else (1,)
downsample_stride = tuple(downsample_stride_T + downsample_stride_HW)
down_block = DownEncoderBlockCausal3D(
in_channels=input_channel,
out_channels=output_channel,
num_layers=layers_per_block,
resnet_eps=1e-6,
resnet_act_fn=act_fn,
resnet_groups=norm_num_groups,
add_downsample=bool(add_spatial or add_time),
downsample_stride=downsample_stride,
downsample_padding=0,
disable_causal=disable_causal,
)
self.down_blocks.append(down_block)
self.mid_block = UNetMidBlockCausal3D(
in_channels=block_out_channels[-1],
temb_channels=None,
num_layers=1,
resnet_eps=1e-6,
resnet_act_fn=act_fn,
resnet_groups=norm_num_groups,
add_attention=mid_block_add_attention,
attention_head_dim=block_out_channels[-1],
disable_causal=disable_causal,
causal_attention=mid_block_causal_attn,
)
self.conv_norm_out = nn.GroupNorm(
num_channels=block_out_channels[-1], num_groups=norm_num_groups, eps=1e-6
)
self.conv_act = nn.SiLU()
conv_out_channels = 2 * out_channels if double_z else out_channels
self.conv_out = CausalConv3d(
block_out_channels[-1],
conv_out_channels,
kernel_size=3,
disable_causal=disable_causal,
)
def forward(self, sample: torch.Tensor) -> torch.Tensor:
sample = self.conv_in(sample)
for down_block in self.down_blocks:
sample = down_block(sample, scale=1.0)
sample = self.mid_block(sample, temb=None)
sample = self.conv_norm_out(sample)
sample = self.conv_act(sample)
sample = self.conv_out(sample)
return sample
class DecoderCausal3D(nn.Module):
"""Decoder - ported from official vae.py. Structure matches for weight loading."""
def __init__(
self,
in_channels: int = 16,
out_channels: int = 3,
up_block_types: Tuple[str, ...] = ("UpDecoderBlockCausal3D",),
block_out_channels: Tuple[int, ...] = (128, 256, 512, 512),
layers_per_block: int = 2,
norm_num_groups: int = 32,
act_fn: str = "silu",
mid_block_add_attention: bool = True,
time_compression_ratio: int = 4,
spatial_compression_ratio: int = 8,
disable_causal: bool = False,
mid_block_causal_attn: bool = False,
):
super().__init__()
self.layers_per_block = layers_per_block
self.conv_in = CausalConv3d(
in_channels,
block_out_channels[-1],
kernel_size=3,
stride=1,
disable_causal=disable_causal,
)
self.mid_block = UNetMidBlockCausal3D(
in_channels=block_out_channels[-1],
temb_channels=None,
num_layers=1,
resnet_eps=1e-6,
resnet_act_fn=act_fn,
resnet_groups=norm_num_groups,
add_attention=mid_block_add_attention,
attention_head_dim=block_out_channels[-1],
disable_causal=disable_causal,
causal_attention=mid_block_causal_attn,
)
self.up_blocks = nn.ModuleList([])
reversed_channels = list(reversed(block_out_channels))
output_channel = reversed_channels[0]
for i, _ in enumerate(up_block_types):
prev_output_channel = output_channel
output_channel = reversed_channels[i]
is_final_block = i == len(block_out_channels) - 1
num_spatial = int(np.log2(spatial_compression_ratio))
num_time = int(np.log2(time_compression_ratio))
if time_compression_ratio == 4:
add_spatial = bool(i < num_spatial)
add_time = bool(
i >= len(block_out_channels) - 1 - num_time and not is_final_block
)
elif time_compression_ratio == 8:
add_spatial = bool(i >= len(block_out_channels) - num_spatial)
add_time = bool(i >= len(block_out_channels) - num_time)
else:
raise ValueError(f"Unsupported time_compression_ratio: {time_compression_ratio}")
upsample_HW = (2, 2) if add_spatial else (1, 1)
upsample_T = (2,) if add_time else (1,)
upsample_scale_factor = tuple(upsample_T + upsample_HW)
up_block = UpDecoderBlockCausal3D(
in_channels=prev_output_channel,
out_channels=output_channel,
num_layers=layers_per_block + 1,
resnet_eps=1e-6,
resnet_act_fn=act_fn,
resnet_groups=norm_num_groups,
add_upsample=bool(add_spatial or add_time),
upsample_scale_factor=upsample_scale_factor,
temb_channels=None,
disable_causal=disable_causal,
)
self.up_blocks.append(up_block)
output_channel = output_channel
self.conv_norm_out = nn.GroupNorm(
num_channels=block_out_channels[0], num_groups=norm_num_groups, eps=1e-6
)
self.conv_act = nn.SiLU()
self.conv_out = CausalConv3d(
block_out_channels[0], out_channels, kernel_size=3, disable_causal=disable_causal
)
def forward(
self,
sample: torch.Tensor,
latent_embeds: Optional[torch.Tensor] = None,
) -> torch.Tensor:
sample = self.conv_in(sample)
sample = self.mid_block(sample, temb=latent_embeds)
for up_block in self.up_blocks:
sample = up_block(sample, temb=latent_embeds, scale=1.0)
sample = self.conv_norm_out(sample)
sample = self.conv_act(sample)
sample = self.conv_out(sample)
return sample
class GameCraftVAE(nn.Module):
"""
GameCraft VAE - ported from official AutoencoderKLCausal3D.
Structure matches exactly for loading official weights.
"""
def __init__(self, config: GameCraftVAEConfig):
super().__init__()
self.config = config
arch = config.arch_config
time_ratio = getattr(arch, "time_compression_ratio", arch.temporal_compression_ratio)
self.encoder = EncoderCausal3D(
in_channels=arch.in_channels,
out_channels=arch.latent_channels,
down_block_types=tuple(arch.down_block_types),
block_out_channels=tuple(arch.block_out_channels),
layers_per_block=arch.layers_per_block,
norm_num_groups=arch.norm_num_groups,
act_fn=arch.act_fn,
double_z=True,
time_compression_ratio=time_ratio,
spatial_compression_ratio=arch.spatial_compression_ratio,
disable_causal=getattr(arch, "disable_causal_conv", False),
mid_block_add_attention=arch.mid_block_add_attention,
mid_block_causal_attn=getattr(arch, "mid_block_causal_attn", False),
)
self.decoder = DecoderCausal3D(
in_channels=arch.latent_channels,
out_channels=arch.out_channels,
up_block_types=tuple(arch.up_block_types),
block_out_channels=tuple(arch.block_out_channels),
layers_per_block=arch.layers_per_block,
norm_num_groups=arch.norm_num_groups,
act_fn=arch.act_fn,
time_compression_ratio=time_ratio,
spatial_compression_ratio=arch.spatial_compression_ratio,
disable_causal=getattr(arch, "disable_causal_conv", False),
mid_block_add_attention=arch.mid_block_add_attention,
mid_block_causal_attn=getattr(arch, "mid_block_causal_attn", False),
)
self.quant_conv = nn.Conv3d(
2 * arch.latent_channels, 2 * arch.latent_channels, kernel_size=1
)
self.post_quant_conv = nn.Conv3d(
arch.latent_channels, arch.latent_channels, kernel_size=1
)
# Scaling factor for latent normalization (required for decoding stage)
self.scaling_factor = arch.scaling_factor
# Tiling support - matches official GameCraft VAE settings
self._tiling_enabled = False
self.tile_overlap_factor = 0.25
# Temporal tiling params (for >64 output frames)
self.tile_sample_min_tsize = 64 # Minimum sample temporal size (video frames)
self.tile_latent_min_tsize = 16 # = 64 // 4 (time_compression_ratio)
# Spatial tiling params - use small tiles to reduce memory
self.tile_sample_min_size = 256 # Minimum spatial tile size in pixel space
self.tile_latent_min_size = 32 # = 256 // 8 (spatial_compression_ratio)
def encode(self, x: torch.Tensor) -> AutoencoderKLOutput:
"""Encode to latent distribution."""
h = self.encoder(x)
moments = self.quant_conv(h)
posterior = DiagonalGaussianDistribution(moments)
return AutoencoderKLOutput(latent_dist=posterior)
def decode(self, z: torch.Tensor) -> torch.Tensor:
"""Decode from latents.
Args:
z: Latent tensor [B, C, T, H, W]
Returns:
Decoded tensor [B, C, T_out, H_out, W_out]
"""
# Use tiled decode for memory efficiency when enabled
if self._tiling_enabled:
# Check if temporal tiling needed (>64 output frames)
if z.shape[2] > self.tile_latent_min_tsize:
return self._temporal_tiled_decode(z)
# Check if spatial tiling needed (large H or W)
if z.shape[-1] > self.tile_latent_min_size or z.shape[-2] > self.tile_latent_min_size:
return self._spatial_tiled_decode(z)
z = self.post_quant_conv(z)
dec = self.decoder(z, latent_embeds=None)
return dec
def _temporal_tiled_decode(self, z: torch.Tensor) -> torch.Tensor:
"""Decode latents in temporal tiles with overlapping and blending.
Based on official GameCraft temporal_tiled_decode implementation.
Only used when T > tile_latent_min_tsize (16).
"""
B, C, T, H, W = z.shape
# Use the pre-configured tiling parameters
overlap_size = int(self.tile_latent_min_tsize * (1 - self.tile_overlap_factor))
blend_extent = int(self.tile_sample_min_tsize * self.tile_overlap_factor)
t_limit = self.tile_sample_min_tsize - blend_extent
row = []
for i in range(0, T, overlap_size):
tile = z[:, :, i : i + self.tile_latent_min_tsize + 1, :, :]
tile = self.post_quant_conv(tile)
decoded = self.decoder(tile, latent_embeds=None)
if i > 0:
decoded = decoded[:, :, 1:, :, :] # Skip first frame for non-first tiles
row.append(decoded)
# Blend overlapping regions
result_row = []
for i, tile in enumerate(row):
if i > 0:
tile = self._blend_t(row[i - 1], tile, blend_extent)
result_row.append(tile[:, :, :t_limit, :, :])
else:
result_row.append(tile[:, :, :t_limit+1, :, :])
return torch.cat(result_row, dim=2)
def _spatial_tiled_decode(self, z: torch.Tensor) -> torch.Tensor:
"""Decode latents in spatial tiles with overlapping and blending.
Based on official GameCraft spatial_tiled_decode implementation.
"""
overlap_size = int(self.tile_latent_min_size * (1 - self.tile_overlap_factor))
blend_extent = int(self.tile_sample_min_size * self.tile_overlap_factor)
row_limit = self.tile_sample_min_size - blend_extent
# Split z into overlapping tiles and decode them separately
rows = []
for i in range(0, z.shape[-2], overlap_size):
row = []
for j in range(0, z.shape[-1], overlap_size):
tile = z[:, :, :, i: i + self.tile_latent_min_size, j: j + self.tile_latent_min_size]
tile = self.post_quant_conv(tile)
decoded = self.decoder(tile, latent_embeds=None)
row.append(decoded)
rows.append(row)
# Blend overlapping regions
result_rows = []
for i, row in enumerate(rows):
result_row = []
for j, tile in enumerate(row):
# Blend with above tile and left tile
if i > 0:
tile = self._blend_v(rows[i - 1][j], tile, blend_extent)
if j > 0:
tile = self._blend_h(row[j - 1], tile, blend_extent)
result_row.append(tile[:, :, :, :row_limit, :row_limit])
result_rows.append(torch.cat(result_row, dim=-1))
return torch.cat(result_rows, dim=-2)
def _blend_t(self, a: torch.Tensor, b: torch.Tensor, blend_extent: int) -> torch.Tensor:
"""Blend two tensors along temporal dimension."""
blend_extent = min(a.shape[-3], b.shape[-3], blend_extent)
if blend_extent == 0:
return b
a_region = a[..., -blend_extent:, :, :]
b_region = b[..., :blend_extent, :, :]
weights = torch.arange(blend_extent, device=a.device, dtype=a.dtype) / blend_extent
weights = weights.view(1, 1, blend_extent, 1, 1)
blended = a_region * (1 - weights) + b_region * weights
b[..., :blend_extent, :, :] = blended
return b
def _blend_v(self, a: torch.Tensor, b: torch.Tensor, blend_extent: int) -> torch.Tensor:
"""Blend two tensors along vertical (height) dimension."""
blend_extent = min(a.shape[-2], b.shape[-2], blend_extent)
if blend_extent == 0:
return b
a_region = a[..., -blend_extent:, :]
b_region = b[..., :blend_extent, :]
weights = torch.arange(blend_extent, device=a.device, dtype=a.dtype) / blend_extent
weights = weights.view(1, 1, 1, blend_extent, 1)
blended = a_region * (1 - weights) + b_region * weights
b[..., :blend_extent, :] = blended
return b
def _blend_h(self, a: torch.Tensor, b: torch.Tensor, blend_extent: int) -> torch.Tensor:
"""Blend two tensors along horizontal (width) dimension."""
blend_extent = min(a.shape[-1], b.shape[-1], blend_extent)
if blend_extent == 0:
return b
a_region = a[..., -blend_extent:]
b_region = b[..., :blend_extent]
weights = torch.arange(blend_extent, device=a.device, dtype=a.dtype) / blend_extent
weights = weights.view(1, 1, 1, 1, blend_extent)
blended = a_region * (1 - weights) + b_region * weights
b[..., :blend_extent] = blended
return b
def enable_tiling(self) -> None:
"""Enable tiling for large inputs."""
self._tiling_enabled = True
def disable_tiling(self) -> None:
"""Disable tiling."""
self._tiling_enabled = False
EntryClass = GameCraftVAE
@@ -1,446 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
GameCraft VAE building blocks - ported from official Hunyuan-GameCraft-1.0/hymm_sp/vae/unet_causal_3d_blocks.py.
Matches the official structure exactly for weight loading.
"""
from typing import Optional, Tuple, Union
import torch
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange
def prepare_causal_attention_mask(
n_frame: int, n_hw: int, dtype, device, batch_size: Optional[int] = None
):
seq_len = n_frame * n_hw
mask = torch.full((seq_len, seq_len), float("-inf"), dtype=dtype, device=device)
for i in range(seq_len):
i_frame = i // n_hw
mask[i, : (i_frame + 1) * n_hw] = 0
if batch_size is not None:
mask = mask.unsqueeze(0).expand(batch_size, -1, -1)
return mask
class CausalConv3d(nn.Module):
"""Causal 3D convolution - matches official structure (has .conv)."""
def __init__(
self,
chan_in: int,
chan_out: int,
kernel_size: Union[int, Tuple[int, int, int]] = 3,
stride: Union[int, Tuple[int, int, int]] = 1,
dilation: Union[int, Tuple[int, int, int]] = 1,
pad_mode: str = "replicate",
disable_causal: bool = False,
**kwargs,
):
super().__init__()
self.pad_mode = pad_mode
if isinstance(kernel_size, int):
k = kernel_size
else:
k = kernel_size[0]
if disable_causal:
padding = (k // 2, k // 2, k // 2, k // 2, k // 2, k // 2)
else:
padding = (k // 2, k // 2, k // 2, k // 2, k - 1, 0)
self.time_causal_padding = padding
self.conv = nn.Conv3d(
chan_in, chan_out, kernel_size, stride=stride, dilation=dilation, **kwargs
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = F.pad(x, self.time_causal_padding, mode=self.pad_mode)
return self.conv(x)
class DownsampleCausal3D(nn.Module):
"""Causal 3D downsampling - matches official (has .conv)."""
def __init__(
self,
channels: int,
out_channels: Optional[int] = None,
padding: int = 1,
stride: Union[int, Tuple[int, int, int]] = 2,
kernel_size: int = 3,
bias: bool = True,
disable_causal: bool = False,
):
super().__init__()
self.out_channels = out_channels or channels
self.conv = CausalConv3d(
channels,
self.out_channels,
kernel_size=kernel_size,
stride=stride,
padding=0,
disable_causal=disable_causal,
bias=bias,
)
def forward(self, hidden_states: torch.Tensor, scale: float = 1.0) -> torch.Tensor:
return self.conv(hidden_states)
class UpsampleCausal3D(nn.Module):
"""Causal 3D upsampling - matches official (has .conv when use_conv=True)."""
def __init__(
self,
channels: int,
out_channels: Optional[int] = None,
kernel_size: int = 3,
upsample_factor: Tuple[int, int, int] = (2, 2, 2),
disable_causal: bool = False,
bias: bool = True,
):
super().__init__()
self.out_channels = out_channels or channels
self.upsample_factor = upsample_factor
self.disable_causal = disable_causal
self.conv = CausalConv3d(
channels,
self.out_channels,
kernel_size=kernel_size,
stride=1,
disable_causal=disable_causal,
bias=bias,
)
def forward(
self,
hidden_states: torch.Tensor,
output_size: Optional[int] = None,
scale: float = 1.0,
) -> torch.Tensor:
B, C, T, H, W = hidden_states.shape
dtype = hidden_states.dtype
if dtype == torch.bfloat16:
hidden_states = hidden_states.to(torch.float32)
if not self.disable_causal and T > 1:
first_h, other_h = hidden_states.split((1, T - 1), dim=2)
other_h = F.interpolate(
other_h, scale_factor=self.upsample_factor, mode="nearest"
)
first_h = F.interpolate(
first_h.squeeze(2), scale_factor=self.upsample_factor[1:], mode="nearest"
).unsqueeze(2)
hidden_states = torch.cat((first_h, other_h), dim=2)
else:
hidden_states = F.interpolate(
hidden_states, scale_factor=self.upsample_factor, mode="nearest"
)
if dtype == torch.bfloat16:
hidden_states = hidden_states.to(dtype)
return self.conv(hidden_states)
class GameCraftVAEAttention(nn.Module):
"""Attention block matching official diffusers Attention structure (group_norm, to_q, to_k, to_v, to_out)."""
def __init__(
self,
in_channels: int,
heads: int,
dim_head: int,
eps: float = 1e-6,
norm_num_groups: Optional[int] = 32,
bias: bool = True,
):
super().__init__()
self.heads = heads
self.dim_head = dim_head
inner_dim = heads * dim_head
self.group_norm = nn.GroupNorm(
norm_num_groups or in_channels, in_channels, eps=eps
)
self.to_q = nn.Linear(in_channels, inner_dim, bias=bias)
self.to_k = nn.Linear(in_channels, inner_dim, bias=bias)
self.to_v = nn.Linear(in_channels, inner_dim, bias=bias)
self.to_out = nn.Sequential(nn.Linear(inner_dim, in_channels, bias=bias))
def forward(
self,
hidden_states: torch.Tensor,
attention_mask: Optional[torch.Tensor] = None,
) -> torch.Tensor:
residual = hidden_states
batch_size, seq_len, _ = hidden_states.shape
hidden_states = self.group_norm(
hidden_states.permute(0, 2, 1)
).permute(0, 2, 1)
q = self.to_q(hidden_states)
k = self.to_k(hidden_states)
v = self.to_v(hidden_states)
q = q.view(batch_size, seq_len, self.heads, self.dim_head).transpose(1, 2)
k = k.view(batch_size, seq_len, self.heads, self.dim_head).transpose(1, 2)
v = v.view(batch_size, seq_len, self.heads, self.dim_head).transpose(1, 2)
scale = self.dim_head**-0.5
attn = torch.matmul(q, k.transpose(-2, -1)) * scale
if attention_mask is not None:
attn = attn + attention_mask
# Official uses upcast_softmax=True: compute softmax in fp32 for numerical stability
attn = F.softmax(attn.float(), dim=-1).to(q.dtype)
hidden_states = torch.matmul(attn, v)
hidden_states = hidden_states.transpose(1, 2).reshape(batch_size, seq_len, -1)
hidden_states = self.to_out(hidden_states) + residual
return hidden_states
class ResnetBlockCausal3D(nn.Module):
"""ResNet block - matches official structure (conv1.conv, conv2.conv, norm1, norm2)."""
def __init__(
self,
in_channels: int,
out_channels: Optional[int] = None,
temb_channels: Optional[int] = None,
eps: float = 1e-6,
groups: int = 32,
dropout: float = 0.0,
non_linearity: str = "swish",
disable_causal: bool = False,
):
super().__init__()
out_channels = out_channels or in_channels
self.norm1 = nn.GroupNorm(groups, in_channels, eps=eps)
self.conv1 = CausalConv3d(in_channels, out_channels, 3, 1, disable_causal=disable_causal)
self.norm2 = nn.GroupNorm(groups, out_channels, eps=eps)
self.conv2 = CausalConv3d(out_channels, out_channels, 3, 1, disable_causal=disable_causal)
self.dropout = nn.Dropout(dropout)
self.conv_shortcut = (
CausalConv3d(in_channels, out_channels, 1, 1, disable_causal=disable_causal)
if in_channels != out_channels
else None
)
self.nonlinearity = getattr(F, non_linearity, F.silu)
def forward(
self,
x: torch.Tensor,
temb: Optional[torch.Tensor] = None,
scale: float = 1.0,
) -> torch.Tensor:
h = self.norm1(x)
h = self.nonlinearity(h)
h = self.conv1(h)
h = self.norm2(h)
h = self.nonlinearity(h)
h = self.dropout(h)
h = self.conv2(h)
if self.conv_shortcut is not None:
x = self.conv_shortcut(x)
return (x + h) / 1.0
class UNetMidBlockCausal3D(nn.Module):
"""Mid block with resnets and optional attention - matches official structure."""
def __init__(
self,
in_channels: int,
temb_channels: Optional[int] = None,
num_layers: int = 1,
resnet_eps: float = 1e-6,
resnet_act_fn: str = "swish",
resnet_groups: int = 32,
add_attention: bool = True,
attention_head_dim: int = 1,
disable_causal: bool = False,
causal_attention: bool = False,
):
super().__init__()
self.add_attention = add_attention
self.causal_attention = causal_attention
self.resnets = nn.ModuleList()
self.attentions = nn.ModuleList()
self.resnets.append(
ResnetBlockCausal3D(
in_channels=in_channels,
out_channels=in_channels,
temb_channels=temb_channels,
eps=resnet_eps,
groups=resnet_groups,
dropout=0.0,
non_linearity=resnet_act_fn,
disable_causal=disable_causal,
)
)
for _ in range(num_layers):
if add_attention:
self.attentions.append(
GameCraftVAEAttention(
in_channels=in_channels,
heads=in_channels // attention_head_dim,
dim_head=attention_head_dim,
eps=resnet_eps,
norm_num_groups=resnet_groups,
bias=True,
)
)
else:
self.attentions.append(None)
self.resnets.append(
ResnetBlockCausal3D(
in_channels=in_channels,
out_channels=in_channels,
temb_channels=temb_channels,
eps=resnet_eps,
groups=resnet_groups,
dropout=0.0,
non_linearity=resnet_act_fn,
disable_causal=disable_causal,
)
)
def forward(
self,
hidden_states: torch.Tensor,
temb: Optional[torch.Tensor] = None,
) -> torch.Tensor:
hidden_states = self.resnets[0](hidden_states, temb)
for attn, resnet in zip(self.attentions, self.resnets[1:]):
if attn is not None:
B, C, T, H, W = hidden_states.shape
hidden_states = rearrange(hidden_states, "b c f h w -> b (f h w) c")
if self.causal_attention:
mask = prepare_causal_attention_mask(
T, H * W, hidden_states.dtype, hidden_states.device, batch_size=B
)
else:
mask = None
hidden_states = attn(hidden_states, attention_mask=mask)
hidden_states = rearrange(
hidden_states, "b (f h w) c -> b c f h w", f=T, h=H, w=W
)
hidden_states = resnet(hidden_states, temb)
return hidden_states
class DownEncoderBlockCausal3D(nn.Module):
"""Encoder down block - matches official structure."""
def __init__(
self,
in_channels: int,
out_channels: int,
num_layers: int = 2,
resnet_eps: float = 1e-6,
resnet_act_fn: str = "swish",
resnet_groups: int = 32,
add_downsample: bool = True,
downsample_stride: Union[int, Tuple[int, int, int]] = 2,
downsample_padding: int = 0,
disable_causal: bool = False,
):
super().__init__()
self.resnets = nn.ModuleList()
for i in range(num_layers):
inc = in_channels if i == 0 else out_channels
self.resnets.append(
ResnetBlockCausal3D(
in_channels=inc,
out_channels=out_channels,
temb_channels=None,
eps=resnet_eps,
groups=resnet_groups,
dropout=0.0,
non_linearity=resnet_act_fn,
disable_causal=disable_causal,
)
)
self.downsamplers = None
if add_downsample:
self.downsamplers = nn.ModuleList([
DownsampleCausal3D(
out_channels,
out_channels=out_channels,
padding=downsample_padding,
stride=downsample_stride,
disable_causal=disable_causal,
)
])
def forward(
self, hidden_states: torch.Tensor, scale: float = 1.0
) -> torch.Tensor:
for resnet in self.resnets:
hidden_states = resnet(hidden_states, temb=None, scale=scale)
if self.downsamplers is not None:
for ds in self.downsamplers:
hidden_states = ds(hidden_states, scale)
return hidden_states
class UpDecoderBlockCausal3D(nn.Module):
"""Decoder up block - matches official structure."""
def __init__(
self,
in_channels: int,
out_channels: int,
num_layers: int = 3,
resnet_eps: float = 1e-6,
resnet_act_fn: str = "swish",
resnet_groups: int = 32,
add_upsample: bool = True,
upsample_scale_factor: Tuple[int, int, int] = (2, 2, 2),
temb_channels: Optional[int] = None,
disable_causal: bool = False,
):
super().__init__()
self.resnets = nn.ModuleList()
for i in range(num_layers):
inc = in_channels if i == 0 else out_channels
self.resnets.append(
ResnetBlockCausal3D(
in_channels=inc,
out_channels=out_channels,
temb_channels=temb_channels,
eps=resnet_eps,
groups=resnet_groups,
dropout=0.0,
non_linearity=resnet_act_fn,
disable_causal=disable_causal,
)
)
self.upsamplers = None
if add_upsample:
self.upsamplers = nn.ModuleList([
UpsampleCausal3D(
out_channels,
out_channels=out_channels,
upsample_factor=upsample_scale_factor,
disable_causal=disable_causal,
)
])
def forward(
self,
hidden_states: torch.Tensor,
temb: Optional[torch.Tensor] = None,
scale: float = 1.0,
) -> torch.Tensor:
for resnet in self.resnets:
hidden_states = resnet(hidden_states, temb=temb, scale=scale)
if self.upsamplers is not None:
for us in self.upsamplers:
hidden_states = us(hidden_states)
return hidden_states
@@ -1,2 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""HunyuanGameCraft pipeline implementations."""
@@ -1,101 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
HunyuanGameCraft video diffusion pipeline implementation.
This module implements the HunyuanGameCraft pipeline for camera/action-conditioned
video generation with the modular pipeline architecture.
"""
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
from fastvideo.pipelines.composed_pipeline_base import ComposedPipelineBase
from fastvideo.pipelines.stages import (
ConditioningStage,
DecodingStage,
InputValidationStage,
LatentPreparationStage,
TextEncodingStage,
TimestepPreparationStage,
)
from fastvideo.pipelines.stages.gamecraft_denoising import GameCraftDenoisingStage
logger = init_logger(__name__)
class HunyuanGameCraftPipeline(ComposedPipelineBase):
"""
Pipeline for HunyuanGameCraft video generation.
This pipeline supports:
- Text-to-video generation with camera/action conditioning
- Autoregressive generation with history frames
- 33-channel input (16 latent + 16 gt_latent + 1 mask)
- CameraNet for encoding Plücker coordinates
"""
_required_config_modules = [
"text_encoder",
"text_encoder_2",
"tokenizer",
"tokenizer_2",
"vae",
"transformer",
"scheduler",
]
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs):
"""Set up pipeline stages with proper dependency injection."""
self.add_stage(
stage_name="input_validation_stage",
stage=InputValidationStage(),
)
self.add_stage(
stage_name="prompt_encoding_stage_primary",
stage=TextEncodingStage(
text_encoders=[
self.get_module("text_encoder"),
self.get_module("text_encoder_2"),
],
tokenizers=[
self.get_module("tokenizer"),
self.get_module("tokenizer_2"),
],
),
)
self.add_stage(
stage_name="conditioning_stage",
stage=ConditioningStage(),
)
self.add_stage(
stage_name="timestep_preparation_stage",
stage=TimestepPreparationStage(
scheduler=self.get_module("scheduler")),
)
self.add_stage(
stage_name="latent_preparation_stage",
stage=LatentPreparationStage(
scheduler=self.get_module("scheduler"),
transformer=self.get_module("transformer"),
),
)
self.add_stage(
stage_name="denoising_stage",
stage=GameCraftDenoisingStage(
transformer=self.get_module("transformer"),
scheduler=self.get_module("scheduler"),
),
)
self.add_stage(
stage_name="decoding_stage",
stage=DecodingStage(vae=self.get_module("vae")),
)
EntryClass = HunyuanGameCraftPipeline
@@ -136,14 +136,6 @@ class ForwardBatch:
# Camera control inputs (HYWorld)
pose: str | None = None # Camera trajectory: pose string (e.g., 'w-31') or JSON file path
# Camera/action control inputs (GameCraft)
camera_states: torch.Tensor | None = None # Plücker coordinates [B, T, 6, H, W]
gt_latents: torch.Tensor | None = None # Ground truth latents for conditioning [B, 16, T, H, W]
conditioning_mask: torch.Tensor | None = None # Mask for conditioning [B, 1, T, H, W]
camera_trajectory: str | None = None # Camera trajectory file/identifier
action_list: list[
str] | None = None # List of actions (e.g., ['forward', 'left'])
action_speed_list: list[float] | None = None # Speed for each action
# Camera control inputs (LingBotWorld)
c2ws_plucker_emb: torch.Tensor | None = None # Plucker embedding: [B, C, F_lat, H_lat, W_lat]
@@ -175,13 +167,6 @@ class ForwardBatch:
eta: float = 0.0
sigmas: list[float] | None = None
# LTX-2 multi-modal CFG parameters
ltx2_cfg_scale_video: float = 1.0
ltx2_cfg_scale_audio: float = 1.0
ltx2_modality_scale_video: float = 1.0
ltx2_modality_scale_audio: float = 1.0
ltx2_rescale_scale: float = 0.0
n_tokens: int | None = None
# Other parameters that may be needed by specific schedulers
-5
View File
@@ -20,8 +20,6 @@ from fastvideo.pipelines.stages.image_encoding import (
ImageEncodingStage, MatrixGameImageEncodingStage, RefImageEncodingStage,
ImageVAEEncodingStage, VideoVAEEncodingStage, Hy15ImageEncodingStage,
HYWorldImageEncodingStage)
from fastvideo.pipelines.stages.gamecraft_image_encoding import (
GameCraftImageVAEEncodingStage)
from fastvideo.pipelines.stages.input_validation import InputValidationStage
from fastvideo.pipelines.stages.latent_preparation import (
Cosmos25LatentPreparationStage, CosmosLatentPreparationStage,
@@ -35,7 +33,6 @@ from fastvideo.pipelines.stages.ltx2_text_encoding import LTX2TextEncodingStage
from fastvideo.pipelines.stages.matrixgame_denoising import (
MatrixGameCausalDenoisingStage)
from fastvideo.pipelines.stages.hyworld_denoising import HYWorldDenoisingStage
from fastvideo.pipelines.stages.gamecraft_denoising import GameCraftDenoisingStage
from fastvideo.pipelines.stages.stepvideo_encoding import (
StepvideoPromptEncodingStage)
from fastvideo.pipelines.stages.text_encoding import (Cosmos25TextEncodingStage,
@@ -67,7 +64,6 @@ __all__ = [
"CausalDMDDenosingStage",
"MatrixGameCausalDenoisingStage",
"HYWorldDenoisingStage",
"GameCraftDenoisingStage",
"CosmosDenoisingStage",
"Cosmos25DenoisingStage",
"Cosmos25T2WDenoisingStage",
@@ -85,7 +81,6 @@ __all__ = [
"RefImageEncodingStage",
"ImageVAEEncodingStage",
"VideoVAEEncodingStage",
"GameCraftImageVAEEncodingStage",
"TextEncodingStage",
"Cosmos25TextEncodingStage",
"StepvideoPromptEncodingStage",
-9
View File
@@ -177,13 +177,6 @@ class DenoisingStage(PipelineStage):
},
)
camera_kwargs = self.prepare_extra_func_kwargs(
self.transformer.forward,
{
"camera_states": batch.camera_states,
},
)
# Prepare STA parameters
if st_attn_available and self.attn_backend == SlidingTileAttentionBackend:
self.prepare_sta_param(batch, fastvideo_args)
@@ -435,7 +428,6 @@ class DenoisingStage(PipelineStage):
**image_kwargs,
**pos_cond_kwargs,
**action_kwargs,
**camera_kwargs,
**timesteps_r_kwarg,
)
@@ -454,7 +446,6 @@ class DenoisingStage(PipelineStage):
**image_kwargs,
**neg_cond_kwargs,
**action_kwargs,
**camera_kwargs,
**timesteps_r_kwarg,
)
@@ -1,352 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
GameCraft denoising stage for camera/action-conditioned video generation.
This stage implements the denoising loop for HunyuanGameCraft, which generates
game-like videos with camera and action conditioning via:
1. CameraNet - Encodes Plücker coordinates into features added to image embeddings
2. Concatenated input - 33 channels (16 latent + 16 gt_latent + 1 mask)
3. Mask-based conditioning for autoregressive generation
"""
import torch
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.forward_context import set_forward_context
from fastvideo.logger import init_logger
from fastvideo.models.loader.component_loader import TransformerLoader
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
from fastvideo.pipelines.stages.denoising import DenoisingStage
from fastvideo.pipelines.stages.validators import StageValidators as V
from fastvideo.pipelines.stages.validators import VerificationResult
logger = init_logger(__name__)
class GameCraftDenoisingStage(DenoisingStage):
"""
Denoising stage for HunyuanGameCraft with camera/action conditioning.
This stage handles:
- Camera state encoding via CameraNet (Plücker coordinates)
- Concatenation of latents with gt_latents and mask (33 channels)
- Flow matching denoising with camera conditioning
- Support for autoregressive generation with history frames
"""
def __init__(
self,
transformer,
scheduler,
pipeline=None,
transformer_2=None,
vae=None,
) -> None:
super().__init__(transformer, scheduler, pipeline, transformer_2, vae)
def forward(
self,
batch: ForwardBatch,
fastvideo_args: FastVideoArgs,
) -> ForwardBatch:
"""
Run the denoising loop with camera/action conditioning.
Args:
batch: The current batch information. Must contain:
- latents: Noise latents [B, 16, T, H, W]
- camera_states: Plücker coordinates [B, T_video, 6, H_video, W_video]
- gt_latents (optional): Ground truth latents for conditioning [B, 16, T, H, W]
- conditioning_mask (optional): Mask for conditioning [B, 1, T, H, W]
fastvideo_args: The inference arguments.
Returns:
The batch with denoised latents.
"""
pipeline = self.pipeline() if self.pipeline else None
if not fastvideo_args.model_loaded["transformer"]:
loader = TransformerLoader()
self.transformer = loader.load(
fastvideo_args.model_paths["transformer"], fastvideo_args)
if pipeline:
pipeline.add_module("transformer", self.transformer)
fastvideo_args.model_loaded["transformer"] = True
# Extract GameCraft-specific parameters
camera_states = getattr(batch, "camera_states", None)
if camera_states is None:
camera_states = batch.extra.get("camera_states", None)
gt_latents = getattr(batch, "gt_latents", None)
if gt_latents is None:
gt_latents = batch.extra.get("gt_latents", None)
conditioning_mask = getattr(batch, "conditioning_mask", None)
if conditioning_mask is None:
conditioning_mask = batch.extra.get("conditioning_mask", None)
# Prepare extra step kwargs for scheduler
extra_step_kwargs = self.prepare_extra_func_kwargs(
self.scheduler.step,
{
"generator": batch.generator,
"eta": batch.eta,
},
)
# Setup precision and autocast settings
target_dtype = torch.bfloat16
autocast_enabled = (target_dtype != torch.float32
) and not fastvideo_args.disable_autocast
# Get timesteps
timesteps = batch.timesteps
if timesteps is None:
raise ValueError("Timesteps must be provided")
num_inference_steps = batch.num_inference_steps
num_warmup_steps = len(
timesteps) - num_inference_steps * self.scheduler.order
# Prepare image embeddings for I2V generation (if any)
image_embeds = batch.image_embeds
if len(image_embeds) > 0:
assert not torch.isnan(
image_embeds[0]).any(), "image_embeds contains nan"
image_embeds = [
image_embed.to(target_dtype) for image_embed in image_embeds
]
image_kwargs = self.prepare_extra_func_kwargs(
self.transformer.forward,
{"encoder_hidden_states_image": image_embeds},
)
pos_cond_kwargs = self.prepare_extra_func_kwargs(
self.transformer.forward,
{
"encoder_hidden_states_2": batch.clip_embedding_pos,
"encoder_attention_mask": batch.prompt_attention_mask,
},
)
neg_cond_kwargs = self.prepare_extra_func_kwargs(
self.transformer.forward,
{
"encoder_hidden_states_2": batch.clip_embedding_neg,
"encoder_attention_mask": batch.negative_attention_mask,
},
)
# Get latents and embeddings
latents = batch.latents
prompt_embeds = batch.prompt_embeds
assert not torch.isnan(
prompt_embeds[0]).any(), "prompt_embeds contains nan"
if batch.do_classifier_free_guidance:
neg_prompt_embeds = batch.negative_prompt_embeds
assert neg_prompt_embeds is not None
assert not torch.isnan(
neg_prompt_embeds[0]).any(), "neg_prompt_embeds contains nan"
# Prepare gt_latents and mask for concatenation
# If not provided, use zeros (for unconditional generation)
if gt_latents is None:
gt_latents = torch.zeros_like(latents)
else:
gt_latents = gt_latents.to(target_dtype)
if conditioning_mask is None:
# Default mask: all zeros (generate everything)
conditioning_mask = torch.zeros(
latents.shape[0],
1,
*latents.shape[2:],
device=latents.device,
dtype=target_dtype,
)
else:
conditioning_mask = conditioning_mask.to(target_dtype)
# Move camera states to device if provided
if camera_states is not None:
camera_states = camera_states.to(device=latents.device,
dtype=target_dtype)
# Debug logging
logger.debug("[GameCraft DEBUG] latents shape: %s, min/max: %.4f/%.4f",
latents.shape, latents.min(), latents.max())
logger.debug("[GameCraft DEBUG] camera_states: %s",
camera_states.shape if camera_states is not None else None)
logger.debug("[GameCraft DEBUG] prompt_embeds[0] shape: %s",
prompt_embeds[0].shape)
# Initialize lists for trajectory
trajectory_timesteps: list[torch.Tensor] = []
trajectory_latents: list[torch.Tensor] = []
# For I2V: extract clean reference latents to inject at every step.
# Official GameCraft replaces conditioned frames of the denoising latents
# with the clean reference at EVERY timestep (not just the first).
# This keeps the conditioned frames noise-free so the model has a strong
# reference signal throughout the denoising process.
#
# ref_latent_for_injection: [B, 16, 1, H, W] or None
ref_latent_for_injection = getattr(batch, "_ref_latent_for_injection",
None)
if (ref_latent_for_injection is None and gt_latents is not None
and conditioning_mask.sum() > 0
and gt_latents[:, :, 0].abs().sum() > 0):
# Extract the clean reference from gt_latents first frame
# gt_latents[:, :, 0] should have the VAE-encoded reference image
ref_latent_for_injection = gt_latents[:, :, 0:1].clone(
) # [B, 16, 1, H, W]
logger.info(
"[GameCraft I2V] Will inject ref latent at conditioned frames each step. "
"ref mean=%.4f",
ref_latent_for_injection.abs().mean())
# Run denoising loop
with self.progress_bar(total=num_inference_steps) as progress_bar:
for i, t in enumerate(timesteps):
# Skip if interrupted
if hasattr(self, "interrupt") and self.interrupt:
continue
current_model = self.transformer
current_guidance_scale = batch.guidance_scale
# I2V: replace conditioned frames with clean reference latent
# (matches official: latents[:,:,0,:,:] = last_latents[:,:,-1,:,:])
if ref_latent_for_injection is not None:
# conditioning_mask shape: [B, 1, T, H, W]
# Where mask == 1, replace latents with the clean ref
cond_frames = conditioning_mask[0, 0, :, 0, 0] > 0.5 # [T]
for t_idx in range(cond_frames.shape[0]):
if cond_frames[t_idx]:
latents[:, :,
t_idx, :, :] = ref_latent_for_injection[:, :,
0, :, :]
# Prepare model input: concatenate latents, gt_latents, and mask
# [B, 33, T, H, W] = [B, 16, T, H, W] + [B, 16, T, H, W] + [B, 1, T, H, W]
latent_model_input = torch.cat(
[latents.to(target_dtype), gt_latents, conditioning_mask],
dim=1,
)
assert not torch.isnan(
latent_model_input).any(), "latent_model_input contains nan"
t_expand = t.repeat(latent_model_input.shape[0])
latent_model_input = self.scheduler.scale_model_input(
latent_model_input, t)
# Official GameCraft does NOT use embedded guidance (guidance=None)
# It uses standard CFG with guidance_scale instead
guidance_expand = None
# Run transformer with camera conditioning
with torch.autocast(
device_type="cuda",
dtype=target_dtype,
enabled=autocast_enabled,
):
batch.is_cfg_negative = False
with set_forward_context(
current_timestep=i,
attn_metadata=None,
forward_batch=batch,
):
noise_pred = current_model(
latent_model_input,
prompt_embeds,
t_expand,
camera_states=camera_states,
guidance=guidance_expand,
**image_kwargs,
**pos_cond_kwargs,
)
# Debug: log first step output
if i == 0:
logger.info(
"[GameCraft DEBUG] Step 0 noise_pred: shape=%s, min/max=%.4f/%.4f",
noise_pred.shape, noise_pred.min(),
noise_pred.max())
# Classifier-free guidance
if batch.do_classifier_free_guidance:
batch.is_cfg_negative = True
with set_forward_context(
current_timestep=i,
attn_metadata=None,
forward_batch=batch,
):
noise_pred_uncond = current_model(
latent_model_input,
neg_prompt_embeds,
t_expand,
camera_states=camera_states,
guidance=guidance_expand,
**image_kwargs,
**neg_cond_kwargs,
)
noise_pred = noise_pred_uncond + current_guidance_scale * (
noise_pred - noise_pred_uncond)
# Compute the previous noisy sample
latents = self.scheduler.step(
noise_pred,
t,
latents,
**extra_step_kwargs,
return_dict=False,
)[0]
# Store trajectory if requested
if batch.return_trajectory_latents:
trajectory_timesteps.append(t.clone())
trajectory_latents.append(latents.clone())
# Update progress bar
if i == len(timesteps) - 1 or (
(i + 1) > num_warmup_steps and
(i + 1) % self.scheduler.order == 0):
progress_bar.update()
# Debug: log final latents
logger.info(
"[GameCraft DEBUG] Final latents: shape=%s, min/max=%.4f/%.4f",
latents.shape, latents.min(), latents.max())
# Store final latents and trajectory
batch.latents = latents
if batch.return_trajectory_latents:
batch.trajectory_timesteps = trajectory_timesteps
batch.trajectory_latents = torch.stack(trajectory_latents, dim=0)
return batch
def verify_input(self, batch: ForwardBatch,
fastvideo_args: FastVideoArgs) -> VerificationResult:
"""Verify that required inputs are present."""
result = VerificationResult()
result.add_check("timesteps", batch.timesteps,
[V.is_tensor, V.min_dims(1)])
result.add_check("latents", batch.latents,
[V.is_tensor, V.with_dims(5)])
result.add_check("prompt_embeds", batch.prompt_embeds, V.list_not_empty)
result.add_check("num_inference_steps", batch.num_inference_steps,
V.positive_int)
return result
def verify_output(self, batch: ForwardBatch,
fastvideo_args: FastVideoArgs) -> VerificationResult:
"""Verify that outputs are properly set."""
result = VerificationResult()
result.add_check("latents", batch.latents,
[V.is_tensor, V.with_dims(5)])
return result
@@ -1,183 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
GameCraft image-to-video encoding stage.
Encodes a reference image into gt_latents and conditioning_mask for
HunyuanGameCraft I2V generation. For T2V this stage is a no-op.
"""
import torch
from fastvideo.distributed import get_local_torch_device
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
from fastvideo.pipelines.stages.base import PipelineStage
from fastvideo.pipelines.stages.validators import VerificationResult
from fastvideo.utils import PRECISION_TO_TYPE
logger = init_logger(__name__)
class GameCraftImageVAEEncodingStage(PipelineStage):
"""
Stage for encoding a reference image into gt_latents and conditioning_mask
for HunyuanGameCraft image-to-video generation.
Official GameCraft I2V flow:
1. VAE-encode the reference image -> [B, 16, 1, H_lat, W_lat]
2. Scale by VAE scaling_factor (0.476986)
3. Repeat to all temporal frames
4. Zero out non-conditioned frames (first frame only for short videos,
first half for longer autoregressive generation)
5. Build a binary mask (1 = conditioned, 0 = generate)
6. Store gt_latents and conditioning_mask on the batch for the denoising stage
If no image is provided (T2V mode), this stage is a no-op; the denoising
stage already falls back to zero gt_latents and zero mask.
"""
def __init__(self, vae) -> None:
super().__init__()
self.vae = vae
@torch.no_grad()
def forward(
self,
batch: ForwardBatch,
fastvideo_args: FastVideoArgs,
) -> ForwardBatch:
"""Encode reference image for I2V, or skip for T2V."""
if batch.pil_image is None:
# T2V mode: nothing to do; denoising stage handles the fallback
return batch
device = get_local_torch_device()
image = batch.pil_image
# ------------------------------------------------------------------
# 1. Preprocess image to tensor [B, 3, 1, H, W] (values in [-1, 1])
# ------------------------------------------------------------------
from torchvision import transforms
target_height, target_width = batch.height, batch.width
if isinstance(image, torch.Tensor):
# Already a tensor (e.g. from InputValidationStage causal path)
if image.dim() == 5:
# [B, C, F, H, W] -> take first frame
ref_pixel = image[:, :, :1]
elif image.dim() == 4:
ref_pixel = image.unsqueeze(
2) # [B, C, H, W] -> [B, C, 1, H, W]
else:
raise ValueError(f"Unexpected image tensor dims: {image.dim()}")
ref_pixel = ref_pixel.to(device=device, dtype=torch.float32)
else:
# PIL Image – resize, center-crop, normalize to [-1, 1]
from PIL import Image as PILImage
if not isinstance(image, PILImage.Image):
import numpy as np
if isinstance(image, np.ndarray):
image = PILImage.fromarray(image)
original_w, original_h = image.size
scale = max(target_width / original_w, target_height / original_h)
resize_w = int(round(original_w * scale))
resize_h = int(round(original_h * scale))
ref_transform = transforms.Compose([
transforms.Resize(
(resize_h, resize_w),
interpolation=transforms.InterpolationMode.LANCZOS,
),
transforms.CenterCrop((target_height, target_width)),
transforms.ToTensor(),
transforms.Normalize([0.5], [0.5]),
])
ref_pixel = ref_transform(image) # [3, H, W]
ref_pixel = ref_pixel.unsqueeze(0).unsqueeze(2) # [1, 3, 1, H, W]
ref_pixel = ref_pixel.to(device=device, dtype=torch.float32)
# ------------------------------------------------------------------
# 2. VAE-encode
# ------------------------------------------------------------------
self.vae = self.vae.to(device)
vae_dtype = PRECISION_TO_TYPE[
fastvideo_args.pipeline_config.vae_precision]
vae_autocast_enabled = (
vae_dtype != torch.float32) and not fastvideo_args.disable_autocast
with torch.autocast(device_type="cuda",
dtype=vae_dtype,
enabled=vae_autocast_enabled):
if fastvideo_args.pipeline_config.vae_tiling:
self.vae.enable_tiling()
if not vae_autocast_enabled:
ref_pixel = ref_pixel.to(vae_dtype)
encoder_output = self.vae.encode(ref_pixel)
# Sample from the distribution (official uses .sample())
ref_latents = encoder_output.latent_dist.sample().to(
dtype=torch.float32)
# Scale by VAE scaling factor (0.476986 for GameCraft)
ref_latents.mul_(self.vae.config.scaling_factor)
# ref_latents: [B, 16, 1, H_lat, W_lat]
# ------------------------------------------------------------------
# 3. Build gt_latents + conditioning_mask
# ------------------------------------------------------------------
# Get latent temporal dimension from batch latents (set by LatentPreparationStage)
latent_frames = batch.latents.shape[
2] if batch.latents is not None else ((batch.num_frames - 1) // 4 +
1)
# Repeat to all frames
gt_latents = ref_latents.repeat(1, 1, latent_frames, 1, 1)
# [B, 16, T_lat, H_lat, W_lat]
# Mask construction following official GameCraft logic:
# - Short videos (latent_frames <= 10): first frame conditioned
# - Longer videos: first half conditioned (autoregressive)
mask = torch.ones(
gt_latents.shape[0],
1,
gt_latents.shape[2],
gt_latents.shape[3],
gt_latents.shape[4],
device=gt_latents.device,
dtype=gt_latents.dtype,
)
if latent_frames <= 10:
# I2V: only first frame 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
batch.gt_latents = gt_latents.to(device=device)
batch.conditioning_mask = mask.to(device=device)
# Offload
if fastvideo_args.vae_cpu_offload:
self.vae.to("cpu")
return batch
def verify_input(self, batch: ForwardBatch,
fastvideo_args: FastVideoArgs) -> VerificationResult:
result = VerificationResult()
# Stage is a no-op when pil_image is None, so nothing required
return result
def verify_output(self, batch: ForwardBatch,
fastvideo_args: FastVideoArgs) -> VerificationResult:
result = VerificationResult()
# gt_latents and conditioning_mask are only set for I2V
return result
+9 -64
View File
@@ -141,7 +141,6 @@ class LTX2DenoisingStage(PipelineStage):
video_shape)
else:
token_count = 1
timestep_template = torch.ones(
(latents.shape[0], token_count),
device=latents.device,
@@ -210,27 +209,12 @@ class LTX2DenoisingStage(PipelineStage):
device=latents.device,
dtype=torch.float32,
)
# Multi-modal CFG parameters (per-stream scales).
cfg_scale_video = batch.ltx2_cfg_scale_video
cfg_scale_audio = batch.ltx2_cfg_scale_audio
modality_scale_video = batch.ltx2_modality_scale_video
modality_scale_audio = batch.ltx2_modality_scale_audio
rescale_scale = batch.ltx2_rescale_scale
do_cfg = batch.do_classifier_free_guidance
logger.info(
"[LTX2] Denoising start: steps=%d dtype=%s "
"cfg_video=%.1f cfg_audio=%.1f mod_video=%.1f "
"mod_audio=%.1f rescale=%.2f "
"[LTX2] Denoising start: steps=%d dtype=%s guidance=%s "
"sigmas_shape=%s latents_shape=%s",
batch.num_inference_steps,
target_dtype,
cfg_scale_video,
cfg_scale_audio,
modality_scale_video,
modality_scale_audio,
rescale_scale,
batch.guidance_scale,
tuple(sigmas.shape),
tuple(latents.shape),
)
@@ -251,7 +235,6 @@ class LTX2DenoisingStage(PipelineStage):
attn_metadata=None,
forward_batch=batch,
):
# Pass 1: Full conditioning (text + cross-modal)
pos_outputs = self.transformer(
hidden_states=latents.to(target_dtype),
encoder_hidden_states=prompt_embeds,
@@ -267,8 +250,8 @@ class LTX2DenoisingStage(PipelineStage):
pos_denoised = pos_outputs
pos_audio = None
if do_cfg:
# Pass 2: Unconditioned text (negative prompt)
# Only run negative pass if CFG is enabled
if batch.do_classifier_free_guidance:
neg_outputs = self.transformer(
hidden_states=latents.to(target_dtype),
encoder_hidden_states=neg_prompt_embeds,
@@ -283,48 +266,11 @@ class LTX2DenoisingStage(PipelineStage):
else:
neg_denoised = neg_outputs
neg_audio = None
# Pass 3: Modality-isolated (skip cross-modal attn)
mod_outputs = self.transformer(
hidden_states=latents.to(target_dtype),
encoder_hidden_states=prompt_embeds,
encoder_attention_mask=prompt_mask,
timestep=timestep,
audio_hidden_states=audio_latents,
audio_encoder_hidden_states=audio_context_p,
audio_timestep=audio_timestep,
skip_cross_modal_attn=True,
)
if isinstance(mod_outputs, tuple):
mod_denoised, mod_audio = mod_outputs
else:
mod_denoised = mod_outputs
mod_audio = None
# Multi-modal guidance formula per stream.
vid = (pos_denoised + (cfg_scale_video - 1) *
(pos_denoised - neg_denoised) +
(modality_scale_video - 1) *
(pos_denoised - mod_denoised))
aud = None
if pos_audio is not None:
aud = (pos_audio + (cfg_scale_audio - 1) *
(pos_audio - neg_audio) +
(modality_scale_audio - 1) *
(pos_audio - mod_audio))
# Guidance rescaling (prevents saturation).
if rescale_scale > 0:
f_v = pos_denoised.std() / vid.std()
f_v = rescale_scale * f_v + (1 - rescale_scale)
vid = vid * f_v
if aud is not None:
f_a = pos_audio.std() / aud.std()
f_a = rescale_scale * f_a + (1 - rescale_scale)
aud = aud * f_a
pos_denoised = vid
pos_audio = aud
pos_denoised = pos_denoised + (batch.guidance_scale - 1) * (
pos_denoised - neg_denoised)
if pos_audio is not None and neg_audio is not None:
pos_audio = pos_audio + (batch.guidance_scale -
1) * (pos_audio - neg_audio)
sigma_value = sigma.to(torch.float32) if isinstance(
sigma, torch.Tensor) else torch.tensor(
@@ -335,7 +281,6 @@ class LTX2DenoisingStage(PipelineStage):
dt = sigma_next - sigma
velocity = ((latents.float() - pos_denoised.float()) /
sigma_value).to(latents.dtype)
latents = (latents.float() + velocity.float() * dt).to(
latents.dtype)
if pos_audio is not None and audio_latents is not None:
+3 -18
View File
@@ -18,7 +18,6 @@ from fastvideo.configs.pipelines.base import PipelineConfig
from fastvideo.configs.pipelines.cosmos import CosmosConfig
from fastvideo.configs.pipelines.cosmos2_5 import Cosmos25Config
from fastvideo.configs.pipelines.hunyuan import FastHunyuanConfig, HunyuanConfig
from fastvideo.configs.pipelines.hunyuangamecraft import HunyuanGameCraftPipelineConfig
from fastvideo.configs.pipelines.hunyuan15 import (
Hunyuan15T2V480PConfig, Hunyuan15I2V480PStepDistilledConfig,
Hunyuan15T2V720PConfig, Hunyuan15I2V720PConfig, Hunyuan15SR1080PConfig)
@@ -60,7 +59,6 @@ from fastvideo.configs.sample.hunyuan15 import (
Hunyuan15_720P_SamplingParam, Hunyuan15_720P_Distilled_I2V_SamplingParam,
Hunyuan15_SR_1080P_SamplingParam)
from fastvideo.configs.sample.hyworld import HYWorld_SamplingParam
from fastvideo.configs.sample.hunyuangamecraft import HunyuanGameCraftSamplingParam
from fastvideo.configs.sample.lingbotworld import LingBotWorld_SamplingParam
from fastvideo.configs.sample.ltx2 import (LTX2BaseSamplingParam,
LTX2DistilledSamplingParam)
@@ -320,18 +318,14 @@ def _register_configs() -> None:
],
)
# Hunyuan (excludes gamecraft, hyworld, and versioned models)
# Hunyuan
register_configs(
sampling_param_cls=HunyuanSamplingParam,
pipeline_config_cls=HunyuanConfig,
hf_model_paths=[
"hunyuanvideo-community/HunyuanVideo",
],
model_detectors=[
lambda path: "hunyuan" in path.lower()
and "gamecraft" not in path.lower() and "hyworld" not in path.lower(
) and "1.5" not in path.lower() and "1-5" not in path.lower()
],
model_detectors=[lambda path: "hunyuan" in path.lower()],
)
register_configs(
sampling_param_cls=FastHunyuanSamplingParam,
@@ -351,15 +345,6 @@ def _register_configs() -> None:
model_detectors=[lambda path: "hyworld" in path.lower()],
)
# HunyuanGameCraft
register_configs(
sampling_param_cls=HunyuanGameCraftSamplingParam,
pipeline_config_cls=HunyuanGameCraftPipelineConfig,
hf_model_paths=[
"FastVideo/HunyuanGameCraft-Diffusers",
],
model_detectors=[lambda path: "gamecraft" in path.lower()],
)
# LingBotWorld
register_configs(
sampling_param_cls=LingBotWorld_SamplingParam,
@@ -679,4 +664,4 @@ __all__ = [
"get_pipeline_config_cls_from_name",
"get_sampling_param_cls_for_name",
"get_pipeline_config_classes",
]
]
@@ -19,10 +19,6 @@ if "A40" in device_name:
device_reference_folder = "A40" + device_reference_folder_suffix
elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
elif "H100" in device_name:
device_reference_folder = "H100" + device_reference_folder_suffix
elif "H200" in device_name:
device_reference_folder = "H200" + device_reference_folder_suffix
else:
# device_reference_folder = "L40S" + device_reference_folder_suffix
logger.warning(f"Unsupported device for ssim tests: {device_name}")
@@ -1,355 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
SSIM regression test for HunyuanGameCraft (T2V and I2V).
Generates a video with deterministic seed and camera trajectory,
then compares against a device-specific reference video via MS-SSIM.
Reference videos must be pre-generated and stored under:
<device>_reference_videos/HunyuanGameCraft/<ATTENTION_BACKEND>/
To create initial reference videos, run this test once and copy the
generated videos into the appropriate reference folder.
"""
import os
import torch
import pytest
from fastvideo import VideoGenerator
from fastvideo.logger import init_logger
from fastvideo.tests.utils import (
compute_video_ssim_torchvision,
write_ssim_results,
)
from fastvideo.worker.multiproc_executor import MultiprocExecutor
logger = init_logger(__name__)
# ---------------------------------------------------------------------------
# Device-dependent reference folder
# ---------------------------------------------------------------------------
device_name = torch.cuda.get_device_name()
device_reference_folder_suffix = "_reference_videos"
if "A40" in device_name:
device_reference_folder = "A40" + device_reference_folder_suffix
elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
elif "A100" in device_name:
device_reference_folder = "A100" + device_reference_folder_suffix
elif "H100" in device_name:
device_reference_folder = "H100" + device_reference_folder_suffix
else:
device_reference_folder = "Unknown" + device_reference_folder_suffix
logger.warning(f"Unsupported device for ssim tests: {device_name}")
# ---------------------------------------------------------------------------
# Helpers – camera trajectory (self-contained, no official repo dependency)
# ---------------------------------------------------------------------------
from fastvideo.models.camera import create_camera_trajectory as _create_camera_trajectory
def _shutdown_executor(generator: VideoGenerator | None) -> None:
if generator is None:
return
if isinstance(generator.executor, MultiprocExecutor):
generator.executor.shutdown()
# ---------------------------------------------------------------------------
# Model parameters
# ---------------------------------------------------------------------------
# Same as basic_gamecraft.py: default HF path; set GAMECRAFT_MODEL_PATH for local weights.
_GAMECRAFT_MODEL_PATH = os.environ.get(
"GAMECRAFT_MODEL_PATH",
"FastVideo/HunyuanGameCraft-Diffusers",
)
GAMECRAFT_T2V_PARAMS = {
"num_gpus": 1,
"model_path": _GAMECRAFT_MODEL_PATH,
"height": 480,
"width": 832,
"num_frames": 33,
"num_inference_steps": 20,
"guidance_scale": 6.0,
"seed": 1024,
"action": "forward",
"action_speed": 0.2,
"negative_prompt": "",
}
GAMECRAFT_I2V_PARAMS = {
**GAMECRAFT_T2V_PARAMS,
"image_path": (
"https://huggingface.co/datasets/huggingface/documentation-images/"
"resolve/main/diffusers/astronaut.jpg"
),
}
MODEL_TO_PARAMS = {
"HunyuanGameCraft-T2V": GAMECRAFT_T2V_PARAMS,
}
I2V_MODEL_TO_PARAMS = {
"HunyuanGameCraft-I2V": GAMECRAFT_I2V_PARAMS,
}
TEST_PROMPTS = [
"A majestic ancient temple stands under a clear blue sky, its grandeur highlighted by towering Doric columns and intricate architectural details.",
]
I2V_TEST_PROMPTS = [
"An astronaut hatching from an egg, on the surface of the moon, the darkness and depth of space realised in the background.",
]
# ---------------------------------------------------------------------------
# T2V SSIM test
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("prompt", TEST_PROMPTS)
@pytest.mark.parametrize("ATTENTION_BACKEND", ["FLASH_ATTN"])
@pytest.mark.parametrize("model_id", list(MODEL_TO_PARAMS.keys()))
def test_gamecraft_t2v_similarity(prompt, ATTENTION_BACKEND, model_id):
"""Generate a T2V video with GameCraft and compare to reference via SSIM."""
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
script_dir = os.path.dirname(os.path.abspath(__file__))
base_output_dir = os.path.join(script_dir, "generated_videos", model_id)
output_dir = os.path.join(base_output_dir, ATTENTION_BACKEND)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
BASE_PARAMS = MODEL_TO_PARAMS[model_id]
num_inference_steps = BASE_PARAMS["num_inference_steps"]
# Build camera trajectory
camera_states = _create_camera_trajectory(
action=BASE_PARAMS["action"],
height=BASE_PARAMS["height"],
width=BASE_PARAMS["width"],
num_frames=BASE_PARAMS["num_frames"],
action_speed=BASE_PARAMS["action_speed"],
dtype=torch.bfloat16,
)
init_kwargs = {
"num_gpus": BASE_PARAMS["num_gpus"],
"use_fsdp_inference": True,
"dit_cpu_offload": True,
"vae_cpu_offload": True,
"text_encoder_cpu_offload": True,
"pin_cpu_memory": True,
}
generation_kwargs = {
"num_inference_steps": num_inference_steps,
"output_path": output_dir,
"height": BASE_PARAMS["height"],
"width": BASE_PARAMS["width"],
"num_frames": BASE_PARAMS["num_frames"],
"guidance_scale": BASE_PARAMS["guidance_scale"],
"seed": BASE_PARAMS["seed"],
"fps": 24,
"camera_states": camera_states,
"negative_prompt": BASE_PARAMS.get("negative_prompt", ""),
"save_video": True,
}
generator: VideoGenerator | None = None
try:
generator = VideoGenerator.from_pretrained(
model_path=BASE_PARAMS["model_path"], **init_kwargs
)
generator.generate_video(prompt, **generation_kwargs)
finally:
_shutdown_executor(generator)
assert os.path.exists(output_dir), (
f"Output video was not generated at {output_dir}"
)
# Compare to reference
reference_folder = os.path.join(
script_dir, device_reference_folder, model_id, ATTENTION_BACKEND
)
if not os.path.exists(reference_folder):
logger.error("Reference folder missing")
raise FileNotFoundError(
f"Reference video folder does not exist: {reference_folder}"
)
reference_video_name = None
for filename in os.listdir(reference_folder):
if filename.endswith(".mp4") and prompt[:100].strip() in filename:
reference_video_name = filename
break
if not reference_video_name:
logger.error(
f"Reference video not found for prompt: {prompt} "
f"with backend: {ATTENTION_BACKEND}"
)
raise FileNotFoundError("Reference video missing")
reference_video_path = os.path.join(reference_folder, reference_video_name)
generated_video_path = os.path.join(output_dir, output_video_name)
logger.info(
f"Computing SSIM between {reference_video_path} and {generated_video_path}"
)
ssim_values = compute_video_ssim_torchvision(
reference_video_path, generated_video_path, use_ms_ssim=True
)
mean_ssim = ssim_values[0]
logger.info(f"SSIM mean value: {mean_ssim}")
logger.info(f"Writing SSIM results to directory: {output_dir}")
success = write_ssim_results(
output_dir,
ssim_values,
reference_video_path,
generated_video_path,
num_inference_steps,
prompt,
)
if not success:
logger.error("Failed to write SSIM results to file")
min_acceptable_ssim = 0.93
assert mean_ssim >= min_acceptable_ssim, (
f"SSIM value {mean_ssim} is below threshold {min_acceptable_ssim} "
f"for {model_id} with backend {ATTENTION_BACKEND}"
)
# ---------------------------------------------------------------------------
# I2V SSIM test
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("prompt", I2V_TEST_PROMPTS)
@pytest.mark.parametrize("ATTENTION_BACKEND", ["FLASH_ATTN"])
@pytest.mark.parametrize("model_id", list(I2V_MODEL_TO_PARAMS.keys()))
def test_gamecraft_i2v_similarity(prompt, ATTENTION_BACKEND, model_id):
"""Generate an I2V video with GameCraft and compare to reference via SSIM."""
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
script_dir = os.path.dirname(os.path.abspath(__file__))
base_output_dir = os.path.join(script_dir, "generated_videos", model_id)
output_dir = os.path.join(base_output_dir, ATTENTION_BACKEND)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
BASE_PARAMS = I2V_MODEL_TO_PARAMS[model_id]
num_inference_steps = BASE_PARAMS["num_inference_steps"]
# Build camera trajectory
camera_states = _create_camera_trajectory(
action=BASE_PARAMS["action"],
height=BASE_PARAMS["height"],
width=BASE_PARAMS["width"],
num_frames=BASE_PARAMS["num_frames"],
action_speed=BASE_PARAMS["action_speed"],
dtype=torch.bfloat16,
)
init_kwargs = {
"num_gpus": BASE_PARAMS["num_gpus"],
"use_fsdp_inference": True,
"dit_cpu_offload": True,
"vae_cpu_offload": True,
"text_encoder_cpu_offload": True,
"pin_cpu_memory": True,
}
generation_kwargs = {
"num_inference_steps": num_inference_steps,
"output_path": output_dir,
"image_path": BASE_PARAMS["image_path"],
"height": BASE_PARAMS["height"],
"width": BASE_PARAMS["width"],
"num_frames": BASE_PARAMS["num_frames"],
"guidance_scale": BASE_PARAMS["guidance_scale"],
"seed": BASE_PARAMS["seed"],
"fps": 24,
"camera_states": camera_states,
"negative_prompt": BASE_PARAMS.get("negative_prompt", ""),
"save_video": True,
}
generator: VideoGenerator | None = None
try:
generator = VideoGenerator.from_pretrained(
model_path=BASE_PARAMS["model_path"], **init_kwargs
)
generator.generate_video(prompt, **generation_kwargs)
finally:
_shutdown_executor(generator)
assert os.path.exists(output_dir), (
f"Output video was not generated at {output_dir}"
)
# Compare to reference
reference_folder = os.path.join(
script_dir, device_reference_folder, model_id, ATTENTION_BACKEND
)
if not os.path.exists(reference_folder):
logger.error("Reference folder missing")
raise FileNotFoundError(
f"Reference video folder does not exist: {reference_folder}"
)
reference_video_name = None
for filename in os.listdir(reference_folder):
if filename.endswith(".mp4") and prompt[:100].strip() in filename:
reference_video_name = filename
break
if not reference_video_name:
logger.error(
f"Reference video not found for prompt: {prompt} "
f"with backend: {ATTENTION_BACKEND}"
)
raise FileNotFoundError("Reference video missing")
reference_video_path = os.path.join(reference_folder, reference_video_name)
generated_video_path = os.path.join(output_dir, output_video_name)
logger.info(
f"Computing SSIM between {reference_video_path} and {generated_video_path}"
)
ssim_values = compute_video_ssim_torchvision(
reference_video_path, generated_video_path, use_ms_ssim=True
)
mean_ssim = ssim_values[0]
logger.info(f"SSIM mean value: {mean_ssim}")
logger.info(f"Writing SSIM results to directory: {output_dir}")
success = write_ssim_results(
output_dir,
ssim_values,
reference_video_path,
generated_video_path,
num_inference_steps,
prompt,
)
if not success:
logger.error("Failed to write SSIM results to file")
min_acceptable_ssim = 0.93
assert mean_ssim >= min_acceptable_ssim, (
f"SSIM value {mean_ssim} is below threshold {min_acceptable_ssim} "
f"for {model_id} with backend {ATTENTION_BACKEND}"
)
@@ -41,10 +41,6 @@ if "A40" in device_name:
device_reference_folder = "A40" + device_reference_folder_suffix
elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
elif "H100" in device_name:
device_reference_folder = "H100" + device_reference_folder_suffix
elif "H200" in device_name:
device_reference_folder = "H200" + device_reference_folder_suffix
else:
# device_reference_folder = "L40S" + device_reference_folder_suffix
logger.warning(f"Unsupported device for ssim tests: {device_name}")
@@ -47,8 +47,6 @@ elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
elif "H100" in device_name:
device_reference_folder = "H100" + device_reference_folder_suffix
elif "H200" in device_name:
device_reference_folder = "H200" + device_reference_folder_suffix
else:
device_reference_folder = None
logger.warning("Unsupported device for ssim tests: %s", device_name)
@@ -35,8 +35,6 @@ elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
elif "H100" in device_name:
device_reference_folder = "H100" + device_reference_folder_suffix
elif "H200" in device_name:
device_reference_folder = "H200" + device_reference_folder_suffix
else:
logger.warning(f"Unsupported device for ssim tests: {device_name}")
@@ -22,10 +22,6 @@ if "A40" in device_name:
device_reference_folder = "A40" + device_reference_folder_suffix
elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
elif "H100" in device_name:
device_reference_folder = "H100" + device_reference_folder_suffix
elif "H200" in device_name:
device_reference_folder = "H200" + device_reference_folder_suffix
else:
# device_reference_folder = "L40S" + device_reference_folder_suffix
logger.warning(f"Unsupported device for ssim tests: {device_name}")
@@ -22,8 +22,6 @@ def _device_reference_folder() -> str:
return "L40S" + suffix
if "H100" in device_name:
return "H100" + suffix
if "H200" in device_name:
return "H200" + suffix
if "RTX 4090" in device_name or "4090" in device_name:
return "RTX4090" + suffix
@@ -121,9 +121,9 @@ def test_distributed_training():
wandb_summary = json.load(open(summary_file))
fields_and_thresholds = {
'avg_step_time': 15,
'avg_step_time': 5,
'grad_norm': 0.5,
'step_time': 15,
'step_time': 5,
'train_loss': 0.04
}
@@ -136,9 +136,9 @@ def test_distributed_training():
wandb_summary = json.load(open(summary_file))
fields_and_thresholds = {
'avg_step_time': 15.0,
'avg_step_time': 6.0,
'grad_norm': 0.3,
'step_time': 15.0,
'step_time': 6.0,
'train_loss': 0.0025
}
@@ -1,141 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Regression test for HunyuanGameCraft transformer.
Tests that the FastVideo HunyuanGameCraft implementation produces consistent outputs.
"""
import os
import pytest
import torch
from fastvideo.configs.models.dits.hunyuangamecraft import HunyuanGameCraftConfig
from fastvideo.configs.pipelines import PipelineConfig
from fastvideo.distributed.parallel_state import (
get_sp_parallel_rank,
get_sp_world_size,
)
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.forward_context import set_forward_context
from fastvideo.logger import init_logger
from fastvideo.models.loader.component_loader import TransformerLoader
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
logger = init_logger(__name__)
os.environ["MASTER_ADDR"] = "localhost"
os.environ["MASTER_PORT"] = "29507"
os.environ.setdefault("TORCHDYNAMO_DISABLE", "1")
os.environ.setdefault("DISABLE_SP", "1")
# Path to converted weights (local path, not HuggingFace)
TRANSFORMER_PATH = "official_weights/hunyuan-gamecraft/transformer"
# Reference latent computed from FastVideo HunyuanGameCraft model with:
# - seed=42, batch=1, frames=9, H=44, W=80, text_seq=32
# - 33 camera frames at 352x640 resolution
# - timestep=500
REFERENCE_LATENT = 42351.12903189659
@pytest.mark.usefixtures("distributed_setup")
def test_hunyuangamecraft_transformer():
"""Test HunyuanGameCraft transformer regression."""
if not os.path.exists(TRANSFORMER_PATH):
pytest.skip(f"Weights not found at {TRANSFORMER_PATH}")
sp_rank = get_sp_parallel_rank()
sp_world_size = get_sp_world_size()
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
precision = torch.bfloat16
precision_str = "bf16"
args = FastVideoArgs(
model_path=TRANSFORMER_PATH,
dit_cpu_offload=False,
use_fsdp_inference=False,
pipeline_config=PipelineConfig(
dit_config=HunyuanGameCraftConfig(), dit_precision=precision_str
),
)
args.device = device
loader = TransformerLoader()
model = loader.load(TRANSFORMER_PATH, args).to(device, dtype=precision)
model.eval()
total_params = sum(p.numel() for p in model.parameters())
weight_sum = sum(p.to(torch.float64).sum().item() for p in model.parameters())
weight_mean = weight_sum / total_params
logger.info("Total parameters: %s", total_params)
logger.info("Weight sum: %s", weight_sum)
logger.info("Weight mean: %s", weight_mean)
torch.manual_seed(42)
batch_size = 1
latent_frames = 9 # GameCraft uses 9 latent frames
latent_height = 44
latent_width = 80
text_seq_len = 32
# Input latents [B, 33, T, H, W] - 16 latent + 16 gt_latent + 1 mask
hidden_states = torch.randn(
batch_size, 33, latent_frames, latent_height, latent_width,
device=device, dtype=precision
)
if sp_world_size > 1:
chunk_per_rank = hidden_states.shape[2] // sp_world_size
hidden_states = hidden_states[:, :, sp_rank * chunk_per_rank:(sp_rank + 1) * chunk_per_rank]
# Text embeddings (LLaMA)
text_states = torch.randn(batch_size, text_seq_len, 4096, device=device, dtype=precision)
# CLIP pooled embeddings
text_states_2 = torch.randn(batch_size, 768, device=device, dtype=precision)
# Text mask
text_mask = torch.ones(batch_size, text_seq_len, device=device, dtype=torch.long)
# Timestep
timestep = torch.tensor([500], device=device, dtype=precision)
# Camera states [B, num_frames, 6, H, W] - 33 video frames at full resolution
# For 9 latent frames, we use 33 camera frames (matching official model)
camera_states = torch.randn(
batch_size, 33, 6, 352, 640,
device=device, dtype=precision
)
encoder_hidden_states = [text_states, text_states_2]
forward_batch = ForwardBatch(data_type="video", enable_teacache=False)
with torch.no_grad():
with torch.amp.autocast('cuda', dtype=precision):
with set_forward_context(current_timestep=0, attn_metadata=None, forward_batch=forward_batch):
output = model(
hidden_states,
encoder_hidden_states,
timestep,
camera_states=camera_states,
encoder_attention_mask=[text_mask],
)
latent = output.double().sum().item()
logger.info(f"Output shape: {output.shape}")
logger.info(f"Current latent: {latent}")
if REFERENCE_LATENT is not None:
diff = abs(REFERENCE_LATENT - latent)
relative_diff = diff / abs(REFERENCE_LATENT)
logger.info(f"Reference latent: {REFERENCE_LATENT}")
logger.info(f"Absolute diff: {diff}, Relative diff: {relative_diff * 100:.4f}%")
assert relative_diff < 0.005, \
f"Output latents differ significantly: relative diff = {relative_diff * 100:.4f}% (max allowed: 0.5%)"
else:
logger.info(f"No reference latent set. To enable regression testing, set REFERENCE_LATENT = {latent}")
-2
View File
@@ -19,7 +19,6 @@ from einops import rearrange
from torch.utils.data import DataLoader
from torchdata.stateful_dataloader import StatefulDataLoader
from tqdm.auto import tqdm
from diffusers import FlowMatchEulerDiscreteScheduler
import fastvideo.envs as envs
try:
@@ -606,7 +605,6 @@ class TrainingPipeline(LoRAPipeline, ABC):
device="cpu").manual_seed(self.seed + self.global_rank)
logger.info("Initialized random seeds with seed: %s",
self.seed + self.global_rank)
self.noise_scheduler = FlowMatchEulerDiscreteScheduler()
if self.training_args.resume_from_checkpoint:
self._resume_from_checkpoint()
+1 -1
View File
@@ -66,7 +66,7 @@ dependencies = [
"remote-pdb",
# Kernel & Packaging
"fastvideo-kernel==0.2.6",
"fastvideo-kernel==0.2.5",
"wheel",
# Training Dependencies
+1 -1
View File
@@ -65,7 +65,7 @@ dependencies = [
"remote-pdb",
# Kernel & Packaging
"fastvideo-kernel==0.2.6",
"fastvideo-kernel==0.2.5",
"wheel",
# Training Dependencies
@@ -1,290 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Convert the full Hunyuan-GameCraft-1.0 model to FastVideo diffusers format.
This script converts:
1. Transformer (DiT) weights from DeepSpeed format
2. VAE weights
3. Text encoders (LLaVA-LLaMA-3-8B and CLIP)
4. Creates model_index.json and scheduler config
Usage:
python scripts/checkpoint_conversion/convert_gamecraft_full.py \
--input-dir Hunyuan-GameCraft-1.0/weights \
--output-dir official_weights/hunyuan-gamecraft-diffusers
# Or specify individual paths:
python scripts/checkpoint_conversion/convert_gamecraft_full.py \
--transformer Hunyuan-GameCraft-1.0/weights/gamecraft_models/mp_rank_00_model_states.pt \
--vae Hunyuan-GameCraft-1.0/weights/stdmodels/vae_3d/hyvae/checkpoint-step-270000.ckpt \
--text-encoder Hunyuan-GameCraft-1.0/weights/stdmodels/llava-llama-3-8b-v1_1-transformers \
--text-encoder-2 Hunyuan-GameCraft-1.0/weights/stdmodels/openai_clip-vit-large-patch14 \
--output-dir official_weights/hunyuan-gamecraft-diffusers
"""
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
import torch
from safetensors.torch import save_file
# Import conversion functions
from convert_gamecraft_weights import convert_weights as convert_transformer
from convert_gamecraft_vae import convert_gamecraft_vae
def create_model_index(output_dir: Path) -> None:
"""Create model_index.json for the pipeline."""
model_index = {
"_class_name": "HunyuanGameCraftPipeline",
"_diffusers_version": "0.30.0",
"scheduler": [
"diffusers",
"FlowMatchEulerDiscreteScheduler"
],
"text_encoder": [
"transformers",
"LlamaModel"
],
"text_encoder_2": [
"transformers",
"CLIPTextModel"
],
"tokenizer": [
"transformers",
"AutoTokenizer"
],
"tokenizer_2": [
"transformers",
"CLIPTokenizer"
],
"transformer": [
"fastvideo",
"HunyuanGameCraftTransformer3DModel"
],
"vae": [
"fastvideo",
"AutoencoderKLCausal3D"
]
}
with open(output_dir / "model_index.json", "w") as f:
json.dump(model_index, f, indent=2)
print(f"Created model_index.json")
def create_scheduler_config(output_dir: Path) -> None:
"""Create scheduler config for FlowMatchEulerDiscreteScheduler."""
scheduler_dir = output_dir / "scheduler"
scheduler_dir.mkdir(parents=True, exist_ok=True)
scheduler_config = {
"_class_name": "FlowMatchEulerDiscreteScheduler",
"_diffusers_version": "0.30.0",
"base_image_seq_len": 256,
"base_shift": 0.5,
"invert_sigmas": False,
"max_shift": 1.15,
"num_train_timesteps": 1000,
"shift": 7.0,
"use_dynamic_shifting": True
}
with open(scheduler_dir / "scheduler_config.json", "w") as f:
json.dump(scheduler_config, f, indent=2)
print(f"Created scheduler config")
def copy_text_encoder(src_dir: Path, dst_dir: Path, name: str) -> None:
"""Copy text encoder files to output directory."""
if not src_dir.exists():
print(f"Warning: {name} not found at {src_dir}")
return
dst_dir.mkdir(parents=True, exist_ok=True)
# Copy all files
for src_file in src_dir.iterdir():
if src_file.is_file():
shutil.copy2(src_file, dst_dir / src_file.name)
print(f"Copied {name} to {dst_dir}")
def copy_tokenizer(src_dir: Path, dst_dir: Path, name: str) -> None:
"""Copy tokenizer files to output directory."""
if not src_dir.exists():
print(f"Warning: {name} not found at {src_dir}")
return
dst_dir.mkdir(parents=True, exist_ok=True)
# Copy tokenizer-related files
tokenizer_files = [
"tokenizer.json",
"tokenizer_config.json",
"special_tokens_map.json",
"vocab.json",
"merges.txt",
"added_tokens.json",
]
for filename in tokenizer_files:
src_file = src_dir / filename
if src_file.exists():
shutil.copy2(src_file, dst_dir / filename)
print(f"Copied {name} to {dst_dir}")
def convert_full_model(
transformer_path: Path | None = None,
vae_path: Path | None = None,
text_encoder_path: Path | None = None,
text_encoder_2_path: Path | None = None,
input_dir: Path | None = None,
output_dir: Path = Path("official_weights/hunyuan-gamecraft-diffusers"),
verbose: bool = False,
) -> None:
"""Convert all components of GameCraft to diffusers format."""
# Resolve paths from input_dir if individual paths not provided
if input_dir is not None:
if transformer_path is None:
transformer_path = input_dir / "gamecraft_models" / "mp_rank_00_model_states.pt"
if vae_path is None:
vae_path = input_dir / "stdmodels" / "vae_3d" / "hyvae" / "checkpoint-step-270000.ckpt"
if not vae_path.exists():
vae_path = input_dir / "stdmodels" / "vae_3d" / "hyvae" / "pytorch_model.pt"
if text_encoder_path is None:
text_encoder_path = input_dir / "stdmodels" / "llava-llama-3-8b-v1_1-transformers"
if text_encoder_2_path is None:
text_encoder_2_path = input_dir / "stdmodels" / "openai_clip-vit-large-patch14"
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Converting GameCraft to diffusers format at {output_dir}")
print("=" * 60)
# 1. Convert transformer
if transformer_path and transformer_path.exists():
print(f"\n[1/5] Converting transformer from {transformer_path}")
convert_transformer(
input_path=transformer_path,
output_dir=output_dir,
save_config=True,
verbose=verbose,
)
else:
print(f"\n[1/5] Skipping transformer (not found: {transformer_path})")
# 2. Convert VAE
if vae_path and vae_path.exists():
print(f"\n[2/5] Converting VAE from {vae_path}")
convert_gamecraft_vae(
input_path=vae_path,
output_dir=output_dir / "vae",
copy_config=True,
)
else:
print(f"\n[2/5] Skipping VAE (not found: {vae_path})")
# 3. Copy text encoder (LLaMA)
if text_encoder_path and text_encoder_path.exists():
print(f"\n[3/5] Copying text encoder from {text_encoder_path}")
copy_text_encoder(text_encoder_path, output_dir / "text_encoder", "text_encoder")
copy_tokenizer(text_encoder_path, output_dir / "tokenizer", "tokenizer")
else:
print(f"\n[3/5] Skipping text_encoder (not found: {text_encoder_path})")
# 4. Copy text encoder 2 (CLIP)
if text_encoder_2_path and text_encoder_2_path.exists():
print(f"\n[4/5] Copying text encoder 2 from {text_encoder_2_path}")
copy_text_encoder(text_encoder_2_path, output_dir / "text_encoder_2", "text_encoder_2")
copy_tokenizer(text_encoder_2_path, output_dir / "tokenizer_2", "tokenizer_2")
else:
print(f"\n[4/5] Skipping text_encoder_2 (not found: {text_encoder_2_path})")
# 5. Create model_index.json and scheduler
print(f"\n[5/5] Creating model_index.json and scheduler config")
create_model_index(output_dir)
create_scheduler_config(output_dir)
print("\n" + "=" * 60)
print(f"Conversion complete! Output: {output_dir}")
print("\nDirectory structure:")
for item in sorted(output_dir.rglob("*")):
if item.is_file():
rel_path = item.relative_to(output_dir)
size_mb = item.stat().st_size / (1024 * 1024)
print(f" {rel_path} ({size_mb:.1f} MB)")
def main():
parser = argparse.ArgumentParser(
description="Convert full Hunyuan-GameCraft-1.0 model to FastVideo diffusers format."
)
parser.add_argument(
"--input-dir",
type=str,
default=None,
help="Path to Hunyuan-GameCraft-1.0/weights directory"
)
parser.add_argument(
"--transformer",
type=str,
default=None,
help="Path to transformer checkpoint (mp_rank_00_model_states.pt)"
)
parser.add_argument(
"--vae",
type=str,
default=None,
help="Path to VAE checkpoint"
)
parser.add_argument(
"--text-encoder",
type=str,
default=None,
help="Path to text encoder (LLaVA-LLaMA) directory"
)
parser.add_argument(
"--text-encoder-2",
type=str,
default=None,
help="Path to text encoder 2 (CLIP) directory"
)
parser.add_argument(
"--output-dir",
type=str,
default="official_weights/hunyuan-gamecraft-diffusers",
help="Output directory for converted model"
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Print detailed conversion info"
)
args = parser.parse_args()
# Require either input-dir or individual paths
if args.input_dir is None and args.transformer is None:
parser.error("Either --input-dir or individual component paths must be provided")
convert_full_model(
transformer_path=Path(args.transformer) if args.transformer else None,
vae_path=Path(args.vae) if args.vae else None,
text_encoder_path=Path(args.text_encoder) if args.text_encoder else None,
text_encoder_2_path=Path(args.text_encoder_2) if args.text_encoder_2 else None,
input_dir=Path(args.input_dir) if args.input_dir else None,
output_dir=Path(args.output_dir),
verbose=args.verbose,
)
if __name__ == "__main__":
main()
@@ -1,95 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Convert GameCraft VAE weights to FastVideo safetensors format.
The official GameCraft VAE (checkpoint-step-270000.ckpt or pytorch_model.pt) uses
a "vae." prefix. This script extracts and converts for FastVideo GameCraftVAE.
Usage:
python scripts/checkpoint_conversion/convert_gamecraft_vae.py \
--input Hunyuan-GameCraft-1.0/weights/stdmodels/vae_3d/hyvae/checkpoint-step-270000.ckpt \
--output official_weights/hunyuan-gamecraft/vae
"""
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
import torch
from safetensors.torch import save_file
def convert_gamecraft_vae(
input_path: Path,
output_dir: Path,
copy_config: bool = True,
) -> dict:
"""Convert official GameCraft VAE checkpoint to FastVideo format."""
ckpt = torch.load(input_path, map_location="cpu", weights_only=True)
if "state_dict" in ckpt:
state_dict = ckpt["state_dict"]
else:
state_dict = ckpt
# Extract VAE weights and strip "vae." prefix
vae_sd = {k.replace("vae.", ""): v for k, v in state_dict.items() if k.startswith("vae.")}
print(f"Extracted {len(vae_sd)} VAE parameters")
output_dir.mkdir(parents=True, exist_ok=True)
save_file(vae_sd, output_dir / "diffusion_pytorch_model.safetensors")
print(f"Saved to {output_dir / 'diffusion_pytorch_model.safetensors'}")
if copy_config:
config_src = input_path.parent / "config.json"
if config_src.exists():
config_dst = output_dir / "config.json"
config = json.loads(config_src.read_text())
config["_class_name"] = "AutoencoderKLCausal3D"
with open(config_dst, "w") as f:
json.dump(config, f, indent=2)
print(f"Saved config to {config_dst}")
else:
print(f"Warning: config.json not found at {config_src}")
return {"total": len(vae_sd)}
def main():
parser = argparse.ArgumentParser(
description="Convert GameCraft VAE weights to FastVideo safetensors format."
)
parser.add_argument(
"--input",
type=str,
required=True,
help="Path to official VAE checkpoint (.ckpt or .pt)",
)
parser.add_argument(
"--output",
type=str,
default="official_weights/hunyuan-gamecraft/vae",
help="Output directory for converted weights",
)
parser.add_argument(
"--no-config",
action="store_true",
help="Don't copy config.json",
)
args = parser.parse_args()
input_path = Path(args.input)
if not input_path.exists():
raise FileNotFoundError(f"Input not found: {input_path}")
stats = convert_gamecraft_vae(
input_path=input_path,
output_dir=Path(args.output),
copy_config=not args.no_config,
)
print(f"\nConversion complete! Total parameters: {stats['total']}")
if __name__ == "__main__":
main()
@@ -1,257 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Convert Hunyuan-GameCraft-1.0 weights from DeepSpeed format to FastVideo format.
The official GameCraft checkpoint (mp_rank_00_model_states.pt) uses slightly different
naming conventions than FastVideo. This script converts the weights to be compatible
with FastVideo's HunyuanGameCraft model.
Usage:
python scripts/checkpoint_conversion/convert_gamecraft_weights.py \
--input Hunyuan-GameCraft-1.0/weights/gamecraft_models/mp_rank_00_model_states.pt \
--output official_weights/hunyuan-gamecraft/transformer/diffusion_pytorch_model.safetensors
"""
from __future__ import annotations
import argparse
import json
import re
from collections import OrderedDict
from pathlib import Path
import torch
from safetensors.torch import save_file
# Mapping from official GameCraft naming to FastVideo naming
# GameCraft weights are already close to FastVideo format, but need minor adjustments
PARAM_NAME_MAP: dict[str, str] = {
# MLP naming: fc1 -> fc_in, fc2 -> fc_out
r"^(.*)\.img_mlp\.fc1\.(.*)$": r"\1.img_mlp.fc_in.\2",
r"^(.*)\.img_mlp\.fc2\.(.*)$": r"\1.img_mlp.fc_out.\2",
r"^(.*)\.txt_mlp\.fc1\.(.*)$": r"\1.txt_mlp.fc_in.\2",
r"^(.*)\.txt_mlp\.fc2\.(.*)$": r"\1.txt_mlp.fc_out.\2",
# Single block MLP naming
r"^single_blocks\.(\d+)\.mlp\.fc1\.(.*)$": r"single_blocks.\1.mlp.fc_in.\2",
r"^single_blocks\.(\d+)\.mlp\.fc2\.(.*)$": r"single_blocks.\1.mlp.fc_out.\2",
# Token refiner naming - rename individual_token_refiner.blocks to refiner_blocks
# This is applied first, then subsequent mappings handle the sub-components
r"^txt_in\.individual_token_refiner\.blocks\.(\d+)\.(.*)$": r"txt_in.refiner_blocks.\1.\2",
# Vector in naming
r"^vector_in\.in_layer\.(.*)$": r"vector_in.fc_in.\1",
r"^vector_in\.out_layer\.(.*)$": r"vector_in.fc_out.\1",
# Time embedder naming
r"^time_in\.mlp\.0\.(.*)$": r"time_in.mlp.fc_in.\1",
r"^time_in\.mlp\.2\.(.*)$": r"time_in.mlp.fc_out.\1",
# Guidance embedder naming (if present)
r"^guidance_in\.mlp\.0\.(.*)$": r"guidance_in.mlp.fc_in.\1",
r"^guidance_in\.mlp\.2\.(.*)$": r"guidance_in.mlp.fc_out.\1",
# Final layer adaLN modulation
r"^final_layer\.adaLN_modulation\.1\.(.*)$": r"final_layer.adaLN_modulation.linear.\1",
# Text in c_embedder (context embedder) naming
r"^txt_in\.c_embedder\.linear_1\.(.*)$": r"txt_in.c_embedder.fc_in.\1",
r"^txt_in\.c_embedder\.linear_2\.(.*)$": r"txt_in.c_embedder.fc_out.\1",
# Text in t_embedder (time embedder) naming
r"^txt_in\.t_embedder\.mlp\.0\.(.*)$": r"txt_in.t_embedder.mlp.fc_in.\1",
r"^txt_in\.t_embedder\.mlp\.2\.(.*)$": r"txt_in.t_embedder.mlp.fc_out.\1",
# Refiner block MLP naming - need to match the actual checkpoint names
r"^txt_in\.refiner_blocks\.(\d+)\.mlp\.fc1\.(.*)$": r"txt_in.refiner_blocks.\1.mlp.fc_in.\2",
r"^txt_in\.refiner_blocks\.(\d+)\.mlp\.fc2\.(.*)$": r"txt_in.refiner_blocks.\1.mlp.fc_out.\2",
# Refiner block adaLN modulation naming - matches .adaLN_modulation.1.weight/bias
r"^txt_in\.refiner_blocks\.(\d+)\.adaLN_modulation\.1\.(.*)$": r"txt_in.refiner_blocks.\1.adaLN_modulation.linear.\2",
# Camera net - keep as-is (already correct)
# r"^camera_net\.(.*)$": r"camera_net.\1",
}
def apply_mapping(key: str) -> str:
"""Apply name mapping to convert official key to FastVideo key.
Mappings are applied repeatedly until no more changes occur.
This allows for chained transformations.
"""
prev_key = None
while prev_key != key:
prev_key = key
for pattern, replacement in PARAM_NAME_MAP.items():
if re.match(pattern, key):
key = re.sub(pattern, replacement, key)
break # Apply only one mapping per iteration
return key
def load_deepspeed_checkpoint(path: Path) -> dict[str, torch.Tensor]:
"""Load weights from a DeepSpeed checkpoint file."""
checkpoint = torch.load(path, map_location="cpu")
if "module" in checkpoint:
state_dict = checkpoint["module"]
elif "state_dict" in checkpoint:
state_dict = checkpoint["state_dict"]
elif "model" in checkpoint:
state_dict = checkpoint["model"]
else:
state_dict = checkpoint
if not isinstance(state_dict, dict):
raise TypeError(f"Expected dict state_dict, got {type(state_dict)}")
return state_dict
def convert_weights(
input_path: Path,
output_dir: Path,
save_config: bool = True,
verbose: bool = False,
) -> dict[str, int]:
"""
Convert GameCraft weights to FastVideo format.
Returns:
dict with conversion statistics
"""
print(f"Loading checkpoint from {input_path}")
state_dict = load_deepspeed_checkpoint(input_path)
print(f"Found {len(state_dict)} parameters")
# Convert weights
converted_state_dict: OrderedDict[str, torch.Tensor] = OrderedDict()
renamed_count = 0
unchanged_count = 0
for key, value in state_dict.items():
new_key = apply_mapping(key)
if new_key != key:
if verbose:
print(f" {key} -> {new_key}")
renamed_count += 1
else:
unchanged_count += 1
converted_state_dict[new_key] = value
print(f"Renamed {renamed_count} parameters, {unchanged_count} unchanged")
# Create output directory structure
transformer_dir = output_dir / "transformer"
transformer_dir.mkdir(parents=True, exist_ok=True)
# Save as safetensors
output_path = transformer_dir / "diffusion_pytorch_model.safetensors"
print(f"Saving converted weights to {output_path}")
save_file(converted_state_dict, str(output_path))
# Save config if requested
if save_config:
config = {
"_class_name": "HunyuanGameCraftTransformer3DModel",
"_fastvideo_version": "0.1.0",
"in_channels": 33, # 16 + 16 + 1 for concat mode
"out_channels": 16,
"hidden_size": 3072,
"num_attention_heads": 24,
"attention_head_dim": 128,
"num_layers": 20,
"num_single_layers": 40,
"num_refiner_layers": 2,
"patch_size": [1, 2, 2],
"rope_axes_dim": [16, 56, 56],
"text_embed_dim": 4096,
"pooled_projection_dim": 768,
"mlp_ratio": 4.0,
"guidance_embeds": False,
"camera_net": True,
}
config_path = transformer_dir / "config.json"
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
print(f"Saved config to {config_path}")
# Verify camera_net weights
camera_keys = [k for k in converted_state_dict.keys() if "camera_net" in k]
print(f"\nCamera net weights ({len(camera_keys)}):")
for k in camera_keys:
print(f" {k}: {converted_state_dict[k].shape}")
# Verify img_in weights (should be 33 channels)
if "img_in.proj.weight" in converted_state_dict:
img_in_shape = converted_state_dict["img_in.proj.weight"].shape
print(f"\nimg_in.proj.weight shape: {img_in_shape}")
expected_in_channels = 33
actual_in_channels = img_in_shape[1]
if actual_in_channels != expected_in_channels:
print(f" WARNING: Expected {expected_in_channels} input channels, got {actual_in_channels}")
else:
print(f" OK: 33 input channels (16 latent + 16 gt_latent + 1 mask)")
return {
"total": len(converted_state_dict),
"renamed": renamed_count,
"unchanged": unchanged_count,
}
def main():
parser = argparse.ArgumentParser(
description="Convert Hunyuan-GameCraft-1.0 weights to FastVideo format."
)
parser.add_argument(
"--input",
type=str,
required=True,
help="Path to official GameCraft checkpoint (mp_rank_00_model_states.pt)"
)
parser.add_argument(
"--output",
type=str,
default="official_weights/hunyuan-gamecraft",
help="Output directory for converted weights"
)
parser.add_argument(
"--no-config",
action="store_true",
help="Don't save config.json"
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Print all renamed parameters"
)
args = parser.parse_args()
input_path = Path(args.input)
output_dir = Path(args.output)
if not input_path.exists():
raise FileNotFoundError(f"Input file not found: {input_path}")
stats = convert_weights(
input_path=input_path,
output_dir=output_dir,
save_config=not args.no_config,
verbose=args.verbose,
)
print(f"\nConversion complete!")
print(f" Total parameters: {stats['total']}")
print(f" Renamed: {stats['renamed']}")
print(f" Unchanged: {stats['unchanged']}")
if __name__ == "__main__":
main()
@@ -1,246 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Extract LlamaModel text encoder from LLaVA weights for GameCraft/HunyuanVideo.
LLaVA stores the text encoder as `language_model.model.XXX`, but FastVideo/HunyuanVideo
expects just `XXX` (e.g., `layers.0.self_attn.q_proj.weight` instead of
`language_model.model.layers.0.self_attn.q_proj.weight`).
This script extracts just the LlamaModel weights and renames them.
Usage:
python scripts/checkpoint_conversion/extract_llava_text_encoder.py \
--input Hunyuan-GameCraft-1.0/weights/stdmodels/llava-llama-3-8b-v1_1-transformers \
--output official_weights/hunyuan-gamecraft-diffusers/text_encoder
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from collections import OrderedDict
import torch
from safetensors import safe_open
from safetensors.torch import save_file
def extract_text_encoder(
input_dir: Path,
output_dir: Path,
) -> dict:
"""Extract LlamaModel from LLaVA weights."""
# Find all safetensor shards
shard_files = sorted(input_dir.glob("model-*.safetensors"))
if not shard_files:
raise FileNotFoundError(f"No safetensor files found in {input_dir}")
print(f"Found {len(shard_files)} weight shards")
# Collect all weights, filtering for language_model.model prefix
extracted_weights: OrderedDict[str, torch.Tensor] = OrderedDict()
for shard_file in shard_files:
print(f"Processing {shard_file.name}...")
with safe_open(str(shard_file), framework="pt") as f:
for key in f.keys():
# Extract only language_model.model weights
if key.startswith("language_model.model."):
# Strip the prefix
new_key = key.replace("language_model.model.", "")
extracted_weights[new_key] = f.get_tensor(key)
print(f"Extracted {len(extracted_weights)} weights")
# Save to output directory
output_dir.mkdir(parents=True, exist_ok=True)
# Save as single file (it should be <10GB which is fine for safetensors)
output_file = output_dir / "model.safetensors"
print(f"Saving to {output_file}...")
save_file(extracted_weights, str(output_file))
# Create config.json for LlamaModel
config = {
"_name_or_path": "llava-llama-3-8b-v1_1-text_encoder",
"architectures": ["LlamaModel"],
"attention_bias": False,
"attention_dropout": 0.0,
"bos_token_id": 128000,
"eos_token_id": 128001,
"head_dim": 128,
"hidden_act": "silu",
"hidden_size": 4096,
"initializer_range": 0.02,
"intermediate_size": 14336,
"max_position_embeddings": 8192,
"mlp_bias": False,
"model_type": "llama",
"num_attention_heads": 32,
"num_hidden_layers": 32,
"num_key_value_heads": 8,
"pretraining_tp": 1,
"rms_norm_eps": 1e-05,
"rope_scaling": None,
"rope_theta": 500000.0,
"tie_word_embeddings": False,
"torch_dtype": "float16",
"transformers_version": "4.48.0",
"use_cache": True,
"vocab_size": 128320
}
with open(output_dir / "config.json", "w") as f:
json.dump(config, f, indent=2)
print(f"Saved config.json")
# Copy tokenizer files if they exist
tokenizer_files = [
"tokenizer.json",
"tokenizer_config.json",
"special_tokens_map.json",
]
for fname in tokenizer_files:
src = input_dir / fname
if src.exists():
import shutil
shutil.copy2(src, output_dir / fname)
print(f"Copied {fname}")
return {"total": len(extracted_weights)}
def extract_clip_text_encoder(
input_dir: Path,
output_dir: Path,
) -> dict:
"""Extract CLIPTextModel from full CLIP model weights."""
# Find weight file
weight_files = list(input_dir.glob("model.safetensors")) + \
list(input_dir.glob("pytorch_model.bin"))
if not weight_files:
raise FileNotFoundError(f"No weight files found in {input_dir}")
weight_file = weight_files[0]
print(f"Loading {weight_file}...")
if weight_file.suffix == ".safetensors":
from safetensors.torch import load_file
state_dict = load_file(str(weight_file))
else:
state_dict = torch.load(weight_file, map_location="cpu")
# Extract only text_model weights
extracted_weights: OrderedDict[str, torch.Tensor] = OrderedDict()
for key, value in state_dict.items():
# CLIP full model has text_model.XXX, we want just XXX
if key.startswith("text_model."):
new_key = key.replace("text_model.", "")
extracted_weights[new_key] = value
if not extracted_weights:
# Maybe it's already just the text model
extracted_weights = OrderedDict(state_dict)
print(f"Extracted {len(extracted_weights)} weights")
# Save to output directory
output_dir.mkdir(parents=True, exist_ok=True)
output_file = output_dir / "model.safetensors"
print(f"Saving to {output_file}...")
save_file(extracted_weights, str(output_file))
# Create config.json for CLIPTextModel
config = {
"_name_or_path": "openai/clip-vit-large-patch14",
"architectures": ["CLIPTextModel"],
"attention_dropout": 0.0,
"bos_token_id": 0,
"dropout": 0.0,
"eos_token_id": 2,
"hidden_act": "quick_gelu",
"hidden_size": 768,
"initializer_factor": 1.0,
"initializer_range": 0.02,
"intermediate_size": 3072,
"layer_norm_eps": 1e-05,
"max_position_embeddings": 77,
"model_type": "clip_text_model",
"num_attention_heads": 12,
"num_hidden_layers": 12,
"pad_token_id": 1,
"projection_dim": 768,
"torch_dtype": "float16",
"transformers_version": "4.48.0",
"vocab_size": 49408
}
with open(output_dir / "config.json", "w") as f:
json.dump(config, f, indent=2)
print(f"Saved config.json")
# Copy tokenizer files if they exist
tokenizer_files = [
"tokenizer.json",
"tokenizer_config.json",
"special_tokens_map.json",
"vocab.json",
"merges.txt",
]
for fname in tokenizer_files:
src = input_dir / fname
if src.exists():
import shutil
shutil.copy2(src, output_dir / fname)
print(f"Copied {fname}")
return {"total": len(extracted_weights)}
def main():
parser = argparse.ArgumentParser(
description="Extract text encoder from LLaVA or CLIP model weights."
)
parser.add_argument(
"--input",
type=str,
required=True,
help="Input directory containing model weights"
)
parser.add_argument(
"--output",
type=str,
required=True,
help="Output directory for extracted weights"
)
parser.add_argument(
"--type",
type=str,
choices=["llama", "clip"],
default="llama",
help="Type of text encoder to extract"
)
args = parser.parse_args()
input_dir = Path(args.input)
output_dir = Path(args.output)
if not input_dir.exists():
raise FileNotFoundError(f"Input directory not found: {input_dir}")
if args.type == "llama":
stats = extract_text_encoder(input_dir, output_dir)
else:
stats = extract_clip_text_encoder(input_dir, output_dir)
print(f"\nExtraction complete! Total weights: {stats['total']}")
if __name__ == "__main__":
main()
@@ -1,247 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Numerical parity test for GameCraft text encoders.
GameCraft uses:
1. LLaVA-LLaMA-3-8B for primary text encoding (4096-dim)
2. CLIP ViT-L/14 for secondary text encoding (768-dim pooled)
Usage:
DISABLE_SP=1 pytest tests/local_tests/encoders/test_gamecraft_encoders_parity.py -v
"""
import os
import sys
from pathlib import Path
import pytest
import torch
from torch.testing import assert_close
os.environ.setdefault("MASTER_ADDR", "localhost")
os.environ.setdefault("MASTER_PORT", "29516")
os.environ.setdefault("DISABLE_SP", "1")
repo_root = Path(__file__).resolve().parents[3]
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
def test_gamecraft_llama_encoder():
"""Test LLaVA-LLaMA-3-8B text encoder."""
torch.manual_seed(42)
llama_path = Path(
os.getenv(
"GAMECRAFT_LLAMA_PATH",
repo_root / "Hunyuan-GameCraft-1.0" / "weights" / "stdmodels" / "llava-llama-3-8b-v1_1-transformers"
)
)
if not llama_path.exists():
pytest.skip(f"LLaMA encoder not found at {llama_path}")
# Check for model weights
model_files = list(llama_path.glob("*.safetensors")) + list(llama_path.glob("*.bin"))
if not model_files:
pytest.skip(f"No model weights found in {llama_path}")
device = torch.device("cuda:0")
dtype = torch.float16 # LLaMA uses fp16
print(f"\n[LLAMA TEST] Loading LLaVA-LLaMA-3-8B from {llama_path}")
try:
from transformers import LlavaForConditionalGeneration, LlamaTokenizerFast
# Load model
model = LlavaForConditionalGeneration.from_pretrained(
llama_path,
low_cpu_mem_usage=True,
torch_dtype=dtype,
)
model = model.to(device)
model.eval()
# Load tokenizer
tokenizer = LlamaTokenizerFast.from_pretrained(llama_path, padding_side="right")
print(f"[LLAMA TEST] Model loaded successfully")
print(f"[LLAMA TEST] Model dtype: {model.dtype}")
# Test encoding
test_prompt = "A medieval village with cobblestone streets"
inputs = tokenizer(
test_prompt,
return_tensors="pt",
padding="max_length",
max_length=256,
truncation=True,
)
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.no_grad():
outputs = model(
input_ids=inputs["input_ids"],
attention_mask=inputs["attention_mask"],
output_hidden_states=True,
)
# Get last hidden state
last_hidden_state = outputs.hidden_states[-1]
print(f"[LLAMA TEST] Input tokens: {inputs['input_ids'].shape}")
print(f"[LLAMA TEST] Output hidden state shape: {last_hidden_state.shape}")
print(f"[LLAMA TEST] Hidden dim: {last_hidden_state.shape[-1]} (expected: 4096)")
print(f"[LLAMA TEST] Output stats: mean={last_hidden_state.mean():.6f}, std={last_hidden_state.std():.6f}")
assert last_hidden_state.shape[-1] == 4096, f"Expected hidden dim 4096, got {last_hidden_state.shape[-1]}"
print("[LLAMA TEST] LLaMA encoder test passed!")
except ImportError as e:
pytest.skip(f"transformers not available: {e}")
except Exception as e:
pytest.fail(f"LLaMA encoder test failed: {e}")
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
def test_gamecraft_clip_encoder():
"""Test CLIP ViT-L/14 text encoder."""
torch.manual_seed(42)
clip_path = Path(
os.getenv(
"GAMECRAFT_CLIP_PATH",
repo_root / "Hunyuan-GameCraft-1.0" / "weights" / "stdmodels" / "openai_clip-vit-large-patch14"
)
)
if not clip_path.exists():
pytest.skip(f"CLIP encoder not found at {clip_path}")
device = torch.device("cuda:0")
dtype = torch.float32 # CLIP typically uses fp32
print(f"\n[CLIP TEST] Loading CLIP ViT-L/14 from {clip_path}")
try:
from transformers import CLIPTextModel, CLIPTokenizer
# Load model
model = CLIPTextModel.from_pretrained(clip_path)
model = model.to(device=device, dtype=dtype)
model.eval()
# Load tokenizer
tokenizer = CLIPTokenizer.from_pretrained(clip_path, max_length=77)
print(f"[CLIP TEST] Model loaded successfully")
print(f"[CLIP TEST] Model dtype: {model.dtype}")
# Test encoding
test_prompt = "A medieval village with cobblestone streets"
inputs = tokenizer(
test_prompt,
return_tensors="pt",
padding="max_length",
max_length=77,
truncation=True,
)
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.no_grad():
outputs = model(
input_ids=inputs["input_ids"],
attention_mask=inputs["attention_mask"],
)
# Get pooled output (used for text_states_2)
pooler_output = outputs.pooler_output
last_hidden_state = outputs.last_hidden_state
print(f"[CLIP TEST] Input tokens: {inputs['input_ids'].shape}")
print(f"[CLIP TEST] Pooled output shape: {pooler_output.shape} (expected: [B, 768])")
print(f"[CLIP TEST] Last hidden state shape: {last_hidden_state.shape}")
print(f"[CLIP TEST] Pooled output stats: mean={pooler_output.mean():.6f}, std={pooler_output.std():.6f}")
assert pooler_output.shape[-1] == 768, f"Expected pooled dim 768, got {pooler_output.shape[-1]}"
print("[CLIP TEST] CLIP encoder test passed!")
except ImportError as e:
pytest.skip(f"transformers not available: {e}")
except Exception as e:
pytest.fail(f"CLIP encoder test failed: {e}")
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
@pytest.mark.skip(reason="Official TextEncoder wrapper incompatible with installed transformers version (language_model attribute removed from LlavaForConditionalGeneration)")
def test_gamecraft_text_encoder_wrapper():
"""Test the official GameCraft TextEncoder wrapper."""
torch.manual_seed(42)
official_path = Path(
os.getenv("GAMECRAFT_OFFICIAL_PATH", repo_root / "Hunyuan-GameCraft-1.0")
)
if not official_path.exists():
pytest.skip(f"Official GameCraft repo not found at {official_path}")
sys.path.insert(0, str(official_path))
llama_path = official_path / "weights" / "stdmodels" / "llava-llama-3-8b-v1_1-transformers"
clip_path = official_path / "weights" / "stdmodels" / "openai_clip-vit-large-patch14"
if not llama_path.exists() or not clip_path.exists():
pytest.skip("Text encoder weights not found")
device = torch.device("cuda:0")
try:
from hymm_sp.text_encoder import TextEncoder
# Test LLaMA encoder wrapper
print("\n[WRAPPER TEST] Testing LLaVA-LLaMA-3-8B wrapper...")
llama_encoder = TextEncoder(
text_encoder_type="llava-llama-3-8b",
max_length=256,
text_encoder_precision="fp16",
text_encoder_path=str(llama_path),
tokenizer_path=str(llama_path),
use_attention_mask=True,
device=device,
)
test_prompt = "A medieval village with cobblestone streets"
llama_output = llama_encoder(test_prompt)
print(f"[WRAPPER TEST] LLaMA hidden state: {llama_output.hidden_state.shape}")
print(f"[WRAPPER TEST] LLaMA attention mask: {llama_output.attention_mask.shape if llama_output.attention_mask is not None else 'None'}")
# Test CLIP encoder wrapper
print("\n[WRAPPER TEST] Testing CLIP wrapper...")
clip_encoder = TextEncoder(
text_encoder_type="clipL",
max_length=77,
text_encoder_precision="fp32",
text_encoder_path=str(clip_path),
tokenizer_path=str(clip_path),
use_attention_mask=True,
device=device,
)
clip_output = clip_encoder(test_prompt)
print(f"[WRAPPER TEST] CLIP pooled output: {clip_output.hidden_state.shape}")
print("[WRAPPER TEST] TextEncoder wrapper test passed!")
except ImportError as e:
pytest.skip(f"Failed to import TextEncoder: {e}")
except Exception as e:
pytest.fail(f"TextEncoder wrapper test failed: {e}")
if __name__ == "__main__":
test_gamecraft_clip_encoder()
test_gamecraft_llama_encoder()
# test_gamecraft_text_encoder_wrapper() # Requires full model weights
@@ -1,508 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
End-to-end pipeline parity test for HunyuanGameCraft.
This test compares the FastVideo pipeline against the official
Hunyuan-GameCraft-1.0 implementation to ensure numerical alignment
across the complete pipeline (text encoding -> denoising -> VAE decode).
Usage:
# Run with default paths
DISABLE_SP=1 pytest tests/local_tests/pipelines/test_gamecraft_pipeline_parity.py -v -s
# Run with custom paths
GAMECRAFT_OFFICIAL_PATH=path/to/Hunyuan-GameCraft-1.0 \
GAMECRAFT_WEIGHTS_PATH=path/to/weights \
DISABLE_SP=1 pytest tests/local_tests/pipelines/test_gamecraft_pipeline_parity.py -v -s
Notes:
- This test requires the official Hunyuan-GameCraft-1.0 repo
- This test requires significant GPU memory (~40GB+)
- The test compares denoised latents, not final decoded video (for speed)
"""
import math
import os
import sys
from pathlib import Path
import pytest
import torch
import numpy as np
from torch.testing import assert_close
# Set up distributed environment defaults
os.environ.setdefault("MASTER_ADDR", "localhost")
os.environ.setdefault("MASTER_PORT", "29520")
os.environ.setdefault("DISABLE_SP", "1")
repo_root = Path(__file__).resolve().parents[3]
def _log_tensor_stats(label: str, tensor: torch.Tensor) -> None:
"""Log tensor statistics for debugging."""
tensor_f32 = tensor.float()
print(
f"[GAMECRAFT PIPELINE] {label}: shape={tuple(tensor.shape)} "
f"dtype={tensor.dtype} device={tensor.device} "
f"min={tensor_f32.min().item():.6f} max={tensor_f32.max().item():.6f} "
f"mean={tensor_f32.mean().item():.6f} std={tensor_f32.std().item():.6f}"
)
def _add_official_to_path():
"""Add official GameCraft implementation to Python path."""
official_path = Path(
os.getenv("GAMECRAFT_OFFICIAL_PATH", repo_root / "Hunyuan-GameCraft-1.0")
)
if official_path.exists() and str(official_path) not in sys.path:
sys.path.insert(0, str(official_path))
return official_path
def _create_camera_trajectory_from_action(
action_id: str,
height: int,
width: int,
num_frames: int,
action_speed: float = 0.2,
device: torch.device = torch.device("cpu"),
dtype: torch.dtype = torch.bfloat16,
):
"""
Create camera trajectory (Plücker coordinates) from an action ID.
This replicates the official GameCraft camera trajectory generation.
Args:
action_id: One of 'w' (forward), 'a' (left), 'd' (right), 's' (backward),
'left_rot', 'right_rot', 'up_rot', 'down_rot'
height: Video height in pixels
width: Video width in pixels
num_frames: Number of video frames
action_speed: Speed of motion
device: Torch device
dtype: Torch dtype
Returns:
plucker_embedding: [1, num_frames, 6, height, width]
uncond_plucker_embedding: [1, num_frames, 6, height, width]
"""
official_path = _add_official_to_path()
try:
from hymm_sp.sample_inference import (
ActionToPoseFromID,
GetPoseEmbedsFromPoses,
)
except ImportError as e:
pytest.skip(f"Cannot import official camera trajectory functions: {e}")
# Generate poses from action
poses = ActionToPoseFromID(action_id, value=action_speed, duration=num_frames)
# Convert to Plücker embeddings
plucker_embedding, uncond_plucker_embedding, _ = GetPoseEmbedsFromPoses(
poses, height, width, num_frames, flip=False, start_index=0
)
# Add batch dimension and convert to target dtype/device
plucker_embedding = plucker_embedding.unsqueeze(0).to(device=device, dtype=dtype)
uncond_plucker_embedding = uncond_plucker_embedding.unsqueeze(0).to(device=device, dtype=dtype)
return plucker_embedding, uncond_plucker_embedding
def _run_official_pipeline(
prompt: str,
action_id: str,
height: int,
width: int,
num_frames: int,
num_inference_steps: int,
seed: int,
device: torch.device,
dtype: torch.dtype,
official_path: Path,
weights_path: Path,
):
"""
Run the official GameCraft pipeline and return denoised latents.
Returns:
denoised_latents: The final denoised latents
timesteps: The timesteps used
"""
sys.path.insert(0, str(official_path))
try:
from hymm_sp.config import parse_args
from hymm_sp.sample_inference import HunyuanVideoSampler
from hymm_sp.constants import PRECISION_TO_TYPE
from hymm_sp.vae import load_vae
from hymm_sp.modules import load_model
from hymm_sp.text_encoder import TextEncoder
from hymm_sp.constants import PROMPT_TEMPLATE
except ImportError as e:
pytest.skip(f"Cannot import official GameCraft modules: {e}")
# Build args for official model - we need to parse default args
# and override specific values
sys.argv = [
"test",
"--model", "HYVideo-T/2",
"--prompt", prompt,
"--vae", "884-16c-hy",
"--vae-precision", "fp16",
"--precision", "bf16",
"--dit-weight", str(weights_path / "gamecraft_models" / "mp_rank_00_model_states.pt"),
"--video-length", str(num_frames + 1), # Official uses +1
"--video-size", str(height), str(width),
"--seed", str(seed),
"--infer-steps", str(num_inference_steps),
"--flow-shift", "7.0",
"--embedded-cfg-scale", "6.0",
"--cfg-scale", "1.0",
"--text-encoder", str(weights_path / "text_encoder"),
"--text-encoder-2", str(weights_path / "text_encoder_2"),
"--tokenizer", str(weights_path / "text_encoder"),
"--tokenizer-2", str(weights_path / "text_encoder_2"),
"--prompt-template-video", "dit-llm-encode-video",
"--load-key", "module",
]
args = parse_args()
args.cpu_offload = 0
args.use_fp8 = False
# Create the sampler
sampler = HunyuanVideoSampler.from_pretrained(
pretrained_model_path=str(weights_path / "gamecraft_models"),
args=args,
device=device,
)
# Run inference
result = sampler.predict(
prompt=prompt,
is_image=True, # 33 frames mode
size=(height, width),
video_length=num_frames + 1,
seed=seed,
infer_steps=num_inference_steps,
guidance_scale=1.0, # CFG scale
flow_shift=7.0,
action_id=action_id,
action_speed=0.2,
start_index=0,
return_latents=True,
output_type="latent",
)
denoised_latents = result.get("denoised_lantents") # Note: typo in official code
timesteps = result.get("timesteps")
# Clean up
del sampler
torch.cuda.empty_cache()
return denoised_latents, timesteps
@pytest.mark.skipif(
not torch.cuda.is_available(),
reason="GameCraft pipeline test requires CUDA.",
)
def test_gamecraft_pipeline_latent_parity():
"""
Test that FastVideo GameCraft pipeline produces matching latents.
This test runs both pipelines with identical:
- Prompt
- Camera trajectory (action)
- Random seed
- Number of inference steps
And compares the denoised latents (before VAE decode) for numerical parity.
"""
# Check for required paths
official_path = _add_official_to_path()
if not official_path.exists():
pytest.skip(f"Official GameCraft repo not found at {official_path}")
weights_path = Path(
os.getenv(
"GAMECRAFT_WEIGHTS_PATH",
repo_root / "Hunyuan-GameCraft-1.0" / "weights" / "stdmodels"
)
)
if not weights_path.exists():
pytest.skip(f"GameCraft weights not found at {weights_path}")
# Check for required weight files
dit_weights = weights_path / "gamecraft_models" / "mp_rank_00_model_states.pt"
if not dit_weights.exists():
pytest.skip(f"DiT weights not found at {dit_weights}")
# Test parameters - small size for faster testing
device = torch.device("cuda:0")
dtype = torch.bfloat16
prompt = "A first-person view of walking through a lush forest."
action_id = "w" # Forward motion
height = 256 # Smaller for testing
width = 256
num_frames = 33 # Standard GameCraft frame count
num_inference_steps = 5 # Reduced for testing
seed = 42
print(f"\n[GAMECRAFT PIPELINE TEST] Configuration:")
print(f" - Prompt: {prompt}")
print(f" - Action: {action_id} (forward)")
print(f" - Size: {height}x{width}")
print(f" - Frames: {num_frames}")
print(f" - Steps: {num_inference_steps}")
print(f" - Seed: {seed}")
# =========================================================================
# Run Official Pipeline
# =========================================================================
print("\n[GAMECRAFT PIPELINE TEST] Running official pipeline...")
try:
official_latents, official_timesteps = _run_official_pipeline(
prompt=prompt,
action_id=action_id,
height=height,
width=width,
num_frames=num_frames,
num_inference_steps=num_inference_steps,
seed=seed,
device=device,
dtype=dtype,
official_path=official_path,
weights_path=weights_path,
)
_log_tensor_stats("Official latents", official_latents)
except Exception as e:
pytest.skip(f"Failed to run official pipeline: {e}")
# Clear memory
torch.cuda.empty_cache()
# =========================================================================
# Run FastVideo Pipeline
# =========================================================================
print("\n[GAMECRAFT PIPELINE TEST] Running FastVideo pipeline...")
# Import FastVideo components
from fastvideo import VideoGenerator
# Get diffusers-format model path
diffusers_path = os.getenv(
"GAMECRAFT_DIFFUSERS_PATH",
str(repo_root / "official_weights" / "hunyuan-gamecraft")
)
if not os.path.exists(diffusers_path):
pytest.skip(f"FastVideo GameCraft weights not found at {diffusers_path}")
# Create camera trajectory for FastVideo
plucker_embedding, uncond_plucker_embedding = _create_camera_trajectory_from_action(
action_id=action_id,
height=height,
width=width,
num_frames=num_frames,
action_speed=0.2,
device=device,
dtype=dtype,
)
# Create generator
generator = VideoGenerator.from_pretrained(
diffusers_path,
num_gpus=1,
use_fsdp_inference=False,
dit_cpu_offload=False,
vae_cpu_offload=True, # Save memory
text_encoder_cpu_offload=True,
pin_cpu_memory=False,
)
# Run generation
result = generator.generate_video(
prompt=prompt,
output_path="outputs_video/gamecraft_test",
save_video=False,
height=height,
width=width,
num_frames=num_frames,
num_inference_steps=num_inference_steps,
seed=seed,
guidance_scale=1.0,
camera_states=plucker_embedding,
)
fastvideo_latents = result.get("latents")
if fastvideo_latents is None:
fastvideo_latents = result.get("samples")
_log_tensor_stats("FastVideo latents", fastvideo_latents)
generator.shutdown()
# =========================================================================
# Compare Results
# =========================================================================
print("\n[GAMECRAFT PIPELINE TEST] Comparing latents...")
# Ensure same shape
assert official_latents.shape == fastvideo_latents.shape, (
f"Shape mismatch: official {official_latents.shape} vs "
f"fastvideo {fastvideo_latents.shape}"
)
# Compute differences
official_f32 = official_latents.float()
fastvideo_f32 = fastvideo_latents.float()
abs_diff = (official_f32 - fastvideo_f32).abs()
max_diff = abs_diff.max().item()
mean_diff = abs_diff.mean().item()
# Compute correlation
official_flat = official_f32.flatten()
fastvideo_flat = fastvideo_f32.flatten()
correlation = torch.corrcoef(torch.stack([official_flat, fastvideo_flat]))[0, 1].item()
print(f"\n[GAMECRAFT PIPELINE TEST] Results:")
print(f" - Max absolute difference: {max_diff:.6f}")
print(f" - Mean absolute difference: {mean_diff:.6f}")
print(f" - Correlation: {correlation:.6f}")
# Assertions
# For full pipeline, we expect higher tolerance due to accumulated errors
assert correlation > 0.99, f"Correlation {correlation} is too low (expected > 0.99)"
assert max_diff < 0.5, f"Max diff {max_diff} is too high (expected < 0.5)"
print("\n[GAMECRAFT PIPELINE TEST] PASSED!")
@pytest.mark.skipif(
not torch.cuda.is_available(),
reason="GameCraft pipeline test requires CUDA.",
)
def test_gamecraft_pipeline_camera_trajectory():
"""
Test that camera trajectory (Plücker embedding) generation matches.
This is a simpler test that just validates camera conditioning matches
between official and FastVideo implementations.
"""
official_path = _add_official_to_path()
if not official_path.exists():
pytest.skip(f"Official GameCraft repo not found at {official_path}")
device = torch.device("cuda:0")
dtype = torch.bfloat16
# Test parameters
height = 256
width = 256
num_frames = 33
action_id = "w"
action_speed = 0.2
print("\n[CAMERA TEST] Generating camera trajectories...")
# Generate using official code path
plucker, uncond_plucker = _create_camera_trajectory_from_action(
action_id=action_id,
height=height,
width=width,
num_frames=num_frames,
action_speed=action_speed,
device=device,
dtype=dtype,
)
_log_tensor_stats("Plücker embedding", plucker)
_log_tensor_stats("Uncond Plücker embedding", uncond_plucker)
# Basic shape validation
expected_shape = (1, num_frames, 6, height, width)
assert plucker.shape == expected_shape, (
f"Plücker shape {plucker.shape} != expected {expected_shape}"
)
assert uncond_plucker.shape == expected_shape, (
f"Uncond Plücker shape {uncond_plucker.shape} != expected {expected_shape}"
)
# Validate uncond is identity (zeros for translation, identity for rotation)
# In practice, uncond should have specific structure
assert not torch.allclose(plucker, uncond_plucker), (
"Conditional and unconditional embeddings should differ"
)
print("\n[CAMERA TEST] PASSED!")
@pytest.mark.skipif(
not torch.cuda.is_available(),
reason="GameCraft pipeline test requires CUDA.",
)
def test_gamecraft_pipeline_smoke():
"""
Smoke test for GameCraft pipeline - just verify it runs without errors.
This is a lighter test that doesn't require the official implementation,
just validates that the FastVideo pipeline can run.
"""
diffusers_path = os.getenv(
"GAMECRAFT_DIFFUSERS_PATH",
str(repo_root / "official_weights" / "hunyuan-gamecraft")
)
if not os.path.exists(diffusers_path):
pytest.skip(f"FastVideo GameCraft weights not found at {diffusers_path}")
if not os.path.isfile(os.path.join(diffusers_path, "model_index.json")):
pytest.skip(f"model_index.json not found in {diffusers_path}")
device = torch.device("cuda:0")
print("\n[SMOKE TEST] Running FastVideo GameCraft pipeline...")
from fastvideo import VideoGenerator
# Very small test
height = 128
width = 128
num_frames = 9 # Minimum
num_inference_steps = 2
generator = VideoGenerator.from_pretrained(
diffusers_path,
num_gpus=1,
use_fsdp_inference=False,
dit_cpu_offload=True,
vae_cpu_offload=True,
text_encoder_cpu_offload=True,
pin_cpu_memory=False,
)
result = generator.generate_video(
prompt="A test video.",
output_path="outputs_video/gamecraft_smoke",
save_video=False,
height=height,
width=width,
num_frames=num_frames,
num_inference_steps=num_inference_steps,
seed=123,
guidance_scale=1.0,
)
assert result is not None, "Pipeline returned None"
assert "samples" in result or "latents" in result, "No output in result"
generator.shutdown()
print("\n[SMOKE TEST] PASSED!")
@@ -1,766 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Numerical parity test for HunyuanGameCraft transformer.
This test compares the FastVideo implementation against the official
Hunyuan-GameCraft-1.0 implementation to ensure numerical alignment.
Usage:
# Run with default paths
pytest tests/local_tests/transformers/test_gamecraft_parity.py -v
# Run with custom paths
GAMECRAFT_OFFICIAL_PATH=path/to/Hunyuan-GameCraft-1.0 \
GAMECRAFT_WEIGHTS_PATH=path/to/weights/gamecraft_models/mp_rank_00_model_states.pt \
pytest tests/local_tests/transformers/test_gamecraft_parity.py -v
# Enable debug logging
GAMECRAFT_DEBUG_LOGS=1 pytest tests/local_tests/transformers/test_gamecraft_parity.py -v
"""
import os
import sys
from pathlib import Path
import pytest
import torch
from torch.testing import assert_close
# Set up distributed environment defaults
os.environ.setdefault("MASTER_ADDR", "localhost")
os.environ.setdefault("MASTER_PORT", "29514")
os.environ.setdefault("DISABLE_SP", "1") # Disable sequence parallelism for testing
repo_root = Path(__file__).resolve().parents[3]
def _add_official_to_path():
"""Add official GameCraft implementation to Python path."""
official_path = Path(
os.getenv("GAMECRAFT_OFFICIAL_PATH", repo_root / "Hunyuan-GameCraft-1.0")
)
if official_path.exists() and str(official_path) not in sys.path:
sys.path.insert(0, str(official_path))
return official_path
def _format_sum(tensor: torch.Tensor | None) -> str:
"""Format tensor sum for logging."""
if tensor is None:
return "None"
return f"{tensor.float().sum().item():.6f}"
def _attach_block_logging(
model: torch.nn.Module,
log_path: Path,
label: str,
enabled: bool,
) -> None:
"""Attach forward hooks to log block outputs."""
if not enabled:
return
log_path.parent.mkdir(parents=True, exist_ok=True)
if log_path.exists():
log_path.unlink()
def _hook_factory(block_idx: int, block_type: str):
def _hook(_module, _inputs, outputs):
if isinstance(outputs, tuple):
img_out, txt_out = outputs
img_sum = _format_sum(img_out)
txt_sum = _format_sum(txt_out)
out_str = f"img_sum={img_sum},txt_sum={txt_sum}"
else:
out_str = f"out_sum={_format_sum(outputs)}"
with log_path.open("a", encoding="utf-8") as f:
f.write(f"{label}:{block_type}:{block_idx}:{out_str}\n")
return _hook
# Attach to double blocks
if hasattr(model, "double_blocks"):
for idx, block in enumerate(model.double_blocks):
block.register_forward_hook(_hook_factory(idx, "double"))
# Attach to single blocks
if hasattr(model, "single_blocks"):
for idx, block in enumerate(model.single_blocks):
block.register_forward_hook(_hook_factory(idx, "single"))
def _load_official_model(official_path: Path, weights_path: Path, device: torch.device, dtype: torch.dtype):
"""Load the official GameCraft model."""
try:
# Add official implementation to path
sys.path.insert(0, str(official_path))
from hymm_sp.modules.models import HYVideoDiffusionTransformer, HUNYUAN_VIDEO_CONFIG
from hymm_sp.config import parse_args
# Create a minimal args object
class MinimalArgs:
text_projection = "single_refiner"
text_states_dim = 4096
use_attention_mask = False
text_states_dim_2 = 768
args = MinimalArgs()
config = HUNYUAN_VIDEO_CONFIG['HYVideo-T/2']
# Initialize model
model = HYVideoDiffusionTransformer(
args=args,
patch_size=[1, 2, 2],
in_channels=16, # Will be adjusted for concat mode
out_channels=16,
hidden_size=config['hidden_size'],
mlp_width_ratio=config['mlp_width_ratio'],
num_heads=config['num_heads'],
depth_double_blocks=config['depth_double_blocks'],
depth_single_blocks=config['depth_single_blocks'],
rope_dim_list=config['rope_dim_list'],
guidance_embed=False,
dtype=dtype,
device='cpu', # Load on CPU first
multitask_mask_training_type="concat", # GameCraft uses concat mode
camera_in_channels=6,
camera_down_coef=8,
)
# Load weights
checkpoint = torch.load(weights_path, map_location='cpu')
state_dict = checkpoint['module']
model.load_state_dict(state_dict, strict=True)
model = model.to(device=device, dtype=dtype)
model.eval()
return model
except ImportError as e:
pytest.skip(f"Failed to import official GameCraft: {e}")
except Exception as e:
pytest.skip(f"Failed to load official GameCraft model: {e}")
def _create_test_inputs(batch_size: int, device: torch.device, dtype: torch.dtype):
"""Create test inputs for the transformer."""
# Video latent shape: [B, C, T, H, W]
# For GameCraft: C=33 (16 latent + 16 gt_latent + 1 mask)
frames = 9 # 9 latent frames = 33 video frames with temporal compression
height = 88 # 704 / 8
width = 160 # 1280 / 8
# Pure latents (what we're denoising)
latents = torch.randn(batch_size, 16, frames, height, width, device=device, dtype=dtype)
# Ground truth latents (history/reference)
gt_latents = torch.randn(batch_size, 16, frames, height, width, device=device, dtype=dtype)
# Mask (1 = use gt, 0 = generate)
mask = torch.zeros(batch_size, 1, frames, height, width, device=device, dtype=dtype)
mask[:, :, :frames//2] = 1.0 # First half is conditioned
# Concatenated input
x = torch.cat([latents, gt_latents, mask], dim=1) # [B, 33, T, H, W]
# Timestep
t = torch.tensor([500.0] * batch_size, device=device, dtype=dtype)
# Text embeddings (LLaMA)
text_states = torch.randn(batch_size, 77, 4096, device=device, dtype=dtype)
text_mask = torch.ones(batch_size, 77, device=device, dtype=dtype)
# CLIP embeddings
text_states_2 = torch.randn(batch_size, 768, device=device, dtype=dtype)
# Camera/action states: [B, T_video, 6, H_video, W_video]
# For 9 latent frames with temporal 4x compression -> ~36 video frames
# But camera net expects raw video resolution
video_frames = 33 # (9-1)*4 + 1 for 884 VAE
cam_latents = torch.randn(batch_size, video_frames, 6, 704, 1280, device=device, dtype=dtype)
return {
'x': x,
't': t,
'text_states': text_states,
'text_mask': text_mask,
'text_states_2': text_states_2,
'cam_latents': cam_latents,
}
def _get_rotary_embeddings(shape, hidden_size, num_heads, rope_dim_list, device, dtype):
"""Generate rotary position embeddings."""
# Simplified RoPE generation for testing
# In production, this uses the full RoPE implementation
t, h, w = shape
seq_len = t * h * w
head_dim = hidden_size // num_heads
freqs_cos = torch.ones(seq_len, head_dim, device=device, dtype=dtype)
freqs_sin = torch.zeros(seq_len, head_dim, device=device, dtype=dtype)
return freqs_cos, freqs_sin
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
def test_gamecraft_transformer_parity():
"""Test numerical parity between official and FastVideo GameCraft implementations."""
torch.manual_seed(42)
# Initialize distributed environment for FastVideo
from fastvideo.distributed.parallel_state import init_distributed_environment, initialize_model_parallel
init_distributed_environment(world_size=1, rank=0, local_rank=0)
initialize_model_parallel(tensor_model_parallel_size=1, sequence_model_parallel_size=1)
# Get paths
official_path = Path(
os.getenv("GAMECRAFT_OFFICIAL_PATH", repo_root / "Hunyuan-GameCraft-1.0")
)
weights_path = Path(
os.getenv(
"GAMECRAFT_WEIGHTS_PATH",
repo_root / "Hunyuan-GameCraft-1.0" / "weights" / "gamecraft_models" / "mp_rank_00_model_states.pt"
)
)
if not official_path.exists():
pytest.skip(f"Official GameCraft repo not found at {official_path}")
if not weights_path.exists():
pytest.skip(f"GameCraft weights not found at {weights_path}")
device = torch.device("cuda:0")
dtype = torch.bfloat16
batch_size = 1
# Load official model
print(f"\n[GameCraft TEST] Loading official model from {weights_path}")
official_model = _load_official_model(official_path, weights_path, device, dtype)
# Enable debug logging if requested
debug_logs = os.getenv("GAMECRAFT_DEBUG_LOGS", "0") == "1"
_attach_block_logging(
official_model,
repo_root / "gamecraft_debug" / "official.log",
"official",
debug_logs,
)
# Create test inputs
# IMPORTANT: latent_len must be 9, 10, or 18 for official model's camera conditioning
frames = 9 # Valid latent temporal length
height = 44 # 352 / 8 (small spatial resolution for testing)
width = 80 # 640 / 8
latents = torch.randn(batch_size, 16, frames, height, width, device=device, dtype=dtype)
gt_latents = torch.randn(batch_size, 16, frames, height, width, device=device, dtype=dtype)
mask = torch.zeros(batch_size, 1, frames, height, width, device=device, dtype=dtype)
mask[:, :, :4] = 1.0 # First 4 frames are conditioned (history)
x = torch.cat([latents, gt_latents, mask], dim=1) # [B, 33, T, H, W]
t = torch.tensor([500.0], device=device, dtype=dtype)
text_states = torch.randn(batch_size, 32, 4096, device=device, dtype=dtype)
text_mask = torch.ones(batch_size, 32, device=device, dtype=dtype)
text_states_2 = torch.randn(batch_size, 768, device=device, dtype=dtype)
# Camera states: for 9 latent frames -> 33 video frames with 884 VAE
video_frames = 33
cam_latents = torch.randn(batch_size, video_frames, 6, 352, 640, device=device, dtype=dtype)
# Get RoPE embeddings - after patchify with patch_size=[1,2,2]: T, H/2, W/2
tt, th, tw = frames, height // 2, width // 2
# Use FastVideo's RoPE implementation for both models
from fastvideo.layers.rotary_embedding import get_rotary_pos_embed
hidden_size = 3072
num_heads = 24
rope_dim_list = [16, 56, 56] # From GameCraft config
rope_theta = 256.0
freqs_cos, freqs_sin = get_rotary_pos_embed(
(tt, th, tw),
hidden_size,
num_heads,
rope_dim_list,
rope_theta,
)
freqs_cos = freqs_cos.to(device=device, dtype=dtype)
freqs_sin = freqs_sin.to(device=device, dtype=dtype)
print(f"[GameCraft TEST] Input shapes:")
print(f" x: {x.shape}")
print(f" text_states: {text_states.shape}")
print(f" cam_latents: {cam_latents.shape}")
# Run official model
with torch.no_grad():
official_output = official_model(
x=x,
t=t,
text_states=text_states,
text_mask=text_mask,
text_states_2=text_states_2,
freqs_cos=freqs_cos,
freqs_sin=freqs_sin,
cam_latents=cam_latents,
return_dict=True,
)
if isinstance(official_output, dict):
official_out = official_output['x']
else:
official_out = official_output
print(f"[GameCraft TEST] Official output shape: {official_out.shape}")
print(f"[GameCraft TEST] Official output stats: mean={official_out.mean():.6f}, std={official_out.std():.6f}")
# Load FastVideo GameCraft model
print("[GameCraft TEST] Loading FastVideo GameCraft model...")
from fastvideo.configs.models.dits.hunyuangamecraft import HunyuanGameCraftConfig
from fastvideo.models.dits.hunyuangamecraft import HunyuanGameCraftTransformer3DModel
# Create FastVideo config
fastvideo_config = HunyuanGameCraftConfig()
fastvideo_config.dtype = dtype
# Load converted weights
converted_weights_path = repo_root / "official_weights" / "hunyuan-gamecraft" / "transformer"
if not converted_weights_path.exists():
# Try to convert weights on-the-fly
print(f"[GameCraft TEST] Converting weights from {weights_path}...")
from scripts.checkpoint_conversion.convert_gamecraft_weights import convert_gamecraft_weights
convert_gamecraft_weights(
input_path=str(weights_path),
output_dir=str(converted_weights_path),
)
# Load hf_config from converted weights
import json
config_path = converted_weights_path / "config.json"
if config_path.exists():
with open(config_path) as f:
hf_config = json.load(f)
else:
# Create minimal hf_config
hf_config = {
"hidden_size": 3072,
"num_attention_heads": 24,
"num_layers": 20,
"num_single_layers": 40,
}
# Initialize FastVideo model
fastvideo_model = HunyuanGameCraftTransformer3DModel(
config=fastvideo_config,
hf_config=hf_config,
)
# Load weights
from safetensors.torch import load_file
safetensor_files = list(converted_weights_path.glob("*.safetensors"))
if safetensor_files:
state_dict = {}
for sf_file in safetensor_files:
state_dict.update(load_file(sf_file))
# Apply param_names_mapping in reverse
reverse_mapping = fastvideo_config.reverse_param_names_mapping
mapped_state_dict = {}
for key, value in state_dict.items():
mapped_key = key
for pattern, replacement in reverse_mapping.items():
import re
if re.match(pattern, key):
mapped_key = re.sub(pattern, replacement, key)
break
mapped_state_dict[mapped_key] = value
missing, unexpected = fastvideo_model.load_state_dict(mapped_state_dict, strict=False)
print(f"[GameCraft TEST] Missing keys: {len(missing)}")
print(f"[GameCraft TEST] Unexpected keys: {len(unexpected)}")
if missing:
print(f"[GameCraft TEST] First 5 missing: {missing[:5]}")
if unexpected:
print(f"[GameCraft TEST] First 5 unexpected: {unexpected[:5]}")
fastvideo_model = fastvideo_model.to(device=device, dtype=dtype)
fastvideo_model.eval()
_attach_block_logging(
fastvideo_model,
repo_root / "gamecraft_debug" / "fastvideo.log",
"fastvideo",
debug_logs,
)
# Prepare FastVideo inputs
# FastVideo expects encoder_hidden_states as [text_states, text_states_2]
fastvideo_encoder_hidden_states = [text_states, text_states_2]
# Import forward context
from fastvideo.forward_context import set_forward_context
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
forward_batch = ForwardBatch(data_type="video", enable_teacache=False)
# Run FastVideo model
with torch.no_grad():
with set_forward_context(current_timestep=0, attn_metadata=None, forward_batch=forward_batch):
fastvideo_out = fastvideo_model(
x, # Already concatenated [B, 33, T, H, W]
fastvideo_encoder_hidden_states,
t,
camera_states=cam_latents,
encoder_attention_mask=[text_mask],
)
print(f"[GameCraft TEST] FastVideo output shape: {fastvideo_out.shape}")
print(f"[GameCraft TEST] FastVideo output stats: mean={fastvideo_out.mean():.6f}, std={fastvideo_out.std():.6f}")
# Compare outputs using appropriate metrics for numerical parity
print("[GameCraft TEST] Comparing outputs...")
# Compute various metrics
diff = (official_out - fastvideo_out).abs()
max_diff = diff.max().item()
mean_diff = diff.mean().item()
# Correlation (most important for numerical alignment)
corr = torch.corrcoef(torch.stack([
official_out.flatten().float(),
fastvideo_out.flatten().float()
]))[0, 1].item()
# Cosine similarity
cos_sim = torch.nn.functional.cosine_similarity(
official_out.flatten().unsqueeze(0).float(),
fastvideo_out.flatten().unsqueeze(0).float()
).item()
# Sum comparison (should match exactly)
off_sum = official_out.double().sum().item()
fv_sum = fastvideo_out.double().sum().item()
sum_diff_pct = abs(off_sum - fv_sum) / (abs(off_sum) + 1e-10) * 100
print(f"[GameCraft TEST] Max diff: {max_diff:.4f}")
print(f"[GameCraft TEST] Mean diff: {mean_diff:.6f}")
print(f"[GameCraft TEST] Correlation: {corr:.6f}")
print(f"[GameCraft TEST] Cosine similarity: {cos_sim:.6f}")
print(f"[GameCraft TEST] Sum diff: {sum_diff_pct:.4f}%")
# Pass criteria: correlation > 0.99 and cosine_sim > 0.99 and sum_diff < 10%
# Sum diff can be larger due to floating point accumulation differences
assert corr > 0.99, f"Correlation too low: {corr}"
assert cos_sim > 0.99, f"Cosine similarity too low: {cos_sim}"
assert sum_diff_pct < 10.0, f"Sum difference too high: {sum_diff_pct}%"
print("[GameCraft TEST] PASSED: Outputs are numerically aligned!")
print("[GameCraft TEST] Parity test complete!")
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
def test_gamecraft_layer_by_layer():
"""Test layer-by-layer parity to find divergence point."""
import os
os.environ['DISABLE_SP'] = '1'
torch.manual_seed(42)
from fastvideo.distributed.parallel_state import init_distributed_environment, initialize_model_parallel
init_distributed_environment(world_size=1, rank=0, local_rank=0)
initialize_model_parallel(tensor_model_parallel_size=1, sequence_model_parallel_size=1)
official_path = Path(
os.getenv("GAMECRAFT_OFFICIAL_PATH", repo_root / "Hunyuan-GameCraft-1.0")
)
weights_path = Path(
os.getenv(
"GAMECRAFT_WEIGHTS_PATH",
repo_root / "Hunyuan-GameCraft-1.0" / "weights" / "gamecraft_models" / "mp_rank_00_model_states.pt"
)
)
if not official_path.exists():
pytest.skip(f"Official GameCraft repo not found at {official_path}")
if not weights_path.exists():
pytest.skip(f"GameCraft weights not found at {weights_path}")
device = torch.device("cuda:0")
dtype = torch.bfloat16
batch_size = 1
frames = 9
height, width = 44, 80
text_seq_len = 32
# Load official model
print("\n[LAYER TEST] Loading official model...")
official_model = _load_official_model(official_path, weights_path, device, dtype)
# Load FastVideo model
print("[LAYER TEST] Loading FastVideo model...")
from fastvideo.configs.models.dits.hunyuangamecraft import HunyuanGameCraftConfig
from fastvideo.models.dits.hunyuangamecraft import HunyuanGameCraftTransformer3DModel
fastvideo_config = HunyuanGameCraftConfig()
converted_weights_path = repo_root / "official_weights" / "hunyuan-gamecraft" / "transformer"
import json
config_path = converted_weights_path / "config.json"
hf_config = {}
if config_path.exists():
with open(config_path) as f:
hf_config = json.load(f)
fastvideo_model = HunyuanGameCraftTransformer3DModel(config=fastvideo_config, hf_config=hf_config)
from safetensors.torch import load_file
safetensor_files = list(converted_weights_path.glob("*.safetensors"))
if safetensor_files:
state_dict = {}
for sf_file in safetensor_files:
state_dict.update(load_file(sf_file))
reverse_mapping = fastvideo_config.reverse_param_names_mapping
mapped_state_dict = {}
for key, value in state_dict.items():
mapped_key = key
for pattern, replacement in reverse_mapping.items():
import re
if re.match(pattern, key):
mapped_key = re.sub(pattern, replacement, key)
break
mapped_state_dict[mapped_key] = value
fastvideo_model.load_state_dict(mapped_state_dict, strict=False)
fastvideo_model = fastvideo_model.to(device=device, dtype=dtype)
fastvideo_model.eval()
# Create inputs
torch.manual_seed(42)
x = torch.randn(batch_size, 33, frames, height, width, device=device, dtype=dtype)
t = torch.tensor([500.0], device=device, dtype=dtype)
text_states = torch.randn(batch_size, text_seq_len, 4096, device=device, dtype=dtype)
text_states_2 = torch.randn(batch_size, 768, device=device, dtype=dtype)
cam_latents = torch.randn(batch_size, 33, 6, 352, 640, device=device, dtype=dtype)
# RoPE
tt, th, tw = frames, height // 2, width // 2
from fastvideo.layers.rotary_embedding import get_rotary_pos_embed
freqs_cos, freqs_sin = get_rotary_pos_embed((tt, th, tw), 3072, 24, [16, 56, 56], 256.0)
freqs_cos = freqs_cos.to(device=device, dtype=dtype)
freqs_sin = freqs_sin.to(device=device, dtype=dtype)
print("[LAYER TEST] Testing embedding layers...")
# Set up FastVideo context early (needed for txt_in)
from fastvideo.forward_context import set_forward_context
from fastvideo.attention.backends.flash_attn import FlashAttnMetadataBuilder
attn_metadata = FlashAttnMetadataBuilder().build(current_timestep=0, attn_mask=None)
with torch.no_grad(), set_forward_context(current_timestep=0, attn_metadata=attn_metadata):
# === img_in ===
off_img = official_model.img_in(x)
fv_img = fastvideo_model.img_in(x)
diff = (off_img - fv_img).abs().max().item()
print(f" img_in: max_diff={diff:.6f} {'OK' if diff < 1e-3 else 'DIFF!'}")
# === time_in ===
off_time = official_model.time_in(t)
fv_time = fastvideo_model.time_in(t)
diff = (off_time - fv_time).abs().max().item()
print(f" time_in: max_diff={diff:.6f} {'OK' if diff < 1e-3 else 'DIFF!'}")
# === vector_in ===
off_vec = official_model.vector_in(text_states_2)
fv_vec = fastvideo_model.vector_in(text_states_2)
diff = (off_vec - fv_vec).abs().max().item()
print(f" vector_in: max_diff={diff:.6f} {'OK' if diff < 1e-3 else 'DIFF!'}")
# === txt_in ===
off_txt = official_model.txt_in(text_states, t, None)
fv_txt = fastvideo_model.txt_in(text_states, t)
diff = (off_txt - fv_txt).abs().max().item()
print(f" txt_in: max_diff={diff:.6f} {'OK' if diff < 1e-3 else 'DIFF!'}")
# === camera_net ===
off_cam = official_model.camera_net(cam_latents)
fv_cam = fastvideo_model.camera_net(cam_latents)
diff = (off_cam - fv_cam).abs().max().item()
print(f" camera_net: max_diff={diff:.6f} {'OK' if diff < 1e-3 else 'DIFF!'}")
# Combined embeddings
off_img_combined = off_img + off_cam
fv_img_combined = fv_img + fv_cam
off_vec_combined = off_time + off_vec
fv_vec_combined = fv_time + fv_vec
print("\n[LAYER TEST] Testing double blocks (first 5)...")
# cu_seqlens for official model
seq_len = off_img_combined.shape[1] + off_txt.shape[1]
cu_seqlens = torch.tensor([0, seq_len, seq_len], device=device, dtype=torch.int32)
off_img_cur, off_txt_cur = off_img_combined, off_txt
fv_img_cur, fv_txt_cur = fv_img_combined, fv_txt
for i in range(min(5, len(official_model.double_blocks))):
# Official
off_img_out, off_txt_out = official_model.double_blocks[i](
off_img_cur, off_txt_cur, off_vec_combined,
cu_seqlens_q=cu_seqlens, cu_seqlens_kv=cu_seqlens,
max_seqlen_q=seq_len, max_seqlen_kv=seq_len,
freqs_cis=(freqs_cos, freqs_sin), use_sage=False
)
# FastVideo (context already set above)
fv_img_out, fv_txt_out = fastvideo_model.double_blocks[i](
fv_img_cur, fv_txt_cur, fv_vec_combined,
freqs_cis=(freqs_cos, freqs_sin)
)
img_diff = (off_img_out - fv_img_out).abs().max().item()
txt_diff = (off_txt_out - fv_txt_out).abs().max().item()
img_mean_diff = (off_img_out - fv_img_out).abs().mean().item()
status = 'OK' if img_diff < 0.1 else 'DIFF!'
print(f" double_block[{i}]: img_max_diff={img_diff:.4f}, img_mean_diff={img_mean_diff:.6f}, txt_max_diff={txt_diff:.4f} {status}")
# Use outputs for next layer
off_img_cur, off_txt_cur = off_img_out, off_txt_out
fv_img_cur, fv_txt_cur = fv_img_out, fv_txt_out
if img_diff > 1.0:
print(f" [STOPPING] Large divergence at double_block[{i}]")
break
print("\n[LAYER TEST] Done.")
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
def test_gamecraft_cameranet_parity():
"""Test CameraNet component in isolation."""
torch.manual_seed(42)
official_path = Path(
os.getenv("GAMECRAFT_OFFICIAL_PATH", repo_root / "Hunyuan-GameCraft-1.0")
)
weights_path = Path(
os.getenv(
"GAMECRAFT_WEIGHTS_PATH",
repo_root / "Hunyuan-GameCraft-1.0" / "weights" / "gamecraft_models" / "mp_rank_00_model_states.pt"
)
)
if not official_path.exists():
pytest.skip(f"Official GameCraft repo not found at {official_path}")
if not weights_path.exists():
pytest.skip(f"GameCraft weights not found at {weights_path}")
device = torch.device("cuda:0")
dtype = torch.bfloat16
# Add official to path
_add_official_to_path()
try:
from hymm_sp.modules.cameranet import CameraNet
except ImportError as e:
pytest.skip(f"Failed to import CameraNet: {e}")
# Load CameraNet weights
checkpoint = torch.load(weights_path, map_location='cpu')
state_dict = checkpoint['module']
# Extract CameraNet weights
camera_state_dict = {}
for k, v in state_dict.items():
if k.startswith('camera_net.'):
new_key = k.replace('camera_net.', '')
camera_state_dict[new_key] = v
# Initialize CameraNet
camera_net = CameraNet(
in_channels=6,
downscale_coef=8,
out_channels=16,
patch_size=[1, 2, 2],
hidden_size=3072,
)
camera_net.load_state_dict(camera_state_dict, strict=True)
camera_net = camera_net.to(device=device, dtype=dtype)
camera_net.eval()
# Test input
batch_size = 1
num_frames = 17
cam_input = torch.randn(batch_size, num_frames, 6, 352, 640, device=device, dtype=dtype)
with torch.no_grad():
cam_output = camera_net(cam_input)
print(f"[CameraNet TEST] Official input shape: {cam_input.shape}")
print(f"[CameraNet TEST] Official output shape: {cam_output.shape}")
print(f"[CameraNet TEST] Official output stats: mean={cam_output.mean():.6f}, std={cam_output.std():.6f}")
# Load FastVideo CameraNet
print("[CameraNet TEST] Loading FastVideo CameraNet...")
from fastvideo.models.dits.hunyuangamecraft import CameraNet as FastVideoCameraNet
# Initialize FastVideo CameraNet
fastvideo_camera_net = FastVideoCameraNet(
in_channels=6,
downscale_coef=8,
out_channels=16,
patch_size=[1, 2, 2],
hidden_size=3072,
dtype=dtype,
prefix="camera_net",
)
# Map official weights to FastVideo format
fastvideo_camera_state_dict = {}
for k, v in camera_state_dict.items():
new_key = k
# Map camera_in -> PatchEmbed format
if "camera_in.proj" in k:
new_key = k # Keep as-is for now
fastvideo_camera_state_dict[new_key] = v
missing, unexpected = fastvideo_camera_net.load_state_dict(fastvideo_camera_state_dict, strict=False)
print(f"[CameraNet TEST] Missing keys: {len(missing)}, Unexpected keys: {len(unexpected)}")
if missing:
print(f"[CameraNet TEST] Missing: {missing}")
if unexpected:
print(f"[CameraNet TEST] Unexpected: {unexpected}")
fastvideo_camera_net = fastvideo_camera_net.to(device=device, dtype=dtype)
fastvideo_camera_net.eval()
# Run FastVideo CameraNet
with torch.no_grad():
fastvideo_cam_output = fastvideo_camera_net(cam_input)
print(f"[CameraNet TEST] FastVideo output shape: {fastvideo_cam_output.shape}")
print(f"[CameraNet TEST] FastVideo output stats: mean={fastvideo_cam_output.mean():.6f}, std={fastvideo_cam_output.std():.6f}")
# Compare outputs
print("[CameraNet TEST] Comparing outputs...")
try:
assert_close(cam_output, fastvideo_cam_output, atol=0.0, rtol=0.0)
print("[CameraNet TEST] PASSED: CameraNet outputs match!")
except AssertionError as e:
diff = (cam_output - fastvideo_cam_output).abs()
print(f"[CameraNet TEST] Max diff: {diff.max():.6f}")
print(f"[CameraNet TEST] Mean diff: {diff.mean():.6f}")
print(f"[CameraNet TEST] FAILED: {e}")
raise
print("[CameraNet TEST] CameraNet parity test complete!")
if __name__ == "__main__":
# Run tests directly
test_gamecraft_cameranet_parity()
test_gamecraft_transformer_parity()
@@ -1,292 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""
Numerical parity test for GameCraft VAE (AutoencoderKLCausal3D).
Ported from official Hunyuan-GameCraft-1.0/hymm_sp/vae. Compares FastVideo's
GameCraftVAE against the official implementation.
Follows the same pattern as test_ltx2_vae.py:
- Loads same weights into both models (strict key checking)
- Encodes same input, compares latents
- Decodes same latents through both decoders, compares output
- Checks for non-finite values
Usage:
DISABLE_SP=1 pytest tests/local_tests/vaes/test_gamecraft_vae_parity.py -v
"""
import json
import os
import sys
from pathlib import Path
import pytest
import torch
from torch.testing import assert_close
os.environ.setdefault("MASTER_ADDR", "localhost")
os.environ.setdefault("MASTER_PORT", "29515")
os.environ.setdefault("DISABLE_SP", "1")
repo_root = Path(__file__).resolve().parents[3]
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
def _load_weights(weights_path: Path) -> dict[str, torch.Tensor]:
"""Load VAE weights from checkpoint and strip 'vae.' prefix."""
print(f"[VAE TEST] Loading weights from {weights_path}")
state_dict = torch.load(weights_path, map_location="cpu", weights_only=True)
if "state_dict" in state_dict:
state_dict = state_dict["state_dict"]
# Strip 'vae.' prefix
vae_sd = {k.replace("vae.", ""): v for k, v in state_dict.items() if k.startswith("vae.")}
print(f"[VAE TEST] Loaded {len(vae_sd)} VAE tensors")
return vae_sd
def _load_into_model(
model: torch.nn.Module, weights: dict[str, torch.Tensor], name: str
) -> tuple[int, list[str]]:
"""Load weights into a model, checking for missing/unexpected keys."""
model_state = model.state_dict()
filtered = {
k: v for k, v in weights.items()
if k in model_state and model_state[k].shape == v.shape
}
missing = [k for k in model_state.keys() if k not in filtered]
unexpected = [k for k in weights.keys() if k not in model_state]
print(
f"[VAE TEST] {name}: Loading {len(filtered)}/{len(model_state)} tensors "
f"({len(missing)} missing, {len(unexpected)} unexpected)"
)
if missing:
print(f"[VAE TEST] {name} missing keys: {missing[:5]}{'...' if len(missing) > 5 else ''}")
if not filtered:
return 0, missing
model.load_state_dict(filtered, strict=False)
return len(filtered), missing
def _load_official_vae(
official_path: Path, vae_sd: dict[str, torch.Tensor],
device: torch.device, dtype: torch.dtype
):
"""Load the official GameCraft VAE from hymm_sp/vae."""
sys.path.insert(0, str(official_path))
try:
from hymm_sp.vae.autoencoder_kl_causal_3d import AutoencoderKLCausal3D
except ImportError as e:
pytest.skip(f"Failed to import official VAE: {e}")
config_path = (
official_path / "weights" / "stdmodels" / "vae_3d" / "hyvae" / "config.json"
)
if not config_path.exists():
pytest.skip(f"VAE config not found at {config_path}")
with open(config_path) as f:
config = json.load(f)
vae = AutoencoderKLCausal3D(
in_channels=config.get("in_channels", 3),
out_channels=config.get("out_channels", 3),
down_block_types=tuple(config.get("down_block_types", [])),
up_block_types=tuple(config.get("up_block_types", [])),
block_out_channels=tuple(config.get("block_out_channels", [])),
layers_per_block=config.get("layers_per_block", 2),
act_fn=config.get("act_fn", "silu"),
latent_channels=config.get("latent_channels", 16),
norm_num_groups=config.get("norm_num_groups", 32),
sample_size=config.get("sample_size", 256),
sample_tsize=config.get("sample_tsize", 64),
scaling_factor=config.get("scaling_factor", 0.476986),
time_compression_ratio=config.get("time_compression_ratio", 4),
mid_block_add_attention=config.get("mid_block_add_attention", True),
mid_block_causal_attn=config.get("mid_block_causal_attn", True),
)
loaded, missing = _load_into_model(vae, vae_sd, "Official VAE")
if loaded == 0:
pytest.skip("Failed to load weights into official VAE")
if missing:
pytest.skip(f"Official VAE has {len(missing)} missing keys; cannot ensure parity")
if not hasattr(vae, "use_trt_decoder"):
vae.use_trt_decoder = False
return vae.to(device=device, dtype=dtype).eval()
def _load_fastvideo_vae(
vae_sd: dict[str, torch.Tensor], device: torch.device, dtype: torch.dtype
):
"""Load FastVideo GameCraftVAE with official weights."""
from fastvideo.configs.models.vaes import GameCraftVAEConfig
from fastvideo.models.vaes.gamecraftvae import GameCraftVAE
config = GameCraftVAEConfig()
vae = GameCraftVAE(config)
loaded, missing = _load_into_model(vae, vae_sd, "FastVideo VAE")
if loaded == 0:
pytest.skip("Failed to load weights into FastVideo VAE")
if missing:
pytest.skip(f"FastVideo VAE has {len(missing)} missing keys; cannot ensure parity")
return vae.to(device=device, dtype=dtype).eval()
def test_gamecraft_vae_parity():
"""Test VAE encode/decode parity between official and FastVideo.
Key: decodes the SAME latents through both decoders to isolate
decode-only differences (following LTX2 VAE test pattern).
"""
torch.manual_seed(42)
official_path = Path(
os.getenv("GAMECRAFT_OFFICIAL_PATH", repo_root / "Hunyuan-GameCraft-1.0")
)
vae_weights_path = Path(
os.getenv(
"GAMECRAFT_VAE_PATH",
repo_root / "Hunyuan-GameCraft-1.0" / "weights" / "stdmodels" / "vae_3d" / "hyvae" / "pytorch_model.pt",
)
)
if not official_path.exists():
pytest.skip(f"Official GameCraft repo not found at {official_path}")
if not vae_weights_path.exists():
alt_paths = [
vae_weights_path.parent / "checkpoint-step-270000.ckpt",
vae_weights_path.parent / "diffusion_pytorch_model.safetensors",
]
for alt in alt_paths:
if alt.exists():
vae_weights_path = alt
break
else:
pytest.skip(f"VAE weights not found. Tried: {vae_weights_path}, {alt_paths}")
# Use fp32 for precise comparison (bf16 amplifies small differences)
use_cuda = torch.cuda.is_available()
try:
if use_cuda:
_ = torch.zeros(1, device="cuda:0")
del _
torch.cuda.empty_cache()
except Exception:
use_cuda = False
device = torch.device("cuda:0" if use_cuda else "cpu")
# Use fp32 for parity test precision (bf16 gives ~0.2 max diff due to precision)
dtype = torch.float32
# Load weights once, share between both models
vae_sd = _load_weights(vae_weights_path)
batch_size = 1
frames = 5
height = 64
width = 64
video_input = torch.randn(batch_size, 3, frames, height, width, device=device, dtype=dtype)
print(f"\n[VAE TEST] Input shape: {video_input.shape}, dtype: {dtype}")
# --- Encode comparison ---
print("\n[VAE TEST] Loading official VAE...")
official_vae = _load_official_vae(official_path, vae_sd, device, dtype)
with torch.no_grad():
official_latent_dist = official_vae.encode(video_input)
if hasattr(official_latent_dist, "latent_dist"):
official_latent_dist = official_latent_dist.latent_dist
official_latents = official_latent_dist.mode()
print("[VAE TEST] Loading FastVideo GameCraftVAE...")
fastvideo_vae = _load_fastvideo_vae(vae_sd, device, dtype)
with torch.no_grad():
fv_latent_dist = fastvideo_vae.encode(video_input)
fv_latents = fv_latent_dist.latent_dist.mode()
print(f"[VAE TEST] Official latents: {official_latents.shape}")
print(f"[VAE TEST] FastVideo latents: {fv_latents.shape}")
assert official_latents.shape == fv_latents.shape, "Latent shape mismatch"
assert torch.isfinite(official_latents).all(), "Official encoder produced non-finite latents"
assert torch.isfinite(fv_latents).all(), "FastVideo encoder produced non-finite latents"
enc_diff = (official_latents - fv_latents).abs().max().item()
enc_mean = (official_latents - fv_latents).abs().mean().item()
print(f"[VAE TEST] Encode max diff: {enc_diff:.6f}, mean diff: {enc_mean:.6f}")
assert_close(official_latents, fv_latents, atol=1e-2, rtol=1e-2)
# --- Decode comparison (SAME latents through both decoders) ---
# Use official latents as the shared input to isolate decode-only differences
shared_latents = official_latents
with torch.no_grad():
official_decoded = official_vae.decode(shared_latents)
if hasattr(official_decoded, "sample"):
official_decoded = official_decoded.sample
# Free official VAE memory before loading FastVideo decode
del official_vae
if torch.cuda.is_available():
torch.cuda.empty_cache()
with torch.no_grad():
fv_decoded = fastvideo_vae.decode(shared_latents)
if hasattr(fv_decoded, "sample"):
fv_decoded = fv_decoded.sample
print(f"[VAE TEST] Official decoded: {official_decoded.shape}")
print(f"[VAE TEST] FastVideo decoded: {fv_decoded.shape}")
assert official_decoded.shape == fv_decoded.shape, "Decoded shape mismatch"
assert torch.isfinite(official_decoded).all(), "Official decoder produced non-finite output"
assert torch.isfinite(fv_decoded).all(), "FastVideo decoder produced non-finite output"
dec_diff = (official_decoded - fv_decoded).abs().max().item()
dec_mean = (official_decoded - fv_decoded).abs().mean().item()
print(f"[VAE TEST] Decode max diff: {dec_diff:.6f}, mean diff: {dec_mean:.6f}")
assert_close(official_decoded, fv_decoded, atol=1e-2, rtol=1e-2)
print("[VAE TEST] PASSED: Numerical parity between official and FastVideo!")
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
def test_gamecraft_vae_config_compatibility():
"""Test that GameCraft VAE config is compatible with FastVideo's GameCraftVAE."""
vae_config_path = (
repo_root / "Hunyuan-GameCraft-1.0" / "weights" / "stdmodels" / "vae_3d" / "hyvae" / "config.json"
)
if not vae_config_path.exists():
pytest.skip(f"VAE config not found at {vae_config_path}")
with open(vae_config_path) as f:
gc_config = json.load(f)
print("\n[VAE CONFIG TEST] GameCraft VAE config:")
for k, v in gc_config.items():
print(f" {k}: {v}")
# Expected HunyuanVideo VAE config
expected_config = {
"in_channels": 3,
"out_channels": 3,
"latent_channels": 16,
"block_out_channels": [128, 256, 512, 512],
"time_compression_ratio": 4,
"scaling_factor": 0.476986,
}
# Verify compatibility
for key, expected in expected_config.items():
if key in gc_config:
actual = gc_config[key]
assert actual == expected, f"Config mismatch for {key}: expected {expected}, got {actual}"
print(f"[VAE CONFIG TEST] {key}: OK (matches expected)")
print("[VAE CONFIG TEST] Config compatibility check passed!")
if __name__ == "__main__":
test_gamecraft_vae_config_compatibility()
test_gamecraft_vae_parity()