File size: 14,752 Bytes
e93d4ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5b6e8aa
e93d4ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ac7aad
e93d4ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ac7aad
e93d4ef
 
 
 
 
 
 
 
 
 
 
 
 
ae6263a
 
 
 
 
 
e93d4ef
ae6263a
 
 
 
e93d4ef
5b6e8aa
e93d4ef
 
 
 
 
 
2ac7aad
e93d4ef
ae6263a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d6f11
 
ae6263a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ac7aad
ae6263a
 
 
 
 
 
 
 
e93d4ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da34445
e93d4ef
 
 
 
 
 
 
da34445
e93d4ef
da34445
b05b931
e93d4ef
 
 
 
 
 
8b0a4e8
 
 
 
 
e93d4ef
8b0a4e8
 
e93d4ef
 
 
 
5b6e8aa
8b0a4e8
e93d4ef
8b0a4e8
 
e93d4ef
8b0a4e8
 
 
 
 
 
 
 
e93d4ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8b0a4e8
e93d4ef
 
 
 
 
 
 
 
 
 
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
"""
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)
                sw = max(len(r) for r in spr)                     # widest row
                home = max(0, min(home, W - sw))                  # keep the whole sprite on screen
                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),
                     ("conductorailabs.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()