File size: 20,070 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
#!/usr/bin/env python3
"""teaser · 编辑 case(外观编辑 + 动作 / 轨迹控制)的合成(maliang venv,CPU)。被 edit_app.py compose / edit_sameworld.py compose
调用,也可单独跑。画法沿用 compose.py(深色底、细边框、胶囊标签、ffmpeg x264 crf 16 BT.709、16 fps、1920×1080)。

产物(outputs/teaser/edit/app/showcase/,下称 $O)
  look_<b>.mp4          同一张语义地图 × 多种首帧外观:左上 = 语义图,其余 = 原首帧 + 各外观编辑(同 ids、同按键)
  look_all_<v>.mp4      同一种编辑(如 night)在所有底子上(每格右上角是原版小窗)
  look_pairs.mp4        每个底子挑 2 种编辑,「原版 | 编辑」并排 4×… 宫格
  mirror_<b>.mp4        「原版 | 镜像(A↔D)」并排 + 按键 HUD
  text_<b>.mp4          原版 + 只改文本的 3 种(全 standing still / 反方向 / 一直攻击)2×2 + 文本按键 HUD(语义不变)
  sameworld_<g>.mp4     左 = 俯视总图(各 clip 轨迹一色 + 当前位置圆点),右 = 各 clip 的生成视频 + 按键 HUD
  sheets/*.jpg          对应的帧拼图(帧 0/20/40/60/80),给人看 / 报告用
按键 HUD(keys_hud):W / A / S / D 方向键 + ATK,按下 = 亮。帧 f 的按键 = blocks20[max(0, ceil(f/4) − 1)](cell j = 帧 4j−3..4j
← block j−1,cell 0 = cell 1)。

usage:$PY scripts/teaser/edit_compose.py app [--only look act]  |  sameworld <group>...
"""
from __future__ import annotations

import argparse
import json
import math
import os
import sys

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

M = "/opt/dlami/nvme/zhiyangdeng/maliang"
HERE = os.path.dirname(os.path.abspath(__file__))
for _p in (f"{M}/code/gc25d", HERE):
    if _p not in sys.path:
        sys.path.insert(0, _p)
import compose as CP  # noqa: E402

A = f"{M}/outputs/teaser/edit/app"
O = f"{A}/showcase"
W, H = CP.CANVAS


# ------------------------------------------------------------------ 按键 HUD ----
def block_of(f: int) -> int:
    return max(0, math.ceil(f / 4) - 1)


_HUD: dict = {}


def keys_hud(state, size: int = 26):
    """state = (w, a, s, d, attack) 0/1 → (rgb, alpha):上排 W,下排 A S D,右边 ATK。按下 = 白底黑字,否则半透明黑底灰字。"""
    key = (tuple(int(x) for x in state), size)
    if key in _HUD:
        return _HUD[key]
    g = round(size * 0.18)
    aw = round(size * 2.1)
    wd, ht = 3 * size + 2 * g + g + aw, 2 * size + g
    im = Image.new("RGBA", (wd + 2, ht + 2), (0, 0, 0, 0))
    d = ImageDraw.Draw(im)
    pos = {"W": (size + g, 0), "A": (0, size + g), "S": (size + g, size + g), "D": (2 * (size + g), size + g)}
    on = dict(zip("WASD", key[0][:4]))
    fs = round(size * 0.55)
    for k, (x, y) in pos.items():
        lit = on[k]
        d.rounded_rectangle((x, y, x + size, y + size), radius=round(size * 0.22),
                            fill=(245, 245, 250, 240) if lit else (0, 0, 0, 120),
                            outline=(255, 255, 255, 200) if lit else (200, 200, 210, 110), width=1)
        tw = CP.text_w(k, fs, "SemiBold")
        CP.draw_text(d, x + (size - tw) / 2, y + (size - fs) / 2 - 1, k, fs, (20, 20, 25, 255) if lit else (210, 212, 220, 220),
                     "SemiBold")
    x0 = 3 * size + 3 * g
    lit = key[0][4]
    d.rounded_rectangle((x0, size + g, x0 + aw, 2 * size + g), radius=round(size * 0.22),
                        fill=(255, 120, 70, 240) if lit else (0, 0, 0, 120),
                        outline=(255, 255, 255, 200) if lit else (200, 200, 210, 110), width=1)
    tw = CP.text_w("ATK", fs, "SemiBold")
    CP.draw_text(d, x0 + (aw - tw) / 2, size + g + (size - fs) / 2 - 1, "ATK", fs,
                 (20, 20, 25, 255) if lit else (210, 212, 220, 220), "SemiBold")
    a = np.asarray(im, np.float32)
    _HUD[key] = (a[..., :3].astype(np.uint8), a[..., 3:] / 255.0)
    return _HUD[key]


def load_blocks(case_dir: str) -> np.ndarray | None:
    p = f"{case_dir}/actions.npy"
    if os.path.exists(p):
        return np.load(p)
    meta = json.load(open(f"{case_dir}/meta.json"))
    act = (meta["job"].get("edit") or {}).get("actions")
    return np.load(act) if act and os.path.exists(act) else None


# ------------------------------------------------------------------ 角色跟拍放大 ----
PLAYER_IDS, BOSS_IDS = (10, 12, 13, 14, 15, 16), (11, 17, 18, 19, 20, 21)


def zoom_boxes(ids: np.ndarray, target: str, factor: float, smooth: int = 7) -> list[tuple[int, int, int, int]]:
    """逐帧跟拍框:target(player / boss)在 ids[t] 里的掩码质心(看不见就沿用上一帧),滑动平均 smooth 帧,框 = 画面 / factor。"""
    F, h, w = ids.shape
    cs, last = [], np.array([w / 2, h / 2])
    for t in range(F):
        ys, xs = np.nonzero(np.isin(ids[t], PLAYER_IDS if target == "player" else BOSS_IDS))
        if len(xs) >= 30:
            last = np.array([xs.mean(), ys.mean()])
        cs.append(last)
    cs = np.asarray(cs)
    k = np.ones(smooth) / smooth
    cs = np.stack([np.convolve(np.pad(cs[:, i], smooth // 2, mode="edge"), k, mode="valid") for i in range(2)], 1)
    bw, bh = w / factor, h / factor
    out = []
    for cx, cy in cs:
        x0 = int(round(np.clip(cx - bw / 2, 0, w - bw)))
        y0 = int(round(np.clip(cy - bh / 2, 0, h - bh)))
        out.append((x0, y0, x0 + int(bw), y0 + int(bh)))
    return out


# ------------------------------------------------------------------ 通用宫格 ----
def tiles_video(out: str, tiles: list[dict], cols: int, title: str | None = None, sub: str | None = None,
                margin: int = 40, gap: int = 14, crf: int = 16, nf: int = 81) -> None:
    """tiles:[{kind: gen|sem|img, case(dir), label, hud(bool), inset(dir:右上角小窗,取它的 gen)}],同步播放。"""
    top = 0
    if title:
        top = 64 if sub else 48
    rows, tw, th, pos = CP.grid_layout(len(tiles), cols, W, H - top, 832 / 480, margin, gap)
    pos = [(x, y + top) for x, y in pos]
    data = []
    for t in tiles:
        d = dict(t)
        if t["kind"] == "gen":
            d["frames"] = CP.read_video(f"{t['case']}/gen.mp4", nf)
            d["blocks"] = load_blocks(t["case"]) if t.get("hud") else None
        elif t["kind"] == "sem":
            d["ids"] = CP.load_case(t["case"])["ids"]()[:nf]
        if t.get("inset"):
            d["inset_frames"] = CP.read_video(f"{t['inset']}/gen.mp4", nf)
        if t.get("zoom") and t["kind"] == "gen":                         # (target, factor):跟拍放大
            ids = CP.load_case(t["case"])["ids"]()[:nf]
            boxes = zoom_boxes(ids, *t["zoom"])
            d["frames"] = np.stack([cv2.resize(f[y0:y1, x0:x1], (f.shape[1], f.shape[0]), interpolation=cv2.INTER_LANCZOS4)
                                    for f, (x0, y0, x1, y1) in zip(d["frames"], boxes)])
        data.append(d)
    fs = min(24, max(14, round(th * 0.06)))
    hud_size = min(28, max(16, round(th * 0.07)))
    for d in data:
        d["pill"] = CP.pill(d["label"], fs, alpha=0.62) if d.get("label") else None
    base = Image.new("RGB", (W, H), CP.BG)
    if title:
        dr = ImageDraw.Draw(base)
        CP.draw_text(dr, margin, 14, title, 28, CP.FG, "SemiBold")
        if sub:
            CP.draw_text(dr, margin, 14 + 34, sub, 18, CP.MUTED)
    base = np.asarray(base).copy()
    for x, y in pos:
        CP.frame_rect(base, x, y, tw, th)

    def frames():
        for i in range(nf):
            fr = base.copy()
            for d, (x, y) in zip(data, pos):
                if d["kind"] == "gen":
                    tile = CP.fit(d["frames"][min(i, len(d["frames"]) - 1)], tw, th)
                elif d["kind"] == "sem":
                    tile = CP.sem_panel(d["ids"][min(i, len(d["ids"]) - 1)], tw, th)
                else:
                    tile = CP.fit(d["img"], tw, th)
                fr[y:y + th, x:x + tw] = tile
                if d.get("inset_frames") is not None:
                    iw, ih = tw // 4, th // 4
                    fr[y + 6:y + 6 + ih, x + tw - iw - 6:x + tw - 6] = CP.fit(d["inset_frames"][i], iw, ih)
                    CP.frame_rect(fr, x + tw - iw - 6, y + 6, iw, ih, (235, 235, 240), 1)
                if d["pill"] is not None:
                    CP.paste_rgba(fr, d["pill"], x + round(fs * 0.5), y + th - d["pill"][0].shape[0] - round(fs * 0.5))
                if d.get("blocks") is not None:
                    hud = keys_hud(d["blocks"][block_of(i)], hud_size)
                    CP.paste_rgba(fr, hud, x + tw - hud[0].shape[1] - 8, y + th - hud[0].shape[0] - 8)
            yield fr
    os.makedirs(os.path.dirname(out), exist_ok=True)
    n = CP.write_video(out, frames(), crf=crf)
    print(f"→ {out}({len(tiles)} 格,{rows}×{cols},{n} 帧)", flush=True)


def sheet(paths: list[str], labels: list[str], out: str, frames=(0, 20, 40, 60, 80), tw: int = 312, sem: str | None = None):
    import edit_app as E
    E.sheet(paths, labels, out, frames, tw)


# ------------------------------------------------------------------ app:外观 / 镜像 / 文本 ----
def compose_app(a) -> None:
    import edit_app as E
    only = set(a.only or ["look", "act", "all_look", "gallery"])
    R = f"{A}/roll"
    bases = E.load_bases()
    done = lambda n: os.path.exists(f"{R}/{n}/meta.json")  # noqa: E731
    if "look" in only:
        for s, b in bases.items():
            vs = [v for v in ["orig"] + b["variants"] if done(f"{s}__{v}")]
            if len(vs) < 2:
                continue
            tiles = [{"kind": "sem", "case": f"{R}/{s}__orig", "label": "Semantic map (shared)"}] + \
                    [{"kind": "gen", "case": f"{R}/{s}__{v}", "label": E.EDIT_EN[v]} for v in vs]
            cols = 4 if len(tiles) > 9 else 3
            tiles_video(f"{O}/look_{s}.mp4", tiles, cols, "Same semantic map, different first frames",
                        "Layout from the semantic map · appearance from the first frame (edited with Qwen-Image) · "
                        "same keys")
            sheet([f"{R}/{s}__{v}" for v in vs], [f"{s}: {E.EDIT_EN[v]}" for v in vs], f"{O}/sheets/look_{s}.jpg")
    if "gallery" in only:
        for s, who in E.GALLERY.items():
            vs = [v for v in ["orig"] + E.gallery_variants(s) if done(f"{s}__{v}")]
            if len(vs) < 3:
                continue
            tiles = [{"kind": "sem", "case": f"{R}/{s}__orig", "label": "Semantic map (shared)"}] + \
                    [{"kind": "gen", "case": f"{R}/{s}__{v}", "label": E.EDIT_EN[v]} for v in vs]
            tiles_video(f"{O}/gallery_{s}_{who}.mp4", tiles, 4, f"Same map, different {'heroes' if who == 'hero' else 'bosses'}",
                        f"Only the {'hero' if who == 'hero' else 'boss'} in the first frame is edited (local Qwen-Image edit, "
                        "pixels outside the character mask pinned) · same semantic map and keys")
            sheet([f"{R}/{s}__{v}" for v in vs], [f"{s}: {E.EDIT_EN[v]}" for v in vs], f"{O}/sheets/gallery_{s}_{who}.jpg")
            zt = [{"kind": "gen", "case": f"{R}/{s}__{v}", "label": E.EDIT_EN[v],
                   "zoom": ("player" if who == "hero" else "boss", 2.6 if who == "hero" else 1.8)} for v in vs]
            tiles_video(f"{O}/gallery_{s}_{who}_zoom.mp4", zt, 4,
                        f"Same map, different {'heroes' if who == 'hero' else 'bosses'} (tracking close-up)",
                        "Camera crop follows the character's semantic mask · identity comes from the edited first frame, "
                        "pose and motion from the semantic capsules")
    if "all_look" in only:
        allv = sorted({v for b in bases.values() for v in b["variants"]})
        for v in allv:
            names = [s for s, b in bases.items() if v in b["variants"] and done(f"{s}__{v}")]
            if len(names) < 3:
                continue
            tiles = [{"kind": "gen", "case": f"{R}/{s}__{v}", "label": f"{s}", "inset": f"{R}/{s}__orig"}
                     for s in names[:12]]
            tiles_video(f"{O}/look_all_{v}.mp4", tiles, 4 if len(tiles) > 9 else 3, f"Edit: {E.EDIT_EN[v]}",
                        "Each tile: edited first frame + the original semantic map and keys (inset = original rollout)")
    if "act" in only:
        for s in E.MIRROR_BASES:
            if done(f"{s}__orig") and done(f"{s}__mirror"):
                tiles = [{"kind": "sem", "case": f"{R}/{s}__orig", "label": "Semantic map"},
                         {"kind": "sem", "case": f"{R}/{s}__mirror", "label": "Mirrored semantic map"},
                         {"kind": "gen", "case": f"{R}/{s}__orig", "label": "Original keys", "hud": True},
                         {"kind": "gen", "case": f"{R}/{s}__mirror", "label": "Mirrored world, A/D swapped",
                          "hud": True}]
                tiles_video(f"{O}/mirror_{s}.mp4", tiles, 2, "Mirror = action control",
                            "Semantic map + first frame mirrored left-right, keys A and D swapped: same world, opposite way")
                sheet([f"{R}/{s}__orig", f"{R}/{s}__mirror"], [f"{s}: original", f"{s}: mirrored (A<->D)"],
                      f"{O}/sheets/mirror_{s}.jpg")
        for s in E.TEXT_BASES:
            names = [f"{s}__orig"] + [f"{s}__text_{t}" for t in E.TEXT_VARIANTS]
            if all(done(n) for n in names):
                labs = ["Original text"] + [E.TEXT_EN[t] for t in E.TEXT_VARIANTS]
                tiles = [{"kind": "gen", "case": f"{R}/{n}", "label": l, "hud": True} for n, l in zip(names, labs)]
                tiles_video(f"{O}/text_{s}.mp4", tiles, 2, "Only the action text changes",
                            "Same semantic map + same first frame; keys / prompts replaced (HUD = the text given)")
                sheet([f"{R}/{n}" for n in names], [f"{s}: {l}" for l in labs], f"{O}/sheets/text_{s}.jpg")


# ------------------------------------------------------------------ sameworld ----
def compose_sameworld(name: str, a=None) -> None:
    S = f"{A}/sameworld"
    info = json.load(open(f"{S}/maps/{name}/group.json"))
    cases = [f"{S}/roll/{name}__{c['k']}_{c['start']:06d}" for c in info["clips"]]
    cases = [c for c in cases if os.path.exists(f"{c}/meta.json")]
    if len(cases) < 2:
        print(f"{name}:完成的 rollout 不够({len(cases)})")
        return
    clips = [c for c in info["clips"] if os.path.exists(f"{S}/roll/{name}__{c['k']}_{c['start']:06d}/meta.json")]
    plan = cv2.cvtColor(cv2.imread(info["plan"]), cv2.COLOR_BGR2RGB)
    nf = 81
    gens = [CP.read_video(f"{c}/gen.mp4", nf) for c in cases]
    blocks = [load_blocks(c) for c in cases]
    top = 64
    mg, gap = 36, 14
    # 左:plan 裁到各 clip 帧 0 视野 + 轨迹的外接框(外扩 15 %),缩到宽 ≤ PLAN_W;右:宫格
    pts = np.concatenate([np.asarray(c["foot0_px"], np.float32) for c in clips] +
                         [np.asarray(c["path_px"], np.float32) for c in clips], 0)
    lo, hi = pts.min(0), pts.max(0)
    pad = 0.15 * (hi - lo).max()
    x0c, y0c = int(max(0, lo[0] - pad)), int(max(0, lo[1] - pad))
    x1c, y1c = int(min(plan.shape[1], hi[0] + pad)), int(min(plan.shape[0], hi[1] + pad))
    plan = plan[y0c:y1c, x0c:x1c]
    PLAN_W, PLAN_H = 560, H - top - 2 * mg
    sc = min(PLAN_W / plan.shape[1], PLAN_H / plan.shape[0])
    pw, ph = round(plan.shape[1] * sc), round(plan.shape[0] * sc)
    plan_s = cv2.resize(plan, (pw, ph), interpolation=cv2.INTER_AREA if sc < 1 else cv2.INTER_LINEAR)
    py0 = top + (H - top - ph) // 2
    rx0 = mg + pw + gap
    n = len(cases)
    cols = 2 if n > 1 else 1
    rows, tw, th, pos = CP.grid_layout(n, cols, W - rx0, H - top, 832 / 480, mg, gap)
    pos = [(x + rx0, y + top) for x, y in pos]
    fs = min(24, max(14, round(th * 0.06)))
    pills = []
    for c in clips:
        col = tuple(int(v) for v in CP_COLORS[c["k"] % len(CP_COLORS)])
        pills.append(CP.pill(f"Play {c['k'] + 1}", fs, fg=(20, 20, 25), bg=col, alpha=0.9))
    mode = info.get("mode")
    title = ("Same world, different playthroughs" if mode == "union" else "Same start frame, different keys")
    sub = ("One procedural world built from the union of the clips' footprints · each clip: own camera, own keys, "
           "own first frame (same scene prompt + seed)" if mode == "union" else
           "Clips translated to one common start (boss removed) · one shared first frame · each clip: its own keys")
    base = Image.new("RGB", (W, H), CP.BG)
    dr = ImageDraw.Draw(base)
    CP.draw_text(dr, mg, 14, title, 28, CP.FG, "SemiBold")
    CP.draw_text(dr, mg, 48, sub, 17, CP.MUTED)
    base = np.asarray(base).copy()
    base[py0:py0 + ph, mg:mg + pw] = plan_s
    CP.frame_rect(base, mg, py0, pw, ph)
    for x, y in pos:
        CP.frame_rect(base, x, y, tw, th)
    hud_size = min(28, max(16, round(th * 0.07)))
    paths = [(np.asarray(c["path_px"], np.float32) - [x0c, y0c]) * sc for c in clips]

    def frames():
        for i in range(nf):
            fr = base.copy()
            for k, (c, P) in enumerate(zip(clips, paths)):
                j = min(len(P) - 1, round(i * (len(P) - 1) / (nf - 1)))
                col = tuple(int(v) for v in CP_COLORS[c["k"] % len(CP_COLORS)])
                cx, cy = int(round(mg + P[j][0])), int(round(py0 + P[j][1]))
                cv2.circle(fr, (cx, cy), 9, (0, 0, 0), -1, cv2.LINE_AA)
                cv2.circle(fr, (cx, cy), 7, col, -1, cv2.LINE_AA)
            for k, (g, b, (x, y)) in enumerate(zip(gens, blocks, pos)):
                fr[y:y + th, x:x + tw] = CP.fit(g[min(i, len(g) - 1)], tw, th)
                CP.paste_rgba(fr, pills[k], x + round(fs * 0.5), y + round(fs * 0.5))
                if b is not None:
                    hud = keys_hud(b[block_of(i)], hud_size)
                    CP.paste_rgba(fr, hud, x + tw - hud[0].shape[1] - 8, y + th - hud[0].shape[0] - 8)
            yield fr
    out = f"{O}/sameworld_{name}.mp4"
    os.makedirs(O, exist_ok=True)
    CP.write_video(out, frames())
    print(f"→ {out}", flush=True)
    sheet(cases, [f"{name} play {c['k'] + 1}" for c in clips], f"{O}/sheets/sameworld_{name}.jpg")
    cv2.imwrite(f"{O}/sheets/sameworld_{name}_plan.png", cv2.cvtColor(plan, cv2.COLOR_RGB2BGR))


def compose_sameworld_looks(name: str, looks=("night", "snow")) -> None:
    """start 组:行 = 外观(原首帧 / night / snow),列 = 各 clip 的按键 ⇒ 语义(同一世界)× 首帧(外观)× 按键(动作)三者独立。"""
    S = f"{A}/sameworld"
    info = json.load(open(f"{S}/maps/{name}/group.json"))
    rows_ = [None] + [lk for lk in looks]
    tiles = []
    for lk in rows_:
        for c in info["clips"]:
            d = f"{S}/roll/{name}__{c['k']}_{c['start']:06d}" + (f"__{lk}" if lk else "")
            if not os.path.exists(f"{d}/meta.json"):
                return
            tiles.append({"kind": "gen", "case": d, "hud": True,
                          "label": f"Play {c['k'] + 1}" + (f" · {lk.capitalize()}" if lk else " · Original")})
    tiles_video(f"{O}/sameworld_{name}_looks.mp4", tiles, len(info["clips"]),
                "One world, one start: appearance x keys",
                "Rows: first frame (original / edited to night / snow) · columns: different keys · same semantic world")
    sheet([t["case"] for t in tiles], [t["label"] for t in tiles], f"{O}/sheets/sameworld_{name}_looks.jpg")


CP_COLORS = [(255, 64, 200), (40, 220, 255), (255, 200, 40), (120, 255, 90), (255, 110, 60), (170, 120, 255)]  # = edit_sameworld.COLORS


def main(argv=None) -> None:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    sp = ap.add_subparsers(dest="cmd", required=True)
    p = sp.add_parser("app")
    p.add_argument("--only", nargs="*", default=None)
    q = sp.add_parser("sameworld")
    q.add_argument("groups", nargs="+")
    a = ap.parse_args(argv)
    if a.cmd == "app":
        compose_app(a)
    else:
        for g in a.groups:
            compose_sameworld(g, a)
            compose_sameworld_looks(g)


if __name__ == "__main__":
    main()