Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d03ffcb5f7 |
@@ -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
|
||||
|
||||
@@ -18,7 +18,6 @@ venv/
|
||||
.venv/
|
||||
runs/
|
||||
samples/
|
||||
Miniconda3-latest-Linux-x86_64.sh
|
||||
*validation/
|
||||
data/
|
||||
outputs/
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+46
-313
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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,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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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}")
|
||||
@@ -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
@@ -66,7 +66,7 @@ dependencies = [
|
||||
"remote-pdb",
|
||||
|
||||
# Kernel & Packaging
|
||||
"fastvideo-kernel==0.2.6",
|
||||
"fastvideo-kernel==0.2.5",
|
||||
"wheel",
|
||||
|
||||
# Training Dependencies
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user