"""Pillow compositor — places branding (logo, headlines, badge, gradient, grain) over a raw Flux scene at any platform canvas size. VERBATIM transcription of composite_for_platform() from platform-recomposite.py (the proven WOM/Gregg production script), with only these changes: - Font/logo paths pulled from brand.py instead of /tmp hardcodes - scene_darken default preserved (0.85) - Color arg accepts (r,g,b) tuples (unchanged from original) - No functional math changes — gradients, margins, positioning, grain all identical """ from pathlib import Path import random import re from PIL import Image, ImageDraw, ImageFont, ImageEnhance from brand import ( FONT_BOLD, FONT_MEDIUM, FONT_REG, LOGO_PATH, REFERENCE_H, RED, WHITE, TEAL, ORANGE, ) def _get_fonts(canvas_h: int) -> dict: """Scale fonts relative to the reference 628px canvas (verbatim from proven flow).""" scale = canvas_h / REFERENCE_H return { "headline": ImageFont.truetype(str(FONT_BOLD), max(24, int(78 * scale))), "sub": ImageFont.truetype(str(FONT_REG), max(10, int(16 * scale))), "badge": ImageFont.truetype(str(FONT_MEDIUM), max(9, int(12 * scale))), # Event-meta footer (when/where/timezone for webinar / in-person / etc). # Bigger than the old subtitle (which was unreadable at 10-16px) but # still secondary to the headline — explicitly part of theme info, # not a separate label band. Bumped vs subtitle so it's actually legible. "event": ImageFont.truetype(str(FONT_MEDIUM), max(13, int(22 * scale))), "event_kind": ImageFont.truetype(str(FONT_BOLD), max(11, int(18 * scale))), } def composite_for_platform( scene_path: Path, output_path: Path, W: int, H: int, headlines: list, # [(text, (r,g,b)), …] badge_text: str, subtitle: str, badge_color: tuple = RED, scene_darken: float = 0.85, event_meta: dict | None = None, # {kind, when, where, timezone} for event campaigns ) -> int: """Full re-composite at target dimensions. Logo/text placed relative to canvas. Returns output file size in bytes. """ scene = Image.open(scene_path).convert("RGBA") # Smart fit: cover the canvas, anchoring scene illustration to right side sw, sh = scene.size scale = max(W / sw, H / sh) new_w = int(sw * scale) new_h = int(sh * scale) resized = scene.resize((new_w, new_h), Image.LANCZOS) # Crop anchored to right (preserve right-side illustration) left = max(0, new_w - W) top = (new_h - H) // 2 fitted = resized.crop((left, top, left + W, top + H)) if scene_darken < 1.0: fitted = ImageEnhance.Brightness(fitted).enhance(scene_darken) img = fitted.copy() fonts = _get_fonts(H) # Margins scale with canvas margin_x = max(20, int(44 * W / 1200)) margin_y = max(16, int(36 * H / REFERENCE_H)) # ── Left gradient for text readability ── grad_w = int(W * 0.42) overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0)) od = ImageDraw.Draw(overlay) for x in range(grad_w): alpha = int(130 * (1 - x / grad_w) ** 1.5) od.line([(x, 0), (x, H)], fill=(0, 0, 0, alpha)) # Top gradient for y in range(int(H * 0.12)): alpha = int(100 * (1 - y / (H * 0.12)) ** 2) od.line([(0, y), (W, y)], fill=(0, 0, 0, alpha)) img = Image.alpha_composite(img, overlay) # ── Accent line (bottom-left) ── accent = Image.new("RGBA", (W, H), (0, 0, 0, 0)) ad = ImageDraw.Draw(accent) for x in range(int(W * 0.35)): t = x / (W * 0.35) ad.line([(x, H - 2), (x, H)], fill=(*badge_color, int(200 * (1 - t)))) img = Image.alpha_composite(img, accent) # ── Logo (top-left, scaled) ── # 2026-05-21: bumped scale factor 24 → 48 (logo was unreadable at small # font; doubled the base size; aspect ratio auto-maintained via lw calc). logo = Image.open(str(LOGO_PATH)).convert("RGBA") lh = max(20, int(48 * H / REFERENCE_H)) lw = int(logo.width * (lh / logo.height)) logo = logo.resize((lw, lh), Image.LANCZOS) img.paste(logo, (margin_x, margin_y), logo) # ── Badge (top-right) ── blayer = Image.new("RGBA", (W, H), (0, 0, 0, 0)) bd = ImageDraw.Draw(blayer) bbox = fonts["badge"].getbbox(badge_text) tw = bbox[2] - bbox[0] bw = tw + max(12, int(20 * W / 1200)) bh_size = max(16, int(22 * H / REFERENCE_H)) bx = W - bw - margin_x by = margin_y bd.rounded_rectangle( [(bx, by), (bx + bw, by + bh_size)], radius=max(2, int(4 * H / REFERENCE_H)), fill=(*badge_color, 220), ) bd.text((bx + bw // 2 - tw // 2, by + 2), badge_text, font=fonts["badge"], fill=WHITE) img = Image.alpha_composite(img, blayer) # ── Headlines (stacked, drop shadow, per-line colors) ── tlayer = Image.new("RGBA", (W, H), (0, 0, 0, 0)) td = ImageDraw.Draw(tlayer) line_h = int(H * 0.135) total_text_h = len(headlines) * line_h y_start = int(H * 0.32) - total_text_h // 4 # bias upward y_start = max(margin_y + lh + 10, y_start) # don't overlap logo for i, (text, color) in enumerate(headlines): y_pos = y_start + i * line_h for dx, dy, a in [(2, 2, 80), (1, 1, 50)]: td.text((margin_x + dx, y_pos + dy), text, font=fonts["headline"], fill=(0, 0, 0, a)) td.text((margin_x, y_pos), text, font=fonts["headline"], fill=(*color, 250)) # Subtitle — DISABLED 2026-05-21 # Per user feedback: at font-size max(10, int(16 * scale)) with alpha=110 # white, the subtitle text was effectively invisible at the platform # sizes we actually post to (LinkedIn 1200×627, Twitter 1280×720). # Headlines + badge already carry the message; subtitle was noise. # If reintroduced later, bump font size to ≥20px and alpha to ≥200. # sub_y = y_start + len(headlines) * line_h + max(6, int(12 * H / REFERENCE_H)) # if sub_y < H - margin_y: # td.text((margin_x + 2, sub_y), subtitle, font=fonts["sub"], fill=(*WHITE, 110)) img = Image.alpha_composite(img, tlayer) # ── Event meta footer (webinar/in-person/live-stream campaigns only) ── # Renders when the creative spec carries event_meta. Sits BELOW the # headlines, ABOVE the bottom accent line. Smaller than headline, # bigger than the deprecated 10-16px subtitle that was unreadable. # Layout: small KIND tag (bold) · WHEN · WHERE · TZ on one line, # left-aligned with the headlines. # # Skips quietly if event_meta missing → evergreen campaigns get the # original layout unchanged. if event_meta and event_meta.get("when"): elayer = Image.new("RGBA", (W, H), (0, 0, 0, 0)) ed = ImageDraw.Draw(elayer) kind_raw = str(event_meta.get("kind") or "").replace("_", " ").upper() when_s = str(event_meta.get("when") or "").strip() where_s = str(event_meta.get("where") or "").strip() tz_s = str(event_meta.get("timezone") or "").strip() # Skip timezone if it's already mentioned in `when` (e.g. "Feb 12 · 10am PT" # already includes "PT" — appending "· PT" duplicates and pushes layout to wrap). if tz_s and (re.search(r"\b" + re.escape(tz_s) + r"\b", when_s, re.IGNORECASE)): tz_s = "" # Compose the right-hand details with a thin separator sep = " · " parts = [p for p in [when_s, where_s, tz_s] if p] details = sep.join(parts) # Y position: blank-line gap below the deepest headline so the footer # feels separated. 2x line_h gives a clear visual blank line between # the big-font headline block and the small-font event details. # (Was 1.5x — bumped 2026-05-27 per user feedback "空行" requested # an even cleaner separation.) last_y = y_start + (len(headlines) - 1) * line_h event_y = last_y + int(line_h * 2.0) # Footer now spans TWO lines, so make sure both fit above the bottom # accent line. Reserve room for line 2 by capping line 1's Y. event_line_h = max(18, int(28 * H / REFERENCE_H)) max_y = H - max(margin_y + 6, int(34 * H / REFERENCE_H)) - event_line_h if event_y > max_y: event_y = max_y # Two-line layout (revised 2026-05-27): # Line 1: colored dot + WHEN (date/time — primary info) # Line 2: + WHERE (location — secondary, indented to align with WHEN) # KIND label dropped — top-right badge already says "Webinar"/etc. # Splitting into two lines keeps each line short and readable; on a # 600×200 email banner one long line ran nearly the full width. kind_color = TEAL if kind_raw not in ("CONFERENCE", "IN PERSON") else ORANGE dot_size = max(6, int(10 * H / REFERENCE_H)) gap = max(10, int(14 * H / REFERENCE_H)) # Line 1: dot + WHEN (timezone already inside when_s; tz_s was zeroed above) ed.rectangle( [(margin_x, event_y + 4), (margin_x + dot_size, event_y + 4 + dot_size)], fill=(*kind_color, 230), ) text_x = margin_x + dot_size + gap ed.text((text_x, event_y), when_s, font=fonts["event"], fill=(*WHITE, 230)) # Line 2: WHERE — indented under WHEN, slightly more muted alpha so # the secondary info reads as secondary. if where_s: ed.text((text_x, event_y + event_line_h), where_s, font=fonts["event"], fill=(*WHITE, 190)) img = Image.alpha_composite(img, elayer) # ── Grain ── random.seed(42) grain = Image.new("RGBA", (W, H), (0, 0, 0, 0)) gpx = grain.load() step = 3 for y in range(0, H, step): for x in range(0, W, step): v = random.randint(0, 2) gpx[x, y] = (255, 255, 255, v) img = Image.alpha_composite(img, grain) final = img.convert("RGB") output_path.parent.mkdir(parents=True, exist_ok=True) final.save(str(output_path), quality=95, dpi=(144, 144)) return output_path.stat().st_size