NANI-Nithin commited on
Commit
c3692eb
·
1 Parent(s): 6172d30

feat: enhance poster generation with offline FLUX support and improve error handling

Browse files
app.py CHANGED
@@ -382,13 +382,9 @@ def generate_recap(session_id: str):
382
  # Generate story
383
  result = generate_story(packet, session_id=session_id)
384
 
385
- # Try to generate a poster image via BFL FLUX
386
- poster_url, poster_status = generate_poster_sync(session_id, result["poster_prompt"])
387
- session["poster_url"] = poster_url
388
-
389
- # Format for display
390
  lines = [
391
- "# 📖 Episode Recap\n",
392
  result["short_recap"],
393
  "",
394
  "---\n",
@@ -396,11 +392,8 @@ def generate_recap(session_id: str):
396
  "",
397
  "---\n",
398
  ]
399
-
400
- if poster_url:
401
- lines.append(f"### 🖼️ Poster\n\n![Recap Poster]({poster_url})\n")
402
- else:
403
- lines.append(f"### 🎨 Poster Prompt\n```{result['poster_prompt']}```\n\n_{poster_status}_\n")
404
 
405
  return "\n".join(lines), result
406
 
 
382
  # Generate story
383
  result = generate_story(packet, session_id=session_id)
384
 
385
+ # Format for display (poster goes in dedicated section below)
 
 
 
 
386
  lines = [
387
+ "# Episode Recap\n",
388
  result["short_recap"],
389
  "",
390
  "---\n",
 
392
  "",
393
  "---\n",
394
  ]
395
+ lines.append(f"**Poster prompt:** _{result['poster_prompt']}_\n")
396
+ lines.append(f"*Click **Generate Poster** below to create an image from this prompt.*\n")
 
 
 
397
 
398
  return "\n".join(lines), result
399
 
app/services/image_gen.py CHANGED
@@ -1,73 +1,116 @@
1
  """Black Forest Labs FLUX poster generation module.
2
 
3
- Generates a recap poster image from a text prompt using the BFL API.
4
- When the API key is not set or the call fails, gracefully returns None
5
- so the pipeline always produces a visible result.
6
 
7
  Sponsor alignment: Black Forest Labs FLUX produces a visible demo artifact
8
  (the recap poster) rather than being hidden in backend code.
9
  """
10
 
11
  import os
 
 
12
  from typing import Optional
13
 
14
 
15
  # ── Configuration ───────────────────────────────────────────────────────────
16
- BFL_API_KEY_ENV = "BFL_API_KEY"
 
17
 
18
 
19
- def generate_poster(
20
- poster_prompt: str,
21
- api_key: Optional[str] = None,
22
- ) -> Optional[str]:
23
- """Generate a poster image using Black Forest Labs FLUX API.
24
 
25
- Args:
26
- poster_prompt: Text prompt describing the desired image.
27
- api_key: BFL API key. Falls back to ``BFL_API_KEY`` env var.
 
 
 
28
 
29
  Returns:
30
- URL string of the generated image, or ``None`` on failure.
31
  """
32
- key = api_key or os.environ.get(BFL_API_KEY_ENV)
33
- if not key:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  print(
35
- "[image_gen] BFL_API_KEY not set "
36
- "set the environment variable to enable poster generation"
37
  )
38
  return None
 
 
 
39
 
40
- try:
41
- import bfl
42
 
43
- client = bfl.Bfl(api_key=key)
 
44
 
45
- # FLUX.1 Pro / FLUX.1.1 Pro — use the latest available
46
- result = client.generate_image(
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  prompt=poster_prompt,
 
 
48
  width=1024,
49
  height=768,
50
- steps=25,
51
- guidance=3.5,
52
- )
53
-
54
- # The result object typically has a ``url`` or ``image_url`` attribute
55
- image_url = getattr(result, "url", None) or getattr(result, "image_url", None)
56
- if image_url:
57
- print(f"[image_gen] Poster generated -> {image_url[:80]}...")
58
- return image_url
59
 
60
- print(f"[image_gen] BFL response had no URL: {result}")
61
- return None
 
 
 
 
 
62
 
63
- except ImportError:
64
- print(
65
- "[image_gen] 'bfl' Python package not installed. "
66
- "Install with: pip install bfl"
67
- )
68
- return None
69
  except Exception as e:
70
- print(f"[image_gen] BFL API call failed: {type(e).__name__}: {e}")
71
  return None
72
 
73
 
@@ -75,33 +118,27 @@ def generate_poster_sync(
75
  session_id: str,
76
  poster_prompt: str,
77
  ) -> tuple[Optional[str], str]:
78
- """Generate a poster and return (image_url, status_message).
79
 
80
  This is a convenience wrapper for use in the pipeline entry point.
81
- The status message explains why no image was generated if the call
82
- fails, so the UI can display a helpful fallback.
83
 
84
  Args:
85
  session_id: Session identifier (for logging).
86
  poster_prompt: Text prompt for the image.
87
 
88
  Returns:
89
- Tuple of (image_url_or_None, status_message).
90
  """
91
- image_url = generate_poster(poster_prompt)
92
-
93
- if image_url:
94
- return image_url, f"\ud83d\uddbc\ufe0f **Poster generated!** [View image]({image_url})"
95
 
96
- key = os.environ.get(BFL_API_KEY_ENV)
97
- if not key:
98
- return None, (
99
- "\u26a0\ufe0f Poster generation unavailable "
100
- "set `BFL_API_KEY` environment variable to enable "
101
- "Black Forest Labs FLUX poster generation."
102
  )
103
 
104
  return None, (
105
- "\u26a0\ufe0f Poster generation failed "
106
- "check the BFL API key and try again."
 
107
  )
 
1
  """Black Forest Labs FLUX poster generation module.
2
 
3
+ Generates a recap poster image from a text prompt using FLUX offline
4
+ via ``diffusers`` (same lazy-download pattern as generator.py / minicpm.py).
 
5
 
6
  Sponsor alignment: Black Forest Labs FLUX produces a visible demo artifact
7
  (the recap poster) rather than being hidden in backend code.
8
  """
9
 
10
  import os
11
+ import uuid
12
+ from pathlib import Path
13
  from typing import Optional
14
 
15
 
16
  # ── Configuration ───────────────────────────────────────────────────────────
17
+ FLUX_MODEL_ID = "black-forest-labs/FLUX.1-schnell"
18
+ POSTER_DIR = Path("app/assets/posters")
19
 
20
 
21
+ # ── Lazy model state ────────────────────────────────────────────────────────
22
+ _model = None
23
+ _model_loaded = False
24
+ _skip_env_var = "CITYQUEST_SKIP_MODEL"
 
25
 
26
+
27
+ def _load_model() -> Optional[object]:
28
+ """Lazy-load the FLUX.1-schnell pipeline via diffusers on first call.
29
+
30
+ Uses the Hugging Face Hub cache so subsequent calls are instant.
31
+ Skips loading if ``CITYQUEST_SKIP_MODEL`` is set (fast-test mode).
32
 
33
  Returns:
34
+ The FLUX pipeline object, or ``None`` on failure / skip.
35
  """
36
+ global _model, _model_loaded
37
+ if _model_loaded:
38
+ return _model
39
+ _model_loaded = True
40
+
41
+ if os.environ.get(_skip_env_var):
42
+ print("[image_gen] CITYQUEST_SKIP_MODEL set — skipping model load")
43
+ return None
44
+
45
+ try:
46
+ import torch
47
+ from diffusers import FluxPipeline
48
+
49
+ print(f"[image_gen] Loading {FLUX_MODEL_ID} via diffusers ...")
50
+ pipe = FluxPipeline.from_pretrained(
51
+ FLUX_MODEL_ID,
52
+ torch_dtype=torch.bfloat16,
53
+ )
54
+ # Move to GPU if available
55
+ if torch.cuda.is_available():
56
+ pipe.to("cuda")
57
+ print("[image_gen] Pipeline moved to GPU")
58
+ else:
59
+ print("[image_gen] Pipeline running on CPU (will be slow)")
60
+
61
+ # Enable memory optimisations
62
+ pipe.enable_model_cpu_offload()
63
+ _model = pipe
64
+ print("[image_gen] FLUX pipeline loaded successfully")
65
+ return pipe
66
+
67
+ except ImportError:
68
  print(
69
+ "[image_gen] Required packages not installed. "
70
+ "Install with: pip install diffusers transformers accelerate"
71
  )
72
  return None
73
+ except Exception as e:
74
+ print(f"[image_gen] FLUX model load failed: {type(e).__name__}: {e}")
75
+ return None
76
 
 
 
77
 
78
+ def generate_poster(poster_prompt: str) -> Optional[str]:
79
+ """Generate a poster image using offline FLUX.1-schnell.
80
 
81
+ Args:
82
+ poster_prompt: Text prompt describing the desired image.
83
+
84
+ Returns:
85
+ Filesystem path (as string) to the saved image, or ``None`` on failure.
86
+ """
87
+ pipe = _load_model()
88
+ if pipe is None:
89
+ return None
90
+
91
+ try:
92
+ import torch
93
+
94
+ print(f"[image_gen] Generating poster for prompt ({len(poster_prompt)} chars) ...")
95
+ image = pipe(
96
  prompt=poster_prompt,
97
+ guidance_scale=0.0, # schnell uses 0.0
98
+ num_inference_steps=4, # schnell: 4 steps is enough
99
  width=1024,
100
  height=768,
101
+ generator=torch.Generator(device="cuda" if torch.cuda.is_available() else "cpu").manual_seed(42),
102
+ ).images[0]
 
 
 
 
 
 
 
103
 
104
+ # Save to disk
105
+ POSTER_DIR.mkdir(parents=True, exist_ok=True)
106
+ filename = f"poster_{uuid.uuid4().hex[:8]}.png"
107
+ filepath = POSTER_DIR / filename
108
+ image.save(str(filepath))
109
+ print(f"[image_gen] Poster saved -> {filepath}")
110
+ return str(filepath)
111
 
 
 
 
 
 
 
112
  except Exception as e:
113
+ print(f"[image_gen] Poster generation failed: {type(e).__name__}: {e}")
114
  return None
115
 
116
 
 
118
  session_id: str,
119
  poster_prompt: str,
120
  ) -> tuple[Optional[str], str]:
121
+ """Generate a poster and return (image_path, status_message).
122
 
123
  This is a convenience wrapper for use in the pipeline entry point.
 
 
124
 
125
  Args:
126
  session_id: Session identifier (for logging).
127
  poster_prompt: Text prompt for the image.
128
 
129
  Returns:
130
+ Tuple of (image_path_or_None, status_message).
131
  """
132
+ image_path = generate_poster(poster_prompt)
 
 
 
133
 
134
+ if image_path:
135
+ return image_path, (
136
+ f"**Poster generated!** Saved as `{image_path}` "
137
+ f"\\u2014 refresh the Recap tab to view."
 
 
138
  )
139
 
140
  return None, (
141
+ "Poster generation unavailable - "
142
+ "FLUX.1-schnell model could not be loaded. "
143
+ "Make sure `diffusers` and `transformers` are installed."
144
  )
app/services/story.py CHANGED
@@ -312,9 +312,11 @@ def _llm_recap(prompt_template: str, story_packet: dict) -> Optional[dict]:
312
  def generate_story(story_packet: dict, session_id: Optional[str] = None) -> dict:
313
  """Generate final recap story from game data and events.
314
 
315
- Attempts Nemotron-based generation first (reuses model already loaded
316
- on GPU from game generation), then MiniCPM, then falls back to
317
- high-quality templates so the pipeline always returns a result.
 
 
318
 
319
  Args:
320
  story_packet: Structured packet with game info, scores, journals, photos.
@@ -324,36 +326,9 @@ def generate_story(story_packet: dict, session_id: Optional[str] = None) -> dict
324
  Story output dict with keys ``short_recap``, ``long_summary``,
325
  ``poster_prompt``, ``story_packet``.
326
  """
327
- # Try Nemotron path first (model already loaded on GPU from game generation)
328
- llm_result = None
329
- template_path = Path("app/prompts/story_recap.txt")
330
- prompt_template = ""
331
- if template_path.exists():
332
- prompt_template = template_path.read_text(encoding="utf-8")
333
-
334
- if prompt_template:
335
- llm_result = _llm_recap(prompt_template, story_packet)
336
- if llm_result:
337
- print("[story] Nemotron recap generated successfully")
338
-
339
- # Fall back to MiniCPM (OpenBMB sponsor model)
340
- if not llm_result:
341
- try:
342
- from app.services.minicpm import generate_recap as minicpm_recap
343
- llm_result = minicpm_recap(story_packet)
344
- if llm_result:
345
- print("[story] MiniCPM recap generated successfully")
346
- except Exception as exc:
347
- print(f"[story] MiniCPM recap unavailable: {exc}")
348
-
349
- if llm_result and all(k in llm_result for k in ("short_recap", "long_summary", "poster_prompt")):
350
- short_recap = llm_result["short_recap"]
351
- long_summary = llm_result["long_summary"]
352
- poster_prompt = llm_result["poster_prompt"]
353
- else:
354
- short_recap = _template_short_recap(story_packet)
355
- long_summary = _template_long_summary(story_packet)
356
- poster_prompt = _template_poster_prompt(story_packet)
357
 
358
  result = {
359
  "short_recap": short_recap,
 
312
  def generate_story(story_packet: dict, session_id: Optional[str] = None) -> dict:
313
  """Generate final recap story from game data and events.
314
 
315
+ Uses high-quality template-based generation (deterministic, no GPU
316
+ required). LLM-based recap paths are disabled by default because:
317
+ - ``@spaces.GPU`` isolates model cache across subprocess boundaries
318
+ - Loading a second model instance on the same GPU risks OOM
319
+ - Templates produce reliable, high-quality output without GPU
320
 
321
  Args:
322
  story_packet: Structured packet with game info, scores, journals, photos.
 
326
  Story output dict with keys ``short_recap``, ``long_summary``,
327
  ``poster_prompt``, ``story_packet``.
328
  """
329
+ short_recap = _template_short_recap(story_packet)
330
+ long_summary = _template_long_summary(story_packet)
331
+ poster_prompt = _template_poster_prompt(story_packet)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
 
333
  result = {
334
  "short_recap": short_recap,
requirements.txt CHANGED
@@ -8,4 +8,5 @@ huggingface_hub
8
  llama-cpp-python
9
  gradio>=6.16.0
10
  jsonschema
 
11
  # modal # uncomment to deploy to Modal cloud GPU
 
8
  llama-cpp-python
9
  gradio>=6.16.0
10
  jsonschema
11
+ diffusers[torch] transformers accelerate # uncomment for offline FLUX poster generation
12
  # modal # uncomment to deploy to Modal cloud GPU
test_end_to_end.py CHANGED
@@ -379,17 +379,17 @@ def main():
379
  print("-" * 80)
380
 
381
  try:
382
- from app.services.image_gen import generate_poster, generate_poster_sync, BFL_API_KEY_ENV
383
 
384
- check("Image gen constants loaded", bool(BFL_API_KEY_ENV))
385
 
386
- # Test with no API key — must return None gracefully
387
- result = generate_poster("A test poster prompt", api_key=None)
388
- check("generate_poster() with no key returns None", result is None)
389
 
390
- url, status = generate_poster_sync("test-session", "A test poster prompt")
391
- check("generate_poster_sync() returns tuple", isinstance(url, type(None)) and isinstance(status, str))
392
- check("Status message is informative", "BFL_API_KEY" in status or "Failed" in status or "unavailable" in status)
393
  except Exception as e:
394
  check("Image gen module", False, str(e))
395
 
 
379
  print("-" * 80)
380
 
381
  try:
382
+ from app.services.image_gen import generate_poster, generate_poster_sync, FLUX_MODEL_ID
383
 
384
+ check("Image gen constants loaded", bool(FLUX_MODEL_ID))
385
 
386
+ # Test with skip-model env var — must return None gracefully
387
+ result = generate_poster("A test poster prompt")
388
+ check("generate_poster() with skip returns None (no model loaded)", result is None)
389
 
390
+ path_or_none, status = generate_poster_sync("test-session", "A test poster prompt")
391
+ check("generate_poster_sync() returns tuple", isinstance(path_or_none, type(None)) and isinstance(status, str))
392
+ check("Status message is informative", "unavailable" in status or "Failed" in status)
393
  except Exception as e:
394
  check("Image gen module", False, str(e))
395