File size: 38,644 Bytes
68e2686
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
#!/usr/bin/env python3
"""teaser · 素材合成(maliang venv,CPU):把 rollout.py 的结果(或 genbench 的旧结果)拼成能直接剪进 teaser 的视频 / 索引。
视频一律经 ffmpeg 管道编码(系统 PATH 里的 ffmpeg,没有就用 imageio_ffmpeg 自带的二进制):libx264 · yuv420p · BT.709(limited,
标记写进码流)· crf 16 · +faststart。读视频也走 ffmpeg(按码流里的色彩标记转回 RGB,与写入对称)。

case = 下面任何一种(load_case 自动识别):
  * rollout.py 的输出目录 <out>/<name>/(gen.mp4 + ids.npz + meta.json;meta 里有标签 scene / map / label / title);
  * genbench3/4 的 roll_bidir|roll_dmd/<tag>/<variant>/(gen.mp4 + meta.json;语义 = <root>/sources.json 里该源链的 cond 拼接,
    bidir 只有 81 帧 = 链首 clip);
  * sample_user.py 的输出(<name>.json + <name>_gen.mp4;给目录或 json 路径;ids = json 里 inputs.ids.path)。
标签:场景名(右 / 角标)= case 的 label,否则 SCENE_EN[scene](scenes.py 的 36 个场景 + genbench4 的 10 个),否则把键名美化;
地图名(左)= MAP_EN[map](procmap 主题),否则美化。命令行 --scene_label / --map_label 覆盖。

子命令
  side   单个 case:语义色图 | 生成 并排(深色底、1 px 细边框、面板上方小字标签:左 = 地图主题,右 = 场景名),--legend 加图例。
  gen    单个 case:只出生成视频(--fit crop 铺满 / pad 留边),左下角标 = 场景名,--inset 右上角语义小窗。
  grid   N 个 case 同步播放的宫格(--cols,--what gen|sem,--labels scene|map|name|none,--margin / --gap)。
  reel   若干 mp4(或 case 目录 ⇒ 它的 gen.mp4)顺序拼接:相邻之间交叉淡化(--mode cross)或经黑场(--mode black),--fade 帧数。
  index  一批结果 → <out>/contact_XX.jpg(分页缩略图:帧 40 + 右上语义小窗 + 名字)+ <out>/thumbs/*.jpg + <out>/index.md(表格 + 路径)。
默认画布 1920×1080、16 fps;side / gen / grid 都取 case 的全部帧(81),--frames N 截短,--fade_io N 首尾各 N 帧淡入淡出(默认 0,交给剪辑);
语义色图默认描 1 px 类别边界(procmap 的干净多边形更像"地图"),--flat 关掉(真实数据的碎植被描线会显脏)。
side 想要更紧的画面:--canvas 1920x640 --margin 24 --gap 24。

usage($PY = .venv/bin/python;不用 GPU):
  $PY scripts/teaser/compose.py side outputs/teaser/roll/<name> -o x.mp4 [--legend] [--map_label Village] [--scene_label "Ice Cave"]
  $PY scripts/teaser/compose.py gen  outputs/teaser/roll/<name> -o x.mp4 [--inset] [--fit crop|pad] [--no_badge]
  $PY scripts/teaser/compose.py grid outputs/teaser/roll/a outputs/teaser/roll/b ... -o g.mp4 --cols 3 [--what gen] [--labels scene]
  $PY scripts/teaser/compose.py reel a.mp4 b.mp4 outputs/teaser/roll/c -o r.mp4 [--fade 8] [--mode cross|black]
  $PY scripts/teaser/compose.py index 'outputs/teaser/roll/*' -o outputs/teaser/roll/_index [--per_page 48] [--cols 8]
glob 参数要加引号(脚本自己展开);grid / reel / index 的 case 也可以写在文件里:@list.txt(每行一个)。
"""
from __future__ import annotations

import argparse
import glob
import json
import os
import shutil
import subprocess
import sys

import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont

M = "/opt/dlami/nvme/zhiyangdeng/maliang"
if f"{M}/code/gc25d" not in sys.path:
    sys.path.insert(0, f"{M}/code/gc25d")
import geometry as GEO  # noqa: E402

DATA_ROOT = f"{M}/data/gc25d_v10"
FPS = 16
CANVAS = (1920, 1080)
BG = (12, 14, 19)
PANEL_BG = (20, 23, 30)
BORDER = (64, 70, 84)
FG = (236, 238, 244)
MUTED = (146, 153, 170)
FONT_LAT = "/usr/share/fonts/google-noto-vf/NotoSans[wght].ttf"
FONT_CJK = "/usr/share/fonts/google-droid-sans-fonts/DroidSansFallbackFull.ttf"     # 只有 CJK,拉丁字母走 Noto

# 语义调色板:静态类 / 玩家 core 10 / Boss core 11 沿用 geometry.PALETTE;肢体部件 12–21 给同色系(genbench4/showcase.py 同款)
PAL = GEO.PALETTE.copy()
PAL[[12, 13, 14, 15, 16]] = [(255, 120, 255), (200, 0, 200), (255, 80, 200), (170, 60, 255), (230, 150, 255)]
PAL[[17, 18, 19, 20, 21]] = [(255, 110, 80), (200, 0, 30), (255, 70, 40), (230, 40, 90), (255, 150, 120)]
LEGEND = [("sky", (0,)), ("terrain", (1,)), ("paving", (2,)), ("architecture", (3,)), ("rock", (4,)), ("vegetation", (5,)),
          ("prop", (6,)), ("player", (10, 12, 13, 14, 15, 16)), ("boss", (11, 17, 18, 19, 20, 21))]

SCENE_EN = {
    "castle": "Medieval Castle", "dungeon": "Dungeon", "lava": "Lava Realm", "icecave": "Ice Cavern", "graveyard": "Haunted Graveyard",
    "swamp": "Swamp", "alien": "Alien Planet", "wildwest": "Wild West Town", "mushroom": "Mushroom Forest", "ruins": "Ruined City",
    "egypt": "Egyptian Temple", "chinese_palace": "Imperial Palace", "jungle_temple": "Jungle Temple", "moonbase": "Moon Base",
    "mars": "Mars Colony", "crystal_cavern": "Crystal Cavern", "candy": "Candy Land", "greek": "Greek Acropolis",
    "sky_islands": "Sky Kingdom", "halloween": "Halloween Patch", "christmas": "Christmas Village", "savanna": "Savanna",
    "beach": "Tropical Beach", "toxic": "Toxic Wasteland", "utopia": "Future City", "elven": "Elven City", "farm": "Countryside Farm",
    "pirate": "Pirate Cove", "samurai": "Samurai Town", "steppe_camp": "Steppe War Camp", "clockwork": "Clockwork World",
    "underworld": "Underworld", "bamboo": "Bamboo Valley", "coral_dry": "Dry Coral Seabed", "autumn_harvest": "Harvest Fair",
    "volcano_forge": "Dwarven Forge", "orig": "Original",
}
MAP_EN = {"village": "Village", "forest": "Forest", "plaza": "Plaza", "canyon": "Canyon", "hedgemaze": "Hedge Maze",
          "stonecircle": "Stone Circle", "pillarhall": "Pillar Hall", "skyislands": "Sky Islands", "cityblock": "City Blocks",
          "mountainpass": "Mountain Pass", "racetrack": "Race Track", "glyph": "Giant Letters", "chess": "Chessboard"}


# ------------------------------------------------------------------ 小工具 ----
def pretty(key: str | None) -> str:
    """键名 → 显示名(下划线 / 连字符 → 空格,首字母大写)。"""
    if not key:
        return ""
    return " ".join(w[:1].upper() + w[1:] for w in str(key).replace("-", "_").split("_") if w)


def even(x: float) -> int:
    return int(round(x / 2)) * 2


def expand(items: list[str]) -> list[str]:
    """命令行的 case / mp4 列表:@文件(每行一个)、glob 自己展开(保持给出的顺序,glob 内按名字排序)。"""
    out = []
    for it in items:
        if it.startswith("@"):
            with open(it[1:], encoding="utf-8") as fh:
                out += expand([ln.strip() for ln in fh if ln.strip() and not ln.startswith("#")])
        elif any(c in it for c in "*?["):
            out += sorted(glob.glob(it))
        else:
            out.append(it)
    return out


# ------------------------------------------------------------------ ffmpeg ----
def ffmpeg_exe() -> str:
    exe = shutil.which("ffmpeg")
    if exe:
        return exe
    import imageio_ffmpeg
    return imageio_ffmpeg.get_ffmpeg_exe()


class VideoWriter:
    """RGB uint8 帧 → ffmpeg 管道 → H.264 yuv420p(BT.709 limited,标记写进码流)。先写 <path>.part.mp4,close 成功才改名
    ⇒ 中途崩了不会留下看似完整的 mp4。W、H 须为偶数(yuv420p)。"""

    def __init__(self, path: str, w: int, h: int, fps: int = FPS, crf: int = 16, preset: str = "slow"):
        assert w % 2 == 0 and h % 2 == 0, (w, h)
        self.path, self.w, self.h, self.n = path, w, h, 0
        os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
        self.tmp = path + ".part.mp4"
        cmd = [ffmpeg_exe(), "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h}", "-r", str(fps),
               "-i", "-", "-vf", "scale=out_color_matrix=bt709:out_range=tv", "-c:v", "libx264", "-preset", preset,
               "-crf", str(crf), "-pix_fmt", "yuv420p", "-colorspace", "bt709", "-color_primaries", "bt709",
               "-color_trc", "bt709", "-color_range", "tv", "-movflags", "+faststart", self.tmp]
        self.p = subprocess.Popen(cmd, stdin=subprocess.PIPE)

    def write(self, fr: np.ndarray) -> None:
        assert fr.dtype == np.uint8 and fr.shape == (self.h, self.w, 3), (fr.dtype, fr.shape, (self.h, self.w))
        self.p.stdin.write(np.ascontiguousarray(fr).tobytes())
        self.n += 1

    def close(self) -> None:
        self.p.stdin.close()
        rc = self.p.wait()
        if rc != 0 or self.n == 0:
            raise RuntimeError(f"ffmpeg 写 {self.path} 失败(rc={rc},{self.n} 帧)")
        os.replace(self.tmp, self.path)

    def __enter__(self):
        return self

    def __exit__(self, et, ev, tb):
        if et is None:
            self.close()
        else:
            self.p.kill()
            self.p.wait()
            if os.path.exists(self.tmp):
                os.remove(self.tmp)


def write_video(path: str, frames, crf: int = 16, fps: int = FPS, preset: str = "slow") -> int:
    """frames:uint8 [F,H,W,3] 或逐帧迭代器(尺寸取第一帧)。返回帧数。"""
    it = iter(frames)
    first = next(it)
    with VideoWriter(path, first.shape[1], first.shape[0], fps=fps, crf=crf, preset=preset) as vw:
        vw.write(first)
        for fr in it:
            vw.write(fr)
    return vw.n


def probe(path: str) -> tuple[int, int]:
    """mp4 的 (W, H)(PyAV 读头,不解码)。"""
    import av
    with av.open(path) as c:
        s = c.streams.video[0]
        return s.codec_context.width, s.codec_context.height


def iter_video(path: str, n: int | None = None):
    """mp4 → 逐帧 RGB uint8(ffmpeg 解码,按码流的色彩标记转 RGB)。"""
    w, h = probe(path)
    cmd = [ffmpeg_exe(), "-loglevel", "error", "-i", path, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]
    if n is not None:
        cmd[5:5] = ["-frames:v", str(n)]
    p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
    sz = w * h * 3
    try:
        while True:
            buf = p.stdout.read(sz)
            if len(buf) < sz:
                break
            yield np.frombuffer(buf, np.uint8).reshape(h, w, 3)
    finally:
        p.stdout.close()
        p.wait()


def read_video(path: str, n: int | None = None) -> np.ndarray:
    return np.stack(list(iter_video(path, n)))


# ------------------------------------------------------------------ 语义着色 ----
def colorize(ids: np.ndarray, edges: bool = True) -> np.ndarray:
    """uint8 [H,W](或 [...,H,W])→ RGB:PAL 着色,255 = 黑底灰斜纹(同 geometry.colorize_ids),edges ⇒ 类别边界压暗成 1 px 描线。"""
    ids = np.asarray(ids)
    out = PAL[ids]
    unk = ids == GEO.UNKNOWN_ID
    if unk.any():
        h, w = ids.shape[-2:]
        yy, xx = np.mgrid[0:h, 0:w]
        out[unk & np.broadcast_to(((xx + yy) // 4) % 2 == 1, ids.shape)] = GEO.HATCH_GREY
    if edges:
        b = np.zeros(ids.shape, bool)
        b[..., :, :-1] |= ids[..., :, :-1] != ids[..., :, 1:]
        b[..., :-1, :] |= ids[..., :-1, :] != ids[..., 1:, :]
        out[b] = (out[b].astype(np.uint16) * 9 // 16).astype(np.uint8)
    return out


def sem_panel(ids: np.ndarray, w: int, h: int, edges: bool = True) -> np.ndarray:
    """一帧 ids → w×h 的色图:先最近邻缩放 id 再着色(描线在目标分辨率上仍是 1 px)。"""
    if ids.shape != (h, w):
        ids = cv2.resize(ids, (w, h), interpolation=cv2.INTER_NEAREST)
    return colorize(ids, edges)


def fit(img: np.ndarray, w: int, h: int) -> np.ndarray:
    if img.shape[:2] == (h, w):
        return img
    up = w > img.shape[1]
    return cv2.resize(img, (w, h), interpolation=cv2.INTER_LANCZOS4 if up else cv2.INTER_AREA)


# ------------------------------------------------------------------ 文字 ----
_FONTS: dict = {}


def font(size: int, weight: str = "Medium", cjk: bool = False):
    k = (size, weight, cjk)
    if k not in _FONTS:
        if cjk:
            f = ImageFont.truetype(FONT_CJK, size)
        else:
            f = ImageFont.truetype(FONT_LAT, size)
            f.set_variation_by_name(weight)
        _FONTS[k] = f
    return _FONTS[k]


def _runs(s: str):
    out = []
    for ch in s:
        c = ord(ch) >= 0x2E80
        if out and out[-1][0] == c:
            out[-1][1] += ch
        else:
            out.append([c, ch])
    return out


def text_w(s: str, size: int, weight: str = "Medium") -> float:
    return sum(font(size, weight, c).getlength(r) for c, r in _runs(s))


def draw_text(d: ImageDraw.ImageDraw, x: float, y: float, s: str, size: int, fill, weight: str = "Medium") -> float:
    """中英混排(CJK 走 Droid,其余 Noto Sans),(x, y) = 左上,基线 y + 0.8·size。返回右端 x。"""
    for c, r in _runs(s):
        d.text((x, y + 0.8 * size), r, font=font(size, weight, c), fill=fill, anchor="ls")
        x += font(size, weight, c).getlength(r)
    return x


def pill(s: str, size: int, fg=FG, bg=(0, 0, 0), alpha: float = 0.62, weight: str = "SemiBold", pad=None):
    """文字小胶囊 → (rgb uint8 [h,w,3], a float32 [h,w,1]),贴在视频上用 paste_rgba。"""
    px, py = pad or (round(size * 0.55), round(size * 0.28))
    w, h = int(np.ceil(text_w(s, size, weight))) + 2 * px, int(round(size * 1.05)) + 2 * py
    im = Image.new("RGBA", (w, h), (0, 0, 0, 0))
    d = ImageDraw.Draw(im)
    d.rounded_rectangle((0, 0, w - 1, h - 1), radius=h // 2 if h < 3 * size else 8, fill=(*bg, int(255 * alpha)))
    draw_text(d, px, py - round(size * 0.02), s, size, (*fg, 255), weight)
    a = np.asarray(im, np.float32)
    return a[..., :3].astype(np.uint8), a[..., 3:] / 255.0


def paste_rgba(dst: np.ndarray, patch, x: int, y: int) -> None:
    rgb, a = patch
    h, w = rgb.shape[:2]
    h, w = min(h, dst.shape[0] - y), min(w, dst.shape[1] - x)
    if h <= 0 or w <= 0:
        return
    roi = dst[y:y + h, x:x + w].astype(np.float32)
    dst[y:y + h, x:x + w] = (roi * (1 - a[:h, :w]) + rgb[:h, :w] * a[:h, :w]).astype(np.uint8)


def frame_rect(img: np.ndarray, x: int, y: int, w: int, h: int, color=BORDER, t: int = 1) -> None:
    """画在面板外侧的细边框(不压住画面)。"""
    cv2.rectangle(img, (x - t, y - t), (x + w + t - 1, y + h + t - 1), color, t, lineType=cv2.LINE_8)


# ------------------------------------------------------------------ case ----
def _sources_json(gen_dir: str) -> str | None:
    """genbench 的 <root>/roll_<mode>/<tag>/<variant> → <root>/sources.json。"""
    p = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(gen_dir))), "sources.json")
    return p if os.path.isfile(p) else None


def _chain_ids(root_sources: str, tag: str, n: int) -> np.ndarray:
    """genbench 源链的 raw id(= eval_rollout.load_chain 的拼法:cond[clip] + cond_ext[:96−81]),取前 n 帧。"""
    src = {s["tag"]: s for s in json.load(open(root_sources))}[tag]
    sem, total = [], 0
    for cid in src["chain"]:
        with np.load(f"{DATA_ROOT}/cond/{cid}.npz") as z:
            sem.append(z["ids"])
        total += len(sem[-1])
        ext = f"{DATA_ROOT}/cond_ext/{cid}.npz"
        if total < n and os.path.exists(ext):
            with np.load(ext) as z:
                sem.append(z["ids"][:96 - 81])
            total += len(sem[-1])
        if total >= n:
            break
    return np.concatenate(sem, 0)[:n]


def _load_npz_ids(path: str) -> np.ndarray:
    if path.endswith(".npy"):
        return np.load(path)
    with np.load(path) as z:
        return z["ids"] if "ids" in z.files else z[z.files[0]]


def load_case(path: str) -> dict:
    """→ {name, dir, gen(mp4), ids()(惰性读全部帧 raw id), scene, map, label, title, seed, sec, frames_npz, sheet, side, meta, kind}。"""
    p = os.path.abspath(path.rstrip("/"))
    if os.path.isdir(p) and os.path.isfile(os.path.join(p, "meta.json")):
        meta = json.load(open(os.path.join(p, "meta.json")))
        gen = os.path.join(p, "gen.mp4")
        if meta.get("kind") == "teaser_rollout":
            lab = meta.get("labels", {})
            ids_path = os.path.join(p, "ids.npz")
            if not os.path.exists(ids_path):                         # 早期结果没存 ids.npz ⇒ 回到输入路径,按 rollout 的规则变换
                ids_path = meta["inputs"]["ids"]["path"]
            res = meta.get("res", "832x480")

            def ids_fn(ids_path=ids_path, res=res):
                a = _load_npz_ids(ids_path)
                w, h = (int(v) for v in res.split("x"))
                if a.shape[1:] == (h, w):
                    return a
                if a.shape[1:] == (GEO.SRC_H, GEO.SRC_W) and res == GEO.RES_TAG:
                    return GEO.resample_ids(a)
                return np.stack([cv2.resize(f, (w, h), interpolation=cv2.INTER_NEAREST) for f in a])
            return {"kind": "teaser", "name": meta["name"], "dir": p, "gen": gen, "ids": ids_fn, "scene": lab.get("scene"),
                    "map": lab.get("map"), "label": lab.get("label"), "title": lab.get("title"), "seed": meta.get("seed"),
                    "sec": meta.get("timing", {}).get("gpu_sec"), "frames_npz": os.path.join(p, "frames.npz"),
                    "sheet": os.path.join(p, "sheet.jpg"), "side": os.path.join(p, "side.mp4"), "meta": meta}
        if "tag" in meta and "variant" in meta:                      # genbench3 / 4 的 roll_<mode>/<tag>/<variant>
            sj = _sources_json(p)
            src = {s["tag"]: s for s in json.load(open(sj))}[meta["tag"]] if sj else {}
            n = int(meta.get("n_frames", 81))
            scene_src = str(src.get("scene", "")).split("_", 1)[-1] if src else ""
            return {"kind": "genbench", "name": f"{meta['tag']}__{meta['variant']}", "dir": p, "gen": gen,
                    "ids": (lambda sj=sj, tag=meta["tag"], n=n: _chain_ids(sj, tag, n)), "scene": meta["variant"],
                    "map": scene_src, "label": None, "title": None, "seed": meta.get("seed"), "sec": meta.get("sec"),
                    "frames_npz": os.path.join(p, "frames.npz"), "sheet": None, "side": None, "meta": meta}
        raise ValueError(f"{p}/meta.json 认不出格式(既不是 teaser rollout 也不是 genbench roll)")
    js = p if p.endswith(".json") else None
    if js is None and os.path.isdir(p):
        cands = [f for f in sorted(glob.glob(os.path.join(p, "*.json")))
                 if os.path.exists(f[:-5] + "_gen.mp4")]
        js = cands[0] if cands else None
    if js is None:
        raise FileNotFoundError(f"{path}:不是 rollout 目录(gen.mp4 + meta.json),也没有 sample_user 的 <name>.json + <name>_gen.mp4")
    meta = json.load(open(js))
    stem = js[:-5]
    name = meta.get("name") or os.path.basename(stem)
    return {"kind": "sample_user", "name": name, "dir": os.path.dirname(js), "gen": stem + "_gen.mp4",
            "ids": (lambda ip=meta["inputs"]["ids"]["path"]: _load_npz_ids(ip)), "scene": None,
            "map": name.split("__")[0] if "__" in name else None, "label": None, "title": None, "seed": meta.get("seed"),
            "sec": meta.get("timing", {}).get("total_sec"), "frames_npz": None, "sheet": stem + "_sheet.jpg",
            "side": stem + ".mp4", "meta": meta}


SCENE_EN.update({  # 量产第二批场景(scenes.py 第二批 35 个)
    "dunes": "Sand Dunes", "oasis": "Desert Oasis", "badlands": "Red-Rock Badlands", "salt_flats": "Salt Flats",
    "arabian": "Arabian Nights", "volcano_rim": "Volcano Crater", "demon_citadel": "Demon Citadel", "ash_wasteland": "Ash Wasteland",
    "phoenix_temple": "Temple of Fire", "enchanted_forest": "Enchanted Forest", "dark_forest": "Cursed Forest",
    "rainforest": "Rainforest", "redwood": "Redwood Forest", "snow_forest": "Snowy Pine Forest", "autumn_maple": "Autumn Maples",
    "cherry_blossom": "Cherry Blossoms", "dino_jungle": "Dinosaur Jungle", "circuit_board": "Circuit Board World",
    "toy_room": "Toy Room", "origami": "Origami World", "synthwave": "Synthwave Grid", "glass_palace": "Glass Palace",
    "honey_hive": "Honey Hive", "cosmic": "Cosmic Nebula", "dragon_lair": "Dragon's Hoard", "floating_library": "Magic Library",
    "ink_landscape": "Ink-Wash Landscape", "venice": "Canal City", "rice_terraces": "Rice Terraces", "tulip_fields": "Tulip Fields",
    "arctic_base": "Arctic Station", "space_station": "Overgrown Space Station", "battlefield": "Ancient Battlefield",
    "tibetan": "Mountain Monastery", "fairground": "Haunted Fairground"})
LOOK_EN = {"photoreal": "Photoreal", "cinematic": "Cinematic", "cartoon": "Cartoon", "pixel": "Pixel Art", "anime": "Anime",
           "painterly": "Hand-Painted", "lowpoly": "Low-Poly", "voxel": "Voxel"}


def scene_label(c: dict, override: str | None = None) -> str:
    """scene 形如 "<场景>@<画风>"(scenes.py 的派生场景)⇒ "Lava Realm · Pixel Art"。"""
    if override is not None:
        return override
    sc = c.get("scene") or ""
    if "@" in sc:
        b, lk = sc.split("@", 1)
        return f"{SCENE_EN.get(b, pretty(b))} · {LOOK_EN.get(lk, pretty(lk))}"
    return c.get("label") or SCENE_EN.get(sc, pretty(sc))


def map_label(c: dict, override: str | None = None) -> str:
    if override is not None:
        return override
    return MAP_EN.get(c.get("map") or "", pretty(c.get("map")))


def case_frames(c: dict, n: int | None) -> tuple[np.ndarray, np.ndarray]:
    """→ (生成 [F,H,W,3], ids [F,h,w]),F = min(视频帧数, ids 帧数, n)。"""
    gen = read_video(c["gen"], n)
    ids = c["ids"]()
    f = min(len(gen), len(ids))
    return gen[:f], ids[:f]


def fade_io(frames, n_total: int, k: int):
    for i, fr in enumerate(frames):
        a = min(1.0, (i + 1) / k, (n_total - i) / k) if k > 0 else 1.0
        yield fr if a >= 1.0 else (fr.astype(np.float32) * a).astype(np.uint8)


# ------------------------------------------------------------------ (a) side / gen ----
def legend_strip(ids_present: set[int], size: int = 20) -> Image.Image:
    items = [(n, ids) for n, ids in LEGEND if ids_present & set(ids)]
    sw = round(size * 0.9)
    widths = [sw + 8 + text_w(n, size) + 22 for n, _ in items]
    im = Image.new("RGBA", (int(sum(widths)) + 2, size + 10), (0, 0, 0, 0))
    d = ImageDraw.Draw(im)
    x = 0
    for (n, ids), w in zip(items, widths):
        d.rounded_rectangle((x, 4, x + sw, 4 + sw), 3, fill=tuple(int(v) for v in PAL[ids[0]]))
        if len(ids) > 1:                                             # 角色:下半格 = 肢体部件色
            d.rectangle((x, 4 + sw // 2, x + sw, 4 + sw), fill=tuple(int(v) for v in PAL[ids[1]]))
        draw_text(d, x + sw + 8, 1, n, size, (*MUTED, 255))
        x += w
    return im


def cmd_side(a) -> None:
    c = load_case(a.case)
    W, H = a.canvas
    gen, ids = case_frames(c, a.frames)
    gap, mx = a.gap, a.margin
    pw = (W - 2 * mx - gap) // 2
    ph = round(pw * gen.shape[1] / gen.shape[2])
    cap = round(24 * W / 1920)                                      # 标签字号(宽 1920 ⇒ 24)
    y0 = (H - ph) // 2 + (cap if a.legend else 0) // 2
    xs = (mx, mx + pw + gap)
    base = Image.new("RGB", (W, H), BG)
    d = ImageDraw.Draw(base)
    for x, (head, sub) in zip(xs, (("SEMANTIC MAP", map_label(c, a.map_label)), ("GENERATED", scene_label(c, a.scene_label)))):
        xe = draw_text(d, x, y0 - cap * 2, head, cap, FG, "SemiBold")
        if sub:
            draw_text(d, xe + cap * 0.6, y0 - cap * 2, "·  " + sub, cap, MUTED, "Medium")
    if a.title:
        draw_text(d, (W - text_w(a.title, cap + 8, "SemiBold")) / 2, y0 - cap * 2 - (cap + 8) * 2.4, a.title, cap + 8, FG, "SemiBold")
    if a.legend:
        lg = legend_strip(set(np.unique(ids[::8]).tolist()), round(cap * 0.82))
        base.paste(lg, (xs[0], y0 + ph + round(cap * 0.8)), lg)
    if a.note:
        draw_text(d, (W - text_w(a.note, round(cap * 0.8))) / 2, H - cap * 2.6, a.note, round(cap * 0.8), MUTED)
    base = np.asarray(base).copy()
    for x in xs:
        frame_rect(base, x, y0, pw, ph)
    nf = len(gen)

    def frames():
        for i in range(nf):
            out = base.copy()
            out[y0:y0 + ph, xs[0]:xs[0] + pw] = sem_panel(ids[i], pw, ph, not a.flat)
            out[y0:y0 + ph, xs[1]:xs[1] + pw] = fit(gen[i], pw, ph)
            yield out
    n = write_video(a.out, fade_io(frames(), nf, a.fade_io), crf=a.crf)
    print(f"side → {a.out}({W}×{H},{n} 帧)")


def cmd_gen(a) -> None:
    c = load_case(a.case)
    W, H = a.canvas
    gen = read_video(c["gen"], a.frames)
    ids = c["ids"]()[:len(gen)] if a.inset else None
    h, w = gen.shape[1:3]
    s = max(W / w, H / h) if a.fit == "crop" else min(W / w, H / h)
    sw, sh = even(w * s), even(h * s)
    if a.fit == "crop":
        sw, sh = max(W, sw), max(H, sh)
    ox, oy = (W - sw) // 2, (H - sh) // 2
    lab = scene_label(c, a.scene_label)
    badge = pill(lab, round(H * 0.03)) if lab and not a.no_badge else None
    m = round(H * 0.035)
    iw = even(W * a.inset_frac)
    ih = even(iw * h / w)
    ins_cap = pill("semantic input", round(H * 0.017), alpha=0.55, weight="Medium") if a.inset else None

    def frames():
        for i in range(len(gen)):
            big = cv2.resize(gen[i], (sw, sh), interpolation=cv2.INTER_LANCZOS4)
            if a.fit == "crop":
                out = big[-oy:-oy + H, -ox:-ox + W].copy()
            else:
                out = np.empty((H, W, 3), np.uint8)
                out[:] = BG
                out[oy:oy + sh, ox:ox + sw] = big
            if a.inset:
                x, y = W - m - iw, m
                out[y:y + ih, x:x + iw] = sem_panel(ids[i], iw, ih, not a.flat)
                frame_rect(out, x, y, iw, ih, (235, 235, 240), 2)
                paste_rgba(out, ins_cap, x + 6, y + ih - ins_cap[0].shape[0] - 6)
            if badge is not None:
                paste_rgba(out, badge, m, H - m - badge[0].shape[0])
            yield out
    n = write_video(a.out, fade_io(frames(), len(gen), a.fade_io), crf=a.crf)
    print(f"gen → {a.out}({W}×{H},{n} 帧,fit={a.fit}{',语义小窗' if a.inset else ''})")


# ------------------------------------------------------------------ (b) grid ----
def grid_layout(n: int, cols: int, W: int, H: int, aspect: float, margin: int, gap: int):
    rows = (n + cols - 1) // cols
    tw = (W - 2 * margin - (cols - 1) * gap) / cols
    th = tw / aspect
    if rows * th + (rows - 1) * gap + 2 * margin > H:
        th = (H - 2 * margin - (rows - 1) * gap) / rows
        tw = th * aspect
    tw, th = int(tw), int(th)
    gw, gh = cols * tw + (cols - 1) * gap, rows * th + (rows - 1) * gap
    x0, y0 = (W - gw) // 2, (H - gh) // 2
    return rows, tw, th, [(x0 + (k % cols) * (tw + gap), y0 + (k // cols) * (th + gap)) for k in range(n)]


def cmd_grid(a) -> None:
    cases = [load_case(p) for p in expand(a.cases)]
    if not cases:
        raise SystemExit("grid:没有 case")
    W, H = a.canvas
    cols = a.cols or int(np.ceil(np.sqrt(len(cases) * 16 / 9 * 480 / 832)))
    gens = [read_video(c["gen"], a.frames) if a.what == "gen" else None for c in cases]
    idss = [c["ids"]() if a.what == "sem" else None for c in cases]
    lens = [len(g) if g is not None else len(i) for g, i in zip(gens, idss)]
    nf = min(lens)
    h0, w0 = (gens[0].shape[1:3] if gens[0] is not None else idss[0].shape[1:3])
    rows, tw, th, pos = grid_layout(len(cases), cols, W, H, w0 / h0, a.margin, a.gap)
    fs = max(13, round(th * 0.075))
    labels = []
    for c in cases:
        s = {"scene": scene_label(c), "map": map_label(c), "name": c["name"], "none": ""}[a.labels]
        labels.append(pill(s, fs, alpha=0.6) if s else None)

    def frames():
        for i in range(nf):
            out = np.empty((H, W, 3), np.uint8)
            out[:] = BG
            for k, (x, y) in enumerate(pos):
                tile = fit(gens[k][i], tw, th) if a.what == "gen" else sem_panel(idss[k][i], tw, th, not a.flat)
                out[y:y + th, x:x + tw] = tile
                if labels[k] is not None:
                    paste_rgba(out, labels[k], x + round(fs * 0.5), y + th - labels[k][0].shape[0] - round(fs * 0.5))
            yield out
    n = write_video(a.out, fade_io(frames(), nf, a.fade_io), crf=a.crf)
    print(f"grid → {a.out}({len(cases)} 个 case,{rows}×{cols},格 {tw}×{th},{W}×{H},{n} 帧)")


# ------------------------------------------------------------------ (c) reel ----
def n_frames(path: str) -> int:
    import av
    with av.open(path) as c:
        n = c.streams.video[0].frames
    return n or sum(1 for _ in iter_video(path))


def cmd_reel(a) -> None:
    srcs = [p if p.endswith(".mp4") else load_case(p)["gen"] for p in expand(a.clips)]
    W, H = a.canvas if a.canvas is not None else probe(srcs[0])
    k = a.fade

    def norm(fr):
        if fr.shape[:2] == (H, W):
            return fr
        h, w = fr.shape[:2]
        s = min(W / w, H / h)
        sw, sh = even(w * s), even(h * s)
        out = np.empty((H, W, 3), np.uint8)
        out[:] = 0
        out[(H - sh) // 2:(H - sh) // 2 + sh, (W - sw) // 2:(W - sw) // 2 + sw] = cv2.resize(
            fr, (sw, sh), interpolation=cv2.INTER_LANCZOS4 if s > 1 else cv2.INTER_AREA)
        return out

    def scale(fr, t):
        return (fr.astype(np.float32) * t).astype(np.uint8)

    def frames():
        tail = None
        for ci, src in enumerate(srcs):
            clip = [norm(f) for f in iter_video(src)]
            first, last = ci == 0, ci == len(srcs) - 1
            if len(clip) <= (0 if first else k) + (0 if last else k):
                raise ValueError(f"{src} 只有 {len(clip)} 帧,不够 --fade {k}")
            if a.mode == "cross":
                head = 0
                if tail is not None:
                    for j in range(k):
                        t = (j + 1) / (k + 1)
                        yield (tail[j].astype(np.float32) * (1 - t) + clip[j].astype(np.float32) * t).astype(np.uint8)
                    head = k
                end = len(clip) if last else len(clip) - k
                yield from clip[head:end]
                tail = clip[end:]
            else:                                                     # 经黑场:每段(首段除外)淡入、(末段除外)淡出
                n = len(clip)
                for j, fr in enumerate(clip):
                    t = 1.0
                    if not first and j < k:
                        t = min(t, (j + 1) / (k + 1))
                    if not last and j >= n - k:
                        t = min(t, (n - j) / (k + 1))
                    yield fr if t >= 1.0 else scale(fr, t)

    total = sum(n_frames(s) for s in srcs) - (k * (len(srcs) - 1) if a.mode == "cross" else 0)
    n = write_video(a.out, fade_io(frames(), total, a.fade_io), crf=a.crf)
    print(f"reel → {a.out}({len(srcs)} 段,{W}×{H},{n} 帧 = {n / FPS:.1f} s,{a.mode} 过渡 {k} 帧)")


# ------------------------------------------------------------------ (d) index ----
def thumb(c: dict, tw: int = 416, th: int = 240, frame: int = 40) -> np.ndarray:
    """帧 frame 的生成画面 + 右上角 1/3 宽语义小窗(frames.npz 有就不解码视频)。"""
    rgb = ids = None
    fz = c.get("frames_npz")
    if fz and os.path.exists(fz):
        with np.load(fz) as z:
            idx = list(z["idx"])
            if frame in idx:
                rgb, ids = z["rgb"][idx.index(frame)], z["ids"][idx.index(frame)]
    if rgb is None:
        g = read_video(c["gen"], frame + 1)
        frame = min(frame, len(g) - 1)
        rgb, ids = g[frame], c["ids"]()[frame]
    out = fit(rgb, tw, th).copy()
    iw, ih = tw // 3, th // 3
    out[3:3 + ih, tw - iw - 3:tw - 3] = sem_panel(ids, iw, ih)
    cv2.rectangle(out, (tw - iw - 4, 2), (tw - 3, 3 + ih), (235, 235, 240), 1)
    return out


def cmd_index(a) -> None:
    paths = expand(a.roots)
    cases = []
    for p in paths:
        try:
            cases.append(load_case(p))
        except (FileNotFoundError, ValueError, KeyError):
            continue
    if not cases:
        raise SystemExit(f"index:{a.roots} 下没有认得出的结果")
    cases.sort(key=lambda c: c["name"])
    os.makedirs(os.path.join(a.out, "thumbs"), exist_ok=True)
    tw, th, lab_h, cols = a.thumb_w, round(a.thumb_w * 480 / 832), 26, a.cols
    tiles = []
    for c in cases:
        t = thumb(c, tw, th)
        tp = os.path.join(a.out, "thumbs", c["name"] + ".jpg")
        cv2.imwrite(tp, cv2.cvtColor(t, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 88])
        c["thumb"] = tp
        strip = Image.new("RGB", (tw, lab_h), BG)
        d = ImageDraw.Draw(strip)
        sub = " · ".join(s for s in (scene_label(c), map_label(c)) if s)
        x = draw_text(d, 6, 4, c["name"][:46], 14, FG, "SemiBold")
        if sub and x + 12 + text_w(sub, 13) < tw:
            draw_text(d, tw - 6 - text_w(sub, 13), 5, sub, 13, MUTED)
        tiles.append(np.concatenate([t, np.asarray(strip)], 0))
    pages = []
    for pi in range(0, len(tiles), a.per_page):
        chunk = tiles[pi:pi + a.per_page]
        rows = (len(chunk) + cols - 1) // cols
        sheet = np.empty((rows * (th + lab_h + 6) + 6, cols * (tw + 6) + 6, 3), np.uint8)
        sheet[:] = BG
        for k, t in enumerate(chunk):
            x, y = 6 + (k % cols) * (tw + 6), 6 + (k // cols) * (th + lab_h + 6)
            sheet[y:y + th + lab_h, x:x + tw] = t
        pp = os.path.join(a.out, f"contact_{len(pages):02d}.jpg")
        cv2.imwrite(pp, cv2.cvtColor(sheet, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 88])
        pages.append(pp)
    rel = lambda p: os.path.relpath(p, a.out) if p else ""      # noqa: E731
    lines = [f"# {a.title or 'teaser 结果索引'}", "",
             f"{len(cases)} 个结果 · 缩略图 = 帧 40(2.5 s)生成画面,右上角小窗 = 同帧语义条件。contact sheet:"
             + "、".join(f"[{os.path.basename(p)}]({rel(p)})" for p in pages), "",
             "| # | 缩略图 | name | 场景 | 地图 | seed | GPU s | 文件 |", "|---|---|---|---|---|---|---|---|"]
    for k, c in enumerate(cases):
        files = [f"[gen]({rel(c['gen'])})"]
        for key in ("side", "sheet"):
            if c.get(key) and os.path.exists(c[key]):
                files.append(f"[{key}]({rel(c[key])})")
        sec = c.get("sec")
        lines.append(f"| {k} | ![]({rel(c['thumb'])}) | `{c['name']}` | {scene_label(c)} | {map_label(c)} | {c.get('seed', '')} | "
                     f"{'' if sec is None else f'{sec:.0f}'} | {' · '.join(files)} |")
    lines += ["", "绝对路径:", ""] + [f"- `{c['name']}`: `{c['dir']}`" for c in cases]
    with open(os.path.join(a.out, "index.md"), "w", encoding="utf-8") as fh:
        fh.write("\n".join(lines) + "\n")
    print(f"index → {a.out}/index.md({len(cases)} 个,{len(pages)} 页 contact sheet)")


# ------------------------------------------------------------------ CLI ----
def _wh(s: str) -> tuple[int, int]:
    w, h = (int(v) for v in s.lower().split("x"))
    assert w % 2 == 0 and h % 2 == 0, "画布宽高须为偶数"
    return w, h


def build_parser() -> argparse.ArgumentParser:
    ap = argparse.ArgumentParser(description="teaser 素材合成(side / gen / grid / reel / index)")
    sub = ap.add_subparsers(dest="cmd", required=True)

    def common(p, canvas=True):
        p.add_argument("-o", "--out", required=True)
        p.add_argument("--crf", type=int, default=16)
        p.add_argument("--fade_io", type=int, default=0, help="首尾各 N 帧从黑淡入 / 淡出到黑")
        p.add_argument("--frames", type=int, default=None, help="只取前 N 帧(默认全部)")
        if canvas:
            p.add_argument("--canvas", type=_wh, default=CANVAS, help="画布 WxH,默认 1920x1080")
            p.add_argument("--flat", action="store_true", help="语义色图不描类别边界(真实数据的碎植被描线会显脏)")

    p = sub.add_parser("side", help="单个 case:语义 | 生成 并排")
    p.add_argument("case")
    common(p)
    p.add_argument("--legend", action="store_true")
    p.add_argument("--map_label", default=None)
    p.add_argument("--scene_label", default=None)
    p.add_argument("--title", default=None, help="面板上方居中的一行标题(默认无)")
    p.add_argument("--note", default=None, help="底部居中的一行小字(默认无)")
    p.add_argument("--margin", type=int, default=48)
    p.add_argument("--gap", type=int, default=32)
    p = sub.add_parser("gen", help="单个 case:只出生成视频 + 角标")
    p.add_argument("case")
    common(p)
    p.add_argument("--fit", choices=("crop", "pad"), default="crop")
    p.add_argument("--scene_label", default=None)
    p.add_argument("--no_badge", action="store_true")
    p.add_argument("--inset", action="store_true", help="右上角语义小窗")
    p.add_argument("--inset_frac", type=float, default=0.24)
    p = sub.add_parser("grid", help="N×M 宫格同步播放")
    p.add_argument("cases", nargs="+")
    common(p)
    p.add_argument("--cols", type=int, default=0, help="列数(默认按画布比例自动)")
    p.add_argument("--what", choices=("gen", "sem"), default="gen")
    p.add_argument("--labels", choices=("scene", "map", "name", "none"), default="scene")
    p.add_argument("--margin", type=int, default=0)
    p.add_argument("--gap", type=int, default=6)
    p = sub.add_parser("reel", help="顺序拼接 + 淡入淡出")
    p.add_argument("clips", nargs="+")
    common(p, canvas=False)
    p.add_argument("--canvas", type=_wh, default=None, help="默认 = 第一段的尺寸;其它尺寸的段等比缩放居中")
    p.add_argument("--fade", type=int, default=8, help="相邻两段之间的过渡帧数")
    p.add_argument("--mode", choices=("cross", "black"), default="cross")
    p = sub.add_parser("index", help="contact sheet + index.md")
    p.add_argument("roots", nargs="+", help="case 目录 / glob(加引号)/ @列表")
    p.add_argument("-o", "--out", required=True, help="输出目录")
    p.add_argument("--per_page", type=int, default=48)
    p.add_argument("--cols", type=int, default=8)
    p.add_argument("--thumb_w", type=int, default=416)
    p.add_argument("--title", default=None)
    return ap


def main(argv=None) -> None:
    a = build_parser().parse_args(argv)
    {"side": cmd_side, "gen": cmd_gen, "grid": cmd_grid, "reel": cmd_reel, "index": cmd_index}[a.cmd](a)


if __name__ == "__main__":
    main()