GitHub Actions commited on
Commit
b96d4b9
Β·
0 Parent(s):

Deploy deathmatch from be2cd11

Browse files
Files changed (5) hide show
  1. README.md +40 -0
  2. app.py +253 -0
  3. mock.py +162 -0
  4. model_runtime.py +118 -0
  5. 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