Spaces:
Running on Zero
Running on Zero
Commit Β·
07fb364
1
Parent(s): 85ae7dd
feat: Add photo path handling in poster generation and enhance collage creation
Browse files- app.py +6 -1
- app/services/image_gen.py +166 -4
- requirements.txt +1 -0
app.py
CHANGED
|
@@ -389,6 +389,7 @@ def upload_photo(
|
|
| 389 |
SESSION_STORE[session_id]["photos"].append({
|
| 390 |
"photo_id": photo_id,
|
| 391 |
"photo_name": photo_name,
|
|
|
|
| 392 |
"caption": caption,
|
| 393 |
"task_id": task_id,
|
| 394 |
})
|
|
@@ -513,7 +514,11 @@ def generate_poster_from_session(session_id: str):
|
|
| 513 |
)
|
| 514 |
result = generate_story(packet, session_id=session_id)
|
| 515 |
|
| 516 |
-
poster_url, status = generate_poster_sync(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
session["poster_url"] = poster_url
|
| 518 |
return poster_url, status
|
| 519 |
|
|
|
|
| 389 |
SESSION_STORE[session_id]["photos"].append({
|
| 390 |
"photo_id": photo_id,
|
| 391 |
"photo_name": photo_name,
|
| 392 |
+
"photo_path": photo_file if isinstance(photo_file, str) else "",
|
| 393 |
"caption": caption,
|
| 394 |
"task_id": task_id,
|
| 395 |
})
|
|
|
|
| 514 |
)
|
| 515 |
result = generate_story(packet, session_id=session_id)
|
| 516 |
|
| 517 |
+
poster_url, status = generate_poster_sync(
|
| 518 |
+
session_id,
|
| 519 |
+
result["poster_prompt"],
|
| 520 |
+
photo_paths=[p.get("photo_path", "") for p in photos if p.get("photo_path")],
|
| 521 |
+
)
|
| 522 |
session["poster_url"] = poster_url
|
| 523 |
return poster_url, status
|
| 524 |
|
app/services/image_gen.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
Sponsor alignment: Black Forest Labs FLUX produces a visible demo artifact
|
| 7 |
(the recap poster) rather than being hidden in backend code.
|
|
@@ -155,21 +158,180 @@ def generate_poster(poster_prompt: str) -> Optional[str]:
|
|
| 155 |
return None
|
| 156 |
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
def generate_poster_sync(
|
| 159 |
session_id: str,
|
| 160 |
poster_prompt: str,
|
|
|
|
| 161 |
) -> tuple[Optional[str], str]:
|
| 162 |
"""Generate a poster and return (image_path, status_message).
|
| 163 |
|
| 164 |
-
|
|
|
|
|
|
|
| 165 |
|
| 166 |
Args:
|
| 167 |
session_id: Session identifier (for logging).
|
| 168 |
poster_prompt: Text prompt for the image.
|
|
|
|
| 169 |
|
| 170 |
Returns:
|
| 171 |
Tuple of (image_path_or_None, status_message).
|
| 172 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
image_path = generate_poster(poster_prompt)
|
| 174 |
|
| 175 |
if image_path:
|
|
|
|
| 1 |
+
"""Poster generation module β two strategies:
|
| 2 |
|
| 3 |
+
1. **Photo collage** (default) β builds an attractive collage from the
|
| 4 |
+
player's uploaded gameplay photos using Pillow. Instant, free, and
|
| 5 |
+
shows *real* game moments.
|
| 6 |
+
2. **FLUX text-to-image** (fallback) β generates an AI poster from a
|
| 7 |
+
text prompt via ``diffusers``. Requires GPU and the FLUX model.
|
| 8 |
|
| 9 |
Sponsor alignment: Black Forest Labs FLUX produces a visible demo artifact
|
| 10 |
(the recap poster) rather than being hidden in backend code.
|
|
|
|
| 158 |
return None
|
| 159 |
|
| 160 |
|
| 161 |
+
# ββ Photo collage poster (uses uploaded gameplay photos) βββββββββββββββββ
|
| 162 |
+
|
| 163 |
+
def generate_collage_poster(
|
| 164 |
+
photo_paths: list[str],
|
| 165 |
+
title: str = "CityQuest",
|
| 166 |
+
game_title: str = "",
|
| 167 |
+
) -> Optional[str]:
|
| 168 |
+
"""Build an attractive photo collage poster from uploaded gameplay photos.
|
| 169 |
+
|
| 170 |
+
Uses Pillow only (no GPU required). Falls back to FLUX text-to-image
|
| 171 |
+
if Pillow is unavailable or no photos are provided.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
photo_paths: List of filesystem paths to uploaded player photos.
|
| 175 |
+
title: Overlay title text (e.g. "CityQuest AI").
|
| 176 |
+
game_title: The game's title from generation.
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
Filesystem path (as string) to the saved poster, or ``None``.
|
| 180 |
+
"""
|
| 181 |
+
if not photo_paths:
|
| 182 |
+
return None
|
| 183 |
+
|
| 184 |
+
# Filter to only files that actually exist
|
| 185 |
+
valid = [p for p in photo_paths if Path(p).exists()]
|
| 186 |
+
if not valid:
|
| 187 |
+
return None
|
| 188 |
+
|
| 189 |
+
try:
|
| 190 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 191 |
+
|
| 192 |
+
POSTER_DIR.mkdir(parents=True, exist_ok=True)
|
| 193 |
+
|
| 194 |
+
# Poster dimensions
|
| 195 |
+
W, H = 1024, 768
|
| 196 |
+
poster = Image.new("RGB", (W, H), (20, 20, 30))
|
| 197 |
+
draw = ImageDraw.Draw(poster)
|
| 198 |
+
|
| 199 |
+
# Layout: pick grid based on photo count
|
| 200 |
+
n = len(valid)
|
| 201 |
+
if n == 1:
|
| 202 |
+
cols, rows = 1, 1
|
| 203 |
+
elif n == 2:
|
| 204 |
+
cols, rows = 2, 1
|
| 205 |
+
elif n <= 4:
|
| 206 |
+
cols, rows = 2, 2
|
| 207 |
+
elif n <= 6:
|
| 208 |
+
cols, rows = 3, 2
|
| 209 |
+
else:
|
| 210 |
+
cols, rows = 4, 2
|
| 211 |
+
|
| 212 |
+
padding = 8
|
| 213 |
+
header_h = 80 # space for title overlay at top
|
| 214 |
+
footer_h = 60 # space for bottom overlay
|
| 215 |
+
grid_w = W - 2 * padding
|
| 216 |
+
grid_h = H - header_h - footer_h - 2 * padding
|
| 217 |
+
cell_w = grid_w // cols
|
| 218 |
+
cell_h = grid_h // rows
|
| 219 |
+
|
| 220 |
+
# Paste photos into grid
|
| 221 |
+
placed = 0
|
| 222 |
+
for idx, path in enumerate(valid[: cols * rows]):
|
| 223 |
+
try:
|
| 224 |
+
img = Image.open(path).convert("RGB")
|
| 225 |
+
# Resize to fit cell, preserve aspect ratio (cover)
|
| 226 |
+
ratio = max(cell_w / img.width, cell_h / img.height)
|
| 227 |
+
new_w = int(img.width * ratio)
|
| 228 |
+
new_h = int(img.height * ratio)
|
| 229 |
+
img = img.resize((new_w, new_h), Image.LANCZOS)
|
| 230 |
+
# Center-crop to cell size
|
| 231 |
+
left = (new_w - cell_w) // 2
|
| 232 |
+
top = (new_h - cell_h) // 2
|
| 233 |
+
img = img.crop((left, top, left + cell_w, top + cell_h))
|
| 234 |
+
# Place in grid
|
| 235 |
+
col = placed % cols
|
| 236 |
+
row = placed // cols
|
| 237 |
+
x = padding + col * cell_w
|
| 238 |
+
y = header_h + padding + row * cell_h
|
| 239 |
+
poster.paste(img, (x, y))
|
| 240 |
+
placed += 1
|
| 241 |
+
except Exception as exc:
|
| 242 |
+
print(f"[image_gen] Skipping photo {path}: {exc}")
|
| 243 |
+
|
| 244 |
+
if placed == 0:
|
| 245 |
+
return None
|
| 246 |
+
|
| 247 |
+
# Semi-transparent header overlay
|
| 248 |
+
overlay = Image.new("RGBA", (W, header_h), (0, 0, 0, 180))
|
| 249 |
+
poster.paste(
|
| 250 |
+
Image.alpha_composite(
|
| 251 |
+
Image.new("RGBA", (W, header_h), (0, 0, 0, 0)),
|
| 252 |
+
overlay,
|
| 253 |
+
).convert("RGB"),
|
| 254 |
+
(0, 0),
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
# Semi-transparent footer overlay
|
| 258 |
+
footer_overlay = Image.new("RGBA", (W, footer_h), (0, 0, 0, 180))
|
| 259 |
+
poster.paste(
|
| 260 |
+
Image.alpha_composite(
|
| 261 |
+
Image.new("RGBA", (W, footer_h), (0, 0, 0, 0)),
|
| 262 |
+
footer_overlay,
|
| 263 |
+
).convert("RGB"),
|
| 264 |
+
(0, H - footer_h),
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
# Draw title text
|
| 268 |
+
draw = ImageDraw.Draw(poster)
|
| 269 |
+
display_title = game_title or title
|
| 270 |
+
try:
|
| 271 |
+
font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36)
|
| 272 |
+
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
|
| 273 |
+
except (OSError, IOError):
|
| 274 |
+
# Fallback if system fonts unavailable
|
| 275 |
+
font_large = ImageFont.load_default()
|
| 276 |
+
font_small = ImageFont.load_default()
|
| 277 |
+
|
| 278 |
+
# Title (centered top)
|
| 279 |
+
bbox = draw.textbbox((0, 0), display_title, font=font_large)
|
| 280 |
+
tw = bbox[2] - bbox[0]
|
| 281 |
+
draw.text(((W - tw) // 2, 20), display_title, fill=(255, 255, 255), font=font_large)
|
| 282 |
+
|
| 283 |
+
# Footer text
|
| 284 |
+
footer_text = f"{placed} photo{'s' if placed != 1 else ''} from gameplay"
|
| 285 |
+
bbox2 = draw.textbbox((0, 0), footer_text, font=font_small)
|
| 286 |
+
fw = bbox2[2] - bbox2[0]
|
| 287 |
+
draw.text(((W - fw) // 2, H - 45), footer_text, fill=(200, 200, 200), font=font_small)
|
| 288 |
+
|
| 289 |
+
# Save
|
| 290 |
+
filename = f"poster_{uuid.uuid4().hex[:8]}.png"
|
| 291 |
+
filepath = POSTER_DIR / filename
|
| 292 |
+
poster.save(str(filepath), quality=95)
|
| 293 |
+
print(f"[image_gen] Collage poster saved -> {filepath} ({placed} photos)")
|
| 294 |
+
return str(filepath)
|
| 295 |
+
|
| 296 |
+
except ImportError:
|
| 297 |
+
print("[image_gen] Pillow not installed β cannot create collage")
|
| 298 |
+
return None
|
| 299 |
+
except Exception as e:
|
| 300 |
+
print(f"[image_gen] Collage generation failed: {type(e).__name__}: {e}")
|
| 301 |
+
return None
|
| 302 |
+
|
| 303 |
+
|
| 304 |
def generate_poster_sync(
|
| 305 |
session_id: str,
|
| 306 |
poster_prompt: str,
|
| 307 |
+
photo_paths: Optional[list[str]] = None,
|
| 308 |
) -> tuple[Optional[str], str]:
|
| 309 |
"""Generate a poster and return (image_path, status_message).
|
| 310 |
|
| 311 |
+
Strategy:
|
| 312 |
+
1. If uploaded photos exist β build a photo collage (instant, no GPU).
|
| 313 |
+
2. Otherwise β fall back to FLUX text-to-image (needs GPU).
|
| 314 |
|
| 315 |
Args:
|
| 316 |
session_id: Session identifier (for logging).
|
| 317 |
poster_prompt: Text prompt for the image.
|
| 318 |
+
photo_paths: Optional list of uploaded gameplay photo file paths.
|
| 319 |
|
| 320 |
Returns:
|
| 321 |
Tuple of (image_path_or_None, status_message).
|
| 322 |
"""
|
| 323 |
+
# ββ Strategy 1: Photo collage from uploaded gameplay photos βββββββββ
|
| 324 |
+
if photo_paths:
|
| 325 |
+
print(f"[image_gen] Attempting collage from {len(photo_paths)} photo(s)")
|
| 326 |
+
collage = generate_collage_poster(photo_paths)
|
| 327 |
+
if collage:
|
| 328 |
+
return collage, (
|
| 329 |
+
f"**Poster created from your gameplay photos!** πΈ\n"
|
| 330 |
+
f"Saved as `{collage}` β refresh the Recap tab to view."
|
| 331 |
+
)
|
| 332 |
+
print("[image_gen] Collage failed, falling back to FLUX text-to-image")
|
| 333 |
+
|
| 334 |
+
# ββ Strategy 2: FLUX text-to-image (GPU required) βββββββββββββββββ
|
| 335 |
image_path = generate_poster(poster_prompt)
|
| 336 |
|
| 337 |
if image_path:
|
requirements.txt
CHANGED
|
@@ -13,6 +13,7 @@ diffusers[torch]>=0.30.0
|
|
| 13 |
transformers>=5.4.0
|
| 14 |
accelerate
|
| 15 |
safetensors
|
|
|
|
| 16 |
# Cohere Transcribe ASR (voice journal) β gated model, requires HF_TOKEN
|
| 17 |
soundfile
|
| 18 |
librosa
|
|
|
|
| 13 |
transformers>=5.4.0
|
| 14 |
accelerate
|
| 15 |
safetensors
|
| 16 |
+
Pillow>=9.0
|
| 17 |
# Cohere Transcribe ASR (voice journal) β gated model, requires HF_TOKEN
|
| 18 |
soundfile
|
| 19 |
librosa
|