2026-04-06 15:12:01 -04:00
|
|
|
import logging
|
|
|
|
|
import os
|
2026-04-06 17:02:36 -04:00
|
|
|
from collections import Counter
|
2026-04-06 15:12:01 -04:00
|
|
|
from datetime import date
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
from urllib.parse import quote
|
|
|
|
|
|
|
|
|
|
import requests
|
|
|
|
|
from PIL import Image as PILImage, ImageDraw, ImageFont
|
|
|
|
|
|
|
|
|
|
import config
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
CATEGORY_PROMPTS = {
|
|
|
|
|
"Government": (
|
|
|
|
|
"Watercolor painting of Plymouth Massachusetts town hall, "
|
|
|
|
|
"New England civic architecture, soft morning light, no text"
|
|
|
|
|
),
|
|
|
|
|
"Culture": (
|
|
|
|
|
"Plymouth Massachusetts waterfront boardwalk, autumn festival "
|
|
|
|
|
"atmosphere, warm tones, New England charm, no text"
|
|
|
|
|
),
|
|
|
|
|
"Culture Calendar": (
|
|
|
|
|
"Plymouth Massachusetts harbor at golden hour, sailboats and "
|
|
|
|
|
"historic waterfront, warm autumn palette, no text"
|
|
|
|
|
),
|
|
|
|
|
"Sports": (
|
|
|
|
|
"Plymouth Massachusetts harbor marina at sunrise, boats and "
|
|
|
|
|
"morning mist on Cape Cod Bay, no text"
|
|
|
|
|
),
|
|
|
|
|
"Environment": (
|
|
|
|
|
"Cape Cod Bay shoreline near Plymouth Massachusetts, coastal "
|
|
|
|
|
"wetlands and dune grass, golden hour light, no text"
|
|
|
|
|
),
|
|
|
|
|
"Opinion": (
|
|
|
|
|
"Plymouth Rock historic site in Plymouth Massachusetts, "
|
|
|
|
|
"dramatic sky, iconic New England landmark, no text"
|
|
|
|
|
),
|
|
|
|
|
"Feature": (
|
|
|
|
|
"Downtown Plymouth Massachusetts street scene, historic "
|
|
|
|
|
"brick storefronts, New England small town charm, no text"
|
|
|
|
|
),
|
|
|
|
|
"Default": (
|
|
|
|
|
"Aerial view of Plymouth Massachusetts coastal town, serene "
|
|
|
|
|
"harbor, Cape Cod Bay, New England charm, no text"
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 15:12:01 -04:00
|
|
|
|
|
|
|
|
def _get_font(size: int) -> ImageFont.FreeTypeFont:
|
|
|
|
|
try:
|
2026-04-06 17:02:36 -04:00
|
|
|
return ImageFont.truetype(
|
|
|
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size
|
|
|
|
|
)
|
2026-04-06 15:12:01 -04:00
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
return ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
return ImageFont.load_default()
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
def _get_dominant_category(categories: list[str]) -> str:
|
|
|
|
|
if not categories:
|
|
|
|
|
return "Default"
|
|
|
|
|
counter = Counter(categories)
|
|
|
|
|
most_common = counter.most_common(1)[0][0]
|
|
|
|
|
return most_common
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _draw_text_overlays(
|
|
|
|
|
draw: ImageDraw.ImageDraw,
|
|
|
|
|
width: int,
|
|
|
|
|
height: int,
|
2026-04-06 15:12:01 -04:00
|
|
|
week_start: date,
|
|
|
|
|
week_end: date,
|
|
|
|
|
headlines: list[str],
|
2026-04-06 17:02:36 -04:00
|
|
|
) -> None:
|
|
|
|
|
title_font = _get_font(38)
|
2026-04-06 15:12:01 -04:00
|
|
|
date_font = _get_font(20)
|
2026-04-06 17:02:36 -04:00
|
|
|
headline_font = _get_font(14)
|
2026-04-06 15:12:01 -04:00
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
draw.rectangle([(0, 0), (width, 90)], fill=(0, 0, 0, 204))
|
|
|
|
|
draw.text(
|
|
|
|
|
(width // 2, 20), "Plymouth Independent",
|
|
|
|
|
fill="white", font=title_font, anchor="mt",
|
|
|
|
|
)
|
2026-04-06 15:12:01 -04:00
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
iso_week = week_start.isocalendar()[1]
|
|
|
|
|
date_str = (
|
|
|
|
|
f"Week {iso_week} \u00b7 "
|
|
|
|
|
f"{week_start.strftime('%b %d')} \u2013 {week_end.strftime('%b %d, %Y')}"
|
|
|
|
|
)
|
|
|
|
|
draw.text(
|
|
|
|
|
(width // 2, 62), date_str,
|
|
|
|
|
fill=(220, 220, 220), font=date_font, anchor="mt",
|
|
|
|
|
)
|
2026-04-06 15:12:01 -04:00
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
if headlines:
|
|
|
|
|
max_headlines = min(len(headlines), 5)
|
|
|
|
|
strip_height = 28 + max_headlines * 22
|
|
|
|
|
strip_top = height - strip_height
|
|
|
|
|
draw.rectangle(
|
|
|
|
|
[(0, strip_top), (width, height)], fill=(0, 0, 0, 170)
|
|
|
|
|
)
|
|
|
|
|
y = strip_top + 8
|
|
|
|
|
for headline in headlines[:max_headlines]:
|
|
|
|
|
text = f"\u2022 {headline}"
|
|
|
|
|
if len(text) > 45:
|
|
|
|
|
text = text[:42] + "..."
|
|
|
|
|
draw.text((16, y), text, fill="white", font=headline_font)
|
|
|
|
|
y += 22
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_programmatic_cover(
|
|
|
|
|
output_dir: str,
|
|
|
|
|
week_start: date,
|
|
|
|
|
week_end: date,
|
|
|
|
|
headlines: list[str],
|
|
|
|
|
categories: list[str],
|
|
|
|
|
) -> str:
|
|
|
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
|
w, h = config.COVER_SIZE
|
|
|
|
|
img = PILImage.new("RGBA", (w, h), color=(245, 245, 245, 255))
|
|
|
|
|
draw = ImageDraw.Draw(img)
|
2026-04-06 15:12:01 -04:00
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
_draw_text_overlays(draw, w, h, week_start, week_end, headlines)
|
2026-04-06 15:12:01 -04:00
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
out = img.convert("RGB")
|
2026-04-06 15:12:01 -04:00
|
|
|
filename = f"cover-{week_start.isoformat()}-text.jpg"
|
|
|
|
|
path = os.path.join(output_dir, filename)
|
2026-04-06 17:02:36 -04:00
|
|
|
out.save(path, format="JPEG", progressive=False, quality=90)
|
2026-04-06 15:12:01 -04:00
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_ai_cover(
|
|
|
|
|
output_dir: str,
|
|
|
|
|
week_start: date,
|
|
|
|
|
week_end: date,
|
|
|
|
|
headlines: list[str],
|
2026-04-06 17:02:36 -04:00
|
|
|
categories: list[str],
|
2026-04-06 15:12:01 -04:00
|
|
|
) -> str:
|
|
|
|
|
os.makedirs(output_dir, exist_ok=True)
|
2026-04-06 17:02:36 -04:00
|
|
|
w, h = config.COVER_SIZE
|
2026-04-06 15:12:01 -04:00
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
dominant = _get_dominant_category(categories)
|
|
|
|
|
prompt = CATEGORY_PROMPTS.get(dominant, CATEGORY_PROMPTS["Default"])
|
2026-04-06 15:12:01 -04:00
|
|
|
url = config.POLLINATIONS_URL.format(prompt=quote(prompt))
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
response = requests.get(url, timeout=60)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
bg = PILImage.open(BytesIO(response.content))
|
|
|
|
|
if bg.mode not in ("RGB", "RGBA"):
|
|
|
|
|
bg = bg.convert("RGB")
|
|
|
|
|
bg = bg.resize((w, h), PILImage.Resampling.LANCZOS)
|
2026-04-06 15:12:01 -04:00
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
img = bg.convert("RGBA")
|
|
|
|
|
overlay = PILImage.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
|
|
|
draw = ImageDraw.Draw(overlay)
|
|
|
|
|
_draw_text_overlays(draw, w, h, week_start, week_end, headlines)
|
|
|
|
|
img = PILImage.alpha_composite(img, overlay)
|
2026-04-06 15:12:01 -04:00
|
|
|
|
2026-04-06 17:02:36 -04:00
|
|
|
out = img.convert("RGB")
|
2026-04-06 15:12:01 -04:00
|
|
|
filename = f"cover-{week_start.isoformat()}-ai.jpg"
|
|
|
|
|
path = os.path.join(output_dir, filename)
|
2026-04-06 17:02:36 -04:00
|
|
|
out.save(path, format="JPEG", progressive=False, quality=90)
|
2026-04-06 15:12:01 -04:00
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-04-06 17:02:36 -04:00
|
|
|
logger.error("AI cover generation failed, falling back to programmatic: %s", e)
|
|
|
|
|
return generate_programmatic_cover(
|
|
|
|
|
output_dir, week_start, week_end, headlines, categories
|
|
|
|
|
)
|
2026-04-06 15:12:01 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_cover(
|
|
|
|
|
method: str,
|
|
|
|
|
output_dir: str,
|
|
|
|
|
week_start: date,
|
|
|
|
|
week_end: date,
|
|
|
|
|
headlines: list[str],
|
2026-04-06 17:02:36 -04:00
|
|
|
categories: list[str],
|
2026-04-06 15:12:01 -04:00
|
|
|
) -> str:
|
|
|
|
|
if method == "ai":
|
2026-04-06 17:02:36 -04:00
|
|
|
return generate_ai_cover(
|
|
|
|
|
output_dir, week_start, week_end, headlines, categories
|
|
|
|
|
)
|
|
|
|
|
return generate_programmatic_cover(
|
|
|
|
|
output_dir, week_start, week_end, headlines, categories
|
|
|
|
|
)
|