| """ |
| Render an LLM Cinema script to an actual video (animated GIF) + a poster frame, |
| so it can be watched outside a terminal. Same scenes/animation the engine plays. |
| |
| python render.py --concept "a tiny knight afraid of the dark" |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| from typing import TYPE_CHECKING |
|
|
| from PIL import Image, ImageDraw, ImageFont |
|
|
| import draw as drawer |
| import movies |
|
|
| if TYPE_CHECKING: |
| from schema import RGB, Grid, MovieSpec, ScenePlan, Shot, Sprite |
|
|
| W, H = 80, 18 |
| CW, CH, PAD = 10, 20, 10 |
| BG = (40, 42, 46) |
| FG = (124, 252, 154) |
| WHITE = (230, 237, 243) |
| YELLOW = (255, 228, 92) |
| |
|
|
|
|
| def _font(size=16): |
| for p in ["/System/Library/Fonts/Menlo.ttc", "/System/Library/Fonts/Monaco.ttf", |
| "/Library/Fonts/Andale Mono.ttf", |
| "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"]: |
| try: |
| return ImageFont.truetype(p, size) |
| except Exception: |
| pass |
| return ImageFont.load_default() |
|
|
|
|
| FONT = _font(16) |
|
|
|
|
| def _stamp(grid: Grid, lines: Sprite, x: int, y: int, rgb: RGB) -> None: |
| for r, line in enumerate(lines): |
| for c, ch in enumerate(line): |
| yy, xx = y + r, x + c |
| if 0 <= yy < H and 0 <= xx < W and ch != " ": |
| grid[yy][xx] = [ch, rgb] |
|
|
|
|
| def _stamp_occlude(grid: Grid, lines: Sprite, x: int, y: int, rgb: RGB) -> None: |
| """Like _stamp, but the sprite's whole bounding box is OPAQUE: non-space cells |
| draw the character, space cells clear to background. Drawing the cast back-to- |
| front, a front character thus fully covers (occludes) the ones behind it β the |
| top-z character is never overlapped; lower-z characters get covered.""" |
| for r, line in enumerate(lines): |
| yy = y + r |
| if not (0 <= yy < H): |
| continue |
| for c, ch in enumerate(line): |
| xx = x + c |
| if 0 <= xx < W: |
| grid[yy][xx] = [ch, rgb] if ch != " " else [" ", FG] |
|
|
|
|
| def _text(grid: Grid, row: int, s: str, rgb: RGB) -> None: |
| s = s[:W - 2] |
| start = max(0, (W - len(s)) // 2) |
| for i, ch in enumerate(s): |
| if 0 <= start + i < W and 0 <= row < H: |
| grid[row][start + i] = [ch, rgb] |
|
|
|
|
| def _wrap(s: str, width: int = W - 2, lines: int = 2) -> list[str]: |
| """Word-wrap `s` into at most `lines` centred subtitle lines; anything that |
| still overflows the last line is clipped with an ellipsis, never dropped.""" |
| out, cur = [], "" |
| for w in s.split(): |
| if cur and len(cur) + 1 + len(w) > width: |
| out.append(cur) |
| cur = w |
| else: |
| cur = (cur + " " + w).strip() |
| if cur: |
| out.append(cur) |
| if len(out) > lines: |
| rest = " ".join(out[lines - 1:]) |
| out = out[:lines - 1] + [rest[:width - 1].rstrip() + "β¦"] |
| return out |
|
|
|
|
| def _floor(grid: Grid, kind: str, f: int) -> None: |
| """Draw the ground as textured terrain: a SURFACE row of shade/wave glyphs over |
| a FILL row, coloured per `kind` (water/grass/sand/snow/stone/road/lava/...). |
| `f` shimmers animated floors (water, lava). 'sky' draws nothing (open air).""" |
| prof = movies.FLOOR.get(kind) |
| if not prof: |
| return |
| surf, fill, scol, fcol = prof["surf"], prof["fill"], prof["scol"], prof["fcol"] |
| shift = f if prof.get("anim") else 0 |
| g = H - 6 |
| for c in range(W): |
| grid[g][c] = [surf[(c + shift) % len(surf)], scol] |
| grid[g + 1][c] = [fill, fcol] |
|
|
|
|
| def _dim(rgb, k=0.6): |
| return tuple(int(c * k) for c in rgb) |
|
|
|
|
| def _scenery(grid: Grid, plan: ScenePlan, f: int, dx: int = 0) -> None: |
| """Set-dressing drawn BEHIND the cast: stars, a corner sun/moon, drifting |
| clouds, and dimmed background props (trees/cactus/pine/rock/mountain). `dx` |
| tracks the ground props with a panning camera.""" |
| for i in range(plan["stars"]): |
| grid[i % 2][(5 + i * 11) % (W - 2) + 1] = ["*", (205, 205, 165)] |
| if plan["sky"]: |
| art = drawer.draw_sprite(plan["sky"]) |
| _stamp(grid, art, W - len(art[0]) - 2, 0, movies.sprite_rgb(plan["sky"])) |
| if plan["clouds"]: |
| cloud = drawer.draw_sprite("cloud") |
| for i in range(plan["clouds"]): |
| _stamp(grid, cloud, (i * 24 + f // 2) % (W + 12) - 10, i % 2, (200, 205, 215)) |
| g = H - 6 |
| for label, frac in plan["ground"]: |
| art = drawer.draw_sprite(label) |
| _stamp(grid, art, int(frac * (W - 1)) - len(art[0]) // 2 + dx, g - len(art), |
| _dim(movies.sprite_rgb(label))) |
|
|
|
|
| def _emote(grid: Grid, glyph: str, rgb: RGB, cx: int, top_row: int) -> None: |
| """Float a mood glyph one row above a character's head, centred on the sprite.""" |
| y = top_row - 1 |
| if y >= 0: |
| _stamp(grid, [glyph], cx, y, rgb) |
|
|
|
|
| def _page_hold(text: str) -> int: |
| """Frames to HOLD one subtitle page, sized to its reading time (clamped).""" |
| read = int((len(text) / movies._READ_CPS) * (1000 / movies.FRAME_MS)) |
| return max(movies._HOLD_MIN, min(movies._HOLD_MAX, read)) |
|
|
|
|
| def _shot_frames(shot: Shot, layout=None, prev_cast=(), move=None, hold=None) -> list[Grid]: |
| """Frames for ONE shot. Characters already on stage (in prev_cast) stay LOCKED; |
| only NEW characters animate in. The narration is split into PAGES that each fit the |
| subtitle area β a long sentence CONTINUES on the next page, and the scene holds on |
| each page long enough to read it, so text is never cut off mid-sentence.""" |
| cast = shot.get("cast") or ["tree"] |
| layout = layout or movies.home_columns(cast, W) |
| action = shot.get("action", "gather") |
| camera = shot.get("camera", "static") |
| moods = shot.get("mood") or [] |
| kind = movies.floor_kind(shot.get("narration", ""), cast, shot.get("setting")) |
| plan = movies.scenery(shot, kind) |
| sprites = {nm: drawer.draw_sprite(nm) for nm in cast} |
| ground = H - 6 |
| narr, dlg = shot.get("narration", ""), shot.get("dialogue", "") |
|
|
| maxlines = 2 if dlg else 3 |
| wrapped = _wrap(narr, lines=10_000) or [""] |
| pages = [wrapped[i:i + maxlines] for i in range(0, len(wrapped), maxlines)] |
| |
| windows = [] |
| for pi, plines in enumerate(pages): |
| ptext = " ".join(plines) |
| mv = (move if move is not None else movies.MOVE_FRAMES) if pi == 0 else 8 |
| hd = hold if (hold is not None and pi == 0) else _page_hold(ptext) |
| windows.append((mv, hd, plines, ptext)) |
| total = sum(mv + hd for mv, hd, _, _ in windows) |
|
|
| out, gf = [], 0 |
| for pi, (mv, hd, plines, ptext) in enumerate(windows): |
| settled = (set(prev_cast) if pi == 0 else set(cast)) |
| for f in range(mv + hd): |
| te = movies.ease(f / max(1, mv)) |
| p = gf / max(1, total - 1) |
| cdx, cdy = movies.camera_offset(camera, p, gf) |
| grid = [[[" ", FG] for _ in range(W)] for _ in range(H)] |
| _floor(grid, kind, gf) |
| _scenery(grid, plan, gf, cdx) |
| for i, nm in enumerate(cast): |
| spr = sprites[nm] |
| h, home = len(spr), layout.get(nm, W // 2) |
| sw = max(len(r) for r in spr) |
| home = max(0, min(home, W - sw)) |
| base = ground - h |
| if action == "exit" and pi == len(windows) - 1: |
| x, row = int(home + te * (W - 2 - home)), base |
| elif nm in settled: |
| x, row = home, base |
| else: |
| start = -len(spr[0]) - 1 if home < W // 2 else W + 1 |
| x, row = int(start + te * (home - start)), base |
| if (nm in settled or f >= mv) and (gf // 5) % 2 == 0: |
| row -= 1 |
| x, row = x + cdx, row + cdy |
| _stamp_occlude(grid, spr, x, row, movies.sprite_rgb(nm)) |
| em = movies.mood_emote(moods[i]) if i < len(moods) else None |
| if em: |
| _emote(grid, em[0], em[1], x + len(spr[0]) // 2, row) |
| |
| base = max(H - len(plines) - 1, H - 3) |
| reveal = int(len(ptext) * te) + 1 |
| for k, line in enumerate(plines): |
| _text(grid, base + k, line[:max(0, reveal)], WHITE) |
| reveal -= len(line) + 1 |
| if dlg: |
| _text(grid, base - 1, "β" + dlg + "β", YELLOW) |
| out.append(grid) |
| gf += 1 |
| return out |
|
|
|
|
| def _card(lines: list[tuple[str, RGB]]) -> Grid: |
| """A centred text card on the charcoal background (title/end cards).""" |
| grid = [[[" ", FG] for _ in range(W)] for _ in range(H)] |
| top = max(0, (H - len(lines)) // 2 - 1) |
| for k, (text, rgb) in enumerate(lines): |
| _text(grid, top + k, text, rgb) |
| return grid |
|
|
|
|
| def _dim_grid(grid: Grid, k: float) -> Grid: |
| """A copy of `grid` with every colour scaled toward black (for fades).""" |
| return [[[cell[0], tuple(int(c * k) for c in cell[1])] for cell in row] for row in grid] |
|
|
|
|
| def _title_card(spec: MovieSpec) -> list[Grid]: |
| title = (spec.get("title") or "untitled").upper()[:30] |
| logline = (spec.get("logline") or "")[:54] |
| card = _card([("β β β L L M C I N E M A β β β", WHITE), ("", FG), |
| ("β" + title + "β", WHITE), (logline, (170, 176, 186))]) |
| return [_dim_grid(card, .3), _dim_grid(card, .65)] + [card] * 14 \ |
| + [_dim_grid(card, .5), _dim_grid(card, .2)] |
|
|
|
|
| def _end_card(spec: MovieSpec) -> list[Grid]: |
| title = (spec.get("title") or "").upper()[:30] |
| end = _card([("T H E E N D", WHITE), ("", FG), ("β" + title + "β", WHITE)]) |
| credits = _card([("directed & produced by", (170, 176, 186)), ("", FG), |
| ("C O N D U C T O R C R E A T I V E L A B S", WHITE), ("", FG), |
| ("conductorailabs.com", (170, 176, 186))]) |
| return ([_dim_grid(end, .3), _dim_grid(end, .65)] + [end] * 12 |
| + [_dim_grid(end, .4)] |
| + [_dim_grid(credits, .4)] + [credits] * 14 |
| + [_dim_grid(credits, .4), _dim_grid(credits, .15)]) |
|
|
|
|
| def iter_movie_frames(spec: MovieSpec, cards: bool = True): |
| """Yield the film one frame at a time, building each shot just-in-time. Streaming |
| players MUST use this (not the list builder): the first frame appears instantly and |
| the worker yields between shots, so a long film never blocks long enough to stall the |
| connection and get the generator restarted from the top.""" |
| order = movies.appearance_order(spec) |
| if cards: |
| yield from _title_card(spec) |
| prev: set[str] = set() |
| shots = spec["shots"] |
| for j, sh in enumerate(shots): |
| cast = [n for n in order if n in sh.get("cast", [])] |
| fr = _shot_frames(sh, movies.home_columns(cast, W), prev) |
| yield from fr |
| if j < len(shots) - 1: |
| yield _dim_grid(fr[-1], .5) |
| yield _dim_grid(fr[-1], .2) |
| prev = set(sh.get("cast", [])) |
| if cards: |
| yield from _end_card(spec) |
|
|
|
|
| def movie_frames(spec: MovieSpec, cards: bool = True) -> list[Grid]: |
| """Every frame of the film as a list (for the GIF/filmstrip). Players that stream |
| inline should use iter_movie_frames instead so they don't block building it all.""" |
| return list(iter_movie_frames(spec, cards)) |
|
|
|
|
| def _img(grid): |
| im = Image.new("RGB", (W * CW + 2 * PAD, H * CH + 2 * PAD), BG) |
| d = ImageDraw.Draw(im) |
| for r in range(H): |
| for c in range(W): |
| ch, rgb = grid[r][c] |
| if ch != " ": |
| d.text((PAD + c * CW, PAD + r * CH), ch, font=FONT, fill=rgb) |
| return im |
|
|
|
|
| def _ascii(grid: Grid) -> str: |
| return "\n".join("".join(cell[0] for cell in row).rstrip() for row in grid) |
|
|
|
|
| def settled_frames(spec: MovieSpec) -> list[Grid]: |
| """The final (settled) frame of each shot β the filmstrip / ascii preview.""" |
| order = movies.appearance_order(spec) |
| out: list[Grid] = [] |
| prev: set[str] = set() |
| for shot in spec["shots"]: |
| cast = [n for n in order if n in shot.get("cast", [])] |
| out.append(_shot_frames(shot, movies.home_columns(cast), prev)[-1]) |
| prev = set(shot.get("cast", [])) |
| return out |
|
|
|
|
| def render_spec(spec: MovieSpec) -> tuple[str, str, int]: |
| """Render a spec to a timestamped GIF + filmstrip PNG. Returns (gif, png, n_frames).""" |
| imgs = [_img(g) for g in movie_frames(spec)] |
| here, stamp = movies.saved_dir(), movies.stamped_slug(spec["title"]) |
| gif = os.path.join(here, stamp + ".gif") |
| imgs[0].save(gif, save_all=True, append_images=imgs[1:], duration=movies.FRAME_MS, loop=0) |
|
|
| shot_imgs = [_img(g) for g in settled_frames(spec)] |
| strip = Image.new("RGB", (shot_imgs[0].width, sum(im.height + 6 for im in shot_imgs)), BG) |
| y = 0 |
| for im in shot_imgs: |
| strip.paste(im, (0, y)) |
| y += im.height + 6 |
| film = os.path.join(here, stamp + ".png") |
| strip.save(film) |
| return gif, film, len(imgs) |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--concept") |
| ap.add_argument("--play", help="render a saved movie JSON (no model needed)") |
| a = ap.parse_args() |
|
|
| if a.play: |
| spec = json.load(open(a.play)) |
| elif a.concept: |
| spec = movies.direct(a.concept) |
| movies.save_movie(spec) |
| else: |
| ap.error("give --concept or --play") |
| print(f"{spec['title']} β {spec.get('logline','')}\n") |
|
|
| gif, film, n = render_spec(spec) |
| for g in settled_frames(spec): |
| print(_ascii(g)) |
| print() |
| print(f"saved video: {gif} ({n} frames)\nfilmstrip: {film}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|