| """ |
| LLM Cinema — the inline STAGE (Gradio app, the hackathon submission). |
| |
| The "Cinema" model writes, draws, and directs ORIGINAL ASCII films that play |
| INLINE on a little charcoal terminal-stage — no separate window, no sound, just |
| the model's imagination streaming frame by frame. |
| |
| • a showpiece film auto-plays the moment the page loads (no model call needed) |
| • Make a film from any concept • a Gallery of films that replay instantly |
| • download your film as a GIF to share |
| |
| Run: python stage.py |
| Deploy: Hugging Face Space (Gradio). Set CLAUDEMOVIES_LLM_URL/_KEY/_MODEL secrets. |
| """ |
|
|
| import glob |
| import html |
| import json |
| import os |
| import random |
| import re |
| import threading |
| import time |
| import uuid |
|
|
| import gradio as gr |
|
|
| import movies |
| import render |
|
|
|
|
| def warm_endpoints(): |
| """Wake the scale-to-zero model endpoints in the background, so the first |
| real 'Make a film' doesn't eat the whole cold start. Safe to call often.""" |
| def ping(prefix): |
| try: |
| movies.llm("Warmup.", "ok", max_tokens=1, prefix=prefix) |
| except Exception: |
| pass |
| for prefix in ("CLAUDEMOVIES_LLM", "CLAUDEMOVIES_ADV"): |
| threading.Thread(target=ping, args=(prefix,), daemon=True).start() |
|
|
| W, H = render.W, render.H |
| HERE = os.path.dirname(__file__) |
| SHOW = os.path.join(HERE, "showcase") |
| SAVED = os.path.join(HERE, "saved") |
|
|
| 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", "two fireflies falling in love over a pond", |
| "a grumpy troll who secretly wants to dance", "a snail who dreams of racing the wind", |
| "a little ghost afraid of humans", "a paper boat's voyage to the endless sea", |
| "a mouse who builds a ladder to the moon", "a candle racing the dawn", |
| ] |
|
|
|
|
| |
| 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 "#7CFC9A" |
| esc = html.escape(seg) |
| row += f"<span style='color:{hexc}'>{esc}</span>" if seg.strip() else esc |
| i = j |
| rows.append(row) |
| return (f"<div class='stage'><div class='bar'><i></i><i></i><i></i>" |
| f"<b>{html.escape(title)}</b></div><pre>{chr(10).join(rows)}</pre></div>") |
|
|
|
|
| def _idle(msg): |
| return ("<div class='stage'><div class='bar'><i></i><i></i><i></i><b>LLM Cinema</b></div>" |
| f"<pre> {html.escape(msg)}</pre></div>") |
|
|
|
|
| _TITLE_ART = r""" |
| _ _ __ __ ___ ___ _ _ ___ __ __ _ |
| | | | | | \/ | / __|_ _| \| | __| \/ | /_\ |
| | |__| |__| |\/| | | (__ | || .` | _|| |\/| |/ _ \ |
| |____|____|_| |_| \___|___|_|\_|___|_| |_/_/ \_\ |
| """ |
|
|
|
|
| def _welcome(): |
| """The first screen: a cinematic ASCII title card + a prompt to make a film.""" |
| e = html.escape |
| art = [ln for ln in _TITLE_ART.strip("\n").split("\n")] |
| w = max(len(ln) for ln in art) |
| art_block = "\n".join(ln.ljust(w) for ln in art) |
| strip = ("▖▖▘▘" * (w // 4 + 1))[:w] |
| body = ( |
| f"<span class='strip'>{e(strip)}</span>\n\n" |
| f"<span class='ttl'>{e(art_block)}</span>\n\n\n" |
| f"<span class='sub'>a small model writes · draws · directs\n" |
| f"original ASCII films from a single idea</span>\n\n\n" |
| f"<span class='cta'>{e('▸ type an idea on the left, then press “Make & play”')}</span>\n" |
| f"<span class='sub'>or pick one from the gallery below</span>\n\n" |
| f"<span class='strip'>{e(strip)}</span>" |
| ) |
| return ("<div class='stage'><div class='bar'><i></i><i></i><i></i>" |
| "<span class='barlabel'>ready · awaiting your idea</span></div>" |
| f"<pre class='welcome'>{body}</pre></div>") |
|
|
|
|
| |
| _BANNED = re.compile(r"\b(" + "|".join([ |
| |
| "fuck", "fucks", "fucking", "fucker", "shit", "bullshit", "bitch", "cunt", |
| "asshole", "bastard", "dick", "piss", "slut", "whore", "wank", "prick", |
| |
| "nigger", "nigga", "faggot", "fag", "kike", "spic", "chink", "gook", |
| "wetback", "tranny", "retard", "retarded", "coon", "dyke", "paki", "beaner", |
| |
| "heil hitler", "sieg heil", "white power", "kkk", "gas the jews", "lynch", |
| ]) + r")\b", re.I) |
|
|
|
|
| def _blocked(*texts) -> bool: |
| """True if any provided text contains banned profanity / slurs / hate terms.""" |
| return any(_BANNED.search(t or "") for t in texts) |
|
|
|
|
| |
| |
| |
| |
| GEN: dict[str, int] = {} |
|
|
|
|
| def _newsid(): |
| """A fresh per-session id (assigned to a gr.State on page load).""" |
| return uuid.uuid4().hex |
|
|
|
|
| def _begin(sid): |
| """Mark a new playback as the current one for this session; return its token.""" |
| GEN[sid] = GEN.get(sid, 0) + 1 |
| return GEN[sid] |
|
|
|
|
| def _stream(spec, sid, mine, cards=True): |
| """Play a spec inline; yields (html, spec). Stops early if a newer playback in this |
| session has superseded `mine`. The lazy frame generator means the first frame is |
| instant and the worker yields every frame (so Gradio never restarts the stream).""" |
| for grid in render.iter_movie_frames(spec, cards=cards): |
| if GEN.get(sid) != mine: |
| return |
| yield grid_to_html(grid, spec.get("title", "")), spec |
| time.sleep(movies.FRAME_MS / 1000) |
|
|
|
|
| FLAGSHIP = "knight.json" |
| GALLERY_ORDER = ["knight.json", "paper-boat.json", "rain-cat.json", "troll-dance.json", |
| "snail-race.json", "ghost.json", "bread-dragon.json"] |
|
|
|
|
| def _films(folder): |
| """title -> json path for every saved film in a folder (best films first).""" |
| def rank(p): |
| name = os.path.basename(p) |
| return (GALLERY_ORDER.index(name) if name in GALLERY_ORDER else 99, name) |
| out = {} |
| for p in sorted(glob.glob(os.path.join(folder, "*.json")), key=rank): |
| try: |
| title = json.load(open(p)).get("title") or os.path.basename(p)[:-5] |
| except Exception: |
| continue |
| out[title] = p |
| return out |
|
|
|
|
| def _featured(): |
| """A spec to auto-play on load: the flagship film, else any showcase/saved gem.""" |
| flag = os.path.join(SHOW, FLAGSHIP) |
| if os.path.exists(flag): |
| return json.load(open(flag)) |
| for folder in (SHOW, SAVED): |
| films = _films(folder) |
| if films: |
| return json.load(open(next(iter(films.values())))) |
| return None |
|
|
|
|
| CSS = """ |
| @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap'); |
| |
| :root, .dark{ |
| --bg:#0e1116; --panel:#0a0c10; --grid:rgba(140,170,200,.05); |
| --line:#1c2129; --muted:#79828f; --ink:#e7ebf0; |
| --accent:#3ddc97; --accent2:#36c6e0; |
| } |
| /* FIXED app height (in px, never vh/%) — on Hugging Face the app sits in an |
| auto-resizing iframe; any viewport-relative height feeds back into infinite |
| growth. A constant height is the only stable answer. The two panes scroll |
| internally; Gradio's own wrappers just size to this fixed app. */ |
| html, body{margin:0} |
| gradio-app{display:block} |
| .gradio-container{max-width:100%!important;width:100%!important;overflow:hidden!important; |
| margin:0!important;padding:0!important; |
| font-family:'JetBrains Mono',ui-monospace,'SFMono-Regular',monospace!important;color:var(--ink)} |
| body, gradio-app, .gradio-container{background:var(--bg)!important} |
| .gradio-container .contain{padding:0!important;margin:0!important;max-width:100%!important} |
| footer{display:none!important} |
| .gradio-container *{border-color:var(--line)!important} /* kill stray accent borders */ |
| |
| /* ── two-pane app on charcoal + faint grid ── */ |
| #app{height:760px!important;overflow:hidden!important; |
| flex-wrap:nowrap!important;gap:0!important;align-items:stretch!important; |
| background-color:var(--bg)!important; |
| background-image:linear-gradient(var(--grid) 1px, transparent 1px), |
| linear-gradient(90deg, var(--grid) 1px, transparent 1px); |
| background-size:32px 32px} |
| #shelf{flex:0 0 300px!important;width:300px!important;min-width:300px!important;max-width:300px!important; |
| height:760px!important;overflow-x:hidden!important;overflow-y:auto!important; |
| flex-direction:column!important;flex-wrap:nowrap!important;align-content:stretch!important; |
| background:rgba(8,10,14,.85);border-right:1px solid var(--line); |
| padding:16px 16px!important;gap:0!important;box-sizing:border-box!important} |
| #shelf::-webkit-scrollbar{width:7px} |
| #shelf::-webkit-scrollbar-thumb{background:#222833;border-radius:4px} |
| /* let every Gradio wrapper in the shelf shrink to the column (no horizontal overflow) */ |
| #shelf, #shelf *{box-sizing:border-box!important} |
| #shelf > *,#shelf .block,#shelf .form,#shelf .gr-form,#shelf .wrap,#shelf .gr-group{ |
| min-width:0!important;max-width:100%!important;margin:0!important} |
| #shelf .gr-group,#shelf .form,#shelf .block{background:none!important;border:none!important; |
| box-shadow:none!important} |
| /* the textbox wrapper collapses to one line while the auto-grown textarea is taller, |
| so it overlaps the next control — force the wrappers to contain their content */ |
| #shelf .block,#shelf .form,#shelf .input-container,#shelf label.container{ |
| height:auto!important;min-height:0!important;overflow:visible!important} |
| #cinema{flex:1 1 auto!important;min-width:0!important;height:760px!important;overflow-y:auto; |
| display:flex!important;flex-direction:column;align-items:center;justify-content:center;padding:36px!important} |
| |
| /* shelf brand + section headers (mono, technical) — compact so it all fits, no scroll */ |
| #brand{font-weight:700;font-size:15px;letter-spacing:.06em;color:var(--ink)} |
| #brand::before{content:"▸ ";color:var(--accent)} |
| #brandsub{color:var(--muted);font-size:10px;margin:3px 0 0;line-height:1.45} |
| .shelf-h{color:var(--accent);font-size:9.5px;font-weight:700;text-transform:uppercase; |
| letter-spacing:.22em;margin:11px 0 4px;border-top:1px solid var(--line);padding-top:9px} |
| .shelf-h::before{content:"// "} |
| /* text inputs: one clean box (no nested wrapper box). Let Gradio size the textarea |
| from its `lines` count — don't override padding/line-height/height or it overflows |
| its wrapper and overlaps the next control. */ |
| #shelf textarea{font-size:13.5px!important; |
| background:#0b0e12!important;border:1px solid #2b3540!important;border-radius:9px!important; |
| color:var(--ink)!important;font-family:'JetBrains Mono',monospace!important} |
| #shelf textarea:focus{border-color:var(--accent)!important;outline:none!important} |
| #shelf textarea::placeholder{color:#6d7682!important} |
| /* force the textbox wrappers tall enough to contain the 3-line field so the button |
| beneath always clears it (Gradio's wrapper otherwise collapses to one line). |
| Use #shelf #id so it beats the #shelf .block{min-height:0} rule above. */ |
| #shelf .form:has(#concept){min-height:84px!important;margin-top:2px!important} |
| #shelf #concept{min-height:84px!important} |
| #shelf #concept textarea{height:72px!important} |
| #shelf button{border-radius:6px!important;font-family:'JetBrains Mono',monospace!important; |
| letter-spacing:.03em;min-height:0!important;padding:7px 10px!important;font-size:12px!important; |
| margin-top:4px!important} |
| #shelf .gr-dropdown,#shelf [data-testid='dropdown']{font-size:12px!important} |
| #shelf fieldset,#shelf .gr-radio{border:none!important;background:none!important;gap:1px!important} |
| #shelf label,#shelf span{font-family:'JetBrains Mono',monospace!important;font-size:12px!important} |
| |
| /* now playing — wraps onto a new line, never truncated */ |
| .np{color:var(--ink);font-size:12.5px;line-height:1.35;white-space:normal; |
| overflow-wrap:anywhere;padding:2px 0 1px} |
| |
| /* gallery list at the bottom — one button per film, text wraps (no truncation) */ |
| #shelf button.film{justify-content:flex-start!important;text-align:left!important; |
| white-space:normal!important;overflow-wrap:anywhere;line-height:1.25!important; |
| font-size:11.5px!important;padding:6px 9px!important;min-height:0!important; |
| margin:0 0 3px 0!important; |
| color:var(--muted)!important;background:#0c0e12!important;border:1px solid var(--line)!important} |
| #shelf button.film:hover{color:var(--ink)!important;border-color:var(--accent)!important} |
| .gradio-container button.primary,.gradio-container .primary{ |
| background:linear-gradient(92deg,var(--accent),var(--accent2))!important; |
| border:none!important;color:#04130d!important;font-weight:700!important;letter-spacing:.04em} |
| |
| #makebtn{font-size:13.5px!important;padding:11px!important;margin-top:8px!important} |
| #makerow{display:flex!important;flex-direction:row!important;flex-wrap:nowrap!important; |
| gap:6px!important;margin-top:5px!important} |
| #makerow > *{flex:1 1 0!important;min-width:0!important} |
| #makerow button{padding:6px!important;font-size:12px!important;width:100%!important} |
| #stopbtn{color:#ff8a84!important;background:#13171c!important;border:1px solid #3a2a2c!important} |
| #stopbtn:hover{border-color:#ff5f57!important;color:#ff5f57!important} |
| |
| /* ── the screen: a clean modern monitor with a status bar + HUD corners ── */ |
| .stage{width:100%;max-width:1040px;margin:0 auto;position:relative; |
| font-family:'JetBrains Mono',ui-monospace,monospace} |
| .stage .bar{display:flex;align-items:center;gap:7px; |
| background:#0a0c10;border:1px solid var(--line);border-bottom:none;border-radius:9px 9px 0 0; |
| padding:9px 14px} |
| .stage .bar i{width:8px;height:8px;border-radius:50%;display:inline-block;background:#2b313b} |
| .stage .bar i:nth-child(1){background:#ff5f57} |
| .stage .bar b{color:var(--muted);font-size:12px;letter-spacing:.05em;margin-left:8px} |
| .stage .bar b::before{content:"now_playing: ";color:var(--accent)} |
| .stage .bar .barlabel{color:var(--muted);font-size:12px;letter-spacing:.05em;margin-left:8px} |
| /* cinematic welcome card */ |
| .welcome{line-height:1.3!important;font-size:16px!important} |
| .welcome .ttl{color:#eaf1f5;font-weight:500} |
| .welcome .strip{color:#27333d;letter-spacing:2px} |
| .welcome .sub{color:var(--muted);font-size:13px} |
| .welcome .cta{color:var(--accent);font-weight:700} |
| .stage pre{position:relative;z-index:1;margin:0; |
| background:#0a0d11; |
| color:#7df9a6;font-size:18px;line-height:1.14;padding:28px 12px; |
| border:1px solid var(--line);border-top:none;border-radius:0 0 9px 9px;white-space:pre; |
| text-align:center;overflow:hidden;min-height:430px;box-sizing:border-box} |
| /* HUD corner brackets */ |
| .stage::before,.stage::after{content:"";position:absolute;width:20px;height:20px; |
| pointer-events:none;z-index:2;border:2px solid var(--accent);opacity:.55} |
| .stage::before{top:-7px;left:-7px;border-right:none;border-bottom:none} |
| .stage::after{bottom:-7px;right:-7px;border-left:none;border-top:none} |
| .endcard{max-width:1040px;margin:10px auto 0;color:var(--muted);text-align:center; |
| font-family:'JetBrains Mono',monospace;letter-spacing:3px;font-size:13px} |
| |
| /* rating stars, in the shelf */ |
| #rating{display:flex!important;align-items:center;gap:4px!important;flex-wrap:nowrap!important;margin:2px 0!important} |
| #rating>*{flex:0 0 auto!important;min-width:0!important;width:auto!important} |
| #rating button.star{background:none!important;border:none!important;box-shadow:none!important; |
| color:#3a414c!important;font-size:22px!important;line-height:1;padding:0 1px!important; |
| min-width:0!important;width:auto!important;cursor:pointer;transition:color .12s,transform .12s} |
| #rating button.star.lit{color:#febc2e!important} /* cumulative fill (set in JS) */ |
| #rating button.star:hover{transform:scale(1.18)} |
| #ratemsg{min-height:18px} |
| #ratemsg .rated{color:#febc2e;font-size:13px} |
| """ |
|
|
|
|
| THEME = gr.themes.Base( |
| primary_hue="emerald", neutral_hue="slate", |
| font=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"], |
| ).set(body_background_fill="#0e1116", body_background_fill_dark="#0e1116") |
|
|
|
|
| FORCE_DARK = """ |
| () => { |
| document.documentElement.classList.add('dark'); |
| document.body.classList.add('dark'); |
| document.querySelector('gradio-app')?.classList.add('dark'); |
| |
| // Star rating: hovering star N fills 1..N; clicking locks that many filled. |
| // Colours are set as inline !important styles so they beat Gradio's own button CSS. |
| const GOLD = '#febc2e', DIM = '#4b5159'; |
| const wire = () => { |
| const box = document.querySelector('#rating'); |
| if (!box) { setTimeout(wire, 300); return; } |
| if (box.dataset.wired) return; box.dataset.wired = '1'; |
| const stars = () => Array.from(box.querySelectorAll('button.star')); |
| let locked = -1; |
| const paint = (upto) => stars().forEach((s, i) => |
| s.style.setProperty('color', i <= upto ? GOLD : DIM, 'important')); |
| paint(-1); |
| box.addEventListener('mouseover', e => { |
| const b = e.target.closest('button.star'); if (b) paint(stars().indexOf(b)); |
| }); |
| box.addEventListener('mouseleave', () => paint(locked)); |
| box.addEventListener('click', e => { |
| const b = e.target.closest('button.star'); if (b) { locked = stars().indexOf(b); paint(locked); } |
| }); |
| }; |
| wire(); |
| } |
| """ |
|
|
|
|
| def build(): |
| with gr.Blocks(title="LLM Cinema") as demo: |
| last_spec = gr.State(None) |
| sid = gr.State() |
| gallery = _films(SHOW) |
|
|
| with gr.Row(elem_id="app"): |
| |
| with gr.Column(elem_id="shelf", scale=0): |
| gr.HTML("<div id='brand'>LLM CINEMA</div>" |
| "<div id='brandsub'>a tiny fine-tuned model writes, draws & " |
| "directs original ASCII films</div>") |
|
|
| gr.HTML("<div class='shelf-h'>Your film</div>") |
| concept = gr.Textbox(show_label=False, lines=3, max_lines=3, elem_id="concept", |
| placeholder="Describe your film — e.g. a tiny knight afraid of the dark") |
| go = gr.Button("▸ Make & play", variant="primary", elem_id="makebtn") |
| with gr.Row(elem_id="makerow"): |
| surprise = gr.Button("Surprise", size="sm") |
| stop = gr.Button("Stop", size="sm", elem_id="stopbtn") |
|
|
| gr.HTML("<div class='shelf-h'>Now playing</div>") |
| now_playing = gr.HTML("<div class='np'>—</div>") |
|
|
| gr.HTML("<div class='shelf-h'>Rate & save</div>") |
| with gr.Row(elem_id="rating"): |
| star_btns = [gr.Button("★", elem_classes="star") for _ in range(5)] |
| rate_msg = gr.HTML("", elem_id="ratemsg") |
| dl = gr.Button("Download GIF", size="sm") |
| dlfile = gr.File(label="your film", visible=False) |
|
|
| gr.HTML("<div class='shelf-h'>Gallery</div>") |
| film_btns = [(t, gr.Button(t, elem_classes="film", size="sm")) for t in gallery] |
|
|
| |
| with gr.Column(elem_id="cinema"): |
| stage = gr.HTML(_welcome()) |
|
|
| |
| |
| load_ev = demo.load(_newsid, None, sid, js=FORCE_DARK) |
| load_ev.then(lambda: warm_endpoints(), None, None) |
|
|
| def _create(concept, sid): |
| """Write a film, streaming progress to the stage and surfacing any error. |
| Yields (stage_html, spec) tuples; on success ends by playing the film.""" |
| mine = _begin(sid) |
| concept = (concept or "").strip() |
| if not concept: |
| yield _idle("Type a concept first, then press Make & play."), None |
| return |
| if _blocked(concept): |
| yield _idle("Let's keep it friendly — that idea can't be filmed.\n\n" |
| "Try something kind, funny or imaginative."), None |
| 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, dots = time.time(), 0 |
| while th.is_alive(): |
| if GEN.get(sid) != mine: |
| return |
| dots = dots % 3 + 1 |
| yield _idle(f"Concept:\n {concept}\n\nCinema is writing your film" |
| + "." * dots + "\n\n" |
| + f"({int(time.time() - t0)}s — the model may be waking up; " |
| "the first request can take a minute)"), None |
| th.join(timeout=0.7) |
| if GEN.get(sid) != mine: |
| return |
| if result.get("error"): |
| yield _idle("Could not reach the Cinema model.\n\n" |
| f"{result['error']}\n\nPlease try again in a moment."), None |
| return |
| spec = result.get("spec") |
| if not spec or not spec.get("shots"): |
| yield _idle("The model returned nothing usable. Please try again."), None |
| return |
| |
| |
| yield _idle(f"\"{spec.get('title', concept)}\"\n\n" |
| f"{len(spec['shots'])} shots written. Drawing and rolling…"), None |
| yield from _stream(spec, sid, mine) |
|
|
| def make_and_play(c, sid): |
| yield from _create(c, sid) |
|
|
| def surprise_me(sid): |
| yield from _create(random.choice(SURPRISES), sid) |
|
|
| def stop_play(sid): |
| _begin(sid) |
| |
| |
| for _ in range(5): |
| yield _welcome(), None |
| time.sleep(0.25) |
|
|
| go.click(make_and_play, [concept, sid], [stage, last_spec]) |
| surprise.click(surprise_me, sid, [stage, last_spec]) |
| stop.click(stop_play, sid, [stage, last_spec]) |
|
|
| def rate(n): |
| def f(spec): |
| try: |
| with open(os.path.join(HERE, "ratings.jsonl"), "a") as fp: |
| fp.write(json.dumps({"stars": n, |
| "title": (spec or {}).get("title", "")}) + "\n") |
| except Exception: |
| pass |
| stars = "★" * n + "☆" * (5 - n) |
| return (f"<span class='rated'><b>{stars}</b>" |
| f" Thanks!</span>") |
| return f |
|
|
| for i, b in enumerate(star_btns, 1): |
| b.click(rate(i), last_spec, rate_msg) |
|
|
| def to_gif(spec): |
| if not spec: |
| return gr.update(visible=False) |
| gif, _, _ = render.render_spec(spec) |
| return gr.update(value=gif, visible=True) |
|
|
| dl.click(to_gif, last_spec, dlfile) |
|
|
| def make_play(title): |
| def f(sid): |
| path = gallery.get(title) |
| if not path: |
| return |
| yield from _stream(json.load(open(path)), sid, _begin(sid)) |
| return f |
|
|
| for title, btn in film_btns: |
| btn.click(make_play(title), sid, [stage, last_spec]) |
|
|
| |
| def _np(spec): |
| title = (spec or {}).get("title") or "—" |
| return f"<div class='np'>{html.escape(title)}</div>" |
|
|
| last_spec.change(_np, last_spec, now_playing) |
|
|
| |
| |
| demo.queue(default_concurrency_limit=12) |
| return demo |
|
|
|
|
| demo = build() |
| warm_endpoints() |
|
|
| if __name__ == "__main__": |
| demo.launch(theme=THEME, css=CSS) |
|
|