GitHub Actions commited on
Commit Β·
b96d4b9
0
Parent(s):
Deploy deathmatch from be2cd11
Browse files- README.md +40 -0
- app.py +253 -0
- mock.py +162 -0
- model_runtime.py +118 -0
- requirements.txt +4 -0
README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Celebrity Deathmatch
|
| 3 |
+
emoji: π₯
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "6.0.0"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Celebrity Deathmatch
|
| 13 |
+
|
| 14 |
+
Upload two photos, and an AI ring director books an MTV-style claymation brawl:
|
| 15 |
+
a fight script with round-by-round announcer commentary, a rendered keyframe
|
| 16 |
+
reel, a declared winner β and an optional **Animate** button that chains it into
|
| 17 |
+
one continuous fight video with the commentary burned in as captions.
|
| 18 |
+
|
| 19 |
+
> β **Parody.** All visuals are AI-generated claymation caricatures of public
|
| 20 |
+
> figures, for comedic effect. Not real.
|
| 21 |
+
|
| 22 |
+
## Models
|
| 23 |
+
|
| 24 |
+
| Stage | Role | Model | GPU |
|
| 25 |
+
|------|------|-------|-----|
|
| 26 |
+
| 1 | Fight director (reads both photos) | MiniCPM-V-2_6 (OpenBMB) | A10G |
|
| 27 |
+
| 2 | Claymation keyframes | FLUX.1-schnell (BFL) | L40S |
|
| 28 |
+
| 3 | Imageβvideo fight clip (opt-in) | LTX-Video (Lightricks) | H100 |
|
| 29 |
+
|
| 30 |
+
## Architecture
|
| 31 |
+
|
| 32 |
+
- **Frontend:** this Gradio Space.
|
| 33 |
+
- **Backend:** Modal GPU app `deathmatch` β set `DEATHMATCH_API_URL` (the deploy
|
| 34 |
+
pipeline does this automatically).
|
| 35 |
+
- **Mock mode:** run locally with `DEATHMATCH_MOCK=1` to exercise the full UX on
|
| 36 |
+
CPU (canned fight, placeholder frames, GIF stand-in for the video).
|
| 37 |
+
|
| 38 |
+
```sh
|
| 39 |
+
DEATHMATCH_MOCK=1 python app.py
|
| 40 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Celebrity Deathmatch β Gradio frontend (HuggingFace Space).
|
| 2 |
+
|
| 3 |
+
Upload two fighters -> FIGHT! runs Stage 1 (fight script) + Stage 2 (keyframe reel).
|
| 4 |
+
The opt-in "Animate" button runs Stage 3 (chained, captioned fight video).
|
| 5 |
+
Runs fully on CPU with DEATHMATCH_MOCK=1 (canned fight, placeholder frames).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import tempfile
|
| 12 |
+
|
| 13 |
+
import gradio as gr
|
| 14 |
+
|
| 15 |
+
from model_runtime import (
|
| 16 |
+
API_URL, MOCK, BackendError, animate, generate_fightcard,
|
| 17 |
+
generate_keyframes, health,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
ARENAS = [
|
| 21 |
+
"Classic Deathmatch ring", "Rooftop at night", "Abandoned shopping mall",
|
| 22 |
+
"Volcano lair", "Suburban backyard", "Neon Tokyo street",
|
| 23 |
+
]
|
| 24 |
+
STYLES = ["Claymation", "Stop-motion", "Cartoon", "Comic book"]
|
| 25 |
+
|
| 26 |
+
CSS = """
|
| 27 |
+
@import url('https://fonts.googleapis.com/css2?family=Anton&family=DM+Sans:wght@400;600;800&family=JetBrains+Mono:wght@600&display=swap');
|
| 28 |
+
:root {
|
| 29 |
+
--dm-bg: #0b0608; --dm-panel: rgba(24,12,16,0.86); --dm-red: #e0303c;
|
| 30 |
+
--dm-gold: #f2b030; --dm-text: #f4ede0; --dm-muted: #b09088;
|
| 31 |
+
}
|
| 32 |
+
body, .gradio-container {
|
| 33 |
+
background:
|
| 34 |
+
radial-gradient(circle at 15% 8%, rgba(224,48,60,0.18), transparent 32%),
|
| 35 |
+
radial-gradient(circle at 85% 4%, rgba(242,176,48,0.12), transparent 34%),
|
| 36 |
+
linear-gradient(160deg, #0b0608 0%, #140a0d 60%, #1c0e12 100%) !important;
|
| 37 |
+
color: var(--dm-text) !important; font-family: 'DM Sans', sans-serif !important;
|
| 38 |
+
}
|
| 39 |
+
.gradio-container { max-width: 1180px !important; margin: 0 auto !important; padding: 22px 20px 44px !important; }
|
| 40 |
+
.dm-disclaimer {
|
| 41 |
+
margin: 0 0 16px; padding: 9px 14px; border: 1px dashed rgba(242,176,48,0.45);
|
| 42 |
+
border-radius: 12px; background: rgba(242,176,48,0.07); color: var(--dm-gold);
|
| 43 |
+
font-family: 'JetBrains Mono', monospace; font-size: 11.5px; letter-spacing: 0.04em; text-align: center;
|
| 44 |
+
}
|
| 45 |
+
.dm-hero {
|
| 46 |
+
position: relative; overflow: hidden; padding: 30px 32px; border-radius: 26px;
|
| 47 |
+
border: 1px solid rgba(224,48,60,0.3);
|
| 48 |
+
background: linear-gradient(120deg, rgba(28,12,16,0.95), rgba(12,6,8,0.9)),
|
| 49 |
+
radial-gradient(circle at 88% 20%, rgba(224,48,60,0.28), transparent 40%);
|
| 50 |
+
box-shadow: 0 22px 80px rgba(0,0,0,0.5);
|
| 51 |
+
}
|
| 52 |
+
.dm-hero h1 {
|
| 53 |
+
margin: 0; font-family: 'Anton', sans-serif; font-size: clamp(46px, 8vw, 92px);
|
| 54 |
+
line-height: 0.9; letter-spacing: 0.01em; text-transform: uppercase;
|
| 55 |
+
background: linear-gradient(90deg, #ffd27a, var(--dm-red) 60%, #ff5060);
|
| 56 |
+
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
| 57 |
+
}
|
| 58 |
+
.dm-hero p { margin: 12px 0 0; color: var(--dm-muted); font-size: 16px; max-width: 640px; }
|
| 59 |
+
.dm-status {
|
| 60 |
+
margin: 14px 0 4px; padding: 11px 14px; border-radius: 14px; font-size: 12px;
|
| 61 |
+
font-family: 'JetBrains Mono', monospace; border: 1px solid rgba(255,255,255,0.1);
|
| 62 |
+
background: rgba(10,6,8,0.7); color: var(--dm-muted);
|
| 63 |
+
}
|
| 64 |
+
.dm-status.ok { border-color: rgba(60,200,110,0.4); }
|
| 65 |
+
.dm-status.mock { border-color: rgba(242,176,48,0.45); color: var(--dm-gold); }
|
| 66 |
+
.dm-status.fail { border-color: rgba(224,48,60,0.5); color: #ff8a8a; }
|
| 67 |
+
.tape { display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: stretch; margin-top: 6px; }
|
| 68 |
+
.tape-card {
|
| 69 |
+
padding: 18px; border-radius: 20px; border: 1px solid rgba(255,255,255,0.09);
|
| 70 |
+
background: var(--dm-panel); box-shadow: 0 14px 44px rgba(0,0,0,0.34);
|
| 71 |
+
}
|
| 72 |
+
.tape-card.b { border-color: rgba(64,160,200,0.28); }
|
| 73 |
+
.tape-card .nm { font-family: 'Anton', sans-serif; font-size: 30px; text-transform: uppercase; color: var(--dm-text); }
|
| 74 |
+
.tape-card .tag { color: var(--dm-muted); font-size: 13px; margin: 6px 0 14px; }
|
| 75 |
+
.tape-card .mv { color: var(--dm-gold); font-size: 13px; font-family: 'JetBrains Mono', monospace; margin-bottom: 14px; }
|
| 76 |
+
.statrow { display:flex; align-items:center; gap:10px; margin:7px 0; font-size:11px; font-family:'JetBrains Mono',monospace; color:var(--dm-muted); }
|
| 77 |
+
.statrow .lab { width: 96px; text-transform: uppercase; }
|
| 78 |
+
.bar { flex:1; height:9px; border-radius:99px; background: rgba(255,255,255,0.08); overflow:hidden; }
|
| 79 |
+
.bar > span { display:block; height:100%; background: linear-gradient(90deg, var(--dm-gold), var(--dm-red)); }
|
| 80 |
+
.vs { display:flex; align-items:center; font-family:'Anton',sans-serif; font-size:40px; color:var(--dm-red); }
|
| 81 |
+
.winner-banner {
|
| 82 |
+
margin-top: 4px; padding: 22px; border-radius: 22px; text-align: center;
|
| 83 |
+
border: 1px solid rgba(242,176,48,0.5);
|
| 84 |
+
background: radial-gradient(circle at 50% 0%, rgba(242,176,48,0.22), transparent 60%), var(--dm-panel);
|
| 85 |
+
}
|
| 86 |
+
.winner-banner .w { font-family:'Anton',sans-serif; font-size: 56px; text-transform: uppercase;
|
| 87 |
+
background: linear-gradient(90deg,#ffd27a,var(--dm-red)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
| 88 |
+
.winner-banner .r { color: var(--dm-muted); font-size: 14px; margin-top: 6px; }
|
| 89 |
+
button.primary, .dm-primary button {
|
| 90 |
+
border: 0 !important; border-radius: 999px !important;
|
| 91 |
+
background: linear-gradient(135deg, #ffd27a, var(--dm-red) 55%, #a01820) !important;
|
| 92 |
+
color: #1a0608 !important; font-family:'Anton',sans-serif !important; letter-spacing:0.06em !important;
|
| 93 |
+
font-size: 18px !important; text-transform: uppercase !important;
|
| 94 |
+
box-shadow: 0 14px 40px rgba(224,48,60,0.32) !important;
|
| 95 |
+
}
|
| 96 |
+
.dm-commentary { padding: 16px 18px; border-radius: 18px; border: 1px solid rgba(255,255,255,0.08); background: var(--dm-panel); }
|
| 97 |
+
label, .block label { color: var(--dm-muted) !important; font-family:'JetBrains Mono',monospace !important;
|
| 98 |
+
font-size: 11px !important; letter-spacing: 0.07em !important; text-transform: uppercase !important; }
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _hero() -> str:
|
| 103 |
+
return ("<section class='dm-hero'><h1>Celebrity<br>Deathmatch</h1>"
|
| 104 |
+
"<p>Upload two fighters. Our AI ring director books the brawl, "
|
| 105 |
+
"renders the claymation reel, and crowns a winner. Then hit "
|
| 106 |
+
"<b>Animate</b> for the full fight.</p></section>")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _status() -> str:
|
| 110 |
+
h = health()
|
| 111 |
+
s = h.get("status")
|
| 112 |
+
if s == "mock":
|
| 113 |
+
return ("<div class='dm-status mock'>β MOCK MODE β canned fight, no GPU. "
|
| 114 |
+
"Set DEATHMATCH_MOCK=0 + deploy Modal for real models.</div>")
|
| 115 |
+
if s == "ok":
|
| 116 |
+
return (f"<div class='dm-status ok'>β BACKEND ONLINE / {h.get('stage1','?')} / "
|
| 117 |
+
f"{h.get('stage2','?')}</div>")
|
| 118 |
+
return (f"<div class='dm-status fail'>β BACKEND UNREACHABLE / {API_URL} / "
|
| 119 |
+
"deploy Modal or run with DEATHMATCH_MOCK=1</div>")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _statbar(label: str, value: int) -> str:
|
| 123 |
+
pct = max(0, min(100, int(value) * 10))
|
| 124 |
+
return (f"<div class='statrow'><span class='lab'>{label}</span>"
|
| 125 |
+
f"<span class='bar'><span style='width:{pct}%'></span></span>"
|
| 126 |
+
f"<span>{int(value)}/10</span></div>")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _fighter_card(f: dict, side: str) -> str:
|
| 130 |
+
stats = f.get("stats", {})
|
| 131 |
+
bars = "".join(_statbar(k, stats.get(k, 5)) for k in ("power", "speed", "showmanship"))
|
| 132 |
+
return (f"<div class='tape-card {side}'>"
|
| 133 |
+
f"<div class='nm'>{f.get('name','?')}</div>"
|
| 134 |
+
f"<div class='tag'>{f.get('persona','')}</div>"
|
| 135 |
+
f"<div class='mv'>β
{f.get('signature_move','')}</div>"
|
| 136 |
+
f"{bars}</div>")
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _tale_html(card: dict) -> str:
|
| 140 |
+
return ("<div class='tape'>"
|
| 141 |
+
+ _fighter_card(card["fighter_a"], "a")
|
| 142 |
+
+ "<div class='vs'>VS</div>"
|
| 143 |
+
+ _fighter_card(card["fighter_b"], "b")
|
| 144 |
+
+ "</div>")
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _commentary_md(card: dict) -> str:
|
| 148 |
+
lines = ["### Ringside commentary"]
|
| 149 |
+
for beat in card["beats"]:
|
| 150 |
+
lines.append(f"**Round {beat['id']} β {beat['title']}** \n"
|
| 151 |
+
f"_{beat['commentary']}_")
|
| 152 |
+
return "\n\n".join(lines)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _winner_html(card: dict) -> str:
|
| 156 |
+
w = card["fighter_a"] if str(card.get("winner", "A")).upper() == "A" else card["fighter_b"]
|
| 157 |
+
return (f"<div class='winner-banner'><div style='font-family:JetBrains Mono;color:#b09088;"
|
| 158 |
+
f"font-size:12px;letter-spacing:0.2em'>WINNER BY KNOCKOUT</div>"
|
| 159 |
+
f"<div class='w'>{w.get('name','?')}</div>"
|
| 160 |
+
f"<div class='r'>{card.get('winner_reason','')}</div></div>")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def run_fight(image_a, image_b, storyline, arena, style):
|
| 164 |
+
if image_a is None or image_b is None:
|
| 165 |
+
raise gr.Error("Upload a photo for BOTH fighters first.")
|
| 166 |
+
try:
|
| 167 |
+
card = generate_fightcard(image_a, image_b, storyline, arena, style)
|
| 168 |
+
frames = generate_keyframes(card, style)
|
| 169 |
+
except (RuntimeError, BackendError) as e:
|
| 170 |
+
raise gr.Error(f"Fight booking failed: {e}")
|
| 171 |
+
gallery = [(img, f"Round {beat['id']}: {beat['title']}")
|
| 172 |
+
for img, beat in zip(frames, card["beats"])]
|
| 173 |
+
return (card, frames, _tale_html(card), gallery, _commentary_md(card),
|
| 174 |
+
gr.update(interactive=True), gr.update(selected="brawl"))
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def run_animate(card, frames, style):
|
| 178 |
+
if not card or not frames:
|
| 179 |
+
raise gr.Error("Book a fight first (press FIGHT!).")
|
| 180 |
+
try:
|
| 181 |
+
path = animate(card, frames, style)
|
| 182 |
+
except (RuntimeError, BackendError) as e:
|
| 183 |
+
raise gr.Error(f"Animation failed: {e}")
|
| 184 |
+
return path, _winner_html(card), gr.update(selected="knockout")
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def export_card(card):
|
| 188 |
+
if not card:
|
| 189 |
+
raise gr.Error("Nothing to export yet.")
|
| 190 |
+
fd, path = tempfile.mkstemp(prefix="deathmatch_fightcard_", suffix=".json")
|
| 191 |
+
with os.fdopen(fd, "w") as f:
|
| 192 |
+
json.dump(card, f, indent=2, ensure_ascii=False)
|
| 193 |
+
return path
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
with gr.Blocks(title="Celebrity Deathmatch") as demo:
|
| 197 |
+
gr.HTML("<div class='dm-disclaimer'>β PARODY β AI-generated claymation caricatures "
|
| 198 |
+
"of public figures for comedic effect. Not real. No real people were harmed.</div>")
|
| 199 |
+
gr.HTML(_hero())
|
| 200 |
+
gr.HTML(_status())
|
| 201 |
+
|
| 202 |
+
card_state = gr.State(None)
|
| 203 |
+
frames_state = gr.State(None)
|
| 204 |
+
|
| 205 |
+
with gr.Tabs(selected="tape") as tabs:
|
| 206 |
+
with gr.Tab("01 Tale of the Tape", id="tape"):
|
| 207 |
+
with gr.Row():
|
| 208 |
+
img_a = gr.Image(label="Fighter A", type="pil")
|
| 209 |
+
img_b = gr.Image(label="Fighter B", type="pil")
|
| 210 |
+
with gr.Row():
|
| 211 |
+
storyline = gr.Textbox(label="Backstory / beef (optional)", max_lines=2,
|
| 212 |
+
placeholder="e.g. They feuded over a sampled hook")
|
| 213 |
+
arena = gr.Dropdown(ARENAS, value=ARENAS[0], label="Arena")
|
| 214 |
+
style = gr.Dropdown(STYLES, value=STYLES[0], label="Visual style")
|
| 215 |
+
fight_btn = gr.Button("FIGHT!", variant="primary", elem_classes=["dm-primary"])
|
| 216 |
+
tale = gr.HTML()
|
| 217 |
+
|
| 218 |
+
with gr.Tab("02 The Brawl", id="brawl"):
|
| 219 |
+
gr.Markdown("### The keyframe reel")
|
| 220 |
+
reel = gr.Gallery(label="Fight reel", columns=5, height=320,
|
| 221 |
+
object_fit="cover")
|
| 222 |
+
commentary = gr.Markdown()
|
| 223 |
+
animate_btn = gr.Button("π¬ Animate the full fight", variant="primary",
|
| 224 |
+
interactive=False, elem_classes=["dm-primary"])
|
| 225 |
+
gr.Markdown("_Animate chains the keyframes into one continuous, "
|
| 226 |
+
"caption-burned fight clip (Stage 3 β opt-in, GPU-heavy)._")
|
| 227 |
+
|
| 228 |
+
with gr.Tab("03 Knockout", id="knockout"):
|
| 229 |
+
winner = gr.HTML()
|
| 230 |
+
fight_video = gr.Video(label="The fight", autoplay=True)
|
| 231 |
+
export_btn = gr.Button("Download fight card (JSON)")
|
| 232 |
+
export_file = gr.File(label="Fight card")
|
| 233 |
+
|
| 234 |
+
fight_btn.click(
|
| 235 |
+
run_fight, [img_a, img_b, storyline, arena, style],
|
| 236 |
+
[card_state, frames_state, tale, reel, commentary, animate_btn, tabs],
|
| 237 |
+
)
|
| 238 |
+
animate_btn.click(
|
| 239 |
+
run_animate, [card_state, frames_state, style],
|
| 240 |
+
[fight_video, winner, tabs],
|
| 241 |
+
)
|
| 242 |
+
export_btn.click(export_card, [card_state], [export_file])
|
| 243 |
+
|
| 244 |
+
demo_a = os.path.join(os.path.dirname(__file__), "examples", "fighter_a.png")
|
| 245 |
+
demo_b = os.path.join(os.path.dirname(__file__), "examples", "fighter_b.png")
|
| 246 |
+
if os.path.exists(demo_a) and os.path.exists(demo_b):
|
| 247 |
+
gr.Examples(examples=[[demo_a, demo_b, "", ARENAS[0], STYLES[0]]],
|
| 248 |
+
inputs=[img_a, img_b, storyline, arena, style],
|
| 249 |
+
label="Try a demo matchup")
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
if __name__ == "__main__":
|
| 253 |
+
demo.launch(max_file_size="10mb", ssr_mode=False, css=CSS)
|
mock.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Celebrity Deathmatch β mock data + CPU placeholder renderers (DEATHMATCH_MOCK=1).
|
| 2 |
+
|
| 3 |
+
Lets the whole UX run on CPU in seconds with no backend: a canned fight card,
|
| 4 |
+
labelled placeholder keyframes, and a mock "fight video" (animated GIF cycling
|
| 5 |
+
the keyframes with commentary captions burned in β proving the captions decision).
|
| 6 |
+
Real models swap in behind the same model_runtime signatures.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import tempfile
|
| 12 |
+
import textwrap
|
| 13 |
+
|
| 14 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 15 |
+
|
| 16 |
+
# Canned Stage-1 output β the marquee matchup. Same shape as the backend FightCard.
|
| 17 |
+
MOCK_FIGHTCARD = {
|
| 18 |
+
"fighter_a": {
|
| 19 |
+
"name": "Britney Spears",
|
| 20 |
+
"appearance": "blonde clay figure with a high ponytail, sparkly red bodysuit, "
|
| 21 |
+
"headset microphone, confident pop-diva pose",
|
| 22 |
+
"persona": "Unbothered pop royalty who turns every hit into choreography",
|
| 23 |
+
"signature_move": "The Toxic Spin Kick",
|
| 24 |
+
"stats": {"power": 6, "speed": 9, "showmanship": 10},
|
| 25 |
+
},
|
| 26 |
+
"fighter_b": {
|
| 27 |
+
"name": "Eminem",
|
| 28 |
+
"appearance": "lanky clay figure with bleached buzzcut, white tank top, baggy jeans, "
|
| 29 |
+
"permanent scowl, fists raised",
|
| 30 |
+
"persona": "Rapid-fire trash-talker who fights to a 140 BPM flow",
|
| 31 |
+
"signature_move": "The Rap God Combo",
|
| 32 |
+
"stats": {"power": 8, "speed": 8, "showmanship": 7},
|
| 33 |
+
},
|
| 34 |
+
"arena": "Classic Deathmatch ring",
|
| 35 |
+
"beats": [
|
| 36 |
+
{"id": 1, "title": "The stare-down / entrances",
|
| 37 |
+
"action": "Britney moonwalks into the ring under a glitter cannon while Eminem "
|
| 38 |
+
"stomps in from the opposite corner, cracking his clay knuckles",
|
| 39 |
+
"commentary": "Welcome to Deathmatch! Pop royalty versus the Rap God β "
|
| 40 |
+
"and the glitter is already in Slim's eyes, Johnny!",
|
| 41 |
+
"video_prompt": "two clay fighters enter a wrestling ring, glitter falling, slow zoom"},
|
| 42 |
+
{"id": 2, "title": "First blood",
|
| 43 |
+
"action": "Eminem fires off a flurry of word-bullet punches; Britney back-flips "
|
| 44 |
+
"away and flicks her ponytail like a whip",
|
| 45 |
+
"commentary": "Ohh! Rapid-fire jabs from Eminem β but Britney's ponytail "
|
| 46 |
+
"snaps back like a bullwhip! That's gotta sting!",
|
| 47 |
+
"video_prompt": "fast punches, clay fighter dodges with a backflip, motion blur"},
|
| 48 |
+
{"id": 3, "title": "Signature move",
|
| 49 |
+
"action": "Britney launches the Toxic Spin Kick, a neon-green tornado of legs "
|
| 50 |
+
"that sends Eminem bouncing off the ropes",
|
| 51 |
+
"commentary": "THE TOXIC SPIN KICK! It's a green tornado of doom, Nick! "
|
| 52 |
+
"Slim Shady is ping-ponging off the ropes!",
|
| 53 |
+
"video_prompt": "spinning kick, green energy tornado, opponent flung into ropes"},
|
| 54 |
+
{"id": 4, "title": "Comeback / turning point",
|
| 55 |
+
"action": "Eminem shakes it off and unleashes the Rap God Combo β fists moving "
|
| 56 |
+
"so fast they blur into a clay smear",
|
| 57 |
+
"commentary": "But here comes the Rap God Combo! His hands are a BLUR! "
|
| 58 |
+
"Britney's tiara just got rearranged!",
|
| 59 |
+
"video_prompt": "blurred rapid punch combo, clay smear trails, dramatic angle"},
|
| 60 |
+
{"id": 5, "title": "Finishing move + KO",
|
| 61 |
+
"action": "Britney counters mid-combo, drops the beat AND Eminem with one final "
|
| 62 |
+
"spin, then strikes a victory pose on his flattened clay body",
|
| 63 |
+
"commentary": "She drops the beat β and drops EMINEM! It's all over! "
|
| 64 |
+
"Britney Spears wins the Deathmatch!",
|
| 65 |
+
"video_prompt": "final spin counter, opponent flattened, victory pose, confetti"},
|
| 66 |
+
],
|
| 67 |
+
"winner": "A",
|
| 68 |
+
"winner_reason": "Britney's showmanship and speed turned every hit into a dance "
|
| 69 |
+
"finale β Eminem never found the off-beat.",
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
_PALETTE = [
|
| 73 |
+
(210, 60, 70), (240, 160, 48), (64, 160, 200), (120, 90, 200), (60, 170, 110),
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _font(size: int):
|
| 78 |
+
for path in (
|
| 79 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 80 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 81 |
+
):
|
| 82 |
+
if os.path.exists(path):
|
| 83 |
+
return ImageFont.truetype(path, size)
|
| 84 |
+
try:
|
| 85 |
+
return ImageFont.load_default(size) # Pillow >= 10
|
| 86 |
+
except TypeError:
|
| 87 |
+
return ImageFont.load_default()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _wrap(draw, text, font, max_w):
|
| 91 |
+
words, lines, cur = text.split(), [], ""
|
| 92 |
+
for w in words:
|
| 93 |
+
trial = f"{cur} {w}".strip()
|
| 94 |
+
if draw.textlength(trial, font=font) <= max_w:
|
| 95 |
+
cur = trial
|
| 96 |
+
else:
|
| 97 |
+
if cur:
|
| 98 |
+
lines.append(cur)
|
| 99 |
+
cur = w
|
| 100 |
+
if cur:
|
| 101 |
+
lines.append(cur)
|
| 102 |
+
return lines
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def placeholder_keyframe(card: dict, beat: dict, size=(1024, 576)) -> Image.Image:
|
| 106 |
+
"""A labelled placeholder panel standing in for a FLUX render."""
|
| 107 |
+
w, h = size
|
| 108 |
+
idx = (beat["id"] - 1) % len(_PALETTE)
|
| 109 |
+
base = _PALETTE[idx]
|
| 110 |
+
img = Image.new("RGB", size, base)
|
| 111 |
+
draw = ImageDraw.Draw(img)
|
| 112 |
+
|
| 113 |
+
# Vignette-ish darkening band at the bottom for caption legibility.
|
| 114 |
+
draw.rectangle([0, h - 150, w, h], fill=(0, 0, 0))
|
| 115 |
+
|
| 116 |
+
title_f = _font(40)
|
| 117 |
+
body_f = _font(26)
|
| 118 |
+
small_f = _font(20)
|
| 119 |
+
|
| 120 |
+
a = card["fighter_a"]["name"]
|
| 121 |
+
b = card["fighter_b"]["name"]
|
| 122 |
+
draw.text((36, 28), f"ROUND {beat['id']}", font=_font(24), fill=(255, 255, 255))
|
| 123 |
+
draw.text((36, 60), f"{a} vs {b}", font=title_f, fill=(255, 255, 255))
|
| 124 |
+
|
| 125 |
+
for i, line in enumerate(_wrap(draw, beat["action"], body_f, w - 72)[:4]):
|
| 126 |
+
draw.text((36, 150 + i * 34), line, font=body_f, fill=(245, 245, 245))
|
| 127 |
+
|
| 128 |
+
for i, line in enumerate(_wrap(draw, beat["commentary"], small_f, w - 72)[:3]):
|
| 129 |
+
draw.text((36, h - 140 + i * 26), line, font=small_f, fill=(255, 220, 120))
|
| 130 |
+
|
| 131 |
+
draw.text((w - 150, 28), "MOCK", font=_font(28), fill=(255, 255, 255))
|
| 132 |
+
return img
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def placeholder_reel(card: dict, size=(1024, 576)) -> list[Image.Image]:
|
| 136 |
+
return [placeholder_keyframe(card, beat, size) for beat in card["beats"]]
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def mock_fight_video(frames: list[Image.Image], seconds_per_beat: float = 1.6,
|
| 140 |
+
fps: int = 24) -> str:
|
| 141 |
+
"""Stitch keyframes into an MP4 β the mock stand-in for the LTX fight video.
|
| 142 |
+
|
| 143 |
+
Commentary is already drawn onto each placeholder frame, so this mirrors the
|
| 144 |
+
real Stage-3 output type (an MP4 for gr.Video) without a GPU. Real mode swaps
|
| 145 |
+
in the chained, caption-burned clip from the deathmatch-video service.
|
| 146 |
+
"""
|
| 147 |
+
import imageio.v2 as imageio
|
| 148 |
+
import numpy as np
|
| 149 |
+
|
| 150 |
+
fd, path = tempfile.mkstemp(prefix="deathmatch_mock_", suffix=".mp4")
|
| 151 |
+
os.close(fd)
|
| 152 |
+
reps = max(1, int(seconds_per_beat * fps))
|
| 153 |
+
writer = imageio.get_writer(path, fps=fps, codec="libx264",
|
| 154 |
+
macro_block_size=8)
|
| 155 |
+
try:
|
| 156 |
+
for f in frames:
|
| 157 |
+
arr = np.asarray(f.convert("RGB"))
|
| 158 |
+
for _ in range(reps):
|
| 159 |
+
writer.append_data(arr)
|
| 160 |
+
finally:
|
| 161 |
+
writer.close()
|
| 162 |
+
return path
|
model_runtime.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Celebrity Deathmatch β runtime client.
|
| 2 |
+
|
| 3 |
+
Talks to the Modal backend (modal.App("deathmatch")) over HTTP, OR serves canned
|
| 4 |
+
data when DEATHMATCH_MOCK is set β same function signatures either way, so the UI
|
| 5 |
+
never branches on mode. The HF Space deploy injects DEATHMATCH_API_URL.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import base64
|
| 10 |
+
import io
|
| 11 |
+
import os
|
| 12 |
+
import tempfile
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
|
| 16 |
+
API_URL = os.environ.get(
|
| 17 |
+
"DEATHMATCH_API_URL",
|
| 18 |
+
"https://rafalbogusdxc--deathmatch-api.modal.run",
|
| 19 |
+
).rstrip("/")
|
| 20 |
+
|
| 21 |
+
MOCK = os.environ.get("DEATHMATCH_MOCK", "").strip().lower() in ("1", "true", "yes", "on")
|
| 22 |
+
|
| 23 |
+
TIMEOUT_S = 900
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class BackendError(RuntimeError):
|
| 27 |
+
"""Inference backend unreachable or returned an error."""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _pil_to_b64(img) -> str:
|
| 31 |
+
buf = io.BytesIO()
|
| 32 |
+
img.convert("RGB").save(buf, format="PNG")
|
| 33 |
+
return base64.b64encode(buf.getvalue()).decode()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _b64_to_pil(data: str):
|
| 37 |
+
from PIL import Image
|
| 38 |
+
return Image.open(io.BytesIO(base64.b64decode(data))).convert("RGB")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _post(path: str, payload: dict, timeout: float = TIMEOUT_S) -> dict:
|
| 42 |
+
url = f"{API_URL}{path}"
|
| 43 |
+
try:
|
| 44 |
+
resp = httpx.post(url, json=payload, timeout=timeout, follow_redirects=True)
|
| 45 |
+
resp.raise_for_status()
|
| 46 |
+
return resp.json()
|
| 47 |
+
except httpx.ConnectError as e:
|
| 48 |
+
raise BackendError(
|
| 49 |
+
f"Cannot reach the Deathmatch backend at {API_URL} β is the Modal app "
|
| 50 |
+
f"deployed? ({e})"
|
| 51 |
+
) from e
|
| 52 |
+
except httpx.ReadTimeout as e:
|
| 53 |
+
raise BackendError(
|
| 54 |
+
"Backend timed out β likely a GPU cold start pulling weights. "
|
| 55 |
+
"Try again in ~1 minute."
|
| 56 |
+
) from e
|
| 57 |
+
except httpx.HTTPStatusError as e:
|
| 58 |
+
raise BackendError(
|
| 59 |
+
f"Backend error {e.response.status_code}: {e.response.text[:300]}"
|
| 60 |
+
) from e
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def health() -> dict:
|
| 64 |
+
if MOCK:
|
| 65 |
+
return {"status": "mock", "service": "deathmatch (mock mode β no GPU)"}
|
| 66 |
+
try:
|
| 67 |
+
resp = httpx.get(f"{API_URL}/health", timeout=10, follow_redirects=True)
|
| 68 |
+
resp.raise_for_status()
|
| 69 |
+
return resp.json()
|
| 70 |
+
except Exception as e: # noqa: BLE001 β banner only, never crash the UI
|
| 71 |
+
return {"status": "unreachable", "error": str(e), "url": API_URL}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def generate_fightcard(image_a, image_b, storyline: str, arena: str, style: str) -> dict:
|
| 75 |
+
"""Stage 1 β two photos -> validated fight card dict."""
|
| 76 |
+
if MOCK:
|
| 77 |
+
import copy
|
| 78 |
+
from mock import MOCK_FIGHTCARD
|
| 79 |
+
card = copy.deepcopy(MOCK_FIGHTCARD)
|
| 80 |
+
card["arena"] = arena or card["arena"]
|
| 81 |
+
return card
|
| 82 |
+
data = _post("/fightcard", {
|
| 83 |
+
"image_a_b64": _pil_to_b64(image_a),
|
| 84 |
+
"image_b_b64": _pil_to_b64(image_b),
|
| 85 |
+
"storyline": (storyline or "")[:500],
|
| 86 |
+
"arena": arena,
|
| 87 |
+
"style": style,
|
| 88 |
+
})
|
| 89 |
+
return data["fightcard"]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def generate_keyframes(card: dict, style: str, aspect: str = "16:9") -> list:
|
| 93 |
+
"""Stage 2 β fight card -> list of 5 keyframe PIL images."""
|
| 94 |
+
if MOCK:
|
| 95 |
+
from mock import placeholder_reel
|
| 96 |
+
return placeholder_reel(card)
|
| 97 |
+
data = _post("/keyframes", {"fightcard": card, "style": style, "aspect": aspect})
|
| 98 |
+
return [_b64_to_pil(b) for b in data["images_b64"]]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def animate(card: dict, keyframes: list, style: str) -> str:
|
| 102 |
+
"""Stage 3 β keyframes -> one chained, captioned fight clip. Returns a file path.
|
| 103 |
+
|
| 104 |
+
Mock: an animated GIF cycling the keyframes with captions burned in.
|
| 105 |
+
Real: an MP4 from LTX-Video (Slice 4) written to a temp file.
|
| 106 |
+
"""
|
| 107 |
+
if MOCK:
|
| 108 |
+
from mock import mock_fight_video
|
| 109 |
+
return mock_fight_video(keyframes)
|
| 110 |
+
data = _post("/animate", {
|
| 111 |
+
"fightcard": card,
|
| 112 |
+
"keyframes_b64": [_pil_to_b64(f) for f in keyframes],
|
| 113 |
+
"style": style,
|
| 114 |
+
})
|
| 115 |
+
fd, path = tempfile.mkstemp(prefix="deathmatch_", suffix=".mp4")
|
| 116 |
+
os.write(fd, base64.b64decode(data["video_b64"]))
|
| 117 |
+
os.close(fd)
|
| 118 |
+
return path
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=6.0
|
| 2 |
+
httpx>=0.27
|
| 3 |
+
Pillow>=10.0
|
| 4 |
+
imageio[ffmpeg]>=2.34
|