multimodalart HF Staff commited on
Commit
87f0da8
·
verified ·
1 Parent(s): 8bd7e0d

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +6 -0
  2. README.md +55 -7
  3. app.py +337 -0
  4. assets/empty_context.pt +3 -0
  5. data/__init__.py +1 -0
  6. data/common.py +38 -0
  7. data/s01_analyze.py +182 -0
  8. data/s02_make_clips.py +198 -0
  9. examples/man_laughing_audio.wav +3 -0
  10. examples/man_laughing_video.mp4 +3 -0
  11. examples/man_smiling_studio_audio.wav +3 -0
  12. examples/man_smiling_studio_video.mp4 +3 -0
  13. examples/woman_laughing_studio_audio.wav +3 -0
  14. examples/woman_laughing_studio_video.mp4 +3 -0
  15. requirements.txt +18 -0
  16. viddub/__init__.py +3 -0
  17. viddub/causal/__init__.py +1 -0
  18. viddub/causal/art_alignment.py +44 -0
  19. viddub/causal/cf/NOTICE +18 -0
  20. viddub/causal/cf/__init__.py +1 -0
  21. viddub/causal/cf/consistency.py +123 -0
  22. viddub/causal/cf/dmd.py +98 -0
  23. viddub/causal/cf/sampling.py +54 -0
  24. viddub/causal/cf/train.py +168 -0
  25. viddub/causal/cf/upstream.py +96 -0
  26. viddub/causal/infer.py +100 -0
  27. viddub/causal/make_trajectories.py +121 -0
  28. viddub/causal/model.py +304 -0
  29. viddub/causal/pretrain_fake.py +118 -0
  30. viddub/causal/quality.py +167 -0
  31. viddub/causal/runtime.py +343 -0
  32. viddub/causal/sampling.py +298 -0
  33. viddub/causal/train.py +6 -0
  34. viddub/causal/train_ar.py +6 -0
  35. viddub/causal/train_dmd.py +6 -0
  36. viddub/causal/train_ode.py +6 -0
  37. viddub/causal/training/__init__.py +3 -0
  38. viddub/causal/training/ar.py +146 -0
  39. viddub/causal/training/common.py +61 -0
  40. viddub/causal/training/dmd.py +133 -0
  41. viddub/causal/training/engine.py +568 -0
  42. viddub/causal/training/fake.py +34 -0
  43. viddub/causal/training/ode.py +72 -0
  44. viddub/causal/training/stages.py +5 -0
  45. viddub/causal/trajectories.py +66 -0
  46. viddub/causal/verify_checkpoint.py +101 -0
  47. viddub/foundation/NOTICE +12 -0
  48. viddub/foundation/__init__.py +8 -0
  49. viddub/foundation/core/__init__.py +1 -0
  50. viddub/foundation/core/gradient/__init__.py +1 -0
.gitattributes CHANGED
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/man_laughing_audio.wav filter=lfs diff=lfs merge=lfs -text
37
+ examples/man_laughing_video.mp4 filter=lfs diff=lfs merge=lfs -text
38
+ examples/man_smiling_studio_audio.wav filter=lfs diff=lfs merge=lfs -text
39
+ examples/man_smiling_studio_video.mp4 filter=lfs diff=lfs merge=lfs -text
40
+ examples/woman_laughing_studio_audio.wav filter=lfs diff=lfs merge=lfs -text
41
+ examples/woman_laughing_studio_video.mp4 filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,61 @@
1
  ---
2
- title: Viddub
3
- emoji: 😻
4
- colorFrom: yellow
5
- colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.31.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: VidDub
3
+ emoji: 🎬
4
+ colorFrom: green
5
+ colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.31.0
 
8
  app_file: app.py
9
+ python_version: "3.10"
10
+ short_description: Two-step video dubbing — re-sync lips to new speech
11
+ startup_duration_timeout: 1h
12
  ---
13
 
14
+ # VidDub — Replacement-Aware Distillation for Two-Step Video Dubbing
15
+
16
+ **VidDub** ([paper](https://huggingface.co/papers/2611.03404)) is a two-step
17
+ diffusion video-dubbing editor: given a source talking-head video and new
18
+ target speech, it regenerates the speaker's lower face so the lips match the
19
+ new audio — everything else is kept from the source. It is trained on a
20
+ Wan2.1-VACE-1.3B backbone with a Whisper-small audio adapter, distilled with
21
+ DMD to just **2 denoising steps** (flow-euler, shift 5, audio CFG 1).
22
+
23
+ - 💻 Code: [LAW1223/VidDub](https://github.com/LAW1223/VidDub)
24
+ - 🤗 Weights: [AIPeanutman/VidDub](https://huggingface.co/AIPeanutman/VidDub)
25
+ (base: [Wan-AI/Wan2.1-VACE-1.3B](https://huggingface.co/Wan-AI/Wan2.1-VACE-1.3B),
26
+ audio encoder: [openai/whisper-small](https://huggingface.co/openai/whisper-small))
27
+
28
+ ## Usage
29
+
30
+ 1. Upload a source video with a single, mostly front-facing face
31
+ (≥ 81 frames, i.e. ~3.4 s; longer videos are processed in 81-frame windows
32
+ and truncated at the frame cap).
33
+ 2. Upload the target speech audio (any common format).
34
+ 3. Press **Dub video**. The output keeps the source resolution and frame rate
35
+ and carries the new speech.
36
+
37
+ The two-step DMD checkpoint is fixed to 2 steps / audio CFG 1 — the sampler
38
+ has no user-tunable steps. Advanced options: noise seed, the paper's
39
+ lower-face color harmonization, mask feather, and the frame cap.
40
+
41
+ ## Implementation notes
42
+
43
+ The app replicates the reference implementation (`scripts/infer.py`) 1:1:
44
+ MediaPipe FaceMesh landmarks → 512² face crop + lower-face mask → VACE latents
45
+ (x0 / inactive / ref) + empty-prompt T5 context + Whisper features →
46
+ two-step bidirectional rollout → Lab color harmonization → feathered paste-back
47
+ into the original frames → AAC muxing at 48 kHz stereo.
48
+
49
+ One deviation: dubbing never uses a text prompt, so the T5 context of the
50
+ empty string is precomputed once (18 KB tensor in `assets/empty_context.pt`)
51
+ instead of downloading the 11 GB umt5-xxl encoder at startup.
52
+
53
+ ## Licenses / credits
54
+
55
+ - Example videos: CC0, from
56
+ [linoyts/repo-to-space-example-videos](https://huggingface.co/datasets/linoyts/repo-to-space-example-videos).
57
+ - Example speech: CC BY 4.0, from
58
+ [LibriSpeech](https://huggingface.co/datasets/openslr/librispeech_asr)
59
+ (Vassil Panayotov / ILSVRC & ISLR, 2015).
60
+ - VidDub model weights are released under the repository's own
61
+ source-available license; Wan2.1 base weights under Apache-2.0.
app.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VidDub: two-step replacement-aware video dubbing.
2
+
3
+ Upload a source video (a talking head) and target speech audio; VidDub
4
+ regenerates the lower face so the lips match the new audio in 2 denoising
5
+ steps, pasting the result back into the original video.
6
+
7
+ Reference implementation: https://github.com/LAW1223/VidDub (scripts/infer.py).
8
+ Model: AIPeanutman/VidDub on top of Wan2.1-VACE-1.3B + whisper-small.
9
+ """
10
+ import json
11
+ import math
12
+ import os
13
+ import sys
14
+ import tempfile
15
+ import time
16
+ from pathlib import Path
17
+
18
+ import spaces # MUST be imported before torch / anything CUDA-touching
19
+
20
+ # The Space repo bundles the VidDub source (viddub/ + data/) at /home/user/app.
21
+ APP_ROOT = Path(__file__).resolve().parent
22
+ for p in (str(APP_ROOT),):
23
+ if p not in sys.path:
24
+ sys.path.insert(0, p)
25
+
26
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
27
+
28
+ import decord
29
+ import gradio as gr
30
+ import librosa
31
+ import numpy as np
32
+ import torch
33
+ from huggingface_hub import hf_hub_download, snapshot_download
34
+ from safetensors.torch import load_file
35
+
36
+ from viddub.causal.model import CausalConfig, CausalWanDub
37
+ from viddub.causal.runtime import autocast
38
+ from viddub.causal.sampling import rollout, schedule
39
+ from viddub.foundation.pipelines.wan_video import ModelConfig, WanVideoPipeline
40
+ from viddub.full_context import runtime as full_context_runtime
41
+ from viddub.full_context.audio import DubAudioAdapter, WhisperFeatures
42
+ from viddub.full_context.inference import dub_video
43
+ from viddub.full_context.runtime import FPS, SR, ZeroLatents, build_inputs, write_mp4
44
+
45
+ from data.s02_make_clips import NUM_FRAMES
46
+
47
+ DEVICE = "cuda"
48
+ VIDDUB_REPO = "AIPeanutman/VidDub"
49
+ BASE_REPO = "Wan-AI/Wan2.1-VACE-1.3B"
50
+ WHISPER_REPO = "openai/whisper-small"
51
+
52
+ MAX_FRAMES = 242 # 3 windows x 81 frames (stride 64) + tail; ~10s @ 24fps
53
+
54
+
55
+ # ---------------------------------------------------------------- weight prep
56
+ def prepare_weights():
57
+ """Download every checkpoint the runtime needs into one local root."""
58
+ root = Path(os.environ.get("VIDDUB_WEIGHT_ROOT", APP_ROOT / "weights"))
59
+ root.mkdir(parents=True, exist_ok=True)
60
+ vace_dir = root / "Wan2.1-VACE-1.3B"
61
+ need = {
62
+ "diffusion": vace_dir / "diffusion_pytorch_model.safetensors",
63
+ "vae": vace_dir / "Wan2.1_VAE.pth",
64
+ }
65
+ if not all(p.is_file() for p in need.values()):
66
+ snapshot_download(
67
+ BASE_REPO, repo_type="model",
68
+ allow_patterns=["diffusion_pytorch_model.safetensors", "Wan2.1_VAE.pth"],
69
+ local_dir=str(vace_dir))
70
+ whisper_dir = root / "whisper-small"
71
+ if not (whisper_dir / "model.safetensors").is_file():
72
+ snapshot_download(
73
+ WHISPER_REPO, repo_type="model",
74
+ allow_patterns=["*.json", "*.txt", "model.safetensors", "vocab.json",
75
+ "merges.txt", "preprocessor_config.json",
76
+ "tokenizer_config.json", "normalizer.json",
77
+ "special_tokens_map.json", "added_tokens.json"],
78
+ local_dir=str(whisper_dir))
79
+ release_dir = root / "VidDub"
80
+ if not (release_dir / "config.json").is_file():
81
+ snapshot_download(
82
+ VIDDUB_REPO, repo_type="model",
83
+ allow_patterns=["*.json", "dit/*", "vace/*", "audio_adapter/*"],
84
+ local_dir=str(release_dir))
85
+ full_context_runtime.WEIGHTS = root
86
+ full_context_runtime.VACE_DIR = vace_dir
87
+ return release_dir
88
+
89
+
90
+ def load_release(model_dir: Path):
91
+ """Load the VidDub release exactly as scripts/infer.py does."""
92
+ config = json.loads((model_dir / "config.json").read_text())
93
+ if config.get("model_type") != "viddub":
94
+ raise ValueError("The model directory is not a VidDub release")
95
+ if float(config["inference_audio_cfg"]) != 1.0:
96
+ raise ValueError("Published VidDub checkpoints require inference audio CFG 1")
97
+ if config["stage"] != "bidirectional_sft" and (
98
+ config["stage"] != "dmd"
99
+ or config.get("paper_selected") is not True
100
+ or config.get("causal") is not False
101
+ or config.get("source_run") != "FULL2_DMD_R4"):
102
+ raise ValueError("The released two-step path requires FULL2_DMD_R4")
103
+
104
+ # Same T5-skip path as viddub/causal/runtime.py: with a precomputed
105
+ # empty-prompt context, the 11 GB T5 encoder is never loaded.
106
+ empty_context = torch.load(os.environ["VIDDUB_EMPTY_CONTEXT"],
107
+ map_location="cpu", weights_only=True)
108
+ if not (torch.is_tensor(empty_context) and empty_context.ndim == 2
109
+ and empty_context.shape[1] == 4096 and 1 <= empty_context.shape[0] <= 512
110
+ and torch.isfinite(empty_context).all()):
111
+ raise ValueError("assets/empty_context.pt must be a finite [L,4096] tensor")
112
+ pipe = WanVideoPipeline.from_pretrained(
113
+ torch_dtype=torch.bfloat16, device=DEVICE,
114
+ model_configs=[ModelConfig(path=str(full_context_runtime.VACE_DIR
115
+ / "diffusion_pytorch_model.safetensors")),
116
+ ModelConfig(path=str(full_context_runtime.VACE_DIR
117
+ / "Wan2.1_VAE.pth"))],
118
+ tokenizer_config=None)
119
+ for m in (pipe.dit, pipe.vace, pipe.vae):
120
+ m.requires_grad_(False).eval()
121
+ pipe._wan_dub_empty_context = empty_context.to(pipe.device, pipe.torch_dtype)
122
+ whisper = WhisperFeatures(str(full_context_runtime.WEIGHTS
123
+ / "whisper-small")).to(DEVICE, torch.bfloat16)
124
+ paths = {name: model_dir / rel for name, rel in config["components"].items()}
125
+ missing = [str(p) for p in paths.values() if not p.is_file()]
126
+ if missing:
127
+ raise FileNotFoundError(f"Missing model files: {missing}")
128
+ # The VidDub release ships bf16 DiT/VACE and fp32 audio adapter; the
129
+ # reference loader loads them straight onto the device with strict=True.
130
+ pipe.dit.load_state_dict(load_file(str(paths["dit"])), strict=True)
131
+ pipe.vace.load_state_dict(load_file(str(paths["vace"])), strict=True)
132
+ adapter = DubAudioAdapter().to(DEVICE)
133
+ adapter.load_state_dict(load_file(str(paths["audio_adapter"])), strict=True)
134
+ adapter.eval()
135
+ return config, pipe, whisper, adapter
136
+
137
+
138
+ def two_step_sampler(model, config):
139
+ """Two-step flow_euler sampler with a full-context (bidirectional) rollout."""
140
+ @torch.no_grad()
141
+ def sample(pipe, whisper, adapter, zero, clip, audio, steps=None,
142
+ audio_cfg=1.0, seed=0, fps=FPS):
143
+ trained_steps = int(config["student_steps"])
144
+ num_frames = len(clip["frames"])
145
+ video = full_context_runtime.to_video_tensor(clip["frames"], pipe)
146
+ keep = torch.from_numpy(~clip["mask"]).to(video)[None, None, None]
147
+ latents = {
148
+ "x0": pipe.vae.encode(video, device=pipe.device)[0],
149
+ "inactive": pipe.vae.encode(video * keep, device=pipe.device)[0],
150
+ "ref": pipe.vae.encode(
151
+ full_context_runtime.to_video_tensor(clip["ref"][None], pipe),
152
+ device=pipe.device)[0],
153
+ }
154
+ x0, vace_context, context = build_inputs(
155
+ latents, clip["mask"], num_frames, zero,
156
+ drop_ref=int(config["num_reference_latents"]) == 0)
157
+ whisper_hs = whisper([audio], pipe.device, torch.bfloat16)
158
+ model.config = CausalConfig(
159
+ int(config["latent_chunk_size"]),
160
+ int(config["num_reference_latents"]), float(fps))
161
+ conditions = {
162
+ "context": context.to(torch.bfloat16),
163
+ "vace_context": vace_context.to(torch.bfloat16),
164
+ "whisper_hs": whisper_hs.to(torch.bfloat16),
165
+ "num_frames": num_frames,
166
+ }
167
+ noise = torch.randn(
168
+ x0.shape, generator=torch.Generator(device=x0.device).manual_seed(seed),
169
+ device=x0.device, dtype=torch.bfloat16)
170
+ with autocast(x0.device, "bf16"):
171
+ output = rollout(
172
+ model, x0.to(torch.bfloat16), conditions,
173
+ schedule(trained_steps, float(config["shift"]), x0.device),
174
+ noise=noise, use_cache=bool(config.get("causal", False)),
175
+ audio_cfg=1.0)
176
+ video = pipe.vae.decode(
177
+ output[:, :, int(config["num_reference_latents"]):].to(pipe.torch_dtype),
178
+ device=pipe.device)
179
+ return ((video[0].permute(1, 2, 3, 0).float().clamp(-1, 1) + 1)
180
+ * 127.5).round().byte().cpu().numpy()
181
+
182
+ return sample
183
+
184
+
185
+ # ------------------------------------------------------------ module-scope load
186
+ # Dubbing is audio-driven: the only text condition is the empty prompt. The
187
+ # bundled assets/empty_context.pt holds the precomputed T5 embedding of "",
188
+ # so the 11 GB umt5-xxl encoder is never downloaded or loaded (this is the
189
+ # repo's own VIDDUB_SKIP_TEXT_ENCODER path, see viddub/causal/runtime.py).
190
+ os.environ["VIDDUB_SKIP_TEXT_ENCODER"] = "1"
191
+ os.environ["VIDDUB_EMPTY_CONTEXT"] = str(APP_ROOT / "assets" / "empty_context.pt")
192
+
193
+ print("Downloading weights ...", flush=True)
194
+ RELEASE_DIR = prepare_weights()
195
+ print("Loading VidDub runtime ...", flush=True)
196
+ CONFIG, PIPE, WHISPER, ADAPTER = load_release(RELEASE_DIR)
197
+ ZERO = ZeroLatents(PIPE)
198
+ INFERENCE_MODEL = CausalWanDub(
199
+ PIPE.dit, PIPE.vace, ADAPTER,
200
+ CausalConfig(int(CONFIG["latent_chunk_size"]),
201
+ int(CONFIG["num_reference_latents"]), float(CONFIG["fps"])),
202
+ causal=False).eval().requires_grad_(False)
203
+ SAMPLE_FN = two_step_sampler(INFERENCE_MODEL, CONFIG)
204
+ RUNTIME = {"models": (PIPE, WHISPER, ADAPTER, ZERO), "config": CONFIG,
205
+ "steps": int(CONFIG["student_steps"]), "sample_fn": SAMPLE_FN}
206
+ print("VidDub runtime ready.", flush=True)
207
+
208
+
209
+ # ----------------------------------------------------------------- inference
210
+ def _probe_duration(video_path: str) -> float:
211
+ """Estimate the GPU duration from the input video's frame count."""
212
+ try:
213
+ n = len(decord.VideoReader(video_path, num_threads=1))
214
+ except Exception:
215
+ n = NUM_FRAMES
216
+ n = max(n, NUM_FRAMES)
217
+ n_windows = max(1, math.ceil((n - NUM_FRAMES) / 64) + 1)
218
+ # 81-frame clip: ~11 s on the RTX PRO 6000; per extra window ~10 s more,
219
+ # plus VAE encode/decode and MediaPipe preprocessing. Measured, +15% margin.
220
+ return min(420, 45 + 11 * n_windows)
221
+
222
+
223
+ @spaces.GPU(duration=_probe_duration)
224
+ def dub(video: str, audio: str, seed: int = 0, harmonize: bool = True,
225
+ feather: int = 15, max_frames: int = MAX_FRAMES,
226
+ progress=gr.Progress(track_tqdm=False)):
227
+ """Re-dub a talking-head video with new speech audio.
228
+
229
+ Args:
230
+ video: source video with a mostly front-facing face (mp4/webm).
231
+ audio: target speech (any format; resampled to 16 kHz mono).
232
+ seed: noise seed for the two-step sampler.
233
+ harmonize: apply the paper's lower-face color harmonization.
234
+ feather: mask feather radius in crop pixels.
235
+ max_frames: cap on processed frames (longer videos are truncated).
236
+
237
+ Returns:
238
+ The dubbed video with the original resolution/fps and the new speech.
239
+ """
240
+ if not video or not audio:
241
+ raise gr.Error("Please provide both a source video and target audio.")
242
+ t0 = time.perf_counter()
243
+ video = str(video)
244
+ audio = str(audio)
245
+ reader = decord.VideoReader(video, num_threads=2)
246
+ fps = float(reader.get_avg_fps())
247
+ frames = reader[:].asnumpy()
248
+ if len(frames) > max_frames:
249
+ frames = frames[:max_frames]
250
+ if len(frames) < NUM_FRAMES:
251
+ raise gr.Error(
252
+ f"Source video needs at least {NUM_FRAMES} frames "
253
+ f"({NUM_FRAMES / FPS:.1f} s); got {len(frames)}.")
254
+ waveform = librosa.load(audio, sr=SR, mono=True)[0]
255
+ audio_samples = round(len(frames) / fps * SR)
256
+ waveform = np.pad(
257
+ waveform[:audio_samples], (0, max(0, audio_samples - len(waveform))))
258
+ progress((0.1,), desc="Dubbing ...")
259
+ generated = dub_video(
260
+ RUNTIME["models"], frames, waveform,
261
+ steps=RUNTIME["steps"], audio_cfg=1.0, feather=feather, seed=seed,
262
+ fps=fps, overlap=17, mode="auto", harmonize=harmonize,
263
+ sample_fn=RUNTIME["sample_fn"],
264
+ log=lambda m: print(m, flush=True))
265
+ out_path = Path(tempfile.mkdtemp()) / "dubbed.mp4"
266
+ write_mp4(out_path, generated, waveform, fps=fps)
267
+ print(f"dub finished in {time.perf_counter() - t0:.1f}s", flush=True)
268
+ return str(out_path)
269
+
270
+
271
+ # ----------------------------------------------------------------------- UI
272
+ EXAMPLES = [
273
+ ["examples/man_laughing_video.mp4", "examples/man_laughing_audio.wav", 0],
274
+ ["examples/woman_laughing_studio_video.mp4", "examples/woman_laughing_studio_audio.wav", 0],
275
+ ["examples/man_smiling_studio_video.mp4", "examples/man_smiling_studio_audio.wav", 0],
276
+ ]
277
+
278
+ with gr.Blocks(theme=gr.themes.Citrus(), title="VidDub") as demo:
279
+ gr.Markdown(
280
+ """
281
+ # 🎬 VidDub — Replacement-Aware Distillation for Two-Step Video Dubbing
282
+
283
+ **VidDub** takes a source talking-head video and a new speech clip and
284
+ re-renders the speaker's lower face so the lips match the new audio —
285
+ in **2 denoising steps** on a Wan2.1-VACE-1.3B backbone.
286
+
287
+ 📄 [Paper](https://huggingface.co/papers/2611.03404) ·
288
+ 💻 [GitHub](https://github.com/LAW1223/VidDub) ·
289
+ 🤗 [AIPeanutman/VidDub](https://huggingface.co/AIPeanutman/VidDub)
290
+
291
+ Upload a short clip with a single, mostly front-facing face and the
292
+ audio you want the face to speak. Everything except the mouth region is
293
+ kept from the source video.
294
+ """)
295
+ with gr.Row():
296
+ with gr.Column():
297
+ video_in = gr.Video(label="Source video (talking head)", sources=["upload"])
298
+ audio_in = gr.Audio(label="Target speech audio", sources=["upload"], type="filepath")
299
+ run_btn = gr.Button("Dub video", variant="primary")
300
+ with gr.Column():
301
+ video_out = gr.Video(label="Dubbed video", autoplay=True)
302
+ with gr.Accordion("Advanced options", open=False):
303
+ seed = gr.Number(label="Seed", value=0, precision=0)
304
+ harmonize = gr.Checkbox(label="Color harmonization (paper default)", value=True)
305
+ feather = gr.Slider(1, 40, value=15, step=1, label="Mask feather (px)")
306
+ max_frames = gr.Slider(NUM_FRAMES, MAX_FRAMES, value=MAX_FRAMES, step=1,
307
+ label="Max frames (longer videos are truncated)")
308
+ gr.Examples(
309
+ examples=EXAMPLES,
310
+ inputs=[video_in, audio_in, seed],
311
+ fn=dub,
312
+ outputs=[video_out],
313
+ cache_examples=True,
314
+ cache_mode="lazy",
315
+ )
316
+ run_btn.click(
317
+ fn=dub,
318
+ inputs=[video_in, audio_in, seed, harmonize, feather, max_frames],
319
+ outputs=[video_out],
320
+ api_name="dub",
321
+ )
322
+ gr.Markdown(
323
+ """
324
+ ---
325
+ **Notes.** The two-step DMD checkpoint is fixed: 2 denoising steps,
326
+ audio CFG 1, 81-frame windows with 17-frame overlap. Videos need a
327
+ detectable, mostly front-facing face and at least 81 frames
328
+ (~3.4 s). VidDub-Bench is CC BY-NC; the bundled example videos come
329
+ from the CC0 pool at
330
+ [linoyts/repo-to-space-example-videos](https://huggingface.co/datasets/linoyts/repo-to-space-example-videos)
331
+ and the speech from
332
+ [LibriSpeech](https://huggingface.co/datasets/openslr/librispeech_asr)
333
+ (CC BY 4.0).
334
+ """)
335
+
336
+ if __name__ == "__main__":
337
+ demo.launch(mcp_server=True)
assets/empty_context.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ad9140ae8591e91b64c3d5dbeb183c6701f19d419c8b5f43fe364146042e88ae
3
+ size 18003
data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """VidDub data preparation and preprocessing tools."""
data/common.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Paths and JSONL helpers shared by the public data pipeline."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+
6
+ PROJECT = Path(__file__).resolve().parents[1]
7
+ DATA = Path(os.environ.get("VIDDUB_DATA_ROOT", PROJECT / "data"))
8
+ MANIFEST = DATA / "manifest"
9
+ WEIGHTS = Path(os.environ.get("VIDDUB_WEIGHT_ROOT", PROJECT / "weights"))
10
+
11
+
12
+ def read_jsonl(path):
13
+ rows = []
14
+ with Path(path).open() as handle:
15
+ for line in handle:
16
+ if line.strip():
17
+ rows.append(json.loads(line))
18
+ return rows
19
+
20
+
21
+ def write_jsonl(path, rows):
22
+ path = Path(path)
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ tmp = path.with_suffix(path.suffix + ".tmp")
25
+ with tmp.open("w") as handle:
26
+ for row in rows:
27
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
28
+ tmp.replace(path)
29
+
30
+
31
+ def resolve_video_path(row):
32
+ """Resolve artifact-relative media paths against VIDDUB_DATA_ROOT."""
33
+ path = Path(row["video_path"]).expanduser()
34
+ return path if path.is_absolute() else DATA / path
35
+
36
+
37
+ def shard(rows, shard_id, num_shards):
38
+ return rows[shard_id::num_shards]
data/s01_analyze.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per video: face landmarks (mediapipe), 16k mono wav, SyncNet offset/confidence.
2
+
3
+ Outputs:
4
+ data/face/<sub>/<id>.npz lm [T,468,2] float16 (nan = no face), nfaces [T]
5
+ data/audio/<sub>/<id>.wav 16kHz mono
6
+ data/manifest/s01/shard_XXXX.jsonl per-video stats (append; resumable)
7
+ """
8
+ import argparse
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import cv2
17
+ import decord
18
+ import mediapipe as mp
19
+ import numpy as np
20
+ import python_speech_features
21
+ import torch
22
+ from scipy import signal
23
+ from scipy.io import wavfile
24
+
25
+ from .common import DATA, MANIFEST, PROJECT, WEIGHTS, read_jsonl, resolve_video_path, shard
26
+
27
+ LATENTSYNC_ROOT = Path(os.environ.get("VIDDUB_LATENTSYNC_ROOT", WEIGHTS / "LatentSync"))
28
+
29
+ SYNCNET_PATH = Path(os.environ.get(
30
+ "VIDDUB_SYNCNET_PATH", WEIGHTS / "LatentSync-1.5/auxiliary/syncnet_v2.model"))
31
+ LM_NOSE, LM_CHEEK_R, LM_CHEEK_L, LM_LIP_UP, LM_LIP_LOW, LM_FOREHEAD, LM_CHIN = 1, 234, 454, 13, 14, 10, 152
32
+
33
+
34
+ def sub_dir(vid):
35
+ return vid[-6:-3]
36
+
37
+
38
+ def load_syncnet(device):
39
+ syncnet_root = LATENTSYNC_ROOT / "eval/syncnet"
40
+ if str(syncnet_root) not in sys.path:
41
+ sys.path.insert(0, str(syncnet_root))
42
+ from syncnet import S
43
+ net = S(num_layers_in_fc_layers=1024).to(device).eval()
44
+ net.load_state_dict(torch.load(SYNCNET_PATH, map_location="cpu", weights_only=True))
45
+ return net
46
+
47
+
48
+ def detect_landmarks(
49
+ frames,
50
+ min_detection_confidence=0.5,
51
+ min_tracking_confidence=0.5,
52
+ ):
53
+ T, H, W = frames.shape[:3]
54
+ lm = np.full((T, 468, 2), np.nan, dtype=np.float32)
55
+ nfaces = np.zeros(T, dtype=np.int8)
56
+ with mp.solutions.face_mesh.FaceMesh(
57
+ static_image_mode=False,
58
+ max_num_faces=2,
59
+ min_detection_confidence=min_detection_confidence,
60
+ min_tracking_confidence=min_tracking_confidence,
61
+ ) as face_mesh:
62
+ for t in range(T):
63
+ res = face_mesh.process(frames[t])
64
+ if not res.multi_face_landmarks:
65
+ continue
66
+ nfaces[t] = len(res.multi_face_landmarks)
67
+ faces = [np.array([(p.x * W, p.y * H) for p in f.landmark[:468]]) for f in res.multi_face_landmarks]
68
+ lm[t] = max(faces, key=lambda a: np.ptp(a[:, 1])) # largest face
69
+ return lm, nfaces
70
+
71
+
72
+ def syncnet_crops(frames, lm, crop_scale=0.4):
73
+ """Replicates syncnet_python crop (S3FD box -> landmark bbox), median-filtered track."""
74
+ valid = ~np.isnan(lm[:, 0, 0])
75
+ idx = np.arange(len(lm))
76
+ xs = np.interp(idx, idx[valid], (np.nanmin(lm[valid, :, 0], 1) + np.nanmax(lm[valid, :, 0], 1)) / 2)
77
+ ys = np.interp(idx, idx[valid], (np.nanmin(lm[valid, :, 1], 1) + np.nanmax(lm[valid, :, 1], 1)) / 2)
78
+ ss = np.interp(idx, idx[valid], np.maximum(np.ptp(lm[valid, :, 0], 1), np.ptp(lm[valid, :, 1], 1)) / 2)
79
+ xs, ys, ss = (signal.medfilt(v, kernel_size=13) for v in (xs, ys, ss))
80
+ crops = []
81
+ for t in range(len(frames)):
82
+ bs = ss[t]
83
+ bsi = int(bs * (1 + 2 * crop_scale))
84
+ img = np.pad(frames[t], ((bsi, bsi), (bsi, bsi), (0, 0)), "constant", constant_values=110)
85
+ my, mx = ys[t] + bsi, xs[t] + bsi
86
+ face = img[int(my - bs):int(my + bs * (1 + 2 * crop_scale)), int(mx - bs * (1 + crop_scale)):int(mx + bs * (1 + crop_scale))]
87
+ crops.append(cv2.resize(face, (224, 224)))
88
+ return np.stack(crops)
89
+
90
+
91
+ @torch.no_grad()
92
+ def syncnet_score(net, crops_rgb_25fps, audio_int16, device, vshift=15, batch=64):
93
+ im = crops_rgb_25fps[..., ::-1].astype(np.float32) # syncnet_python feeds cv2 BGR frames
94
+ im = torch.from_numpy(np.ascontiguousarray(im)).permute(3, 0, 1, 2)[None] # 1,3,T,224,224
95
+ mfcc = np.stack(python_speech_features.mfcc(audio_int16, 16000), 1) # 13, T_a
96
+ cc = torch.from_numpy(mfcc.astype(np.float32))[None, None]
97
+ n = min(im.shape[2], len(audio_int16) // 640) - 5
98
+ im_feat, cc_feat = [], []
99
+ for i in range(0, n, batch):
100
+ r = range(i, min(n, i + batch))
101
+ im_feat.append(net.forward_lip(torch.cat([im[:, :, v:v + 5] for v in r]).to(device)).cpu())
102
+ cc_feat.append(net.forward_aud(torch.cat([cc[:, :, :, v * 4:v * 4 + 20] for v in r]).to(device)).cpu())
103
+ im_feat, cc_feat = torch.cat(im_feat), torch.cat(cc_feat)
104
+ win = 2 * vshift + 1
105
+ ccp = torch.nn.functional.pad(cc_feat, (0, 0, vshift, vshift))
106
+ dists = torch.stack([torch.nn.functional.pairwise_distance(im_feat[[i]].repeat(win, 1), ccp[i:i + win]) for i in range(n)], 1)
107
+ mean_d = dists.mean(1)
108
+ min_d, minidx = mean_d.min(0)
109
+ return int(vshift - minidx), float(min_d), float(mean_d.median() - min_d)
110
+
111
+
112
+ def analyze(row, net, device):
113
+ vid = row["id"]
114
+ video_path = resolve_video_path(row)
115
+ vr = decord.VideoReader(str(video_path), num_threads=2)
116
+ fps = float(vr.get_avg_fps())
117
+ frames = vr.get_batch(list(range(len(vr)))).asnumpy() # T,H,W,3 RGB
118
+ T, H, W = frames.shape[:3]
119
+
120
+ wav_path = DATA / "audio" / sub_dir(vid) / f"{vid}.wav"
121
+ wav_path.parent.mkdir(parents=True, exist_ok=True)
122
+ subprocess.run(["ffmpeg", "-loglevel", "error", "-nostdin", "-y", "-i", str(video_path), "-vn", "-ac", "1", "-ar", "16000",
123
+ "-acodec", "pcm_s16le", str(wav_path)], check=True)
124
+ _, audio = wavfile.read(wav_path)
125
+
126
+ lm, nfaces = detect_landmarks(frames)
127
+ face_path = DATA / "face" / sub_dir(vid) / f"{vid}.npz"
128
+ face_path.parent.mkdir(parents=True, exist_ok=True)
129
+ np.savez_compressed(face_path, lm=lm.astype(np.float16), nfaces=nfaces)
130
+
131
+ out = {"id": vid, "n_frames": T, "fps": fps, "height": H, "width": W, "dur_audio": len(audio) / 16000,
132
+ "face_missing": float((nfaces == 0).mean()), "multi_face": float((nfaces > 1).mean())}
133
+ valid = nfaces > 0
134
+ if valid.sum() < 0.5 * T:
135
+ return out
136
+ v = lm[valid]
137
+ face_h = v[:, LM_CHIN, 1] - v[:, LM_FOREHEAD, 1]
138
+ yaw = (v[:, LM_NOSE, 0] - v[:, LM_CHEEK_R, 0]) / (v[:, LM_CHEEK_L, 0] - v[:, LM_CHEEK_R, 0] + 1e-6)
139
+ mouth = (v[:, LM_LIP_LOW, 1] - v[:, LM_LIP_UP, 1]) / face_h
140
+ out.update({"face_h": float(np.median(face_h)), "yaw_ok": float(((yaw > 0.3) & (yaw < 0.7)).mean()),
141
+ "mouth_std": float(mouth.std())})
142
+
143
+ crops = syncnet_crops(frames, lm)
144
+ idx25 = (np.arange(int(T * 25 / fps)) * fps / 25).astype(int)
145
+ offset, min_dist, conf = syncnet_score(net, crops[idx25], audio, device)
146
+ out.update({"sync_offset": offset, "sync_dist": min_dist, "sync_conf": conf})
147
+ return out
148
+
149
+
150
+ def main():
151
+ ap = argparse.ArgumentParser()
152
+ ap.add_argument("--shard", type=int, default=0)
153
+ ap.add_argument("--num_shards", type=int, default=1)
154
+ ap.add_argument("--limit", type=int, default=None)
155
+ args = ap.parse_args()
156
+
157
+ rows = read_jsonl(MANIFEST / "s00_manifest.jsonl")
158
+ if args.limit:
159
+ rows = rows[:args.limit]
160
+ rows = shard(rows, args.shard, args.num_shards)
161
+ out_path = MANIFEST / "s01" / f"shard_{args.shard:04d}.jsonl"
162
+ out_path.parent.mkdir(parents=True, exist_ok=True)
163
+ done = {r["id"] for r in read_jsonl(out_path)} if out_path.exists() else set()
164
+ rows = [r for r in rows if r["id"] not in done]
165
+
166
+ device = "cuda" if torch.cuda.is_available() else "cpu"
167
+ net = load_syncnet(device)
168
+ t0 = time.time()
169
+ with open(out_path, "a") as f:
170
+ for i, row in enumerate(rows):
171
+ try:
172
+ res = analyze(row, net, device)
173
+ except Exception as e: # corrupt video etc.: record and continue
174
+ res = {"id": row["id"], "error": repr(e)[:300]}
175
+ f.write(json.dumps(res) + "\n")
176
+ f.flush()
177
+ if i % 50 == 0:
178
+ print(f"[shard {args.shard}] {i}/{len(rows)} {(time.time() - t0) / (i + 1):.2f}s/it", flush=True)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ main()
data/s02_make_clips.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Filter s01 results and cut training clips.
2
+
3
+ Per clip (data/clips/<sub>/<clip_id>/):
4
+ video.mp4 512x512, 81 frames, 24fps (face-centred crop, fixed box per clip)
5
+ mask.png 512x512 uint8, 255 = region to generate (lower face, union over the clip)
6
+ ref.png 512x512 face frame from outside the clip window (same crop box)
7
+ audio.wav 16kHz mono, aligned to the clip (SyncNet offset corrected)
8
+ Rows -> data/manifest/s02/shard_XXXX.jsonl
9
+ """
10
+ import argparse
11
+ import json
12
+ import re
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ import cv2
17
+ import decord
18
+ import imageio_ffmpeg
19
+ import numpy as np
20
+ from scipy.io import wavfile
21
+
22
+ from .common import DATA, MANIFEST, read_jsonl, resolve_video_path, shard
23
+
24
+ NUM_FRAMES, SIZE, CROP_SCALE = 81, 512, 1.8
25
+ LM_NOSE_BOTTOM, LM_CHEEK_R, LM_CHEEK_L, LM_CHIN, LM_LIP_UP, LM_LIP_LOW, LM_FOREHEAD = 2, 234, 454, 152, 13, 14, 10
26
+ FILTERS = { # decided from the s01 distribution, see STATE.md
27
+ "face_missing_max": 0.02, "multi_face_max": 0.02, "face_h_min": 150, "yaw_ok_min": 0.7,
28
+ "sync_conf_min": 6.0, "sync_offset_range": (-4, 2), "mouth_std_min": 0.01,
29
+ }
30
+
31
+
32
+ def keep(r):
33
+ f = FILTERS
34
+ return ("error" not in r and "sync_conf" in r and r["face_missing"] <= f["face_missing_max"]
35
+ and r["multi_face"] <= f["multi_face_max"] and r["face_h"] >= f["face_h_min"] and r["yaw_ok"] >= f["yaw_ok_min"]
36
+ and r["sync_conf"] >= f["sync_conf_min"] and f["sync_offset_range"][0] <= r["sync_offset"] <= f["sync_offset_range"][1]
37
+ and r["mouth_std"] >= f["mouth_std_min"] and r["n_frames"] >= NUM_FRAMES)
38
+
39
+
40
+ def clean_prompt(prompt, speech_text):
41
+ """Drop sentences that carry the spoken line (it would conflict with new audio at dubbing time)."""
42
+ prompt = prompt.replace(speech_text, "")
43
+ parts = re.split(r"(?<=[。!?.!?])\s*", prompt)
44
+ bad = re.compile(r"台词|口型|说道|说:|lip|speak|says|saying|dialogue|words", re.I)
45
+ return " ".join(p for p in parts if p.strip() and not bad.search(p)).strip()
46
+
47
+
48
+ def crop_box(lm, H, W):
49
+ cx = (np.nanmin(lm[:, :, 0]) + np.nanmax(lm[:, :, 0])) / 2
50
+ cy = (np.nanmin(lm[:, :, 1]) + np.nanmax(lm[:, :, 1])) / 2
51
+ face = np.nanmedian(np.maximum(np.ptp(lm[:, :, 0], 1), np.ptp(lm[:, :, 1], 1)))
52
+ side = int(min(face * CROP_SCALE, H, W))
53
+ x0 = int(np.clip(cx - side / 2, 0, W - side))
54
+ y0 = int(np.clip(cy - side / 2, 0, H - side))
55
+ return x0, y0, side
56
+
57
+
58
+ def lower_face_mask(lm, box):
59
+ x0, y0, side = box
60
+ s = SIZE / side
61
+ face_h = np.nanmedian(lm[:, LM_CHIN, 1] - lm[:, LM_FOREHEAD, 1])
62
+ top = np.nanmin(lm[:, LM_NOSE_BOTTOM, 1])
63
+ bottom = np.nanmax(lm[:, LM_CHIN, 1]) + 0.1 * face_h
64
+ left, right = np.nanmin(lm[:, LM_CHEEK_R, 0]), np.nanmax(lm[:, LM_CHEEK_L, 0])
65
+ mask = np.zeros((SIZE, SIZE), np.uint8)
66
+ pts = np.array([[left, top], [right, bottom]])
67
+ (l, t), (r, b) = ((pts - [x0, y0]) * s).round().astype(int)
68
+ mask[max(t, 0):min(b, SIZE), max(l, 0):min(r, SIZE)] = 255
69
+ return mask
70
+
71
+
72
+ def write_video(path, frames):
73
+ proc = subprocess.Popen([imageio_ffmpeg.get_ffmpeg_exe(), "-loglevel", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{SIZE}x{SIZE}",
74
+ "-r", "24", "-i", "-", "-c:v", "libx264", "-crf", "15", "-pix_fmt", "yuv420p", str(path)], stdin=subprocess.PIPE)
75
+ proc.stdin.write(np.ascontiguousarray(frames).tobytes())
76
+ proc.stdin.close()
77
+ assert proc.wait() == 0
78
+
79
+
80
+ def load_selection(path):
81
+ """Return source_id -> selected clip_ids from JSON, JSONL, or text."""
82
+ path = Path(path)
83
+ if path.suffix == ".json":
84
+ value = json.loads(path.read_text())
85
+ values = value.keys() if isinstance(value, dict) else value
86
+ else:
87
+ values = []
88
+ with path.open() as handle:
89
+ for line in handle:
90
+ if not line.strip():
91
+ continue
92
+ values.append(json.loads(line) if line.lstrip().startswith("{") else line.strip())
93
+ selected = {}
94
+ for value in values:
95
+ clip_id = value["clip_id"] if isinstance(value, dict) else str(value)
96
+ source_id = value.get("id") if isinstance(value, dict) else None
97
+ source_id = source_id or clip_id.rsplit("_", 1)[0]
98
+ selected.setdefault(source_id, set()).add(clip_id)
99
+ if not selected:
100
+ raise ValueError(f"Empty selection: {path}")
101
+ return selected
102
+
103
+
104
+ def make_clips(row, s01, selected=None, done=None):
105
+ vid = row["id"]
106
+ sub = vid[-6:-3]
107
+ face = np.load(DATA / "face" / sub / f"{vid}.npz")
108
+ lm = face["lm"].astype(np.float32)
109
+ T = len(lm)
110
+ vr = decord.VideoReader(str(resolve_video_path(row)), num_threads=2)
111
+ frames = vr.get_batch(list(range(T))).asnumpy()
112
+ H, W = frames.shape[1:3]
113
+ _, audio = wavfile.read(DATA / "audio" / sub / f"{vid}.wav")
114
+ mouth = (lm[:, LM_LIP_LOW, 1] - lm[:, LM_LIP_UP, 1]) / (lm[:, LM_CHIN, 1] - lm[:, LM_FOREHEAD, 1])
115
+ mouth = np.nan_to_num(mouth, nan=-1)
116
+ # best-matching audio for video time t is at t - offset/25 s (offset < 0: audio is late)
117
+ audio_shift = -s01["sync_offset"] / 25.0
118
+ starts = [0] if T - NUM_FRAMES < 16 else [0, T - NUM_FRAMES]
119
+ out = []
120
+ for start in starts:
121
+ clip_id = f"{vid}_{start:03d}"
122
+ if selected is not None and clip_id not in selected:
123
+ continue
124
+ if done is not None and clip_id in done:
125
+ continue
126
+ win = slice(start, start + NUM_FRAMES)
127
+ box = crop_box(lm[win], H, W)
128
+ x0, y0, side = box
129
+ outside = np.r_[0:start, start + NUM_FRAMES:T]
130
+ ref_idx = int(outside[np.argmax(mouth[outside])]) if len(outside) else (T - 1 if start == 0 else 0)
131
+ crop = lambda f: cv2.resize(f[y0:y0 + side, x0:x0 + side], (SIZE, SIZE), interpolation=cv2.INTER_AREA)
132
+
133
+ d = DATA / "clips" / sub / clip_id
134
+ d.mkdir(parents=True, exist_ok=True)
135
+ write_video(d / "video.mp4", np.stack([crop(f) for f in frames[win]]))
136
+ cv2.imwrite(str(d / "mask.png"), lower_face_mask(lm[win], box))
137
+ cv2.imwrite(str(d / "ref.png"), cv2.cvtColor(crop(frames[ref_idx]), cv2.COLOR_RGB2BGR))
138
+ a0 = int(round((start / 24.0 + audio_shift) * 16000))
139
+ n = int(round(NUM_FRAMES / 24.0 * 16000))
140
+ seg = np.zeros(n, np.int16)
141
+ src = audio[max(a0, 0):max(a0 + n, 0)]
142
+ seg[max(-a0, 0):max(-a0, 0) + len(src)] = src[:n - max(-a0, 0)]
143
+ wavfile.write(d / "audio.wav", 16000, seg)
144
+ out.append({"clip_id": clip_id, "id": vid, "dir": str(d.relative_to(DATA / "clips")), "start": start, "ref_idx": ref_idx,
145
+ "crop_box": box, "sync_conf": s01["sync_conf"], "sync_offset": s01["sync_offset"],
146
+ "lang": row.get("speech_language", row.get("language", "")),
147
+ "prompt": clean_prompt(row.get("prompt", ""), row.get("speech_text", ""))})
148
+ return out
149
+
150
+
151
+ def main():
152
+ ap = argparse.ArgumentParser()
153
+ ap.add_argument("--shard", type=int, default=0)
154
+ ap.add_argument("--num_shards", type=int, default=1)
155
+ ap.add_argument("--limit", type=int, default=None)
156
+ ap.add_argument(
157
+ "--selection",
158
+ help="Optional JSON/JSONL/text clip selection; unlisted windows are not materialized",
159
+ )
160
+ args = ap.parse_args()
161
+
162
+ selection = load_selection(args.selection) if args.selection else None
163
+ s01 = {}
164
+ for p in sorted((MANIFEST / "s01").glob("shard_*.jsonl")):
165
+ s01.update({r["id"]: r for r in read_jsonl(p)})
166
+ rows = [r for r in read_jsonl(MANIFEST / "s00_manifest.jsonl") if r["id"] in s01 and keep(s01[r["id"]])]
167
+ if selection is not None:
168
+ rows = [r for r in rows if r["id"] in selection]
169
+ if args.limit:
170
+ rows = rows[:args.limit]
171
+ rows = shard(rows, args.shard, args.num_shards)
172
+ out_path = MANIFEST / "s02" / f"shard_{args.shard:04d}.jsonl"
173
+ out_path.parent.mkdir(parents=True, exist_ok=True)
174
+ done = {
175
+ r["clip_id"] for r in read_jsonl(out_path) if "clip_id" in r
176
+ } if out_path.exists() else set()
177
+ with open(out_path, "a") as f:
178
+ for i, row in enumerate(rows):
179
+ try:
180
+ clips = make_clips(
181
+ row,
182
+ s01[row["id"]],
183
+ selected=selection.get(row["id"]) if selection is not None else None,
184
+ done=done,
185
+ )
186
+ except Exception as e:
187
+ clips = [{"id": row["id"], "error": repr(e)[:300]}]
188
+ for c in clips:
189
+ f.write(json.dumps(c, ensure_ascii=False) + "\n")
190
+ if "clip_id" in c:
191
+ done.add(c["clip_id"])
192
+ f.flush()
193
+ if i % 50 == 0:
194
+ print(f"[shard {args.shard}] {i}/{len(rows)}", flush=True)
195
+
196
+
197
+ if __name__ == "__main__":
198
+ main()
examples/man_laughing_audio.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f53d0999a39251346563a4de62384920c28a67c053ae91fe05c948bf04644c5e
3
+ size 144078
examples/man_laughing_video.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a09134a1e470c253e9241521887c7bd01714eaf45a8a16159b6bd325ec8c0eaa
3
+ size 1003686
examples/man_smiling_studio_audio.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:da0044fcc67fd95ba6b3e75c8f5507905c046100b8074fb13cb3da82ac6f6c05
3
+ size 144078
examples/man_smiling_studio_video.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5a54a7fe60ceb0be72dc6cd2f6dd3b91ed3b83db919a0e618cd91168e011f9cb
3
+ size 776166
examples/woman_laughing_studio_audio.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:99cb56c5ccb274afeed195232dcd65745ed4eab9559a4b0f34670e32defada7b
3
+ size 144078
examples/woman_laughing_studio_video.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:911cf4085ab10fd231136a1cb7c72e4e2e0cc45da5e8a0672a49924646c26b0c
3
+ size 716276
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ numpy==1.26.4
2
+ mediapipe==0.10.21
3
+ opencv-contrib-python-headless==4.11.0.86
4
+ decord
5
+ librosa
6
+ soundfile
7
+ scipy
8
+ python-speech-features
9
+ einops
10
+ ftfy
11
+ regex
12
+ safetensors
13
+ imageio-ffmpeg
14
+ Pillow
15
+ tqdm
16
+ accelerate==1.10.1
17
+ transformers==4.56.2
18
+ https://huggingface.co/datasets/multimodalart/zerogpu-blackwell-wheels/resolve/main/wheels/pt212-cu130-cp310/flash_attn-2.8.3-cp310-cp310-linux_x86_64.whl
viddub/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """VidDub model, training, and inference implementation."""
2
+
3
+ __version__ = "1.0.0"
viddub/causal/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Checkpoint-compatible, block-causal Wan-Dub training and sampling."""
viddub/causal/art_alignment.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trailing-window Art-REPA for a causal prefix; no future hidden states.
2
+
3
+ This module is prepared for second-wave experiments. It is not enabled in A0-A3.
4
+ The last four video latents map to the last sixteen decoded frames once at
5
+ least five video latents are available. Reference positions are excluded.
6
+ """
7
+ import torch
8
+ from torch import nn
9
+ from torch.nn import functional as F
10
+
11
+
12
+ class CausalArtAlignment(nn.Module):
13
+ def __init__(self,dim=1536,embedding_dim=2048):
14
+ super().__init__()
15
+ self.dim=dim
16
+ self.proj=nn.Sequential(nn.Linear(4*dim,embedding_dim),nn.SiLU(),
17
+ nn.Linear(embedding_dim,embedding_dim),nn.SiLU(),
18
+ nn.Linear(embedding_dim,embedding_dim))
19
+
20
+ def forward(self,hidden,pixel_mask,target,*,num_ref=1,history_end=0,warmup=False):
21
+ """hidden=[B,F,Htokens,Wtokens,C], target=frozen GT visual embedding.
22
+
23
+ All hidden positions through history_end are detached, as they would
24
+ be for a generated-history cache. warmup detaches every hidden state.
25
+ Caller must pass a prefix ending at the supervised block boundary.
26
+ """
27
+ b,frames,h,w,c=hidden.shape
28
+ if c!=self.dim or target.shape!=(b,self.proj[-1].out_features):
29
+ raise ValueError('Art hidden/target dimensions differ')
30
+ if frames-num_ref<5:
31
+ # Keep trainable projector represented for DDP without inventing
32
+ # a 16-frame target at the first short block.
33
+ return sum(parameter.sum()*0 for parameter in self.proj.parameters())
34
+ token_mask=F.adaptive_max_pool2d(pixel_mask.float()[None,None],(h,w))[0,0]>0
35
+ if not token_mask.any():raise ValueError('Empty Art mouth mask')
36
+ values=hidden[:,frames-4:]
37
+ if warmup:values=values.detach()
38
+ elif history_end:
39
+ cut=max(0,min(4,history_end-(frames-4)))
40
+ values=torch.cat([values[:,:cut].detach(),values[:,cut:]],1)
41
+ pooled=values[:,:,token_mask].mean(2).flatten(1)
42
+ pred=F.normalize(self.proj(pooled).float(),dim=-1)
43
+ target=F.normalize(target.detach().float(),dim=-1)
44
+ return (1-(pred*target).sum(-1)).mean()
viddub/causal/cf/NOTICE ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Wan-Dub Causal Forcing++ adaptation
2
+
3
+ The flow-matching scheduler compatibility code is adapted from the official
4
+ Causal-Forcing repository, commit da3ddf1a590f30c9edc568e11c2f77170995c31c,
5
+ under Apache-2.0. The license text is retained under
6
+ dependencies/licenses/Apache-2.0.txt.
7
+
8
+ The adjacent-timestep consistency algorithm and re-noising sampler in this
9
+ directory are adaptations of model/naive_consistency.py,
10
+ utils/wan_wrapper.py, pipeline/causal_inference.py and
11
+ pipeline/self_forcing_training.py from that repository. The DMD adapter follows
12
+ model/dmd.py with documented differences in gradient truncation and conditioning.
13
+
14
+ Changes by the Wan-Dub integration: VACE/audio conditions, independent reference,
15
+ split-backward CD, sampled-chunk DMD gradients, explicit sampler metadata,
16
+ finite jobs, full training-state restore, task-specific GT/Sync objectives and
17
+ source-matched validation. See the repository README and
18
+ `docs/ARCHITECTURE.md` for the public implementation scope.
viddub/causal/cf/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Causal Forcing++ adaptation for the existing Wan-Dub model and conditions."""
viddub/causal/cf/consistency.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adapted from official model/naive_consistency.py (Apache-2.0).
2
+
3
+ Changes: Wan-Dub conditions/reference; optional frozen history caches; chunkwise
4
+ backward to bound memory. All seven losses can be accumulated with full student
5
+ history gradients, matching the upstream teacher-forcing attention dependency.
6
+ """
7
+ from contextlib import contextmanager
8
+ import torch
9
+ from torch import nn
10
+ from torch.nn import functional as F
11
+
12
+ from .upstream import FlowMatchScheduler
13
+ from ..model import HistoryCache
14
+ from ..sampling import commit, evaluating, predict_chunk
15
+
16
+
17
+ def flow_to_x0(xt, velocity, timestep, scheduler):
18
+ """Same nearest-grid and float64 arithmetic as official WanDiffusionWrapper."""
19
+ grid = scheduler.timesteps.to(device=xt.device, dtype=torch.float64)
20
+ index = (grid - torch.as_tensor(timestep, device=xt.device).double()).abs().argmin()
21
+ sigma = scheduler.sigmas.to(device=xt.device, dtype=torch.float64)[index]
22
+ return (xt.double() - sigma * velocity.double()).to(velocity.dtype)
23
+
24
+
25
+ class ModelEMA:
26
+ """Frozen target model, updated every optimizer step and included in resume."""
27
+ def __init__(self, model, decay):
28
+ self.model = model.eval().requires_grad_(False)
29
+ self.decay, self.updates = decay, 0
30
+
31
+ @torch.no_grad()
32
+ def update(self, student):
33
+ for (_, target), (_, source) in zip(self.model.named_parameters(), student.named_parameters()):
34
+ target.mul_(self.decay).add_(source.detach().to(target), alpha=1-self.decay)
35
+ for (_, target), (_, source) in zip(self.model.named_buffers(), student.named_buffers()):
36
+ target.copy_(source)
37
+ self.updates += 1
38
+
39
+ def state_dict(self):
40
+ return {'model': self.model.state_dict(), 'decay': self.decay, 'updates': self.updates}
41
+
42
+ def load_state_dict(self, state):
43
+ if state['decay'] != self.decay:
44
+ raise ValueError('EMA decay differs on resume')
45
+ self.model.load_state_dict(state['model'], strict=True)
46
+ self.updates = state['updates']
47
+
48
+
49
+ @torch.no_grad()
50
+ def clean_history_cache(model, clean, conditions, end):
51
+ cache = HistoryCache()
52
+ with evaluating(model):
53
+ for a, b in model.config.blocks(clean.shape[2]):
54
+ if b > end:
55
+ break
56
+ commit(model, clean[:, :, a:b], conditions, a, cache)
57
+ if cache.frames != end:
58
+ raise ValueError('History endpoint must be a block boundary')
59
+ return cache
60
+
61
+
62
+ class CausalCD(nn.Module):
63
+ def __init__(self, model, teacher, ema, cfg, quality=None):
64
+ super().__init__()
65
+ self.model = model
66
+ # Do not register frozen targets in DDP or the student optimizer.
67
+ self.targets = [teacher, ema]
68
+ self.quality = [quality]
69
+ self.cfg = cfg
70
+ self.grid = FlowMatchScheduler(shift=cfg['shift'], sigma_min=0., extra_one_step=True)
71
+ self.grid.set_timesteps(cfg['cd_grid_steps'])
72
+ self.conversion_grid = FlowMatchScheduler(shift=cfg['shift'], sigma_min=0., extra_one_step=True)
73
+ self.conversion_grid.set_timesteps(1000, training=True)
74
+
75
+ def sample_input(self, clean, index, noise):
76
+ if not 0 <= index < self.cfg['cd_grid_steps'] - 1:
77
+ raise ValueError('CD adjacent index outside nonzero upstream grid')
78
+ # Official add_noise uses the exact 48-point sigma; the transformer
79
+ # timesteps are cast to the configured compute dtype (BF16 upstream).
80
+ s = self.grid.sigmas[index].to(device=clean.device, dtype=torch.float32)
81
+ xt = ((1-s.float()) * clean.float() + s.float()*noise.float()).to(clean.dtype)
82
+ t = self.grid.timesteps[index].to(clean)
83
+ tn = self.grid.timesteps[index+1].to(clean)
84
+ return xt, t, tn
85
+
86
+ def forward(self, clean, c, *, xt, timestep, next_timestep, chunk_index):
87
+ a, b = self.model.config.video_blocks(clean.shape[2])[chunk_index]
88
+ current = xt[:, :, a:b]
89
+ prefix = clean[:, :, :a].detach()
90
+ teacher, ema = self.targets
91
+ with torch.no_grad():
92
+ teacher_cache = clean_history_cache(teacher, clean, c, a) if self.cfg['cd_frozen_cache'] else None
93
+ velocity = predict_chunk(teacher, current, float(timestep)/1000, prefix, c, a, teacher_cache)
94
+ if self.cfg['teacher_cfg'] != 1:
95
+ null_c = dict(c, drop_audio=torch.ones(clean.shape[0],device=clean.device,dtype=torch.bool))
96
+ # Conditional and unconditional histories must be different.
97
+ velocity_null = predict_chunk(teacher, current, float(timestep)/1000, prefix, c, a,
98
+ drop_audio=null_c['drop_audio'])
99
+ velocity = velocity_null + self.cfg['teacher_cfg'] * (velocity-velocity_null)
100
+ dt = (timestep-next_timestep)/1000
101
+ next_xt = current - dt * velocity
102
+ del teacher_cache
103
+ ema_cache = clean_history_cache(ema, clean, c, a) if self.cfg['cd_frozen_cache'] else None
104
+ ema_v = predict_chunk(ema, next_xt, float(next_timestep)/1000, prefix, c, a, ema_cache)
105
+ target = flow_to_x0(next_xt, ema_v, next_timestep, self.conversion_grid).detach()
106
+ del ema_cache
107
+ # No student cache by default: preserve parameter gradients through
108
+ # clean-history forward, as in the official doubled-sequence TF mask.
109
+ cache = clean_history_cache(self.model, clean, c, a) if self.cfg['cd_student_cache'] else None
110
+ velocity = predict_chunk(self.model, current, float(timestep)/1000, prefix, c, a, cache,
111
+ gradient_checkpointing=self.cfg['gradient_checkpointing'])
112
+ estimate = flow_to_x0(current, velocity, timestep, self.conversion_grid)
113
+ cd_loss = F.mse_loss(estimate.float(), target.float())
114
+ loss = cd_loss
115
+ stats = {'cd': cd_loss.detach(), 'sigma': float(timestep)/1000,
116
+ 'chunk': float(chunk_index), 'aux_loss': 0.}
117
+ if self.quality[0] is not None:
118
+ available = torch.cat([prefix, estimate], 2)
119
+ aux, aux_stats = self.quality[0](available, c, a, b, float(timestep)/1000)
120
+ loss = loss + self.cfg['cd_aux_scale'] * aux
121
+ stats.update({k: v.detach() for k,v in aux_stats.items()})
122
+ stats['aux_loss'] = aux.detach()
123
+ return loss, stats
viddub/causal/cf/dmd.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Asymmetric DMD aligned to pinned CF sampling and score-query conventions.
2
+
3
+ The real/fake models remain bidirectional. A single exit step is used for all
4
+ chunks and both fake/generator rollouts use the same exit-step distribution.
5
+ Wan-Dub differences: audio CFG (standard null + cfg*(cond-null) convention),
6
+ optional mouth weighting/quality/ODE retention, extra clean reference excluded
7
+ from video loss. No pretrained T2V checkpoint is substituted for Wan-Dub.
8
+ """
9
+ import torch
10
+ import torch.distributed as dist
11
+ from .sampling import cf_rollout
12
+ from .upstream import FlowMatchScheduler
13
+ from ..training.common import flow_noise, spatial_weight, weighted_mse
14
+ from ..training.dmd import DMDObjective
15
+
16
+
17
+ def shared_exit(steps, device):
18
+ value=torch.randint(steps,(1,),device=device)
19
+ if dist.is_available() and dist.is_initialized():dist.broadcast(value,src=0)
20
+ return int(value)
21
+
22
+
23
+ def score_sigma(device, shift=5., minimum=.02, maximum=.98):
24
+ # Official DMD samples integer indices, shifts, clamps, then add_noise
25
+ # maps the timestep to the nearest point on the 1000-point scheduler.
26
+ t=torch.randint(0,1000,(),device=device).float()/1000
27
+ t=(shift*t/(1+(shift-1)*t)).clamp(minimum,maximum)*1000
28
+ grid=FlowMatchScheduler(shift=shift,sigma_min=0.,extra_one_step=True)
29
+ grid.set_timesteps(1000,training=True)
30
+ index=(grid.timesteps.to(device)-t).abs().argmin()
31
+ return grid.sigmas.to(device)[index]
32
+
33
+
34
+ class CFAsymmetricDMD(DMDObjective):
35
+ def dmd(self,x0,c,chunk_index=None,dmd_mode='combined'):
36
+ if dmd_mode != 'combined':
37
+ raise ValueError('CF asymmetric DMD does not implement split-quality backward')
38
+ teacher,fake,anchor=self.scores
39
+ if teacher is None or fake is None or teacher.causal or fake.causal:
40
+ raise ValueError('CF asymmetric DMD requires bidirectional real and fake scores')
41
+ ranges=self.model.config.video_blocks(x0.shape[2])
42
+ i=int(torch.randint(len(ranges),())) if chunk_index is None else chunk_index
43
+ a,b=ranges[i]
44
+ exit_step=(self.student_steps-1 if self.dmd_rollout_mode=='final_step'
45
+ else shared_exit(self.student_steps,x0.device))
46
+ seed=int(torch.randint(2**31-1,(),device=x0.device))
47
+ generator=torch.Generator(device=x0.device).manual_seed(seed)
48
+ # Sample one chunk for parameter gradients. This is a documented
49
+ # memory-bounded unbiased chunk estimator, not full upstream backprop.
50
+ gen=cf_rollout(self.model,x0,c,steps=self.student_steps,shift=self.shift,
51
+ generator=generator,use_cache=self.use_cache,grad_chunk=i,grad_exit=exit_step,
52
+ gradient_checkpointing=self.gradient_checkpointing)
53
+ with torch.no_grad():
54
+ s=score_sigma(x0.device,self.shift,self.matching_min,self.matching_max)
55
+ noisy=flow_noise(gen.detach(),s,torch.randn_like(gen))
56
+ ts=noisy.new_full((noisy.shape[0],noisy.shape[2]),float(s)*1000)
57
+ real_v=self._score(teacher,noisy,ts,c)
58
+ if self.teacher_cfg!=1:
59
+ null=torch.ones(noisy.shape[0],device=x0.device,dtype=torch.bool)
60
+ null_v=self._score(teacher,noisy,ts,c,null)
61
+ real_v=null_v+self.teacher_cfg*(real_v-null_v)
62
+ real_x=noisy.float()-s*real_v.float()
63
+ fake_x=noisy.float()-s*self._score(fake,noisy,ts,c).float()
64
+ n=self.model.config.num_ref
65
+ denom=(gen[:,:,n:].float()-real_x[:,:,n:]).abs().mean((1,2,3,4),keepdim=True).clamp_min(1e-6)
66
+ direction=(fake_x-real_x)/denom
67
+ if not torch.isfinite(direction).all():raise FloatingPointError('Nonfinite DMD direction')
68
+ target=gen.detach().float()-direction
69
+ weight=spatial_weight(c['vace_context'][:,:,a:b],self.mouth_weight)
70
+ loss=.5*weighted_mse(gen[:,:,a:b],target[:,:,a:b],weight)
71
+ stats={'dmd':loss.detach(),'chunk':float(i),'exit_step':float(exit_step),
72
+ 'score_sigma':float(s),'direction_rms':direction[:,:,n:].square().mean().sqrt()}
73
+ if self.dmd_retention_weight:
74
+ if anchor is None:raise ValueError('Missing frozen anchor')
75
+ if self.dmd_score_offload:anchor.to(x0.device)
76
+ with torch.no_grad():
77
+ fixed=cf_rollout(anchor,x0,c,steps=self.student_steps,shift=self.shift,
78
+ generator=torch.Generator(device=x0.device).manual_seed(seed),
79
+ use_cache=self.use_cache,grad_exit=exit_step)
80
+ if self.dmd_score_offload:anchor.cpu();torch.cuda.empty_cache()
81
+ retain=weighted_mse(gen[:,:,a:b],fixed[:,:,a:b],weight)
82
+ loss+=self.dmd_retention_weight*retain;stats['retention']=retain.detach()
83
+ if self.quality[0] is not None and (not c.get('cross',False) or self.rollout_quality_cross):
84
+ aux,aux_stats=self.quality[0](gen[:,:,:b],c,a,b,0.)
85
+ loss+=self.rollout_quality_weight*aux
86
+ stats.update({f'rollout_{k}':v.detach() for k,v in aux_stats.items()})
87
+ return loss,stats
88
+
89
+ def fake(self,generated,c):
90
+ if generated is None:raise ValueError('Fake score requires generated samples')
91
+ gen=generated.detach();s=score_sigma(gen.device,self.shift,self.matching_min,self.matching_max)
92
+ noise=torch.randn_like(gen);noisy=flow_noise(gen,s,noise)
93
+ ts=gen.new_full((gen.shape[0],gen.shape[2]),float(s)*1000)
94
+ pred=self._score(self.model,noisy,ts,c,grad=True)
95
+ n=self.model.config.num_ref
96
+ loss=weighted_mse(pred[:,:,n:],(noise-gen)[:,:,n:],
97
+ spatial_weight(c['vace_context'][:,:,n:],self.mouth_weight))
98
+ return loss,{'fake_flow':loss.detach(),'score_sigma':float(s)}
viddub/causal/cf/sampling.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Official CF few-step rule: x0 prediction then independent re-noising.
2
+
3
+ Wan-Dub implementation of pipeline/causal_inference.py from the pinned source.
4
+ Unlike Euler sampling, this has independent Gaussian noise at each transition.
5
+ """
6
+ import torch
7
+ from .upstream import FlowMatchScheduler
8
+ from .consistency import flow_to_x0
9
+ from ..model import HistoryCache
10
+ from ..sampling import evaluating, commit, predict_chunk
11
+
12
+
13
+ def cf_rollout(model, shape_reference, c, *, steps=4, shift=5., noise=None,
14
+ generator=None, use_cache=True, grad_chunk=None, grad_exit=None,
15
+ gradient_checkpointing=False):
16
+ if steps < 1 or 1000 % steps:
17
+ raise ValueError('CF schedule requires steps to divide 1000')
18
+ scheduler = FlowMatchScheduler(shift=shift, sigma_min=0., extra_one_step=True)
19
+ scheduler.set_timesteps(1000, training=True)
20
+ timesteps = scheduler.timesteps[torch.arange(0,1000,1000//steps)].to(shape_reference.device)
21
+ noise = torch.randn(shape_reference.shape,device=shape_reference.device,dtype=shape_reference.dtype,
22
+ generator=generator) if noise is None else noise
23
+ if noise.shape != shape_reference.shape:
24
+ raise ValueError('Noise shape differs')
25
+ cache = HistoryCache() if use_cache else None
26
+ prefix = shape_reference[:, :, :model.config.num_ref].detach()
27
+ output = [prefix]
28
+ if grad_exit is not None and not 0 <= grad_exit < steps:
29
+ raise ValueError('Invalid exit step')
30
+ with evaluating(model):
31
+ if prefix.shape[2]:
32
+ commit(model, prefix, c, 0, cache)
33
+ for index, (a,b) in enumerate(model.config.video_blocks(shape_reference.shape[2])):
34
+ current = noise[:, :, a:b]
35
+ final = steps-1 if grad_exit is None else grad_exit
36
+ for j in range(final+1):
37
+ keep = index == grad_chunk and j == final
38
+ with torch.set_grad_enabled(keep):
39
+ # Upstream quantizes the warped timestep to integer at inference.
40
+ t = timesteps[j].long()
41
+ v = predict_chunk(model,current,float(t)/1000,prefix,c,a,cache,
42
+ gradient_checkpointing=gradient_checkpointing,detach_history=True)
43
+ estimate = flow_to_x0(current,v,t,scheduler)
44
+ if j < final:
45
+ with torch.no_grad():
46
+ tnext = timesteps[j+1].long()
47
+ step_index = (scheduler.timesteps.to(tnext.device)-tnext).abs().argmin()
48
+ sigma = scheduler.sigmas[step_index].to(estimate)
49
+ eps = torch.randn(estimate.shape,device=estimate.device,dtype=estimate.dtype,generator=generator)
50
+ current = ((1-sigma)*estimate.detach()+sigma*eps).to(shape_reference.dtype)
51
+ output.append(estimate)
52
+ commit(model,estimate,c,a,cache)
53
+ prefix = torch.cat([prefix.detach(),estimate.detach()],2)
54
+ return torch.cat(output,2)
viddub/causal/cf/train.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Finite, resumable CF++ causal-CD trainer with Wan-Dub conditioning.
2
+
3
+ Uses the official online adjacent teacher-step objective, AdamW and EMA.
4
+ All blocks contribute per clip; individual backward calls bound activation
5
+ memory without detaching student clean-history parameter paths by default.
6
+ """
7
+ import argparse
8
+ import copy
9
+ import datetime
10
+ import hashlib
11
+ import json
12
+ import random
13
+ import time
14
+ from contextlib import nullcontext
15
+ from pathlib import Path
16
+
17
+ import torch
18
+
19
+ from .consistency import CausalCD, ModelEMA
20
+ from .upstream import provenance as upstream_provenance
21
+ from ..runtime import (atomic_json, autocast, checkpoint_paths, export_weights, fingerprint,
22
+ first_item, load_components, local_imports, make_dataset, prepare_clip,
23
+ read_config, require_causal_metadata)
24
+ from ..train import check_loss, finite_update, optimizer
25
+
26
+
27
+ def main():
28
+ parser = argparse.ArgumentParser(description=__doc__)
29
+ parser.add_argument('--config',required=True)
30
+ parser.add_argument('--init-dir',required=True)
31
+ parser.add_argument('--init-step',type=int,required=True)
32
+ parser.add_argument('--output',required=True)
33
+ parser.add_argument('--resume')
34
+ parser.add_argument('--stop-after-step',type=int)
35
+ parser.add_argument('--check-only',action='store_true')
36
+ args = parser.parse_args()
37
+ cfg = read_config(args.config)
38
+ if cfg['stage'] != 'cd' or cfg['grad_accum'] != 1 or cfg['p_cross'] != 0 or cfg['art_weight']:
39
+ raise ValueError('CD pilot requires stage=cd, grad_accum=1, same data, Art disabled')
40
+ if cfg['warmup'] or cfg['lr_schedule'] != 'constant':
41
+ raise ValueError('This CF CD trainer uses the official constant LR without warmup')
42
+ final = cfg['max_steps'] if args.stop_after_step is None else args.stop_after_step
43
+ if not 0 < final <= cfg['max_steps']:
44
+ raise ValueError('Invalid operational stop')
45
+ out = Path(args.output)
46
+ if not args.resume and out.exists() and any(out.iterdir()):
47
+ raise FileExistsError(f'Output is not empty: {out}')
48
+ paths = checkpoint_paths(args.init_dir,args.init_step)
49
+ require_causal_metadata(args.init_dir,args.init_step,cfg,('ar',))
50
+ dataset, rows = make_dataset(cfg)
51
+ record = {'student_init': fingerprint(paths), 'upstream':upstream_provenance(),
52
+ 'rows_sha256':hashlib.sha256(json.dumps(rows,sort_keys=True).encode()).hexdigest(),
53
+ 'adapter_sources':fingerprint({n:Path(__file__).parent/n for n in
54
+ ['train.py','consistency.py','sampling.py','upstream.py']})}
55
+ resume = None
56
+ if args.resume:
57
+ if Path(args.resume).resolve().parent != out.resolve():
58
+ raise ValueError('Resume state must belong to this run')
59
+ resume = json.loads((Path(args.resume)/'complete.json').read_text())
60
+ if read_config(resume['config']) != cfg or resume['provenance'] != record:
61
+ raise ValueError('Resume config, code or provenance differs')
62
+ if args.check_only:
63
+ print(json.dumps({'status':'validated_no_gpu','samples':len(dataset),'provenance':record},indent=2))
64
+ return
65
+ local_imports()
66
+ from accelerate import Accelerator, DistributedDataParallelKwargs, InitProcessGroupKwargs
67
+ from viddub.full_context.runtime import ZeroLatents
68
+ accel = Accelerator(mixed_precision='no',gradient_accumulation_steps=1,
69
+ kwargs_handlers=[DistributedDataParallelKwargs(find_unused_parameters=False,gradient_as_bucket_view=True),
70
+ InitProcessGroupKwargs(timeout=datetime.timedelta(minutes=20))])
71
+ if accel.device.type != 'cuda':
72
+ raise RuntimeError('Training requires CUDA')
73
+ random.seed(cfg['seed']+accel.process_index);torch.manual_seed(cfg['seed']+accel.process_index)
74
+ pipe, whisper, student = load_components(paths,cfg,accel.device)
75
+ student.requires_grad_(True).eval()
76
+ teacher = copy.deepcopy(student).requires_grad_(False).eval()
77
+ ema = ModelEMA(copy.deepcopy(student),cfg['cd_ema_decay'])
78
+ quality = None
79
+ if any(cfg[k] for k in ['pixel_weight','lpips_weight','keep_weight','sync_weight']):
80
+ from ..quality import GTQuality
81
+ quality = GTQuality(pipe,cfg,accel.device)
82
+ obj = CausalCD(student,teacher,ema.model,cfg,quality)
83
+ opt = optimizer(student,cfg)
84
+ loader = torch.utils.data.DataLoader(dataset,batch_size=1,shuffle=True,collate_fn=first_item,
85
+ num_workers=cfg['workers'],generator=torch.Generator().manual_seed(cfg['seed']))
86
+ obj,opt,loader = accel.prepare(obj,opt,loader)
87
+ accel.register_for_checkpointing(ema)
88
+ step=epoch=consumed=0
89
+ if resume:
90
+ if resume['world_size'] != accel.num_processes:
91
+ raise ValueError('Resume requires same world size')
92
+ accel.load_state(args.resume)
93
+ step,epoch,consumed=(resume[k] for k in ('step','epoch','consumed_batches'))
94
+ else:
95
+ random.seed(cfg['seed']+accel.process_index);torch.manual_seed(cfg['seed']+accel.process_index)
96
+ if accel.is_main_process:
97
+ out.mkdir(parents=True,exist_ok=True)
98
+ atomic_json(out/'run.json',{'config':cfg,'provenance':record,'world_size':accel.num_processes,
99
+ 'scope':'diagnostic_CD_pilot_not_AR_promotion'})
100
+ accel.wait_for_everyone()
101
+ zero = ZeroLatents(pipe)
102
+ torch.cuda.reset_peak_memory_stats()
103
+ started=time.monotonic()
104
+ def save():
105
+ state=out/f'state_step{step:06d}'
106
+ exists=accel.reduce(torch.tensor(int(state.exists()),device=accel.device),'sum')
107
+ if int(exists):raise FileExistsError(state)
108
+ full = step == cfg['max_steps'] or step == final or step % cfg['save_state_every'] == 0
109
+ if full:accel.save_state(str(state))
110
+ if accel.is_main_process:
111
+ export_weights(student,out,step,cfg,record)
112
+ export_weights(ema.model,out/'ema',step,cfg,{**record,'export':'ema','ema_updates':ema.updates})
113
+ if full:
114
+ atomic_json(state/'complete.json',{'step':step,'epoch':epoch,'consumed_batches':consumed,
115
+ 'config':cfg,'provenance':record,'world_size':accel.num_processes,'ema_updates':ema.updates})
116
+ accel.wait_for_everyone()
117
+ while step < final:
118
+ if hasattr(loader,'set_epoch'):loader.set_epoch(epoch)
119
+ if getattr(loader,'generator',None) is not None:loader.generator.manual_seed(cfg['seed']+epoch)
120
+ active=accel.skip_first_batches(loader,consumed) if consumed else loader
121
+ for item in active:
122
+ consumed += 1
123
+ clean,c = prepare_clip(item,pipe,whisper,zero,cfg)
124
+ c['cross']=False
125
+ # Identical RNG consumption in pure CD and GT auxiliary variants.
126
+ index=random.randrange(cfg['cd_grid_steps']-1)
127
+ noise=torch.randn_like(clean)
128
+ raw=accel.unwrap_model(obj)
129
+ xt,t,tn=raw.sample_input(clean,index,noise)
130
+ ranges=student.config.video_blocks(clean.shape[2])
131
+ indices=list(range(len(ranges))) if cfg['cd_all_chunks'] else [int(torch.randint(len(ranges),()))]
132
+ stats={};value=clean.new_zeros((),dtype=torch.float32)
133
+ opt.zero_grad(set_to_none=True)
134
+ for j,i in enumerate(indices):
135
+ # Backward per chunk; accumulate parameter gradients before one
136
+ # DDP all-reduce and optimizer step for the complete clip.
137
+ context=accel.no_sync(obj) if j<len(indices)-1 else nullcontext()
138
+ with context:
139
+ with autocast(accel.device,cfg['precision']):
140
+ loss,metrics=obj(clean,c,xt=xt,timestep=t,next_timestep=tn,chunk_index=i)
141
+ loss=loss/len(indices)
142
+ check_loss(accel,loss)
143
+ accel.backward(loss)
144
+ value += loss.detach()
145
+ for key,v in metrics.items():stats[key]=stats.get(key,0.)+float(v)/len(indices)
146
+ del loss,metrics
147
+ norm=finite_update(accel,obj,opt,cfg)
148
+ ema.update(student)
149
+ step += 1
150
+ if step%cfg['log_every']==0 or step==final:
151
+ keys=sorted(stats)
152
+ values=torch.tensor([stats[k] for k in keys],device=accel.device)
153
+ values=accel.reduce(values,'mean').tolist()
154
+ log={'step':step,'loss':float(accel.reduce(value,'mean')),'grad_norm':norm,
155
+ 'elapsed_seconds':time.monotonic()-started,'ema_updates':ema.updates,
156
+ 'blocks_per_clip':len(indices),'grid_index':index,
157
+ 'peak_allocated_gib':torch.cuda.max_memory_allocated()/1024**3,
158
+ 'peak_reserved_gib':torch.cuda.max_memory_reserved()/1024**3,**dict(zip(keys,values))}
159
+ accel.print(json.dumps(log),flush=True)
160
+ if accel.is_main_process:
161
+ with (out/'train.jsonl').open('a') as handle:handle.write(json.dumps(log)+'\n')
162
+ if step%cfg['save_every']==0 or step in cfg['save_steps'] or step==final:save()
163
+ if step==final:break
164
+ epoch,consumed=epoch+1,0
165
+ accel.end_training()
166
+
167
+
168
+ if __name__=='__main__':main()
viddub/causal/cf/upstream.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Attributed scheduler compatibility from Causal-Forcing.
2
+
3
+ Adapted from ``utils/scheduler.py`` at commit
4
+ da3ddf1a590f30c9edc568e11c2f77170995c31c. The upstream project is
5
+ Apache-2.0 licensed. Keeping this small compatibility class locally avoids
6
+ requiring users to clone an otherwise unused complete repository.
7
+ """
8
+ import hashlib
9
+ from pathlib import Path
10
+
11
+ import torch
12
+
13
+ COMMIT = "da3ddf1a590f30c9edc568e11c2f77170995c31c"
14
+ REPOSITORY = "https://github.com/thu-ml/Causal-Forcing"
15
+
16
+
17
+ def provenance():
18
+ source = Path(__file__).resolve()
19
+ return {
20
+ "repository": REPOSITORY,
21
+ "commit": COMMIT,
22
+ "compatibility_file": source.name,
23
+ "sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
24
+ }
25
+
26
+
27
+ class FlowMatchScheduler:
28
+ def __init__(self, num_inference_steps=100, num_train_timesteps=1000,
29
+ shift=3.0, sigma_max=1.0, sigma_min=0.003 / 1.002,
30
+ inverse_timesteps=False, extra_one_step=False,
31
+ reverse_sigmas=False):
32
+ self.num_train_timesteps = num_train_timesteps
33
+ self.shift = shift
34
+ self.sigma_max = sigma_max
35
+ self.sigma_min = sigma_min
36
+ self.inverse_timesteps = inverse_timesteps
37
+ self.extra_one_step = extra_one_step
38
+ self.reverse_sigmas = reverse_sigmas
39
+ self.set_timesteps(num_inference_steps)
40
+
41
+ def set_timesteps(self, num_inference_steps=100, denoising_strength=1.0,
42
+ training=False):
43
+ sigma_start = self.sigma_min + (self.sigma_max - self.sigma_min) * denoising_strength
44
+ count = num_inference_steps + 1 if self.extra_one_step else num_inference_steps
45
+ self.sigmas = torch.linspace(sigma_start, self.sigma_min, count)
46
+ if self.extra_one_step:
47
+ self.sigmas = self.sigmas[:-1]
48
+ if self.inverse_timesteps:
49
+ self.sigmas = torch.flip(self.sigmas, dims=[0])
50
+ self.sigmas = self.shift * self.sigmas / (1 + (self.shift - 1) * self.sigmas)
51
+ if self.reverse_sigmas:
52
+ self.sigmas = 1 - self.sigmas
53
+ self.timesteps = self.sigmas * self.num_train_timesteps
54
+ if training:
55
+ x = self.timesteps
56
+ y = torch.exp(-2 * ((x - num_inference_steps / 2) / num_inference_steps) ** 2)
57
+ shifted = y - y.min()
58
+ self.linear_timesteps_weights = shifted * (num_inference_steps / shifted.sum())
59
+
60
+ def step(self, model_output, timestep, sample, to_final=False):
61
+ if timestep.ndim == 2:
62
+ timestep = timestep.flatten(0, 1)
63
+ self.sigmas = self.sigmas.to(model_output.device)
64
+ self.timesteps = self.timesteps.to(model_output.device)
65
+ timestep_id = torch.argmin(
66
+ (self.timesteps.unsqueeze(0) - timestep.unsqueeze(1)).abs(), dim=1)
67
+ shape = (-1,) + (1,) * (sample.ndim - 1)
68
+ sigma = self.sigmas[timestep_id].reshape(shape)
69
+ if to_final or (timestep_id + 1 >= len(self.timesteps)).any():
70
+ next_sigma = 1 if (self.inverse_timesteps or self.reverse_sigmas) else 0
71
+ else:
72
+ next_sigma = self.sigmas[timestep_id + 1].reshape(shape)
73
+ return sample + model_output * (next_sigma - sigma)
74
+
75
+ def add_noise(self, original_samples, noise, timestep):
76
+ if timestep.ndim == 2:
77
+ timestep = timestep.flatten(0, 1)
78
+ self.sigmas = self.sigmas.to(noise.device)
79
+ self.timesteps = self.timesteps.to(noise.device)
80
+ timestep_id = torch.argmin(
81
+ (self.timesteps.unsqueeze(0) - timestep.unsqueeze(1)).abs(), dim=1)
82
+ shape = (-1,) + (1,) * (noise.ndim - 1)
83
+ sigma = self.sigmas[timestep_id].reshape(shape)
84
+ return ((1 - sigma) * original_samples + sigma * noise).type_as(noise)
85
+
86
+ @staticmethod
87
+ def training_target(sample, noise, timestep):
88
+ return noise - sample
89
+
90
+ def training_weight(self, timestep):
91
+ if timestep.ndim == 2:
92
+ timestep = timestep.flatten(0, 1)
93
+ self.linear_timesteps_weights = self.linear_timesteps_weights.to(timestep.device)
94
+ timestep_id = torch.argmin(
95
+ (self.timesteps.unsqueeze(1) - timestep.unsqueeze(0)).abs(), dim=0)
96
+ return self.linear_timesteps_weights[timestep_id]
viddub/causal/infer.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate one prepared face clip for same/cross audio comparison.
2
+
3
+ This CLI outputs a cropped clip; full-video detection/paste-back remains in the
4
+ the full-video inference entry point. It does not pretend to be an end-to-end streaming server.
5
+ """
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+ import time
10
+
11
+ import numpy as np
12
+ import torch
13
+
14
+ from .runtime import (atomic_json, autocast, checkpoint_paths, load_components,
15
+ local_imports, read_config, require_causal_metadata)
16
+ from .sampling import rollout, schedule
17
+
18
+
19
+ def main():
20
+ p = argparse.ArgumentParser(description=__doc__)
21
+ p.add_argument("--config", required=True)
22
+ p.add_argument("--checkpoint-dir", required=True)
23
+ p.add_argument("--step", type=int, required=True)
24
+ p.add_argument("--clip-id", required=True, help="Prepared validation clip id in s02 manifest")
25
+ p.add_argument("--audio", help="Optional replacement WAV; original clip audio otherwise")
26
+ p.add_argument("--output", required=True)
27
+ p.add_argument("--steps", type=int, help="AR defaults to 30; ODE/DMD requires trained step count")
28
+ p.add_argument("--audio-cfg", type=float, default=1)
29
+ p.add_argument("--seed", type=int, default=0)
30
+ p.add_argument("--no-cache", action="store_true", help="Reference recomputation implementation")
31
+ a = p.parse_args()
32
+ cfg = read_config(a.config)
33
+ meta = require_causal_metadata(a.checkpoint_dir, a.step, cfg)
34
+ steps = a.steps if a.steps is not None else (30 if meta["stage"] == "ar" else cfg["student_steps"])
35
+ if meta["stage"] != "ar":
36
+ if steps != meta["config"]["student_steps"] or cfg["shift"] != meta["config"]["shift"]:
37
+ raise ValueError("Few-step inference must use the training schedule")
38
+ if a.audio_cfg != 1:
39
+ raise ValueError("Guidance is distilled into ODE/DMD weights; use audio-cfg=1")
40
+ if Path(a.output).exists():
41
+ raise FileExistsError(a.output)
42
+ local_imports()
43
+ import librosa
44
+ from viddub.full_context import runtime as full_context_runtime
45
+ from viddub.full_context.runtime import ZeroLatents, build_inputs, compute_latents, load_clip, load_rows, write_mp4
46
+ if cfg.get("clips_root"):
47
+ full_context_runtime.CLIPS = Path(cfg["clips_root"])
48
+ rows = {r["clip_id"]: r for r in load_rows("val")}
49
+ if a.clip_id not in rows:
50
+ raise ValueError("clip-id must be from the validation split")
51
+ if not torch.cuda.is_available():
52
+ raise RuntimeError("Full inference requires CUDA")
53
+ device = torch.device("cuda")
54
+ pipe, whisper, model = load_components(checkpoint_paths(a.checkpoint_dir, a.step), cfg, device)
55
+ model.requires_grad_(False).eval()
56
+ clip = load_clip(rows[a.clip_id], cfg["num_frames"])
57
+ audio = librosa.load(a.audio, sr=16000, mono=True)[0] if a.audio else clip["audio"]
58
+ length = round(cfg["num_frames"] / cfg["fps"] * 16000)
59
+ audio = np.pad(audio[:length], (0, max(0, length - len(audio))))
60
+ started = time.monotonic()
61
+ cached = full_context_runtime.CLIPS / rows[a.clip_id]["dir"] / cfg["latents_name"]
62
+ if cached.is_file():
63
+ lat = torch.load(cached, map_location="cpu", weights_only=False)
64
+ else:
65
+ if pipe.text_encoder is None:
66
+ raise ValueError("No cached latents: unset VIDDUB_SKIP_TEXT_ENCODER for text encoding")
67
+ pipe.text_encoder.to(device)
68
+ lat = compute_latents(pipe, clip)
69
+ pipe.text_encoder.cpu()
70
+ x0, vc, ctx = build_inputs(lat, clip["mask"], cfg["num_frames"], ZeroLatents(pipe),
71
+ drop_ref=cfg["num_ref"] == 0)
72
+ dtype = torch.bfloat16 if cfg["precision"] == "bf16" else torch.float32
73
+ hs = whisper([audio], device, torch.bfloat16)
74
+ conditions = {"vace_context": vc.to(dtype), "context": ctx.to(dtype),
75
+ "whisper_hs": hs.to(dtype), "num_frames": cfg["num_frames"]}
76
+ torch.manual_seed(a.seed)
77
+ torch.cuda.synchronize()
78
+ denoise_start = time.monotonic()
79
+ with torch.no_grad(), autocast(device, cfg["precision"]):
80
+ if cfg['sampler']=='cf_renoise':
81
+ from .cf.sampling import cf_rollout
82
+ latents=cf_rollout(model,x0.to(dtype),conditions,steps=steps,shift=cfg['shift'],use_cache=not a.no_cache)
83
+ else:
84
+ latents = rollout(model, x0.to(dtype), conditions, schedule(steps, cfg["shift"], device),
85
+ use_cache=not a.no_cache, audio_cfg=a.audio_cfg)
86
+ torch.cuda.synchronize()
87
+ denoise_seconds = time.monotonic() - denoise_start
88
+ video = pipe.vae.decode(latents[:, :, cfg["num_ref"]:].to(pipe.torch_dtype), device=device)
89
+ frames = ((video[0].permute(1, 2, 3, 0).float().clamp(-1, 1) + 1) * 127.5).round().byte().cpu().numpy()
90
+ write_mp4(a.output, frames, audio, cfg["fps"])
91
+ report = {"clip_id": a.clip_id, "step": a.step, "stage": meta["stage"], "sampling_steps": steps,
92
+ "cached": not a.no_cache, "sampler":cfg['sampler'], "denoise_seconds": denoise_seconds,
93
+ "total_seconds": time.monotonic() - started,
94
+ "frames": len(frames), "note": "Offline clip; excludes model load, no streaming latency claim"}
95
+ atomic_json(Path(a.output).with_suffix(".json"), report)
96
+ print(json.dumps(report, indent=2))
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
viddub/causal/make_trajectories.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate auditable causal teacher rollouts; never initialize from ground-truth video."""
2
+ import argparse
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import torch
8
+
9
+ from .runtime import (atomic_json, autocast, checkpoint_paths, fingerprint, load_components,
10
+ local_imports, make_dataset, prepare_clip, read_config, require_causal_metadata)
11
+ from .sampling import rollout, schedule
12
+
13
+
14
+ def safe_tensor_tree(value):
15
+ """Convert trajectory payloads to weights_only-safe primitive/tensor trees."""
16
+ if isinstance(value, np.ndarray):
17
+ return torch.from_numpy(value)
18
+ if torch.is_tensor(value):
19
+ return value.cpu()
20
+ if isinstance(value, dict):
21
+ return {k: safe_tensor_tree(v) for k, v in value.items()}
22
+ if isinstance(value, (list, tuple)):
23
+ return type(value)(safe_tensor_tree(v) for v in value)
24
+ if value is None or isinstance(value, (str, int, float, bool)):
25
+ return value
26
+ raise TypeError(f'Unsafe trajectory payload type: {type(value).__name__}')
27
+
28
+
29
+ def main():
30
+ p = argparse.ArgumentParser(description=__doc__)
31
+ p.add_argument("--config", required=True, help="ODE-stage config; schedule must match student training")
32
+ p.add_argument("--teacher-dir", required=True)
33
+ p.add_argument("--teacher-step", type=int, required=True)
34
+ p.add_argument("--output", required=True)
35
+ p.add_argument("--teacher-steps", type=int, default=32)
36
+ p.add_argument("--max-clips", type=int, default=256)
37
+ p.add_argument("--audio-mode", choices=["same", "cross", "both"], default="both")
38
+ p.add_argument("--shard", type=int, default=0)
39
+ p.add_argument("--shards", type=int, default=1)
40
+ p.add_argument("--check-only", action="store_true")
41
+ a = p.parse_args()
42
+ cfg = read_config(a.config)
43
+ paths = checkpoint_paths(a.teacher_dir, a.teacher_step)
44
+ require_causal_metadata(a.teacher_dir, a.teacher_step, cfg, ("ar",))
45
+ if a.teacher_steps < cfg["student_steps"] or a.teacher_steps % cfg["student_steps"]:
46
+ raise ValueError("teacher-steps must be a positive multiple of student_steps")
47
+ if a.max_clips < 1:
48
+ raise ValueError("max-clips must be positive")
49
+ if a.shards < 1 or not 0 <= a.shard < a.shards:
50
+ raise ValueError("invalid shard assignment")
51
+ out = Path(a.output)
52
+ if out.exists() and any(out.iterdir()):
53
+ raise ValueError("Use a new empty trajectory output directory")
54
+ dataset, rows = make_dataset(cfg)
55
+ if a.audio_mode != "same" and len(dataset) < 2:
56
+ raise ValueError("Cross-audio trajectories need at least two clips")
57
+ if a.check_only:
58
+ print(json.dumps({"status": "inputs_validated_no_gpu_execution", "clips": min(a.max_clips, len(dataset)),
59
+ "teacher_steps": a.teacher_steps, "student_steps": cfg["student_steps"]}, indent=2))
60
+ return
61
+ if not torch.cuda.is_available():
62
+ raise RuntimeError("Trajectory generation requires CUDA")
63
+ local_imports()
64
+ from viddub.full_context import runtime as full_context_runtime
65
+ from viddub.full_context.runtime import ZeroLatents
66
+ from .runtime import PixelClipDataset
67
+ if not isinstance(dataset, PixelClipDataset):
68
+ dataset = PixelClipDataset(dataset, full_context_runtime.CLIPS)
69
+ device = torch.device("cuda")
70
+ pipe, whisper, model = load_components(paths, cfg, device)
71
+ model.requires_grad_(False).eval()
72
+ zero = ZeroLatents(pipe)
73
+ sigmas = schedule(a.teacher_steps, cfg["shift"], device)
74
+ interval = a.teacher_steps // cfg["student_steps"]
75
+ manifest = {"schema": 1, "complete": False, "config": cfg,
76
+ "student_steps": cfg["student_steps"], "teacher_sigmas": sigmas.cpu().tolist(),
77
+ "boundary_indices": list(range(0, a.teacher_steps + 1, interval)),
78
+ "teacher": fingerprint(paths), "teacher_cfg": cfg["teacher_cfg"], "records": []}
79
+ out.mkdir(parents=True, exist_ok=True)
80
+ atomic_json(out / "manifest.json", manifest)
81
+ for index in range(a.shard, min(a.max_clips, len(dataset)), a.shards):
82
+ modes = ("same", "cross") if a.audio_mode == "both" else (a.audio_mode,)
83
+ for mode in modes:
84
+ clip = dataset[index]
85
+ audio_id = clip["clip_id"]
86
+ if mode == "cross":
87
+ partner_id = rows[index].get('audio_cross_id')
88
+ if partner_id is not None:
89
+ candidates = [j for j, row in enumerate(rows) if row['clip_id'] == partner_id]
90
+ if len(candidates) != 1:
91
+ raise ValueError('Frozen cross-audio partner missing or ambiguous')
92
+ other = dataset[candidates[0]]
93
+ else:
94
+ other = dataset[(index + 1) % len(dataset)]
95
+ clip["audio"], audio_id = other["audio"], other["clip_id"]
96
+ x0, conditions = prepare_clip(clip, pipe, whisper, zero, cfg)
97
+ conditions['cross'] = mode == 'cross'
98
+ torch.manual_seed(cfg["seed"] + index * 2 + int(mode == "cross"))
99
+ with torch.no_grad(), autocast(device, cfg["precision"]):
100
+ generated, chunks = rollout(model, x0, conditions, sigmas, use_cache=cfg["use_cache"],
101
+ audio_cfg=cfg["teacher_cfg"], trajectory=True)
102
+ # Persist reference + shape only, never use GT video as trajectory input.
103
+ shape_ref = torch.zeros_like(x0)
104
+ shape_ref[:, :, :cfg["num_ref"]] = x0[:, :, :cfg["num_ref"]]
105
+ name = f"{index:06d}_{mode}.pt"
106
+ data = {"clip_id": clip["clip_id"], "audio_id": audio_id, "mode": mode,
107
+ "shape_reference": shape_ref.cpu(), "gt_latents": x0.cpu(),
108
+ "generated": generated.cpu(), "chunks": chunks,
109
+ "conditions": safe_tensor_tree(conditions)}
110
+ torch.save(data, out / (name + ".tmp"))
111
+ (out / (name + ".tmp")).replace(out / name)
112
+ manifest["records"].append({"file": name, "clip_id": clip["clip_id"], "audio_id": audio_id,
113
+ "mode": mode, "bytes": (out / name).stat().st_size})
114
+ atomic_json(out / "manifest.json", manifest)
115
+ print(json.dumps(manifest["records"][-1]), flush=True)
116
+ manifest["complete"] = True
117
+ atomic_json(out / "manifest.json", manifest)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
viddub/causal/model.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Causal forward over the retained Wan/VACE and VidDub audio weights.
2
+
3
+ Parameter names remain compatible with the released checkpoints. The reference
4
+ is a separate prefix block; video blocks attend to themselves and earlier blocks.
5
+ Cache writes are explicit, inference-only transactions on clean history.
6
+ """
7
+ from dataclasses import asdict, dataclass, field
8
+
9
+ import torch
10
+ from torch import nn
11
+ from torch.nn import functional as F
12
+ from torch.utils.checkpoint import checkpoint
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CausalConfig:
17
+ chunk_size: int = 3 # latent frames; first video chunk includes the VAE first frame
18
+ num_ref: int = 1
19
+ fps: float = 24.0
20
+
21
+ def __post_init__(self):
22
+ if self.chunk_size < 1 or self.num_ref not in (0, 1) or self.fps <= 0:
23
+ raise ValueError("Require chunk_size >= 1, num_ref in {0,1}, fps > 0")
24
+
25
+ def blocks(self, frames):
26
+ if frames <= self.num_ref:
27
+ raise ValueError("Sequence must contain at least one video latent")
28
+ out = [(0, self.num_ref)] if self.num_ref else []
29
+ out += [(i, min(i + self.chunk_size, frames))
30
+ for i in range(self.num_ref, frames, self.chunk_size)]
31
+ return out
32
+
33
+ def video_blocks(self, frames):
34
+ return [(a, b) for a, b in self.blocks(frames) if a >= self.num_ref]
35
+
36
+
37
+ @dataclass
38
+ class HistoryCache:
39
+ """Use a NEW instance for every sample/condition/CFG branch/model revision.
40
+
41
+ Keys are layer-specific; positions are absolute and never reset at a chunk.
42
+ No eviction yet: this reference implementation is bounded by training length.
43
+ """
44
+ frames: int = 0
45
+ entries: dict = field(default_factory=dict)
46
+ geometry: tuple | None = None
47
+ model_id: int | None = None
48
+ # None preserves the correctness-reference behavior (all clean history).
49
+ # Otherwise keep the separate reference prefix plus at most this many
50
+ # most-recent video latent frames. Absolute RoPE is already embedded in K.
51
+ max_video_history_frames: int | None = None
52
+ retained_frames: int = 0
53
+
54
+ def __post_init__(self):
55
+ if self.max_video_history_frames is not None and self.max_video_history_frames < 0:
56
+ raise ValueError("max_video_history_frames must be nonnegative or None")
57
+
58
+
59
+ def _sinusoidal(dim, t):
60
+ freq = torch.pow(10000, -torch.arange(dim // 2, device=t.device,
61
+ dtype=torch.float64) / (dim // 2))
62
+ angles = t.flatten().double()[:, None] * freq[None]
63
+ return torch.cat([angles.cos(), angles.sin()], -1).to(t.dtype)
64
+
65
+
66
+ def _rope(x, freqs, heads):
67
+ b, n, dim = x.shape
68
+ z = x.reshape(b, n, heads, -1, 2).double().contiguous()
69
+ z = torch.view_as_complex(z) * freqs
70
+ return torch.view_as_real(z).flatten(2).to(x.dtype)
71
+
72
+
73
+ def _attention(q, k, v, heads):
74
+ # RMSNorm can promote q/k when trainable master weights are float32.
75
+ q, k = q.to(v.dtype), k.to(v.dtype)
76
+ if v.is_cuda and v.dtype in (torch.bfloat16, torch.float16):
77
+ # Use the SAME fused backend as the SFT teacher. BF16 SDPA vs FA2
78
+ # differences compound substantially through this trained checkpoint.
79
+ from viddub.foundation.models.wan_video_dit import flash_attention
80
+ return flash_attention(q, k, v, heads)
81
+ def split(z):
82
+ return z.unflatten(-1, (heads, -1)).transpose(1, 2)
83
+ y = F.scaled_dot_product_attention(split(q), split(k), split(v), dropout_p=0.0)
84
+ return y.transpose(1, 2).flatten(2)
85
+
86
+
87
+ class CausalWanDub(nn.Module):
88
+ def __init__(self, dit, vace, adapter, config=CausalConfig(), causal=True):
89
+ super().__init__()
90
+ self.dit, self.vace, self.adapter = dit, vace, adapter
91
+ self.config, self.causal = config, causal
92
+ if dit.patch_size[0] != 1 or vace.vace_patch_embedding.kernel_size[0] != 1:
93
+ raise ValueError("Only temporal patch size 1 is supported")
94
+ if len(adapter.blocks) != len(dit.blocks):
95
+ raise ValueError("Audio/DiT layer counts differ")
96
+
97
+ def _self_attention(self, attn, x, freqs, blocks, tokens_per_frame,
98
+ start_frame, cache, pending, key, detach_history_before):
99
+ q = _rope(attn.norm_q(attn.q(x)), freqs, attn.num_heads)
100
+ k = _rope(attn.norm_k(attn.k(x)), freqs, attn.num_heads)
101
+ v = attn.v(x)
102
+ k = k.to(v.dtype)
103
+ if cache is None and detach_history_before:
104
+ stop = detach_history_before * tokens_per_frame
105
+ k = torch.cat([k[:, :stop].detach(), k[:, stop:]], 1)
106
+ v = torch.cat([v[:, :stop].detach(), v[:, stop:]], 1)
107
+ if cache is not None:
108
+ old = cache.entries.get(key)
109
+ if cache.frames and old is None:
110
+ raise ValueError(f"Missing history for {key}")
111
+ if old is not None:
112
+ k = torch.cat([old[0], k], 1)
113
+ v = torch.cat([old[1], v], 1)
114
+ if pending is not None:
115
+ pending[key] = (k.detach(), v.detach())
116
+ if not self.causal:
117
+ y = _attention(q, k, v, attn.num_heads)
118
+ elif cache is not None:
119
+ # A cached call is exactly one complete current block. Its K/V are
120
+ # retained clean history followed by all tokens of this block, so
121
+ # every current query may read the entire tensor. Absolute temporal
122
+ # positions were already applied by RoPE before K entered the cache.
123
+ y = _attention(q, k, v, attn.num_heads)
124
+ else:
125
+ pieces = []
126
+ for a, b in blocks:
127
+ a, b = max(a, start_frame), min(b, start_frame + x.shape[1] // tokens_per_frame)
128
+ if a >= b:
129
+ continue
130
+ qa, qb = (a - start_frame) * tokens_per_frame, (b - start_frame) * tokens_per_frame
131
+ # All spatial tokens WITHIN this block are mutually visible.
132
+ pieces.append(_attention(q[:, qa:qb], k[:, :b * tokens_per_frame],
133
+ v[:, :b * tokens_per_frame], attn.num_heads))
134
+ y = torch.cat(pieces, 1)
135
+ return attn.o(y)
136
+
137
+ def _block(self, block, x, context, t_mod, freqs, blocks, spatial,
138
+ start, cache, pending, key, detach_history_before):
139
+ shift, scale, gate, shift_mlp, scale_mlp, gate_mlp = (
140
+ block.modulation.to(t_mod) + t_mod).unbind(2)
141
+ h = block.norm1(x) * (1 + scale) + shift
142
+ x = x + gate * self._self_attention(block.self_attn, h, freqs, blocks,
143
+ spatial, start, cache, pending, key, detach_history_before)
144
+ ca = block.cross_attn
145
+ if ca.has_image_input:
146
+ raise ValueError("Use the VACE reference prefix, not CLIP image conditioning")
147
+ h = block.norm3(x)
148
+ x = x + ca.o(_attention(ca.norm_q(ca.q(h)), ca.norm_k(ca.k(context)),
149
+ ca.v(context), ca.num_heads))
150
+ return x + gate_mlp * block.ffn(block.norm2(x) * (1 + scale_mlp) + shift_mlp)
151
+
152
+ def forward(self, latents, timesteps, context, vace_context, whisper_hs,
153
+ num_frames, drop_audio=None, *, start_frame=0, cache=None,
154
+ commit_cache=False, gradient_checkpointing=False, detach_history_before=0,
155
+ return_hidden_layer=None, audio_tokens=None):
156
+ """timesteps: scalar, [B], or [B,F], using t=1000*sigma.
157
+
158
+ A cached call receives ONLY a single new block of latents/conditions.
159
+ whisper_hs remains the full (offline) clip feature tensor, indexed with
160
+ absolute frame offsets. Reference latents must be clean at sampling time.
161
+ """
162
+ b, _, frames, height, width = latents.shape
163
+ if return_hidden_layer is not None and not 0 <= return_hidden_layer < len(self.dit.blocks):
164
+ raise ValueError('Invalid hidden layer')
165
+ total = (num_frames - 1) // 4 + 1 + self.config.num_ref
166
+ if num_frames < 1 or (num_frames - 1) % 4 or start_frame + frames > total:
167
+ raise ValueError("num_frames must be 4n+1 and cover this latent slice")
168
+ if vace_context.shape[0] != b or vace_context.shape[2:] != latents.shape[2:]:
169
+ raise ValueError("VACE and video latent shapes must match")
170
+ if height % self.dit.patch_size[1] or width % self.dit.patch_size[2]:
171
+ raise ValueError("Latent spatial dimensions must be divisible by patch size")
172
+ blocks = self.config.blocks(total)
173
+ read_cache = cache
174
+ if cache is None:
175
+ if start_frame or commit_cache:
176
+ raise ValueError("A nonzero start/commit requires a history cache")
177
+ pending = None
178
+ else:
179
+ if not self.causal or start_frame != cache.frames:
180
+ raise ValueError("Cache must be contiguous and causal")
181
+ if (start_frame, start_frame + frames) not in blocks:
182
+ raise ValueError("Cached call must cover exactly one complete block")
183
+ geometry = (b, height, width, total, self.config, latents.device, latents.dtype)
184
+ if cache.geometry is not None and cache.geometry != geometry:
185
+ raise ValueError("Cache geometry changed; create a new cache")
186
+ if cache.model_id not in (None, id(self)):
187
+ raise ValueError("Cache belongs to a different model")
188
+ if commit_cache and (torch.is_grad_enabled() or self.training):
189
+ raise ValueError("Cache commits require eval() and torch.no_grad()")
190
+ pending = {} if commit_cache else None
191
+ # Checkpoint recomputation must see the history from THIS forward,
192
+ # even after rollout commits subsequent chunks to the live cache.
193
+ read_cache = HistoryCache(cache.frames, cache.entries, cache.geometry, cache.model_id,
194
+ cache.max_video_history_frames, cache.retained_frames)
195
+ ts = torch.as_tensor(timesteps, device=latents.device, dtype=latents.dtype)
196
+ if ts.numel() == 1:
197
+ ts = ts.reshape(1, 1).expand(b, frames)
198
+ elif ts.shape == (b,):
199
+ ts = ts[:, None].expand(b, frames)
200
+ elif ts.shape != (b, frames):
201
+ raise ValueError("timesteps must be scalar, [B], or [B,F]")
202
+ if commit_cache and torch.count_nonzero(ts).item():
203
+ raise ValueError("Only t=0 clean history may be committed")
204
+ if audio_tokens is None:
205
+ tokens = self.adapter.tokens(whisper_hs, num_frames, self.config.num_ref,
206
+ torch.zeros(b, device=latents.device, dtype=torch.bool)
207
+ if drop_audio is None else drop_audio,
208
+ fps=self.config.fps)
209
+ else:
210
+ if drop_audio is not None and bool(torch.as_tensor(drop_audio).any()):
211
+ raise ValueError("Precomputed conditional audio tokens cannot serve a null-audio branch")
212
+ expected = (num_frames - 1) // 4 + 1 + self.config.num_ref
213
+ if audio_tokens.shape[:2] != (b, expected):
214
+ raise ValueError("Precomputed audio-token shape does not match the sequence")
215
+ tokens = audio_tokens
216
+ tokens = tokens[:, start_frame:start_frame + frames]
217
+ x = self.dit.patchify(latents)
218
+ _, _, f, h, w = x.shape
219
+ spatial = h * w
220
+ x = x.flatten(2).transpose(1, 2).contiguous()
221
+ context = self.dit.text_embedding(context)
222
+ # Project each distinct timestep before spatial expansion. Projecting
223
+ # thousands of repeated BF16 rows selects different GEMM kernels than
224
+ # the original single-timestep path and needlessly amplifies rounding.
225
+ unique_ts, inverse = torch.unique(ts, return_inverse=True)
226
+ time_values, mod_values = [], []
227
+ for value in unique_ts.unbind():
228
+ emb = self.dit.time_embedding(_sinusoidal(self.dit.freq_dim, value[None]))
229
+ time_values.append(emb)
230
+ mod_values.append(self.dit.time_projection(emb))
231
+ t = torch.cat(time_values)[inverse].reshape(b, f, -1).repeat_interleave(spatial, 1)
232
+ t_mod = torch.cat(mod_values)[inverse].reshape(b, f, 6, self.dit.dim).repeat_interleave(spatial, 1)
233
+ ff, fh, fw = self.dit.freqs
234
+ if start_frame + f > len(ff) or h > len(fh) or w > len(fw):
235
+ raise ValueError("Position exceeds the model RoPE table")
236
+ freqs = torch.cat([
237
+ ff[start_frame:start_frame + f].view(f, 1, 1, -1).expand(f, h, w, -1),
238
+ fh[:h].view(1, h, 1, -1).expand(f, h, w, -1),
239
+ fw[:w].view(1, 1, w, -1).expand(f, h, w, -1),
240
+ ], -1).reshape(f * h * w, 1, -1).to(x.device)
241
+
242
+ def run(block, value, key):
243
+ def fn(value):
244
+ return self._block(block, value, context, t_mod, freqs, blocks,
245
+ spatial, start_frame, read_cache, pending, key, detach_history_before)
246
+ if gradient_checkpointing and torch.is_grad_enabled():
247
+ return checkpoint(fn, value, use_reentrant=False)
248
+ return fn(value)
249
+
250
+ # Original VACE concatenates per-example tokens, yielding contiguous
251
+ # [B,N,C] storage before before_proj. Preserve that GEMM input layout.
252
+ c = self.vace.vace_patch_embedding(vace_context).flatten(2).transpose(1, 2).contiguous()
253
+ hints = []
254
+ for i, block in enumerate(self.vace.vace_blocks):
255
+ if i == 0:
256
+ c = block.before_proj(c) + x
257
+ c = run(block, c, ("vace", i))
258
+ hints.append(block.after_proj(c))
259
+ selected_hidden = None
260
+ for i, block in enumerate(self.dit.blocks):
261
+ x = run(block, x, ("dit", i))
262
+ if i in self.vace.vace_layers_mapping:
263
+ x = x + hints[self.vace.vace_layers_mapping[i]]
264
+ audio_block = self.adapter.blocks[i]
265
+ x = checkpoint(audio_block, x, tokens, f, use_reentrant=False) if (
266
+ gradient_checkpointing and torch.is_grad_enabled()) else audio_block(x, tokens, f)
267
+ if i == return_hidden_layer:
268
+ selected_hidden = x.reshape(b, f, h, w, -1)
269
+ out = self.dit.unpatchify(self.dit.head(x, t), (f, h, w))
270
+ if pending is not None:
271
+ # Publish only after EVERY layer and output succeeded.
272
+ retained_frames = start_frame + frames
273
+ if cache.max_video_history_frames is not None:
274
+ keep_video = min(cache.max_video_history_frames,
275
+ max(0, retained_frames - self.config.num_ref))
276
+ keep_total = self.config.num_ref + keep_video
277
+ for key, (k, v) in pending.items():
278
+ ref_tokens = self.config.num_ref * spatial
279
+ video_tokens = keep_video * spatial
280
+ if ref_tokens:
281
+ k_ref, v_ref = k[:, :ref_tokens], v[:, :ref_tokens]
282
+ else:
283
+ k_ref, v_ref = k[:, :0], v[:, :0]
284
+ if video_tokens:
285
+ k_video, v_video = k[:, -video_tokens:], v[:, -video_tokens:]
286
+ else:
287
+ k_video, v_video = k[:, :0], v[:, :0]
288
+ pending[key] = (torch.cat([k_ref, k_video], 1).detach(),
289
+ torch.cat([v_ref, v_video], 1).detach())
290
+ retained_frames = keep_total
291
+ cache.entries = pending
292
+ cache.frames = start_frame + frames
293
+ cache.retained_frames = retained_frames
294
+ cache.geometry, cache.model_id = geometry, id(self)
295
+ return (out, selected_hidden) if return_hidden_layer is not None else out
296
+
297
+ def architecture(self):
298
+ return {"config": asdict(self.config), "causal": self.causal,
299
+ "dim": self.dit.dim, "blocks": len(self.dit.blocks),
300
+ "vace_blocks": len(self.vace.vace_blocks),
301
+ "reference_policy": ("clean_separate_prefix" if self.causal
302
+ else "jointly_noised_full_context"),
303
+ "history_policy": ("clean_t0" if self.causal else "none"),
304
+ "audio_policy": "offline_whisper"}
viddub/causal/pretrain_fake.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pretrain and export one reusable fake-score model on frozen student rollouts."""
2
+ import argparse
3
+ import copy
4
+ import datetime
5
+ import json
6
+ import random
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import torch
11
+ from safetensors.torch import load_file
12
+
13
+ from .training.stages import FakeObjective
14
+ from .runtime import (atomic_json, autocast, checkpoint_paths, export_weights, fingerprint,
15
+ first_item, load_components, make_dataset, prepare_clip, read_config,
16
+ require_causal_metadata)
17
+ from .sampling import rollout, schedule
18
+ from .training.engine import check_loss, finite_update, lr_scheduler, optimizer
19
+
20
+
21
+ def main():
22
+ p=argparse.ArgumentParser(description=__doc__)
23
+ p.add_argument('--config',required=True)
24
+ p.add_argument('--student-dir',required=True);p.add_argument('--student-step',type=int,required=True)
25
+ p.add_argument('--teacher-dir',required=True);p.add_argument('--teacher-step',type=int,required=True)
26
+ p.add_argument('--output',required=True);p.add_argument('--resume')
27
+ p.add_argument('--stop-after-step',type=int)
28
+ a=p.parse_args();cfg=read_config(a.config)
29
+ run_until = cfg['max_steps'] if a.stop_after_step is None else a.stop_after_step
30
+ if not 0 < run_until <= cfg['max_steps']:
31
+ raise ValueError('stop-after-step must lie within the configured training budget')
32
+ if cfg['sampler'] != 'euler':
33
+ raise ValueError('Shared fake pretraining currently supports only the Euler sampler')
34
+ if cfg['stage']!='dmd':raise ValueError('Fake pretraining requires a DMD config')
35
+ student_paths=checkpoint_paths(a.student_dir,a.student_step)
36
+ require_causal_metadata(a.student_dir,a.student_step,cfg,('ode','dmd'))
37
+ teacher_paths=checkpoint_paths(a.teacher_dir,a.teacher_step)
38
+ if a.resume and Path(a.resume).resolve().parent != Path(a.output).resolve():
39
+ raise ValueError('Resume state must belong to this output directory')
40
+ if not a.resume and Path(a.output).exists() and any(Path(a.output).iterdir()):
41
+ raise ValueError('Output is not empty; choose a new output or explicitly --resume')
42
+ dataset,rows=make_dataset(cfg)
43
+ from .runtime import local_imports
44
+ local_imports()
45
+ import accelerate
46
+ from viddub.full_context.runtime import ZeroLatents
47
+ accel=accelerate.Accelerator(mixed_precision='no',gradient_accumulation_steps=1,
48
+ kwargs_handlers=[accelerate.DistributedDataParallelKwargs(find_unused_parameters=False,
49
+ gradient_as_bucket_view=True),accelerate.InitProcessGroupKwargs(timeout=datetime.timedelta(hours=2))])
50
+ pipe,whisper,student=load_components(student_paths,cfg,accel.device)
51
+ fake=copy.deepcopy(student)
52
+ for name,path in teacher_paths.items():
53
+ getattr(fake,name).load_state_dict(load_file(str(path)),strict=True)
54
+ student.requires_grad_(False).eval();fake.causal=False;fake.requires_grad_(True).train()
55
+ objective=FakeObjective(fake,mouth_weight=cfg['mouth_weight'],shift=cfg['shift'],
56
+ student_steps=cfg['student_steps'],use_cache=cfg['use_cache'],
57
+ gradient_checkpointing=cfg['gradient_checkpointing'],
58
+ matching_min=cfg['matching_min'], matching_max=cfg['matching_max'])
59
+ opt=optimizer(fake,dict(cfg,lr=cfg['fake_lr'],lr_base=cfg['fake_lr_base']))
60
+ sched=lr_scheduler(opt,cfg)
61
+ loader=torch.utils.data.DataLoader(dataset,batch_size=1,shuffle=True,collate_fn=first_item,
62
+ num_workers=cfg['workers'],generator=torch.Generator().manual_seed(cfg['seed']))
63
+ objective,opt,loader=accel.prepare(objective,opt,loader);accel.register_for_checkpointing(sched)
64
+ provenance={'student':fingerprint(student_paths),'teacher_init':fingerprint(teacher_paths)}
65
+ out=Path(a.output);out.mkdir(parents=True,exist_ok=True)
66
+ if accel.is_main_process:atomic_json(out/'run.json',{'config':cfg,'provenance':provenance,
67
+ 'world_size':accel.num_processes,'kind':'shared_fake_pretrain'})
68
+ step=epoch=consumed=0
69
+ random.seed(cfg['seed']+accel.process_index)
70
+ torch.manual_seed(cfg['seed']+accel.process_index)
71
+ if a.resume:
72
+ meta=json.loads((Path(a.resume)/'complete.json').read_text())
73
+ if meta['provenance']!=provenance or meta['config']!=cfg:raise ValueError('Resume mismatch')
74
+ if meta['world_size']!=accel.num_processes:raise ValueError('Resume requires same world size')
75
+ accel.load_state(a.resume)
76
+ step,epoch,consumed=(meta[k] for k in ('step','epoch','consumed_batches'))
77
+ zero=ZeroLatents(pipe);start=time.monotonic()
78
+ row_index={r['clip_id']:i for i,r in enumerate(rows)}
79
+ while step<run_until:
80
+ if hasattr(loader,'set_epoch'):loader.set_epoch(epoch)
81
+ if getattr(loader,'generator',None) is not None:
82
+ loader.generator.manual_seed(cfg['seed']+epoch)
83
+ active_loader=accel.skip_first_batches(loader,consumed) if consumed else loader
84
+ for item in active_loader:
85
+ consumed+=1
86
+ if random.random()<cfg['p_cross']:
87
+ other=dataset[random.randrange(len(dataset))]
88
+ if other['clip_id']==item['clip_id']:
89
+ other=dataset[(row_index[item['clip_id']]+1)%len(dataset)]
90
+ item=dict(item,audio=other['audio'],cross=True)
91
+ x0,c=prepare_clip(item,pipe,whisper,zero,cfg)
92
+ c['cross']=bool(item.get('cross',False))
93
+ with torch.no_grad(),autocast(accel.device,cfg['precision']):
94
+ generated=rollout(student,x0,c,schedule(cfg['student_steps'],cfg['shift'],x0.device),
95
+ use_cache=cfg['use_cache'])
96
+ with autocast(accel.device,cfg['precision']):loss,metrics=objective(x0,c,generated=generated)
97
+ check_loss(accel,loss);accel.backward(loss);norm=finite_update(accel,objective,opt,cfg);sched.step()
98
+ step+=1
99
+ if step%cfg['log_every']==0:accel.print(json.dumps({'step':step,'loss':float(loss.detach()),
100
+ 'grad_norm':norm,'elapsed_seconds':time.monotonic()-start,
101
+ **{k:float(v) for k,v in metrics.items()}}),flush=True)
102
+ if step%cfg['save_every']==0 or step==cfg['max_steps'] or step==run_until:
103
+ state=out/f'state_step{step:06d}'
104
+ exists=accel.reduce(torch.tensor(int(state.exists()),device=accel.device),'sum')
105
+ if int(exists):raise FileExistsError(state)
106
+ accel.save_state(str(state))
107
+ if accel.is_main_process:
108
+ export_weights(accel.unwrap_model(objective).model,out,step,cfg,provenance)
109
+ atomic_json(state/'complete.json',{'step':step,'epoch':epoch,
110
+ 'consumed_batches':consumed,'config':cfg,'provenance':provenance,
111
+ 'world_size':accel.num_processes})
112
+ accel.wait_for_everyone()
113
+ if step==run_until:break
114
+ epoch,consumed=epoch+1,0
115
+ accel.end_training()
116
+
117
+
118
+ if __name__=='__main__':main()
viddub/causal/quality.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Paired GT quality and audio supervision on a causal, trailing decoded window.
2
+
3
+ Frozen encoders are external to the trainable generator. Decode the complete
4
+ available temporal prefix, so an arbitrary latent slice is never treated as a
5
+ new VAE first frame. Spatial crop is shared by prediction and pixel GT.
6
+ """
7
+ import torch
8
+ import numpy as np
9
+ import os
10
+ from pathlib import Path
11
+ from torch import nn
12
+ from torch.nn import functional as F
13
+ from torch.utils.checkpoint import checkpoint
14
+
15
+
16
+ def sync_mel_window(mel, start_seconds):
17
+ from viddub.full_context.sync_loss import mel_window
18
+ if torch.is_tensor(mel):
19
+ mel = mel.detach().cpu().numpy()
20
+ if not isinstance(mel, np.ndarray):
21
+ raise TypeError(f'Expected mel tensor/array, got {type(mel).__name__}')
22
+ return mel_window(mel, start_seconds)
23
+
24
+
25
+ def trailing_window(latent_frames, size=16):
26
+ frames = 4 * (latent_frames - 1) + 1
27
+ return max(0, frames - size), frames
28
+
29
+
30
+ def roi_from_mask(mask, latent_height, latent_width, margin=2, max_h=36, max_w=48):
31
+ ys, xs = torch.where(mask)
32
+ if not len(ys):
33
+ raise ValueError('Empty generation mask')
34
+ t, b, l, r = int(ys.min()), int(ys.max()) + 1, int(xs.min()), int(xs.max()) + 1
35
+ lt, lb = max(0, t // 8 - margin), min(latent_height, (b + 7) // 8 + margin)
36
+ ll, lr = max(0, l // 8 - margin), min(latent_width, (r + 7) // 8 + margin)
37
+ if lb - lt > max_h:
38
+ lt = (lt + lb - max_h) // 2; lb = lt + max_h
39
+ if lr - ll > max_w:
40
+ ll = (ll + lr - max_w) // 2; lr = ll + max_w
41
+ return lt, lb, ll, lr
42
+
43
+
44
+ class GTQuality:
45
+ def __init__(self, pipe, cfg, device):
46
+ from .runtime import local_imports
47
+ local_imports()
48
+ self.pipe, self.cfg = pipe, cfg
49
+ self.sync = self.lpips = None
50
+ if cfg.get('sync_weight', 0) or cfg.get('art_weight', 0):
51
+ from viddub.full_context import sync_loss
52
+ from .runtime import PROJECT
53
+ sync_loss.LS_ROOT = Path(os.environ.get(
54
+ 'VIDDUB_LATENTSYNC_ROOT', PROJECT / 'weights' / 'LatentSync'))
55
+ self.sync = sync_loss.SyncLoss().to(device).eval().requires_grad_(False)
56
+ if cfg.get('lpips_weight', 0):
57
+ from viddub.full_context.runtime import PROJECT
58
+ import lpips
59
+ torch.hub.set_dir(str(Path(os.environ.get(
60
+ 'VIDDUB_TORCH_HUB', PROJECT / 'weights' / 'torch_hub'))))
61
+ self.lpips = lpips.LPIPS(net='vgg', verbose=False).to(device).eval().requires_grad_(False)
62
+
63
+ @torch.no_grad()
64
+ def art_target(self, conditions, end):
65
+ first,last=trailing_window(end-conditions['num_ref'])
66
+ if last-first!=16:return None
67
+ device=conditions['vace_context'].device
68
+ mask=conditions['pixel_mask'].to(device).bool()
69
+ ys,xs=torch.where(mask)
70
+ box=(int(ys.min()),int(ys.max())+1,int(xs.min()),int(xs.max())+1)
71
+ if conditions.get('cross',False):
72
+ mel=sync_mel_window(conditions['mel'],first/self.cfg['fps']).to(device)
73
+ return self.sync.net.audio_encoder(mel).flatten(1).float()
74
+ gt=conditions['gt_frames'][first:last].to(device).float().permute(3,0,1,2)/127.5-1
75
+ return self.sync.net.visual_encoder(self.sync.crop(gt,box)).flatten(1).float()
76
+
77
+ def decode(self, z):
78
+ vae = self.pipe.vae
79
+ # Checkpoint forward and recompute each own a fresh VAE feature cache.
80
+ vae.model.clear_cache()
81
+ out = vae.model.decode(z.to(self.pipe.torch_dtype), vae.scale)
82
+ vae.model.clear_cache()
83
+ return out
84
+
85
+ def decode_window(self, z, grad_begin, first, last):
86
+ """Exact prefix decode, but frozen GT-history latents require no graph.
87
+
88
+ The original conv2 has temporal kernel 1. Processing history under
89
+ no_grad is equivalent to decoding a detached history with frozen VAE
90
+ weights, and avoids retaining dozens of irrelevant frame graphs.
91
+ """
92
+ vae = self.pipe.vae
93
+ net = vae.model
94
+ if net.conv2.kernel_size[0] != 1:
95
+ raise ValueError('Window decode assumes temporal conv2 kernel 1')
96
+ net.clear_cache()
97
+ z = z.to(self.pipe.torch_dtype)
98
+ mean, inv_std = (s.to(z) for s in vae.scale)
99
+ values = []
100
+ grad_enabled = torch.is_grad_enabled()
101
+ for i in range(z.shape[2]):
102
+ with torch.set_grad_enabled(grad_enabled and i >= grad_begin):
103
+ latent = z[:, :, i:i+1] / inv_std.view(1,-1,1,1,1) + mean.view(1,-1,1,1,1)
104
+ x = net.conv2(latent)
105
+ decoded, net._feat_map, net._conv_idx = net.decoder(
106
+ x, feat_cache=net._feat_map, feat_idx=[0])
107
+ frame_start = 0 if i == 0 else 4*i-3
108
+ frame_end = frame_start + decoded.shape[2]
109
+ lo, hi = max(first, frame_start), min(last, frame_end)
110
+ if lo < hi:values.append(decoded[:, :, lo-frame_start:hi-frame_start])
111
+ net.clear_cache()
112
+ return torch.cat(values, 2)
113
+
114
+ def __call__(self, estimate_prefix, conditions, start, end, sigma):
115
+ zero = estimate_prefix.new_zeros((), dtype=torch.float32)
116
+ stats = {'pixel': zero, 'lpips': zero, 'sync': zero, 'keep': zero,
117
+ 'aux_active': zero, 'sync_active': zero}
118
+ q_active = float(sigma) <= self.cfg['aux_sigma_max']
119
+ sync_active = float(sigma) <= self.cfg['sync_sigma_max']
120
+ if not q_active and not sync_active:
121
+ return zero, stats
122
+ if conditions.get('gt_frames') is None:
123
+ raise ValueError('GT auxiliary loss requires original pixel frames')
124
+ n_ref = conditions['num_ref']
125
+ z = estimate_prefix[:, :, n_ref:end]
126
+ if z.shape[2] < 1:return zero, stats
127
+ mask = conditions['pixel_mask'].to(z.device).bool()
128
+ lt, lb, ll, lr = roi_from_mask(mask, z.shape[-2], z.shape[-1],
129
+ max_h=self.cfg['aux_max_latent_h'],
130
+ max_w=self.cfg['aux_max_latent_w'])
131
+ first, last = trailing_window(z.shape[2])
132
+ z_roi = z[:, :, :, lt:lb, ll:lr]
133
+ grad_begin = max(0, start - n_ref)
134
+ decoded = checkpoint(self.decode_window, z_roi, grad_begin, first, last,
135
+ use_reentrant=False)[0].float()
136
+ gt = conditions['gt_frames'][first:last, lt*8:lb*8, ll*8:lr*8].to(z.device).float()
137
+ gt = gt.permute(3, 0, 1, 2) / 127.5 - 1
138
+ if decoded.shape != gt.shape:
139
+ raise ValueError(f'Decoded/GT window mismatch {decoded.shape} != {gt.shape}')
140
+ local_mask = mask[lt*8:lb*8, ll*8:lr*8]
141
+ # Q applies only where paired GT exists; current first-wave AR is same.
142
+ if q_active and not conditions.get('cross', False):
143
+ if self.cfg.get('pixel_weight', 0):
144
+ stats['pixel'] = (decoded - gt).abs()[:, :, local_mask].mean()
145
+ if self.lpips is not None:
146
+ # Four evenly spaced frames keep memory bounded, including end.
147
+ ids = torch.linspace(0, last-first-1, min(4, last-first), device=z.device).round().long()
148
+ ys, xs = torch.where(local_mask)
149
+ box = (int(ys.min()), int(ys.max())+1, int(xs.min()), int(xs.max())+1)
150
+ t,b,l,r = box
151
+ pred = decoded[:, ids, t:b, l:r].transpose(0,1)
152
+ target = gt[:, ids, t:b, l:r].transpose(0,1)
153
+ with torch.autocast(z.device.type, enabled=False):
154
+ stats['lpips'] = self.lpips(pred.clamp(-1,1), target).mean()
155
+ if q_active and self.cfg.get('keep_weight', 0) and (~local_mask).any():
156
+ stats['keep'] = (decoded - gt).abs()[:, :, ~local_mask].mean()
157
+ dropped = conditions.get('drop_audio')
158
+ if (sync_active and self.sync is not None and last-first == 16 and
159
+ not (dropped is not None and bool(dropped.any()))):
160
+ ys, xs = torch.where(local_mask)
161
+ box = (int(ys.min()), int(ys.max())+1, int(xs.min()), int(xs.max())+1)
162
+ mel = sync_mel_window(conditions['mel'], first / self.cfg['fps']).to(z.device)
163
+ stats['sync'] = self.sync(decoded, box, mel)
164
+ stats['sync_active'] = zero + 1
165
+ stats['aux_active'] = zero + float(q_active)
166
+ loss = sum(self.cfg.get(key+'_weight', 0) * stats[key] for key in ['pixel','lpips','sync','keep'])
167
+ return loss, stats
viddub/causal/runtime.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local Wan-Dub data/checkpoint integration; imports heavy dependencies lazily."""
2
+ from contextlib import nullcontext
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import torch
9
+
10
+ from .model import CausalConfig, CausalWanDub
11
+
12
+ PROJECT = Path(__file__).resolve().parents[2]
13
+
14
+
15
+ def local_imports():
16
+ """Compatibility hook retained for callers; package imports need no path edits."""
17
+
18
+
19
+ def atomic_json(path, data):
20
+ path = Path(path)
21
+ temp = path.with_name(path.name + ".tmp")
22
+ temp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
23
+ temp.replace(path)
24
+
25
+
26
+ def read_config(path):
27
+ # Normalize stored configs as well, so adding DISABLED optional features
28
+ # does not prevent resuming an older, otherwise identical training state.
29
+ cfg = dict(path) if isinstance(path, dict) else json.loads(Path(path).read_text())
30
+ allowed = {"stage", "causal", "chunk_size", "num_ref", "fps", "num_frames", "max_steps", "warmup",
31
+ "lr", "lr_base", "weight_decay", "grad_accum", "grad_clip", "precision", "workers",
32
+ "seed", "max_train_clips", "latents_name", "save_every", "log_every", "mouth_weight",
33
+ "p_drop_audio", "p_cross", "shift", "student_steps", "teacher_cfg", "use_cache",
34
+ "gradient_checkpointing", "fake_updates", "matching_min", "matching_max",
35
+ "rows_file", "clips_root", "pixel_weight", "lpips_weight", "sync_weight",
36
+ "keep_weight", "aux_sigma_max", "sync_sigma_max", "aux_max_latent_h", "aux_max_latent_w",
37
+ "lr_schedule", "lr_min_ratio", "save_steps", "save_state_every", "ema_decay",
38
+ "dmd_rollout_mode", "art_weight", "art_layer", "art_warmup", "art_lr"}
39
+ allowed.update({"ode_gt_flow_weight", "rollout_quality_weight", "rollout_quality_cross",
40
+ "dmd_retention_weight"})
41
+ allowed.update({"fake_lr", "fake_lr_base"})
42
+ allowed.update({"dmd_score_offload", "dmd_split_quality_backward"})
43
+ allowed.update({'cd_grid_steps','cd_ema_decay','cd_student_cache','cd_frozen_cache',
44
+ 'cd_aux_scale','cd_all_chunks','beta1','beta2','sampler'})
45
+ allowed.update({"ar_history_mode", "ar_history_prob_start", "ar_history_prob_end",
46
+ "ar_history_ramp_steps", "ar_history_steps", "ar_history_sigma_max",
47
+ "ar_blocks_per_sample", "ar_history_mouth_drop_prob",
48
+ "ar_history_mouth_drop_cross_only"})
49
+ unknown = set(cfg) - allowed
50
+ if unknown:
51
+ raise ValueError(f"Unknown config fields: {sorted(unknown)}")
52
+ defaults = dict(stage="ar", causal=True, chunk_size=3, num_ref=1, fps=24, num_frames=81,
53
+ max_steps=2000, warmup=100, lr=1e-5, lr_base=2e-6, weight_decay=0.01,
54
+ grad_accum=1, grad_clip=1.0, precision="bf16", workers=2, seed=1234,
55
+ max_train_clips=None, latents_name="latents.pt", save_every=250, log_every=10,
56
+ mouth_weight=3, p_drop_audio=0.1, p_cross=0.5, shift=5, student_steps=4, teacher_cfg=1,
57
+ use_cache=True, gradient_checkpointing=True, fake_updates=5,
58
+ matching_min=0.02, matching_max=0.98, rows_file=None, clips_root=None,
59
+ pixel_weight=0.0, lpips_weight=0.0, sync_weight=0.0, keep_weight=0.0,
60
+ aux_sigma_max=0.5, sync_sigma_max=None,
61
+ aux_max_latent_h=36, aux_max_latent_w=48,
62
+ lr_schedule='constant', lr_min_ratio=0.1, save_steps=[], save_state_every=0,
63
+ ema_decay=0.0, dmd_rollout_mode='legacy_early_exit',
64
+ art_weight=0.0, art_layer=8, art_warmup=50, art_lr=1e-4,
65
+ ode_gt_flow_weight=0.0, rollout_quality_weight=1.0,
66
+ rollout_quality_cross=False, dmd_retention_weight=0.0,
67
+ fake_lr=None, fake_lr_base=None, dmd_score_offload=False,
68
+ dmd_split_quality_backward=False)
69
+ defaults.update(ar_history_mode='gt', ar_history_prob_start=0.0,
70
+ ar_history_prob_end=0.0, ar_history_ramp_steps=0,
71
+ ar_history_steps=4, ar_history_sigma_max=0.5,
72
+ ar_blocks_per_sample=1, ar_history_mouth_drop_prob=0.0,
73
+ ar_history_mouth_drop_cross_only=False)
74
+ defaults.update(cd_grid_steps=48, cd_ema_decay=.99, cd_student_cache=False,
75
+ cd_frozen_cache=False, cd_aux_scale=1., cd_all_chunks=True,
76
+ beta1=.9, beta2=.999, sampler='euler')
77
+ defaults.update(cfg)
78
+ cfg = defaults
79
+ if cfg['sync_sigma_max'] is None:
80
+ cfg['sync_sigma_max'] = cfg['aux_sigma_max']
81
+ CausalConfig(cfg["chunk_size"], cfg["num_ref"], cfg["fps"])
82
+ if cfg["stage"] not in ("ar", "ode", "dmd", "cd") or cfg["precision"] not in ("bf16", "fp32"):
83
+ raise ValueError("Unknown stage or precision")
84
+ if cfg["num_frames"] < 1 or (cfg["num_frames"] - 1) % 4:
85
+ raise ValueError("num_frames must be 4n+1")
86
+ if cfg["fps"] != 24:
87
+ raise ValueError("Existing cached training dataset is 24fps")
88
+ for name in ("max_steps", "lr", "lr_base", "grad_accum", "grad_clip", "save_every",
89
+ "log_every", "shift", "student_steps", "fake_updates"):
90
+ if cfg[name] <= 0:
91
+ raise ValueError(f"{name} must be positive")
92
+ if cfg["workers"] < 0 or cfg["warmup"] < 0 or not 0 <= cfg["p_drop_audio"] <= 1 or not 0 <= cfg["p_cross"] <= 1:
93
+ raise ValueError("Invalid workers/warmup/audio dropout")
94
+ if cfg["mouth_weight"] < 1 or cfg["teacher_cfg"] < 0:
95
+ raise ValueError("Invalid mouth weight or guidance")
96
+ if not 0 < cfg["matching_min"] < cfg["matching_max"] < 1:
97
+ raise ValueError("Invalid DMD matching range")
98
+ if cfg["stage"] == "dmd" and cfg["grad_accum"] != 1:
99
+ raise ValueError("DMD alternating optimizers currently require grad_accum=1")
100
+ if any(cfg[k] < 0 for k in ['pixel_weight','lpips_weight','sync_weight','keep_weight',
101
+ 'ode_gt_flow_weight','rollout_quality_weight','dmd_retention_weight']):
102
+ raise ValueError('Auxiliary weights must be nonnegative')
103
+ if (cfg['lr_schedule'] not in ('constant','cosine') or
104
+ not 0 < cfg['aux_sigma_max'] <= 1 or not 0 < cfg['sync_sigma_max'] <= 1):
105
+ raise ValueError('Invalid scheduler/aux sigma')
106
+ if not 0 <= cfg['ema_decay'] < 1:raise ValueError('Invalid EMA decay')
107
+ if cfg['dmd_rollout_mode'] not in ('legacy_early_exit','final_step'):
108
+ raise ValueError('Invalid DMD rollout mode')
109
+ if not isinstance(cfg['dmd_split_quality_backward'], bool):
110
+ raise ValueError('dmd_split_quality_backward must be boolean')
111
+ if cfg['dmd_split_quality_backward'] and cfg['stage'] != 'dmd':
112
+ raise ValueError('dmd_split_quality_backward is DMD-only')
113
+ if (cfg['stage'] == 'dmd' and any(cfg[k] for k in
114
+ ['pixel_weight','lpips_weight','sync_weight','keep_weight','dmd_retention_weight'])
115
+ and cfg['dmd_rollout_mode'] != 'final_step'):
116
+ raise ValueError('DMD quality/retention losses require final_step rollout')
117
+ if cfg['art_weight'] < 0 or cfg['art_layer'] < 0 or cfg['art_warmup'] < 0 or cfg['art_lr'] <= 0:
118
+ raise ValueError('Invalid Art configuration')
119
+ cfg['fake_lr'] = cfg['lr'] if cfg['fake_lr'] is None else cfg['fake_lr']
120
+ cfg['fake_lr_base'] = cfg['lr_base'] if cfg['fake_lr_base'] is None else cfg['fake_lr_base']
121
+ if cfg['fake_lr'] <= 0 or cfg['fake_lr_base'] <= 0:
122
+ raise ValueError('Fake-score learning rates must be positive')
123
+ if cfg['ar_history_mode'] not in ('gt','resample','rollout'):
124
+ raise ValueError('Invalid AR history mode')
125
+ if not 0 <= cfg['ar_history_prob_start'] <= 1 or not 0 <= cfg['ar_history_prob_end'] <= 1:
126
+ raise ValueError('AR history probabilities must lie in [0,1]')
127
+ if cfg['ar_history_ramp_steps'] < 0 or cfg['ar_history_steps'] < 1:
128
+ raise ValueError('Invalid AR history schedule')
129
+ if not 0 < cfg['ar_history_sigma_max'] <= 1:
130
+ raise ValueError('Invalid AR history sigma maximum')
131
+ if not isinstance(cfg['ar_blocks_per_sample'], int) or cfg['ar_blocks_per_sample'] < 0:
132
+ raise ValueError('ar_blocks_per_sample must be an integer >= 0 (0 means all blocks)')
133
+ if cfg['stage'] != 'ar' and cfg['ar_blocks_per_sample'] != 1:
134
+ raise ValueError('ar_blocks_per_sample is AR-only')
135
+ if not 0 <= cfg['ar_history_mouth_drop_prob'] <= 1:
136
+ raise ValueError('ar_history_mouth_drop_prob must lie in [0,1]')
137
+ if not isinstance(cfg['ar_history_mouth_drop_cross_only'], bool):
138
+ raise ValueError('ar_history_mouth_drop_cross_only must be boolean')
139
+ if cfg['stage'] != 'ar' and (cfg['ar_history_mouth_drop_prob'] or
140
+ cfg['ar_history_mouth_drop_cross_only']):
141
+ raise ValueError('AR history mouth dropout is AR-only')
142
+ if cfg['ema_decay'] != 0:
143
+ raise ValueError('EMA export is not implemented in this trainer; use ema_decay=0')
144
+ if cfg['cd_grid_steps'] < 2 or not 0 <= cfg['cd_ema_decay'] < 1 or cfg['cd_aux_scale'] < 0:
145
+ raise ValueError('Invalid consistency configuration')
146
+ if not 0 <= cfg['beta1'] < 1 or not 0 <= cfg['beta2'] < 1:
147
+ raise ValueError('Invalid optimizer betas')
148
+ if cfg['sampler'] not in ('euler','cf_renoise'):
149
+ raise ValueError('Invalid sampler')
150
+ return cfg
151
+
152
+
153
+ def checkpoint_paths(directory, step):
154
+ if not directory or step is None:
155
+ raise ValueError("Provide a checkpoint directory AND step")
156
+ paths = {name: Path(directory).resolve() / f"{name}_step{step:06d}.safetensors"
157
+ for name in ("dit", "vace", "adapter")}
158
+ for p in paths.values():
159
+ if not p.is_file():
160
+ raise FileNotFoundError(p)
161
+ return paths
162
+
163
+
164
+ def fingerprint(paths):
165
+ result = {}
166
+ for name, path in paths.items():
167
+ h = hashlib.sha256()
168
+ with path.open("rb") as f:
169
+ for chunk in iter(lambda: f.read(8 * 1024**2), b""):
170
+ h.update(chunk)
171
+ result[name] = {"path": str(path), "bytes": path.stat().st_size, "sha256": h.hexdigest()}
172
+ return result
173
+
174
+
175
+ def load_components(paths, cfg, device):
176
+ local_imports()
177
+ from safetensors.torch import load_file
178
+ from viddub.full_context import runtime as full_context_runtime
179
+ # Explicit experiment-local cache, never changes the old training module
180
+ # on disk. Other processes retain their existing paths.
181
+ if os.environ.get("VIDDUB_WEIGHT_ROOT"):
182
+ full_context_runtime.WEIGHTS = Path(os.environ["VIDDUB_WEIGHT_ROOT"])
183
+ full_context_runtime.VACE_DIR = full_context_runtime.WEIGHTS / "Wan2.1-VACE-1.3B"
184
+ from viddub.full_context.audio import DubAudioAdapter
185
+ # A skipped T5 is safe only with the exact precomputed empty-prompt tensor.
186
+ # If no cache was supplied, load/encode/unload T5 once instead of producing
187
+ # a tokenizer=None failure on the first minibatch.
188
+ empty_path = os.environ.get("VIDDUB_EMPTY_CONTEXT")
189
+ empty_context = None
190
+ if empty_path:
191
+ empty_context = torch.load(empty_path, map_location="cpu", weights_only=True)
192
+ if (not torch.is_tensor(empty_context) or empty_context.ndim != 2 or
193
+ empty_context.shape[1] != 4096 or not 1 <= empty_context.shape[0] <= 512 or
194
+ not torch.isfinite(empty_context).all()):
195
+ raise ValueError("VIDDUB_EMPTY_CONTEXT must contain finite [L,4096] empty T5 embeddings")
196
+ if os.environ.get("VIDDUB_SKIP_TEXT_ENCODER") == "1" and empty_context is not None:
197
+ # Cached-data experiments never invoke T5; avoid loading its 11 GB
198
+ # checkpoint for every short diagnostic/training run.
199
+ from viddub.foundation.pipelines.wan_video import WanVideoPipeline, ModelConfig
200
+ from viddub.full_context.audio import WhisperFeatures
201
+ pipe = WanVideoPipeline.from_pretrained(
202
+ torch_dtype=torch.bfloat16, device=device,
203
+ model_configs=[ModelConfig(path=str(full_context_runtime.VACE_DIR / "diffusion_pytorch_model.safetensors")),
204
+ ModelConfig(path=str(full_context_runtime.VACE_DIR / "Wan2.1_VAE.pth"))],
205
+ tokenizer_config=None)
206
+ for m in (pipe.dit, pipe.vace, pipe.vae):
207
+ m.requires_grad_(False).eval()
208
+ whisper = WhisperFeatures(str(full_context_runtime.WEIGHTS / "whisper-small")).to(device, torch.bfloat16)
209
+ else:
210
+ pipe, whisper = full_context_runtime.load_models(device)
211
+ # Evaluation needs only the empty prompt. Encode it once while T5 is
212
+ # resident on the correct device, then keep the frozen encoder off GPU.
213
+ pipe._wan_dub_empty_context = full_context_runtime.encode_text(pipe, "").detach().cpu()
214
+ if empty_context is not None:
215
+ pipe._wan_dub_empty_context = empty_context
216
+ adapter = DubAudioAdapter().to(device)
217
+ for name, module in (("dit", pipe.dit), ("vace", pipe.vace), ("adapter", adapter)):
218
+ # Preserve FP32 fine-tuning updates when loading a causal checkpoint;
219
+ # loading into a BF16 base module first silently rounds those updates.
220
+ module.float()
221
+ module.load_state_dict(load_file(str(paths[name])), strict=True)
222
+ model = CausalWanDub(
223
+ pipe.dit,
224
+ pipe.vace,
225
+ adapter,
226
+ CausalConfig(cfg["chunk_size"], cfg["num_ref"], cfg["fps"]),
227
+ causal=cfg.get("causal", True),
228
+ )
229
+ # FP32 master weights in both modes; BF16 only applies to compute.
230
+ model.float()
231
+ # Training/inference here consumes cached text. Keep T5 off the GPU unless
232
+ # an explicit encoding operation calls the pipeline's model manager.
233
+ if pipe.text_encoder is not None:
234
+ pipe.text_encoder.cpu()
235
+ if device.type == "cuda":
236
+ torch.cuda.empty_cache()
237
+ return pipe, whisper, model
238
+
239
+
240
+ def autocast(device, precision):
241
+ if precision == "bf16":
242
+ # Rollout switches no_grad/grad within one autocast scope. Reusing a
243
+ # weight cast cached during no_grad can silently drop weight gradients
244
+ # and changes checkpoint-recomputation saved tensor metadata.
245
+ return torch.autocast(device.type, dtype=torch.bfloat16, cache_enabled=False)
246
+ return nullcontext()
247
+
248
+
249
+ def export_weights(model, directory, step, cfg, provenance):
250
+ from safetensors.torch import save_file
251
+ directory = Path(directory)
252
+ directory.mkdir(parents=True, exist_ok=True)
253
+ for name in ("dit", "vace", "adapter"):
254
+ dst = directory / f"{name}_step{step:06d}.safetensors"
255
+ temp = dst.with_suffix(".safetensors.tmp")
256
+ # Keep fp32 masters; existing inference loaders may cast to bf16.
257
+ save_file({k: v.detach().float().cpu().contiguous()
258
+ for k, v in getattr(model, name).state_dict().items()}, str(temp))
259
+ temp.replace(dst)
260
+ atomic_json(directory / f"causal_step{step:06d}.json",
261
+ {"schema": 1, "step": step, "stage": cfg["stage"], "config": cfg,
262
+ "architecture": model.architecture(), "provenance": provenance,
263
+ "quality_validated": False})
264
+
265
+
266
+ def require_causal_metadata(directory, step, cfg, stages=None):
267
+ path = Path(directory) / f"causal_step{step:06d}.json"
268
+ data = json.loads(path.read_text())
269
+ if data.get("architecture", {}).get(
270
+ "causal", data["config"].get("causal", True)
271
+ ) != cfg["causal"]:
272
+ raise ValueError("Checkpoint/config causal architecture mismatch")
273
+ for key in ("chunk_size", "num_ref", "fps", "num_frames", "shift"):
274
+ if data["config"][key] != cfg[key]:
275
+ raise ValueError(f"Checkpoint/config mismatch for {key}")
276
+ if stages and data["stage"] not in stages:
277
+ raise ValueError(f"Expected stages {stages}, got {data['stage']}")
278
+ if data["stage"] in ("ode", "dmd", "cd") and data["config"]["student_steps"] != cfg["student_steps"]:
279
+ raise ValueError("Checkpoint/config mismatch for student_steps")
280
+ if data['stage'] in ('ode','dmd','cd') and data['config'].get('sampler','euler') != cfg.get('sampler','euler'):
281
+ raise ValueError('Checkpoint/config sampler mismatch')
282
+ return data
283
+
284
+
285
+ def make_dataset(cfg):
286
+ local_imports()
287
+ from viddub.full_context import runtime as full_context_runtime
288
+ from viddub.full_context.runtime import CachedClipDataset, load_rows, cached_clip_ids
289
+ if cfg.get("clips_root"):
290
+ full_context_runtime.CLIPS = Path(cfg["clips_root"])
291
+ rows = json.loads(Path(cfg["rows_file"]).read_text()) if cfg.get("rows_file") else load_rows("train")
292
+ sources = sorted({r.get('source', 'h3') for r in rows})
293
+ ids = cached_clip_ids(sources)
294
+ if cfg.get('rows_file'):
295
+ missing = [r['clip_id'] for r in rows if r['clip_id'] not in ids]
296
+ if missing:
297
+ raise ValueError(f'Explicit manifest contains {len(missing)} uncached clips: {missing[:5]}')
298
+ rows = [r for r in rows if r["clip_id"] in ids]
299
+ if cfg["max_train_clips"] is not None:
300
+ rows = rows[:cfg["max_train_clips"]]
301
+ if not rows:
302
+ raise ValueError("No cached training clips")
303
+ dataset = CachedClipDataset(rows, cfg["num_frames"], cfg["latents_name"])
304
+ if any(cfg.get(k, 0) for k in ['pixel_weight','lpips_weight','sync_weight','keep_weight',
305
+ 'art_weight']):
306
+ dataset = PixelClipDataset(dataset, full_context_runtime.CLIPS)
307
+ return dataset, rows
308
+
309
+
310
+ class PixelClipDataset(torch.utils.data.Dataset):
311
+ def __init__(self, dataset, root):
312
+ self.dataset, self.root = dataset, Path(root)
313
+ def __len__(self):return len(self.dataset)
314
+ def __getitem__(self, index):
315
+ import decord
316
+ value = self.dataset[index]
317
+ path = self.root / self.dataset.rows[index]['dir'] / 'video.mp4'
318
+ reader = decord.VideoReader(str(path), num_threads=1)
319
+ value['gt_frames'] = torch.from_numpy(reader.get_batch(list(range(self.dataset.num_frames))).asnumpy())
320
+ return value
321
+
322
+
323
+ def first_item(batch):
324
+ return batch[0]
325
+
326
+
327
+ def prepare_clip(clip, pipe, whisper, zero_latents, cfg):
328
+ local_imports()
329
+ from viddub.full_context.runtime import build_inputs
330
+ x0, vc, ctx = build_inputs(clip["latents"], clip["mask"], cfg["num_frames"], zero_latents,
331
+ drop_ref=cfg["num_ref"] == 0)
332
+ if x0.shape[2] != (cfg["num_frames"] - 1) // 4 + 1 + cfg["num_ref"]:
333
+ raise ValueError("Cached clip is shorter than configured training length")
334
+ hs = whisper([clip["audio"]], pipe.device, torch.bfloat16)
335
+ # Whisper remains frozen BF16; transformer input follows compute precision.
336
+ dtype = torch.bfloat16 if cfg["precision"] == "bf16" else torch.float32
337
+ conditions = {"context": ctx.to(dtype), "vace_context": vc.to(dtype),
338
+ "whisper_hs": hs.to(dtype), "num_frames": cfg["num_frames"], 'num_ref':cfg['num_ref']}
339
+ if 'gt_frames' in clip:
340
+ from viddub.full_context.sync_loss import melspectrogram
341
+ conditions.update(gt_frames=clip['gt_frames'], pixel_mask=torch.from_numpy(clip['mask']),
342
+ mel=melspectrogram(clip['audio']))
343
+ return x0.to(dtype), conditions
viddub/causal/sampling.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flow schedules and clean-history autoregressive rollout.
2
+
3
+ This is a correctness reference: no cache eviction and no streaming VAE decoder.
4
+ All denoising passes READ history. One extra t=0 pass commits each final chunk.
5
+ """
6
+ from contextlib import contextmanager
7
+
8
+ import torch
9
+
10
+ from .model import HistoryCache
11
+
12
+
13
+ def schedule(steps, shift=5.0, device="cpu"):
14
+ if steps < 1 or shift <= 0:
15
+ raise ValueError("steps and shift must be positive")
16
+ s = torch.linspace(1, 0, steps + 1, device=device, dtype=torch.float32)
17
+ return shift * s / (1 + (shift - 1) * s)
18
+
19
+
20
+ def validate_schedule(sigmas):
21
+ if sigmas.ndim != 1 or len(sigmas) < 2 or not torch.isfinite(sigmas).all():
22
+ raise ValueError("Expected finite 1D schedule")
23
+ if abs(float(sigmas[0]) - 1) > 1e-6 or float(sigmas[-1]) != 0:
24
+ raise ValueError("Schedule must go from pure noise (1) to clean (0)")
25
+ if not torch.all(sigmas[:-1] > sigmas[1:]):
26
+ raise ValueError("Schedule must be strictly decreasing")
27
+
28
+
29
+ @contextmanager
30
+ def evaluating(model):
31
+ old = model.training
32
+ model.eval()
33
+ try:
34
+ yield
35
+ finally:
36
+ model.train(old)
37
+
38
+
39
+ def predict_chunk(model, chunk, sigma, prefix, conditions, start, cache=None,
40
+ drop_audio=None, gradient_checkpointing=False, detach_history=False,
41
+ return_hidden_layer=None):
42
+ """Cache-free and cached implementations have exactly the same semantics."""
43
+ if cache is not None:
44
+ vc_start = int(conditions.get("vace_start", 0))
45
+ local_start = start-vc_start
46
+ if local_start < 0 or local_start+chunk.shape[2] > conditions["vace_context"].shape[2]:
47
+ raise ValueError("Current VACE condition does not cover the requested absolute block")
48
+ x = chunk
49
+ vc = conditions["vace_context"][:, :, local_start:local_start + chunk.shape[2]]
50
+ offset = start
51
+ ts = chunk.new_full((chunk.shape[0], chunk.shape[2]), float(sigma) * 1000)
52
+ else:
53
+ x = torch.cat([prefix, chunk], 2)
54
+ vc, offset = conditions["vace_context"][:, :, :x.shape[2]], 0
55
+ ts = x.new_zeros((x.shape[0], x.shape[2]))
56
+ ts[:, start:] = float(sigma) * 1000
57
+ out = model(x, ts, conditions["context"], vc, conditions["whisper_hs"],
58
+ conditions["num_frames"], drop_audio, start_frame=offset, cache=cache,
59
+ gradient_checkpointing=gradient_checkpointing,
60
+ detach_history_before=start if detach_history and cache is None else 0,
61
+ return_hidden_layer=return_hidden_layer,
62
+ audio_tokens=conditions.get("audio_tokens"))
63
+ if return_hidden_layer is not None:
64
+ prediction, hidden = out
65
+ return (prediction if cache is not None else prediction[:, :, start:]), hidden
66
+ return out if cache is not None else out[:, :, start:]
67
+
68
+
69
+ def commit(model, chunk, conditions, start, cache, drop_audio=None):
70
+ if cache is None:
71
+ return
72
+ vc_start = int(conditions.get("vace_start", 0))
73
+ local_start = start-vc_start
74
+ if local_start < 0 or local_start+chunk.shape[2] > conditions["vace_context"].shape[2]:
75
+ raise ValueError("Current VACE condition does not cover the committed absolute block")
76
+ with torch.no_grad():
77
+ model(chunk.detach(), 0.0, conditions["context"],
78
+ conditions["vace_context"][:, :, local_start:local_start + chunk.shape[2]],
79
+ conditions["whisper_hs"], conditions["num_frames"], drop_audio,
80
+ start_frame=start, cache=cache, commit_cache=True,
81
+ audio_tokens=conditions.get("audio_tokens"))
82
+
83
+
84
+ def history_chunk(chunk, conditions, start, mode):
85
+ """Inference-only intervention on what is committed as clean visual history.
86
+
87
+ The returned tensor affects future chunks only; the visible output for the
88
+ current chunk remains unchanged. VACE channels 0:C contain the inactive
89
+ source latent and channels 32: encode the editable-region mask.
90
+ """
91
+ if mode == "full":
92
+ return chunk.detach()
93
+ vc_start = int(conditions.get("vace_start", 0))
94
+ local_start = start-vc_start
95
+ if local_start < 0 or local_start+chunk.shape[2] > conditions["vace_context"].shape[2]:
96
+ raise ValueError("Current VACE condition does not cover the history block")
97
+ inactive = conditions["vace_context"][:, :chunk.shape[1], local_start:local_start + chunk.shape[2]].to(chunk)
98
+ if mode == "inactive":
99
+ return inactive.detach()
100
+ if mode == "mouth_suppressed":
101
+ mask = conditions["vace_context"][:, 32:, local_start:local_start + chunk.shape[2]].amax(
102
+ 1, keepdim=True).float().clamp(0, 1).to(chunk)
103
+ return (chunk.detach() * (1 - mask) + inactive * mask).detach()
104
+ raise ValueError(f"Unknown history mode: {mode}")
105
+
106
+
107
+ def rollout(model, shape_reference, conditions, sigmas, *, noise=None,
108
+ use_cache=True, audio_cfg=1.0, grad_chunk=None, grad_step=None,
109
+ gradient_checkpointing=False, trajectory=False, history_mode="full",
110
+ on_chunk=None, max_video_history_frames=None):
111
+ """Generate all video chunks, retaining gradients for at most one chunk/step.
112
+
113
+ Normal inference: grad_chunk=None. DMD: randomly choose a video chunk and
114
+ an exit step; use its x0 estimate and detach every other rollout operation.
115
+ Later chunks condition on that estimate, but gradients do not pass through
116
+ cache/history. This deliberately implements truncated rollout gradients.
117
+ shape_reference video values are NEVER read; only the clean reference prefix
118
+ (if configured) is consumed. noise has the full [B,C,F,H,W] shape.
119
+ """
120
+ validate_schedule(sigmas)
121
+ if audio_cfg < 0:
122
+ raise ValueError("audio_cfg must be nonnegative")
123
+ if not model.causal:
124
+ if use_cache:
125
+ raise ValueError(
126
+ "Bidirectional full-context rollout cannot use a history cache")
127
+ if on_chunk is not None or max_video_history_frames is not None:
128
+ raise ValueError(
129
+ "Streaming callbacks/history bounds are causal-only")
130
+ noise = torch.randn_like(shape_reference) if noise is None else noise
131
+ if noise.shape != shape_reference.shape:
132
+ raise ValueError("noise shape mismatch")
133
+ ranges = model.config.video_blocks(shape_reference.shape[2])
134
+ if grad_chunk is not None and not 0 <= grad_chunk < len(ranges):
135
+ raise ValueError("grad_chunk is outside video chunks")
136
+ if grad_step is not None and not 0 <= grad_step < len(sigmas) - 1:
137
+ raise ValueError("grad_step outside schedule")
138
+ null = torch.ones(
139
+ shape_reference.shape[0],
140
+ device=shape_reference.device,
141
+ dtype=torch.bool,
142
+ )
143
+ current = noise
144
+ states = [current.detach().cpu()] if trajectory else None
145
+ stop = grad_step if grad_step is not None else len(sigmas) - 2
146
+ with evaluating(model):
147
+ for step in range(stop + 1):
148
+ keep_grad = grad_chunk is not None and step == stop
149
+ with torch.set_grad_enabled(keep_grad):
150
+ ts = current.new_full(
151
+ (current.shape[0], current.shape[2]),
152
+ float(sigmas[step]) * 1000,
153
+ )
154
+ pred = model(
155
+ current,
156
+ ts,
157
+ conditions["context"],
158
+ conditions["vace_context"][:, :, :current.shape[2]],
159
+ conditions["whisper_hs"],
160
+ conditions["num_frames"],
161
+ gradient_checkpointing=gradient_checkpointing,
162
+ audio_tokens=conditions.get("audio_tokens"),
163
+ )
164
+ if audio_cfg != 1:
165
+ pred_null = model(
166
+ current,
167
+ ts,
168
+ conditions["context"],
169
+ conditions["vace_context"][:, :, :current.shape[2]],
170
+ conditions["whisper_hs"],
171
+ conditions["num_frames"],
172
+ null,
173
+ gradient_checkpointing=gradient_checkpointing,
174
+ audio_tokens=conditions.get("audio_tokens"),
175
+ )
176
+ pred = pred_null + audio_cfg * (pred - pred_null)
177
+ if keep_grad and grad_step is not None:
178
+ current = current.float() - sigmas[step] * pred.float()
179
+ else:
180
+ current = (
181
+ current.float()
182
+ + (sigmas[step + 1] - sigmas[step]) * pred.float()
183
+ )
184
+ current = current.to(shape_reference.dtype)
185
+ if trajectory:
186
+ states.append(current.detach().cpu())
187
+ if trajectory:
188
+ record = {
189
+ "start": 0,
190
+ "end": shape_reference.shape[2],
191
+ "states": torch.stack(states),
192
+ }
193
+ return current, [record]
194
+ return current
195
+ if history_mode not in ("full", "mouth_suppressed", "inactive"):
196
+ raise ValueError("Unsupported history mode")
197
+ n_ref = model.config.num_ref
198
+ ranges = model.config.video_blocks(shape_reference.shape[2])
199
+ if grad_chunk is not None and not 0 <= grad_chunk < len(ranges):
200
+ raise ValueError("grad_chunk is outside video chunks")
201
+ if grad_step is not None and not 0 <= grad_step < len(sigmas) - 1:
202
+ raise ValueError("grad_step outside schedule")
203
+ noise = torch.randn_like(shape_reference) if noise is None else noise
204
+ if noise.shape != shape_reference.shape:
205
+ raise ValueError("noise shape mismatch")
206
+ cache = HistoryCache(max_video_history_frames=max_video_history_frames) if use_cache else None
207
+ null_cache = HistoryCache(max_video_history_frames=max_video_history_frames) if use_cache and audio_cfg != 1 else None
208
+ null = torch.ones(shape_reference.shape[0], device=shape_reference.device, dtype=torch.bool)
209
+ prefix = shape_reference[:, :, :n_ref].detach()
210
+ chunks, records = [prefix], []
211
+ with evaluating(model):
212
+ if n_ref:
213
+ commit(model, prefix, conditions, 0, cache)
214
+ if null_cache is not None:
215
+ commit(model, prefix, conditions, 0, null_cache, null)
216
+ for index, (start, end) in enumerate(ranges):
217
+ current = noise[:, :, start:end]
218
+ states = [current.detach().cpu()] if trajectory else None
219
+ stop = grad_step if index == grad_chunk and grad_step is not None else len(sigmas) - 2
220
+ for step in range(stop + 1):
221
+ keep_grad = index == grad_chunk and step == stop
222
+ with torch.set_grad_enabled(keep_grad):
223
+ pred = predict_chunk(model, current, sigmas[step], prefix.detach(), conditions,
224
+ start, cache, gradient_checkpointing=gradient_checkpointing,
225
+ detach_history=True)
226
+ if audio_cfg != 1:
227
+ pred_null = predict_chunk(model, current, sigmas[step], prefix.detach(), conditions,
228
+ start, null_cache, null,
229
+ gradient_checkpointing=gradient_checkpointing,
230
+ detach_history=True)
231
+ pred = pred_null + audio_cfg * (pred - pred_null)
232
+ if keep_grad and grad_step is not None:
233
+ current = current.float() - sigmas[step] * pred.float()
234
+ else:
235
+ current = current.float() + (sigmas[step + 1] - sigmas[step]) * pred.float()
236
+ current = current.to(shape_reference.dtype)
237
+ if trajectory:
238
+ states.append(current.detach().cpu())
239
+ chunks.append(current)
240
+ if trajectory:
241
+ records.append({"start": start, "end": end, "states": torch.stack(states)})
242
+ committed = history_chunk(current, conditions, start, history_mode)
243
+ commit(model, committed, conditions, start, cache)
244
+ if null_cache is not None:
245
+ commit(model, committed, conditions, start, null_cache, null)
246
+ prefix = torch.cat([prefix.detach(), committed], 2)
247
+ if on_chunk is not None:
248
+ on_chunk(index=index, start=start, end=end, chunk=current.detach())
249
+ out = torch.cat(chunks, 2)
250
+ return (out, records) if trajectory else out
251
+
252
+
253
+ def streaming_rollout_blocks(model, reference, reference_vace, block_inputs,
254
+ conditions, sigmas, *, max_video_history_frames,
255
+ history_mode="full"):
256
+ """Yield causal blocks without materializing full-length video/VACE tensors.
257
+
258
+ ``block_inputs`` is an iterable of dictionaries containing absolute ``start``,
259
+ initial ``noise`` [B,C,F,H,W], and local ``vace_context`` for exactly that
260
+ block. ``conditions`` contains global text/audio tensors, ``num_frames`` and
261
+ optional precomputed ``audio_tokens``. The reference and every generated block
262
+ are committed once to one bounded clean-history cache.
263
+ """
264
+ validate_schedule(sigmas)
265
+ if max_video_history_frames is None:
266
+ raise ValueError("Streaming rollout requires an explicit history bound")
267
+ if reference.shape[2] != model.config.num_ref:
268
+ raise ValueError("Reference latent count differs from model configuration")
269
+ if reference_vace.shape[2] != reference.shape[2]:
270
+ raise ValueError("Reference VACE shape mismatch")
271
+ cache = HistoryCache(max_video_history_frames=max_video_history_frames)
272
+ base = dict(conditions)
273
+ with evaluating(model):
274
+ if model.config.num_ref:
275
+ ref_conditions = dict(base, vace_context=reference_vace, vace_start=0)
276
+ commit(model, reference, ref_conditions, 0, cache)
277
+ expected = model.config.video_blocks(
278
+ (base["num_frames"]-1)//4+1+model.config.num_ref)
279
+ for index, item in enumerate(block_inputs):
280
+ if index >= len(expected):
281
+ raise ValueError("Too many streaming blocks")
282
+ start, end = expected[index]
283
+ noise = item["noise"]
284
+ if item.get("start", start) != start or noise.shape[2] != end-start:
285
+ raise ValueError("Streaming block does not match the absolute schedule")
286
+ local = dict(base, vace_context=item["vace_context"], vace_start=start)
287
+ current = noise
288
+ for step in range(len(sigmas)-1):
289
+ pred = predict_chunk(model, current, sigmas[step], reference, local,
290
+ start, cache, detach_history=True)
291
+ current = (current.float()+(sigmas[step+1]-sigmas[step])*pred.float()).to(noise.dtype)
292
+ committed = history_chunk(current, local, start, history_mode)
293
+ commit(model, committed, local, start, cache)
294
+ yield {"index": index, "start": start, "end": end,
295
+ "chunk": current, "cache_frames": cache.frames,
296
+ "cache_retained_frames": cache.retained_frames}
297
+ if len(expected) and cache.frames != expected[-1][1]:
298
+ raise ValueError("Streaming input ended before the configured video length")
viddub/causal/train.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Backward-compatible generic causal training entry point."""
2
+ from .training.engine import main
3
+
4
+
5
+ if __name__ == "__main__":
6
+ main()
viddub/causal/train_ar.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Train the autoregressive causal VidDub parent."""
2
+ from .training.engine import main
3
+
4
+
5
+ if __name__ == "__main__":
6
+ main("ar")
viddub/causal/train_dmd.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Refine a few-step VidDub student with distribution matching."""
2
+ from .training.engine import main
3
+
4
+
5
+ if __name__ == "__main__":
6
+ main("dmd")
viddub/causal/train_ode.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Distill a few-step ODE VidDub student from frozen trajectories."""
2
+ from .training.engine import main
3
+
4
+
5
+ if __name__ == "__main__":
6
+ main("ode")
viddub/causal/training/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Shared causal training implementation."""
2
+
3
+ from .stages import ARObjective, DMDObjective, FakeObjective, ODEObjective
viddub/causal/training/ar.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Autoregressive causal flow-matching objective."""
2
+ import torch
3
+
4
+ from ..sampling import evaluating, predict_chunk, schedule
5
+ from .common import ObjectiveBase, flow_noise, spatial_weight, weighted_mse
6
+
7
+
8
+ class ARObjective(ObjectiveBase):
9
+ def __init__(self, model, *, art=None, art_weight=0, art_layer=8,
10
+ art_warmup=50, ar_history_mode="gt",
11
+ ar_history_prob_start=0, ar_history_prob_end=0,
12
+ ar_history_ramp_steps=0, ar_history_steps=4,
13
+ ar_history_sigma_max=.5, ar_history_mouth_drop_prob=0,
14
+ ar_history_mouth_drop_cross_only=False, **kwargs):
15
+ super().__init__(model, **kwargs)
16
+ self.art, self.art_weight = art, art_weight
17
+ self.art_layer, self.art_warmup = art_layer, art_warmup
18
+ self.ar_history_mode = ar_history_mode
19
+ self.ar_history_prob_start = ar_history_prob_start
20
+ self.ar_history_prob_end = ar_history_prob_end
21
+ self.ar_history_ramp_steps = ar_history_ramp_steps
22
+ self.ar_history_steps = ar_history_steps
23
+ self.ar_history_sigma_max = ar_history_sigma_max
24
+ self.ar_history_mouth_drop_prob = ar_history_mouth_drop_prob
25
+ self.ar_history_mouth_drop_cross_only = ar_history_mouth_drop_cross_only
26
+
27
+ def _history_probability(self, step):
28
+ if self.ar_history_ramp_steps <= 0:
29
+ return self.ar_history_prob_end
30
+ alpha = min(1., max(0., float(step) / self.ar_history_ramp_steps))
31
+ return self.ar_history_prob_start + alpha * (
32
+ self.ar_history_prob_end - self.ar_history_prob_start)
33
+
34
+ @torch.no_grad()
35
+ def _generated_history(self, x0, conditions, end, generator):
36
+ n_ref = self.model.config.num_ref
37
+ prefix = x0[:, :, :n_ref].detach()
38
+ if end <= n_ref:
39
+ return prefix
40
+ with evaluating(self.model):
41
+ for start, stop in self.model.config.video_blocks(end):
42
+ if self.ar_history_mode == "resample":
43
+ u_max = self.ar_history_sigma_max / (
44
+ self.shift - self.ar_history_sigma_max * (self.shift - 1))
45
+ u = torch.rand((), device=x0.device, generator=generator) * u_max
46
+ sigma = self.shift * u / (1 + (self.shift - 1) * u)
47
+ clean = x0[:, :, start:stop]
48
+ noise = torch.randn(clean.shape, device=clean.device,
49
+ dtype=clean.dtype, generator=generator)
50
+ noisy = flow_noise(clean, sigma, noise)
51
+ pred = predict_chunk(
52
+ self.model, noisy, sigma, prefix, conditions, start,
53
+ drop_audio=conditions.get("drop_audio"))
54
+ chunk = (noisy.float() - float(sigma) * pred.float()).to(x0.dtype)
55
+ elif self.ar_history_mode == "rollout":
56
+ current = torch.randn(
57
+ x0[:, :, start:stop].shape, device=x0.device,
58
+ dtype=x0.dtype, generator=generator)
59
+ sigmas = schedule(self.ar_history_steps, self.shift, x0.device)
60
+ for index in range(len(sigmas) - 1):
61
+ pred = predict_chunk(
62
+ self.model, current, sigmas[index], prefix,
63
+ conditions, start, drop_audio=conditions.get("drop_audio"))
64
+ current = (current.float() + (sigmas[index + 1] - sigmas[index])
65
+ * pred.float()).to(x0.dtype)
66
+ chunk = current
67
+ else:
68
+ raise ValueError(self.ar_history_mode)
69
+ prefix = torch.cat([prefix, chunk.detach()], 2)
70
+ return prefix
71
+
72
+ def forward(self, x0, conditions, *, chunk_index=None, sigma=None,
73
+ noise=None, **_):
74
+ ranges = self.model.config.video_blocks(x0.shape[2])
75
+ index = int(torch.randint(len(ranges), ())) if chunk_index is None else chunk_index
76
+ start, stop = ranges[index]
77
+ u = torch.rand((), device=x0.device).clamp(1e-4, 1 - 1e-4)
78
+ sigma = self.shift * u / (1 + (self.shift - 1) * u) if sigma is None else sigma
79
+ clean = x0[:, :, start:stop]
80
+ noise = torch.randn_like(clean) if noise is None else noise
81
+ noisy = flow_noise(clean, sigma, noise)
82
+ prefix = x0[:, :, :start].detach()
83
+
84
+ history_mouth_dropped = False
85
+ eligible = (start > self.model.config.num_ref and
86
+ (not self.ar_history_mouth_drop_cross_only or conditions.get("cross", False)))
87
+ if self.ar_history_mouth_drop_prob and eligible:
88
+ generator = torch.Generator(device=x0.device).manual_seed(
89
+ int(conditions.get("history_seed", 0)) + index * 1009 + 17)
90
+ history_mouth_dropped = bool(
91
+ torch.rand((), device=x0.device, generator=generator)
92
+ < self.ar_history_mouth_drop_prob)
93
+ if history_mouth_dropped:
94
+ mask = conditions["vace_context"][:, 32:, :start].amax(
95
+ 1, keepdim=True).float().clamp(0, 1)
96
+ mask[:, :, :self.model.config.num_ref] = 0
97
+ inactive = conditions["vace_context"][:, :x0.shape[1], :start].to(prefix)
98
+ prefix = prefix * (1 - mask) + inactive * mask
99
+
100
+ history_used = False
101
+ if self.ar_history_mode != "gt" and start > self.model.config.num_ref:
102
+ generator = torch.Generator(device=x0.device).manual_seed(
103
+ int(conditions.get("history_seed", 0)))
104
+ probability = self._history_probability(conditions.get("train_step", 0))
105
+ history_used = bool(torch.rand((), device=x0.device, generator=generator) < probability)
106
+ if history_used:
107
+ prefix = self._generated_history(x0, conditions, start, generator)
108
+
109
+ result = predict_chunk(
110
+ self.model, noisy, sigma, prefix, conditions, start,
111
+ drop_audio=conditions.get("drop_audio"),
112
+ gradient_checkpointing=self.gradient_checkpointing,
113
+ return_hidden_layer=self.art_layer if self.art is not None else None)
114
+ pred, hidden = result if self.art is not None else (result, None)
115
+ current_mask = conditions["vace_context"][:, 32:, start:stop].amax(
116
+ 1, keepdim=True).float().clamp(0, 1)
117
+ weight = ((1 - current_mask) if conditions.get("cross", False)
118
+ else spatial_weight(conditions["vace_context"][:, :, start:stop],
119
+ self.mouth_weight))
120
+ loss = weighted_mse(pred, noise - clean, weight)
121
+ metrics = {
122
+ "fm": loss.detach(), "chunk": float(index), "sigma": float(sigma),
123
+ "history_eligible": float(start > self.model.config.num_ref),
124
+ "history_generated": float(history_used),
125
+ "history_probability": float(self._history_probability(
126
+ conditions.get("train_step", 0))),
127
+ "cross": float(conditions.get("cross", False)),
128
+ "history_mouth_dropped": float(history_mouth_dropped),
129
+ }
130
+ if self.quality[0] is not None:
131
+ estimate = noisy.float() - float(sigma) * pred.float()
132
+ auxiliary, stats = self.quality[0](
133
+ torch.cat([prefix, estimate], 2), conditions, start, stop, sigma)
134
+ loss = loss + auxiliary
135
+ metrics.update({key: value.detach() for key, value in stats.items()})
136
+ metrics["aux_loss"] = auxiliary.detach()
137
+ if self.art is not None:
138
+ target = self.quality[0].art_target(conditions, stop)
139
+ art_loss = (sum(parameter.sum() * 0 for parameter in self.art.parameters())
140
+ if target is None else self.art(
141
+ hidden, conditions["pixel_mask"].to(hidden.device), target,
142
+ num_ref=self.model.config.num_ref, history_end=start,
143
+ warmup=conditions.get("train_step", 0) < self.art_warmup))
144
+ loss = loss + self.art_weight * art_loss
145
+ metrics["art"] = art_loss.detach()
146
+ return loss, metrics
viddub/causal/training/common.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared math and state used by causal training objectives."""
2
+ import torch
3
+ from torch import nn
4
+
5
+
6
+ def spatial_weight(vace_context, mouth_weight):
7
+ if mouth_weight < 1:
8
+ raise ValueError("mouth_weight must be >= 1")
9
+ mask = vace_context[:, 32:].amax(1, keepdim=True).float()
10
+ return 1 + (mouth_weight - 1) * mask
11
+
12
+
13
+ def weighted_mse(a, b, weight):
14
+ return ((a.float() - b.float()).square() * weight).mean() / weight.mean()
15
+
16
+
17
+ def flow_noise(x0, sigma, noise):
18
+ return (1 - sigma) * x0 + sigma * noise
19
+
20
+
21
+ def dmd_gradient(x0, real_x0, fake_x0, active, eps=1e-6):
22
+ dims = tuple(range(1, x0.ndim))
23
+ denom = (active * (x0 - real_x0).abs()).sum(dims, keepdim=True)
24
+ count = active.expand_as(x0).sum(dims, keepdim=True).clamp_min(1)
25
+ denom = (denom / count).clamp_min(eps)
26
+ gradient = (fake_x0 - real_x0) / denom
27
+ if not torch.isfinite(gradient).all():
28
+ raise FloatingPointError("Non-finite DMD score difference")
29
+ return gradient * active
30
+
31
+
32
+ class ObjectiveBase(nn.Module):
33
+ """Common generator state; frozen score/quality networks stay unregistered."""
34
+ def __init__(self, model, *, mouth_weight=3, shift=5, student_steps=4,
35
+ use_cache=True, gradient_checkpointing=True, quality=None,
36
+ **_ignored):
37
+ super().__init__()
38
+ self.model = model
39
+ self.mouth_weight = mouth_weight
40
+ self.shift = shift
41
+ self.student_steps = student_steps
42
+ self.use_cache = use_cache
43
+ self.gradient_checkpointing = gradient_checkpointing
44
+ self.quality = [quality]
45
+
46
+
47
+ class ScoreMixin:
48
+ def _score(self, score, x, timestep, conditions, drop_audio=None, grad=False):
49
+ if grad and self.dmd_score_offload:
50
+ raise ValueError("Trainable scores must remain resident through backward")
51
+ if self.dmd_score_offload:
52
+ score.to(x.device)
53
+ try:
54
+ return score(
55
+ x, timestep, conditions["context"], conditions["vace_context"],
56
+ conditions["whisper_hs"], conditions["num_frames"], drop_audio,
57
+ gradient_checkpointing=grad and self.gradient_checkpointing)
58
+ finally:
59
+ if self.dmd_score_offload:
60
+ score.cpu()
61
+ torch.cuda.empty_cache()
viddub/causal/training/dmd.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Distribution-matching objective for the few-step causal student."""
2
+ import torch
3
+
4
+ from ..sampling import rollout, schedule
5
+ from .common import (ObjectiveBase, ScoreMixin, dmd_gradient, flow_noise,
6
+ spatial_weight, weighted_mse)
7
+
8
+
9
+ class DMDObjective(ScoreMixin, ObjectiveBase):
10
+ def __init__(self, model, *, teacher=None, fake=None, anchor=None,
11
+ teacher_cfg=1, matching_min=.02, matching_max=.98,
12
+ dmd_rollout_mode="legacy_early_exit",
13
+ rollout_quality_weight=1, rollout_quality_cross=False,
14
+ dmd_retention_weight=0, dmd_score_offload=False, **kwargs):
15
+ super().__init__(model, **kwargs)
16
+ if not 0 < matching_min < matching_max < 1:
17
+ raise ValueError("Matching range must lie strictly within (0,1)")
18
+ if dmd_rollout_mode not in ("legacy_early_exit", "final_step"):
19
+ raise ValueError("Unknown DMD rollout mode")
20
+ self.scores = [teacher, fake, anchor]
21
+ self.teacher_cfg = teacher_cfg
22
+ self.matching_min, self.matching_max = matching_min, matching_max
23
+ self.dmd_rollout_mode = dmd_rollout_mode
24
+ self.rollout_quality_weight = rollout_quality_weight
25
+ self.rollout_quality_cross = rollout_quality_cross
26
+ self.dmd_retention_weight = dmd_retention_weight
27
+ self.dmd_score_offload = dmd_score_offload
28
+
29
+ def _anchor_rollout(self, anchor, x0, conditions, sigmas, noise):
30
+ if self.dmd_score_offload:
31
+ anchor.to(x0.device)
32
+ try:
33
+ return rollout(anchor, x0, conditions, sigmas, noise=noise,
34
+ use_cache=self.use_cache)
35
+ finally:
36
+ if self.dmd_score_offload:
37
+ anchor.cpu()
38
+ torch.cuda.empty_cache()
39
+
40
+ def forward(self, x0, conditions, *, chunk_index=None,
41
+ dmd_mode="combined", **_):
42
+ if dmd_mode not in ("combined", "primary", "quality"):
43
+ raise ValueError("Unknown DMD backward mode")
44
+ teacher, fake, anchor = self.scores
45
+ if teacher is None or fake is None:
46
+ raise ValueError("DMD requires both real and fake score models")
47
+ ranges = self.model.config.video_blocks(x0.shape[2])
48
+ index = int(torch.randint(len(ranges), ())) if chunk_index is None else chunk_index
49
+ start, stop = ranges[index]
50
+ exit_step = (int(torch.randint(self.student_steps, ()))
51
+ if self.dmd_rollout_mode == "legacy_early_exit" else None)
52
+ sigmas = schedule(self.student_steps, self.shift, x0.device)
53
+ rollout_noise = torch.randn_like(x0)
54
+ generated = rollout(
55
+ self.model, x0, conditions, sigmas, noise=rollout_noise,
56
+ use_cache=self.use_cache, grad_chunk=index, grad_step=exit_step,
57
+ gradient_checkpointing=self.gradient_checkpointing)
58
+
59
+ if dmd_mode == "quality":
60
+ if self.quality[0] is None:
61
+ raise ValueError("DMD quality-only pass requires quality supervision")
62
+ if conditions.get("cross", False) and not self.rollout_quality_cross:
63
+ zero = generated[:, :, start:stop].sum() * 0
64
+ return zero, {
65
+ "rollout_aux_loss": zero.detach(), "chunk": float(index),
66
+ "exit_step": float(self.student_steps - 1
67
+ if exit_step is None else exit_step)}
68
+ auxiliary, stats = self.quality[0](
69
+ generated[:, :, :stop], conditions, start, stop, 0.)
70
+ loss = self.rollout_quality_weight * auxiliary
71
+ metrics = {f"rollout_{key}": value.detach()
72
+ for key, value in stats.items()}
73
+ metrics.update(
74
+ rollout_aux_loss=auxiliary.detach(), chunk=float(index),
75
+ exit_step=float(self.student_steps - 1
76
+ if exit_step is None else exit_step))
77
+ return loss, metrics
78
+
79
+ with torch.no_grad():
80
+ sigma = torch.empty((), device=x0.device).uniform_(
81
+ self.matching_min, self.matching_max)
82
+ noisy = flow_noise(generated.detach(), sigma, torch.randn_like(generated))
83
+ timestep = noisy.new_full(
84
+ (noisy.shape[0], noisy.shape[2]), float(sigma) * 1000)
85
+ real_velocity = self._score(teacher, noisy, timestep, conditions)
86
+ if self.teacher_cfg != 1:
87
+ null = torch.ones(noisy.shape[0], dtype=torch.bool,
88
+ device=noisy.device)
89
+ null_velocity = self._score(
90
+ teacher, noisy, timestep, conditions, null)
91
+ real_velocity = null_velocity + self.teacher_cfg * (
92
+ real_velocity - null_velocity)
93
+ real_x0 = noisy.float() - sigma * real_velocity.float()
94
+ fake_x0 = noisy.float() - sigma * self._score(
95
+ fake, noisy, timestep, conditions).float()
96
+ active = torch.zeros_like(generated[:, :1], dtype=torch.float32)
97
+ active[:, :, start:stop] = 1
98
+ direction = dmd_gradient(
99
+ generated.float(), real_x0, fake_x0, active)
100
+ target = generated.detach().float() - direction
101
+
102
+ loss = .5 * weighted_mse(
103
+ generated[:, :, start:stop], target[:, :, start:stop],
104
+ spatial_weight(conditions["vace_context"][:, :, start:stop],
105
+ self.mouth_weight))
106
+ metrics = {
107
+ "dmd": loss.detach(),
108
+ "direction_rms": direction.square().mean().sqrt(),
109
+ "chunk": float(index),
110
+ "exit_step": float(self.student_steps - 1
111
+ if exit_step is None else exit_step),
112
+ }
113
+ if self.dmd_retention_weight:
114
+ if anchor is None:
115
+ raise ValueError("DMD retention requires a frozen ODE anchor")
116
+ with torch.no_grad():
117
+ anchor_generated = self._anchor_rollout(
118
+ anchor, x0, conditions, sigmas, rollout_noise)
119
+ retention = weighted_mse(
120
+ generated[:, :, start:stop], anchor_generated[:, :, start:stop],
121
+ spatial_weight(conditions["vace_context"][:, :, start:stop],
122
+ self.mouth_weight))
123
+ loss = loss + self.dmd_retention_weight * retention
124
+ metrics["retention"] = retention.detach()
125
+ if (dmd_mode == "combined" and self.quality[0] is not None and
126
+ (not conditions.get("cross", False) or self.rollout_quality_cross)):
127
+ auxiliary, stats = self.quality[0](
128
+ generated[:, :, :stop], conditions, start, stop, 0.)
129
+ loss = loss + self.rollout_quality_weight * auxiliary
130
+ metrics.update({f"rollout_{key}": value.detach()
131
+ for key, value in stats.items()})
132
+ metrics["rollout_aux_loss"] = auxiliary.detach()
133
+ return loss, metrics
viddub/causal/training/engine.py ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared implementation behind the AR, ODE and DMD training entry points."""
2
+ import argparse
3
+ import copy
4
+ import datetime
5
+ import hashlib
6
+ import json
7
+ import random
8
+ import math
9
+ import time
10
+ from pathlib import Path
11
+
12
+ import torch
13
+ from torch.optim import _functional as optim_functional
14
+
15
+ from .stages import ARObjective, DMDObjective, FakeObjective, ODEObjective
16
+ from ..runtime import (atomic_json, autocast, checkpoint_paths, export_weights, fingerprint,
17
+ first_item, load_components, local_imports, make_dataset, prepare_clip,
18
+ read_config, require_causal_metadata)
19
+ from ..sampling import rollout, schedule
20
+
21
+
22
+ def optimizer(model, cfg):
23
+ return torch.optim.AdamW([
24
+ {"params": model.adapter.parameters(), "lr": cfg["lr"]},
25
+ {"params": list(model.dit.parameters()) + list(model.vace.parameters()), "lr": cfg["lr_base"]},
26
+ ], weight_decay=cfg["weight_decay"], betas=(cfg.get('beta1',.9),cfg.get('beta2',.999)), foreach=False)
27
+
28
+
29
+ def lr_scheduler(opt, cfg, updates_per_step=1):
30
+ """Schedule measured in generator updates, including repeated critic steps."""
31
+ if updates_per_step < 1:
32
+ raise ValueError("updates_per_step must be positive")
33
+ def scale(step):
34
+ step = step / updates_per_step
35
+ warm = min(1.0, (step + 1) / max(1, cfg["warmup"]))
36
+ if cfg.get('lr_schedule') == 'cosine' and step >= cfg['warmup']:
37
+ progress = min(1., (step-cfg['warmup'])/max(1,cfg['max_steps']-cfg['warmup']))
38
+ return cfg['lr_min_ratio']+(1-cfg['lr_min_ratio'])*0.5*(1+math.cos(math.pi*progress))
39
+ return warm
40
+ return torch.optim.lr_scheduler.LambdaLR(opt, scale)
41
+
42
+
43
+ def set_trainable(model, enabled):
44
+ model.requires_grad_(enabled)
45
+ if not enabled:
46
+ for p in model.parameters():
47
+ p.grad = None
48
+
49
+
50
+ def move_optimizer_state(opt, device):
51
+ for state in opt.state.values():
52
+ for key, value in state.items():
53
+ if torch.is_tensor(value):
54
+ state[key] = value.to(device)
55
+
56
+
57
+ def restore_optimizer_state_for_step(opt, device):
58
+ """Restore offloaded Adam state after backward without retaining cache fragments.
59
+
60
+ Production clips can leave hundreds of MiB in non-contiguous cached blocks after
61
+ fake-score backward. The Adam tensors need contiguous allocations and may OOM
62
+ despite that memory being reported as reserved-but-free. Releasing only the
63
+ allocator cache here does not modify live tensors, gradients, optimizer values,
64
+ RNG, or update order; it merely makes already-free blocks available to the CUDA
65
+ allocator before the state transfer.
66
+ """
67
+ if torch.device(device).type == "cuda":
68
+ torch.cuda.empty_cache()
69
+ move_optimizer_state(opt, device)
70
+
71
+
72
+ @torch.no_grad()
73
+ def streaming_offloaded_adamw_step(opt, device, chunk_elements=262144):
74
+ """Apply the native AdamW update one parameter at a time.
75
+
76
+ The optimizer state remains on CPU between parameters. For each parameter we
77
+ move bounded chunks of its first/second moments to the parameter device, call
78
+ PyTorch's own Adam functional with the original parameter-group flags, then
79
+ immediately move those chunks back to CPU. Every chunk sees the same old step;
80
+ the persistent step counter advances once per parameter update. This preserves
81
+ ordinary AdamW state_dict semantics without materializing a full parameter's,
82
+ let alone the full model's, moments on CUDA.
83
+ """
84
+ base = getattr(opt, "optimizer", opt)
85
+ if not isinstance(base, torch.optim.AdamW):
86
+ raise TypeError("Streaming offload currently requires torch.optim.AdamW")
87
+ if chunk_elements < 1:
88
+ raise ValueError("chunk_elements must be positive")
89
+ if torch.device(device).type == "cuda":
90
+ torch.cuda.empty_cache()
91
+ for group in base.param_groups:
92
+ if group.get("foreach") not in (False, None) or group.get("fused"):
93
+ raise ValueError("Streaming offload requires foreach=False and fused=False")
94
+ beta1, beta2 = group["betas"]
95
+ for parameter in group["params"]:
96
+ gradient = parameter.grad
97
+ if gradient is None:
98
+ continue
99
+ if gradient.is_sparse:
100
+ raise RuntimeError("Adam does not support sparse gradients")
101
+ state = base.state[parameter]
102
+ if not state:
103
+ # Match Adam._init_group, except moments are born on CPU because
104
+ # this optimizer is explicitly offloaded between updates.
105
+ state["step"] = torch.tensor(0.0, dtype=torch.float32)
106
+ state["exp_avg"] = torch.zeros_like(
107
+ parameter, device="cpu", memory_format=torch.preserve_format)
108
+ state["exp_avg_sq"] = torch.zeros_like(
109
+ parameter, device="cpu", memory_format=torch.preserve_format)
110
+ if group["amsgrad"]:
111
+ state["max_exp_avg_sq"] = torch.zeros_like(
112
+ parameter, device="cpu", memory_format=torch.preserve_format)
113
+ if not parameter.is_contiguous() or not gradient.is_contiguous():
114
+ raise ValueError("Streaming AdamW requires contiguous parameters and gradients")
115
+ parameter_flat = parameter.view(-1)
116
+ gradient_flat = gradient.view(-1)
117
+ exp_avg_cpu = state["exp_avg"].view(-1)
118
+ exp_avg_sq_cpu = state["exp_avg_sq"].view(-1)
119
+ max_cpu = state.get("max_exp_avg_sq")
120
+ max_cpu = max_cpu.view(-1) if max_cpu is not None else None
121
+ old_step = state["step"].clone()
122
+ next_step = None
123
+ for start in range(0, parameter.numel(), chunk_elements):
124
+ end = min(parameter.numel(), start + chunk_elements)
125
+ exp_avg = exp_avg_cpu[start:end].to(parameter.device)
126
+ exp_avg_sq = exp_avg_sq_cpu[start:end].to(parameter.device)
127
+ max_exp_avg_sq = (max_cpu[start:end].to(parameter.device)
128
+ if max_cpu is not None else None)
129
+ local_step = old_step.clone()
130
+ optim_functional.adam(
131
+ [parameter_flat[start:end]], [gradient_flat[start:end]],
132
+ [exp_avg], [exp_avg_sq],
133
+ [max_exp_avg_sq] if max_exp_avg_sq is not None else [],
134
+ [local_step], foreach=False,
135
+ capturable=group["capturable"],
136
+ differentiable=group["differentiable"], fused=False,
137
+ grad_scale=getattr(base, "grad_scale", None),
138
+ found_inf=getattr(base, "found_inf", None),
139
+ has_complex=torch.is_complex(parameter),
140
+ decoupled_weight_decay=True, amsgrad=group["amsgrad"],
141
+ beta1=beta1, beta2=beta2, lr=group["lr"],
142
+ weight_decay=group["weight_decay"], eps=group["eps"],
143
+ maximize=group["maximize"])
144
+ exp_avg_cpu[start:end].copy_(exp_avg.cpu())
145
+ exp_avg_sq_cpu[start:end].copy_(exp_avg_sq.cpu())
146
+ if max_exp_avg_sq is not None:
147
+ max_cpu[start:end].copy_(max_exp_avg_sq.cpu())
148
+ next_step = local_step
149
+ if next_step is not None:
150
+ state["step"].copy_(next_step)
151
+
152
+
153
+ def finite_streaming_offloaded_update(accel, objective, opt, cfg):
154
+ norm = accel.clip_grad_norm_(objective.parameters(), cfg["grad_clip"])
155
+ valid = accel.reduce(torch.tensor(int(torch.isfinite(norm)), device=accel.device), "sum")
156
+ if int(valid) != accel.num_processes:
157
+ opt.zero_grad(set_to_none=True)
158
+ raise FloatingPointError("Non-finite gradients; stopped without advancing checkpoint")
159
+ streaming_offloaded_adamw_step(opt, accel.device)
160
+ opt.zero_grad(set_to_none=True)
161
+ return float(norm)
162
+
163
+
164
+ def finite_update(accel, objective, opt, cfg):
165
+ norm = accel.clip_grad_norm_(objective.parameters(), cfg["grad_clip"])
166
+ valid = accel.reduce(torch.tensor(int(torch.isfinite(norm)), device=accel.device), "sum")
167
+ if int(valid) != accel.num_processes:
168
+ opt.zero_grad(set_to_none=True)
169
+ raise FloatingPointError("Non-finite gradients; stopped without advancing checkpoint")
170
+ opt.step()
171
+ opt.zero_grad(set_to_none=True)
172
+ return float(norm)
173
+
174
+
175
+ def check_loss(accel, loss):
176
+ finite = accel.reduce(torch.tensor(int(torch.isfinite(loss)), device=accel.device), "sum")
177
+ if int(finite) != accel.num_processes:
178
+ raise FloatingPointError("Non-finite loss on at least one rank")
179
+
180
+
181
+ def main(expected_stage=None):
182
+ p = argparse.ArgumentParser(description=__doc__)
183
+ p.set_defaults(teacher_dir=None, teacher_step=None, fake_dir=None,
184
+ fake_step=None, trajectories=None)
185
+ p.add_argument("--config", required=True)
186
+ p.add_argument("--init-dir", required=True)
187
+ p.add_argument("--init-step", required=True, type=int)
188
+ p.add_argument("--output", required=True)
189
+ if expected_stage in (None, "ode"):
190
+ p.add_argument(
191
+ "--trajectories",
192
+ help="Trajectory directory produced by viddub.causal.make_trajectories",
193
+ )
194
+ if expected_stage in (None, "dmd"):
195
+ p.add_argument("--teacher-dir", help="Original bidirectional VidDub checkpoint")
196
+ p.add_argument("--teacher-step", type=int)
197
+ p.add_argument("--fake-dir", help="Pretrained fake-score checkpoint")
198
+ p.add_argument("--fake-step", type=int)
199
+ p.add_argument("--resume", help="A complete state_stepNNNNNN directory")
200
+ p.add_argument("--stop-after-step", type=int,
201
+ help="Operational stop after a saved step; does not alter the training config")
202
+ p.add_argument("--check-only", action="store_true", help="Validate inputs, metadata and data; do not allocate GPUs")
203
+ a = p.parse_args()
204
+ cfg = read_config(a.config)
205
+ if expected_stage is not None and cfg["stage"] != expected_stage:
206
+ p.error(f"This entry point requires stage={expected_stage}, got {cfg['stage']}")
207
+ if cfg['stage'] == 'cd':
208
+ p.error('Use python -m viddub.causal.cf.train for consistency training')
209
+ if a.stop_after_step is not None and not 0 < a.stop_after_step <= cfg['max_steps']:
210
+ p.error('--stop-after-step must lie within 1..max_steps')
211
+ run_until = cfg['max_steps'] if a.stop_after_step is None else a.stop_after_step
212
+ paths = checkpoint_paths(a.init_dir, a.init_step)
213
+ teacher_paths = None
214
+ run_provenance = {"student_init": fingerprint(paths)}
215
+ if cfg["stage"] == "ode":
216
+ if cfg["causal"]:
217
+ require_causal_metadata(a.init_dir, a.init_step, cfg, ("ar",))
218
+ if not a.trajectories:
219
+ p.error("ODE requires --trajectories")
220
+ if cfg["stage"] == "dmd":
221
+ require_causal_metadata(a.init_dir, a.init_step, cfg, ("ode", "dmd", "cd"))
222
+ teacher_paths = checkpoint_paths(a.teacher_dir, a.teacher_step)
223
+ run_provenance["teacher"] = fingerprint(teacher_paths)
224
+ if bool(a.fake_dir) != (a.fake_step is not None):
225
+ p.error("Provide both --fake-dir and --fake-step")
226
+ fake_paths = checkpoint_paths(a.fake_dir, a.fake_step) if a.fake_dir else None
227
+ if fake_paths is not None:
228
+ run_provenance['fake_init'] = fingerprint(fake_paths)
229
+ if a.resume:
230
+ if Path(a.resume).resolve().parent != Path(a.output).resolve():
231
+ raise ValueError("Resume state must belong to this output directory")
232
+ resume_meta = json.loads((Path(a.resume) / "complete.json").read_text())
233
+ if read_config(resume_meta["config"]) != cfg:
234
+ raise ValueError("Resume requires exactly the saved config")
235
+ else:
236
+ resume_meta = None
237
+ if not a.resume and Path(a.output).exists() and any(Path(a.output).iterdir()):
238
+ raise ValueError("Output is not empty; choose a new output or explicitly --resume")
239
+ if cfg["stage"] == "ode":
240
+ from ..trajectories import TrajectoryDataset
241
+ dataset = TrajectoryDataset(a.trajectories, cfg)
242
+ trajectory_causal = dataset.manifest.get(
243
+ "teacher_architecture", {}
244
+ ).get("causal", dataset.manifest.get("config", {}).get("causal", True))
245
+ if trajectory_causal != cfg["causal"]:
246
+ raise ValueError("Trajectory/student causal architecture mismatch")
247
+ for name in paths:
248
+ if dataset.manifest["teacher"][name]["sha256"] != run_provenance["student_init"][name]["sha256"]:
249
+ raise ValueError(f"Trajectory teacher differs from student initialization: {name}")
250
+ run_provenance["trajectories"] = str(Path(a.trajectories).resolve())
251
+ run_provenance["trajectory_manifest"] = fingerprint({"manifest": dataset.manifest_path})["manifest"]
252
+ rows = None
253
+ else:
254
+ dataset, rows = make_dataset(cfg)
255
+ if cfg["stage"] == "dmd" and cfg["p_cross"] > 0 and len(dataset) < 2:
256
+ raise ValueError("Cross-audio DMD requires at least two training clips")
257
+ if rows is not None:
258
+ row_json = json.dumps(rows, sort_keys=True).encode()
259
+ run_provenance["dataset_rows_sha256"] = hashlib.sha256(row_json).hexdigest()
260
+ if resume_meta and resume_meta["provenance"] != run_provenance:
261
+ raise ValueError("Resume checkpoint/data provenance differs")
262
+ if a.check_only:
263
+ print(json.dumps({"config": cfg, "checkpoint_files": {k: str(v) for k, v in paths.items()},
264
+ "samples": len(dataset), "status": "inputs_validated_no_gpu_execution"}, indent=2))
265
+ return
266
+ local_imports()
267
+ import accelerate
268
+ from safetensors.torch import load_file
269
+ from viddub.full_context.runtime import ZeroLatents
270
+ accel = accelerate.Accelerator(
271
+ mixed_precision="no", gradient_accumulation_steps=cfg["grad_accum"],
272
+ step_scheduler_with_optimizer=False,
273
+ kwargs_handlers=[accelerate.DistributedDataParallelKwargs(find_unused_parameters=False, gradient_as_bucket_view=True),
274
+ accelerate.InitProcessGroupKwargs(timeout=datetime.timedelta(hours=2))])
275
+ if accel.device.type != "cuda":
276
+ raise RuntimeError(
277
+ "Production training needs CUDA; run `python -m unittest discover -s tests` "
278
+ "for CPU-only contract verification"
279
+ )
280
+ if "DEEPSPEED" in str(accel.distributed_type) or "FSDP" in str(accel.distributed_type):
281
+ raise ValueError("This first implementation supports plain single GPU/DDP only")
282
+ random.seed(cfg["seed"] + accel.process_index)
283
+ torch.manual_seed(cfg["seed"] + accel.process_index)
284
+ pipe, whisper, student = load_components(paths, cfg, accel.device)
285
+ student.requires_grad_(True).train()
286
+ teacher = fake = anchor = fake_objective = fake_opt = fake_sched = None
287
+ if cfg["stage"] == "dmd":
288
+ if cfg['dmd_retention_weight']:
289
+ anchor = copy.deepcopy(student).requires_grad_(False).eval()
290
+ teacher = copy.deepcopy(student)
291
+ teacher.causal = False
292
+ for name, path in teacher_paths.items():
293
+ getattr(teacher, name).load_state_dict(load_file(str(path)), strict=True)
294
+ teacher.requires_grad_(False).eval()
295
+ fake = copy.deepcopy(teacher)
296
+ if fake_paths is not None:
297
+ for name, path in fake_paths.items():
298
+ getattr(fake, name).load_state_dict(load_file(str(path)), strict=True)
299
+ fake.requires_grad_(True).train()
300
+ kwargs = dict(mouth_weight=cfg["mouth_weight"], shift=cfg["shift"], student_steps=cfg["student_steps"],
301
+ teacher_cfg=cfg["teacher_cfg"], use_cache=cfg["use_cache"],
302
+ gradient_checkpointing=cfg["gradient_checkpointing"],
303
+ matching_min=cfg["matching_min"], matching_max=cfg["matching_max"])
304
+ kwargs['dmd_rollout_mode'] = cfg['dmd_rollout_mode']
305
+ kwargs['ode_gt_flow_weight'] = cfg['ode_gt_flow_weight']
306
+ kwargs['rollout_quality_weight'] = cfg['rollout_quality_weight']
307
+ kwargs['rollout_quality_cross'] = cfg['rollout_quality_cross']
308
+ kwargs['dmd_retention_weight'] = cfg['dmd_retention_weight']
309
+ kwargs['dmd_score_offload'] = cfg['dmd_score_offload']
310
+ for key in ['ar_history_mode','ar_history_prob_start','ar_history_prob_end',
311
+ 'ar_history_ramp_steps','ar_history_steps','ar_history_sigma_max',
312
+ 'ar_history_mouth_drop_prob','ar_history_mouth_drop_cross_only']:
313
+ kwargs[key]=cfg[key]
314
+ quality = None
315
+ if any(cfg.get(k,0) for k in ['pixel_weight','lpips_weight','sync_weight','keep_weight','art_weight']):
316
+ from ..quality import GTQuality
317
+ quality = GTQuality(pipe, cfg, accel.device)
318
+ art = None
319
+ if cfg.get('art_weight',0):
320
+ if cfg['stage']!='ar':raise ValueError('Art integration is initially AR-only')
321
+ from ..art_alignment import CausalArtAlignment
322
+ art=CausalArtAlignment(student.dit.dim).to(accel.device)
323
+ objective_class={"ar":ARObjective,"ode":ODEObjective,"dmd":DMDObjective}[cfg["stage"]]
324
+ if cfg['stage']=='dmd' and cfg['sampler']=='cf_renoise':
325
+ from ..cf.dmd import CFAsymmetricDMD
326
+ objective_class=CFAsymmetricDMD
327
+ objective = objective_class(student, teacher=teacher, fake=fake, anchor=anchor,
328
+ quality=quality,
329
+ art=art,art_weight=cfg['art_weight'],art_layer=cfg['art_layer'],
330
+ art_warmup=cfg['art_warmup'],**kwargs)
331
+ opt = optimizer(student, cfg)
332
+ if art is not None:opt.add_param_group({'params':art.parameters(),'lr':cfg['art_lr']})
333
+ sched = lr_scheduler(opt, cfg)
334
+ loader_kwargs = dict(batch_size=1, shuffle=True, collate_fn=first_item, num_workers=cfg["workers"])
335
+ loader_kwargs["generator"] = torch.Generator().manual_seed(cfg["seed"])
336
+ if cfg["workers"]:
337
+ loader_kwargs.update(persistent_workers=True, prefetch_factor=2)
338
+ loader = torch.utils.data.DataLoader(dataset, **loader_kwargs)
339
+ objective, opt, loader = accel.prepare(objective, opt, loader)
340
+ accel.register_for_checkpointing(sched)
341
+ if fake is not None:
342
+ # The training loop owns fake residency through forward+backward.
343
+ # Self-offloading inside Objective.fake would move checkpointed weights
344
+ # to CPU before backward recomputation.
345
+ fake_kwargs = dict(kwargs, dmd_score_offload=False)
346
+ fake_objective = FakeObjective(fake, **fake_kwargs)
347
+ fake_opt = optimizer(fake, dict(cfg, lr=cfg['fake_lr'], lr_base=cfg['fake_lr_base']))
348
+ fake_sched = lr_scheduler(fake_opt, cfg, updates_per_step=cfg["fake_updates"])
349
+ fake_objective, fake_opt = accel.prepare(fake_objective, fake_opt)
350
+ accel.register_for_checkpointing(fake_sched)
351
+ if cfg['dmd_score_offload']:
352
+ teacher.cpu()
353
+ if anchor is not None:anchor.cpu()
354
+ fake.cpu()
355
+ torch.cuda.empty_cache()
356
+ provenance = run_provenance
357
+ out = Path(a.output)
358
+ if accel.is_main_process:
359
+ out.mkdir(parents=True, exist_ok=True)
360
+ atomic_json(out / "run.json", {"config": cfg, "provenance": provenance,
361
+ "world_size": accel.num_processes,
362
+ "note": "DDP shards samples; all models are replicated per GPU."})
363
+ accel.wait_for_everyone()
364
+ provenance = json.loads((out / "run.json").read_text())["provenance"]
365
+ step, epoch, consumed = 0, 0, 0
366
+ if a.resume:
367
+ if resume_meta["world_size"] != accel.num_processes:
368
+ raise ValueError("Resume requires the same world size")
369
+ accel.load_state(a.resume)
370
+ if fake is not None and cfg['dmd_score_offload']:
371
+ move_optimizer_state(fake_opt, 'cpu')
372
+ fake.cpu()
373
+ teacher.cpu()
374
+ if anchor is not None:anchor.cpu()
375
+ torch.cuda.empty_cache()
376
+ step, epoch, consumed = (resume_meta[k] for k in ("step", "epoch", "consumed_batches"))
377
+ zero_latents = ZeroLatents(pipe)
378
+ # Auxiliary network initialization must not change the data/noise stream.
379
+ if not a.resume:
380
+ random.seed(cfg['seed']+accel.process_index)
381
+ torch.manual_seed(cfg['seed']+accel.process_index)
382
+ start_time = time.monotonic()
383
+ row_index = {r["clip_id"]: i for i, r in enumerate(rows)} if rows is not None else {}
384
+ metric_sums = {}
385
+ metric_count = 0
386
+
387
+ def save(epoch, consumed):
388
+ state = out / f"state_step{step:06d}"
389
+ # All ranks must finish the existence check BEFORE any rank calls
390
+ # save_state (which creates the directory). Otherwise a faster rank
391
+ # makes a slower rank mistake this very save for an overwrite.
392
+ exists = accel.reduce(torch.tensor(int(state.exists()), device=accel.device), "sum")
393
+ if int(exists):
394
+ raise FileExistsError(f"Refusing to overwrite state {state}")
395
+ full_state = not cfg.get('save_state_every') or step % cfg['save_state_every'] == 0 or step == cfg['max_steps']
396
+ if full_state:accel.save_state(str(state))
397
+ if accel.is_main_process:
398
+ export_weights(student, out, step, cfg, provenance)
399
+ if full_state:atomic_json(state / "complete.json", {"step": step, "epoch": epoch,
400
+ "consumed_batches": consumed, "config": cfg, "provenance": provenance,
401
+ "world_size": accel.num_processes})
402
+ accel.wait_for_everyone()
403
+
404
+ while step < run_until:
405
+ if hasattr(loader, "set_epoch"):
406
+ loader.set_epoch(epoch)
407
+ # Seed permutation by epoch, independent of model/Whisper RNG use.
408
+ if getattr(loader, "generator", None) is not None:
409
+ loader.generator.manual_seed(cfg["seed"] + epoch)
410
+ active_loader = accel.skip_first_batches(loader, consumed) if consumed else loader
411
+ for item in active_loader:
412
+ consumed += 1
413
+ record = None
414
+ if cfg["stage"] == "ode":
415
+ dtype = torch.bfloat16 if cfg["precision"] == "bf16" else torch.float32
416
+ x0 = item["shape_reference"].to(accel.device, dtype)
417
+ c = {}
418
+ for k, v in item["conditions"].items():
419
+ if torch.is_tensor(v):
420
+ c[k] = v.to(accel.device, dtype) if k in (
421
+ 'context', 'vace_context', 'whisper_hs') else v
422
+ else:
423
+ c[k] = v
424
+ record = item["record"]
425
+ else:
426
+ if cfg["stage"] in ("ar", "dmd") and random.random() < cfg["p_cross"]:
427
+ # Conditions stay on the source video; swap only audio.
428
+ other = dataset[random.randrange(len(dataset))]
429
+ if other["clip_id"] == item["clip_id"]:
430
+ source_index = row_index[item["clip_id"]]
431
+ other = dataset[(source_index + 1) % len(dataset)]
432
+ item = dict(item, audio=other["audio"], cross=True)
433
+ x0, c = prepare_clip(item, pipe, whisper, zero_latents, cfg)
434
+ c['cross'] = bool(item.get('cross', False))
435
+ if cfg["stage"] == "ar":
436
+ c['train_step']=step
437
+ c['history_seed']=cfg['seed']+consumed*100003+accel.process_index
438
+ c["drop_audio"] = torch.rand(x0.shape[0], device=x0.device) < cfg["p_drop_audio"]
439
+ if c['cross']:
440
+ c["drop_audio"].zero_()
441
+ if fake is not None:
442
+ set_trainable(student, False)
443
+ if cfg['dmd_score_offload']:
444
+ fake.to(accel.device)
445
+ set_trainable(fake, True)
446
+ fake.train()
447
+ for _ in range(cfg["fake_updates"]):
448
+ # Keep the (large) Adam state on CPU while rolling out the
449
+ # student sample. After the first fake update those states
450
+ # otherwise overlap the causal rollout peak by only tens of
451
+ # MiB on 96-GB cards. This changes only residency timing:
452
+ # fake weights, samples, RNG, update count and objectives are
453
+ # unchanged.
454
+ with torch.no_grad(), autocast(accel.device, cfg["precision"]):
455
+ if cfg['sampler']=='cf_renoise':
456
+ from ..cf.dmd import shared_exit
457
+ from ..cf.sampling import cf_rollout
458
+ generated=cf_rollout(student,x0,c,steps=cfg['student_steps'],shift=cfg['shift'],
459
+ use_cache=cfg['use_cache'],grad_exit=(None if cfg['dmd_rollout_mode']=='final_step'
460
+ else shared_exit(cfg['student_steps'],x0.device)))
461
+ else:
462
+ generated = rollout(student, x0, c, schedule(cfg["student_steps"], cfg["shift"], x0.device),
463
+ use_cache=cfg["use_cache"])
464
+ with autocast(accel.device, cfg["precision"]):
465
+ fake_loss, _ = fake_objective(x0, c, generated=generated)
466
+ check_loss(accel, fake_loss)
467
+ accel.backward(fake_loss)
468
+ if cfg['dmd_score_offload']:
469
+ finite_streaming_offloaded_update(
470
+ accel, fake_objective, fake_opt, cfg)
471
+ else:
472
+ finite_update(accel, fake_objective, fake_opt, cfg)
473
+ fake_sched.step()
474
+ del generated
475
+ set_trainable(fake, False)
476
+ fake.eval()
477
+ if cfg['dmd_score_offload']:
478
+ fake.cpu()
479
+ torch.cuda.empty_cache()
480
+ set_trainable(student, True)
481
+ with accel.accumulate(objective):
482
+ if cfg['stage'] == 'ar' and cfg['ar_blocks_per_sample'] != 1:
483
+ ranges = student.config.video_blocks(x0.shape[2])
484
+ count = len(ranges) if cfg['ar_blocks_per_sample'] == 0 else cfg['ar_blocks_per_sample']
485
+ if count > len(ranges):
486
+ raise ValueError(f'ar_blocks_per_sample={count} exceeds {len(ranges)} video blocks')
487
+ # Without replacement: every selected block provides a distinct
488
+ # temporal/audio location. Zero means the official-style all-block path.
489
+ indices = (list(range(len(ranges))) if count == len(ranges)
490
+ else torch.randperm(len(ranges))[:count].tolist())
491
+ metrics = {}
492
+ loss_value = x0.new_zeros((), dtype=torch.float32)
493
+ for chunk_index in indices:
494
+ with autocast(accel.device, cfg["precision"]):
495
+ chunk_loss, chunk_metrics = objective(x0, c, chunk_index=chunk_index)
496
+ check_loss(accel, chunk_loss)
497
+ accel.backward(chunk_loss / count)
498
+ loss_value = loss_value + chunk_loss.detach().float() / count
499
+ for key, value in chunk_metrics.items():
500
+ value = torch.as_tensor(value, device=x0.device, dtype=torch.float32).detach()
501
+ metrics[key] = metrics.get(key, 0.0) + value / count
502
+ loss = loss_value
503
+ metrics['blocks_per_sample'] = x0.new_tensor(float(count))
504
+ metrics['block_coverage_fraction'] = x0.new_tensor(float(count / len(ranges)))
505
+ else:
506
+ split_quality = (cfg['stage'] == 'dmd' and cfg['dmd_split_quality_backward'] and
507
+ any(cfg[k] for k in ['pixel_weight','lpips_weight','sync_weight','keep_weight']))
508
+ if split_quality:
509
+ cpu_rng = torch.random.get_rng_state()
510
+ cuda_rng = torch.cuda.get_rng_state(accel.device)
511
+ with autocast(accel.device, cfg["precision"]):
512
+ primary, metrics = objective(x0, c, record=record, dmd_mode='primary')
513
+ post_cpu_rng = torch.random.get_rng_state()
514
+ post_cuda_rng = torch.cuda.get_rng_state(accel.device)
515
+ check_loss(accel, primary)
516
+ accel.backward(primary)
517
+ torch.random.set_rng_state(cpu_rng)
518
+ torch.cuda.set_rng_state(cuda_rng, accel.device)
519
+ with autocast(accel.device, cfg["precision"]):
520
+ auxiliary, aux_metrics = objective(x0, c, record=record, dmd_mode='quality')
521
+ check_loss(accel, auxiliary)
522
+ accel.backward(auxiliary)
523
+ torch.random.set_rng_state(post_cpu_rng)
524
+ torch.cuda.set_rng_state(post_cuda_rng, accel.device)
525
+ loss = primary.detach().float() + auxiliary.detach().float()
526
+ metrics.update(aux_metrics)
527
+ metrics['split_quality_backward'] = x0.new_tensor(1.)
528
+ else:
529
+ with autocast(accel.device, cfg["precision"]):
530
+ loss, metrics = objective(x0, c, record=record)
531
+ check_loss(accel, loss)
532
+ accel.backward(loss)
533
+ if accel.sync_gradients:
534
+ norm = finite_update(accel, objective, opt, cfg)
535
+ sched.step()
536
+ metric_count += 1
537
+ for key, value in metrics.items():
538
+ metric_sums[key] = metric_sums.get(key, 0.0) + float(value)
539
+ if not accel.sync_gradients:
540
+ continue
541
+ step += 1
542
+ if step % cfg["log_every"] == 0:
543
+ value = float(accel.reduce(loss.detach(), "mean"))
544
+ count = accel.reduce(torch.tensor(float(metric_count), device=accel.device), "sum")
545
+ averaged = {}
546
+ for key, total in metric_sums.items():
547
+ reduced = accel.reduce(torch.tensor(total, device=accel.device), "sum")
548
+ averaged[key] = float(reduced / count)
549
+ log = {"step": step, "loss": value, "grad_norm": norm,
550
+ "elapsed_seconds": time.monotonic() - start_time,
551
+ "metric_window_steps": cfg["log_every"], **averaged}
552
+ if fake is not None:
553
+ log["fake_loss"] = float(fake_loss.detach())
554
+ accel.print(json.dumps(log), flush=True)
555
+ if accel.is_main_process:
556
+ with (out / "train.jsonl").open("a") as f:
557
+ f.write(json.dumps(log) + "\n")
558
+ metric_sums.clear();metric_count=0
559
+ if step % cfg["save_every"] == 0 or step in cfg.get('save_steps',[]) or step == cfg["max_steps"]:
560
+ save(epoch, consumed)
561
+ if step == run_until:
562
+ break
563
+ epoch, consumed = epoch + 1, 0
564
+ accel.end_training()
565
+
566
+
567
+ if __name__ == "__main__":
568
+ main()
viddub/causal/training/fake.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fake-score flow-matching objective used by DMD."""
2
+ import torch
3
+
4
+ from .common import (ObjectiveBase, ScoreMixin, flow_noise, spatial_weight,
5
+ weighted_mse)
6
+
7
+
8
+ class FakeObjective(ScoreMixin, ObjectiveBase):
9
+ def __init__(self, model, *, matching_min=.02, matching_max=.98,
10
+ dmd_score_offload=False, **kwargs):
11
+ super().__init__(model, **kwargs)
12
+ if not 0 < matching_min < matching_max < 1:
13
+ raise ValueError("Matching range must lie strictly within (0,1)")
14
+ self.matching_min, self.matching_max = matching_min, matching_max
15
+ self.dmd_score_offload = dmd_score_offload
16
+
17
+ def forward(self, x0, conditions, *, generated=None, **_):
18
+ if generated is None:
19
+ raise ValueError("Fake score training requires a student rollout")
20
+ generated = generated.detach()
21
+ sigma = torch.empty((), device=generated.device).uniform_(
22
+ self.matching_min, self.matching_max)
23
+ noise = torch.randn_like(generated)
24
+ noisy = flow_noise(generated, sigma, noise)
25
+ n_ref = self.model.config.num_ref
26
+ timestep = generated.new_full(
27
+ (generated.shape[0], generated.shape[2]), float(sigma) * 1000)
28
+ pred = self._score(
29
+ self.model, noisy, timestep, conditions, grad=True)
30
+ loss = weighted_mse(
31
+ pred[:, :, n_ref:], (noise - generated)[:, :, n_ref:],
32
+ spatial_weight(conditions["vace_context"][:, :, n_ref:],
33
+ self.mouth_weight))
34
+ return loss, {"fake_flow": loss.detach()}
viddub/causal/training/ode.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trajectory-regression objective for few-step ODE distillation."""
2
+ import torch
3
+
4
+ from ..sampling import predict_chunk, rollout, schedule
5
+ from .common import ObjectiveBase, flow_noise, spatial_weight, weighted_mse
6
+
7
+
8
+ class ODEObjective(ObjectiveBase):
9
+ def __init__(self, model, *, ode_gt_flow_weight=0,
10
+ rollout_quality_weight=1, rollout_quality_cross=False,
11
+ **kwargs):
12
+ super().__init__(model, **kwargs)
13
+ self.ode_gt_flow_weight = ode_gt_flow_weight
14
+ self.rollout_quality_weight = rollout_quality_weight
15
+ self.rollout_quality_cross = rollout_quality_cross
16
+
17
+ def forward(self, x0, conditions, *, record=None, **_):
18
+ if record is None:
19
+ raise ValueError("ODE stage requires a trajectory record")
20
+ start, stop, sigma = record["start"], record["end"], record["sigma"]
21
+ noisy = record["noisy"].to(x0)
22
+ pred = predict_chunk(
23
+ self.model, noisy, sigma, record["prefix"].to(x0), conditions, start,
24
+ gradient_checkpointing=self.gradient_checkpointing)
25
+ estimate = noisy.float() - float(sigma) * pred.float()
26
+ loss = weighted_mse(
27
+ estimate, record["target"].to(x0),
28
+ spatial_weight(conditions["vace_context"][:, :, start:stop],
29
+ self.mouth_weight))
30
+ metrics = {"ode_x0": loss.detach()}
31
+
32
+ gt = record.get("gt_x0")
33
+ if self.ode_gt_flow_weight:
34
+ if gt is None:
35
+ raise ValueError("ODE GT flow requires trajectories containing gt_latents")
36
+ if not conditions.get("cross", False):
37
+ gt = gt.to(x0)
38
+ clean = gt[:, :, start:stop]
39
+ u = torch.rand((), device=x0.device).clamp(1e-4, 1 - 1e-4)
40
+ gt_sigma = self.shift * u / (1 + (self.shift - 1) * u)
41
+ noise = torch.randn_like(clean)
42
+ gt_noisy = flow_noise(clean, gt_sigma, noise)
43
+ gt_pred = predict_chunk(
44
+ self.model, gt_noisy, gt_sigma, gt[:, :, :start].detach(),
45
+ conditions, start,
46
+ gradient_checkpointing=self.gradient_checkpointing)
47
+ gt_flow = weighted_mse(
48
+ gt_pred, noise - clean,
49
+ spatial_weight(conditions["vace_context"][:, :, start:stop],
50
+ self.mouth_weight))
51
+ loss = loss + self.ode_gt_flow_weight * gt_flow
52
+ metrics["ode_gt_flow"] = gt_flow.detach()
53
+ else:
54
+ metrics["ode_gt_flow"] = loss.detach() * 0
55
+
56
+ if (self.quality[0] is not None and
57
+ (not conditions.get("cross", False) or self.rollout_quality_cross)):
58
+ ranges = self.model.config.video_blocks(x0.shape[2])
59
+ chunk_index = next(
60
+ index for index, pair in enumerate(ranges) if pair == (start, stop))
61
+ generated = rollout(
62
+ self.model, x0, conditions,
63
+ schedule(self.student_steps, self.shift, x0.device),
64
+ use_cache=self.use_cache, grad_chunk=chunk_index, grad_step=None,
65
+ gradient_checkpointing=self.gradient_checkpointing)
66
+ auxiliary, stats = self.quality[0](
67
+ generated[:, :, :stop], conditions, start, stop, 0.)
68
+ loss = loss + self.rollout_quality_weight * auxiliary
69
+ metrics.update({f"rollout_{key}": value.detach()
70
+ for key, value in stats.items()})
71
+ metrics["rollout_aux_loss"] = auxiliary.detach()
72
+ return loss, metrics
viddub/causal/training/stages.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Stable imports for the public training stages."""
2
+ from .ar import ARObjective
3
+ from .dmd import DMDObjective
4
+ from .fake import FakeObjective
5
+ from .ode import ODEObjective
viddub/causal/trajectories.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validated on-disk causal teacher trajectories (one clip per .pt shard)."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import torch
6
+
7
+ from .sampling import schedule
8
+
9
+
10
+ class TrajectoryDataset(torch.utils.data.Dataset):
11
+ def __init__(self, directory, cfg):
12
+ source = Path(directory).resolve()
13
+ self.manifest_path = source if source.is_file() else source / 'manifest.json'
14
+ self.directory = self.manifest_path.parent
15
+ self.cfg = cfg
16
+ self.manifest = json.loads(self.manifest_path.read_text())
17
+ if self.manifest.get("schema") != 1 or not self.manifest.get("complete"):
18
+ raise ValueError("Trajectory generation has not completed")
19
+ for key in ("chunk_size", "num_ref", "fps", "num_frames", "shift"):
20
+ if self.manifest["config"][key] != cfg[key]:
21
+ raise ValueError(f"Trajectory/config mismatch for {key}")
22
+ if self.manifest["student_steps"] != cfg["student_steps"]:
23
+ raise ValueError("Trajectory student step count mismatch")
24
+ if self.manifest["teacher_cfg"] != cfg["teacher_cfg"]:
25
+ raise ValueError("Trajectory guidance mismatch")
26
+ self.records = self.manifest["records"]
27
+ if not self.records:
28
+ raise ValueError("No trajectories")
29
+ self.indices = self.manifest["boundary_indices"][:-1]
30
+ self.sigmas = self.manifest["teacher_sigmas"]
31
+ expected = schedule(cfg["student_steps"], cfg["shift"])
32
+ actual = torch.tensor([self.sigmas[i] for i in self.manifest["boundary_indices"]])
33
+ if not torch.allclose(actual, expected, atol=1e-6, rtol=1e-6):
34
+ raise ValueError("Teacher trajectory boundaries do not match student schedule")
35
+ for record in self.records:
36
+ path = (self.directory / record["file"]).resolve()
37
+ if not path.is_relative_to(self.directory) or not path.is_file():
38
+ raise ValueError(f"Missing/unsafe trajectory path: {path}")
39
+ if "bytes" in record and path.stat().st_size != record["bytes"]:
40
+ raise ValueError(f"Trajectory size mismatch: {path}")
41
+
42
+ def __len__(self):
43
+ return len(self.records)
44
+
45
+ def __getitem__(self, index):
46
+ record = self.records[index]
47
+ data = torch.load(self.directory / record["file"], map_location="cpu", weights_only=True)
48
+ if any(self.cfg.get(k, 0) for k in ('ode_gt_flow_weight','pixel_weight','lpips_weight',
49
+ 'sync_weight','keep_weight')):
50
+ if data.get('gt_latents') is None:
51
+ raise ValueError('This ODE variant requires trajectories with gt_latents')
52
+ if data.get('conditions', {}).get('gt_frames') is None:
53
+ raise ValueError('This ODE variant requires trajectories with pixel GT')
54
+ chunks = data["chunks"]
55
+ chunk = chunks[int(torch.randint(len(chunks), ()))]
56
+ step = int(torch.randint(len(self.indices), ()))
57
+ i = self.indices[step]
58
+ a, b = chunk["start"], chunk["end"]
59
+ conditions = dict(data["conditions"])
60
+ conditions["cross"] = data.get("mode", record.get("mode", "same")) == "cross"
61
+ return {"shape_reference": data["shape_reference"], "conditions": conditions,
62
+ "record": {"start": a, "end": b, "sigma": self.sigmas[i],
63
+ "noisy": chunk["states"][i], "target": chunk["states"][-1],
64
+ "prefix": data["generated"][:, :, :a],
65
+ "gt_x0": data.get("gt_latents")},
66
+ "clip_id": data["clip_id"]}
viddub/causal/verify_checkpoint.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verify real Wan-Dub weights before spending GPU time on causal training.
2
+
3
+ Checks original bidirectional parity, future-video isolation, cache/no-cache
4
+ parity, and one AR backward pass. Does not step an optimizer or save weights.
5
+ """
6
+ import argparse
7
+ from pathlib import Path
8
+ import time
9
+
10
+ import torch
11
+
12
+ from .runtime import (atomic_json, autocast, checkpoint_paths, load_components,
13
+ local_imports, make_dataset, prepare_clip, read_config)
14
+ from .model import HistoryCache
15
+ from .training.stages import ARObjective
16
+ from .sampling import commit, predict_chunk
17
+
18
+
19
+ def main():
20
+ p = argparse.ArgumentParser(description=__doc__)
21
+ p.add_argument("--config", required=True)
22
+ p.add_argument("--checkpoint-dir", required=True)
23
+ p.add_argument("--step", type=int, required=True)
24
+ p.add_argument("--output", required=True, help="New JSON report path")
25
+ p.add_argument("--clip-index", type=int, default=0)
26
+ a = p.parse_args()
27
+ cfg = read_config(a.config)
28
+ paths = checkpoint_paths(a.checkpoint_dir, a.step)
29
+ if Path(a.output).exists():
30
+ raise FileExistsError(a.output)
31
+ if not torch.cuda.is_available():
32
+ raise RuntimeError("Real checkpoint verification requires CUDA")
33
+ local_imports()
34
+ from viddub.full_context.runtime import ZeroLatents, predict
35
+ data, _ = make_dataset(cfg)
36
+ device = torch.device("cuda")
37
+ pipe, whisper, model = load_components(paths, cfg, device)
38
+ model.eval().requires_grad_(False)
39
+ x0, c = prepare_clip(data[a.clip_index], pipe, whisper, ZeroLatents(pipe), cfg)
40
+ started = time.monotonic()
41
+ # Native reference uses the project's own adapter ordering and attention backend.
42
+ torch.manual_seed(cfg["seed"])
43
+ xt = torch.randn_like(x0)
44
+ tol = 0.04 if cfg["precision"] == "bf16" else 0.0003
45
+ report = {"status": "running", "precision": cfg["precision"], "clip_id": data[a.clip_index]["clip_id"],
46
+ "tolerance": {"atol": tol, "rtol": tol}, "checks": {}}
47
+ try:
48
+ with torch.no_grad(), autocast(device, cfg["precision"]):
49
+ tok = model.adapter.tokens(c["whisper_hs"], cfg["num_frames"], cfg["num_ref"],
50
+ torch.tensor([False], device=device))
51
+ native = predict(pipe, model.adapter, xt, xt.new_tensor([500]), c["context"], c["vace_context"], tok)
52
+ model.causal = False
53
+ actual = model(xt, 500, c["context"], c["vace_context"], c["whisper_hs"], c["num_frames"])
54
+ report["checks"]["bidirectional_max_abs"] = float((actual - native).abs().max())
55
+ torch.testing.assert_close(actual, native, atol=tol, rtol=tol)
56
+ model.causal = True
57
+ full = model(xt, 500, c["context"], c["vace_context"], c["whisper_hs"], c["num_frames"])
58
+ end = model.config.video_blocks(xt.shape[2])[0][1]
59
+ changed, vc = xt.clone(), c["vace_context"].clone()
60
+ changed[:, :, end:] += 20
61
+ vc[:, :, end:] -= 20
62
+ future = model(changed, 500, c["context"], vc, c["whisper_hs"], c["num_frames"])
63
+ report["checks"]["future_max_abs"] = float((full[:, :, :end] - future[:, :, :end]).abs().max())
64
+ torch.testing.assert_close(full[:, :, :end], future[:, :, :end], atol=tol, rtol=tol)
65
+ cache = HistoryCache()
66
+ n_ref = cfg["num_ref"]
67
+ if n_ref:
68
+ commit(model, x0[:, :, :n_ref], c, 0, cache)
69
+ errors = []
70
+ for start, end in model.config.video_blocks(x0.shape[2]):
71
+ expected = predict_chunk(model, xt[:, :, start:end], 0.5, x0[:, :, :start], c, start)
72
+ actual = predict_chunk(model, xt[:, :, start:end], 0.5, x0[:, :, :start], c, start, cache)
73
+ errors.append(float((actual - expected).abs().max()))
74
+ torch.testing.assert_close(actual, expected, atol=tol, rtol=tol)
75
+ commit(model, x0[:, :, start:end], c, start, cache)
76
+ report["checks"]["cache_max_abs"] = max(errors)
77
+ del cache, native, actual, full, changed, vc, future
78
+ model.requires_grad_(True).train()
79
+ with autocast(device, cfg["precision"]):
80
+ loss, _ = ARObjective(model, shift=cfg["shift"])(x0, c, chunk_index=0, sigma=0.5)
81
+ loss.backward()
82
+ for name in ("dit", "vace", "adapter"):
83
+ grads = [p.grad for p in getattr(model, name).parameters() if p.grad is not None]
84
+ if not grads or not all(bool(torch.isfinite(g).all()) for g in grads):
85
+ raise AssertionError(f"Missing/nonfinite {name} gradients")
86
+ report["checks"][name + "_grad_abs_sum"] = sum(float(g.abs().sum()) for g in grads)
87
+ report["checks"]["ar_loss"] = float(loss.detach())
88
+ report["status"] = "passed"
89
+ except Exception as error:
90
+ report["status"] = "failed"
91
+ report["error"] = repr(error)
92
+ raise
93
+ finally:
94
+ report["elapsed_seconds"] = time.monotonic() - started
95
+ report["peak_allocated_gib"] = torch.cuda.max_memory_allocated() / 2**30
96
+ Path(a.output).parent.mkdir(parents=True, exist_ok=True)
97
+ atomic_json(a.output, report)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
viddub/foundation/NOTICE ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Reduced DiffSynth-Studio Wan2.1-VACE foundation
2
+
3
+ Derived from DiffSynth-Studio commit
4
+ 7686e54d41d25c0e8ed5f1318acc23b6bb832654 under the Apache License 2.0.
5
+
6
+ Retained components: Wan DiT, VACE, Wan VAE, Wan T5/tokenizer, gradient
7
+ checkpointing, and the Wan flow-matching schedule. Unrelated model families and
8
+ optional Wan methods were removed. VidDub adds frame-local audio
9
+ cross-attention integration and causal streaming VAE helpers.
10
+
11
+ The Apache-2.0 license text is available at
12
+ ../dependencies/licenses/Apache-2.0.txt.
viddub/foundation/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Minimal Wan2.1-VACE backend used by VidDub.
2
+
3
+ The retained Wan model code is derived from DiffSynth-Studio commit
4
+ 7686e54d41d25c0e8ed5f1318acc23b6bb832654 under Apache-2.0. Unrelated model
5
+ families and Wan extensions are intentionally excluded from this fork.
6
+ """
7
+
8
+ from .pipelines.wan_video import ModelConfig, WanVideoPipeline
viddub/foundation/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .gradient import gradient_checkpoint_forward
viddub/foundation/core/gradient/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .gradient_checkpoint import gradient_checkpoint_forward