Skip to content

Commit 20b0073

Browse files
committed
feat: add story image generation modules and tests
1 parent 2ab27f7 commit 20b0073

10 files changed

Lines changed: 1156 additions & 0 deletions

scripts/config.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
Configuration settings for story image generation
3+
"""
4+
import os
5+
from dataclasses import dataclass, field
6+
from typing import Dict, Tuple
7+
8+
9+
@dataclass
10+
class Config:
11+
# Paths
12+
LOGO_PATH: str = "../public/images/logo.png"
13+
FONT_PRIMARY: str = "../src/fonts/DejaVu Sans Bold.ttf"
14+
FONT_SECONDARY: str = "../src/fonts/Square 721 Regular.otf"
15+
OUTPUT_DIR: str = "output"
16+
17+
# Image settings
18+
STORY_SIZE: Tuple[int, int] = (1080, 1920)
19+
SAFE_MARGIN: int = 100
20+
BLUR_PERCENTAGE: float = 0.3
21+
22+
# Colors
23+
BG_COLOR: str = "#002454"
24+
BG_GRADIENT_BOTTOM: str = "#00152E"
25+
COLOR_WHITE: str = "#FFFFFF"
26+
COLOR_YELLOW: str = "#FFCC00"
27+
COLOR_GREEN: str = "#00FFAA"
28+
29+
# Font sizes
30+
FONT_SIZE_NAME: int = 60
31+
FONT_SIZE_ROLE: int = 40
32+
FONT_SIZE_SESSION: int = 60
33+
FONT_SIZE_EVENT: int = 30
34+
ROLE_OFFSET_Y: int = 60
35+
36+
# API endpoints
37+
SPEAKERS_API: str = "https://sessionize.com/api/v2/xhudniix/view/Speakers"
38+
SESSIONS_API: str = "https://sessionize.com/api/v2/xhudniix/view/Sessions"
39+
40+
# Event details
41+
EVENT_DETAILS: str = "July 8–10, 2025 — La Farga, Barcelona"
42+
43+
# Track gradients
44+
TRACK_GRADIENTS: Dict[str, Tuple[str, str]] = field(
45+
default_factory=lambda: {
46+
"Java | JVM | Cloud": ("#003366", "#001122"),
47+
"DevOps, VMs, Kubernetes": ("#004422", "#001911"),
48+
"Frontend, JS, WASM": ("#223366", "#111122"),
49+
"Leadership, Agile, Diversity": ("#662244", "#221122"),
50+
"Big Data, ML, AI, Python": ("#222244", "#111122"),
51+
"No Track": ("#002454", "#00152E"),
52+
})
53+
54+
def get_track_gradient(self, track_name: str) -> Tuple[str, str]:
55+
"""Get gradient colors for a specific track"""
56+
return self.TRACK_GRADIENTS.get(track_name, (self.BG_COLOR,
57+
self.BG_GRADIENT_BOTTOM))
58+
59+
def ensure_output_dir(self) -> None:
60+
"""Create output directory if it doesn't exist"""
61+
os.makedirs(self.OUTPUT_DIR, exist_ok=True)

scripts/data_fetcher.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""
2+
Module for fetching data from APIs and local resources
3+
"""
4+
import logging
5+
import requests
6+
from io import BytesIO
7+
from typing import Dict, List, Any, Optional
8+
from PIL import Image
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class DataFetcher:
14+
"""Class for fetching data from various sources"""
15+
16+
def __init__(self, request_timeout: int = 10):
17+
self.request_timeout = request_timeout
18+
19+
def fetch_json(self, url: str) -> Any:
20+
"""Fetch JSON data from a URL
21+
22+
Args:
23+
url: The URL to fetch data from
24+
25+
Returns:
26+
Parsed JSON data
27+
28+
Raises:
29+
requests.RequestException: If the request fails
30+
"""
31+
try:
32+
response = requests.get(url, timeout=self.request_timeout)
33+
response.raise_for_status()
34+
return response.json()
35+
except requests.RequestException as e:
36+
logger.error(f"Failed to fetch JSON from {url}: {e}")
37+
raise
38+
39+
def fetch_image(self, url: str) -> Image.Image:
40+
"""Fetch an image from a URL
41+
42+
Args:
43+
url: The URL to fetch the image from
44+
45+
Returns:
46+
PIL Image object
47+
48+
Raises:
49+
requests.RequestException: If the request fails
50+
IOError: If the image cannot be processed
51+
"""
52+
try:
53+
response = requests.get(url, timeout=self.request_timeout)
54+
response.raise_for_status()
55+
return Image.open(BytesIO(response.content)).convert("RGBA")
56+
except requests.RequestException as e:
57+
logger.error(f"Failed to fetch image from {url}: {e}")
58+
raise
59+
except IOError as e:
60+
logger.error(f"Failed to process image from {url}: {e}")
61+
raise
62+
63+
def load_local_image(self, path: str) -> Image.Image:
64+
"""Load an image from a local file
65+
66+
Args:
67+
path: Path to the image file
68+
69+
Returns:
70+
PIL Image object
71+
72+
Raises:
73+
FileNotFoundError: If the file does not exist
74+
IOError: If the image cannot be processed
75+
"""
76+
try:
77+
return Image.open(path).convert("RGBA")
78+
except (FileNotFoundError, IOError) as e:
79+
logger.error(f"Failed to load image from {path}: {e}")
80+
raise
81+
82+
def fetch_speakers(self, api_url: str) -> Dict[str, Dict]:
83+
"""Fetch speaker data and index by ID
84+
85+
Args:
86+
api_url: URL for the speakers API
87+
88+
Returns:
89+
Dictionary of speakers indexed by ID
90+
"""
91+
speakers_data = self.fetch_json(api_url)
92+
return {s['id']: s for s in speakers_data}
93+
94+
def fetch_sessions_by_track(self, api_url: str) -> Dict[str, List[Dict]]:
95+
"""Fetch session data grouped by track
96+
97+
Args:
98+
api_url: URL for the sessions API
99+
100+
Returns:
101+
Dictionary of sessions grouped by track name
102+
"""
103+
sessions_grouped = self.fetch_json(api_url)
104+
tracks = {}
105+
106+
for group in sessions_grouped:
107+
track_name = group.get("title", "No Track")
108+
for session in group.get("sessions", []):
109+
tracks.setdefault(track_name, []).append(session)
110+
111+
return tracks

scripts/file_utils.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""
2+
Utility functions for file operations
3+
"""
4+
import os
5+
import logging
6+
import re
7+
from typing import Optional
8+
from PIL import Image
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
def slugify(value: str) -> str:
14+
"""
15+
Convert a string to a URL-friendly slug
16+
17+
Args:
18+
value: String to convert
19+
20+
Returns:
21+
Slugified string
22+
"""
23+
# Remove special characters
24+
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
25+
# Replace whitespace with hyphens
26+
return re.sub(r'[-\s]+', '-', value)
27+
28+
29+
def save_image(image: Image.Image, directory: str, filename: str) -> str:
30+
"""
31+
Save an image to a file
32+
33+
Args:
34+
image: PIL Image to save
35+
directory: Directory to save in
36+
filename: Base filename (will be slugified)
37+
38+
Returns:
39+
Path to the saved file
40+
"""
41+
os.makedirs(directory, exist_ok=True)
42+
43+
# Ensure the filename is safe
44+
safe_filename = f"{slugify(filename)}.png"
45+
output_path = os.path.join(directory, safe_filename)
46+
47+
try:
48+
image.save(output_path)
49+
logger.info(f"Saved image to {output_path}")
50+
return output_path
51+
except Exception as e:
52+
logger.error(f"Failed to save image to {output_path}: {e}")
53+
raise

scripts/generate-story-images.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate conference story images for speakers and sessions.
4+
5+
This script uses the refactored modules to generate story images for the conference,
6+
following SOLID principles for better maintainability and testability.
7+
"""
8+
import argparse
9+
import logging
10+
from concurrent.futures import ThreadPoolExecutor
11+
from typing import Dict, Any, List, Tuple
12+
13+
from config import Config
14+
from data_fetcher import DataFetcher
15+
from story_generator import StoryGenerator
16+
17+
# Set up logging
18+
logging.basicConfig(
19+
level=logging.INFO,
20+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
21+
)
22+
logger = logging.getLogger(__name__)
23+
24+
25+
def parse_arguments():
26+
"""Parse command line arguments"""
27+
parser = argparse.ArgumentParser(
28+
description='Generate story images for conference speakers and sessions')
29+
parser.add_argument('--max-workers', type=int, default=10,
30+
help='Maximum number of worker threads')
31+
parser.add_argument('--timeout', type=int, default=10,
32+
help='Timeout for HTTP requests in seconds')
33+
parser.add_argument('--output-dir', type=str, default='output',
34+
help='Directory to save generated images')
35+
return parser.parse_args()
36+
37+
38+
def process_session(generator: StoryGenerator, session: Dict[str, Any],
39+
speaker: Dict[str, Any], track_name: str) -> str:
40+
"""Process a single session and generate its story image
41+
42+
Args:
43+
generator: StoryGenerator instance
44+
session: Session data
45+
speaker: Speaker data
46+
track_name: Name of the track
47+
48+
Returns:
49+
Path to the generated image file
50+
"""
51+
try:
52+
return generator.generate_story(speaker, session, track_name)
53+
except Exception as e:
54+
logger.error(
55+
f"Failed to process session {session.get('title', 'Unknown')}: {e}")
56+
raise
57+
58+
59+
def main():
60+
"""Main entry point"""
61+
args = parse_arguments()
62+
63+
# Initialize configuration
64+
config = Config()
65+
# Override output directory from command line if provided
66+
if args.output_dir:
67+
config.OUTPUT_DIR = args.output_dir
68+
69+
# Ensure output directory exists
70+
config.ensure_output_dir()
71+
72+
# Initialize components
73+
data_fetcher = DataFetcher(request_timeout=args.timeout)
74+
story_generator = StoryGenerator(config, data_fetcher)
75+
76+
logger.info("Fetching speakers and sessions...")
77+
78+
try:
79+
# Fetch data
80+
speakers_lookup = data_fetcher.fetch_speakers(config.SPEAKERS_API)
81+
tracks = data_fetcher.fetch_sessions_by_track(config.SESSIONS_API)
82+
83+
# Process all sessions
84+
with ThreadPoolExecutor(max_workers=args.max_workers) as executor:
85+
futures = []
86+
87+
for track_name, sessions in tracks.items():
88+
logger.info(
89+
f"Processing track: {track_name} with {len(sessions)} sessions")
90+
91+
for session in sessions:
92+
speaker_ids = session.get('speakers', [])
93+
speaker_id = speaker_ids[0]['id'] if speaker_ids else None
94+
95+
if speaker_id and speaker_id in speakers_lookup:
96+
speaker = speakers_lookup[speaker_id]
97+
futures.append(
98+
executor.submit(
99+
process_session,
100+
story_generator,
101+
session,
102+
speaker,
103+
track_name
104+
)
105+
)
106+
107+
# Wait for all tasks to complete
108+
completed = 0
109+
for future in futures:
110+
future.result()
111+
completed += 1
112+
if completed % 5 == 0:
113+
logger.info(
114+
f"Processed {completed}/{len(futures)} sessions")
115+
116+
logger.info("✅ All stories generated!")
117+
118+
except Exception as e:
119+
logger.error(f"Error processing stories: {e}")
120+
return 1
121+
122+
return 0
123+
124+
125+
if __name__ == "__main__":
126+
exit(main())

0 commit comments

Comments
 (0)