TTS fixes, Inworld improvements, footer redesign, episodes 15-25, invoice script fix
- Fix TTS text pipeline: new caps handling (spell out unknown acronyms, lowercase emphasis words), action-word lookahead for parenthetical stripping, abbreviation expansions (US→United States, NM→New Mexico), pronunciation fixes - Inworld TTS: camelCase API fields, speakingRate per-voice overrides, retry logic with exponential backoff (3 attempts) - Footer redesign: SVG icons for social/podcast links across all pages - Stats page: show "Rate us on Spotify" instead of "not public" placeholder - New voices, expanded caller prompts and problem scenarios - Social posting via Postiz, YouTube upload in publish pipeline - Episode transcripts 15-25, terms page, sitemap updates - Fix invoice script: match Timing totals using merged Task+App intervals Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
271
make_social_post.py
Normal file
271
make_social_post.py
Normal file
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate social media announcement images for Luke at the Roost.
|
||||
|
||||
Usage:
|
||||
python make_social_post.py # regenerate with defaults
|
||||
python make_social_post.py --title "NEW FEATURE" # custom title
|
||||
python make_social_post.py --body body_text.txt # body from file
|
||||
|
||||
Outputs square (1080x1080) and landscape (1200x675) PNGs to social_posts/.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import textwrap
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||
|
||||
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")
|
||||
|
||||
# Brand colors
|
||||
BG = (18, 13, 7)
|
||||
ACCENT = (232, 121, 29)
|
||||
WHITE = (255, 255, 255)
|
||||
MUTED = (175, 165, 150)
|
||||
LIGHTER = (220, 215, 205)
|
||||
|
||||
# macOS system fonts — swap these on Linux/Windows
|
||||
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"
|
||||
|
||||
|
||||
def load_font(path, size):
|
||||
return ImageFont.truetype(path, size)
|
||||
|
||||
|
||||
def text_bbox(draw, text, font):
|
||||
bb = draw.textbbox((0, 0), text, font=font)
|
||||
return bb[2] - bb[0], bb[3] - bb[1], bb[1] # width, height, y_offset
|
||||
|
||||
|
||||
def wrap_text(draw, text, x, y, max_w, font, fill, line_gap=10,
|
||||
cover_right=None, cover_bottom=None):
|
||||
"""Word-wrap text onto the image, narrowing lines that overlap the cover.
|
||||
|
||||
line_gap: fixed pixel gap between lines (not a multiplier).
|
||||
Returns y just below the last line of text (no trailing gap)."""
|
||||
words = text.split()
|
||||
lines = []
|
||||
cur = ""
|
||||
cur_y = y
|
||||
|
||||
for word in words:
|
||||
test = f"{cur} {word}".strip()
|
||||
eff_w = max_w
|
||||
if cover_right and cover_bottom and cur_y < cover_bottom:
|
||||
eff_w = cover_right - x - 20
|
||||
|
||||
tw, th, _ = text_bbox(draw, test, font)
|
||||
if tw > eff_w and cur:
|
||||
lines.append((cur, cur_y))
|
||||
_, lh, _ = text_bbox(draw, cur, font)
|
||||
cur_y += lh + line_gap
|
||||
cur = word
|
||||
else:
|
||||
cur = test
|
||||
|
||||
if cur:
|
||||
lines.append((cur, cur_y))
|
||||
_, lh, _ = text_bbox(draw, cur, font)
|
||||
|
||||
for line, ly in lines:
|
||||
draw.text((x, ly), line, font=font, fill=fill)
|
||||
|
||||
return cur_y + lh # return y just past the last line's bottom
|
||||
|
||||
|
||||
def center_text(draw, text, y, canvas_w, font, fill):
|
||||
tw, th, _ = text_bbox(draw, text, font)
|
||||
draw.text(((canvas_w - tw) // 2, y), text, font=font, fill=fill)
|
||||
return y + th
|
||||
|
||||
|
||||
def draw_email_box(draw, email, y, canvas_w, font):
|
||||
tw, th, y_off = text_bbox(draw, email, font)
|
||||
px, py = 22, 16
|
||||
box_w = tw + px * 2
|
||||
box_x = (canvas_w - box_w) // 2
|
||||
draw.rounded_rectangle(
|
||||
[box_x, y, box_x + box_w, y + th + py * 2],
|
||||
radius=8, fill=(45, 30, 12), outline=ACCENT, width=2,
|
||||
)
|
||||
draw.text((box_x + px, y + py - y_off), email, font=font, fill=ACCENT)
|
||||
return y + th + py * 2
|
||||
|
||||
|
||||
def draw_accent_bars(draw, w, h, thickness):
|
||||
draw.rectangle([0, 0, w, thickness], fill=ACCENT)
|
||||
draw.rectangle([0, h - thickness, w, h], fill=ACCENT)
|
||||
|
||||
|
||||
def paste_cover(img, x, y, size, radius):
|
||||
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 make_square(title, paragraphs, email, filename="email_announcement_square.png"):
|
||||
W = 1080
|
||||
img = Image.new("RGB", (W, W), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw_accent_bars(draw, W, W, 8)
|
||||
|
||||
# Cover image — top right
|
||||
cover_size, cover_x, cover_y = 240, W - 290, 35
|
||||
paste_cover(img, cover_x, cover_y, cover_size, 20)
|
||||
cover_bottom = cover_y + cover_size + 15
|
||||
|
||||
m = 60
|
||||
y = 40
|
||||
tw_full = W - m * 2
|
||||
|
||||
# Header
|
||||
draw.text((m, y), "LUKE AT THE ROOST", font=load_font(FONT_BOLD, 24), fill=ACCENT)
|
||||
y += 30
|
||||
tag = load_font(FONT_REG, 20)
|
||||
draw.text((m, y), "Late-night call-in radio", font=tag, fill=MUTED)
|
||||
draw.text((m, y + 26), "powered by AI", font=tag, fill=MUTED)
|
||||
y += 75
|
||||
|
||||
# Consistent spacing constants
|
||||
LINE_GAP = 12 # between lines within a block
|
||||
SECTION_GAP = 32 # between sections (body→CTA, CTA→footer)
|
||||
PARA_GAP = 26 # between body paragraphs
|
||||
TITLE_GAP = 48 # between title and first body paragraph
|
||||
|
||||
# Title
|
||||
y = wrap_text(draw, title, m, y, tw_full, load_font(FONT_BLACK, 72), WHITE,
|
||||
line_gap=LINE_GAP, cover_right=cover_x, cover_bottom=cover_bottom)
|
||||
y += TITLE_GAP
|
||||
|
||||
# Body paragraphs
|
||||
body_font = load_font(FONT_REG, 32)
|
||||
colors = [LIGHTER] + [MUTED] * (len(paragraphs) - 1)
|
||||
for i, (para, color) in enumerate(zip(paragraphs, colors)):
|
||||
cr = cover_x if y < cover_bottom else None
|
||||
cb = cover_bottom if y < cover_bottom else None
|
||||
y = wrap_text(draw, para, m, y, tw_full, body_font, color,
|
||||
line_gap=LINE_GAP, cover_right=cr, cover_bottom=cb)
|
||||
if i < len(paragraphs) - 1:
|
||||
y += PARA_GAP
|
||||
|
||||
y += SECTION_GAP
|
||||
|
||||
# Email CTA
|
||||
y = draw_email_box(draw, email, y, W, load_font(FONT_BOLD, 36))
|
||||
y += SECTION_GAP
|
||||
|
||||
# Footer
|
||||
y = center_text(draw, "New episodes drop daily. Be part of the next one.",
|
||||
y, W, load_font(FONT_REG, 24), MUTED)
|
||||
y += PARA_GAP
|
||||
info = load_font(FONT_REG, 22)
|
||||
center_text(draw, "lukeattheroost.com", y, W, info, ACCENT)
|
||||
y += PARA_GAP
|
||||
center_text(draw, "Spotify \u00b7 Apple Podcasts \u00b7 YouTube \u00b7 RSS",
|
||||
y, W, info, MUTED)
|
||||
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
img.save(os.path.join(OUT_DIR, filename), quality=95)
|
||||
print(f"Square: {filename}")
|
||||
|
||||
|
||||
def make_landscape(title, paragraphs, email, filename="email_announcement_twitter.png"):
|
||||
TW, TH = 1200, 675
|
||||
img = Image.new("RGB", (TW, TH), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw_accent_bars(draw, TW, TH, 6)
|
||||
|
||||
# Cover image — top right
|
||||
cover_size, cover_x, cover_y = 180, TW - 220, 22
|
||||
paste_cover(img, cover_x, cover_y, cover_size, 16)
|
||||
cover_bottom = cover_y + cover_size + 10
|
||||
|
||||
m = 45
|
||||
y = 25
|
||||
tw_full = TW - m * 2
|
||||
|
||||
# Header
|
||||
draw.text((m, y), "LUKE AT THE ROOST", font=load_font(FONT_BOLD, 20), fill=ACCENT)
|
||||
y += 24
|
||||
draw.text((m, y), "Late-night call-in radio powered by AI",
|
||||
font=load_font(FONT_REG, 17), fill=MUTED)
|
||||
y += 38
|
||||
|
||||
# Consistent spacing constants
|
||||
LINE_GAP = 8 # between lines within a block
|
||||
SECTION_GAP = 20 # between sections
|
||||
PARA_GAP = 16 # between body paragraphs
|
||||
TITLE_GAP = 32 # between title and first body paragraph
|
||||
|
||||
# Title
|
||||
y = wrap_text(draw, title, m, y, tw_full, load_font(FONT_BLACK, 50), WHITE,
|
||||
line_gap=LINE_GAP, cover_right=cover_x, cover_bottom=cover_bottom)
|
||||
y += TITLE_GAP
|
||||
|
||||
# Body paragraphs
|
||||
body_font = load_font(FONT_REG, 23)
|
||||
colors = [LIGHTER] + [MUTED] * (len(paragraphs) - 1)
|
||||
for i, (para, color) in enumerate(zip(paragraphs, colors)):
|
||||
cr = cover_x if y < cover_bottom else None
|
||||
cb = cover_bottom if y < cover_bottom else None
|
||||
y = wrap_text(draw, para, m, y, tw_full, body_font, color,
|
||||
line_gap=LINE_GAP, cover_right=cr, cover_bottom=cb)
|
||||
if i < len(paragraphs) - 1:
|
||||
y += PARA_GAP
|
||||
|
||||
y += SECTION_GAP
|
||||
|
||||
# Email CTA
|
||||
y = draw_email_box(draw, email, y, TW, load_font(FONT_BOLD, 26))
|
||||
y += SECTION_GAP
|
||||
|
||||
# Footer
|
||||
y = center_text(draw, "New episodes drop daily. Be part of the next one.",
|
||||
y, TW, load_font(FONT_REG, 19), MUTED)
|
||||
y += PARA_GAP
|
||||
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
|
||||
y, TW, load_font(FONT_REG, 17), (140, 132, 120))
|
||||
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
img.save(os.path.join(OUT_DIR, filename), quality=95)
|
||||
print(f"Landscape: {filename}")
|
||||
|
||||
|
||||
# --- Default content ---
|
||||
|
||||
DEFAULT_TITLE = "NOW ACCEPTING LISTENER EMAILS"
|
||||
DEFAULT_EMAIL = "submissions@lukeattheroost.com"
|
||||
DEFAULT_PARAGRAPHS = [
|
||||
"Got a story? A question? A hot take that\u2019s been eating at you since midnight? A confession you need to get off your chest? Send it to the show.",
|
||||
"The best listener emails get read live on air during the next episode \u2014 either by Luke himself on the mic, or by one of his robot friends. Your words, on the show, heard by everyone tuning in.",
|
||||
"Can\u2019t call 208-439-LUKE at 2 AM? Don\u2019t want to talk on the phone? Now you\u2019ve got another way to be part of the conversation. Write in anytime \u2014 day or night, long or short, serious or unhinged.",
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate social media images")
|
||||
parser.add_argument("--title", default=DEFAULT_TITLE)
|
||||
parser.add_argument("--email", default=DEFAULT_EMAIL)
|
||||
parser.add_argument("--body", help="Text file with paragraphs (blank-line separated)")
|
||||
parser.add_argument("--prefix", default="email_announcement",
|
||||
help="Output filename prefix")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.body:
|
||||
with open(args.body) as f:
|
||||
paragraphs = [p.strip() for p in f.read().split("\n\n") if p.strip()]
|
||||
else:
|
||||
paragraphs = DEFAULT_PARAGRAPHS
|
||||
|
||||
make_square(args.title, paragraphs, args.email,
|
||||
filename=f"{args.prefix}_square.png")
|
||||
make_landscape(args.title, paragraphs, args.email,
|
||||
filename=f"{args.prefix}_twitter.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user