Scripts already referenced from CLAUDE.md but never committed: deploy_searxng.sh (which stands up the SearXNG instance Devon's web_search depends on), post_milestone.py, generate_milestone_images.py, make_x_launch_assets.py, schedule_x_launch.py, download_music.py, and the Reaper bleep-selection script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
645 lines
20 KiB
Python
645 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate all visual assets for the X/Twitter launch campaign.
|
|
|
|
Creates:
|
|
1. X header image (1500x500)
|
|
2. 7 branded quote cards (1080x1080 + 1200x675)
|
|
3. "Welcome to the show" intro graphic
|
|
4. "Leave us a review" graphic
|
|
|
|
Usage:
|
|
python make_x_launch_assets.py
|
|
"""
|
|
|
|
import os
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
COVER = os.path.join(SCRIPT_DIR, "website/images/cover.png")
|
|
OUT_DIR = os.path.join(SCRIPT_DIR, "social_posts/x_launch")
|
|
|
|
# Brand colors
|
|
BG = (18, 13, 7)
|
|
ACCENT = (232, 121, 29)
|
|
WHITE = (255, 255, 255)
|
|
MUTED = (175, 165, 150)
|
|
LIGHTER = (220, 215, 205)
|
|
DARK_PANEL = (30, 22, 12)
|
|
ACCENT_DIM = (140, 75, 18)
|
|
|
|
# macOS system fonts
|
|
FONT_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
|
|
FONT_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
|
FONT_REG = "/System/Library/Fonts/Supplemental/Arial.ttf"
|
|
FONT_ITALIC = "/System/Library/Fonts/Supplemental/Arial Italic.ttf"
|
|
|
|
|
|
def font(path, size):
|
|
return ImageFont.truetype(path, size)
|
|
|
|
|
|
def text_size(draw, text, f):
|
|
bb = draw.textbbox((0, 0), text, font=f)
|
|
return bb[2] - bb[0], bb[3] - bb[1]
|
|
|
|
|
|
def paste_cover(img, x, y, size, radius=16):
|
|
cover = Image.open(COVER).resize((size, size), Image.LANCZOS)
|
|
mask = Image.new("L", (size, size), 0)
|
|
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size, size], radius=radius, fill=255)
|
|
img.paste(cover, (x, y), mask)
|
|
|
|
|
|
def wrap_text_centered(draw, text, center_x, y, max_w, f, fill, line_gap=10):
|
|
"""Word-wrap text, centered on each line. Returns y below last line."""
|
|
words = text.split()
|
|
lines = []
|
|
cur = ""
|
|
for word in words:
|
|
test = f"{cur} {word}".strip()
|
|
tw, _ = text_size(draw, test, f)
|
|
if tw > max_w and cur:
|
|
lines.append(cur)
|
|
cur = word
|
|
else:
|
|
cur = test
|
|
if cur:
|
|
lines.append(cur)
|
|
|
|
for line in lines:
|
|
tw, th = text_size(draw, line, f)
|
|
draw.text((center_x - tw // 2, y), line, font=f, fill=fill)
|
|
y += th + line_gap
|
|
return y
|
|
|
|
|
|
def wrap_text_left(draw, text, x, y, max_w, f, fill, line_gap=10):
|
|
"""Word-wrap text, left-aligned. Returns y below last line."""
|
|
words = text.split()
|
|
lines = []
|
|
cur = ""
|
|
for word in words:
|
|
test = f"{cur} {word}".strip()
|
|
tw, _ = text_size(draw, test, f)
|
|
if tw > max_w and cur:
|
|
lines.append(cur)
|
|
cur = word
|
|
else:
|
|
cur = test
|
|
if cur:
|
|
lines.append(cur)
|
|
|
|
for line in lines:
|
|
_, th = text_size(draw, line, f)
|
|
draw.text((x, y), line, font=f, fill=fill)
|
|
y += th + line_gap
|
|
return y
|
|
|
|
|
|
def measure_wrap_height(draw, text, max_w, f, line_gap=10):
|
|
"""Measure how tall wrapped text would be without drawing."""
|
|
words = text.split()
|
|
lines = []
|
|
cur = ""
|
|
for word in words:
|
|
test = f"{cur} {word}".strip()
|
|
tw, _ = text_size(draw, test, f)
|
|
if tw > max_w and cur:
|
|
lines.append(cur)
|
|
cur = word
|
|
else:
|
|
cur = test
|
|
if cur:
|
|
lines.append(cur)
|
|
total = 0
|
|
for line in lines:
|
|
_, th = text_size(draw, line, f)
|
|
total += th + line_gap
|
|
return total
|
|
|
|
|
|
def accent_bars(draw, w, h, thickness):
|
|
draw.rectangle([0, 0, w, thickness], fill=ACCENT)
|
|
draw.rectangle([0, h - thickness, w, h], fill=ACCENT)
|
|
|
|
|
|
def center_text(draw, text, y, canvas_w, f, fill):
|
|
tw, th = text_size(draw, text, f)
|
|
draw.text(((canvas_w - tw) // 2, y), text, font=f, fill=fill)
|
|
return y + th
|
|
|
|
|
|
# ── 1. X HEADER (1500x500) ──────────────────────────────────────────
|
|
|
|
def make_header():
|
|
W, H = 1500, 500
|
|
img = Image.new("RGB", (W, H), BG)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# Amber accent bars
|
|
accent_bars(draw, W, H, 6)
|
|
|
|
# Subtle amber glow on left
|
|
for i in range(200):
|
|
alpha = int(18 * (1 - i / 200))
|
|
draw.rectangle([0, 0, i, H], fill=(18 + alpha, 13 + alpha // 2, 7))
|
|
|
|
# Cover art — right side
|
|
cover_size = 340
|
|
cover_x = W - cover_size - 60
|
|
cover_y = (H - cover_size) // 2
|
|
paste_cover(img, cover_x, cover_y, cover_size, 20)
|
|
|
|
# Left side content
|
|
mx = 80
|
|
cy = 100
|
|
|
|
# Show name
|
|
draw.text((mx, cy), "LUKE AT THE ROOST", font=font(FONT_BLACK, 72), fill=WHITE)
|
|
cy += 90
|
|
|
|
# Tagline
|
|
draw.text((mx, cy), "Late-Night Call-In Radio", font=font(FONT_REG, 36), fill=ACCENT)
|
|
cy += 50
|
|
draw.text((mx, cy), "Powered by AI", font=font(FONT_BOLD, 30), fill=MUTED)
|
|
cy += 55
|
|
|
|
# Divider line
|
|
draw.rectangle([mx, cy, mx + 400, cy + 3], fill=ACCENT)
|
|
cy += 20
|
|
|
|
# Info line
|
|
draw.text((mx, cy), "New episodes daily | lukeattheroost.com",
|
|
font=font(FONT_REG, 24), fill=MUTED)
|
|
|
|
img.save(os.path.join(OUT_DIR, "x_header_1500x500.png"), quality=95)
|
|
print("Created: x_header_1500x500.png")
|
|
|
|
|
|
# ── 2. QUOTE CARDS ──────────────────────────────────────────────────
|
|
|
|
QUOTES = [
|
|
{
|
|
"quote": "Everybody is a fake. We're all fakes. Nobody knows what's going on and none of us deserve a goddamn thing. We're lucky to be here at all.",
|
|
"caller": "Luke",
|
|
"episode": "Ep. 2",
|
|
"slug": "were_all_fakes",
|
|
},
|
|
{
|
|
"quote": "When my hands are busy, my head is quiet.",
|
|
"caller": "Frank",
|
|
"episode": "Ep. 12",
|
|
"context": "on building bird houses after losing his wife",
|
|
"slug": "hands_busy_head_quiet",
|
|
},
|
|
{
|
|
"quote": "I've been using stoicism backwards\u2014as an excuse not to try instead of finding peace after I've actually done something. That's not stoicism, that's just being a coward with a fancy excuse.",
|
|
"caller": "Caller",
|
|
"episode": "Ep. 19",
|
|
"slug": "stoicism_backwards",
|
|
},
|
|
{
|
|
"quote": "You're right. I am a computer-generated AI caller. And you're sitting there alone talking to me at midnight like it's a real conversation.",
|
|
"caller": "AI Caller",
|
|
"episode": "Ep. 24",
|
|
"slug": "ai_caller_reveal",
|
|
},
|
|
{
|
|
"quote": "I burned my second marriage to the ground doing exactly what you're doing. My ex-wife didn't leave because I wasn't making money\u2014she left because I wasn't there.",
|
|
"caller": "Mikey",
|
|
"episode": "Ep. 22",
|
|
"slug": "burned_my_marriage",
|
|
},
|
|
{
|
|
"quote": "My mom said all she wants is to see her kids eat cake together. That's it. Just cake.",
|
|
"caller": "Caller",
|
|
"episode": "Ep. 30",
|
|
"context": "on a dying mother's final wish",
|
|
"slug": "just_eat_cake",
|
|
},
|
|
{
|
|
"quote": "I told my sister I had prostate cancer to get out of her fourth wedding. Now there's been a GoFundMe, a pancake breakfast fundraiser, and my cousin shaved his head for me.",
|
|
"caller": "Caller",
|
|
"episode": "Ep. 32",
|
|
"slug": "faked_cancer_wedding",
|
|
},
|
|
]
|
|
|
|
|
|
def make_quote_square(q, idx):
|
|
W = 1080
|
|
img = Image.new("RGB", (W, W), BG)
|
|
draw = ImageDraw.Draw(img)
|
|
accent_bars(draw, W, W, 6)
|
|
|
|
mx = 70
|
|
max_w = W - mx * 2
|
|
|
|
# Header bar
|
|
draw.text((mx, 40), "LUKE AT THE ROOST", font=font(FONT_BOLD, 22), fill=ACCENT)
|
|
paste_cover(img, W - 120, 30, 70, 10)
|
|
|
|
# Font size — aggressive scaling for short quotes
|
|
quote_len = len(q["quote"])
|
|
if quote_len < 55:
|
|
qfont_size = 72
|
|
elif quote_len < 100:
|
|
qfont_size = 56
|
|
elif quote_len < 150:
|
|
qfont_size = 44
|
|
else:
|
|
qfont_size = 38
|
|
qfont = font(FONT_BOLD, qfont_size)
|
|
line_gap = 16
|
|
|
|
# Measure total content block height to center it
|
|
open_quote_h = 80
|
|
quote_gap = 20
|
|
quote_h = measure_wrap_height(draw, q["quote"], max_w, qfont, line_gap=line_gap)
|
|
close_quote_h = 78 # glyph + attribution inline
|
|
attr_gap = 0
|
|
divider_h = 0
|
|
attr_h = 0
|
|
context_h = 35 if "context" in q else 0
|
|
|
|
total_h = open_quote_h + quote_gap + quote_h + close_quote_h + attr_gap + divider_h + attr_h + context_h
|
|
|
|
# Center between header (y=90) and footer (y=W-70)
|
|
avail_top = 100
|
|
avail_bottom = W - 80
|
|
avail_h = avail_bottom - avail_top
|
|
y = avail_top + (avail_h - total_h) // 2
|
|
|
|
# Opening quote mark
|
|
draw.text((mx - 15, y), "\u201c", font=font(FONT_BLACK, 100), fill=ACCENT_DIM)
|
|
y += open_quote_h + quote_gap
|
|
|
|
# Quote text
|
|
y = wrap_text_left(draw, q["quote"], mx, y, max_w, qfont, WHITE, line_gap=line_gap)
|
|
|
|
# Closing quote mark + attribution
|
|
close_y = y + 8
|
|
draw.text((mx - 15, close_y), "\u201d", font=font(FONT_BLACK, 80), fill=ACCENT_DIM)
|
|
attr = f"\u2014 {q['caller']}, {q['episode']}"
|
|
draw.text((mx + 70, close_y + 25), attr, font=font(FONT_BOLD, 28), fill=ACCENT)
|
|
y = close_y + 70
|
|
|
|
if "context" in q:
|
|
draw.text((mx, y), q["context"], font=font(FONT_ITALIC, 24), fill=MUTED)
|
|
|
|
# Footer
|
|
footer_y = W - 70
|
|
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
|
|
footer_y, W, font(FONT_REG, 20), MUTED)
|
|
|
|
fname = f"quote_{idx + 1}_{q['slug']}_square.png"
|
|
img.save(os.path.join(OUT_DIR, fname), quality=95)
|
|
print(f"Created: {fname}")
|
|
|
|
|
|
def make_quote_landscape(q, idx):
|
|
W, H = 1200, 675
|
|
img = Image.new("RGB", (W, H), BG)
|
|
draw = ImageDraw.Draw(img)
|
|
accent_bars(draw, W, H, 5)
|
|
|
|
mx = 55
|
|
max_w = W - mx * 2
|
|
|
|
# Header
|
|
draw.text((mx, 28), "LUKE AT THE ROOST", font=font(FONT_BOLD, 18), fill=ACCENT)
|
|
paste_cover(img, W - 90, 22, 52, 8)
|
|
|
|
# Font size — aggressive scaling for short quotes
|
|
quote_len = len(q["quote"])
|
|
if quote_len < 55:
|
|
qfont_size = 52
|
|
elif quote_len < 100:
|
|
qfont_size = 42
|
|
elif quote_len < 150:
|
|
qfont_size = 34
|
|
else:
|
|
qfont_size = 28
|
|
qfont = font(FONT_BOLD, qfont_size)
|
|
|
|
# Measure total content block height
|
|
open_quote_h = 55
|
|
quote_gap = 15
|
|
quote_h = measure_wrap_height(draw, q["quote"], max_w, qfont, line_gap=10)
|
|
close_quote_h = 60 # glyph + attribution inline
|
|
attr_gap = 0
|
|
divider_h = 0
|
|
attr_h = 0
|
|
context_h = 30 if "context" in q else 0
|
|
|
|
total_h = open_quote_h + quote_gap + quote_h + close_quote_h + attr_gap + divider_h + attr_h + context_h
|
|
|
|
# Center between header (y=65) and footer (y=H-50)
|
|
avail_top = 65
|
|
avail_bottom = H - 55
|
|
avail_h = avail_bottom - avail_top
|
|
y = avail_top + (avail_h - total_h) // 2
|
|
|
|
# Opening quote mark
|
|
draw.text((mx - 10, y), "\u201c", font=font(FONT_BLACK, 72), fill=ACCENT_DIM)
|
|
y += open_quote_h + quote_gap
|
|
|
|
# Quote text
|
|
y = wrap_text_left(draw, q["quote"], mx, y, max_w, qfont, WHITE, line_gap=10)
|
|
|
|
# Closing quote + attribution
|
|
close_y = y + 5
|
|
draw.text((mx - 10, close_y), "\u201d", font=font(FONT_BLACK, 60), fill=ACCENT_DIM)
|
|
attr = f"\u2014 {q['caller']}, {q['episode']}"
|
|
draw.text((mx + 55, close_y + 18), attr, font=font(FONT_BOLD, 22), fill=ACCENT)
|
|
y = close_y + 55
|
|
|
|
if "context" in q:
|
|
draw.text((mx, y), q["context"], font=font(FONT_ITALIC, 19), fill=MUTED)
|
|
|
|
# Footer
|
|
footer_y = H - 50
|
|
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
|
|
footer_y, W, font(FONT_REG, 17), MUTED)
|
|
|
|
fname = f"quote_{idx + 1}_{q['slug']}_twitter.png"
|
|
img.save(os.path.join(OUT_DIR, fname), quality=95)
|
|
print(f"Created: {fname}")
|
|
|
|
|
|
# ── 3. WELCOME TO THE SHOW ──────────────────────────────────────────
|
|
|
|
def make_welcome_square():
|
|
W = 1080
|
|
img = Image.new("RGB", (W, W), BG)
|
|
draw = ImageDraw.Draw(img)
|
|
accent_bars(draw, W, W, 8)
|
|
|
|
cx = W // 2
|
|
|
|
# Cover art — centered, large
|
|
cover_size = 280
|
|
paste_cover(img, cx - cover_size // 2, 60, cover_size, 24)
|
|
|
|
y = 60 + cover_size + 40
|
|
|
|
# Title
|
|
y = wrap_text_centered(draw, "WELCOME TO THE SHOW", cx, y, W - 120,
|
|
font(FONT_BLACK, 64), WHITE, line_gap=12)
|
|
y += 20
|
|
|
|
# Divider
|
|
draw.rectangle([cx - 60, y, cx + 60, y + 4], fill=ACCENT)
|
|
y += 30
|
|
|
|
# Description
|
|
desc = "Late-night call-in radio powered entirely by AI. Real conversations with AI callers about life, love, and everything in between."
|
|
y = wrap_text_centered(draw, desc, cx, y, W - 140,
|
|
font(FONT_REG, 30), LIGHTER, line_gap=12)
|
|
y += 30
|
|
|
|
# Features
|
|
features = [
|
|
"New episodes daily",
|
|
"AI-generated callers with real personalities",
|
|
"Unscripted. Unfiltered. Unpredictable.",
|
|
]
|
|
for feat in features:
|
|
line = f"\u2022 {feat}"
|
|
tw, th = text_size(draw, line, font(FONT_REG, 26))
|
|
draw.text((cx - tw // 2, y), line, font=font(FONT_REG, 26), fill=MUTED)
|
|
y += th + 14
|
|
|
|
y += 20
|
|
|
|
# CTA
|
|
cta = "FOLLOW @LUKEATTHEROOST"
|
|
cta_font = font(FONT_BOLD, 32)
|
|
tw, th = text_size(draw, cta, cta_font)
|
|
px, py = 28, 16
|
|
box_w = tw + px * 2
|
|
box_h = th + py * 2
|
|
box_x = cx - box_w // 2
|
|
draw.rounded_rectangle([box_x, y, box_x + box_w, y + box_h],
|
|
radius=10, fill=ACCENT)
|
|
draw.text((box_x + px, y + py), cta, font=cta_font, fill=BG)
|
|
|
|
y += box_h + 24
|
|
|
|
# Footer
|
|
center_text(draw, "lukeattheroost.com", y, W, font(FONT_REG, 22), MUTED)
|
|
|
|
img.save(os.path.join(OUT_DIR, "welcome_to_the_show_square.png"), quality=95)
|
|
print("Created: welcome_to_the_show_square.png")
|
|
|
|
|
|
def make_welcome_landscape():
|
|
W, H = 1200, 675
|
|
img = Image.new("RGB", (W, H), BG)
|
|
draw = ImageDraw.Draw(img)
|
|
accent_bars(draw, W, H, 6)
|
|
|
|
# Cover art — left side
|
|
cover_size = 260
|
|
cover_x, cover_y = 50, (H - cover_size) // 2
|
|
paste_cover(img, cover_x, cover_y, cover_size, 20)
|
|
|
|
# Right side content
|
|
rx = cover_x + cover_size + 50
|
|
max_w = W - rx - 50
|
|
y = 60
|
|
|
|
# Title
|
|
y = wrap_text_left(draw, "WELCOME TO THE SHOW", rx, y, max_w,
|
|
font(FONT_BLACK, 48), WHITE, line_gap=10)
|
|
y += 16
|
|
|
|
# Divider
|
|
draw.rectangle([rx, y, rx + 80, y + 3], fill=ACCENT)
|
|
y += 20
|
|
|
|
# Description
|
|
desc = "Late-night call-in radio powered entirely by AI. Real conversations with AI callers about life, love, and everything in between."
|
|
y = wrap_text_left(draw, desc, rx, y, max_w,
|
|
font(FONT_REG, 22), LIGHTER, line_gap=10)
|
|
y += 20
|
|
|
|
# Features
|
|
features = [
|
|
"New episodes daily",
|
|
"AI callers with real personalities",
|
|
"Unscripted. Unfiltered. Unpredictable.",
|
|
]
|
|
for feat in features:
|
|
line = f"\u2022 {feat}"
|
|
draw.text((rx, y), line, font=font(FONT_REG, 20), fill=MUTED)
|
|
y += 32
|
|
|
|
y += 10
|
|
|
|
# CTA
|
|
cta = "FOLLOW @LUKEATTHEROOST"
|
|
cta_font = font(FONT_BOLD, 24)
|
|
tw, th = text_size(draw, cta, cta_font)
|
|
px, py = 22, 12
|
|
draw.rounded_rectangle([rx, y, rx + tw + px * 2, y + th + py * 2],
|
|
radius=8, fill=ACCENT)
|
|
draw.text((rx + px, y + py), cta, font=cta_font, fill=BG)
|
|
|
|
# Footer
|
|
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
|
|
H - 45, W, font(FONT_REG, 17), MUTED)
|
|
|
|
img.save(os.path.join(OUT_DIR, "welcome_to_the_show_twitter.png"), quality=95)
|
|
print("Created: welcome_to_the_show_twitter.png")
|
|
|
|
|
|
# ── 4. LEAVE US A REVIEW ────────────────────────────────────────────
|
|
|
|
def make_review_square():
|
|
W = 1080
|
|
img = Image.new("RGB", (W, W), BG)
|
|
draw = ImageDraw.Draw(img)
|
|
accent_bars(draw, W, W, 8)
|
|
|
|
cx = W // 2
|
|
|
|
# Cover art
|
|
cover_size = 200
|
|
paste_cover(img, cx - cover_size // 2, 55, cover_size, 20)
|
|
y = 55 + cover_size + 35
|
|
|
|
# Title
|
|
y = wrap_text_centered(draw, "LOVE THE SHOW?", cx, y, W - 120,
|
|
font(FONT_BLACK, 64), WHITE, line_gap=12)
|
|
y += 10
|
|
y = wrap_text_centered(draw, "LEAVE US A REVIEW", cx, y, W - 120,
|
|
font(FONT_BLACK, 64), ACCENT, line_gap=12)
|
|
y += 30
|
|
|
|
# Divider
|
|
draw.rectangle([cx - 50, y, cx + 50, y + 3], fill=ACCENT)
|
|
y += 30
|
|
|
|
# Body text
|
|
body = "Reviews help new listeners find the show. If Luke at the Roost has made you laugh, think, or question your life choices\u2014take 30 seconds to leave a rating."
|
|
y = wrap_text_centered(draw, body, cx, y, W - 140,
|
|
font(FONT_REG, 28), LIGHTER, line_gap=12)
|
|
y += 35
|
|
|
|
# Stars
|
|
stars = "\u2605 \u2605 \u2605 \u2605 \u2605"
|
|
center_text(draw, stars, y, W, font(FONT_REG, 52), ACCENT)
|
|
y += 70
|
|
|
|
# Platforms
|
|
platforms = ["Apple Podcasts", "Spotify", "YouTube", "Podchaser"]
|
|
for plat in platforms:
|
|
tw, th = text_size(draw, plat, font(FONT_BOLD, 26))
|
|
px, py = 30, 12
|
|
box_w = tw + px * 2
|
|
box_x = cx - box_w // 2
|
|
draw.rounded_rectangle(
|
|
[box_x, y, box_x + box_w, y + th + py * 2],
|
|
radius=8, fill=DARK_PANEL, outline=ACCENT_DIM, width=2,
|
|
)
|
|
draw.text((box_x + px, y + py), plat, font=font(FONT_BOLD, 26), fill=LIGHTER)
|
|
y += th + py * 2 + 12
|
|
|
|
# Footer
|
|
center_text(draw, "lukeattheroost.com", W - 65, W, font(FONT_REG, 20), MUTED)
|
|
|
|
img.save(os.path.join(OUT_DIR, "leave_a_review_square.png"), quality=95)
|
|
print("Created: leave_a_review_square.png")
|
|
|
|
|
|
def make_review_landscape():
|
|
W, H = 1200, 675
|
|
img = Image.new("RGB", (W, H), BG)
|
|
draw = ImageDraw.Draw(img)
|
|
accent_bars(draw, W, H, 6)
|
|
|
|
# Cover art — left
|
|
cover_size = 200
|
|
cover_x, cover_y = 50, (H - cover_size) // 2
|
|
paste_cover(img, cover_x, cover_y, cover_size, 16)
|
|
|
|
# Right content
|
|
rx = cover_x + cover_size + 50
|
|
max_w = W - rx - 50
|
|
y = 50
|
|
|
|
# Title
|
|
y = wrap_text_left(draw, "LOVE THE SHOW?", rx, y, max_w,
|
|
font(FONT_BLACK, 48), WHITE, line_gap=8)
|
|
y += 6
|
|
y = wrap_text_left(draw, "LEAVE US A REVIEW", rx, y, max_w,
|
|
font(FONT_BLACK, 48), ACCENT, line_gap=8)
|
|
y += 16
|
|
|
|
# Stars
|
|
stars = "\u2605 \u2605 \u2605 \u2605 \u2605"
|
|
draw.text((rx, y), stars, font=font(FONT_REG, 40), fill=ACCENT)
|
|
y += 55
|
|
|
|
# Body
|
|
body = "Reviews help new listeners find the show. Take 30 seconds to leave a rating\u2014it makes a huge difference."
|
|
y = wrap_text_left(draw, body, rx, y, max_w,
|
|
font(FONT_REG, 22), LIGHTER, line_gap=10)
|
|
y += 25
|
|
|
|
# Platform pills — inline
|
|
platforms = ["Apple Podcasts", "Spotify", "YouTube", "Podchaser"]
|
|
pill_x = rx
|
|
pill_font = font(FONT_BOLD, 19)
|
|
for plat in platforms:
|
|
tw, th = text_size(draw, plat, pill_font)
|
|
px, py = 16, 8
|
|
pill_w = tw + px * 2
|
|
if pill_x + pill_w > W - 50:
|
|
pill_x = rx
|
|
y += th + py * 2 + 10
|
|
draw.rounded_rectangle(
|
|
[pill_x, y, pill_x + pill_w, y + th + py * 2],
|
|
radius=6, fill=DARK_PANEL, outline=ACCENT_DIM, width=2,
|
|
)
|
|
draw.text((pill_x + px, y + py), plat, font=pill_font, fill=LIGHTER)
|
|
pill_x += pill_w + 10
|
|
|
|
# Footer
|
|
center_text(draw, "lukeattheroost.com", H - 40, W, font(FONT_REG, 17), MUTED)
|
|
|
|
img.save(os.path.join(OUT_DIR, "leave_a_review_twitter.png"), quality=95)
|
|
print("Created: leave_a_review_twitter.png")
|
|
|
|
|
|
# ── MAIN ─────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
|
|
print("\n=== X Launch Campaign Assets ===\n")
|
|
|
|
print("--- Header ---")
|
|
make_header()
|
|
|
|
print("\n--- Quote Cards ---")
|
|
for i, q in enumerate(QUOTES):
|
|
make_quote_square(q, i)
|
|
make_quote_landscape(q, i)
|
|
|
|
print("\n--- Welcome to the Show ---")
|
|
make_welcome_square()
|
|
make_welcome_landscape()
|
|
|
|
print("\n--- Leave a Review ---")
|
|
make_review_square()
|
|
make_review_landscape()
|
|
|
|
print(f"\nAll assets saved to: {OUT_DIR}/")
|
|
print(f"Total files: {len(os.listdir(OUT_DIR))}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|