# collage.py — task 183 deterministic layout planner for animated manga collages. # Pure geometry, no ffmpeg/IO: ordered panels (+ aspects, RTL, emphasis, frame) -> a layout state # (template, aspect-correct resting rectangles, entrance vectors, z-order, hold/transition timing). # worker_render turns a state into a clip; this module decides WHERE everything sits so the decision # is testable without rendering. Coordinates are pixels in the output frame, origin top-left. # # ponytail: a bounded template set (n=1..4) covering the reference's common resting layouts, not all 7. # Add templates when a fixture proves a missing one is needed — the fit/RTL/entrance machinery is shared. MARGIN = 0.035 # outer + inter-panel gap as a fraction of the frame's short side HOLD_S = 3.0 # reference holds run ~2-4s; #185 overrides per-beat from narration length TRANS_S = 0.45 # reference transitions run ~0.3-0.6s def _fit(aspect, slot): """Aspect-correct rect centered inside slot (x,y,w,h). aspect=w/h. No stretch: letterbox to fit.""" sx, sy, sw, sh = slot if aspect >= sw / sh: # panel wider than slot -> width-bound w = sw; h = w / aspect else: # taller than slot -> height-bound h = sh; w = h * aspect return (sx + (sw - w) / 2.0, sy + (sh - h) / 2.0, w, h) def _slots(template, n, W, H, g): """Slot rectangles (pre-fit) for a template, in READING order (slot[0] = read first). g = gap in pixels. Slots tile the safe area; _fit later letterboxes each panel inside its slot.""" x0, y0 = g, g fw, fh = W - 2 * g, H - 2 * g if template == "centered_wide": return [(x0, y0, fw, fh)] if template == "vertical_pair": # two columns cw = (fw - g) / 2.0 return [(x0, y0, cw, fh), (x0 + cw + g, y0, cw, fh)] if template == "stacked_wides": # two rows rh = (fh - g) / 2.0 return [(x0, y0, fw, rh), (x0, y0 + rh + g, fw, rh)] if template == "strip_over_dominant": # thin top strip (reads first), big bottom sh = fh * 0.32 return [(x0, y0, fw, sh), (x0, y0 + sh + g, fw, fh - sh - g)] if template == "supporting_left_dominant_right": # small stack left, one dominant right lw = fw * 0.34 k = max(1, n - 1) rh = (fh - (k - 1) * g) / k left = [(x0, y0 + i * (rh + g), lw, rh) for i in range(k)] return left + [(x0 + lw + g, y0, fw - lw - g, fh)] if template == "quad": # 2x2 cw, rh = (fw - g) / 2.0, (fh - g) / 2.0 return [(x0, y0, cw, rh), (x0 + cw + g, y0, cw, rh), (x0, y0 + rh + g, cw, rh), (x0 + cw + g, y0 + rh + g, cw, rh)] raise ValueError(f"unknown template {template}") def _pick_template(aspects): """Choose a resting template from panel count and aspect ratios (w/h).""" n = len(aspects) if n <= 1: return "centered_wide" if n == 2: if all(a < 0.9 for a in aspects): # two tall panels -> side by side return "vertical_pair" if all(a > 1.15 for a in aspects): # two wide panels -> stacked return "stacked_wides" return "vertical_pair" if n == 3: return "supporting_left_dominant_right" return "quad" # 4 (planner caps callers at 4) def plan_layout(aspects, rtl=True, active=0, frame=(1080, 1920)): """Deterministic collage layout for one beat. aspects: panel width/height ratios in reading order (len 1..4). rtl: right-to-left reading (manga) -> reading-first panel takes the RIGHTmost horizontal slot. active: index of the dominant/emphasized panel (gets the largest slot where a template has one). returns dict: template, rects[(x,y,w,h)] aligned to input panel order, entrances[(dx,dy)] pixel offset a panel starts at before sliding to rest, z_order, emphasis, hold_s, transition_s. Rects/entrances are indexed to match the INPUT panel order (not slot order).""" n = len(aspects) if n == 0: return {"template": "empty", "rects": [], "entrances": [], "z_order": [], "emphasis": 0, "hold_s": HOLD_S, "transition_s": TRANS_S} W, H = frame g = int(MARGIN * min(W, H)) template = _pick_template(aspects) slots = _slots(template, n, W, H, g) # Map input panels (reading order) to slots. Templates whose first slots are a horizontal run # honor RTL by reversing that run so the reading-first panel lands on the right. order = list(range(n)) if rtl and template in ("vertical_pair", "quad"): if template == "vertical_pair": order = [1, 0] else: # quad: reverse each row order = [1, 0, 3, 2][:n] # dominant panel takes the dominant slot when the template has a distinguished one (last slot). if template in ("strip_over_dominant", "supporting_left_dominant_right") and 0 <= active < n: rest = [i for i in range(n) if i != active] order = rest + [active] # dominant slot is the last one in _slots order rects = [None] * n for slot_i, panel_i in enumerate(order): rects[panel_i] = _fit(aspects[panel_i], slots[slot_i]) # entrances: reading-direction slide for horizontal templates; dominant scales in place (dy=0,dx=0). edge = W if rtl else -W # RTL panels enter from the right (+x), LTR from left entrances = [] for i in range(n): if i == active and template in ("centered_wide", "strip_over_dominant", "supporting_left_dominant_right"): entrances.append((0, 0)) # emphasized panel resolves by scale, not slide elif template == "stacked_wides": entrances.append((0, -H if i == 0 else H)) # rows drop/rise into place else: entrances.append((edge, 0)) # supporting panels sit above the dominant in z so their shadow reads; dominant drawn first (back). z_order = sorted(range(n), key=lambda i: 0 if i == active else 1) return {"template": template, "rects": rects, "entrances": entrances, "z_order": z_order, "emphasis": active, "hold_s": HOLD_S, "transition_s": TRANS_S} if __name__ == "__main__": W, H = 1080, 1920 def _inside(r): x, y, w, h = r return x >= -1 and y >= -1 and x + w <= W + 1 and y + h <= H + 1 # 1) single panel -> centered_wide, aspect preserved, inside frame. p = plan_layout([1.5], frame=(W, H)) assert p["template"] == "centered_wide" and len(p["rects"]) == 1 x, y, w, h = p["rects"][0] assert abs(w / h - 1.5) < 1e-3 and _inside(p["rects"][0]) # 2) two tall panels -> vertical_pair; RTL puts reading-first (panel 0) on the RIGHT. p = plan_layout([0.6, 0.6], rtl=True, frame=(W, H)) assert p["template"] == "vertical_pair" assert p["rects"][0][0] > p["rects"][1][0], "RTL: panel 0 is rightmost" # LTR flips it. q = plan_layout([0.6, 0.6], rtl=False, frame=(W, H)) assert q["rects"][0][0] < q["rects"][1][0], "LTR: panel 0 is leftmost" # 3) two wide panels -> stacked_wides; panel 0 on top. p = plan_layout([1.6, 1.6], frame=(W, H)) assert p["template"] == "stacked_wides" and p["rects"][0][1] < p["rects"][1][1] # 4) three panels, active=2 -> dominant takes the large right slot (widest rect). p = plan_layout([0.7, 0.7, 1.3], active=2, frame=(W, H)) assert p["template"] == "supporting_left_dominant_right" dom = p["rects"][2][2] * p["rects"][2][3] assert all(dom >= p["rects"][i][2] * p["rects"][i][3] for i in (0, 1)), "active is dominant area" assert all(_inside(r) for r in p["rects"]) # 5) aspect never stretched: fitted rect ratio == input aspect for every panel/template. for asp in ([1.5], [0.6, 0.6], [1.6, 1.6], [0.7, 0.7, 1.3], [1.0, 1.0, 1.0, 1.0]): pl = plan_layout(asp, frame=(W, H)) for a, r in zip(asp, pl["rects"]): assert abs(r[2] / r[3] - a) < 1e-3, (asp, a, r) # 6) quad RTL reverses each row; all four rects disjoint-ish (tile the frame). p = plan_layout([1, 1, 1, 1], rtl=True, frame=(W, H)) assert p["template"] == "quad" and len(p["rects"]) == 4 assert p["rects"][0][0] > p["rects"][1][0], "top row RTL: panel0 right of panel1" # 7) entrances: RTL horizontal-slide panels start off the right edge; deterministic. p = plan_layout([0.6, 0.6], rtl=True, frame=(W, H)) assert any(dx > 0 for dx, _ in p["entrances"]), "RTL entrance from right" assert plan_layout([0.6, 0.6], frame=(W, H)) == plan_layout([0.6, 0.6], frame=(W, H)) print("collage self-check ok")