[feat] cosmos3 PR2: t2vs pipeline (joint denoise + AVAE decode + AV mux)

Wire text-to-video+sound end-to-end, completing the audio PR.

- CFG velocity (`cosmos3_get_cfg_velocity`) + `Cosmos3DenoiseEngine` take an
  optional `sound_specs`: the denoise latent becomes a combined [vision | sound]
  flat vector; each step packs vision+sound, forwards the DiT, zeros the
  prediction on conditioning frames for both, and returns the concatenated
  velocity. The UniPC scheduler steps the joint vector. `Cosmos3SoundSpec` +
  `_split_sound_latent` added.
- `Cosmos3DenoisingStage` t2vs path (gated on `COSMOS3_T2VS`): size the sound
  latent from the video duration (framework `create_placeholder_audio` +
  `get_latent_num_samples`), append sound noise, joint-denoise, split, AVAE-decode
  the sound latent, and set `batch.extra["audio"]` / `["audio_sample_rate"]` so
  the generator muxes a stereo 48 kHz AAC track into the mp4. Sound AVAE is
  lazy-loaded from `<model_path>/sound_tokenizer` (`_get_sound_vae`).
- Parity (`test_cosmos3_sound_parity::test_t2vs_cfg_velocity_matches_framework`):
  the combined [vision|sound] sequential-CFG velocity vs a framework-DiT oracle —
  max abs diff = 0.0, mean abs diff = 0.0 across t2vs/i2vs cases.
- Example `basic_cosmos3_t2vs_new_api.py`.

Verified on B200 (real weights, 1280x704, 29f, 35 steps): coherent ocean/sunset
video WITH a real stereo 48 kHz audio track (mean -10.2 dB, peak -0.4 dB; not
silence). Full cosmos3 suite: 130 passed, 0 skipped.
This commit is contained in:
SolitaryThinker
2026-07-20 12:19:46 -07:00
parent d6affff3a1
commit eb9d439bab
4 changed files with 261 additions and 17 deletions
@@ -0,0 +1,67 @@
import os
import time
# t2vs (text -> video + sound). The Cosmos3 denoise stage generates a joint
# [vision | sound] latent and AVAE-decodes the sound to a waveform muxed into the
# mp4. The joint-sound path is gated on COSMOS3_T2VS (set here for the example).
os.environ.setdefault("COSMOS3_T2VS", "1")
from fastvideo import VideoGenerator # noqa: E402
from fastvideo.api import ( # noqa: E402
EngineConfig,
GenerationRequest,
GeneratorConfig,
OffloadConfig,
OutputConfig,
SamplingConfig,
)
OUTPUT_PATH = "video_samples_cosmos3_t2vs"
def main():
model_name = os.environ.get("COSMOS3_MODEL_PATH", "nvidia/Cosmos3-Nano")
generator_config = GeneratorConfig(
model_path=model_name,
engine=EngineConfig(
num_gpus=1,
use_fsdp_inference=False,
offload=OffloadConfig(text_encoder=True, pin_cpu_memory=True, dit=False, vae=False),
),
)
load_start = time.perf_counter()
generator = VideoGenerator.from_config(generator_config)
load_time = time.perf_counter() - load_start
prompt = (
"Ocean waves crash against a rocky shore at sunset, white foam spraying "
"into the air as seagulls wheel overhead. Golden light, cinematic wide "
"shot, the rhythmic roar of the surf."
)
request = GenerationRequest(
prompt=prompt,
sampling=SamplingConfig(
num_frames=int(os.environ.get("COSMOS3_NUM_FRAMES", "189")),
height=int(os.environ.get("COSMOS3_HEIGHT", "704")),
width=int(os.environ.get("COSMOS3_WIDTH", "1280")),
num_inference_steps=int(os.environ.get("COSMOS3_STEPS", "35")),
guidance_scale=6.0,
fps=24,
seed=1024,
),
output=OutputConfig(output_path=OUTPUT_PATH, save_video=True, return_frames=False),
)
start = time.perf_counter()
result = generator.generate(request)
gen_time = time.perf_counter() - start
print(f"Time taken to load model: {load_time} seconds")
print(f"Time taken to generate video+sound: {gen_time} seconds")
print(f"Output written to: {result.video_path}")
if __name__ == "__main__":
main()
@@ -38,7 +38,7 @@ third-party, both explicitly allowed.
from __future__ import annotations
import math
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
import torch
@@ -49,6 +49,7 @@ from fastvideo.models.schedulers.scheduling_unipc_multistep import (
UniPCMultistepScheduler, )
from fastvideo.pipelines.basic.cosmos3.sequence_packing import (
Cosmos3SampleInputs,
Cosmos3SoundItem,
Cosmos3VisionItem,
pack_cosmos3_video_sequence,
)
@@ -191,6 +192,35 @@ def _split_flat_latent(flat: torch.Tensor, specs: list[Cosmos3VisionSpec]) -> li
return out
@dataclass
class Cosmos3SoundSpec:
"""Geometry + conditioning for one sound item in a denoise run.
Args:
shape: ``(C, T)`` of the sound latent (channels, temporal frames).
condition_frame_indexes: Latent-frame indices kept clean (``[]`` for t2vs).
fps: Sound latent FPS (``sound_latent_fps``); used iff fps modulation is on.
"""
shape: tuple[int, int]
condition_frame_indexes: list[int] = field(default_factory=list)
fps: float | None = None
@property
def numel(self) -> int:
return int(math.prod(self.shape))
def _split_sound_latent(flat: torch.Tensor, specs: list[Cosmos3SoundSpec]) -> list[torch.Tensor]:
"""Split a flat sound vector into per-item ``[C, T]`` tensors."""
out: list[torch.Tensor] = []
offset = 0
for spec in specs:
out.append(flat[offset:offset + spec.numel].reshape(spec.shape))
offset += spec.numel
return out
def cosmos3_get_cfg_velocity(
*,
transformer: Any,
@@ -210,6 +240,8 @@ def cosmos3_get_cfg_velocity(
include_end_of_generation_token: bool = False,
fps_per_item: list[float] | None = None,
normalize_cfg: bool = False,
sound_specs: list[Cosmos3SoundSpec] | None = None,
sound_fps_per_item: list[float] | None = None,
) -> torch.Tensor:
"""Sequential-CFG velocity for one denoise step (framework math).
@@ -231,10 +263,23 @@ def cosmos3_get_cfg_velocity(
assert timestep.numel() == 1, "timestep must be a scalar"
timestep_value = float(timestep.reshape(()).item())
noise_x_vision = _split_flat_latent(flat_latent, specs)
# Combined flat layout (t2vs): [all vision | all sound], matching the
# framework per-sample concat order ([vision_i | sound_i]); single sample here.
vision_total = sum(spec.numel for spec in specs)
noise_x_vision = _split_flat_latent(flat_latent[:vision_total], specs)
noise_x_sound = (_split_sound_latent(flat_latent[vision_total:], sound_specs) if sound_specs else None)
device = next(transformer.parameters()).device
def _run(token_ids: list[int]) -> torch.Tensor:
sound_items: list[Cosmos3SoundItem] = []
if sound_specs is not None and noise_x_sound is not None:
sound_items = [
Cosmos3SoundItem(
latent=noise_x_sound[i],
condition_frame_indexes=list(ss.condition_frame_indexes),
fps=(sound_fps_per_item[i] if sound_fps_per_item is not None else None),
) for i, ss in enumerate(sound_specs)
]
samples = [
Cosmos3SampleInputs(
text_ids=list(token_ids),
@@ -243,6 +288,7 @@ def cosmos3_get_cfg_velocity(
condition_frame_indexes=list(spec.condition_frame_indexes),
fps=(fps_per_item[i] if fps_per_item is not None else None),
),
sound=(sound_items[i] if i < len(sound_items) else None),
timestep=timestep_value,
) for i, (latent, spec) in enumerate(zip(noise_x_vision, specs, strict=False))
]
@@ -258,20 +304,35 @@ def cosmos3_get_cfg_velocity(
temporal_compression_factor=temporal_compression_factor,
)
out = transformer(**packed.to_dit_kwargs(device=device))
# Vision velocity: zero on conditioning frames, per item, flattened.
vision_vel = torch.zeros(vision_total, device=flat_latent.device, dtype=flat_latent.dtype)
preds = out.get("preds_vision")
if preds is None:
# Fully-conditioned clip: no noisy frames, zero velocity.
return torch.zeros_like(flat_latent)
# Zero the prediction on conditioning frames, per item.
velocity_items: list[torch.Tensor] = []
for pred, cond_mask in zip(preds, packed.vision_condition_mask, strict=False):
pred = pred.squeeze(0) if pred.dim() == 5 else pred # [C, T, H, W]
keep = (1.0 - cond_mask).to(dtype=pred.dtype, device=pred.device) # [T,1,1]
if keep.sum() > 0:
velocity_items.append(pred * keep)
else:
velocity_items.append(torch.zeros_like(pred))
return torch.cat([v.reshape(-1) for v in velocity_items])
if preds is not None:
items: list[torch.Tensor] = []
for pred, cond_mask in zip(preds, packed.vision_condition_mask, strict=False):
pred = pred.squeeze(0) if pred.dim() == 5 else pred # [C, T, H, W]
keep = (1.0 - cond_mask).to(dtype=pred.dtype, device=pred.device) # [T,1,1]
items.append(pred * keep if keep.sum() > 0 else torch.zeros_like(pred))
vision_vel = torch.cat([v.reshape(-1) for v in items]).to(flat_latent.dtype)
if not sound_specs:
return vision_vel
# Sound velocity: preds_sound are per-item [C, T], already zero on clean
# frames (unpack fills only noisy frames); zero on cond frames defensively.
sound_total = sum(spec.numel for spec in sound_specs)
sound_vel = torch.zeros(sound_total, device=flat_latent.device, dtype=flat_latent.dtype)
preds_s = out.get("preds_sound")
if preds_s is not None:
s_items: list[torch.Tensor] = []
for pred, cond_mask in zip(preds_s, packed.sound_condition_mask, strict=False):
pred = pred.squeeze(0) if pred.dim() == 3 else pred # [C, T]
keep = (1.0 - cond_mask).reshape(1, -1).to(dtype=pred.dtype, device=pred.device) # [1, T]
s_items.append(pred * keep)
sound_vel = torch.cat([v.reshape(-1) for v in s_items]).to(flat_latent.dtype)
return torch.cat([vision_vel, sound_vel])
cond_v = _run(cond_token_ids)
uncond_v = _run(uncond_token_ids)
@@ -325,6 +386,8 @@ class Cosmos3DenoiseEngine:
cond_token_ids: list[int],
uncond_token_ids: list[int],
fps_per_item: list[float] | None = None,
sound_specs: list[Cosmos3SoundSpec] | None = None,
sound_fps_per_item: list[float] | None = None,
) -> torch.Tensor:
return cosmos3_get_cfg_velocity(
transformer=self.transformer,
@@ -343,6 +406,8 @@ class Cosmos3DenoiseEngine:
temporal_compression_factor=self.temporal_compression_factor,
include_end_of_generation_token=self.include_end_of_generation_token,
fps_per_item=fps_per_item,
sound_specs=sound_specs,
sound_fps_per_item=sound_fps_per_item,
)
def denoise(
@@ -356,12 +421,16 @@ class Cosmos3DenoiseEngine:
uncond_token_ids: list[int],
fps_per_item: list[float] | None = None,
progress_bar: Any | None = None,
sound_specs: list[Cosmos3SoundSpec] | None = None,
sound_fps_per_item: list[float] | None = None,
) -> torch.Tensor:
"""Run the full UniPC denoise loop, returning the final flat latent.
For each timestep: compute the sequential-CFG velocity, then
``scheduler.step(model_output=v, timestep, sample=latent.unsqueeze(0))``
(the framework steps with a leading batch axis), squeezing back to flat.
For t2vs the flat latent is ``[vision | sound]`` and the velocity covers
both; the scheduler steps the combined vector jointly.
"""
latent = flat_latent
iterator = progress_bar(timesteps) if progress_bar is not None else timesteps
@@ -374,6 +443,8 @@ class Cosmos3DenoiseEngine:
cond_token_ids=cond_token_ids,
uncond_token_ids=uncond_token_ids,
fps_per_item=fps_per_item,
sound_specs=sound_specs,
sound_fps_per_item=sound_fps_per_item,
)
stepped = self.scheduler.step(
model_output=v_pred,
+55 -2
View File
@@ -20,6 +20,7 @@ This mirrors the framework ``Cosmos3OmniDiffusersPipeline.__call__``.
"""
from __future__ import annotations
import os
import weakref
from typing import Any
@@ -31,6 +32,7 @@ from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
from fastvideo.pipelines.basic.cosmos3.cosmos3_pipeline import (
Cosmos3DenoiseEngine,
Cosmos3SoundSpec,
Cosmos3VisionSpec,
_VaeNorm,
cosmos3_special_tokens,
@@ -186,6 +188,29 @@ class Cosmos3DenoisingStage(PipelineStage):
flat_latent = init_latent.reshape(-1)
fps_per_item = [fps] if bool(arch.enable_fps_modulation) else None
# ---- t2vs: jointly generate sound (combined [vision | sound] latent) ----
# Mirrors the framework: a placeholder audio sized to the video duration
# sets the sound latent length; sound shares the denoise/CFG with vision.
with_audio = is_video and os.environ.get("COSMOS3_T2VS", "") not in ("", "0")
sound_specs = None
sound_fps_per_item = None
sound_vae = None
sound_shape: tuple[int, int] | None = None
if with_audio:
sound_vae = self._get_sound_vae(pipe, device, dtype)
sound_dim = int(arch.sound_dim)
sound_latent_fps = float(arch.sound_latent_fps)
# Framework ``create_placeholder_audio`` + ``get_latent_num_samples``.
num_audio_samples = int(num_frames / fps * sound_vae.sample_rate)
sound_latent_t = max(1, sound_vae.get_latent_num_samples(num_audio_samples))
sound_shape = (sound_dim, sound_latent_t)
sound_noise = randn_tensor((sound_dim, sound_latent_t), generator=generator, device=device,
dtype=dtype).float()
flat_latent = torch.cat([flat_latent, sound_noise.reshape(-1)])
sound_specs = [Cosmos3SoundSpec(shape=sound_shape, condition_frame_indexes=[], fps=sound_latent_fps)]
sound_fps_per_item = [sound_latent_fps] if bool(arch.enable_fps_modulation) else None
final_flat = engine.denoise(
flat_latent=flat_latent,
timesteps=timesteps,
@@ -195,15 +220,25 @@ class Cosmos3DenoisingStage(PipelineStage):
uncond_token_ids=uncond_ids,
fps_per_item=fps_per_item,
progress_bar=lambda it: tqdm(it, desc="Cosmos3 denoising"),
sound_specs=sound_specs,
sound_fps_per_item=sound_fps_per_item,
)
# ---- Decode: [C, T, H, W] -> pixels [B, 3, T, H, W] in [0, 1] ----
result_latent = final_flat.reshape(latent_shape).unsqueeze(0).to(device=device, dtype=dtype)
# ---- Decode vision: [C, T, H, W] -> pixels [B, 3, T, H, W] in [0, 1] ----
vision_flat = final_flat[:spec.numel]
result_latent = vision_flat.reshape(latent_shape).unsqueeze(0).to(device=device, dtype=dtype)
decoded = cosmos3_vae_decode(self.vae, result_latent, norm) # [B, 3, T, H, W] in [-1, 1]
video = ((1.0 + decoded) / 2.0).clamp(0.0, 1.0)
batch.latents = result_latent
batch.output = video
# ---- Decode sound: AVAE latent [C, T] -> waveform [C, N] in [-1, 1] ----
if with_audio and sound_vae is not None and sound_shape is not None:
sound_latent = final_flat[spec.numel:].reshape(sound_shape).unsqueeze(0).to(device=device, dtype=dtype)
waveform = sound_vae.decode(sound_latent) # [1, C_audio, N]
batch.extra["audio"] = waveform[0].detach().float().cpu() # [C_audio, N]
batch.extra["audio_sample_rate"] = int(sound_vae.sample_rate)
return batch
# ------------------------------------------------------------------
@@ -223,6 +258,24 @@ class Cosmos3DenoisingStage(PipelineStage):
img = TF.resize(img, [resize_h, resize_w])
return TF.center_crop(img, [target_h, target_w])
@staticmethod
def _get_sound_vae(pipe: Any, device: torch.device, dtype: torch.dtype) -> Any:
"""Lazily load + cache the Cosmos3 sound AVAE decoder from the checkpoint.
The video path does not load ``sound_tokenizer``; t2vs needs only its
decoder, so we load it on first use from ``<model_path>/sound_tokenizer``.
"""
cached = getattr(pipe, "_sound_vae", None) if pipe is not None else None
if cached is not None:
return cached
from fastvideo.models.audio.cosmos3_avae import Cosmos3SoundVAE
model_path = pipe.model_path
sound_dir = os.path.join(model_path, "sound_tokenizer")
sound_vae = Cosmos3SoundVAE.from_pretrained(sound_dir, torch_dtype=dtype).to(device)
if pipe is not None:
pipe._sound_vae = sound_vae
return sound_vae
@classmethod
def _image_to_video_tensor(
cls,
@@ -233,3 +233,56 @@ class TestCosmos3SoundParity:
torch.testing.assert_close(fv_out["preds_vision"][0], fw_out["preds_vision"][0], atol=1e-4, rtol=1e-3)
torch.testing.assert_close(fv_out["preds_sound"][0], fw_out["preds_sound"][0], atol=1e-4, rtol=1e-3)
torch.testing.assert_close(fv_on_fw["preds_sound"][0], fw_out["preds_sound"][0], atol=1e-4, rtol=1e-3)
@pytest.mark.parametrize(("grid_t", "lh", "lw", "snd_t", "n_text", "cond_v", "cond_s"), _CASES)
def test_t2vs_cfg_velocity_matches_framework(self, grid_t, lh, lw, snd_t, n_text, cond_v, cond_s):
"""The combined [vision|sound] sequential-CFG velocity (the t2vs denoise
step's pipeline glue) matches a framework-DiT oracle."""
from fastvideo.pipelines.basic.cosmos3.cosmos3_pipeline import (
Cosmos3SoundSpec,
Cosmos3VisionSpec,
cosmos3_get_cfg_velocity,
)
vfm, dit = self._build()
vision_shape = (_LATENT_CHANNEL, grid_t, lh // _LATENT_PATCH_SIZE, lw // _LATENT_PATCH_SIZE)
# _make_inputs builds the vision LATENT [1,C,T,H,W]; here we drive the
# combined flat latent directly, so use the un-patchified latent shape.
vlat_shape = (_LATENT_CHANNEL, grid_t, lh, lw)
sound_shape = (_SOUND_DIM, snd_t)
torch.manual_seed(3)
cond_ids = torch.randint(0, 60, (n_text,)).tolist()
uncond_ids = torch.randint(0, 60, (max(1, n_text - 1),)).tolist()
vis_numel = int(torch.tensor(vlat_shape).prod())
snd_numel = int(torch.tensor(sound_shape).prod())
flat = torch.randn(vis_numel + snd_numel)
guidance, ts = 6.0, 500.0
def _fw_velocity(ids):
vision = flat[:vis_numel].reshape(vlat_shape).unsqueeze(0) # [1,C,T,H,W]
sound = flat[vis_numel:].reshape(sound_shape) # [C,T]
ps = _framework_pack_sound(text_ids=ids, vision=vision, sound=sound,
cond_vision=cond_v, cond_sound=cond_s,
timestep=ts, is_image_batch=(grid_t == 1))
with torch.no_grad():
out = vfm(packed_seq=ps)
pv = out["preds_vision"][0].squeeze(0) # [C,T,H,W] (zero on clean)
psd = out["preds_sound"][0] # [C,T] (zero on clean)
return torch.cat([pv.reshape(-1), psd.reshape(-1)])
fw_cond, fw_uncond = _fw_velocity(cond_ids), _fw_velocity(uncond_ids)
fw_v = fw_uncond + guidance * (fw_cond - fw_uncond)
fv_v = cosmos3_get_cfg_velocity(
transformer=dit, flat_latent=flat, timestep=torch.tensor([ts]), guidance=guidance,
specs=[Cosmos3VisionSpec(shape=vlat_shape, condition_frame_indexes=list(cond_v))],
sound_specs=[Cosmos3SoundSpec(shape=sound_shape, condition_frame_indexes=list(cond_s))],
cond_token_ids=cond_ids, uncond_token_ids=uncond_ids,
special_tokens=_SPECIAL_TOKENS, latent_patch_size=_LATENT_PATCH_SIZE,
temporal_modality_margin=_TEMPORAL_MODALITY_MARGIN, reset_spatial_ids=_RESET_SPATIAL_IDS,
enable_fps_modulation=False, base_fps=24.0, temporal_compression_factor=_TCF,
)
assert fv_v.shape == fw_v.shape, f"shape fv={fv_v.shape} fw={fw_v.shape}"
mx, mn = _diffs(fv_v, fw_v)
print(f"\n[t2vs_cfg_velocity {grid_t}x{lh}x{lw} snd={snd_t}] max={mx:.3e} mean={mn:.3e}")
torch.testing.assert_close(fv_v, fw_v, atol=1e-4, rtol=1e-3)