"""Celebrity Deathmatch — Gradio frontend (HuggingFace Space). Upload two fighters -> FIGHT! runs Stage 1 (fight script) + Stage 2 (keyframe reel). The opt-in "Animate" button runs Stage 3 (chained, captioned fight video). Runs fully on CPU with DEATHMATCH_MOCK=1 (canned fight, placeholder frames). """ from __future__ import annotations import json import os import tempfile import gradio as gr from model_runtime import ( API_URL, MOCK, BackendError, animate, generate_fightcard, generate_keyframes, health, ) ARENAS = [ "Classic Deathmatch ring", "Rooftop at night", "Abandoned shopping mall", "Volcano lair", "Suburban backyard", "Neon Tokyo street", ] STYLES = ["Claymation", "Stop-motion", "Cartoon", "Comic book"] CSS = """ @import url('https://fonts.googleapis.com/css2?family=Anton&family=DM+Sans:wght@400;600;800&family=JetBrains+Mono:wght@600&display=swap'); :root { --dm-bg: #0b0608; --dm-panel: rgba(24,12,16,0.86); --dm-red: #e0303c; --dm-gold: #f2b030; --dm-text: #f4ede0; --dm-muted: #b09088; } body, .gradio-container { background: radial-gradient(circle at 15% 8%, rgba(224,48,60,0.18), transparent 32%), radial-gradient(circle at 85% 4%, rgba(242,176,48,0.12), transparent 34%), linear-gradient(160deg, #0b0608 0%, #140a0d 60%, #1c0e12 100%) !important; color: var(--dm-text) !important; font-family: 'DM Sans', sans-serif !important; } .gradio-container { max-width: 1180px !important; margin: 0 auto !important; padding: 22px 20px 44px !important; } .dm-disclaimer { margin: 0 0 16px; padding: 9px 14px; border: 1px dashed rgba(242,176,48,0.45); border-radius: 12px; background: rgba(242,176,48,0.07); color: var(--dm-gold); font-family: 'JetBrains Mono', monospace; font-size: 11.5px; letter-spacing: 0.04em; text-align: center; } .dm-hero { position: relative; overflow: hidden; padding: 30px 32px; border-radius: 26px; border: 1px solid rgba(224,48,60,0.3); background: linear-gradient(120deg, rgba(28,12,16,0.95), rgba(12,6,8,0.9)), radial-gradient(circle at 88% 20%, rgba(224,48,60,0.28), transparent 40%); box-shadow: 0 22px 80px rgba(0,0,0,0.5); } .dm-hero h1 { margin: 0; font-family: 'Anton', sans-serif; font-size: clamp(46px, 8vw, 92px); line-height: 0.9; letter-spacing: 0.01em; text-transform: uppercase; color: #ffc233; text-shadow: 0 0 22px rgba(255, 150, 30, 0.6), 0 0 6px rgba(255, 200, 60, 0.5), 0 2px 4px rgba(0, 0, 0, 0.55); } .dm-hero p { margin: 12px 0 0; color: #ffd98a; font-size: 16px; max-width: 640px; } .dm-status { margin: 14px 0 4px; padding: 11px 14px; border-radius: 14px; font-size: 12px; font-family: 'JetBrains Mono', monospace; border: 1px solid rgba(255,255,255,0.1); background: rgba(10,6,8,0.7); color: var(--dm-muted); } .dm-status.ok { border-color: rgba(60,200,110,0.4); } .dm-status.mock { border-color: rgba(242,176,48,0.45); color: var(--dm-gold); } .dm-status.fail { border-color: rgba(224,48,60,0.5); color: #ff8a8a; } .tape { display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: stretch; margin-top: 6px; } .tape-card { padding: 18px; border-radius: 20px; border: 1px solid rgba(255,255,255,0.09); background: var(--dm-panel); box-shadow: 0 14px 44px rgba(0,0,0,0.34); } .tape-card.b { border-color: rgba(64,160,200,0.28); } .tape-card .nm { font-family: 'Anton', sans-serif; font-size: 30px; text-transform: uppercase; color: var(--dm-text); } .tape-card .tag { color: var(--dm-muted); font-size: 13px; margin: 6px 0 14px; } .tape-card .mv { color: var(--dm-gold); font-size: 13px; font-family: 'JetBrains Mono', monospace; margin-bottom: 14px; } .statrow { display:flex; align-items:center; gap:10px; margin:7px 0; font-size:11px; font-family:'JetBrains Mono',monospace; color:var(--dm-muted); } .statrow .lab { width: 96px; text-transform: uppercase; } .bar { flex:1; height:9px; border-radius:99px; background: rgba(255,255,255,0.08); overflow:hidden; } .bar > span { display:block; height:100%; background: linear-gradient(90deg, var(--dm-gold), var(--dm-red)); } .vs { display:flex; align-items:center; font-family:'Anton',sans-serif; font-size:40px; color:var(--dm-red); } .winner-banner { margin-top: 4px; padding: 22px; border-radius: 22px; text-align: center; border: 1px solid rgba(242,176,48,0.5); background: radial-gradient(circle at 50% 0%, rgba(242,176,48,0.22), transparent 60%), var(--dm-panel); } .winner-banner .w { font-family:'Anton',sans-serif; font-size: 56px; text-transform: uppercase; background: linear-gradient(90deg,#ffd27a,var(--dm-red)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; } .winner-banner .r { color: var(--dm-muted); font-size: 14px; margin-top: 6px; } button.primary, .dm-primary button { border: 0 !important; border-radius: 999px !important; background: linear-gradient(135deg, #ffd27a, var(--dm-red) 55%, #a01820) !important; color: #1a0608 !important; font-family:'Anton',sans-serif !important; letter-spacing:0.06em !important; font-size: 18px !important; text-transform: uppercase !important; box-shadow: 0 14px 40px rgba(224,48,60,0.32) !important; } .dm-commentary { padding: 16px 18px; border-radius: 18px; border: 1px solid rgba(242,176,48,0.28); background: var(--dm-panel) !important; text-shadow: 0 1px 2px rgba(0,0,0,0.6); } /* Force fire-yellow commentary text so it stays legible regardless of the visitor's light/dark browser theme (Gradio markdown otherwise inherits a theme color that can vanish on a light background). */ .dm-commentary, .dm-commentary * { color: #ffc233 !important; } .animate-status { margin: 14px 0 0; padding: 16px 18px; border-radius: 16px; font-size: 14px; line-height: 1.55; } .animate-status.loading { border: 1px solid rgba(242,176,48,0.45); background: rgba(242,176,48,0.08); color: #ffd98a; animation: dm-pulse 1.6s ease-in-out infinite; } .animate-status.fail { border: 1px solid rgba(224,48,60,0.5); background: rgba(224,48,60,0.12); color: #ffb0b0; } @keyframes dm-pulse { 0%, 100% { opacity: 0.72; } 50% { opacity: 1; } } label, .block label { color: var(--dm-muted) !important; font-family:'JetBrains Mono',monospace !important; font-size: 11px !important; letter-spacing: 0.07em !important; text-transform: uppercase !important; } /* ───────── de-Gradio: hide framework chrome, theme the native controls so the Space stops looking like a Gradio template (Off-Brand) ───────── */ footer, .gradio-container footer { display: none !important; } .gradio-container { border: none !important; box-shadow: none !important; } /* Let the page background show through default component panels */ .block, .form, .gr-group, .gr-box { background: transparent !important; border: none !important; box-shadow: none !important; } /* Tabs → claymation chips, not default Gradio tabs */ .tab-nav, .tabs > .tab-nav { border-bottom: none !important; gap: 8px !important; margin-bottom: 14px !important; } .tab-nav button { background: rgba(10,6,8,0.6) !important; color: var(--dm-muted) !important; border: 1px solid rgba(255,255,255,0.08) !important; border-radius: 12px !important; font-family: 'Anton', sans-serif !important; letter-spacing: 0.05em !important; text-transform: uppercase !important; padding: 8px 16px !important; } .tab-nav button.selected { color: #1a0608 !important; border-color: transparent !important; background: linear-gradient(135deg, var(--dm-gold), var(--dm-red)) !important; } /* Native inputs → dark and on-theme */ input[type=text], textarea, select, .gr-input, .wrap input, .multiselect input { background: rgba(10,6,8,0.66) !important; color: var(--dm-text) !important; border: 1px solid rgba(255,255,255,0.12) !important; border-radius: 12px !important; } input::placeholder, textarea::placeholder { color: rgba(176,144,136,0.6) !important; } /* Upload dropzones → dashed gold, matching the disclaimer band */ .image-container, [data-testid="image"], .gr-image, .upload-container { border: 1px dashed rgba(242,176,48,0.4) !important; border-radius: 16px !important; background: rgba(10,6,8,0.4) !important; } /* Secondary / download buttons → de-orange */ button.secondary, .secondary > button { background: rgba(255,255,255,0.06) !important; color: var(--dm-text) !important; border: 1px solid rgba(255,255,255,0.14) !important; border-radius: 999px !important; font-family: 'JetBrains Mono', monospace !important; text-transform: uppercase !important; letter-spacing: 0.06em !important; font-size: 12px !important; } /* Kill the orange Gradio accent + focus ring */ .gradio-container { --color-accent: var(--dm-gold) !important; --color-accent-soft: rgba(242,176,48,0.2) !important; } :focus-visible { outline: 1px solid var(--dm-gold) !important; box-shadow: none !important; } /* Fighter uploads: identical boxes regardless of the photo's own aspect ratio */ .dm-fighter, .dm-fighter .image-container, .dm-fighter [data-testid="image"] { height: 400px !important; border-radius: 16px !important; overflow: hidden !important; } .dm-fighter img { height: 100% !important; width: 100% !important; object-fit: cover !important; } """ def _hero() -> str: return ("

Celebrity
Deathmatch

" "

Upload two fighters. Our AI ring director books the brawl, " "renders the claymation reel, and crowns a winner. Then hit " "Animate for the full fight.

") def _status() -> str: h = health() s = h.get("status") if s == "mock": return ("
● MOCK MODE — canned fight, no GPU. " "Set DEATHMATCH_MOCK=0 + deploy Modal for real models.
") if s == "ok": return (f"
● BACKEND ONLINE / {h.get('stage1','?')} / " f"{h.get('stage2','?')}
") return (f"
● BACKEND UNREACHABLE / {API_URL} / " "deploy Modal or run with DEATHMATCH_MOCK=1
") def _statbar(label: str, value: int) -> str: pct = max(0, min(100, int(value) * 10)) return (f"
{label}" f"" f"{int(value)}/10
") def _fighter_card(f: dict, side: str) -> str: stats = f.get("stats", {}) bars = "".join(_statbar(k, stats.get(k, 5)) for k in ("power", "speed", "showmanship")) return (f"
" f"
{f.get('name','?')}
" f"
{f.get('persona','')}
" f"
★ {f.get('signature_move','')}
" f"{bars}
") def _tale_html(card: dict) -> str: return ("
" + _fighter_card(card["fighter_a"], "a") + "
VS
" + _fighter_card(card["fighter_b"], "b") + "
") def _commentary_md(card: dict) -> str: lines = ["### Ringside commentary"] for beat in card["beats"]: lines.append(f"**Round {beat['id']} — {beat['title']}** \n" f"_{beat['commentary']}_") return "\n\n".join(lines) def _winner_html(card: dict) -> str: w = card["fighter_a"] if str(card.get("winner", "A")).upper() == "A" else card["fighter_b"] return (f"
WINNER BY KNOCKOUT
" f"
{w.get('name','?')}
" f"
{card.get('winner_reason','')}
") def _animate_loading_html() -> str: return ( "
" "🎬 Generating the full fight video…
" "Chaining your 5 keyframes into one continuous clip with captioned commentary. " "This usually takes 1–3 minutes (longer on a cold GPU). " "Please keep this tab open — you'll land on Knockout when it's ready." "
" ) def _animate_error_html(message: str) -> str: safe = message.replace("&", "&").replace("<", "<").replace(">", ">") return ( f"
" f"Animation failed
{safe}" f"
" ) def run_fight(image_a, image_b, storyline, arena, style, name_a, name_b, progress=gr.Progress()): if image_a is None or image_b is None: raise gr.Error("Upload a photo for BOTH fighters first.") progress(0, desc="Booking the fight (MiniCPM)…") try: card = generate_fightcard(image_a, image_b, storyline, arena, style, name_a, name_b) progress(0.5, desc="Rendering claymation keyframes (FLUX)…") frames = generate_keyframes(card, style) except (RuntimeError, BackendError) as e: raise gr.Error(f"Fight booking failed: {e}") progress(1.0, desc="Fight booked!") gallery = [(img, f"Round {beat['id']}: {beat['title']}") for img, beat in zip(frames, card["beats"])] return (card, frames, _tale_html(card), gallery, _commentary_md(card), gr.update(interactive=True), gr.update(selected="brawl"), "", None, "") def run_animate(card, frames, style, progress=gr.Progress()): """Generator: first yield shows loading immediately; second yields result or error.""" if not card or not frames: raise gr.Error("Book a fight first (press FIGHT!).") loading = _animate_loading_html() btn_busy = gr.update(interactive=False, value="🎬 Animating…") btn_ready = gr.update(interactive=True, value="🎬 Animate the full fight") # Immediate feedback before the long GPU call. yield ( loading, None, loading, gr.update(selected="knockout"), btn_busy, ) try: progress(0.05, desc="Starting video engine (LTX + ComfyUI)…") progress(0.2, desc="Animating 5 beats + burning in captions…") path = animate(card, frames, style) progress(1.0, desc="Fight video ready!") except (RuntimeError, BackendError) as e: err = _animate_error_html(str(e)) yield (err, None, err, gr.update(selected="knockout"), btn_ready) return yield ("", path, _winner_html(card), gr.update(selected="knockout"), btn_ready) def export_card(card): if not card: raise gr.Error("Nothing to export yet.") fd, path = tempfile.mkstemp(prefix="deathmatch_fightcard_", suffix=".json") with os.fdopen(fd, "w") as f: json.dump(card, f, indent=2, ensure_ascii=False) return path with gr.Blocks(title="Celebrity Deathmatch") as demo: gr.HTML("
⚠ PARODY — AI-generated claymation caricatures " "of public figures for comedic effect. Not real. No real people were harmed.
") gr.HTML(_hero()) gr.HTML(_status()) card_state = gr.State(None) frames_state = gr.State(None) with gr.Tabs(selected="tape") as tabs: with gr.Tab("01 Tale of the Tape", id="tape"): with gr.Row(equal_height=True): img_a = gr.Image(label="Fighter A", type="pil", height=400, elem_classes=["dm-fighter"]) img_b = gr.Image(label="Fighter B", type="pil", height=400, elem_classes=["dm-fighter"]) with gr.Row(): name_a = gr.Textbox(label="Fighter A name (optional)", placeholder="auto-detected if left blank") name_b = gr.Textbox(label="Fighter B name (optional)", placeholder="auto-detected if left blank") with gr.Row(): storyline = gr.Textbox(label="Backstory / beef (optional)", max_lines=2, placeholder="e.g. They feuded over a sampled hook") arena = gr.Dropdown(ARENAS, value=ARENAS[0], label="Arena") style = gr.Dropdown(STYLES, value=STYLES[0], label="Visual style") fight_btn = gr.Button("FIGHT!", variant="primary", elem_classes=["dm-primary"]) tale = gr.HTML() with gr.Tab("02 The Brawl", id="brawl"): gr.Markdown("### The keyframe reel") reel = gr.Gallery(label="Fight reel", columns=5, height=320, object_fit="cover") commentary = gr.Markdown(elem_classes=["dm-commentary"]) animate_btn = gr.Button("🎬 Animate the full fight", variant="primary", interactive=False, elem_classes=["dm-primary"]) animate_status = gr.HTML() gr.Markdown("_Animate chains the keyframes into one continuous, " "caption-burned fight clip (Stage 3 — opt-in, GPU-heavy)._") with gr.Tab("03 Knockout", id="knockout"): winner = gr.HTML() fight_video = gr.Video(label="The fight", autoplay=True) export_btn = gr.Button("Download fight card (JSON)") export_file = gr.File(label="Fight card") fight_btn.click( run_fight, [img_a, img_b, storyline, arena, style, name_a, name_b], [card_state, frames_state, tale, reel, commentary, animate_btn, tabs, animate_status, fight_video, winner], show_progress="full", ) animate_btn.click( run_animate, [card_state, frames_state, style], [animate_status, fight_video, winner, tabs, animate_btn], show_progress="full", ) export_btn.click(export_card, [card_state], [export_file]) # Pre-loaded celebrity matchups (parody). Each entry pre-fills both photos + # names + arena. A matchup only appears once BOTH of its photos are present in # ui/examples/ (drop in legally-licensed images — see examples/README.md), so # this is safe to ship even before the photos are added. _EX_DIR = os.path.join(os.path.dirname(__file__), "examples") def _ex_photo(slug): for ext in (".jpg", ".jpeg", ".png", ".webp"): p = os.path.join(_EX_DIR, slug + ext) if os.path.exists(p): return p return None # (slug_a, name_a, slug_b, name_b, arena) EXAMPLE_MATCHUPS = [ # Open-source vs closed AI — a wink for the Hugging Face judges (lead bout). ("clem", "Clem Delangue", "altman", "Sam Altman", "Volcano lair"), ("britney", "Britney Spears", "eminem", "Eminem", "Classic Deathmatch ring"), ("keanu", "Keanu Reeves", "scarlett", "Scarlett Johansson", "Rooftop at night"), ] _examples = [] for a_slug, a_name, b_slug, b_name, ex_arena in EXAMPLE_MATCHUPS: pa, pb = _ex_photo(a_slug), _ex_photo(b_slug) if pa and pb: arena_val = ex_arena if ex_arena in ARENAS else ARENAS[0] _examples.append([pa, pb, "", arena_val, STYLES[0], a_name, b_name]) if _examples: # Fold the matchup picker into a collapsed accordion so it tucks away # like a menu instead of always sprawling at the bottom. with gr.Accordion("⚡ Try a celebrity matchup (parody)", open=False): gr.Examples( examples=_examples, inputs=[img_a, img_b, storyline, arena, style, name_a, name_b], label="", examples_per_page=3, ) if __name__ == "__main__": demo.launch(max_file_size="10mb", ssr_mode=False, css=CSS)