Files
Sammir Alejandro Femat RoblesandClaude Opus 4.8 42ba6363f2 chore(repo): elimina el harness de Modal y toda referencia — solo nodo funcional
modal/ (validación en GPU serverless) no es parte del nodo de ComfyUI: se quita del
repo entero. Eliminadas todas las menciones a Modal en README (sección "Validate on
Modal" + referencias), model.py, .gitignore, tools/ y tests/. El repo queda con el
runtime funcional: bernini/, nodes.py, workflows/, tools/, tests/ y el CI de publish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 02:42:40 -06:00

116 lines
4.6 KiB
Python

# Copyright (c) 2026 — ComfyUI-BerniniR. Apache-2.0.
"""Test de *wiring* end-to-end en CPU con pesos aleatorios.
NO valida calidad ni fidelidad numérica contra Bernini-R (eso requiere los pesos
reales en GPU). Aquí solo comprobamos que el ENSAMBLADO está bien
conectado: dos `WanTransformer3DModel` diminutos -> `BerniniExpert` ->
`BerniniRenderer` -> `BerniniSampler` corren los modos de guía sin excepción,
emiten un latente espacial `[1, 16, T, H, W]` y no producen NaN.
Diseño:
* Modelos diminutos (num_layers=2, heads=4, head_dim=32, ffn=64) con pesos
aleatorios — caben en CPU y corren en segundos.
* Entradas pequeñas: 5 frames, 64x64 -> latente (1,16,2,8,8); 1 vídeo fuente y
2 imágenes de referencia; 2 pasos de denoising.
* Tres modos representativos: `t2v` (ancla: solo target, CFG sobre texto),
`rv2v` (4 forwards, CFG encadenado lineal sobre vídeo+referencias) y
`r2v_apg` (APG encadenada en x-space con las referencias).
Se puede correr con pytest o directamente: python tests/test_wiring.py
"""
import os
import sys
import torch
# raíz del repo en el path para `import bernini`
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
from diffusers import WanTransformer3DModel # noqa: E402
from bernini.model import BerniniExpert, BerniniRenderer # noqa: E402
from bernini.sampler import BerniniSampler # noqa: E402
from bernini import latents as L # noqa: E402
# ---- config de un experto DIMINUTO (dim = heads*head_dim = 4*32 = 128) -------
TINY_CFG = dict(
patch_size=(1, 2, 2),
num_attention_heads=4,
attention_head_dim=32,
in_channels=16,
out_channels=16,
text_dim=4096,
freq_dim=256,
ffn_dim=64,
num_layers=2,
cross_attn_norm=True,
qk_norm="rms_norm_across_heads",
eps=1e-6,
rope_max_seq_len=1024,
)
# 5 frames, 64x64 -> latente Wan (1, 16, T_lat=2, H/8=8, W/8=8)
NUM_FRAMES, HEIGHT, WIDTH = 5, 64, 64
DEVICE = "cpu"
MODES = ["t2v", "rv2v", "r2v_apg"]
def _tiny_transformer(seed: int) -> WanTransformer3DModel:
torch.manual_seed(seed)
m = WanTransformer3DModel(**TINY_CFG)
return m.eval().requires_grad_(False)
def build_renderer() -> BerniniRenderer:
"""Dos expertos (high/low-noise) con pesos aleatorios distintos."""
hi = BerniniExpert(_tiny_transformer(0))
lo = BerniniExpert(_tiny_transformer(1))
return BerniniRenderer(high=hi, low=lo)
def _make_inputs():
target_shape = L.latent_shape(num_frames=NUM_FRAMES, height=HEIGHT, width=WIDTH)
g = torch.Generator(device=DEVICE).manual_seed(1234)
# vídeo fuente: mismo layout temporal que el target (T_lat=2)
video = torch.randn(1, 16, target_shape[2], target_shape[3], target_shape[4],
generator=g, dtype=torch.float32)
# imágenes de referencia: un solo fotograma (T_lat=1)
img0 = torch.randn(1, 16, 1, target_shape[3], target_shape[4], generator=g, dtype=torch.float32)
img1 = torch.randn(1, 16, 1, target_shape[3], target_shape[4], generator=g, dtype=torch.float32)
# embeds de texto ficticios [1, S_txt, text_dim] (solo importa la forma)
pos = torch.randn(1, 16, TINY_CFG["text_dim"], generator=g, dtype=torch.float32)
neg = torch.randn(1, 16, TINY_CFG["text_dim"], generator=g, dtype=torch.float32)
return target_shape, [video], [img0, img1], pos, neg
def _run_mode(renderer: BerniniRenderer, mode: str) -> torch.Tensor:
target_shape, vids, imgs, pos, neg = _make_inputs()
sampler = BerniniSampler(renderer, mode, dict(num_inference_steps=2), offload_experts=False)
out = sampler.sample(vids, imgs, pos, neg, target_shape, device=DEVICE, seed=0)
assert isinstance(out, torch.Tensor), f"[{mode}] salida no es Tensor"
assert tuple(out.shape) == tuple(target_shape), \
f"[{mode}] shape {tuple(out.shape)} != esperado {tuple(target_shape)}"
assert out.ndim == 5 and out.shape[0] == 1 and out.shape[1] == 16, \
f"[{mode}] no es [1, 16, T, H, W]: {tuple(out.shape)}"
assert not torch.isnan(out).any(), f"[{mode}] la salida contiene NaN"
assert torch.isfinite(out).all(), f"[{mode}] la salida contiene Inf"
return out
def test_wiring():
"""Corre los 3 modos sobre el mismo renderer y valida shape + finitud."""
renderer = build_renderer()
for mode in MODES:
out = _run_mode(renderer, mode)
print(f"[ok] {mode:>8}: salida {tuple(out.shape)} {out.dtype} "
f"min={out.min().item():+.3f} max={out.max().item():+.3f}")
if __name__ == "__main__":
test_wiring()
print("\n[OK] wiring t2v / rv2v / r2v_apg pasó (shape [1,16,2,8,8], sin NaN).")