File size: 17,700 Bytes
1811aab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
"""Pre-encode rendered clips into training windows, exactly as the released CWM inference builds them.

Per clip, in paths.CACHE/<clip>/:
    anchor.safetensors        latents_ref_000_image      Picture 1 (frame 0, short edge 2048)
    first_frame.safetensors   first_frame [24,1,48,84]   frame 0 at 1344x768 (window-0 keyframe)
    wNN.visual.safetensors    target [24,37,48,84]       VAE latent of the window's 124 GT frames
                              duv [24,37,12,21]          coarse-proxy reference latent
                              prefix [24,10,48,84]       (NN>0) GT frames 0..33 re-encoded (Retake34)
    wNN.text.safetensors      hidden [L,5120] bf16, tags [L]   Qwen conditioning (w0 / wn system prompt)
    stream.safetensors        target [24,5K,48,84]       the whole clip as one latent stream: its first K
                                                         17-frame VAE clips (918 of 934 frames), 5 latents each
                              duv [24,5K,12,21]          the coarse proxy, encoded the same way
    anchor_cMM.png / .safetensors, static_cMM.text.safetensors
                              per start frame m (rtcwm.data.anchors): its Picture 1 latent and the Qwen states
                              of its static caption (that frame and its proxy only, w0 system prompt)
paths.CACHE/silence_audio.safetensors   audio [32,2,207]

    python -m rtcwm.train.preencode --stage visual|text|stream|static_text [--shard] [--reverse] [CLIP_DIR ...]
Without CLIP_DIRs every clip of the dataset with a complete prompt set is taken; --shard splits them
over SLURM tasks (task i takes clips i, i+n, ...); --reverse walks from the end (a helper job meeting
the main one in the middle). Existing files are skipped; writes are atomic per process.
"""
from __future__ import annotations

import argparse
import gc
import os
import time
from pathlib import Path

import av
import numpy as np
import torch
from safetensors.torch import save_file

from cwm_h3_inference.cache import _load_anchor
from cwm_h3_inference.config import _canonical_caption
from cwm_h3_inference.constants import (AUDIO_LATENT_SHAPE, FPS, OVERLAP_FRAMES, QWEN_OFFSETS, REFERENCE_LATENT_SHAPE,
                                        STRIDE_FRAMES, TARGET_HEIGHT, TARGET_WIDTH, VIDEO_LATENT_SHAPE,
                                        VIDEO_PREFIX_LATENTS, WINDOW_AUDIO_SAMPLES, WINDOW_FRAMES)
from cwm_h3_inference.duv import load_duv_window
from cwm_h3_inference.presentation import build_ref2va_presentation

from .. import paths
from ..data.clip import has_prompts, n_windows, qc_pass, read_meta


def windows_of(clip: Path) -> int:
    return min(n_windows(read_meta(clip)["frames"]), int(os.environ.get("MAX_WINDOWS", 99)))


def read_frames(path: Path) -> np.ndarray:
    with av.open(str(path)) as c:
        return np.stack([f.to_ndarray(format="rgb24") for f in c.decode(video=0)])


def atomic_save(path: Path, tensors: dict, meta: dict | None = None):
    tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")     # unique: a helper job may write the same file
    save_file({k: v.contiguous() for k, v in tensors.items()}, str(tmp), metadata=meta or {})
    tmp.replace(path)


def stage_visual(clips):
    from musubi_tuner.minimax_h3.audio_vae import load_audio_vae
    from musubi_tuner.minimax_h3.video_vae import encode_video_condition, load_video_vae
    from musubi_tuner.minimax_h3_generate_video import _prepare_pixels
    from PIL import Image

    dev = torch.device("cuda:0")
    vae = load_video_vae(paths.VIDEO_VAE, device=dev, dtype=torch.float16, disable_mmap=False).eval().requires_grad_(False)
    sil = paths.CACHE / "silence_audio.safetensors"
    if not sil.exists():
        avae = load_audio_vae(paths.AUDIO_VAE, device=dev, dtype=torch.float32, disable_mmap=False).eval()
        with torch.inference_mode():
            a = avae.encode(torch.zeros(1, 2, WINDOW_AUDIO_SAMPLES, device=dev)).float().cpu()[0]
        assert tuple(a.shape) == AUDIO_LATENT_SHAPE[1:], a.shape
        paths.CACHE.mkdir(parents=True, exist_ok=True)
        atomic_save(sil, {"audio": a})
        del avae

    def enc(frames_uint8: np.ndarray) -> torch.Tensor:
        px = _prepare_pixels(torch.from_numpy(np.ascontiguousarray(frames_uint8))).to(dev, torch.float16)
        with torch.inference_mode():
            return vae.encode(px).float().cpu()[0]

    for clip in clips:
        out = paths.CACHE / clip.name
        out.mkdir(parents=True, exist_ok=True)
        W = windows_of(clip)
        if all((out / f"w{w:02d}.visual.safetensors").exists() for w in range(W)) and (out / "anchor.safetensors").exists():
            continue
        frames = read_frames(clip / "rgb.mp4")
        assert frames.shape[1:3] == (TARGET_HEIGHT, TARGET_WIDTH), frames.shape
        anchor_png = out / "first_frame.png"
        Image.fromarray(frames[0]).save(anchor_png)
        if not (out / "anchor.safetensors").exists():
            big = _load_anchor(anchor_png)
            px = _prepare_pixels(torch.from_numpy(big).unsqueeze(0)).to(dev, torch.float16)
            with torch.inference_mode():
                lat = encode_video_condition(vae, px)[0].cpu().float()
            atomic_save(out / "anchor.safetensors", {"latents_ref_000_image": lat})
            ff = enc(frames[:1])
            assert ff.shape[1] == 1
            atomic_save(out / "first_frame.safetensors", {"first_frame": ff})
        for w in range(W):
            p = out / f"w{w:02d}.visual.safetensors"
            if p.exists():
                continue
            s = STRIDE_FRAMES * w
            target = enc(frames[s:s + WINDOW_FRAMES])
            assert tuple(target.shape) == VIDEO_LATENT_SHAPE[1:], target.shape
            duv = load_duv_window(clip / "conditions_coarse", s, for_qwen=False)
            dpx = _prepare_pixels(torch.from_numpy(duv.copy())).to(dev, torch.float16)
            with torch.inference_mode():
                ref = encode_video_condition(vae, dpx)[0].cpu().float()
            assert tuple(ref.shape) == REFERENCE_LATENT_SHAPE, ref.shape
            t = {"target": target, "duv": ref}
            if w > 0:
                tail = frames[s:s + OVERLAP_FRAMES]
                padded = np.concatenate([tail, np.repeat(tail[-1:], WINDOW_FRAMES - OVERLAP_FRAMES, 0)], 0)
                t["prefix"] = enc(padded)[:, :VIDEO_PREFIX_LATENTS]
            atomic_save(p, t)
            gc.collect(); torch.cuda.empty_cache()
        print("visual", clip.name, W, "windows", flush=True)


def stage_text(clips):
    from musubi_tuner.minimax_h3.media import H3AudioSource, H3Record, H3Reference
    from musubi_tuner.minimax_h3.text_encoder import (H3TextVisual, build_presentation, encode_h3_presentation,
                                                       load_h3_processor, load_h3_text_encoder)
    dev = torch.device("cuda:0")
    proc = load_h3_processor(str(paths.PROCESSOR))
    model = load_h3_text_encoder(str(paths.TEXT_ENCODER), processor_path=str(paths.PROCESSOR), device=dev,
                                 dtype=torch.bfloat16).eval()
    for clip in clips:
        out = paths.CACHE / clip.name
        anchor_png = out / "first_frame.png"
        anchor = torch.from_numpy(np.ascontiguousarray(_load_anchor(anchor_png))).unsqueeze(0)
        cond = clip / "conditions_coarse"
        for w in range(windows_of(clip)):
            p = out / f"w{w:02d}.text.safetensors"
            if p.exists():
                continue
            caption = _canonical_caption((clip / "prompts" / f"{w:03d}.txt").read_text(), f"{clip.name}/w{w}")
            s = STRIDE_FRAMES * w
            duv = torch.from_numpy(np.ascontiguousarray(load_duv_window(cond, s, for_qwen=True)))
            image_key, video_key = anchor_png, cond / f"{s:06d}.semantic_id.png"
            placeholder = cond / ".h3-presentation-placeholder"
            record = H3Record(video_path=placeholder, caption=caption,
                              target_audio=H3AudioSource(path=placeholder, embedded=False),
                              references=(H3Reference(type="image", path=image_key),
                                          H3Reference(type="video", path=video_key, duration_seconds=WINDOW_FRAMES / FPS)),
                              jsonl_line=0)
            plain = build_presentation(record, "ref2va", {image_key: H3TextVisual(anchor),
                                                          video_key: H3TextVisual(duv, tuple(o / FPS for o in QWEN_OFFSETS))})
            pres = build_ref2va_presentation(proc, plain, w, caption)        # w0 / wn system prompt by index
            with torch.inference_mode():
                hidden, tags = encode_h3_presentation(proc, model, pres)
            atomic_save(p, {"hidden": hidden.to("cpu", torch.bfloat16), "tags": tags.to("cpu", torch.int64)},
                        {"system_prompt": "w0" if w == 0 else "wn"})
            gc.collect(); torch.cuda.empty_cache()
        print("text", clip.name, flush=True)


CLIP_FRAMES = 17                       # the video VAE encodes independent 17-frame clips into 5 latents each


def stage_stream(clips):
    """The whole clip as a continuous latent stream (streaming generation). The VAE encodes 17-frame clips
    independently, so the stream is the concatenation of per-clip latents; one dummy clip is appended
    because the encoder drops the last 3 latents of whatever it is given."""
    from musubi_tuner.minimax_h3.video_vae import encode_video_condition, load_video_vae
    from musubi_tuner.minimax_h3_generate_video import _prepare_pixels
    from cwm_h3_inference.duv import load_duv_frame

    dev = torch.device("cuda:0")
    vae = load_video_vae(paths.VIDEO_VAE, device=dev, dtype=torch.float16, disable_mmap=False).eval().requires_grad_(False)

    def padded(frames: np.ndarray) -> torch.Tensor:
        frames = np.concatenate([frames, np.repeat(frames[-1:], CLIP_FRAMES, 0)], 0)
        return _prepare_pixels(torch.from_numpy(np.ascontiguousarray(frames))).to(dev, torch.float16)

    for clip in clips:
        p = paths.CACHE / clip.name / "stream.safetensors"
        if p.exists():
            continue
        frames = read_frames(clip / "rgb.mp4")
        k = len(frames) // CLIP_FRAMES
        n = k * CLIP_FRAMES
        duv = np.stack([load_duv_frame(clip / "conditions_coarse", i, for_qwen=False) for i in range(n)])
        with torch.inference_mode():
            target = vae.encode(padded(frames[:n])).float().cpu()[0, :, :5 * k]
            ref = encode_video_condition(vae, padded(duv))[0].float().cpu()[:, :5 * k]
        assert target.shape[1] == ref.shape[1] == 5 * k, (target.shape, ref.shape)
        p.parent.mkdir(parents=True, exist_ok=True)
        atomic_save(p, {"target": target, "duv": ref}, {"clip_frames": str(CLIP_FRAMES), "vae_clips": str(k)})
        gc.collect(); torch.cuda.empty_cache()
        print("stream", clip.name, k, "VAE clips", flush=True)


def stage_static_text(clips, follow_job: str = "", res: str = "native"):
    """Per start frame (rtcwm.data.anchors): the Picture 1 latent of that frame (short edge 2048, as the
    released CWM encodes its anchor) and the Qwen states of its static caption. The text must not carry
    anything from later frames: Qwen sees Picture 1 and, as the Ref2VA video reference, the proxy of the
    start frame repeated (not a window's proxy video), with the first-window system prompt."""
    from musubi_tuner.minimax_h3.text_encoder import load_h3_processor, load_h3_text_encoder
    from musubi_tuner.minimax_h3.video_vae import load_video_vae
    dev = torch.device("cuda:0")
    vae = load_video_vae(paths.VIDEO_VAE, device=dev, dtype=torch.float16, disable_mmap=False).eval().requires_grad_(False)
    proc = load_h3_processor(str(paths.PROCESSOR))
    model = load_h3_text_encoder(str(paths.TEXT_ENCODER), processor_path=str(paths.PROCESSOR), device=dev,
                                 dtype=torch.bfloat16).eval()
    while True:
        running = bool(follow_job) and _job_running(follow_job)      # captions still being written?
        n = _static_text_pass(clips, vae, proc, model, dev, res)
        if not running:
            break
        if not n:
            time.sleep(60)


def _static_text_pass(clips, vae, proc, model, dev, res: str = "native") -> int:
    from PIL import Image
    from musubi_tuner.minimax_h3.video_vae import encode_video_condition
    from musubi_tuner.minimax_h3_generate_video import _prepare_pixels
    from ..data.anchors import anchor_frame, cache_paths, caption_path, clip_anchors
    done = 0
    for clip in clips:
        todo = [m for m in clip_anchors(clip) if caption_path(clip, m).exists() and not cache_paths(clip.name, m, res)["text"].exists()]
        if not todo:
            continue
        frames = read_frames(clip / "rgb.mp4")
        (paths.CACHE / clip.name).mkdir(parents=True, exist_ok=True)
        for m in todo:
            cp = cache_paths(clip.name, m, res)
            Image.fromarray(frames[anchor_frame(m)]).save(cp["png"])
            big = _load_anchor(cp["png"]) if res == "2048" else np.ascontiguousarray(frames[anchor_frame(m)])
            with torch.inference_mode():
                lat = encode_video_condition(vae, _prepare_pixels(torch.from_numpy(big).unsqueeze(0)).to(dev, torch.float16))[0].cpu().float()
            atomic_save(cp["latent"], {"latents_ref_000_image": lat})
            caption = _canonical_caption(caption_path(clip, m).read_text(), f"{clip.name}/static_c{m:02d}")
            hidden, tags = static_text_states(proc, model, big, cp["png"], clip / "conditions_coarse", anchor_frame(m), caption)
            atomic_save(cp["text"], {"hidden": hidden.to("cpu", torch.bfloat16), "tags": tags.to("cpu", torch.int64)},
                        {"system_prompt": "w0", "duv": "start frame repeated", "start_chunk": str(m)})
            gc.collect(); torch.cuda.empty_cache()
        done += len(todo)
        print("static_text", clip.name, len(todo), "start frames", flush=True)
    return done


def static_text_states(proc, model, picture1: np.ndarray, picture1_png: Path, cond: Path, frame: int, caption: str):
    """Qwen states [L,5120] and token tags of a static caption: Qwen sees Picture 1 ([H,W,3] uint8, saved at
    picture1_png) and, as the Ref2VA video reference, the proxy of `frame` repeated -- nothing of later
    frames -- with the first-window system prompt."""
    from musubi_tuner.minimax_h3.media import H3AudioSource, H3Record, H3Reference
    from musubi_tuner.minimax_h3.text_encoder import H3TextVisual, build_presentation, encode_h3_presentation
    from cwm_h3_inference.duv import load_duv_frame
    anchor = torch.from_numpy(np.ascontiguousarray(picture1)).unsqueeze(0)
    f0 = load_duv_frame(cond, frame, for_qwen=True)
    duv = torch.from_numpy(np.ascontiguousarray(np.stack([f0] * len(QWEN_OFFSETS))))
    video_key = cond / f"{frame:06d}.semantic_id.png"
    placeholder = cond / ".h3-presentation-placeholder"
    record = H3Record(video_path=placeholder, caption=caption, target_audio=H3AudioSource(path=placeholder, embedded=False),
                      references=(H3Reference(type="image", path=picture1_png),
                                  H3Reference(type="video", path=video_key, duration_seconds=WINDOW_FRAMES / FPS)),
                      jsonl_line=0)
    plain = build_presentation(record, "ref2va", {picture1_png: H3TextVisual(anchor),
                                                  video_key: H3TextVisual(duv, tuple(o / FPS for o in QWEN_OFFSETS))})
    with torch.inference_mode():
        return encode_h3_presentation(proc, model, build_ref2va_presentation(proc, plain, 0, caption))


def _job_running(job: str) -> bool:
    import subprocess
    return bool(subprocess.run(["squeue", "-h", "-j", job], capture_output=True, text=True).stdout.strip())


def text_done(clip: Path) -> bool:
    return all((paths.CACHE / clip.name / f"w{w:02d}.text.safetensors").exists() for w in range(windows_of(clip)))


def select_clips(shard: bool, reverse: bool, only_incomplete: bool):
    clips = [d for d in sorted(paths.DATASET.glob("clip_*")) if d.is_dir() and qc_pass(d) and has_prompts(d)]
    if only_incomplete:
        clips = [c for c in clips if not text_done(c)]
    if reverse:
        clips = clips[::-1]
    if shard:
        i, n = int(os.environ["SLURM_PROCID"]), int(os.environ["SLURM_NTASKS"])
        clips = clips[i::n]
    return clips


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--stage", choices=["visual", "text", "both", "stream", "static_text"], required=True)
    ap.add_argument("clips", nargs="*", type=Path)
    ap.add_argument("--shard", action="store_true", help="take this SLURM task's share of the dataset")
    ap.add_argument("--reverse", action="store_true")
    ap.add_argument("--only-incomplete", action="store_true", help="skip clips whose text stage is done")
    ap.add_argument("--follow-job", default="", help="static_text: keep encoding new captions while this SLURM job runs")
    ap.add_argument("--picture1", choices=["native", "2048"], default="native", help="static_text: Picture 1 resolution")
    a = ap.parse_args()
    clips = [c.resolve() for c in a.clips] or select_clips(a.shard, a.reverse, a.only_incomplete)
    print(f"{len(clips)} clips", flush=True)
    if a.stage in ("visual", "both"):
        stage_visual(clips)
    if a.stage in ("text", "both"):
        stage_text(clips)
    if a.stage == "stream":
        stage_stream(clips)
    if a.stage == "static_text":
        stage_static_text(clips, a.follow_job, a.picture1)


if __name__ == "__main__":
    main()