| """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 ("<section class='dm-hero'><h1>Celebrity<br>Deathmatch</h1>" |
| "<p>Upload two fighters. Our AI ring director books the brawl, " |
| "renders the claymation reel, and crowns a winner. Then hit " |
| "<b>Animate</b> for the full fight.</p></section>") |
|
|
|
|
| def _status() -> str: |
| h = health() |
| s = h.get("status") |
| if s == "mock": |
| return ("<div class='dm-status mock'>● MOCK MODE — canned fight, no GPU. " |
| "Set DEATHMATCH_MOCK=0 + deploy Modal for real models.</div>") |
| if s == "ok": |
| return (f"<div class='dm-status ok'>● BACKEND ONLINE / {h.get('stage1','?')} / " |
| f"{h.get('stage2','?')}</div>") |
| return (f"<div class='dm-status fail'>● BACKEND UNREACHABLE / {API_URL} / " |
| "deploy Modal or run with DEATHMATCH_MOCK=1</div>") |
|
|
|
|
| def _statbar(label: str, value: int) -> str: |
| pct = max(0, min(100, int(value) * 10)) |
| return (f"<div class='statrow'><span class='lab'>{label}</span>" |
| f"<span class='bar'><span style='width:{pct}%'></span></span>" |
| f"<span>{int(value)}/10</span></div>") |
|
|
|
|
| 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"<div class='tape-card {side}'>" |
| f"<div class='nm'>{f.get('name','?')}</div>" |
| f"<div class='tag'>{f.get('persona','')}</div>" |
| f"<div class='mv'>★ {f.get('signature_move','')}</div>" |
| f"{bars}</div>") |
|
|
|
|
| def _tale_html(card: dict) -> str: |
| return ("<div class='tape'>" |
| + _fighter_card(card["fighter_a"], "a") |
| + "<div class='vs'>VS</div>" |
| + _fighter_card(card["fighter_b"], "b") |
| + "</div>") |
|
|
|
|
| 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"<div class='winner-banner'><div style='font-family:JetBrains Mono;color:#b09088;" |
| f"font-size:12px;letter-spacing:0.2em'>WINNER BY KNOCKOUT</div>" |
| f"<div class='w'>{w.get('name','?')}</div>" |
| f"<div class='r'>{card.get('winner_reason','')}</div></div>") |
|
|
|
|
| def _animate_loading_html() -> str: |
| return ( |
| "<div class='animate-status loading'>" |
| "<strong>🎬 Generating the full fight video…</strong><br>" |
| "Chaining your 5 keyframes into one continuous clip with captioned commentary. " |
| "This usually takes <b>1–3 minutes</b> (longer on a cold GPU). " |
| "Please keep this tab open — you'll land on Knockout when it's ready." |
| "</div>" |
| ) |
|
|
|
|
| def _animate_error_html(message: str) -> str: |
| safe = message.replace("&", "&").replace("<", "<").replace(">", ">") |
| return ( |
| f"<div class='animate-status fail'>" |
| f"<strong>Animation failed</strong><br>{safe}" |
| f"</div>" |
| ) |
|
|
|
|
| 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") |
|
|
| |
| 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("<div class='dm-disclaimer'>⚠ PARODY — AI-generated claymation caricatures " |
| "of public figures for comedic effect. Not real. No real people were harmed.</div>") |
| 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]) |
|
|
| |
| |
| |
| |
| _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 |
|
|
| |
| EXAMPLE_MATCHUPS = [ |
| |
| ("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: |
| |
| |
| 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) |
|
|