Matys BIECHE Claude Opus 4.8 (1M context) commited on
Commit
6b66235
·
1 Parent(s): 8c940b4

Add ZeroGPU-ready Gradio app for HF Space

Browse files

Adapt copie_de_poc.py into app.py: remove Colab pip-install/share/debug,
add spaces lib with @spaces.GPU decorator, load models on cuda at startup,
queue() the demo. Add requirements.txt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +830 -0
  2. requirements.txt +9 -0
app.py ADDED
@@ -0,0 +1,830 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """GameGen - POC mini-jeux génératifs visuels (HuggingFace Space / ZeroGPU)
3
+
4
+ Qwen3-4B génère la spec JSON, SDXL génère les assets, un canvas exécute le mini-jeu.
5
+ Adapté du POC Colab pour tourner sur HuggingFace Spaces avec ZeroGPU.
6
+ """
7
+
8
+ import spaces # doit être importé avant torch sur ZeroGPU
9
+
10
+ import torch, json, re, base64, io, html
11
+ from PIL import Image
12
+ from pydantic import BaseModel, Field
13
+ from typing import Literal, List, Dict
14
+
15
+
16
+ class VisualMiniGameSpec(BaseModel):
17
+ template: Literal["grab_target", "avoid_falling", "timing_hit"]
18
+ instruction: str = Field(min_length=5, max_length=80)
19
+ theme: str = Field(min_length=3, max_length=80)
20
+ duration_seconds: int = Field(ge=3, le=8)
21
+ difficulty: int = Field(ge=1, le=5)
22
+
23
+ visual_style: str = Field(min_length=10, max_length=200)
24
+ background_prompt: str = Field(min_length=10, max_length=300)
25
+ target_prompt: str = Field(min_length=10, max_length=300)
26
+ decoy_prompts: List[str] = Field(min_length=2, max_length=2)
27
+
28
+ success_text: str = Field(min_length=2, max_length=80)
29
+ failure_text: str = Field(min_length=2, max_length=80)
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Chargement des modèles (au démarrage du Space)
34
+ # ---------------------------------------------------------------------------
35
+ from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
36
+
37
+ LLM_ID = "Qwen/Qwen3-4B-Instruct-2507"
38
+
39
+ bnb_config = BitsAndBytesConfig(
40
+ load_in_4bit=True,
41
+ bnb_4bit_compute_dtype=torch.float16,
42
+ bnb_4bit_quant_type="nf4",
43
+ bnb_4bit_use_double_quant=True,
44
+ )
45
+
46
+ tokenizer = AutoTokenizer.from_pretrained(LLM_ID, trust_remote_code=True)
47
+
48
+ llm = AutoModelForCausalLM.from_pretrained(
49
+ LLM_ID,
50
+ quantization_config=bnb_config,
51
+ device_map="cuda",
52
+ trust_remote_code=True,
53
+ )
54
+
55
+ from diffusers import StableDiffusionXLPipeline
56
+
57
+ IMAGE_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
58
+
59
+ pipe = StableDiffusionXLPipeline.from_pretrained(
60
+ IMAGE_MODEL_ID,
61
+ torch_dtype=torch.float16,
62
+ use_safetensors=True,
63
+ variant="fp16",
64
+ )
65
+ pipe.to("cuda")
66
+ pipe.enable_attention_slicing()
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Génération de la spec (LLM)
71
+ # ---------------------------------------------------------------------------
72
+ SYSTEM_PROMPT = """
73
+ Tu es un game designer pour micro-jeux très courts type WarioWare-like.
74
+
75
+ Tu dois répondre uniquement en JSON valide.
76
+ Aucun markdown. Aucun commentaire. Aucun texte hors JSON.
77
+
78
+ Tu génères un mini-jeu visuel, pas un quiz.
79
+ Les objets doivent être rendus comme des vrais assets visuels générables par un modèle image.
80
+
81
+ Templates autorisés:
82
+ - grab_target: cliquer/toucher rapidement l'objet cible qui bouge
83
+ - avoid_falling: éviter des objets qui tombent
84
+ - timing_hit: cliquer/toucher au bon moment quand l'objet passe dans une zone
85
+
86
+ Contraintes:
87
+ - duration_seconds entre 3 et 8
88
+ - difficulty entre 1 et 5
89
+ - instruction courte, impérative, arcade
90
+ - prompts visuels en anglais
91
+ - style cohérent entre background, target et decoys
92
+ - target_prompt doit décrire un objet visuel concret
93
+ - decoy_prompts doivent décrire de vrais objets visuels, pas du texte
94
+ - pas de violence réaliste
95
+ - pas de texte dans les images
96
+ """
97
+
98
+ EXAMPLE_JSON = {
99
+ "template": "grab_target",
100
+ "instruction": "Attrape la banane !",
101
+ "theme": "marché fruité arcade",
102
+ "duration_seconds": 5,
103
+ "difficulty": 2,
104
+ "visual_style": "colorful arcade sticker art, bold black outlines, clean silhouettes, playful exaggerated shapes",
105
+ "background_prompt": "colorful arcade fruit market background, dynamic playful composition, bold shapes, vibrant colors, no text",
106
+ "target_prompt": "single ripe banana, sticker art, bold black outline, centered, clean silhouette, vibrant yellow, no text",
107
+ "decoy_prompts": [
108
+ "single yellow boomerang, sticker art, bold black outline, centered, clean silhouette, no text",
109
+ "single crescent moon, sticker art, bold black outline, centered, clean silhouette, no text",
110
+ ],
111
+ "success_text": "Bien joué !",
112
+ "failure_text": "Raté !",
113
+ }
114
+
115
+
116
+ def extract_json(text: str):
117
+ match = re.search(r"\{.*\}", text, re.DOTALL)
118
+ if not match:
119
+ raise ValueError("Aucun JSON trouvé")
120
+ return json.loads(match.group(0))
121
+
122
+
123
+ def generate_spec(user_prompt: str) -> VisualMiniGameSpec:
124
+ messages = [
125
+ {"role": "system", "content": SYSTEM_PROMPT},
126
+ {
127
+ "role": "user",
128
+ "content": f"""
129
+ Inspiration utilisateur: {user_prompt}
130
+
131
+ Génère un JSON exactement dans ce style:
132
+ {json.dumps(EXAMPLE_JSON, ensure_ascii=False, indent=2)}
133
+ """,
134
+ },
135
+ ]
136
+
137
+ try:
138
+ prompt = tokenizer.apply_chat_template(
139
+ messages,
140
+ tokenize=False,
141
+ add_generation_prompt=True,
142
+ enable_thinking=False,
143
+ )
144
+ except TypeError:
145
+ prompt = tokenizer.apply_chat_template(
146
+ messages,
147
+ tokenize=False,
148
+ add_generation_prompt=True,
149
+ )
150
+
151
+ inputs = tokenizer(prompt, return_tensors="pt").to(llm.device)
152
+
153
+ with torch.no_grad():
154
+ output = llm.generate(
155
+ **inputs,
156
+ max_new_tokens=420,
157
+ do_sample=True,
158
+ temperature=0.75,
159
+ top_p=0.9,
160
+ repetition_penalty=1.05,
161
+ )
162
+
163
+ decoded = tokenizer.decode(
164
+ output[0][inputs["input_ids"].shape[-1]:],
165
+ skip_special_tokens=True,
166
+ )
167
+
168
+ data = extract_json(decoded)
169
+ return VisualMiniGameSpec(**data)
170
+
171
+
172
+ def fallback_spec():
173
+ return VisualMiniGameSpec(**EXAMPLE_JSON)
174
+
175
+
176
+ def safe_generate_spec(prompt):
177
+ try:
178
+ return generate_spec(prompt)
179
+ except Exception as e:
180
+ print("Fallback spec:", e)
181
+ return fallback_spec()
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Génération des assets (SDXL)
186
+ # ---------------------------------------------------------------------------
187
+ ASSET_CACHE = {}
188
+
189
+ NEGATIVE_PROMPT = """
190
+ text, words, letters, watermark, logo, blurry, low quality,
191
+ photorealistic, realistic photo, messy background, cropped object,
192
+ multiple objects, extra limbs, duplicate, unreadable
193
+ """
194
+
195
+
196
+ def pil_to_b64(img: Image.Image) -> str:
197
+ buffer = io.BytesIO()
198
+ img.save(buffer, format="PNG")
199
+ return base64.b64encode(buffer.getvalue()).decode("utf-8")
200
+
201
+
202
+ def generate_image(prompt, width=512, height=512, steps=18):
203
+ key = (prompt, width, height, steps)
204
+
205
+ if key in ASSET_CACHE:
206
+ return ASSET_CACHE[key]
207
+
208
+ with torch.no_grad():
209
+ image = pipe(
210
+ prompt=prompt,
211
+ negative_prompt=NEGATIVE_PROMPT,
212
+ width=width,
213
+ height=height,
214
+ num_inference_steps=steps,
215
+ guidance_scale=7.0,
216
+ ).images[0]
217
+
218
+ ASSET_CACHE[key] = image
219
+ return image
220
+
221
+
222
+ def generate_assets(spec: VisualMiniGameSpec):
223
+ style = spec.visual_style
224
+
225
+ background = generate_image(
226
+ f"{spec.background_prompt}, {style}, no text, game background",
227
+ width=768,
228
+ height=512,
229
+ steps=18,
230
+ )
231
+
232
+ target = generate_image(
233
+ f"{spec.target_prompt}, {style}, isolated object, white background, no text",
234
+ width=512,
235
+ height=512,
236
+ steps=18,
237
+ )
238
+
239
+ decoy_1 = generate_image(
240
+ f"{spec.decoy_prompts[0]}, {style}, isolated object, white background, no text",
241
+ width=512,
242
+ height=512,
243
+ steps=18,
244
+ )
245
+
246
+ decoy_2 = generate_image(
247
+ f"{spec.decoy_prompts[1]}, {style}, isolated object, white background, no text",
248
+ width=512,
249
+ height=512,
250
+ steps=18,
251
+ )
252
+
253
+ return {
254
+ "background": pil_to_b64(background),
255
+ "target": pil_to_b64(target),
256
+ "decoy_1": pil_to_b64(decoy_1),
257
+ "decoy_2": pil_to_b64(decoy_2),
258
+ }
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Rendu du mini-jeu (canvas dans une iframe)
263
+ # ---------------------------------------------------------------------------
264
+ def render_canvas_game(spec: VisualMiniGameSpec, assets: Dict[str, str]):
265
+ spec_json = json.dumps(spec.model_dump(), ensure_ascii=False)
266
+ assets_json = json.dumps(assets)
267
+
268
+ iframe_html = f"""
269
+ <!doctype html>
270
+ <html>
271
+ <head>
272
+ <meta charset="utf-8" />
273
+ <style>
274
+ html, body {{
275
+ margin: 0;
276
+ padding: 0;
277
+ width: 100%;
278
+ height: 100%;
279
+ overflow: hidden;
280
+ background: #111;
281
+ font-family: system-ui, sans-serif;
282
+ }}
283
+
284
+ #wrap {{
285
+ position: relative;
286
+ width: 100%;
287
+ height: 100vh;
288
+ background: #111;
289
+ }}
290
+
291
+ canvas {{
292
+ display: block;
293
+ width: 100%;
294
+ height: 100%;
295
+ background: #000;
296
+ }}
297
+
298
+ #hud {{
299
+ position: absolute;
300
+ left: 16px;
301
+ right: 16px;
302
+ top: 14px;
303
+ display: flex;
304
+ justify-content: space-between;
305
+ align-items: flex-start;
306
+ pointer-events: none;
307
+ color: white;
308
+ text-shadow: 0 3px 0 #000;
309
+ }}
310
+
311
+ #instruction {{
312
+ font-size: 32px;
313
+ font-weight: 1000;
314
+ line-height: 1;
315
+ max-width: 72%;
316
+ }}
317
+
318
+ #timer {{
319
+ font-size: 42px;
320
+ font-weight: 1000;
321
+ background: #ff2d55;
322
+ border: 4px solid white;
323
+ color: white;
324
+ padding: 4px 16px;
325
+ border-radius: 16px;
326
+ box-shadow: 0 5px 0 #000;
327
+ }}
328
+
329
+ #center {{
330
+ position: absolute;
331
+ left: 50%;
332
+ top: 50%;
333
+ transform: translate(-50%, -50%);
334
+ }}
335
+
336
+ #start {{
337
+ font-size: 30px;
338
+ font-weight: 1000;
339
+ padding: 20px 42px;
340
+ border: 5px solid white;
341
+ border-radius: 22px;
342
+ background: #ffe600;
343
+ color: #111;
344
+ cursor: pointer;
345
+ box-shadow: 0 8px 0 #000;
346
+ }}
347
+
348
+ #result {{
349
+ position: absolute;
350
+ left: 0;
351
+ right: 0;
352
+ bottom: 26px;
353
+ text-align: center;
354
+ color: white;
355
+ font-size: 42px;
356
+ font-weight: 1000;
357
+ text-shadow: 0 4px 0 #000;
358
+ pointer-events: none;
359
+ }}
360
+ </style>
361
+ </head>
362
+
363
+ <body>
364
+ <div id="wrap">
365
+ <canvas id="game" width="768" height="512"></canvas>
366
+
367
+ <div id="hud">
368
+ <div id="instruction"></div>
369
+ <div id="timer"></div>
370
+ </div>
371
+
372
+ <div id="center">
373
+ <button id="start">START</button>
374
+ </div>
375
+
376
+ <div id="result"></div>
377
+ </div>
378
+
379
+ <script>
380
+ const spec = {spec_json};
381
+ const assets = {assets_json};
382
+
383
+ const canvas = document.getElementById("game");
384
+ const ctx = canvas.getContext("2d");
385
+ const instruction = document.getElementById("instruction");
386
+ const timer = document.getElementById("timer");
387
+ const startBtn = document.getElementById("start");
388
+ const result = document.getElementById("result");
389
+
390
+ instruction.textContent = spec.instruction;
391
+ timer.textContent = spec.duration_seconds;
392
+
393
+ const W = canvas.width;
394
+ const H = canvas.height;
395
+
396
+ let images = {{}};
397
+ let running = false;
398
+ let startTime = 0;
399
+ let raf = null;
400
+ let particles = [];
401
+ let gameState = null;
402
+
403
+ function loadImage(src) {{
404
+ return new Promise(resolve => {{
405
+ const img = new Image();
406
+ img.onload = () => resolve(img);
407
+ img.src = "data:image/png;base64," + src;
408
+ }});
409
+ }}
410
+
411
+ async function loadAssets() {{
412
+ images.background = await loadImage(assets.background);
413
+ images.target = await loadImage(assets.target);
414
+ images.decoy1 = await loadImage(assets.decoy_1);
415
+ images.decoy2 = await loadImage(assets.decoy_2);
416
+ }}
417
+
418
+ function rand(min, max) {{
419
+ return min + Math.random() * (max - min);
420
+ }}
421
+
422
+ function clamp(v, min, max) {{
423
+ return Math.max(min, Math.min(max, v));
424
+ }}
425
+
426
+ function drawCover(img, x, y, w, h) {{
427
+ const s = Math.max(w / img.width, h / img.height);
428
+ const nw = img.width * s;
429
+ const nh = img.height * s;
430
+ ctx.drawImage(img, x + (w - nw) / 2, y + (h - nh) / 2, nw, nh);
431
+ }}
432
+
433
+ function drawSprite(img, x, y, size, angle=0, pulse=1) {{
434
+ ctx.save();
435
+ ctx.translate(x, y);
436
+ ctx.rotate(angle);
437
+ ctx.scale(pulse, pulse);
438
+
439
+ ctx.shadowColor = "rgba(0,0,0,0.45)";
440
+ ctx.shadowBlur = 16;
441
+ ctx.shadowOffsetY = 8;
442
+
443
+ ctx.fillStyle = "white";
444
+ ctx.beginPath();
445
+ ctx.roundRect(-size/2, -size/2, size, size, 24);
446
+ ctx.fill();
447
+
448
+ ctx.drawImage(img, -size/2 + 8, -size/2 + 8, size - 16, size - 16);
449
+ ctx.restore();
450
+ }}
451
+
452
+ function addBurst(x, y, color="#ffe600") {{
453
+ for (let i = 0; i < 26; i++) {{
454
+ particles.push({{
455
+ x, y,
456
+ vx: rand(-7, 7),
457
+ vy: rand(-7, 7),
458
+ life: rand(18, 34),
459
+ color
460
+ }});
461
+ }}
462
+ }}
463
+
464
+ function drawParticles() {{
465
+ particles = particles.filter(p => p.life > 0);
466
+ for (const p of particles) {{
467
+ p.x += p.vx;
468
+ p.y += p.vy;
469
+ p.vy += 0.25;
470
+ p.life -= 1;
471
+
472
+ ctx.globalAlpha = clamp(p.life / 30, 0, 1);
473
+ ctx.fillStyle = p.color;
474
+ ctx.beginPath();
475
+ ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
476
+ ctx.fill();
477
+ ctx.globalAlpha = 1;
478
+ }}
479
+ }}
480
+
481
+ function endGame(success) {{
482
+ if (!running) return;
483
+ running = false;
484
+ cancelAnimationFrame(raf);
485
+
486
+ result.textContent = success ? spec.success_text : spec.failure_text;
487
+ result.style.color = success ? "#35ff6b" : "#ff3b30";
488
+
489
+ addBurst(W/2, H/2, success ? "#35ff6b" : "#ff3b30");
490
+
491
+ for (let i = 0; i < 20; i++) {{
492
+ drawFrame(performance.now());
493
+ }}
494
+
495
+ startBtn.textContent = "REJOUER";
496
+ startBtn.style.display = "block";
497
+ }}
498
+
499
+ function timeRemaining(now) {{
500
+ const elapsed = (now - startTime) / 1000;
501
+ return Math.max(0, spec.duration_seconds - elapsed);
502
+ }}
503
+
504
+ function resetBase() {{
505
+ result.textContent = "";
506
+ particles = [];
507
+ timer.textContent = spec.duration_seconds;
508
+ }}
509
+
510
+ function initGrabTarget() {{
511
+ const speed = 2.6 + spec.difficulty * 0.8;
512
+
513
+ gameState = {{
514
+ target: {{
515
+ x: rand(130, W - 130),
516
+ y: rand(150, H - 110),
517
+ vx: rand(-speed, speed),
518
+ vy: rand(-speed, speed),
519
+ size: 120
520
+ }},
521
+ decoys: [
522
+ {{
523
+ x: rand(100, W - 100),
524
+ y: rand(150, H - 100),
525
+ vx: rand(-speed, speed),
526
+ vy: rand(-speed, speed),
527
+ size: 105,
528
+ img: images.decoy1
529
+ }},
530
+ {{
531
+ x: rand(100, W - 100),
532
+ y: rand(150, H - 100),
533
+ vx: rand(-speed, speed),
534
+ vy: rand(-speed, speed),
535
+ size: 105,
536
+ img: images.decoy2
537
+ }}
538
+ ]
539
+ }};
540
+ }}
541
+
542
+ function updateMovingObject(o) {{
543
+ o.x += o.vx;
544
+ o.y += o.vy;
545
+
546
+ if (o.x < o.size/2 || o.x > W - o.size/2) o.vx *= -1;
547
+ if (o.y < 115 || o.y > H - o.size/2) o.vy *= -1;
548
+
549
+ o.x = clamp(o.x, o.size/2, W - o.size/2);
550
+ o.y = clamp(o.y, 115, H - o.size/2);
551
+ }}
552
+
553
+ function initAvoidFalling() {{
554
+ const count = 4 + spec.difficulty * 2;
555
+ const hazards = [];
556
+
557
+ for (let i = 0; i < count; i++) {{
558
+ hazards.push({{
559
+ x: rand(60, W - 60),
560
+ y: rand(-H, -40),
561
+ size: rand(70, 105),
562
+ speed: rand(2.4, 4.5) + spec.difficulty * 0.6,
563
+ img: Math.random() > 0.5 ? images.target : images.decoy1
564
+ }});
565
+ }}
566
+
567
+ gameState = {{
568
+ player: {{ x: W/2, y: H - 65, size: 92 }},
569
+ hazards
570
+ }};
571
+ }}
572
+
573
+ function initTimingHit() {{
574
+ const speed = 4 + spec.difficulty * 1.1;
575
+
576
+ gameState = {{
577
+ obj: {{
578
+ x: -80,
579
+ y: H / 2 + 40,
580
+ size: 115,
581
+ speed
582
+ }},
583
+ zoneX: W / 2,
584
+ zoneW: 120,
585
+ resolved: false
586
+ }};
587
+ }}
588
+
589
+ function startGame() {{
590
+ resetBase();
591
+ startBtn.style.display = "none";
592
+ running = true;
593
+ startTime = performance.now();
594
+
595
+ if (spec.template === "grab_target") initGrabTarget();
596
+ if (spec.template === "avoid_falling") initAvoidFalling();
597
+ if (spec.template === "timing_hit") initTimingHit();
598
+
599
+ raf = requestAnimationFrame(drawFrame);
600
+ }}
601
+
602
+ function drawBackground() {{
603
+ drawCover(images.background, 0, 0, W, H);
604
+
605
+ ctx.fillStyle = "rgba(0,0,0,0.22)";
606
+ ctx.fillRect(0, 0, W, H);
607
+
608
+ ctx.strokeStyle = "rgba(255,255,255,0.35)";
609
+ ctx.lineWidth = 8;
610
+ ctx.strokeRect(8, 8, W - 16, H - 16);
611
+ }}
612
+
613
+ function drawFrame(now) {{
614
+ drawBackground();
615
+
616
+ const left = timeRemaining(now);
617
+ timer.textContent = Math.ceil(left);
618
+
619
+ if (running && left <= 0) {{
620
+ if (spec.template === "avoid_falling") endGame(true);
621
+ else endGame(false);
622
+ return;
623
+ }}
624
+
625
+ const t = now / 1000;
626
+
627
+ if (running && spec.template === "grab_target") {{
628
+ const target = gameState.target;
629
+ updateMovingObject(target);
630
+
631
+ for (const d of gameState.decoys) {{
632
+ updateMovingObject(d);
633
+ drawSprite(d.img, d.x, d.y, d.size, Math.sin(t * 4) * 0.12, 1);
634
+ }}
635
+
636
+ drawSprite(
637
+ images.target,
638
+ target.x,
639
+ target.y,
640
+ target.size,
641
+ Math.sin(t * 5) * 0.16,
642
+ 1 + Math.sin(t * 8) * 0.04
643
+ );
644
+ }}
645
+
646
+ if (running && spec.template === "avoid_falling") {{
647
+ const p = gameState.player;
648
+
649
+ drawSprite(images.target, p.x, p.y, p.size, 0, 1);
650
+
651
+ for (const h of gameState.hazards) {{
652
+ h.y += h.speed;
653
+
654
+ if (h.y > H + 80) {{
655
+ h.y = rand(-220, -60);
656
+ h.x = rand(60, W - 60);
657
+ }}
658
+
659
+ drawSprite(h.img, h.x, h.y, h.size, Math.sin(t * 6 + h.x) * 0.2, 1);
660
+
661
+ const dx = h.x - p.x;
662
+ const dy = h.y - p.y;
663
+ const dist = Math.sqrt(dx*dx + dy*dy);
664
+
665
+ if (dist < (h.size + p.size) * 0.38) {{
666
+ endGame(false);
667
+ return;
668
+ }}
669
+ }}
670
+ }}
671
+
672
+ if (running && spec.template === "timing_hit") {{
673
+ const obj = gameState.obj;
674
+ const zoneX = gameState.zoneX;
675
+ const zoneW = gameState.zoneW;
676
+
677
+ obj.x += obj.speed;
678
+
679
+ ctx.fillStyle = "rgba(53,255,107,0.35)";
680
+ ctx.fillRect(zoneX - zoneW/2, 105, zoneW, H - 125);
681
+
682
+ ctx.strokeStyle = "#35ff6b";
683
+ ctx.lineWidth = 6;
684
+ ctx.strokeRect(zoneX - zoneW/2, 105, zoneW, H - 125);
685
+
686
+ drawSprite(images.target, obj.x, obj.y, obj.size, Math.sin(t*7)*0.15, 1);
687
+
688
+ if (obj.x > W + 100) {{
689
+ endGame(false);
690
+ return;
691
+ }}
692
+ }}
693
+
694
+ drawParticles();
695
+
696
+ if (running) {{
697
+ raf = requestAnimationFrame(drawFrame);
698
+ }}
699
+ }}
700
+
701
+ function canvasPoint(event) {{
702
+ const rect = canvas.getBoundingClientRect();
703
+ return {{
704
+ x: (event.clientX - rect.left) * (W / rect.width),
705
+ y: (event.clientY - rect.top) * (H / rect.height)
706
+ }};
707
+ }}
708
+
709
+ canvas.addEventListener("mousemove", e => {{
710
+ if (!running || spec.template !== "avoid_falling") return;
711
+ const p = canvasPoint(e);
712
+ gameState.player.x = clamp(p.x, 55, W - 55);
713
+ }});
714
+
715
+ canvas.addEventListener("click", e => {{
716
+ if (!running) return;
717
+ const p = canvasPoint(e);
718
+
719
+ if (spec.template === "grab_target") {{
720
+ const target = gameState.target;
721
+ const dx = p.x - target.x;
722
+ const dy = p.y - target.y;
723
+ const dist = Math.sqrt(dx*dx + dy*dy);
724
+
725
+ if (dist < target.size * 0.48) {{
726
+ addBurst(target.x, target.y, "#35ff6b");
727
+ endGame(true);
728
+ return;
729
+ }}
730
+
731
+ for (const d of gameState.decoys) {{
732
+ const ddx = p.x - d.x;
733
+ const ddy = p.y - d.y;
734
+ const ddist = Math.sqrt(ddx*ddx + ddy*ddy);
735
+ if (ddist < d.size * 0.48) {{
736
+ addBurst(d.x, d.y, "#ff3b30");
737
+ endGame(false);
738
+ return;
739
+ }}
740
+ }}
741
+ }}
742
+
743
+ if (spec.template === "timing_hit") {{
744
+ const obj = gameState.obj;
745
+ const zoneX = gameState.zoneX;
746
+ const zoneW = gameState.zoneW;
747
+ const success = obj.x > zoneX - zoneW/2 && obj.x < zoneX + zoneW/2;
748
+ addBurst(obj.x, obj.y, success ? "#35ff6b" : "#ff3b30");
749
+ endGame(success);
750
+ }}
751
+ }});
752
+
753
+ window.addEventListener("keydown", e => {{
754
+ if (!running) return;
755
+
756
+ if (spec.template === "timing_hit" && (e.code === "Space" || e.code === "Enter")) {{
757
+ const obj = gameState.obj;
758
+ const zoneX = gameState.zoneX;
759
+ const zoneW = gameState.zoneW;
760
+ const success = obj.x > zoneX - zoneW/2 && obj.x < zoneX + zoneW/2;
761
+ endGame(success);
762
+ }}
763
+
764
+ if (spec.template === "avoid_falling") {{
765
+ const p = gameState.player;
766
+ if (e.key === "ArrowLeft" || e.key === "q" || e.key === "a") p.x = clamp(p.x - 42, 55, W - 55);
767
+ if (e.key === "ArrowRight" || e.key === "d") p.x = clamp(p.x + 42, 55, W - 55);
768
+ }}
769
+ }});
770
+
771
+ startBtn.onclick = startGame;
772
+
773
+ loadAssets().then(() => {{
774
+ drawBackground();
775
+ timer.textContent = spec.duration_seconds;
776
+ }});
777
+ </script>
778
+ </body>
779
+ </html>
780
+ """
781
+
782
+ srcdoc = html.escape(iframe_html, quote=True)
783
+
784
+ return f"""
785
+ <iframe
786
+ srcdoc="{srcdoc}"
787
+ style="width:100%; height:620px; border:0; border-radius:18px; overflow:hidden;"
788
+ ></iframe>
789
+ """
790
+
791
+
792
+ # ---------------------------------------------------------------------------
793
+ # Pipeline complet (décoré ZeroGPU : GPU attaché le temps de l'appel)
794
+ # ---------------------------------------------------------------------------
795
+ @spaces.GPU(duration=120)
796
+ def make_visual_game(prompt):
797
+ spec = safe_generate_spec(prompt)
798
+ assets = generate_assets(spec)
799
+ html_game = render_canvas_game(spec, assets)
800
+ return spec.model_dump(), html_game
801
+
802
+
803
+ # ---------------------------------------------------------------------------
804
+ # Interface Gradio
805
+ # ---------------------------------------------------------------------------
806
+ import gradio as gr
807
+
808
+ with gr.Blocks() as demo:
809
+ gr.Markdown("# POC mini-jeux génératifs visuels")
810
+ gr.Markdown(
811
+ "Qwen3-4B génère la spec. SDXL génère les assets. Canvas exécute le mini-jeu."
812
+ )
813
+
814
+ prompt = gr.Textbox(
815
+ label="Inspiration",
816
+ value="mini-jeu absurde avec une vraie banane arcade, rapide et visuel",
817
+ )
818
+
819
+ btn = gr.Button("Générer un mini-jeu visuel")
820
+ json_out = gr.JSON(label="Spec générée")
821
+ game_out = gr.HTML(label="Mini-jeu jouable")
822
+
823
+ btn.click(
824
+ make_visual_game,
825
+ inputs=prompt,
826
+ outputs=[json_out, game_out],
827
+ )
828
+
829
+ if __name__ == "__main__":
830
+ demo.queue().launch()
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ spaces
2
+ torch
3
+ transformers>=4.51.0
4
+ accelerate>=0.30.0
5
+ bitsandbytes>=0.43.0
6
+ diffusers>=0.27.0
7
+ safetensors
8
+ pydantic>=2.0
9
+ pillow