| """ |
| LLM Cinema — CUSTOM frontend on gr.Server (Gradio's FastAPI API engine). |
| |
| This is the "off-brand" build: instead of Gradio components, we serve our OWN |
| HTML/CSS/JS cinema and stream frames over Server-Sent Events from FastAPI routes |
| on a gr.Server instance. The film engine (movies + render) is reused as-is. |
| |
| Run: python server_app.py |
| Deploy: HF Space (set app_file: server_app.py). Same CLAUDEMOVIES_LLM_* secrets. |
| """ |
|
|
| import html |
| import json |
| import os |
| import re |
| import threading |
| import time |
| import uuid |
|
|
| import gradio as gr |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse |
|
|
| import movies |
| import render |
|
|
| W, H = render.W, render.H |
| HERE = os.path.dirname(__file__) |
| SHOW = os.path.join(HERE, "showcase") |
|
|
| |
| _BANNED = re.compile(r"\b(" + "|".join([ |
| "fuck", "fucks", "fucking", "fucker", "shit", "bitch", "cunt", "asshole", "bastard", |
| "dick", "piss", "slut", "whore", "nigger", "nigga", "faggot", "fag", "kike", "spic", |
| "chink", "gook", "wetback", "tranny", "retard", "coon", "dyke", "paki", "beaner", |
| "heil hitler", "sieg heil", "white power", "kkk", "gas the jews", |
| ]) + r")\w*", re.I) |
|
|
|
|
| def _blocked(t): |
| return bool(_BANNED.search(t or "")) |
|
|
|
|
| _MAXLEN = 60 |
| |
| _INJECT = re.compile( |
| r"(ignore\s+(all\s+|the\s+)?(previous|prior|above|earlier)" |
| r"|disregard\s+(all|the|previous|prior)" |
| r"|system\s+prompt|you\s+are\s+now|act\s+as\s+(a|an|if)" |
| r"|jailbreak|developer\s+mode|override\s+(the|your)" |
| r"|forget\s+(everything|all|the)|new\s+instructions)", re.I) |
|
|
|
|
| def _clean_concept(s): |
| """Authoritative server-side sanitiser: strip control chars / markup, collapse whitespace, |
| and HARD-cap to 60 chars so a single short film idea is all the model ever receives.""" |
| s = re.sub(r"[\x00-\x1f\x7f]", " ", s or "") |
| s = "".join(ch for ch in s if ch.isprintable()) |
| s = re.sub(r"[<>{}`\\]", "", s) |
| s = re.sub(r"\s+", " ", s).strip() |
| return s[:_MAXLEN] |
|
|
|
|
| def _films(folder): |
| out = {} |
| order = ["knight.json", "paper-boat.json", "rain-cat.json", "troll-dance.json", |
| "snail-race.json", "ghost.json", "bread-dragon.json"] |
| paths = sorted(__import__("glob").glob(os.path.join(folder, "*.json")), |
| key=lambda p: (order.index(os.path.basename(p)) if os.path.basename(p) in order else 99, |
| os.path.basename(p))) |
| for p in paths: |
| try: |
| out[json.load(open(p)).get("title") or os.path.basename(p)[:-5]] = p |
| except Exception: |
| continue |
| return out |
|
|
|
|
| def grid_to_html(grid, title=""): |
| rows = [] |
| for r in range(H): |
| cells, row, i = grid[r], "", 0 |
| while i < W: |
| col, j, seg = cells[i][1], i, "" |
| while j < W and cells[j][1] == col: |
| seg += cells[j][0] |
| j += 1 |
| hexc = "#%02x%02x%02x" % col if isinstance(col, tuple) else "#7df9a6" |
| esc = html.escape(seg) |
| row += f"<span style='color:{hexc}'>{esc}</span>" if seg.strip() else esc |
| i = j |
| rows.append(row) |
| bar = (f"<div class='bar'><i></i><i></i><i></i><b>now_playing: {html.escape(title)}</b></div>") |
| return f"{bar}<pre>{chr(10).join(rows)}</pre>" |
|
|
|
|
| def _tint(grid): |
| """The frame's lit colour and 'energy' — a saturation/brightness-weighted average of the |
| drawn cells, so the audience glow becomes a low-opacity MIRROR of the action on screen. |
| Energy is keyed to how much is lit, so sparse frames (the title/credit cards) glow ~0.""" |
| tr = tg = tb = tw = 0.0 |
| lit = 0 |
| for row in grid: |
| for cell in row: |
| ch = cell[0] |
| if ch == " " or ch == "": |
| continue |
| r, g, b = cell[1] |
| lit += 1 |
| mx, mn = max(r, g, b), min(r, g, b) |
| lum = 0.2126 * r + 0.7152 * g + 0.0722 * b |
| sat = (mx - mn) / mx if mx else 0.0 |
| w = lum * (0.35 + sat) |
| tr += r * w; tg += g * w; tb += b * w; tw += w |
| cov = lit / float(W * H) |
| energy = max(0.0, min(1.0, (cov - 0.10) * 6.0)) |
| if tw <= 0: |
| return [150, 180, 235], 0.0 |
| return [int(tr / tw), int(tg / tw), int(tb / tw)], round(energy, 3) |
|
|
|
|
| def _payload(grid, title=""): |
| tint, energy = _tint(grid) |
| return {"html": grid_to_html(grid, title), "tint": tint, "lvl": energy} |
|
|
|
|
| def _screen(msg, label="ready"): |
| bar = f"<div class='bar'><i></i><i></i><i></i><b>{html.escape(label)}</b></div>" |
| return f"{bar}<pre class='msg'>{html.escape(msg)}</pre>" |
|
|
|
|
| def _sse(frames): |
| for fr in frames: |
| payload = fr if isinstance(fr, dict) else {"html": fr} |
| yield "data: " + json.dumps(payload) + "\n\n" |
| yield "data: " + json.dumps({"done": True}) + "\n\n" |
|
|
|
|
| |
| |
| PAGE = r"""<!doctype html><html lang=en><head><meta charset=utf-8> |
| <meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover"> |
| <title>LLM Cinema</title> |
| <meta name=description content="A small model writes, draws and directs original ~90-second ASCII films."> |
| <meta property="og:title" content="LLM Cinema"> |
| <meta property="og:description" content="A small model writes, draws and directs original ASCII films. Presented by Conductor Creative Labs."> |
| <meta property="og:type" content="website"> |
| <meta property="og:image" content="https://conductorailabs-llm-cinema.hf.space/static/share.png"> |
| <meta property="og:image:width" content="1200"> |
| <meta property="og:image:height" content="630"> |
| <meta name="twitter:card" content="summary_large_image"> |
| <meta name="twitter:title" content="LLM Cinema"> |
| <meta name="twitter:description" content="A small model writes, draws and directs original ASCII films."> |
| <meta name="twitter:image" content="https://conductorailabs-llm-cinema.hf.space/static/share.png"> |
| <link rel=preconnect href=https://fonts.googleapis.com> |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel=stylesheet> |
| <style> |
| :root{--accent:#3ddc97;--accent2:#36c6e0;--ink:#e7ebf0} |
| *{box-sizing:border-box;margin:0} |
| html,body{height:100%;font-family:'JetBrains Mono',ui-monospace,monospace;background:#04060a;color:var(--ink);overflow:hidden} |
| /* the photo (with a transparent screen cut-out) sits ON TOP; the film screen is BEHIND |
| it and shows through the hole — pixel-perfect. While a film plays we dim the PHOTO itself |
| (its transparent hole stays clear so the screen stays bright) and pool soft light below. */ |
| #bg{position:fixed;inset:0;width:100%;height:100%;object-fit:cover;z-index:1; |
| filter:brightness(.62) saturate(.9);transition:filter 1.4s ease} |
| /* while a film plays the whole room goes really dark */ |
| body.playing #bg{filter:brightness(.3) saturate(.78)} |
| /* the projection, BEHIND the photo, fills the cut-out hole */ |
| #screen{position:fixed;left:22.3%;top:11.1%;width:57.7%;height:43.9%;z-index:0;background:#04060a; |
| display:flex;align-items:center;justify-content:center;overflow:hidden} |
| #screen .bar{display:none} |
| /* REVEAL: a second copy of the photo at near-full brightness, shown ONLY through a soft, |
| very blurry blob rising from UNDER the screen. The projector light "uncovers" the dark |
| room (reveals the photo's own opacity) rather than painting white on top of it. */ |
| #reveal{position:fixed;inset:0;z-index:2;pointer-events:none;opacity:0; |
| background:url(static/cinema.png) center/cover no-repeat; |
| filter:brightness(1.12) saturate(1.04) blur(8px); |
| -webkit-mask-image: |
| linear-gradient(to bottom, transparent 0 58%, #000 80%), |
| radial-gradient(56% 48% at 51% 75%, #000 0%, rgba(0,0,0,.5) 44%, transparent 80%); |
| -webkit-mask-composite:source-in; |
| mask-image: |
| linear-gradient(to bottom, transparent 0 58%, #000 80%), |
| radial-gradient(56% 48% at 51% 75%, #000 0%, rgba(0,0,0,.5) 44%, transparent 80%); |
| mask-composite:intersect; |
| transition:opacity 1.4s ease, filter .22s linear} |
| /* the colour-mirror bloom over the reveal — JS sets its colour per frame (transparent until then) */ |
| #spill{position:fixed;inset:0;z-index:3;pointer-events:none;opacity:0;mix-blend-mode:screen; |
| background:transparent;filter:blur(40px);transition:opacity 1.4s ease, filter .22s linear; |
| -webkit-mask-image:linear-gradient(to bottom, transparent 0 58%, #000 76%); |
| mask-image:linear-gradient(to bottom, transparent 0 58%, #000 76%)} |
| body.playing #reveal{opacity:1} |
| body.playing #spill{opacity:1} |
| #brand{position:fixed;top:16px;left:20px;z-index:4;font-weight:700;letter-spacing:.06em;font-size:15px; |
| text-shadow:0 1px 8px #000}#brand b{color:var(--accent)} |
| #screen pre{margin:0;white-space:pre;text-align:center;color:#cfd6df;line-height:1.12; |
| font-size:clamp(11px,1.5vw,19px);text-shadow:none} |
| #screen pre.msg{color:var(--ink)} |
| /* opening-credits sequence, projected on the screen while the film renders */ |
| #credwrap{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center; |
| gap:20px;text-align:center;padding:0 6%;opacity:0;transition:opacity 1.1s ease;color:#eef2f6} |
| #credwrap.show{opacity:1} |
| .cr-s{font-size:clamp(10px,1.45vw,17px);letter-spacing:.42em;text-transform:uppercase;color:#cfd6df;line-height:1.9} |
| .cr-s b{color:#fff;font-weight:700} |
| .cr-big{font-size:clamp(30px,6.6vw,82px);font-weight:700;letter-spacing:.07em;line-height:.96; |
| text-shadow:0 0 42px rgba(238,242,246,.22)} |
| /* the ANSI-shadow ASCII title — WHITE, sized per context (#screen pre would otherwise |
| force its green film-frame style, so these need the #screen id for specificity) */ |
| #screen pre.cr-title{margin:0;white-space:pre;line-height:1.0;color:#eef2f6;text-align:center; |
| font-size:clamp(4px,0.78vw,9px);text-shadow:0 0 26px rgba(238,242,246,.2)} |
| #screen pre.wel-title{margin:0;white-space:pre;line-height:1.0;color:#eef2f6;text-align:center; |
| font-size:clamp(4px,0.72vw,8px);text-shadow:0 0 16px rgba(238,242,246,.16)} |
| #screen pre.cr-art{font-size:clamp(7px,1.05vw,13px);line-height:1.05;color:#8b94a2;margin-top:4px; |
| white-space:pre;text-shadow:none} |
| .cr-sub{font-size:clamp(9px,1.2vw,14px);letter-spacing:.5em;text-transform:uppercase;color:#9aa3b0; |
| animation:lpulse 1.8s ease-in-out infinite} |
| /* the idle "now showing" welcome card on the theatre screen */ |
| .scene-center{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center; |
| gap:14px;text-align:center;padding:0 6%;color:#eef2f6} |
| .w-dir{font-size:clamp(8px,1.15vw,13px);color:#aab2bd;letter-spacing:.03em;line-height:1.7;max-width:84%;margin-top:2px} |
| .w-dir .wlink{color:#cfd6df;text-decoration:underline}.w-dir .wlink:hover{color:#fff} |
| /* controls docked along the bottom, over the audience */ |
| #controls{position:fixed;left:0;right:0;bottom:0;z-index:4;padding:18px 22px 20px; |
| background:linear-gradient(to top,rgba(4,6,10,.97),rgba(4,6,10,.78) 65%,transparent)} |
| #crow{display:flex;gap:10px;align-items:center;max-width:1120px;margin:0 auto 10px;width:100%} |
| #concept{flex:1;min-width:0;background:rgba(11,14,18,.92);border:1px solid #2b3540;border-radius:11px; |
| color:var(--ink);padding:12px 15px;font:14px 'JetBrains Mono',monospace} |
| #concept:focus{outline:none;border-color:var(--accent)} |
| #controls button{border:none;border-radius:11px;padding:12px 18px;cursor:pointer;white-space:nowrap; |
| font:700 13px 'JetBrains Mono',monospace;color:var(--ink);background:#222b36} |
| #controls button:hover{filter:brightness(1.18)} |
| #go{background:linear-gradient(92deg,var(--accent),var(--accent2));color:#04130d} |
| #stop{color:#ff8a84;background:#16191f;border:1px solid #3a2a2c} |
| #gallery{display:flex;gap:8px;overflow-x:auto;max-width:1120px;margin:0 auto;width:100%; |
| padding-bottom:3px;scrollbar-width:thin} |
| #gallery .film{flex:0 0 auto;background:rgba(12,15,20,.8);border:1px solid #2b3540;color:#aeb6c2; |
| border-radius:9px;padding:8px 13px;font-size:12px;cursor:pointer;white-space:nowrap} |
| #gallery .film:hover{color:#fff;border-color:var(--accent)} |
| #gallery::-webkit-scrollbar{height:6px}#gallery::-webkit-scrollbar-thumb{background:#2a3340;border-radius:3px} |
| /* off-grid download note — always visible under the controls */ |
| #foot{max-width:1120px;margin:9px auto 0;text-align:center;font-size:11px;color:#8b94a2;letter-spacing:.02em} |
| #foot a{color:#aeb6c2;text-decoration:underline}#foot a:hover{color:#fff} |
| /* About button + modal */ |
| #aboutbtn{position:fixed;top:14px;right:16px;z-index:6;background:rgba(11,14,18,.7);border:1px solid #2b3540; |
| color:#cfd6df;border-radius:9px;padding:7px 14px;font:600 12px 'JetBrains Mono',monospace;cursor:pointer; |
| letter-spacing:.06em;text-transform:uppercase;backdrop-filter:blur(4px)} |
| #aboutbtn:hover{color:#fff;border-color:var(--accent)} |
| #about{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;padding:20px; |
| background:rgba(2,4,8,.8);backdrop-filter:blur(6px);opacity:0;pointer-events:none;transition:opacity .35s ease} |
| #about.show{opacity:1;pointer-events:auto} |
| #about .card{position:relative;max-width:560px;max-height:86vh;overflow:auto;background:#0b0e13; |
| border:1px solid #232c38;border-radius:16px;padding:30px 32px;color:#dfe5ec;box-shadow:0 24px 80px rgba(0,0,0,.6)} |
| #about h1{font-size:22px;letter-spacing:.06em;margin-bottom:8px}#about h1 b{color:var(--accent)} |
| #about .lead{color:#aeb6c2;font-size:13px;line-height:1.65;margin-bottom:6px} |
| #about h2{font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:#8b94a2;margin:18px 0 9px} |
| #about ul{list-style:none;display:flex;flex-direction:column;gap:9px} |
| #about li{font-size:12.5px;line-height:1.5;color:#cfd6df}#about li b{color:#fff} |
| #about code{background:#161b22;border-radius:4px;padding:1px 5px;font-size:11.5px} |
| #about .foot2{margin-top:20px;padding-top:16px;border-top:1px solid #1c242f;font-size:12.5px;color:#9aa3b0;line-height:1.8} |
| #about .foot2 b{color:#eef2f6}#about a{color:var(--accent2);text-decoration:none}#about a:hover{text-decoration:underline} |
| #about .x{position:absolute;top:14px;right:18px;color:#8b94a2;font-size:24px;line-height:1;cursor:pointer} |
| #about .x:hover{color:#fff} |
| /* ── mobile: stack the input over the buttons so nothing is cut off, and keep clear of the |
| phone's home indicator with the safe-area inset ── */ |
| #fs.mob{display:none} /* fullscreen toggle: mobile only */ |
| @media (max-width:680px){ |
| /* on mobile, placeScreen() zooms the photo so the theatre screen fills the width and pins |
| #screen to it (nothing cut off). The photo-reveal glow assumes desktop cover, so hide it. */ |
| #reveal{display:none} |
| /* the theatre screen is short & wide on mobile — shrink intro content so it fits, not clips */ |
| .scene-center{gap:7px;padding:0 4%} |
| #screen pre.wel-title,#screen pre.cr-title{font-size:clamp(3px,1.5vw,6px)} |
| #screen pre.cr-art{display:none} /* drop the decorative reel on mobile */ |
| .cr-s{font-size:9px;letter-spacing:.22em;line-height:1.45} |
| .cr-sub{font-size:8px;letter-spacing:.26em} |
| .w-dir{font-size:8.5px;line-height:1.4;max-width:94%} |
| #controls{padding:10px 10px calc(10px + env(safe-area-inset-bottom,0px))} |
| #crow{flex-wrap:wrap;gap:7px;margin-bottom:8px} |
| #concept{flex:1 1 100%;font-size:13px;padding:11px 13px;border-radius:9px} |
| #controls button{flex:1 1 auto;padding:11px 8px;font-size:12px;border-radius:9px} |
| #fs.mob{display:inline-block;flex:1 1 100%;background:#16191f;border:1px solid #2b3540;color:#cfd6df} |
| #brand{font-size:12px;top:10px;left:12px} |
| #gallery{gap:6px} |
| #gallery .film{font-size:11px;padding:7px 10px} |
| #foot{font-size:9.5px;margin-top:7px} |
| } |
| /* full-screen loader so the scene reveals all at once (no elements popping in) */ |
| #loader{position:fixed;inset:0;z-index:99;background:#04060a;display:flex;flex-direction:column; |
| align-items:center;justify-content:center;gap:20px;text-align:center;transition:opacity 1.1s ease} |
| #loader.hide{opacity:0;pointer-events:none} |
| #loader .lsmall{color:#eef2f6;font-size:12px;letter-spacing:.55em;text-transform:uppercase; |
| animation:lpulse 1.8s ease-in-out infinite} |
| #loader .lbig{font-weight:700;letter-spacing:.04em;color:#eef2f6; |
| text-shadow:0 0 34px rgba(238,242,246,.22)} |
| /* the ANSI-shadow ASCII title (LLM CINEMA) — rendered as a monospace block */ |
| pre#loadtitle{margin:0;white-space:pre;line-height:1.0;font-size:clamp(5px,1.0vw,11px); |
| text-align:center} |
| #loader .lbar{width:min(280px,52vw);height:2px;background:#161b22;border-radius:2px;overflow:hidden} |
| #loader .lbar::after{content:"";display:block;height:100%;width:42%; |
| background:linear-gradient(90deg,transparent,#eef2f6,transparent);animation:lslide 1.25s linear infinite} |
| @keyframes lpulse{0%,100%{opacity:.45}50%{opacity:1}} |
| @keyframes lslide{0%{transform:translateX(-120%)}100%{transform:translateX(330%)}} |
| </style></head><body> |
| <div id=loader> |
| <div class=lsmall>presented by conductor creative labs</div> |
| <pre id=loadtitle class=lbig>LLM CINEMA</pre> |
| <div class=lbar></div> |
| </div> |
| <div id=screen></div> |
| <img id=bg src="static/cinema.png" alt=""> |
| <div id=reveal></div> |
| <div id=spill></div> |
| <div id=brand>▶ LLM <b>CINEMA</b></div> |
| <button id=aboutbtn>about</button> |
| <div id=about><div class=card><div class=x id=aboutx>×</div> |
| <h1>LLM <b>CINEMA</b></h1> |
| <div class=lead>Cinema is a fine-tuned <b>MiniCPM3-4B</b> — a four-billion-parameter model that |
| <b>writes, draws, and directs</b> original ASCII short films. It invents the story, draws every |
| character, picks the terrain, camera and mood, then stages and plays it frame by frame. No video, |
| no footage — pure imagination, one frame at a time.</div> |
| <h2>How it earns its badges</h2> |
| <ul> |
| <li>🎨 <b>Off-Brand</b> — a fully custom <code>gr.Server</code> front end (this whole cinema), well past the default Gradio look.</li> |
| <li>🐜 <b>Tiny Titan</b> — runs on a genuinely tiny <b>4B</b> model, under the ≤4B bar.</li> |
| <li>🔌 <b>Off the Grid</b> — can <b>optionally</b> run as a fully local build with <b>no cloud APIs</b>.</li> |
| <li>🦙 <b>Llama Champion</b> — the model runs through the <b>llama.cpp</b> runtime.</li> |
| <li>📓 <b>Field Notes</b> — the build is written up in the repo.</li> |
| </ul> |
| <h2>Small & local</h2> |
| <div class=lead>Just 4B parameters — small enough to run on your laptop. Download the off-the-grid |
| build and run it locally on llama.cpp, no cloud needed. |
| <a href="https://huggingface.co/spaces/build-small-hackathon/llm-cinema/blob/main/LOCAL_LLAMACPP.md" target=_blank rel=noopener>Get the local build ↗</a></div> |
| <div class=foot2>Presented by <b>Conductor Creative Labs</b><br> |
| <a href="https://conductorailabs.com" target=_blank rel=noopener>conductorailabs.com</a></div> |
| </div></div> |
| <div id=controls> |
| <div id=crow> |
| <input id=concept maxlength=60 autocomplete=off spellcheck=false |
| placeholder="Describe your film — e.g. a tiny knight afraid of the dark"> |
| <button id=go>▶ Make & play</button> |
| <button id=surprise>Surprise</button> |
| <button id=replay>↻ Replay</button> |
| <button id=stop>Stop</button> |
| <button id=fs class=mob>⛶ Fullscreen</button> |
| </div> |
| <div id=gallery></div> |
| <div id=foot>Generates live in the cloud here · <a href="https://huggingface.co/spaces/build-small-hackathon/llm-cinema/blob/main/LOCAL_LLAMACPP.md" target=_blank rel=noopener>🔌 download the off-the-grid build — runs locally on llama.cpp ↗</a></div> |
| </div> |
| <script> |
| const screen=document.getElementById('screen'); |
| // ── ANSI-shadow ASCII font for the LLM CINEMA title (loader + opening credits) ── |
| let GLYPH=null; |
| function asciiWord(t){if(!GLYPH)return t;t=t.toUpperCase(); |
| let rows=[];for(let r=0;r<6;r++){let line=""; |
| for(const c of t){line+= c===' ' ? ' ' : (GLYPH[c]?GLYPH[c][r]:' ');}rows.push(line);} |
| return rows.join('\n');} |
| function asciiTitle(){return asciiWord('LLM')+'\n'+asciiWord('CINEMA');} // stacked, fits narrow screens |
| function paintLoaderTitle(){const el=document.getElementById('loadtitle');if(el&&GLYPH)el.textContent=asciiTitle();} |
| fetch('static/ansifont.json').then(r=>r.json()).then(g=>{GLYPH=g;paintLoaderTitle(); |
| if(!es&&!playTimer&&!phase)welcome();}).catch(()=>{}); // re-render welcome with the ASCII title |
| const SURPRISES=["a tiny knight who is afraid of the dark","a lonely robot who finds a stray cat", |
| "a dragon who would rather bake than fight","a snail who dreams of racing the wind", |
| "a little ghost afraid of humans","a candle racing the dawn"]; |
| let es=null; |
| // ── loader: reveal the whole scene at once, once the bg image + gallery are ready ── |
| const loader=document.getElementById('loader'); |
| let bgReady=false,galReady=false,minDone=false; |
| function reveal(){if(bgReady&&galReady&&minDone){loader.classList.add('hide'); |
| setTimeout(()=>{loader.style.display='none';},1200);}} |
| (function(){const b=document.getElementById('bg'); |
| if(b.complete&&b.naturalWidth>0)bgReady=true; else b.addEventListener('load',()=>{bgReady=true;reveal();}); |
| setTimeout(()=>{minDone=true;reveal();},1600); // min on-screen time for the copy |
| setTimeout(()=>{bgReady=galReady=minDone=true;reveal();},7000);})(); // safety: never hang |
| const revealEl=document.getElementById('reveal'), spillEl=document.getElementById('spill'); |
| // fit a full film frame (80x18 grid) to exactly fill the screen box without cropping the |
| // subtitle — measured from real glyph metrics, so it tracks the cut-out at any size. |
| let _fs=null; |
| function fit(force){const p=screen.querySelector('pre'); if(!p||p.classList.contains('msg'))return; |
| if(_fs&&!force){p.style.fontSize=_fs+'px';return;} |
| p.style.fontSize='10px'; |
| const cw=p.scrollWidth/10, ch=p.scrollHeight/10; |
| if(!cw||!ch)return; |
| _fs=(Math.min(screen.clientWidth/cw, screen.clientHeight/ch)*0.99).toFixed(2); |
| p.style.fontSize=_fs+'px';} |
| // map the film screen EXACTLY onto the photo's cut-out (x596..2102, y172..807 of 2638x1478). |
| // DESKTOP: the photo is object-fit:cover; mirror that transform. MOBILE (portrait): a landscape |
| // photo can't cover without hiding the screen, so we ZOOM the photo so the cut-out fills the |
| // width and pin #screen to it — the theatre screen stays the focus and nothing is cut off. |
| const SX=596,SY=172,SW=1507,SH=636,IW=2638,IH=1478; |
| function placeScreen(){const bg=document.getElementById('bg'),vw=window.innerWidth,vh=window.innerHeight; |
| if(vw<=680){ // mobile: zoom to the screen |
| const s=(vw*0.96)/SW, lx=vw*0.02, ty=vh*0.16; |
| bg.style.position='fixed';bg.style.objectFit='fill';bg.style.right='auto';bg.style.bottom='auto'; |
| bg.style.width=(IW*s)+'px';bg.style.height=(IH*s)+'px'; |
| bg.style.left=(lx-SX*s)+'px';bg.style.top=(ty-SY*s)+'px'; |
| screen.style.left=lx+'px';screen.style.top=ty+'px'; |
| screen.style.width=(SW*s)+'px';screen.style.height=(SH*s)+'px'; |
| }else{ // desktop: match object-fit:cover |
| bg.style.position='';bg.style.objectFit='';bg.style.right='';bg.style.bottom=''; |
| bg.style.width='';bg.style.height='';bg.style.left='';bg.style.top=''; |
| const s=Math.max(vw/IW,vh/IH), ox=(vw-IW*s)/2, oy=(vh-IH*s)/2; |
| screen.style.left=(ox+SX*s)+'px';screen.style.top=(oy+SY*s)+'px'; |
| screen.style.width=(SW*s)+'px';screen.style.height=(SH*s)+'px'; |
| }} |
| placeScreen(); |
| window.addEventListener('resize',()=>{placeScreen();_fs=null;if(screen.querySelector('pre'))fit(true);}); |
| function show(h){screen.innerHTML=h;fit();} |
| function playing(on){document.body.classList.toggle('playing',on); |
| if(!on){revealEl.style.opacity='';revealEl.style.filter='';spillEl.style.background='';}} |
| function welcomeHTML(){const title=GLYPH?("<pre class='wel-title'>"+asciiTitle()+"</pre>") |
| :"<div class='cr-big'>LLM CINEMA</div>"; |
| return "<div class='scene-center'>"+title |
| +"<div class='cr-s'>presented by conductor creative labs</div>" |
| +"<div class='w-dir'>Type an idea below and press “Make & play” — a small model will " |
| +"write, draw and direct your film. Or pick one from the gallery. " |
| +"You can also <a class='wlink' href='https://huggingface.co/spaces/build-small-hackathon/llm-cinema/blob/main/LOCAL_LLAMACPP.md' target='_blank' rel='noopener'>run it off the grid, locally on llama.cpp</a>.</div></div>";} |
| function welcome(){playing(false);screen.innerHTML=welcomeHTML();} // set directly (no fit() on the title pre) |
| // one film frame -> screen + the colour-mirror glow (used live AND on replay) |
| function renderFilmFrame(d){show(d.html);playing(true); |
| if(d.tint){const r=d.tint[0],g=d.tint[1],b=d.tint[2],e=(d.lvl||0); |
| const a=(0.15*e).toFixed(3), a2=(0.05*e).toFixed(3); |
| spillEl.style.background='radial-gradient(76% 56% at 51% 70%, rgba('+r+','+g+','+b+','+a+') 0%, ' |
| +'rgba('+r+','+g+','+b+','+a2+') 46%, transparent 78%)'; |
| revealEl.style.opacity=Math.min(0.7,e*1.4).toFixed(3); |
| revealEl.style.filter='brightness('+(1.06+0.12*e).toFixed(3)+') saturate(1.05) blur(8px)';}} |
| // ── intro: while the model writes, hold the TITLE + a status; when it's done, fade to a |
| // short "presented by" bumper, then play the film (buffered so nothing is skipped) ── |
| const BUMPER=[ |
| "<div class='cr-s'>Conductor Creative Labs<br><b>presents</b></div>", |
| "<div class='cr-s'>in conjunction with<br><b>Hugging Face Spaces & Gradio</b></div>"]; |
| function showCard(inner){screen.innerHTML="<div id=credwrap>"+inner+"</div>"; |
| const w=document.getElementById('credwrap');requestAnimationFrame(()=>{if(w)w.classList.add('show');});} |
| function fadeCard(){const w=document.getElementById('credwrap');if(w)w.classList.remove('show');} |
| function startWriting(){phase='writing';playing(true); |
| revealEl.style.opacity='0';spillEl.style.background=''; // dim the house only — no glow yet |
| const t=GLYPH?("<pre class='cr-title'>"+asciiTitle()+"</pre>"):"<div class='cr-big'>LLM CINEMA</div>"; |
| showCard(t+"<div class='cr-sub'>now writing and directing, please wait</div>");} |
| function playBumper(done){let i=0; |
| (function step(){ |
| if(i>=BUMPER.length){fadeCard();creditsTimer=setTimeout(done,900);return;} |
| showCard(BUMPER[i]); |
| creditsTimer=setTimeout(()=>{fadeCard();creditsTimer=setTimeout(()=>{i++;step();},700);},2200);})();} |
| // ── streaming + a buffer so any film can be replayed exactly ── |
| let phase=null,buf=[],playTimer=null,creditsTimer=null,streamDone=false; |
| function startBufferedPlay(){phase='playing';let i=0; |
| playTimer=setInterval(()=>{ |
| if(i<buf.length){renderFilmFrame(buf[i++]);} |
| else if(streamDone){clearInterval(playTimer);playTimer=null;playing(false);}},150);} |
| function stopStream(){if(es){es.close();es=null;} |
| if(playTimer){clearInterval(playTimer);playTimer=null;} |
| if(creditsTimer){clearTimeout(creditsTimer);creditsTimer=null;}} |
| function stop(){stopStream();phase=null;welcome();} |
| function stream(url,withCredits){stopStream();phase=null;buf=[];streamDone=false; |
| if(withCredits)startWriting(); |
| es=new EventSource(url); |
| es.onmessage=e=>{const d=JSON.parse(e.data); |
| if(d.done){streamDone=true;if(es){es.close();es=null;} |
| if(!withCredits){playing(false);} return;} // gallery stops here; make drains its buffer |
| if(d.phase)return; // heartbeat while writing -> hold the title |
| if(!d.html)return; |
| const film=d.html.indexOf('<span')>=0; |
| if(!film){stopStream();phase=null;show(d.html);playing(false);return;} // terminal message |
| buf.push(d); |
| if(withCredits){ |
| if(phase==='writing'){phase='bumper';playBumper(startBufferedPlay);} // first frame -> bumper |
| return;} // bumper/playing: buffered player renders |
| renderFilmFrame(d);}; // gallery: render live |
| es.onerror=()=>{if(es){es.close();es=null;}if(phase==null)playing(false);};} |
| function replay(){if(!buf.length)return;stopStream();phase='playing'; |
| const frames=buf.slice();let i=0;playing(true); |
| playTimer=setInterval(()=>{if(i>=frames.length){clearInterval(playTimer);playTimer=null;playing(false);return;} |
| renderFilmFrame(frames[i++]);},150);} |
| const concept=document.getElementById('concept'); |
| function make(){const c=concept.value.trim().slice(0,60); |
| if(!c){concept.focus();return;} |
| stream('api/make?concept='+encodeURIComponent(c),true);} |
| document.getElementById('go').onclick=make; |
| concept.addEventListener('keydown',e=>{if(e.key==='Enter')make();}); |
| document.getElementById('surprise').onclick=()=>{const c=SURPRISES[Math.floor(Math.random()*SURPRISES.length)]; |
| concept.value=c;stream('api/make?concept='+encodeURIComponent(c),true);}; |
| document.getElementById('replay').onclick=replay; |
| document.getElementById('stop').onclick=stop; |
| // About modal |
| const aboutEl=document.getElementById('about'); |
| document.getElementById('aboutbtn').onclick=()=>aboutEl.classList.add('show'); |
| document.getElementById('aboutx').onclick=()=>aboutEl.classList.remove('show'); |
| aboutEl.onclick=e=>{if(e.target===aboutEl)aboutEl.classList.remove('show');}; |
| document.addEventListener('keydown',e=>{if(e.key==='Escape')aboutEl.classList.remove('show');}); |
| // fullscreen (mobile): go fullscreen and try to lock landscape for a proper cinema view |
| document.getElementById('fs').onclick=()=>{const d=document, el=d.documentElement; |
| if(d.fullscreenElement||d.webkitFullscreenElement){(d.exitFullscreen||d.webkitExitFullscreen||function(){}).call(d);return;} |
| const req=el.requestFullscreen||el.webkitRequestFullscreen; |
| if(req){const p=req.call(el); const lock=()=>{try{window.screen.orientation&&window.screen.orientation.lock&& |
| window.screen.orientation.lock('landscape').catch(()=>{});}catch(e){}}; |
| if(p&&p.then)p.then(lock).catch(()=>{}); else lock();}}; |
| fetch('api/gallery').then(r=>r.json()).then(films=>{const g=document.getElementById('gallery'); |
| films.forEach(n=>{const b=document.createElement('button');b.className='film';b.textContent=n; |
| b.onclick=()=>stream('api/play?name='+encodeURIComponent(n),false);g.appendChild(b);}); |
| galReady=true;reveal();}).catch(()=>{galReady=true;reveal();}); |
| welcome(); |
| </script></body></html>""" |
|
|
|
|
| server = gr.Server(title="LLM Cinema") |
|
|
|
|
| @server.get("/") |
| def index(): |
| return HTMLResponse(PAGE) |
|
|
|
|
| @server.get("/static/cinema.png") |
| def bg_image(): |
| return FileResponse(os.path.join(HERE, "static", "cinema.png")) |
|
|
|
|
| @server.get("/static/ansifont.json") |
| def ansifont(): |
| return FileResponse(os.path.join(HERE, "static", "ansifont.json"), media_type="application/json") |
|
|
|
|
| @server.get("/static/share.png") |
| def share_image(): |
| return FileResponse(os.path.join(HERE, "static", "share.png"), media_type="image/png") |
|
|
|
|
| @server.get("/api/gallery") |
| def gallery(): |
| return JSONResponse(list(_films(SHOW).keys())) |
|
|
|
|
| @server.get("/api/play") |
| def play(name: str): |
| path = _films(SHOW).get(name) |
| if not path: |
| return JSONResponse({"error": "not found"}, status_code=404) |
| spec = json.load(open(path)) |
|
|
| def frames(): |
| for grid in render.iter_movie_frames(spec): |
| yield _payload(grid, spec.get("title", "")) |
| time.sleep(movies.FRAME_MS / 1000) |
| return StreamingResponse(_sse(frames()), media_type="text/event-stream") |
|
|
|
|
| @server.get("/api/make") |
| def make(concept: str): |
| concept = _clean_concept(concept) |
|
|
| def frames(): |
| if not concept: |
| yield _screen("Type a short film idea to begin.", "ready") |
| return |
| if _blocked(concept) or _INJECT.search(concept): |
| yield _screen("Let's keep it friendly — try a kind, fun film idea.", "blocked") |
| return |
| result = {} |
|
|
| def work(): |
| try: |
| result["spec"] = movies.direct(concept) |
| except Exception as e: |
| result["error"] = f"{type(e).__name__}: {e}" |
| th = threading.Thread(target=work, daemon=True) |
| th.start() |
| t0 = time.time() |
| while th.is_alive(): |
| |
| yield {"phase": "writing", "t": int(time.time() - t0)} |
| th.join(timeout=0.7) |
| if result.get("error") or not (result.get("spec") or {}).get("shots"): |
| yield _screen("Could not reach the model. Please try again.", "error") |
| return |
| spec = result["spec"] |
| for grid in render.iter_movie_frames(spec): |
| yield _payload(grid, spec.get("title", "")) |
| time.sleep(movies.FRAME_MS / 1000) |
| return StreamingResponse(_sse(frames()), media_type="text/event-stream") |
|
|
|
|
| if __name__ == "__main__": |
| |
| server.launch(server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"), |
| server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860"))) |
|
|