Spaces:
Running
Running
| """ | |
| 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) # charcoal, like a terminal background | |
| FG = (124, 252, 154) | |
| WHITE = (230, 237, 243) | |
| YELLOW = (255, 228, 92) | |
| # per-sprite colours live in movies.sprite_rgb (shared with the terminal player) | |
| 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 # surface row; fill sits just below | |
| 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"]): # stable night-star scatter | |
| grid[i % 2][(5 + i * 11) % (W - 2) + 1] = ["*", (205, 205, 165)] | |
| if plan["sky"]: # sun / moon in the top-right corner | |
| art = drawer.draw_sprite(plan["sky"]) | |
| _stamp(grid, art, W - len(art[0]) - 2, 0, movies.sprite_rgb(plan["sky"])) | |
| if plan["clouds"]: # clouds drift slowly across the top | |
| 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 # background props stand on the floor line | |
| 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 [""] # ALL lines, never clipped | |
| pages = [wrapped[i:i + maxlines] for i in range(0, len(wrapped), maxlines)] | |
| # one (move, hold, lines, text) window per page; entrance only on the first page | |
| 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)) # pages after the first: all settled | |
| for f in range(mv + hd): | |
| te = movies.ease(f / max(1, mv)) | |
| p = gf / max(1, total - 1) # full-shot progress (camera) | |
| 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) | |
| base = ground - h | |
| if action == "exit" and pi == len(windows) - 1: # leaving on the last page | |
| x, row = int(home + te * (W - 2 - home)), base | |
| elif nm in settled: # locked, no re-entry | |
| x, row = home, base | |
| else: # new: slide in from nearer side | |
| 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 # gentle idle "breathing" | |
| 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) | |
| # this page's subtitle, typed on over its move window then held | |
| base = max(H - len(plines) - 1, H - 3) # keep a blank row above the floor | |
| 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), | |
| ("conductorcreativelabs.com", (170, 176, 186))]) | |
| return ([_dim_grid(end, .3), _dim_grid(end, .65)] + [end] * 12 | |
| + [_dim_grid(end, .4)] # fade THE END out | |
| + [_dim_grid(credits, .4)] + [credits] * 14 # fade the credits in + hold | |
| + [_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: # soft dip between scenes | |
| 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)] # full film: cards + shots + fades | |
| 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() | |