Spaces:
Running on Zero
Running on Zero
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +6 -0
- README.md +55 -7
- app.py +337 -0
- assets/empty_context.pt +3 -0
- data/__init__.py +1 -0
- data/common.py +38 -0
- data/s01_analyze.py +182 -0
- data/s02_make_clips.py +198 -0
- examples/man_laughing_audio.wav +3 -0
- examples/man_laughing_video.mp4 +3 -0
- examples/man_smiling_studio_audio.wav +3 -0
- examples/man_smiling_studio_video.mp4 +3 -0
- examples/woman_laughing_studio_audio.wav +3 -0
- examples/woman_laughing_studio_video.mp4 +3 -0
- requirements.txt +18 -0
- viddub/__init__.py +3 -0
- viddub/causal/__init__.py +1 -0
- viddub/causal/art_alignment.py +44 -0
- viddub/causal/cf/NOTICE +18 -0
- viddub/causal/cf/__init__.py +1 -0
- viddub/causal/cf/consistency.py +123 -0
- viddub/causal/cf/dmd.py +98 -0
- viddub/causal/cf/sampling.py +54 -0
- viddub/causal/cf/train.py +168 -0
- viddub/causal/cf/upstream.py +96 -0
- viddub/causal/infer.py +100 -0
- viddub/causal/make_trajectories.py +121 -0
- viddub/causal/model.py +304 -0
- viddub/causal/pretrain_fake.py +118 -0
- viddub/causal/quality.py +167 -0
- viddub/causal/runtime.py +343 -0
- viddub/causal/sampling.py +298 -0
- viddub/causal/train.py +6 -0
- viddub/causal/train_ar.py +6 -0
- viddub/causal/train_dmd.py +6 -0
- viddub/causal/train_ode.py +6 -0
- viddub/causal/training/__init__.py +3 -0
- viddub/causal/training/ar.py +146 -0
- viddub/causal/training/common.py +61 -0
- viddub/causal/training/dmd.py +133 -0
- viddub/causal/training/engine.py +568 -0
- viddub/causal/training/fake.py +34 -0
- viddub/causal/training/ode.py +72 -0
- viddub/causal/training/stages.py +5 -0
- viddub/causal/trajectories.py +66 -0
- viddub/causal/verify_checkpoint.py +101 -0
- viddub/foundation/NOTICE +12 -0
- viddub/foundation/__init__.py +8 -0
- viddub/foundation/core/__init__.py +1 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.31.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|