File size: 41,522 Bytes
bcdd8fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
"""gc25d 模型:Wan2.2-TI2V-5B DiT + 逐像素数字语义图条件(SPEC §6)。

    x = dit.patch_embedding(latents) + dit.gc_sem_cond(sem_ids)      # [b, 3072, 21, H/32, W/32]

形状对分辨率通用(全部从输入张量取);下面的数是 832×480(geometry 默认 GC_RES)的例子,1280×704 时
H/4×W/4 = 176×320、latent 44×80、token 22×40。
`gc_sem_cond`(SemCondEncoder)把 81×480×832 的 uint8 语义 id 视频压到 latent 网格:
  1. LUT:raw 0..6 → 0..6,10 → 7,11 → 8,其余(含 UNKNOWN=255)→ 9(K=10);
  2. 逐类 4×4 平均池化 one-hot → [b, K, 81, 120, 208](一类一类算,不物化全分辨率 one-hot);
  3. 时间分组同 VAE:帧 0 重复 4 次 + 帧 1..80 → [b, 4K, 21, 120, 208];
  4. pixel-unshuffle ×4 → [b, 64K=640, 21, 30, 52];
  5. Conv3d(640, 512, 1) → SiLU → Conv3d(512, 512, (1,3,3), pad (0,1,1)) → SiLU →
     零初始化 Conv3d(512, dim, (1,2,2), stride (1,2,2))。
刚装上时最后一层权重与偏置全 0 ⇒ 输出恒为 0 ⇒ 前向与原模型逐位相同(tests/test_model.py::test_a)。

`gc_forward` 是 `actionrope.arope.arope_forward` 普通路径(offset_px=None, mask_input=None, arm=None)的照抄,
只在 patch embedding 后多加一项 sem 条件;block / RoPE 查表 / 逐 cell 文本 / 逐 token timestep 全部直接
import `actionrope.arope` 的函数,不复制。

实现上的取舍(SPEC 没写死、这里定下来的约定,infer 侧必须一致 —— 它直接 import 本文件即可)
------------------------------------------------------------------------------------------
* 通道顺序:时间分组后通道 = k·4 + τ(k 类别,τ 组内帧 0..3),unshuffle 后通道 = (k·4 + τ)·16 + p·4 + q
  (p 行内偏移、q 列内偏移),即 einops "b k (f t) (h p) (w q) -> b (k t p q) f h w"。
* 第 9 类("其它 / UNKNOWN")的池化值 = 1 − Σ_{k<9} 池化值。池化值都是 1/16 的整数倍,fp32 下精确,
  所以与逐像素判断"不属于前 9 类"再池化逐位相同,而且省一次全分辨率比较。
* one-hot 池化不依赖参数,放在 no_grad 里算([b,10,81,120,208] bf16 ≈ 40 MB/样本,1280×704 ≈ 2.26×),再 cast 到模块 dtype。
* 前两层 conv 用 PyTorch 默认初始化,但在 fork_rng + 固定种子下建:各 rank、各次运行初始化相同,
  不吃全局 RNG(DeepSpeed 也会从 rank 0 广播,这里是双保险,且单卡 / 多卡结果可比)。

白模条件(cond_type="whitebox",2026-10-12,GameCrafter-Parkour;见文件中部"白模条件"一节)
--------------------------------------------------------------------------------------
另一种控制视频:pk 渲染的白模(灰度,像素对齐 RGB,255 留作 UNKNOWN)。模块挂在**另一个名字** `dit.gc_wb_cond`
(WhiteboxCondEncoder),结构与 SemCondEncoder 同构(逐帧 4×4 块特征 → 同一套 4 帧分组 + unshuffle → 1×1 → SiLU →
1×3×3 → SiLU → 零初始化 patch-stride conv),只有输入特征不同(5 通道:RGB 块均值、亮度块内标准差、已知像素比例)。
默认一律是 "ids":不传 cond_type、dit 上没有 gc_wb_cond 时,所有函数的行为与加白模之前逐位相同。
"""
from __future__ import annotations

import functools
import math
import os

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange
from safetensors import safe_open
from safetensors.torch import load_file

from actionrope.arope import (
    _block_forward,
    _embed_context,
    _per_token_timestep,
    build_world_freqs,
)
from diffsynth.core.gradient import gradient_checkpoint_forward
from diffsynth.models.wan_video_dit import WanModel, modulate, rope_apply, sinusoidal_embedding_1d

UNKNOWN_ID = 255
SEM_COND_NAME = "gc_sem_cond"
# raw id → 类别下标(K=10)。0..6 原样,10 玩家 → 7,11 boss → 8,其余(含 255)→ 9
RAW_TO_INDEX = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 10: 7, 11: 8}
NUM_CLASSES = 10
OTHER_INDEX = 9

# ----------------------------------------------------------------------------
# 词表(maliang / v10,2026-10-22)
# ----------------------------------------------------------------------------
# "ids"   = 上面的 K=10(v3/v4/v8 的 gc25d,逐位不变)。
# "parts" = v10 胶囊白模角色按部位拆开(数值代理,不过 VAE):玩家 / Boss 各 6 个部位 raw id,K=20。
#   raw 10 / 11 = 玩家 / Boss 躯干(pelvis_spine, spine_neck, head),保持与 "ids" 相同的值;
#   raw 12..16 = 玩家 左臂 / 右臂 / 左腿 / 右腿 / 武器,raw 17..21 = Boss 同序;左右按骨骼名(l_* / r_*),不按屏幕。
#   部位由代码对 json['v9']['characters'] 的胶囊逐像素射线求交得到(capsules.py),推理时代码世界用同一函数画。
#   collapse_parts():12..16 → 10,17..21 → 11,即退回 "ids" 词表。
PART_NAMES = ("core", "l_arm", "r_arm", "l_leg", "r_leg", "weapon")
PLAYER_PART_IDS = (10, 12, 13, 14, 15, 16)
BOSS_PART_IDS = (11, 17, 18, 19, 20, 21)
RAW_TO_INDEX_PARTS = {**{i: i for i in range(7)},
                      **{raw: 7 + j for j, raw in enumerate(PLAYER_PART_IDS)},
                      **{raw: 13 + j for j, raw in enumerate(BOSS_PART_IDS)}}
VOCABS = {"ids": (RAW_TO_INDEX, NUM_CLASSES), "parts": (RAW_TO_INDEX_PARTS, 20)}
DEFAULT_VOCAB = "ids"
assert all(len(m) == k - 1 and sorted(m.values()) == list(range(k - 1)) for m, k in VOCABS.values())


def vocab_spec(vocab: str) -> tuple[dict, int]:
    if vocab not in VOCABS:
        raise ValueError(f"vocab 只能是 {tuple(VOCABS)},得到 {vocab!r}")
    return VOCABS[vocab]


def valid_raw_ids(vocab: str) -> frozenset:
    """条件图里允许出现的 raw 值(含 UNKNOWN)。其它值模型会静默当成 "other",prep / 代码世界应断言。"""
    return frozenset(vocab_spec(vocab)[0]) | {UNKNOWN_ID}


def collapse_parts(ids):
    """parts 词表的 raw id → ids 词表(12..16 → 10,17..21 → 11);numpy / torch 都行,返回同类型新数组。"""
    lut = np.arange(256, dtype=np.uint8)
    lut[list(PLAYER_PART_IDS)] = 10
    lut[list(BOSS_PART_IDS)] = 11
    if isinstance(ids, torch.Tensor):
        return torch.from_numpy(lut).to(ids.device)[ids.long()]
    return lut[ids]


def vocab_from_in_channels(in_channels: int) -> str:
    for name, (_, k) in VOCABS.items():
        if k * T_GROUP * UNSHUFFLE * UNSHUFFLE == in_channels:
            return name
    raise ValueError(f"gc_sem_cond.proj_in 输入通道 {in_channels} 不对应任何词表 {tuple(VOCABS)}")
# 空间:池化 4× + unshuffle 4× = 16× = VAE 空间压缩;时间:VAE 4×(帧 0 单独成组,这里重复 4 次补齐)
POOL = 4
UNSHUFFLE = 4
T_GROUP = 4
HIDDEN = 512
SEM_COND_INIT_SEED = 20261010


def _lut(vocab: str = DEFAULT_VOCAB) -> torch.Tensor:
    m, k = vocab_spec(vocab)
    lut = torch.full((256,), k - 1, dtype=torch.uint8)
    for raw, i in m.items():
        lut[raw] = i
    return lut


def sem_ids_to_index(sem_ids: torch.Tensor, vocab: str = DEFAULT_VOCAB) -> torch.Tensor:
    """raw id → 类别下标(uint8,同形状)。只给测试 / 可视化用;前向不走这条(见 pooled_onehot)。"""
    ids = sem_ids.long()
    if ids.min() < 0 or ids.max() > 255:
        raise ValueError(f"sem_ids 超出 uint8 范围:[{int(ids.min())}, {int(ids.max())}]")
    return _lut(vocab).to(sem_ids.device)[ids]


@torch.no_grad()
def pooled_onehot(sem_ids: torch.Tensor, dtype=torch.float32, vocab: str = DEFAULT_VOCAB) -> torch.Tensor:
    """sem_ids [b, T, H, W](uint8 或整数)→ 逐类 4×4 平均池化 one-hot [b, K, T, H/4, W/4](dtype)。

    一类一类算:每次只有一张 [b·T, 1, H, W] 的 0/1 图(fp32 ≈ 128 MB/样本,用完即弃),
    从不物化 [b, K, T, H, W] 的全分辨率 one-hot。"""
    assert sem_ids.dim() == 4, f"sem_ids 应为 [b, T, H, W],得到 {tuple(sem_ids.shape)}"
    if sem_ids.dtype.is_floating_point or sem_ids.dtype == torch.bool:
        raise TypeError(f"sem_ids 必须是整数 id(uint8 / long),得到 {sem_ids.dtype}")
    b, T, H, W = sem_ids.shape
    assert H % POOL == 0 and W % POOL == 0, (H, W)
    flat = sem_ids.reshape(b * T, 1, H, W)
    raw_to_index, num_classes = vocab_spec(vocab)
    raw_of = {k: raw for raw, k in raw_to_index.items()}
    planes = []
    for k in range(num_classes - 1):
        m = (flat == raw_of[k]).to(torch.float32)
        planes.append(F.avg_pool2d(m, POOL).view(b, T, H // POOL, W // POOL))
        del m
    # 最后一类("other",ids 词表是第 9 类)= 1 − 其余之和(池化值是 1/16 的整数倍,fp32 精确,见文件头)
    other = 1.0 - torch.stack(planes, 0).sum(0)
    planes.append(other)
    out = torch.stack(planes, 1)  # [b, K, T, H/4, W/4]
    return out.to(dtype)


def group_and_unshuffle(pooled: torch.Tensor) -> torch.Tensor:
    """[b, K, T=4f−3, h4, w4] → 帧 0 重复 4 次后按 4 帧分组 → pixel-unshuffle ×4 → [b, K·4·16, f, h4/4, w4/4]。"""
    b, K, T, h4, w4 = pooled.shape
    assert (T - 1) % T_GROUP == 0, f"帧数应为 4k+1,得到 {T}"
    x = torch.cat([pooled[:, :, :1].expand(b, K, T_GROUP - 1, h4, w4), pooled], dim=2)  # [b, K, 4f, h4, w4]
    return rearrange(x, "b k (f t) (h p) (w q) -> b (k t p q) f h w", t=T_GROUP, p=UNSHUFFLE, q=UNSHUFFLE)


class SemCondEncoder(nn.Module):
    """语义 id 视频 → patch-embedding 空间的加性条件。见文件头。"""

    def __init__(self, dim: int, patch_size=(1, 2, 2), hidden: int = HIDDEN, vocab: str = DEFAULT_VOCAB):
        super().__init__()
        _, num_classes = vocab_spec(vocab)
        self.vocab = vocab
        self.num_classes = num_classes
        self.in_channels = num_classes * T_GROUP * UNSHUFFLE * UNSHUFFLE  # ids 640 / parts 1280
        self.dim = dim
        self.proj_in = nn.Conv3d(self.in_channels, hidden, kernel_size=1)
        self.conv_mid = nn.Conv3d(hidden, hidden, kernel_size=(1, 3, 3), padding=(0, 1, 1))
        self.proj_out = nn.Conv3d(hidden, dim, kernel_size=tuple(patch_size), stride=tuple(patch_size))
        nn.init.zeros_(self.proj_out.weight)
        nn.init.zeros_(self.proj_out.bias)

    def features(self, sem_ids: torch.Tensor) -> torch.Tensor:
        """sem_ids [b, 81, H, W] → 卷积前的输入 [b, 64K, 21, H/16, W/16](模块 dtype;832×480:[b, 640, 21, 30, 52])。"""
        dtype = self.proj_in.weight.dtype
        x = group_and_unshuffle(pooled_onehot(sem_ids.to(self.proj_in.weight.device), torch.float32, self.vocab))
        return x.to(dtype)

    def forward(self, sem_ids: torch.Tensor) -> torch.Tensor:
        x = self.features(sem_ids)
        assert x.shape[1] == self.in_channels, (x.shape, self.in_channels)
        x = F.silu(self.proj_in(x))
        x = F.silu(self.conv_mid(x))
        return self.proj_out(x)

    def forward_pooled(self, pooled: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
        """pooled one-hot [b, K, T, H/4, W/4] (fp32) → (patch-embedding term [b, dim, f, h, w], trunk features
        [b, 512, f, 2h, 2w]). Same numbers as forward(); the trunk features feed the v2 deep adapter."""
        dtype = self.proj_in.weight.dtype
        x = group_and_unshuffle(pooled).to(dtype)
        x = F.silu(self.proj_in(x))
        h = F.silu(self.conv_mid(x))
        return self.proj_out(h), h

    def is_zero_output(self) -> bool:
        """最后一层权重与偏置全 0(刚装上 / 从无此模块的 ckpt 热启动)⇒ 输出恒为 0。"""
        return bool((self.proj_out.weight == 0).all() and (self.proj_out.bias == 0).all())


# ----------------------------------------------------------------------------
# 白模条件(cond_type="whitebox",2026-10-12)
# ----------------------------------------------------------------------------
# GameCrafter-Parkour 的控制视频是 pk 渲染的白模:只有方块 / 圆柱 / 胶囊、固定光照(亮度 ∝ 面朝向)、黑天空、1–3 px 的
# 暗轮廓线(深度 / 法线突变处;窗框、窗台、障碍物顶面与屋顶的分界只靠它)。数据里是灰度(R=G=B),prep 存 uint8
# [81,480,832] 并把亮度截到 ≤254,255 留作 UNKNOWN(整段条件 dropout / CFG 负支 = 全 255,与语义 id 同一约定)。
#
# 编码器与 SemCondEncoder 同构,只换"逐帧 4×4 块特征"这一步(K=10 的池化 one-hot → WB_FEATURES=5 个通道):
#   0..2 R, G, B 块均值(x/127.5 − 1);3 亮度块内标准差(轮廓线在 4×4 均值里几乎消失,在标准差里是强信号);
#   4 已知像素比例(全 255 ⇒ 0;没有它,"未知"会与均值 ≈ 0 的中灰立面混在一起)。
# 均值 / 标准差只在已知像素上统计。之后帧 0 重复 4 次 + 4 帧分组 + unshuffle ×4(group_and_unshuffle,对通道数通用)
# → [b, 5·4·16 = 320, 21, 30, 52] → 1×1 → SiLU → 1×3×3 → SiLU → 零初始化 Conv3d(512, dim, (1,2,2), stride (1,2,2))。
# 时间方向的核全是 1 ⇒ cell j 的条件只依赖 cell j 自己的帧(逐 latent cell 因果,可蒸馏成因果学生,
# tests/test_wb_cond.py 验证)。输入也接受 RGB uint8 [b,T,H,W,3](三通道都是 255 才算 UNKNOWN),给以后的彩色控制视频用。
WB_COND_NAME = "gc_wb_cond"
WB_COND_INIT_SEED = 20261012
WB_FEATURES = 5
WB_FEATURE_NAMES = ("mean_r", "mean_g", "mean_b", "luma_std", "known_frac")
LUMA_WEIGHTS = (0.299, 0.587, 0.114)
COND_TYPES = ("ids", "whitebox")
COND_MODULE = {"ids": SEM_COND_NAME, "whitebox": WB_COND_NAME}


@torch.no_grad()
def wb_pooled_features(wb: torch.Tensor, dtype=torch.float32) -> torch.Tensor:
    """白模视频 uint8 [b, T, H, W](灰度)或 [b, T, H, W, 3](RGB)→ 逐帧 4×4 块特征 [b, 5, T, H/4, W/4](dtype)。

    通道见上(WB_FEATURE_NAMES)。255 = UNKNOWN:灰度看该像素,RGB 看三通道是否都是 255。块内一个已知像素都没有时
    前 4 个通道为 0、第 5 个为 0。逐帧独立计算(不跨帧),fp32。"""
    if wb.dtype != torch.uint8:
        raise TypeError(f"白模必须是 uint8 像素值(255 = UNKNOWN),得到 {wb.dtype}")
    if wb.dim() == 4:
        rgb = False
    elif wb.dim() == 5 and wb.shape[-1] == 3:
        rgb = True
    else:
        raise AssertionError(f"白模应为 [b, T, H, W] 或 [b, T, H, W, 3],得到 {tuple(wb.shape)}")
    b, T, H, W = wb.shape[:4]
    assert H % POOL == 0 and W % POOL == 0, (H, W)
    h, w = H // POOL, W // POOL
    if rgb:
        x = wb.reshape(b * T, H, W, 3).permute(0, 3, 1, 2)                     # [bT, 3, H, W]
        known = (x != UNKNOWN_ID).all(1, keepdim=True)
        v = x.float().div_(127.5).sub_(1.0).mul_(known)
        luma = sum(c * v[:, i:i + 1] for i, c in enumerate(LUMA_WEIGHTS))
    else:
        x = wb.reshape(b * T, 1, H, W)
        known = x != UNKNOWN_ID
        v = x.float().div_(127.5).sub_(1.0).mul_(known)
        luma = v
    kf = F.avg_pool2d(known.float(), POOL)                                     # [bT, 1, h, w],1/16 的整数倍
    denom = kf.clamp_min(1.0 / (POOL * POOL))
    mean = F.avg_pool2d(v, POOL) / denom                                       # [bT, 1 或 3, h, w]
    lmean = mean if not rgb else F.avg_pool2d(luma, POOL) / denom
    dev = (luma - lmean.repeat_interleave(POOL, 2).repeat_interleave(POOL, 3)).mul_(known)
    std = (F.avg_pool2d(dev.square_(), POOL) / denom).sqrt_()                  # 两遍法:常数块的 std 恰为 0
    if not rgb:
        mean = mean.expand(-1, 3, -1, -1)
    out = torch.cat([mean, std, kf], 1)                                        # [bT, 5, h, w]
    return out.view(b, T, WB_FEATURES, h, w).transpose(1, 2).to(dtype)


class WhiteboxCondEncoder(nn.Module):
    """白模视频 → patch-embedding 空间的加性条件(与 SemCondEncoder 同构、同层名,只换输入特征,见上)。"""

    def __init__(self, dim: int, patch_size=(1, 2, 2), hidden: int = HIDDEN):
        super().__init__()
        self.n_features = WB_FEATURES
        self.in_channels = WB_FEATURES * T_GROUP * UNSHUFFLE * UNSHUFFLE  # 320
        self.dim = dim
        self.proj_in = nn.Conv3d(self.in_channels, hidden, kernel_size=1)
        self.conv_mid = nn.Conv3d(hidden, hidden, kernel_size=(1, 3, 3), padding=(0, 1, 1))
        self.proj_out = nn.Conv3d(hidden, dim, kernel_size=tuple(patch_size), stride=tuple(patch_size))
        nn.init.zeros_(self.proj_out.weight)
        nn.init.zeros_(self.proj_out.bias)

    def features(self, wb: torch.Tensor) -> torch.Tensor:
        """白模 [b, 81, H, W(, 3)] uint8 → 卷积前的输入 [b, 320, 21, H/16, W/16](模块 dtype)。"""
        x = group_and_unshuffle(wb_pooled_features(wb.to(self.proj_in.weight.device), torch.float32))
        return x.to(self.proj_in.weight.dtype)

    def forward(self, wb: torch.Tensor) -> torch.Tensor:
        x = self.features(wb)
        assert x.shape[1] == self.in_channels, (x.shape, self.in_channels)
        x = F.silu(self.proj_in(x))
        x = F.silu(self.conv_mid(x))
        return self.proj_out(x)

    def forward_pooled(self, pooled: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
        """wb_pooled_features 的输出 [b, 5, T, H/4, W/4] → (patch-embedding 项, trunk)。与 forward() 同一组数。"""
        x = group_and_unshuffle(pooled).to(self.proj_in.weight.dtype)
        x = F.silu(self.proj_in(x))
        h = F.silu(self.conv_mid(x))
        return self.proj_out(h), h

    def is_zero_output(self) -> bool:
        return bool((self.proj_out.weight == 0).all() and (self.proj_out.bias == 0).all())


# ----------------------------------------------------------------------------
# v2 architecture (2026-10-10): deep semantic injection + semantic attention bias. Both zero-initialised ⇒ a model with
# them freshly installed computes exactly what v1 computes (warm start from any v1 export).
# ----------------------------------------------------------------------------
ADAPTER_NAME = "adapter"            # dit.gc_sem_cond.adapter
ATTN_GAIN_NAME = "attn_qk"          # dit.gc_sem_cond.attn_qk  [n_blocks, 2, K, dim]
ADAPTER_HIDDEN = 1024


class SemAdapter(nn.Module):
    """Deep injection (VACE / ControlNet-style, but light): the encoder's trunk features are patchified to the token grid,
    mixed by a per-cell 3×3 conv, and added to the residual stream before each block in `layers` through a per-layer
    zero-init Linear. In v1 the ids enter only once at the patch embedding (sem term ≈ 12 % of its RMS) and the
    cond / no-cond val gap stays tiny, i.e. the network barely relies on the map deeper in."""

    def __init__(self, dim: int, layers, hidden_in: int = HIDDEN, hidden: int = ADAPTER_HIDDEN):
        super().__init__()
        self.patch = nn.Conv3d(hidden_in, hidden, kernel_size=(1, 2, 2), stride=(1, 2, 2))
        # spatial only: every latent cell sees only its own ids ⇒ the condition stays causal / chunk-local, so the model
        # can be distilled to an autoregressive (chunked, KV-cached) student where future ids are not known yet
        self.mix = nn.Conv3d(hidden, hidden, kernel_size=(1, 3, 3), padding=(0, 1, 1))
        self.inj = nn.ModuleDict({str(int(i)): nn.Linear(hidden, dim) for i in layers})
        for lin in self.inj.values():
            nn.init.zeros_(lin.weight)
            nn.init.zeros_(lin.bias)

    @property
    def layers(self) -> list[int]:
        return sorted(int(k) for k in self.inj.keys())

    def forward(self, h: torch.Tensor) -> torch.Tensor:
        """trunk [b, 512, f, H/16, W/16] → token features [b, f·(H/32)·(W/32), hidden](832×480:f·15·26)."""
        t = F.silu(self.patch(h))
        t = F.silu(self.mix(t))
        t = rearrange(t, "b c f h w -> b (f h w) c")
        return F.layer_norm(t, (t.shape[-1],))    # unit-RMS tokens: the zero-init injections become effective quickly

    def is_zero_output(self) -> bool:
        return all(bool((l.weight == 0).all() and (l.bias == 0).all()) for l in self.inj.values())


def token_class_dist(pooled: torch.Tensor) -> torch.Tensor:
    """pooled one-hot [b, K, 4f−3, H/4, W/4] → per-token class fractions [b, f·(H/32)·(W/32), K] (fp32), the
    'other / UNKNOWN' class zeroed (unknown tokens neither give nor get the same-class bonus)."""
    b, K, T, h4, w4 = pooled.shape
    x = torch.cat([pooled[:, :, :1].expand(b, K, T_GROUP - 1, h4, w4), pooled], dim=2)       # [b, K, 4f, h4, w4]
    f = x.shape[2] // T_GROUP
    x = x.view(b, K, f, T_GROUP, h4, w4).mean(3)                                            # [b, K, f, h4, w4]
    tok = UNSHUFFLE * 2                       # a DiT token = 2×2 latent px = 8×8 pooled px = 32×32 image px
    x = F.avg_pool2d(x.reshape(b, K * f, h4, w4), tok)
    x = x.view(b, K, f, h4 // tok, w4 // tok).clone()
    x[:, K - 1] = 0
    return rearrange(x, "b k f h w -> b (f h w) k")


def _self_attn(sa, x, freqs, p_tok=None, e_qk=None):
    """WanSelfAttention.forward; with (p_tok [b, L, K], e_qk [2, K, dim]) learned class embeddings are added to q and k
    after RoPE (q += p·E_q, k += p·E_k). Δq·Δk is a learnable class-pair attention bias (per head a rank-≤128 K×K
    matrix), q·Δk lets content look for a class. Head dim stays 128 ⇒ same SDPA kernel as v1; E = 0 ⇒ bit-identical."""
    if e_qk is None:
        return sa(x, freqs)
    n = sa.num_heads
    q = rope_apply(sa.norm_q(sa.q(x)), freqs, n)
    k = rope_apply(sa.norm_k(sa.k(x)), freqs, n)
    p = p_tok.to(q.dtype)
    q = q + p @ e_qk[0].to(q.dtype)
    k = k + p @ e_qk[1].to(k.dtype)
    return sa.o(sa.attn(q, k, sa.v(x)))


def gc_block_forward(block, x, context, t_mod, freqs, p_tok=None, e_qk=None, *, f: int):
    """actionrope.arope._block_forward with arm=None (DiTBlock.forward + per-cell text cross-attn), self-attention
    through _self_attn. e_qk=None ⇒ identical to _block_forward."""
    has_seq = len(t_mod.shape) == 4
    chunk_dim = 2 if has_seq else 1
    shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (
        block.modulation.to(dtype=t_mod.dtype, device=t_mod.device) + t_mod).chunk(6, dim=chunk_dim)
    if has_seq:
        shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (
            shift_msa.squeeze(2), scale_msa.squeeze(2), gate_msa.squeeze(2),
            shift_mlp.squeeze(2), scale_mlp.squeeze(2), gate_mlp.squeeze(2),
        )
    input_x = modulate(block.norm1(x), shift_msa, scale_msa)
    x = block.gate(x, gate_msa, _self_attn(block.self_attn, input_x, freqs, p_tok, e_qk))
    q_in = block.norm3(x)
    if context.shape[0] == x.shape[0]:
        x = x + block.cross_attn(q_in, context)
    else:
        b = x.shape[0]
        assert context.shape[0] == b * f, f"逐 cell context 应有 b*f={b * f} 行,得到 {context.shape[0]}"
        q_in = rearrange(q_in, "b (f n) c -> (b f) n c", f=f)
        y = block.cross_attn(q_in, context)
        x = x + rearrange(y, "(b f) n c -> b (f n) c", f=f)
    input_x = modulate(block.norm2(x), shift_mlp, scale_mlp)
    x = block.gate(x, gate_mlp, block.ffn(input_x))
    return x


def install_v2(dit: WanModel, adapter_every: int = 0, sem_attn: bool = False) -> WanModel:
    """Add the zero-init v2 modules under dit.gc_sem_cond (idempotent). adapter_every=k ⇒ inject before blocks 0, k, 2k…"""
    if not hasattr(dit, SEM_COND_NAME):
        install_sem_cond(dit)
    enc = getattr(dit, SEM_COND_NAME)
    ref = dit.patch_embedding.weight
    with torch.random.fork_rng(devices=[]):
        torch.manual_seed(SEM_COND_INIT_SEED + 1)
        if adapter_every and not hasattr(enc, ADAPTER_NAME):
            ad = SemAdapter(dit.dim, range(0, len(dit.blocks), int(adapter_every)))
            enc.add_module(ADAPTER_NAME, ad.to(device=ref.device, dtype=ref.dtype))
    if sem_attn and not hasattr(enc, ATTN_GAIN_NAME):
        enc.register_parameter(ATTN_GAIN_NAME, nn.Parameter(
            torch.zeros(len(dit.blocks), 2, enc.num_classes, dit.dim, device=ref.device, dtype=ref.dtype)))
    return dit


def v2_config_from_keys(keys) -> dict:
    """Which v2 modules a checkpoint carries (from its key names)."""
    pre = SEM_COND_NAME + "."
    inj = sorted({int(k.split(".")[3]) for k in keys if k.startswith(f"{pre}{ADAPTER_NAME}.inj.")})
    every = (inj[1] - inj[0]) if len(inj) > 1 else (1 if inj else 0)
    return {"adapter_every": every, "sem_attn": f"{pre}{ATTN_GAIN_NAME}" in set(keys)}


# ----------------------------------------------------------------------------
# 加载 / 安装
# ----------------------------------------------------------------------------

def load_dit(model_dir: str, device: str = "cpu") -> WanModel:
    """只加载 DiT(bf16),与 actionrope/train.py::load_dit 同一条路径(逐行照抄,免得 import actionrope.train 拖进
    baseline / dataset;maliang 不带 actionrope/train.py)。"""
    from diffsynth.pipelines.wan_video import ModelConfig, WanVideoPipeline

    files = [os.path.join(model_dir, f"diffusion_pytorch_model-0000{i}-of-00003.safetensors") for i in (1, 2, 3)]
    pipe = WanVideoPipeline.from_pretrained(
        torch_dtype=torch.bfloat16, device=device, model_configs=[ModelConfig(path=files)],
        tokenizer_config=None, redirect_common_files=False,
    )
    return pipe.dit


def install_sem_cond(dit: WanModel, vocab: str = DEFAULT_VOCAB) -> WanModel:
    """给 DiT 装零初始化的 `dit.gc_sem_cond`(幂等:已装则原样返回,不重置权重;已装的词表与 vocab 不同则报错)。"""
    if hasattr(dit, SEM_COND_NAME):
        enc = getattr(dit, SEM_COND_NAME)
        assert isinstance(enc, SemCondEncoder), f"dit.{SEM_COND_NAME} 不是 SemCondEncoder:{type(enc)}"
        if enc.vocab != vocab:
            raise ValueError(f"dit.{SEM_COND_NAME} 已按词表 {enc.vocab!r} 安装,要求的是 {vocab!r}")
        return dit
    ref = dit.patch_embedding.weight
    # 固定种子建模块:不消耗、也不依赖全局 RNG(各 rank 一致,见文件头)
    with torch.random.fork_rng(devices=[]):
        torch.manual_seed(SEM_COND_INIT_SEED)
        enc = SemCondEncoder(dit.dim, patch_size=tuple(dit.patch_size), vocab=vocab)
    enc.to(device=ref.device, dtype=ref.dtype)
    dit.add_module(SEM_COND_NAME, enc)
    return dit


def install_wb_cond(dit: WanModel) -> WanModel:
    """给 DiT 装零初始化的 `dit.gc_wb_cond`(白模条件;幂等)。与 gc_sem_cond 互斥(双条件以后再说)。"""
    if hasattr(dit, SEM_COND_NAME):
        raise ValueError(f"dit 上已有 {SEM_COND_NAME}(语义 id 条件);白模模型只装 {WB_COND_NAME}")
    if hasattr(dit, WB_COND_NAME):
        enc = getattr(dit, WB_COND_NAME)
        assert isinstance(enc, WhiteboxCondEncoder), f"dit.{WB_COND_NAME} 不是 WhiteboxCondEncoder:{type(enc)}"
        return dit
    ref = dit.patch_embedding.weight
    with torch.random.fork_rng(devices=[]):
        torch.manual_seed(WB_COND_INIT_SEED)
        enc = WhiteboxCondEncoder(dit.dim, patch_size=tuple(dit.patch_size))
    enc.to(device=ref.device, dtype=ref.dtype)
    dit.add_module(WB_COND_NAME, enc)
    return dit


def install_cond(dit: WanModel, cond_type: str = "ids") -> WanModel:
    """按 cond_type 装条件模块:ids ⇒ install_sem_cond,whitebox ⇒ install_wb_cond。"""
    if cond_type not in COND_TYPES:
        raise ValueError(f"cond_type 只能是 {COND_TYPES},得到 {cond_type!r}")
    return install_wb_cond(dit) if cond_type == "whitebox" else install_sem_cond(dit)


def resolve_cond_type(dit, cond_type: str | None = None) -> str:
    """显式给了就用(并核对合法);None ⇒ 看 dit 上装了哪个:只有 gc_wb_cond ⇒ "whitebox",否则 "ids"(旧行为)。"""
    if cond_type is not None:
        if cond_type not in COND_TYPES:
            raise ValueError(f"cond_type 只能是 {COND_TYPES},得到 {cond_type!r}")
        return cond_type
    has_sem, has_wb = hasattr(dit, SEM_COND_NAME), hasattr(dit, WB_COND_NAME)
    if has_sem and has_wb:
        raise ValueError(f"dit 上同时有 {SEM_COND_NAME} 和 {WB_COND_NAME},请显式传 cond_type")
    return "whitebox" if has_wb else "ids"


def sem_vocab(dit) -> str:
    """dit 上已装的 gc_sem_cond 的词表。"""
    return getattr(dit, SEM_COND_NAME).vocab


def vocab_from_state(sd_or_header) -> str | None:
    """state dict / safetensors 头(键 → 张量或 {'shape': ...})→ gc_sem_cond 的词表;没有 gc_sem_cond 键 ⇒ None。"""
    for k in (f"{SEM_COND_NAME}.proj_in.weight", f"dit.{SEM_COND_NAME}.proj_in.weight"):
        if k in sd_or_header:
            v = sd_or_header[k]
            shape = v["shape"] if isinstance(v, dict) else tuple(v.shape)
            return vocab_from_in_channels(int(shape[1]))
    return None


def cond_type_from_keys(keys) -> str:
    """ckpt 的键名 → 它是哪种条件的导出(有 gc_wb_cond.* / dit.gc_wb_cond.* ⇒ whitebox,否则 ids)。"""
    pre = (WB_COND_NAME + ".", "dit." + WB_COND_NAME + ".")
    return "whitebox" if any(k.startswith(pre) for k in keys) else "ids"


def _read_safetensors(path) -> tuple[dict, dict]:
    paths = [path] if isinstance(path, (str, os.PathLike)) else list(path)
    sd, meta = {}, {}
    for p in paths:
        part = load_file(str(p))
        dup = set(part) & set(sd)
        if dup:
            raise RuntimeError(f"{p} 与前面的分片有重复键:{sorted(dup)[:5]}")
        sd.update(part)
        with safe_open(str(p), framework="pt") as fh:
            meta.update(fh.metadata() or {})
    return sd, meta


def load_gc_weights(dit: WanModel, path, cond_type: str = "ids") -> dict:
    """从导出的 safetensors(或分片列表)加载 DiT 权重。先 install_sem_cond(幂等),再按 ckpt 里出现的键装上 v2 模块
    (gc_sem_cond.adapter.* / gc_sem_cond.attn_gain)。

    严格匹配 dit.state_dict() 的键集;放行的缺失只有两类,且对应模块必须仍是零输出(刚装上):
    * 全部 `gc_sem_cond.*`(从普通 Wan / ActionRoPE plain 导出热启动);
    * v2 模块(调用方先 install_v2 再从 v1 导出热启动)。
    返回 info:missing / n_tensors / has_sem_cond / v2 / metadata。
    cond_type="whitebox" ⇒ 走 _load_wb_weights(gc_wb_cond,见那里);默认 "ids" 的行为与原来完全相同。"""
    if cond_type != "ids":
        if cond_type != "whitebox":
            raise ValueError(f"cond_type 只能是 {COND_TYPES},得到 {cond_type!r}")
        return _load_wb_weights(dit, path)
    sd, meta = _read_safetensors(path)
    # 词表:ckpt 里有 gc_sem_cond ⇒ 按它的输入通道定;没有(Wan / plain 热启动)⇒ 用 dit 上已装的,未装则默认 ids
    ck_vocab = vocab_from_state(sd)
    have = sem_vocab(dit) if hasattr(dit, SEM_COND_NAME) else None
    if ck_vocab and have and ck_vocab != have:
        raise RuntimeError(f"{path} 的 gc_sem_cond 是 {ck_vocab!r} 词表,dit 上装的是 {have!r}")
    install_sem_cond(dit, ck_vocab or have or DEFAULT_VOCAB)
    v2 = v2_config_from_keys(sd.keys())
    install_v2(dit, **v2)
    missing, unexpected = dit.load_state_dict(sd, strict=False)
    prefix = SEM_COND_NAME + "."
    v2_prefixes = (f"{prefix}{ADAPTER_NAME}.", f"{prefix}{ATTN_GAIN_NAME}")
    bad_missing = [k for k in missing if not k.startswith(prefix)]
    if bad_missing or unexpected:
        raise RuntimeError(f"{path} 与模型键集不符:缺 {bad_missing[:5]}…({len(bad_missing)}),"
                           f"多 {list(unexpected)[:5]}…({len(unexpected)})")
    enc = getattr(dit, SEM_COND_NAME)
    sem_missing = [k for k in missing if k.startswith(prefix) and not k.startswith(v2_prefixes)]
    v2_missing = [k for k in missing if k.startswith(v2_prefixes)]
    n_sem_total = sum(1 for k in dit.state_dict() if k.startswith(prefix) and not k.startswith(v2_prefixes))
    if sem_missing and len(sem_missing) != n_sem_total:
        raise RuntimeError(f"{path} 只含部分 {prefix}* 键(缺 {sem_missing}),ckpt 不完整")
    if sem_missing and not enc.is_zero_output():
        raise RuntimeError(f"{path} 没有 {prefix}* 键,但 dit 上的 {SEM_COND_NAME} 已不是零初始化状态;"
                           f"请在全新 install 的模型上热启动")
    if v2_missing:
        ad, g = getattr(enc, ADAPTER_NAME, None), getattr(enc, ATTN_GAIN_NAME, None)
        if (ad is not None and not ad.is_zero_output()) or (g is not None and bool((g != 0).any())):
            raise RuntimeError(f"{path} 缺 v2 键 {v2_missing[:3]}…,但 v2 模块已不是零初始化状态")
    return {"missing": list(missing), "n_tensors": len(sd), "has_sem_cond": not sem_missing, "v2": v2, "vocab": enc.vocab,
            "v2_missing": len(v2_missing), "metadata": meta, "path": str(path)}


def _load_wb_weights(dit: WanModel, path) -> dict:
    """白模模型(dit.gc_wb_cond)的加载:install_wb_cond(幂等)后严格匹配键集。

    * ckpt 里的 `gc_sem_cond.*`(含 v2 子模块)一律丢弃并记进 info["dropped_sem_keys"] —— 从语义 id 模型
      (如 gc25d_v3v4/step-6000,825 个 Wan 键 + 6 个 gc_sem_cond 键)热启动时只取 825 个 DiT 键;
    * 唯一放行的缺失:6 个 `gc_wb_cond.*` 全缺(热启动),且模块仍是零输出(刚装上);只缺一部分 ⇒ 报错;
    * 缺任何 DiT 键 / 有其它多余键 ⇒ 报错;同时带 gc_sem_cond.* 与 gc_wb_cond.* 的 ckpt ⇒ 报错。"""
    install_wb_cond(dit)
    sd, meta = _read_safetensors(path)
    n_file = len(sd)
    sem_prefix, prefix = SEM_COND_NAME + ".", WB_COND_NAME + "."
    dropped = sorted(k for k in sd if k.startswith(sem_prefix))
    has_wb = any(k.startswith(prefix) for k in sd)
    if dropped and has_wb:
        raise RuntimeError(f"{path} 同时有 {sem_prefix}* 和 {prefix}* 键,不知道该用哪个")
    for k in dropped:
        del sd[k]
    missing, unexpected = dit.load_state_dict(sd, strict=False)
    bad_missing = [k for k in missing if not k.startswith(prefix)]
    if bad_missing or unexpected:
        raise RuntimeError(f"{path} 与白模模型键集不符:缺 {bad_missing[:5]}…({len(bad_missing)}),"
                           f"多 {list(unexpected)[:5]}…({len(unexpected)})")
    enc = getattr(dit, WB_COND_NAME)
    wb_missing = [k for k in missing if k.startswith(prefix)]
    n_wb_total = sum(1 for k in dit.state_dict() if k.startswith(prefix))
    if wb_missing and len(wb_missing) != n_wb_total:
        raise RuntimeError(f"{path} 只含部分 {prefix}* 键(缺 {wb_missing}),ckpt 不完整")
    if wb_missing and not enc.is_zero_output():
        raise RuntimeError(f"{path} 没有 {prefix}* 键,但 dit 上的 {WB_COND_NAME} 已不是零初始化状态;"
                           f"请在全新 install 的模型上热启动")
    return {"missing": list(missing), "n_tensors": n_file, "n_loaded": len(sd), "cond_type": "whitebox",
            "has_wb_cond": not wb_missing, "has_sem_cond": False, "dropped_sem_keys": dropped,
            "v2": {"adapter_every": 0, "sem_attn": False}, "v2_missing": 0, "metadata": meta, "path": str(path)}


# ----------------------------------------------------------------------------
# 前向
# ----------------------------------------------------------------------------

def gc_forward(
    dit: WanModel,
    latents: torch.Tensor,
    timestep: torch.Tensor,
    context: torch.Tensor,
    sem_ids,
    *,
    first_frame_cond: bool = True,
    context_ids=None,
    use_gradient_checkpointing: bool = False,
    use_gradient_checkpointing_offload: bool = False,
    aux: dict | None = None,
    cond_type: str | None = None,
) -> torch.Tensor:
    """gc25d 的 DiT 前向。latents [b, 48, f, H/16, W/16] → v_pred 同形(832×480:[b, 48, f, 30, 52])。

    cond_type:"ids"(gc_sem_cond,语义 id)/ "whitebox"(gc_wb_cond,sem_ids 参数里放白模 uint8 [b, 4f−3, H, W(, 3)]);
              None ⇒ resolve_cond_type(只装了 gc_wb_cond ⇒ whitebox,否则 ids —— 不传时与原来逐位相同)。

    = arope_forward(offset_px=None, mask_input=None, arm=None) 的逐行照抄 + patch embedding 后的 sem 条件项。
    sem_ids:  [b, 4f−3, 16·lat_h, 16·lat_w] uint8 / 整数 raw id(255 = UNKNOWN);None ⇒ 不加 sem 项(≡ 原模型)。
              给了但 dit 没装 gc_sem_cond ⇒ 报错(避免"以为用了条件其实没用")。
    context:  [b, 512, 4096](整条)或 [b, f, 512, 4096](逐 cell);context_ids [b, f] 用于逐 cell 去重。
    timestep: 标量 / [1] / [b](1000 制);first_frame_cond ⇒ 帧 0 的 t=0。
    aux:      给一个 dict ⇒ 填入诊断量(sem 项与 patch embedding 的 RMS),不影响计算。
    """
    b, _, f, lat_h, lat_w = latents.shape
    p_f, p_h, p_w = dit.patch_size
    n_tok_per_frame = (lat_h // p_h) * (lat_w // p_w)

    # 时间步:逐 token,帧 0 为 0(照抄 arope_forward / model_fn_wan_video)
    t_tok = _per_token_timestep(latents, timestep, n_tok_per_frame, first_frame_cond)
    t = dit.time_embedding(sinusoidal_embedding_1d(dit.freq_dim, t_tok.reshape(-1)).view(b, t_tok.shape[1], -1))
    t_mod = dit.time_projection(t).unflatten(2, (6, dit.dim))

    # 文本(逐 cell 时只对唯一串算一次 text_embedding)
    context = _embed_context(dit, context, context_ids)

    # patch embedding + 语义条件
    x = dit.patch_embedding(latents)
    adapter = attn_gain = tok = p_tok = None
    if sem_ids is not None and resolve_cond_type(dit, cond_type) == "whitebox":
        enc = getattr(dit, WB_COND_NAME, None)
        if enc is None:
            raise ValueError(f"cond_type=whitebox 但 dit 没有 {WB_COND_NAME},请先 install_wb_cond(dit) / "
                             f"load_gc_weights(..., cond_type='whitebox')")
        exp = (b, T_GROUP * (f - 1) + 1, lat_h * POOL * UNSHUFFLE, lat_w * POOL * UNSHUFFLE)
        assert tuple(sem_ids.shape[:4]) == exp and sem_ids.dim() in (4, 5), \
            f"白模形状应为 {exp}(灰度)或 {exp + (3,)}(RGB),得到 {tuple(sem_ids.shape)}"
        s, _ = enc.forward_pooled(wb_pooled_features(sem_ids.to(enc.proj_in.weight.device), torch.float32))
        assert s.shape == x.shape, f"白模项 {tuple(s.shape)} ≠ patch embedding {tuple(x.shape)}"
        if aux is not None:
            aux["sem_rms"] = s.detach().float().pow(2).mean().sqrt()
            aux["x_rms"] = x.detach().float().pow(2).mean().sqrt()
        x = x + s.to(x.dtype)
    elif sem_ids is not None:
        enc = getattr(dit, SEM_COND_NAME, None)
        if enc is None:
            raise ValueError(f"给了 sem_ids 但 dit 没有 {SEM_COND_NAME},请先 install_sem_cond(dit) / load_gc_weights")
        exp = (b, T_GROUP * (f - 1) + 1, lat_h * POOL * UNSHUFFLE, lat_w * POOL * UNSHUFFLE)
        assert tuple(sem_ids.shape) == exp, f"sem_ids 形状应为 {exp},得到 {tuple(sem_ids.shape)}"
        adapter = getattr(enc, ADAPTER_NAME, None)
        attn_gain = getattr(enc, ATTN_GAIN_NAME, None)
        pooled = pooled_onehot(sem_ids.to(enc.proj_in.weight.device), torch.float32, enc.vocab)
        s, trunk = enc.forward_pooled(pooled)
        assert s.shape == x.shape, f"sem 项 {tuple(s.shape)} ≠ patch embedding {tuple(x.shape)}"
        if aux is not None:
            aux["sem_rms"] = s.detach().float().pow(2).mean().sqrt()
            aux["x_rms"] = x.detach().float().pow(2).mean().sqrt()
        x = x + s.to(x.dtype)
        if adapter is not None:
            tok = adapter(trunk)                                                  # [b, L, 1024]
        if attn_gain is not None:
            p_tok = token_class_dist(pooled)                                      # [b, L, K] fp32
        del pooled
    f, h, w = x.shape[2:]
    assert f * h * w == t_tok.shape[1]
    x = rearrange(x, "b c f h w -> b (f h w) c").contiguous()

    # 普通 RoPE 查表(与 model_fn_wan_video 逐位相同)
    freqs = build_world_freqs(dit, f, h, w, None, device=x.device)

    # blocks:v1 modules only ⇒ actionrope._block_forward (bit-identical to v1); v2 ⇒ gc_block_forward + injections
    inj_rms = []
    for i, block in enumerate(dit.blocks):
        if adapter is not None and str(i) in adapter.inj:
            r = adapter.inj[str(i)](tok)
            if aux is not None:
                inj_rms.append(r.detach().float().pow(2).mean())
            x = x + r.to(x.dtype)
        if attn_gain is not None:
            x = gradient_checkpoint_forward(
                functools.partial(gc_block_forward, block, f=f),
                use_gradient_checkpointing, use_gradient_checkpointing_offload,
                x, context, t_mod, freqs, p_tok, attn_gain[i],
            )
        else:
            x = gradient_checkpoint_forward(
                functools.partial(_block_forward, block, f=f, arm=None, idx=i, grid=None),
                use_gradient_checkpointing,
                use_gradient_checkpointing_offload,
                x, context, t_mod, freqs, None,
            )
    if aux is not None and inj_rms:
        aux["inj_rms"] = torch.stack(inj_rms).mean().sqrt()
    if aux is not None and attn_gain is not None:
        aux["attn_bias_absmax"] = attn_gain.detach().float().pow(2).mean().sqrt()   # RMS of the class embeddings

    x = dit.head(x, t)
    return dit.unpatchify(x, (f, h, w))