Track deploy, social, and Reaper scripts
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>
This commit is contained in:
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy SearXNG to the QNAP NAS (mmgnas) as Devon's always-on web-search backend.
|
||||||
|
# Public image — no build. Ships a settings.yml that enables the JSON API and
|
||||||
|
# disables the bot limiter so the backend can query format=json programmatically.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
NAS_PORT="8001"
|
||||||
|
NAS_USER="luke"
|
||||||
|
NAS_HOST="${NAS_HOST:-mmgnas}" # override with NAS_HOST=mmgnas-10g for wired
|
||||||
|
DOCKER="/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||||
|
DEPLOY_DIR="/share/CACHEDEV1_DATA/docker/searxng"
|
||||||
|
CONTAINER="searxng"
|
||||||
|
HOST_PORT="8888"
|
||||||
|
IMAGE="searxng/searxng:latest"
|
||||||
|
|
||||||
|
SSH="ssh -p $NAS_PORT $NAS_USER@$NAS_HOST"
|
||||||
|
|
||||||
|
echo "==> Ensuring deploy dir $DEPLOY_DIR"
|
||||||
|
$SSH "mkdir -p $DEPLOY_DIR"
|
||||||
|
|
||||||
|
# Generate settings.yml only if absent (preserve secret_key across redeploys)
|
||||||
|
if ! $SSH "test -f $DEPLOY_DIR/settings.yml"; then
|
||||||
|
echo "==> Writing settings.yml (first deploy)"
|
||||||
|
SECRET=$(openssl rand -hex 32)
|
||||||
|
TMP=$(mktemp)
|
||||||
|
cat > "$TMP" <<EOF
|
||||||
|
use_default_settings: true
|
||||||
|
server:
|
||||||
|
secret_key: "$SECRET"
|
||||||
|
bind_address: "0.0.0.0"
|
||||||
|
limiter: false
|
||||||
|
image_proxy: true
|
||||||
|
search:
|
||||||
|
safe_search: 0
|
||||||
|
formats:
|
||||||
|
- html
|
||||||
|
- json
|
||||||
|
EOF
|
||||||
|
scp -P "$NAS_PORT" "$TMP" "$NAS_USER@$NAS_HOST:$DEPLOY_DIR/settings.yml"
|
||||||
|
rm "$TMP"
|
||||||
|
else
|
||||||
|
echo "==> settings.yml already present — leaving it (and its secret_key) intact"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Pulling $IMAGE"
|
||||||
|
$SSH "$DOCKER pull $IMAGE"
|
||||||
|
|
||||||
|
echo "==> (Re)starting container"
|
||||||
|
$SSH "$DOCKER rm -f $CONTAINER 2>/dev/null || true"
|
||||||
|
$SSH "$DOCKER run -d --name $CONTAINER --restart unless-stopped \
|
||||||
|
-p $HOST_PORT:8080 \
|
||||||
|
-v $DEPLOY_DIR:/etc/searxng \
|
||||||
|
-e SEARXNG_BASE_URL=http://$NAS_HOST:$HOST_PORT/ \
|
||||||
|
$IMAGE"
|
||||||
|
|
||||||
|
echo "==> Verifying"
|
||||||
|
sleep 6
|
||||||
|
$SSH "$DOCKER ps --filter name=$CONTAINER --format '{{.Status}}'"
|
||||||
|
$SSH "$DOCKER logs $CONTAINER 2>&1 | tail -15"
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Download vocal-free background music from Jamendo (CC-licensed).
|
||||||
|
|
||||||
|
Targets late-night talk-radio vibe + hip-hop. Skips tracks shorter than 60s,
|
||||||
|
dedupes against existing files in music/, and appends CREDITS.txt entries.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python download_music.py # 100 tracks across all buckets
|
||||||
|
python download_music.py --count 30 # smaller batch
|
||||||
|
python download_music.py --dry-run # show what would be downloaded
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.request import urlopen, Request
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
CLIENT_ID = os.getenv("JAMENDO_CLIENT_ID")
|
||||||
|
if not CLIENT_ID:
|
||||||
|
print("ERROR: JAMENDO_CLIENT_ID not set in .env", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
MUSIC_DIR = Path(__file__).parent / "music"
|
||||||
|
CREDITS_FILE = MUSIC_DIR / "CREDITS.txt"
|
||||||
|
|
||||||
|
# (tag_query, genre_label, target_count) — hip-hop weighted heaviest per user pref
|
||||||
|
BUCKETS = [
|
||||||
|
("hiphop+instrumental", "Hip-Hop", 40),
|
||||||
|
("jazz", "Jazz", 20),
|
||||||
|
("lofi", "Lo-Fi", 15),
|
||||||
|
("funk", "Funk", 15),
|
||||||
|
("soul", "Soul", 10),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Filenames already on disk — skip duplicates by (artist, title) signature
|
||||||
|
def _existing_signatures() -> set[str]:
|
||||||
|
sigs = set()
|
||||||
|
for f in MUSIC_DIR.glob("*.mp3"):
|
||||||
|
# "Artist - Title [Genre].mp3" or "Artist - Title.mp3"
|
||||||
|
stem = f.stem
|
||||||
|
stem = re.sub(r"\s*\[[^\]]+\]\s*$", "", stem)
|
||||||
|
sigs.add(stem.lower().strip())
|
||||||
|
for f in MUSIC_DIR.glob("*.wav"):
|
||||||
|
sigs.add(f.stem.lower().strip())
|
||||||
|
return sigs
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(s: str) -> str:
|
||||||
|
s = s.replace("/", "-").replace("\\", "-")
|
||||||
|
s = re.sub(r'[<>:"|?*]', "", s)
|
||||||
|
return s.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_jamendo_page(tag_query: str, offset: int, limit: int = 50) -> list[dict]:
|
||||||
|
params = {
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"format": "json",
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"vocalinstrumental": "instrumental",
|
||||||
|
"fuzzytags": tag_query,
|
||||||
|
"audioformat": "mp32",
|
||||||
|
"include": "musicinfo+licenses",
|
||||||
|
"audiodlallowed": "true",
|
||||||
|
"ccnd": "true", # allow non-derivative (we won't modify)
|
||||||
|
"order": "popularity_total",
|
||||||
|
}
|
||||||
|
url = "https://api.jamendo.com/v3.0/tracks/?" + urlencode(params)
|
||||||
|
with urlopen(Request(url, headers={"User-Agent": "ai-podcast-music-fetcher/1.0"}), timeout=30) as r:
|
||||||
|
import json
|
||||||
|
data = json.load(r)
|
||||||
|
if data.get("headers", {}).get("status") != "success":
|
||||||
|
print(f" API error: {data.get('headers', {}).get('error_message')}")
|
||||||
|
return []
|
||||||
|
return data.get("results", [])
|
||||||
|
|
||||||
|
|
||||||
|
def _download(url: str, dest: Path) -> bool:
|
||||||
|
try:
|
||||||
|
req = Request(url, headers={"User-Agent": "ai-podcast-music-fetcher/1.0"})
|
||||||
|
with urlopen(req, timeout=120) as r, open(dest, "wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = r.read(64 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out.write(chunk)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" download failed: {e}")
|
||||||
|
if dest.exists():
|
||||||
|
dest.unlink()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_bucket(tag_query: str, genre_label: str, target: int, existing: set[str], dry_run: bool) -> list[tuple[Path, dict]]:
|
||||||
|
"""Returns list of (path, track_info) successfully downloaded."""
|
||||||
|
print(f"\n=== {genre_label} (target {target}) ===")
|
||||||
|
downloaded: list[tuple[Path, dict]] = []
|
||||||
|
offset = 0
|
||||||
|
seen_ids = set()
|
||||||
|
while len(downloaded) < target and offset < 500: # cap pagination
|
||||||
|
page = _fetch_jamendo_page(tag_query, offset)
|
||||||
|
if not page:
|
||||||
|
break
|
||||||
|
offset += len(page)
|
||||||
|
for track in page:
|
||||||
|
if len(downloaded) >= target:
|
||||||
|
break
|
||||||
|
tid = track.get("id")
|
||||||
|
if tid in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(tid)
|
||||||
|
|
||||||
|
if track.get("duration", 0) < 60:
|
||||||
|
continue
|
||||||
|
if not track.get("audiodownload_allowed"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
artist = _sanitize(track.get("artist_name", "Unknown"))
|
||||||
|
name = _sanitize(track.get("name", "Untitled"))
|
||||||
|
sig = f"{artist} - {name}".lower().strip()
|
||||||
|
if sig in existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filename = f"{artist} - {name} [{genre_label}].mp3"
|
||||||
|
dest = MUSIC_DIR / filename
|
||||||
|
|
||||||
|
audio_url = track.get("audiodownload") or track.get("audio")
|
||||||
|
if not audio_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f" [DRY] {filename} ({track.get('duration')}s)")
|
||||||
|
downloaded.append((dest, track))
|
||||||
|
existing.add(sig)
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" ↓ {filename} ({track.get('duration')}s)")
|
||||||
|
if _download(audio_url, dest):
|
||||||
|
downloaded.append((dest, track))
|
||||||
|
existing.add(sig)
|
||||||
|
time.sleep(0.5) # be polite
|
||||||
|
if len(page) < 50:
|
||||||
|
break
|
||||||
|
return downloaded
|
||||||
|
|
||||||
|
|
||||||
|
def append_credits(entries: list[tuple[Path, dict]]):
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
with open(CREDITS_FILE, "a") as f:
|
||||||
|
f.write(f"\n# Added {time.strftime('%Y-%m-%d')} — vocal-free batch via Jamendo API\n")
|
||||||
|
for dest, track in entries:
|
||||||
|
license_url = track.get("license_ccurl", "")
|
||||||
|
share_url = track.get("shareurl", "")
|
||||||
|
artist = track.get("artist_name", "")
|
||||||
|
name = track.get("name", "")
|
||||||
|
f.write(f"{dest.name} | {artist} - {name} | {license_url} | {share_url}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--count", type=int, default=100, help="Total tracks (default 100)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
MUSIC_DIR.mkdir(exist_ok=True)
|
||||||
|
existing = _existing_signatures()
|
||||||
|
print(f"Existing tracks: {len(existing)}")
|
||||||
|
|
||||||
|
# Scale bucket targets proportionally to --count
|
||||||
|
scale = args.count / sum(b[2] for b in BUCKETS)
|
||||||
|
all_new: list[tuple[Path, dict]] = []
|
||||||
|
for tag, label, weight in BUCKETS:
|
||||||
|
target = max(1, round(weight * scale))
|
||||||
|
all_new.extend(fetch_bucket(tag, label, target, existing, args.dry_run))
|
||||||
|
|
||||||
|
print(f"\n=== Done. {len(all_new)} new tracks {'planned' if args.dry_run else 'downloaded'}. ===")
|
||||||
|
if not args.dry_run:
|
||||||
|
append_credits(all_new)
|
||||||
|
print(f"CREDITS.txt updated.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate 1000 downloads milestone images using Nano Banana 2 (Gemini Flash Image)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
# Load .env manually
|
||||||
|
env_path = Path(__file__).parent / ".env"
|
||||||
|
if env_path.exists():
|
||||||
|
for line in env_path.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
os.environ.setdefault(key.strip(), value.strip())
|
||||||
|
|
||||||
|
client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY"))
|
||||||
|
MODEL = "gemini-3.1-flash-image-preview"
|
||||||
|
OUTPUT_DIR = Path("social_posts/1000_milestone")
|
||||||
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_image(prompt: str, filename: str, aspect_ratio: str = "1:1"):
|
||||||
|
print(f"Generating {filename}...")
|
||||||
|
response = client.models.generate_content(
|
||||||
|
model=MODEL,
|
||||||
|
contents=[prompt],
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
response_modalities=["TEXT", "IMAGE"],
|
||||||
|
image_config=types.ImageConfig(
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size="2K",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for part in response.parts:
|
||||||
|
if part.inline_data is not None:
|
||||||
|
image = part.as_image()
|
||||||
|
path = OUTPUT_DIR / filename
|
||||||
|
image.save(str(path))
|
||||||
|
print(f" Saved: {path}")
|
||||||
|
return
|
||||||
|
print(f" WARNING: No image generated for {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
STYLE_BASE = (
|
||||||
|
"Professional podcast promotional graphic. Dark navy/black background with subtle "
|
||||||
|
"warm amber and gold accent lighting, evoking a late-night radio studio atmosphere. "
|
||||||
|
"Clean modern typography. No photorealistic people. Subtle microphone and radio wave "
|
||||||
|
"design elements. Polished, minimal, high contrast."
|
||||||
|
)
|
||||||
|
|
||||||
|
images = [
|
||||||
|
{
|
||||||
|
"filename": "main_milestone_square.png",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Large bold glowing text '1,000' as the hero element in the center, with "
|
||||||
|
"'DOWNLOADS' directly below it in a thinner font. Below that in smaller text: "
|
||||||
|
"'27 episodes · 200+ callers · 1 month'. "
|
||||||
|
"At the top: 'LUKE AT THE ROOST' in elegant lettering. "
|
||||||
|
"Subtle golden microphone icon above the title. "
|
||||||
|
"At the bottom: 'lukeattheroost.com' in small clean text. "
|
||||||
|
"The overall feel is celebratory but classy, like a late-night milestone announcement."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "main_milestone_twitter.png",
|
||||||
|
"aspect_ratio": "16:9",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Wide banner format. Left side has 'LUKE AT THE ROOST' title with a subtle "
|
||||||
|
"microphone graphic. Right side has large bold glowing '1,000 DOWNLOADS' text "
|
||||||
|
"with '27 episodes · 200+ callers · 1 month' below. "
|
||||||
|
"Warm amber glow connecting the two sides. "
|
||||||
|
"Bottom right corner: 'lukeattheroost.com'. "
|
||||||
|
"Designed as a Twitter/X post image."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "carousel_1_downloads.png",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Instagram carousel slide 1. Giant bold text '1,000' taking up most of the frame, "
|
||||||
|
"with 'DOWNLOADS' below. Subtle radio wave ripples emanating from the numbers. "
|
||||||
|
"'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||||
|
"Small '1/5' page indicator dots at the bottom."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "carousel_2_episodes.png",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Instagram carousel slide 2. Large bold text '27' in the center with "
|
||||||
|
"'EPISODES' below. A subtle audio waveform timeline graphic running horizontally "
|
||||||
|
"behind the number. 'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||||
|
"Small '2/5' page indicator dots at the bottom."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "carousel_3_callers.png",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Instagram carousel slide 3. Large bold text '200+' in the center with "
|
||||||
|
"'CALLERS' below. Subtle vintage telephone handset icon above the number. "
|
||||||
|
"'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||||
|
"Small '3/5' page indicator dots at the bottom."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "carousel_4_regulars.png",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Instagram carousel slide 4. Large bold text '13' in the center with "
|
||||||
|
"'RETURNING REGULARS' below. Subtle connected dots/nodes graphic suggesting "
|
||||||
|
"a network of recurring characters. "
|
||||||
|
"'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||||
|
"Small '4/5' page indicator dots at the bottom."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "carousel_5_thankyou.png",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Instagram carousel slide 5. Warm, heartfelt tone. Large elegant text "
|
||||||
|
"'THANK YOU' in the center with a soft golden glow. Below: "
|
||||||
|
"'lukeattheroost.com' and '208-439-LUKE' in clean small text. "
|
||||||
|
"'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||||
|
"Small '5/5' page indicator dots at the bottom. "
|
||||||
|
"Slightly warmer color temperature than the other slides to feel like a closing moment."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for img in images:
|
||||||
|
try:
|
||||||
|
generate_image(img["prompt"], img["filename"], img["aspect_ratio"])
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR generating {img['filename']}: {e}")
|
||||||
|
print(f"\nDone! Images saved to {OUTPUT_DIR}/")
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
#!/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()
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Post 1000 downloads milestone to all social platforms via Postiz."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Load .env
|
||||||
|
env_path = Path(__file__).parent / ".env"
|
||||||
|
if env_path.exists():
|
||||||
|
for line in env_path.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
os.environ.setdefault(key.strip(), value.strip())
|
||||||
|
|
||||||
|
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
|
||||||
|
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
|
||||||
|
|
||||||
|
IMAGE_PATH = Path(__file__).parent / "social_posts" / "1000_milestone" / "main_1000_celebration.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_url(path: str) -> str:
|
||||||
|
return f"{POSTIZ_URL.rstrip('/')}/api/public/v1{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def api_headers() -> dict:
|
||||||
|
return {"Authorization": POSTIZ_API_KEY, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_integrations() -> list[dict]:
|
||||||
|
resp = requests.get(get_api_url("/integrations"), headers=api_headers(), timeout=15)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"Error fetching integrations: {resp.status_code} {resp.text[:200]}")
|
||||||
|
sys.exit(1)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def find_integration(integrations: list[dict], provider: str) -> dict | None:
|
||||||
|
for integ in integrations:
|
||||||
|
if integ.get("identifier", "").startswith(provider) and not integ.get("disabled"):
|
||||||
|
return integ
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(file_path: Path) -> dict:
|
||||||
|
headers = {"Authorization": POSTIZ_API_KEY}
|
||||||
|
mime = "image/jpeg" if file_path.suffix.lower() in (".jpg", ".jpeg") else "image/png"
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
resp = requests.post(
|
||||||
|
get_api_url("/upload"),
|
||||||
|
headers=headers,
|
||||||
|
files={"file": (file_path.name, f, mime)},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
print(f"Upload failed: {resp.status_code} {resp.text[:200]}")
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def create_post(integration_id: str, content: str, media: dict, settings: dict) -> dict:
|
||||||
|
date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
payload = {
|
||||||
|
"type": "now",
|
||||||
|
"date": date,
|
||||||
|
"shortLink": False,
|
||||||
|
"tags": [],
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"integration": {"id": integration_id},
|
||||||
|
"value": [{"content": content, "image": [media] if media else []}],
|
||||||
|
"settings": settings,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
resp = requests.post(get_api_url("/posts"), headers=api_headers(), json=payload, timeout=30)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
print(f" Post failed: {resp.status_code} {resp.text[:300]}")
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Post content per platform ---
|
||||||
|
|
||||||
|
POSTS = {
|
||||||
|
"instagram": {
|
||||||
|
"content": """1,000 downloads in one month. 🎙️
|
||||||
|
|
||||||
|
27 episodes. 200+ callers. 111 unique characters. 13 returning regulars.
|
||||||
|
|
||||||
|
Luke at the Roost is a late-night call-in show where AI-generated characters phone in with their problems — relationship drama, moral dilemmas, conspiracy theories, drunk confessions — and I give them real advice, live.
|
||||||
|
|
||||||
|
Every caller has a unique voice, a backstory, and a reason for calling. Some of them keep calling back.
|
||||||
|
|
||||||
|
Thank you to everyone who's tuned in. This thing was supposed to be a weird experiment. Now it's a weird experiment that 1,000 people have listened to.
|
||||||
|
|
||||||
|
New episodes daily. Link in bio.
|
||||||
|
|
||||||
|
#podcast #ai #artificialintelligence #sideproject #indieproject #podcastlife #latenightradio #callinshow #milestone #1000downloads""",
|
||||||
|
"settings": {"__type": "instagram", "post_type": "post", "collaborators": []},
|
||||||
|
},
|
||||||
|
"facebook": {
|
||||||
|
"content": """🎙️ MILESTONE: 1,000 Downloads
|
||||||
|
|
||||||
|
One month ago I launched a weird experiment — a late-night call-in radio show where AI-generated characters phone in with their problems, and I give them real advice.
|
||||||
|
|
||||||
|
27 episodes later, 200+ callers have phoned in. Some of them keep calling back. Listeners have favorites. People genuinely care about what happens to these characters.
|
||||||
|
|
||||||
|
Thank you to every single person who gave this a listen. It started as a side project and it's become something I look forward to every day.
|
||||||
|
|
||||||
|
Listen free: lukeattheroost.com
|
||||||
|
Call in for real: 208-439-LUKE""",
|
||||||
|
"settings": {"__type": "facebook"},
|
||||||
|
},
|
||||||
|
"threads": {
|
||||||
|
"content": """1,000 downloads in one month. 🎙️
|
||||||
|
|
||||||
|
27 episodes. 200+ callers. 111 unique characters. 13 returning regulars.
|
||||||
|
|
||||||
|
Luke at the Roost is a late-night call-in show where AI-generated characters phone in with their problems and I give them real advice, live.
|
||||||
|
|
||||||
|
Thank you to everyone who's tuned in. This weird experiment just hit a milestone.
|
||||||
|
|
||||||
|
lukeattheroost.com
|
||||||
|
|
||||||
|
#podcast #ai #sideproject #1000downloads""",
|
||||||
|
"settings": {"__type": "threads"},
|
||||||
|
},
|
||||||
|
"linkedin": {
|
||||||
|
"content": """1,000 downloads in 30 days — here's what I learned building an AI radio show
|
||||||
|
|
||||||
|
A month ago I launched Luke at the Roost, a late-night call-in radio show where every caller is an AI-generated character. I'm the host. They phone in with problems. I give them advice. Every conversation is improvised.
|
||||||
|
|
||||||
|
27 episodes and 200+ callers later, the show just hit 1,000 downloads.
|
||||||
|
|
||||||
|
Some things that surprised me:
|
||||||
|
|
||||||
|
People connect with AI characters. Listeners have favorites. They ask about regulars by name. When a caller's story evolves across episodes, people notice and care. The characters aren't real, but the emotional engagement is.
|
||||||
|
|
||||||
|
Constraints drive creativity. Each caller gets a token budget based on their personality type. Emotional callers get more room to ramble. Gossip callers are quick and punchy. This artificial constraint mirrors how real people actually talk — and it makes every call feel distinct.
|
||||||
|
|
||||||
|
The tech is the easy part. LLMs, voice synthesis, audio routing — that's engineering. The hard part is being a good host. Knowing when to push, when to listen, when to make a joke. AI handles the callers. The human skill is the conversation.
|
||||||
|
|
||||||
|
The full technical breakdown: lukeattheroost.com/how-it-works
|
||||||
|
Listen: lukeattheroost.com
|
||||||
|
|
||||||
|
Thank you to everyone who gave this weird experiment a chance.""",
|
||||||
|
"settings": {"__type": "linkedin"},
|
||||||
|
},
|
||||||
|
"mastodon": {
|
||||||
|
"content": """1,000 downloads. 27 episodes. 200+ AI callers given advice on everything from breakups to fish consciousness.
|
||||||
|
|
||||||
|
Luke at the Roost hit a milestone today and I just want to say thank you to everyone who's been listening.
|
||||||
|
|
||||||
|
This whole thing is self-hosted end-to-end — Castopod on a QNAP NAS, Cloudflare CDN, custom Python pipeline for recording, post-production, and publishing. No big platforms in the loop.
|
||||||
|
|
||||||
|
If you haven't heard it: it's a late-night call-in show. AI characters phone in. I talk to them live. It's improvised, weird, and somehow heartfelt.
|
||||||
|
|
||||||
|
https://lukeattheroost.com""",
|
||||||
|
"settings": {"__type": "mastodon"},
|
||||||
|
},
|
||||||
|
"tiktok": {
|
||||||
|
"content": """1,000 downloads in one month 🎙️
|
||||||
|
|
||||||
|
27 episodes. 200+ AI callers. 13 returning regulars.
|
||||||
|
|
||||||
|
Luke at the Roost — a late-night call-in show where AI characters phone in with their problems and I give them real advice, live.
|
||||||
|
|
||||||
|
Thank you to everyone listening.
|
||||||
|
|
||||||
|
#podcast #ai #artificialintelligence #sideproject #latenightradio #callinshow #1000downloads""",
|
||||||
|
"settings": {
|
||||||
|
"__type": "tiktok",
|
||||||
|
"privacy_level": "PUBLIC_TO_EVERYONE",
|
||||||
|
"duet": False,
|
||||||
|
"stitch": False,
|
||||||
|
"comment": True,
|
||||||
|
"autoAddMusic": "no",
|
||||||
|
"brand_content_toggle": False,
|
||||||
|
"brand_organic_toggle": False,
|
||||||
|
"content_posting_method": "DIRECT_POST",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"nostr": {
|
||||||
|
"content": """1,000 downloads. 27 episodes. 200+ AI callers given advice.
|
||||||
|
|
||||||
|
Luke at the Roost just hit a milestone. Thank you to everyone listening.
|
||||||
|
|
||||||
|
It's a late-night call-in show where AI-generated characters phone in with their problems and I give them real advice, live. Every conversation is improvised.
|
||||||
|
|
||||||
|
https://lukeattheroost.com""",
|
||||||
|
"settings": {"__type": "nostr"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
dry_run = "--dry-run" in sys.argv
|
||||||
|
|
||||||
|
if not POSTIZ_API_KEY:
|
||||||
|
print("Error: POSTIZ_API_KEY not set")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not IMAGE_PATH.exists():
|
||||||
|
print(f"Error: Image not found at {IMAGE_PATH}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Fetching connected accounts from Postiz...")
|
||||||
|
integrations = fetch_integrations()
|
||||||
|
|
||||||
|
available = {}
|
||||||
|
for platform in POSTS:
|
||||||
|
integ = find_integration(integrations, platform)
|
||||||
|
if integ:
|
||||||
|
available[platform] = integ
|
||||||
|
print(f" ✓ {platform}: {integ.get('name', 'connected')}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ {platform}: not connected, skipping")
|
||||||
|
|
||||||
|
if not available:
|
||||||
|
print("\nNo platforms available!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\nWill post to {len(available)} platform(s) with image: {IMAGE_PATH.name}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print("\n--- DRY RUN ---")
|
||||||
|
for platform in available:
|
||||||
|
print(f"\n[{platform.upper()}]")
|
||||||
|
print(POSTS[platform]["content"][:200] + "...")
|
||||||
|
print("\nDry run complete — nothing posted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Upload image once
|
||||||
|
print(f"\nUploading image...")
|
||||||
|
media = upload_image(IMAGE_PATH)
|
||||||
|
if not media:
|
||||||
|
print("Failed to upload image, aborting")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f" Uploaded: {media.get('path', 'ok')}")
|
||||||
|
|
||||||
|
# Post to each platform
|
||||||
|
results = {}
|
||||||
|
for platform, integ in available.items():
|
||||||
|
post_data = POSTS[platform]
|
||||||
|
print(f"\nPosting to {platform}...")
|
||||||
|
result = create_post(integ["id"], post_data["content"], media, post_data["settings"])
|
||||||
|
if result:
|
||||||
|
print(f" ✓ {platform}: Posted!")
|
||||||
|
results[platform] = True
|
||||||
|
else:
|
||||||
|
print(f" ✗ {platform}: Failed")
|
||||||
|
results[platform] = False
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
succeeded = [p for p, ok in results.items() if ok]
|
||||||
|
failed = [p for p, ok in results.items() if not ok]
|
||||||
|
print(f"\n{'='*40}")
|
||||||
|
print(f"Posted to {len(succeeded)}/{len(results)} platforms")
|
||||||
|
if succeeded:
|
||||||
|
print(f" ✓ {', '.join(succeeded)}")
|
||||||
|
if failed:
|
||||||
|
print(f" ✗ {', '.join(failed)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
-- Bleep Selection — censor a time range on the selected track(s)
|
||||||
|
--
|
||||||
|
-- Replaces the audio inside the time selection with a 1kHz tone rather than
|
||||||
|
-- muting it. Muted regions read as silence to strip_silence_dialog.lua
|
||||||
|
-- (SILENCE_DB -30, thresholds 5-6s), so a muted bleep over a slowly-read phone
|
||||||
|
-- number can get stripped and shift everything after it out of sync. A tone at
|
||||||
|
-- TONE_GAIN_DB is nowhere near -30, so the silence pass leaves it alone.
|
||||||
|
--
|
||||||
|
-- Usage: make a time selection over the digits, select the track, run.
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
-- SETTINGS
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
local TONE_GAIN_DB = 0.0 -- adjust bleep level; file is generated at -15 dBFS
|
||||||
|
local FADE_MS = 4.0 -- fade in/out on the tone, prevents clicks
|
||||||
|
local TONE_FILE = "bleep_1khz.wav" -- 60s 1kHz sine @48k, sits next to this script
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local EPS = 1e-9
|
||||||
|
|
||||||
|
local function script_dir()
|
||||||
|
local src = debug.getinfo(1, "S").source
|
||||||
|
return src:match("@?(.*[/\\])") or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local function items_on(track)
|
||||||
|
local t = {}
|
||||||
|
for i = 0, reaper.CountTrackMediaItems(track) - 1 do
|
||||||
|
t[#t + 1] = reaper.GetTrackMediaItem(track, i)
|
||||||
|
end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
local function item_bounds(item)
|
||||||
|
local pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
|
||||||
|
return pos, pos + reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Split every item crossing either edge so the range is cleanly separable
|
||||||
|
local function split_at_edges(track, sel_start, sel_end)
|
||||||
|
for _, item in ipairs(items_on(track)) do
|
||||||
|
local pos, fin = item_bounds(item)
|
||||||
|
if fin > sel_start + EPS and pos < sel_end - EPS then
|
||||||
|
local right = item
|
||||||
|
if pos < sel_start - EPS then
|
||||||
|
right = reaper.SplitMediaItem(item, sel_start)
|
||||||
|
end
|
||||||
|
if right then
|
||||||
|
local rpos, rfin = item_bounds(right)
|
||||||
|
if rfin > sel_end + EPS and rpos < sel_end - EPS then
|
||||||
|
reaper.SplitMediaItem(right, sel_end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function delete_inside(track, sel_start, sel_end)
|
||||||
|
local removed = 0
|
||||||
|
for _, item in ipairs(items_on(track)) do
|
||||||
|
local pos, fin = item_bounds(item)
|
||||||
|
if pos >= sel_start - EPS and fin <= sel_end + EPS then
|
||||||
|
reaper.DeleteTrackMediaItem(track, item)
|
||||||
|
removed = removed + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return removed
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Build the item directly rather than via InsertMedia(): InsertMedia behaves
|
||||||
|
-- like the user-facing "insert media file" action — it obeys ripple editing
|
||||||
|
-- (shifting other tracks), can spawn a new track, and moves the edit cursor.
|
||||||
|
-- AddMediaItemToTrack touches nothing but this track.
|
||||||
|
local function insert_tone(track, sel_start, sel_len, tone_source)
|
||||||
|
local item = reaper.AddMediaItemToTrack(track)
|
||||||
|
if not item then return nil end
|
||||||
|
local take = reaper.AddTakeToMediaItem(item)
|
||||||
|
if not take then return nil end
|
||||||
|
reaper.SetMediaItemTake_Source(take, tone_source)
|
||||||
|
|
||||||
|
reaper.SetMediaItemInfo_Value(item, "D_POSITION", sel_start)
|
||||||
|
reaper.SetMediaItemInfo_Value(item, "D_LENGTH", sel_len)
|
||||||
|
reaper.SetMediaItemInfo_Value(item, "B_LOOPSRC", 0)
|
||||||
|
reaper.SetMediaItemInfo_Value(item, "D_VOL", 10 ^ (TONE_GAIN_DB / 20))
|
||||||
|
|
||||||
|
local fade = math.min(FADE_MS / 1000, sel_len / 2)
|
||||||
|
reaper.SetMediaItemInfo_Value(item, "D_FADEINLEN", fade)
|
||||||
|
reaper.SetMediaItemInfo_Value(item, "D_FADEOUTLEN", fade)
|
||||||
|
return item
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function main()
|
||||||
|
local sel_start, sel_end = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false)
|
||||||
|
local sel_len = sel_end - sel_start
|
||||||
|
if sel_len <= 0 then
|
||||||
|
reaper.ShowMessageBox("Make a time selection over the audio to bleep.", "Bleep Selection", 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local n_tracks = reaper.CountSelectedTracks(0)
|
||||||
|
if n_tracks == 0 then
|
||||||
|
reaper.ShowMessageBox("Select the track to bleep.", "Bleep Selection", 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local tone_path = script_dir() .. TONE_FILE
|
||||||
|
local f = io.open(tone_path, "rb")
|
||||||
|
if not f then
|
||||||
|
reaper.ShowMessageBox("Tone file not found:\n" .. tone_path, "Bleep Selection", 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
f:close()
|
||||||
|
|
||||||
|
local tone_source = reaper.PCM_Source_CreateFromFile(tone_path)
|
||||||
|
if not tone_source then
|
||||||
|
reaper.ShowMessageBox("Could not load tone file:\n" .. tone_path, "Bleep Selection", 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local targets = {}
|
||||||
|
for i = 0, n_tracks - 1 do
|
||||||
|
targets[#targets + 1] = reaper.GetSelectedTrack(0, i)
|
||||||
|
end
|
||||||
|
|
||||||
|
reaper.Undo_BeginBlock()
|
||||||
|
reaper.PreventUIRefresh(1)
|
||||||
|
|
||||||
|
-- Ripple editing would shift unrelated items (and other tracks) when items
|
||||||
|
-- are removed. Force it off for the duration, restore the user's mode after.
|
||||||
|
local ripple_per_track = reaper.GetToggleCommandStateEx(0, 40310) == 1
|
||||||
|
local ripple_all = reaper.GetToggleCommandStateEx(0, 40311) == 1
|
||||||
|
if ripple_per_track or ripple_all then
|
||||||
|
reaper.Main_OnCommand(40309, 0) -- ripple editing off
|
||||||
|
end
|
||||||
|
|
||||||
|
local bleeped = 0
|
||||||
|
for _, track in ipairs(targets) do
|
||||||
|
split_at_edges(track, sel_start, sel_end)
|
||||||
|
delete_inside(track, sel_start, sel_end)
|
||||||
|
if insert_tone(track, sel_start, sel_len, tone_source) then
|
||||||
|
bleeped = bleeped + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if ripple_all then
|
||||||
|
reaper.Main_OnCommand(40311, 0)
|
||||||
|
elseif ripple_per_track then
|
||||||
|
reaper.Main_OnCommand(40310, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
reaper.PreventUIRefresh(-1)
|
||||||
|
reaper.UpdateArrange()
|
||||||
|
reaper.Undo_EndBlock(string.format("Bleep %.2fs on %d track(s)", sel_len, bleeped), -1)
|
||||||
|
end
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Schedule the X/Twitter launch campaign posts via Postiz.
|
||||||
|
|
||||||
|
Schedules 2 weeks of posts from the growth strategy to @lukeattheroost.
|
||||||
|
All times are ET, converted to UTC for the Postiz API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python schedule_x_launch.py # schedule all posts
|
||||||
|
python schedule_x_launch.py --dry-run # preview without scheduling
|
||||||
|
python schedule_x_launch.py --week 1 # schedule week 1 only
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Load .env
|
||||||
|
env_path = Path(__file__).parent / ".env"
|
||||||
|
if env_path.exists():
|
||||||
|
for line in env_path.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
os.environ.setdefault(key.strip(), value.strip())
|
||||||
|
|
||||||
|
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
|
||||||
|
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
|
X_INTEGRATION_ID = "cmlk4hi880001k76wbqjo21s0"
|
||||||
|
|
||||||
|
# ET = UTC-4 (EDT) in March 2026
|
||||||
|
ET_OFFSET = timedelta(hours=-4)
|
||||||
|
|
||||||
|
|
||||||
|
def et_to_utc(year, month, day, hour, minute=0):
|
||||||
|
"""Convert ET datetime to UTC ISO string for Postiz."""
|
||||||
|
et = datetime(year, month, day, hour, minute, tzinfo=timezone(ET_OFFSET))
|
||||||
|
utc = et.astimezone(timezone.utc)
|
||||||
|
return utc.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_url(path):
|
||||||
|
return f"{POSTIZ_URL.rstrip('/')}/api/public/v1{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def api_headers():
|
||||||
|
return {"Authorization": POSTIZ_API_KEY, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(file_path):
|
||||||
|
headers = {"Authorization": POSTIZ_API_KEY}
|
||||||
|
suffix = file_path.suffix.lower()
|
||||||
|
if suffix == ".mp4":
|
||||||
|
mime = "video/mp4"
|
||||||
|
elif suffix in (".jpg", ".jpeg"):
|
||||||
|
mime = "image/jpeg"
|
||||||
|
else:
|
||||||
|
mime = "image/png"
|
||||||
|
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
resp = requests.post(
|
||||||
|
get_api_url("/upload"),
|
||||||
|
headers=headers,
|
||||||
|
files={"file": (file_path.name, f, mime)},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
print(f" Upload failed: {resp.status_code} {resp.text[:200]}")
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_post(content, media, schedule_time, retries=3):
|
||||||
|
payload = {
|
||||||
|
"type": "schedule",
|
||||||
|
"date": schedule_time,
|
||||||
|
"shortLink": False,
|
||||||
|
"tags": [],
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"integration": {"id": X_INTEGRATION_ID},
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"content": content,
|
||||||
|
"image": [media] if media else [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {"__type": "x", "who_can_reply_post": "everyone"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for attempt in range(retries):
|
||||||
|
resp = requests.post(
|
||||||
|
get_api_url("/posts"),
|
||||||
|
headers=api_headers(),
|
||||||
|
json=payload,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
return resp.json()
|
||||||
|
if resp.status_code == 429 and attempt < retries - 1:
|
||||||
|
wait = 15 * (attempt + 1)
|
||||||
|
print(f"(rate limited, waiting {wait}s)...", end=" ", flush=True)
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
print(f" Schedule failed: {resp.status_code} {resp.text[:300]}")
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST DEFINITIONS ─────────────────────────────────────────────────
|
||||||
|
# Day 1 = Monday March 17, 2026
|
||||||
|
|
||||||
|
WEEK_1 = [
|
||||||
|
# Day 1 — Monday March 17
|
||||||
|
{
|
||||||
|
"label": "W1-Mon-AM (pinned intro)",
|
||||||
|
"time": et_to_utc(2026, 3, 17, 10),
|
||||||
|
"content": """Every caller on my show is AI-generated.
|
||||||
|
|
||||||
|
Every personality. Every voice. Every problem they call in about.
|
||||||
|
|
||||||
|
But the conversations are real, the advice is real, and the chaos is very real.
|
||||||
|
|
||||||
|
38 episodes. 200+ callers. A cult leader, a guy who opened paternity results live on air, and someone who faked cancer to skip a wedding.
|
||||||
|
|
||||||
|
This is Luke at the Roost.
|
||||||
|
|
||||||
|
📞 208-439-LUKE (real humans can call in too)
|
||||||
|
🔗 lukeattheroost.com""",
|
||||||
|
"media": "website/images/cover.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Mon-PM (chili clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 17, 14),
|
||||||
|
"content": """A guy called in to talk about chili contest cheaters.
|
||||||
|
|
||||||
|
Turns out he was really calling about his failing marriage.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast #callinshow""",
|
||||||
|
"media": "clips/episode-37/clip-1-chili-contest-cheaters-marriage-troubles.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Mon-EVE (intro thread)",
|
||||||
|
"time": et_to_utc(2026, 3, 17, 21),
|
||||||
|
"content": """People keep asking what Luke at the Roost is.
|
||||||
|
|
||||||
|
Short version:
|
||||||
|
→ AI characters call into my show with real problems
|
||||||
|
→ I give them actual advice
|
||||||
|
→ Everything goes off the rails
|
||||||
|
→ New episode every day
|
||||||
|
|
||||||
|
lukeattheroost.com""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
# Day 2 — Tuesday March 18
|
||||||
|
{
|
||||||
|
"label": "W1-Tue-AM (hospice clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 18, 12),
|
||||||
|
"content": """A caller's mom is in hospice. The nurse asked her to think about final conversations.
|
||||||
|
|
||||||
|
Her only wish? To see her kids eat cake together one last time.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-30/clip-2-mom-s-dying-wish-just-eat-cake-together.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Tue-PM (engagement)",
|
||||||
|
"time": et_to_utc(2026, 3, 18, 19),
|
||||||
|
"content": """What's the wildest thing you've ever called into a radio show about?
|
||||||
|
|
||||||
|
(Or wanted to but chickened out?)""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
# Day 3 — Wednesday March 19
|
||||||
|
{
|
||||||
|
"label": "W1-Wed-AM (cancer clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 19, 11),
|
||||||
|
"content": """This caller faked having cancer to get out of going to a wedding.
|
||||||
|
|
||||||
|
Then his friends staged a coffee enema intervention.
|
||||||
|
|
||||||
|
I can't make this up. (Well, the AI did.)
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-32/clip-1-i-faked-cancer-to-skip-a-wedding.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Wed-PM (fakes clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 19, 16),
|
||||||
|
"content": """"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."
|
||||||
|
|
||||||
|
— A caller on Episode 2. Still the hardest truth anyone's dropped on my show.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-2/clip-1-we-re-all-fakes-and-that-s-okay.mp4",
|
||||||
|
},
|
||||||
|
# Day 4 — Thursday March 20
|
||||||
|
{
|
||||||
|
"label": "W1-Thu-AM (BTS)",
|
||||||
|
"time": et_to_utc(2026, 3, 20, 12),
|
||||||
|
"content": """How my show works:
|
||||||
|
|
||||||
|
→ AI generates a caller with a full backstory, personality, and voice
|
||||||
|
→ They call in live
|
||||||
|
→ I have zero idea what they're going to say
|
||||||
|
→ I give them real advice
|
||||||
|
→ Post-production runs automatically
|
||||||
|
→ Episode publishes
|
||||||
|
|
||||||
|
38 episodes. Built the whole thing from scratch.""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Thu-PM (stakeout clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 20, 20),
|
||||||
|
"content": """He spent four hours staking out his best friend at Starbucks.
|
||||||
|
|
||||||
|
What he found was worse than what he expected.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-28/clip-3-four-hours-spying-on-his-best-friend.mp4",
|
||||||
|
},
|
||||||
|
# Day 5 — Friday March 21
|
||||||
|
{
|
||||||
|
"label": "W1-Fri-AM (review ask)",
|
||||||
|
"time": et_to_utc(2026, 3, 21, 11),
|
||||||
|
"content": """If you've listened to Luke at the Roost and liked it — a review on Apple Podcasts or Spotify goes further than you'd think.
|
||||||
|
|
||||||
|
Not guilt-tripping. Just saying it helps a one-person show more than anything else.
|
||||||
|
|
||||||
|
🔗 lukeattheroost.com""",
|
||||||
|
"media": "social_posts/x_launch/leave_a_review_twitter.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Fri-PM (wall clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 21, 18),
|
||||||
|
"content": """She opened up a mystery wall in her house LIVE on the show.
|
||||||
|
|
||||||
|
There were stacks of cash inside.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-35/clip-3-woman-finds-cash-in-secret-wall.mp4",
|
||||||
|
},
|
||||||
|
# Day 6 — Saturday March 22
|
||||||
|
{
|
||||||
|
"label": "W1-Sat-AM (poll)",
|
||||||
|
"time": et_to_utc(2026, 3, 22, 12),
|
||||||
|
"content": """Which is wilder?
|
||||||
|
|
||||||
|
A) Woman hid her daughter from her husband for 8 years
|
||||||
|
B) Cult leader existential crisis on air
|
||||||
|
C) Paternity results opened live on the show
|
||||||
|
D) Faked cancer to skip a wedding""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Sat-PM (silence clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 22, 20),
|
||||||
|
"content": """"I told my girlfriend my biggest fantasy and she went completely silent for 10 seconds."
|
||||||
|
|
||||||
|
The silence in this clip is brutal.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-30/clip-3-latex-fetish-confession-goes-silent.mp4",
|
||||||
|
},
|
||||||
|
# Day 7 — Sunday March 23
|
||||||
|
{
|
||||||
|
"label": "W1-Sun-PM (week recap)",
|
||||||
|
"time": et_to_utc(2026, 3, 23, 15),
|
||||||
|
"content": """One week on X. Dropped 38 episodes before making an account.
|
||||||
|
|
||||||
|
If any of these clips made you laugh, cringe, or feel something — the full episodes are even wilder.
|
||||||
|
|
||||||
|
📞 208-439-LUKE
|
||||||
|
🔗 lukeattheroost.com
|
||||||
|
🎧 Spotify · Apple · YouTube""",
|
||||||
|
"media": "website/images/cover.png",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WEEK_2 = [
|
||||||
|
# Day 8 — Monday March 24
|
||||||
|
{
|
||||||
|
"label": "W2-Mon-AM (second family clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 24, 10),
|
||||||
|
"content": """A guy called in and found out his dad had a whole second family.
|
||||||
|
|
||||||
|
Three kids in Tucson who grew up calling his dad "Dad."
|
||||||
|
|
||||||
|
He found out via email from a stranger.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-20/clip-2-dad-s-secret-second-family-revealed.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W2-Mon-PM (engagement)",
|
||||||
|
"time": et_to_utc(2026, 3, 24, 19),
|
||||||
|
"content": """What would you do if you found out your dad had a whole second family?
|
||||||
|
|
||||||
|
Asking because a caller found out via email from a woman in Tucson.""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
# Day 9 — Tuesday March 25
|
||||||
|
{
|
||||||
|
"label": "W2-Tue-AM (spanish clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 25, 11),
|
||||||
|
"content": """A caller pretended to speak Spanish at his job for 8 years.
|
||||||
|
|
||||||
|
Eight. Years.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-14/clip-1-i-lied-about-speaking-spanish-for-8-years.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W2-Tue-PM (quote)",
|
||||||
|
"time": et_to_utc(2026, 3, 25, 18),
|
||||||
|
"content": """"Middle management is plagiarism with a 401k."
|
||||||
|
|
||||||
|
AI callers drop better one-liners than most standup specials.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "social_posts/x_launch/quote_3_stoicism_backwards_twitter.png",
|
||||||
|
},
|
||||||
|
# Day 10 — Wednesday March 26
|
||||||
|
{
|
||||||
|
"label": "W2-Wed-AM (BTS)",
|
||||||
|
"time": et_to_utc(2026, 3, 26, 12),
|
||||||
|
"content": """People ask if I script the show.
|
||||||
|
|
||||||
|
I don't even know who's calling until they're on the line. The AI generates the caller, picks a unique voice, gives them a backstory, and dials in.
|
||||||
|
|
||||||
|
My job is just to be a good host. The chaos handles itself.""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W2-Wed-PM (roomba clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 26, 20),
|
||||||
|
"content": """His neighbor's Roomba broke into his kitchen at 2:30 AM.
|
||||||
|
|
||||||
|
This is the content you're here for.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-26/clip-2-neighbor-s-roomba-breaks-into-kitchen-at-2-30-am.mp4",
|
||||||
|
},
|
||||||
|
# Day 11 — Thursday March 27
|
||||||
|
{
|
||||||
|
"label": "W2-Thu-AM (stalking clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 27, 11),
|
||||||
|
"content": """She sat in a Dairy Queen parking lot for 20 minutes watching her ex's truck at Sonic across the street.
|
||||||
|
|
||||||
|
We've all been there. (Right?)
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-22/clip-2-stalking-your-ex-at-sonic.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W2-Thu-PM (engagement)",
|
||||||
|
"time": et_to_utc(2026, 3, 27, 19),
|
||||||
|
"content": """Be honest: what's a lie you've kept going for way too long?
|
||||||
|
|
||||||
|
A caller on my show pretended to speak Spanish at work for 8 years. You can't beat that.""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
# Day 12 — Friday March 28
|
||||||
|
{
|
||||||
|
"label": "W2-Fri-AM (hidden room clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 28, 10),
|
||||||
|
"content": """A man found an impossible hidden room in a junkyard.
|
||||||
|
|
||||||
|
Inside? Beer that was still fresh after 12 years.
|
||||||
|
|
||||||
|
Two clips. One mystery.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-34/clip-1-man-finds-impossible-hidden-room-in-junkyard.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W2-Fri-PM (fix quote)",
|
||||||
|
"time": et_to_utc(2026, 3, 28, 18),
|
||||||
|
"content": """"You can't fix somebody who doesn't want to be fixed."
|
||||||
|
|
||||||
|
A caller said this about their partner and it hit like a truck.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-30/clip-1-you-can-t-fix-someone-who-won-t-be-fixed.mp4",
|
||||||
|
},
|
||||||
|
# Day 13 — Saturday March 29
|
||||||
|
{
|
||||||
|
"label": "W2-Sat-AM (BTS callers)",
|
||||||
|
"time": et_to_utc(2026, 3, 29, 12),
|
||||||
|
"content": """Each AI caller gets generated with:
|
||||||
|
|
||||||
|
• A name, age, job, and hometown
|
||||||
|
• A reason for calling
|
||||||
|
• A communication style + energy level
|
||||||
|
• An emotional state
|
||||||
|
• A "signature detail" that makes them unique
|
||||||
|
• A unique AI voice
|
||||||
|
|
||||||
|
None of it is scripted. They just... call in and talk.""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W2-Sat-PM (check clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 29, 20),
|
||||||
|
"content": """He deposited a $5,000 check instead of $500 three months ago.
|
||||||
|
|
||||||
|
Spent it all.
|
||||||
|
|
||||||
|
Now his company might find out.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-25/clip-2-accidentally-kept-4-500-from-work.mp4",
|
||||||
|
},
|
||||||
|
# Day 14 — Sunday March 30
|
||||||
|
{
|
||||||
|
"label": "W2-Sun-PM (week 2 recap + review)",
|
||||||
|
"time": et_to_utc(2026, 3, 30, 15),
|
||||||
|
"content": """Two weeks in. Thank you to everyone who's followed, listened, or dropped a comment.
|
||||||
|
|
||||||
|
This show started as a weird experiment — a guy giving life advice to AI-generated callers at 2 AM.
|
||||||
|
|
||||||
|
38 episodes later it's still weird. But now people are listening.
|
||||||
|
|
||||||
|
If you've been enjoying it, a rating on Apple or Spotify makes a real difference. 🙏
|
||||||
|
|
||||||
|
lukeattheroost.com""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Schedule X launch campaign via Postiz")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Preview without scheduling")
|
||||||
|
parser.add_argument("--week", type=int, choices=[1, 2], help="Schedule only week 1 or 2")
|
||||||
|
parser.add_argument("--skip", type=int, default=0, help="Skip first N posts (for retrying after partial run)")
|
||||||
|
parser.add_argument("--delay", type=int, default=10, help="Seconds between API calls (default 10)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not POSTIZ_API_KEY:
|
||||||
|
print("Error: POSTIZ_API_KEY not set in .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
all_posts = []
|
||||||
|
if args.week != 2:
|
||||||
|
all_posts.extend(WEEK_1)
|
||||||
|
if args.week != 1:
|
||||||
|
all_posts.extend(WEEK_2)
|
||||||
|
posts = all_posts[args.skip:]
|
||||||
|
|
||||||
|
print(f"\n=== X Launch Campaign — {len(posts)} posts ===\n")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
for i, post in enumerate(posts, 1):
|
||||||
|
has_media = "📎" if post["media"] else " "
|
||||||
|
print(f" {i:2d}. {has_media} {post['label']}")
|
||||||
|
print(f" Schedule: {post['time']}")
|
||||||
|
preview = post["content"][:80].replace("\n", " ")
|
||||||
|
print(f" Content: {preview}...")
|
||||||
|
if post["media"]:
|
||||||
|
print(f" Media: {post['media']}")
|
||||||
|
print()
|
||||||
|
print(f"Dry run complete — {len(posts)} posts would be scheduled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Upload media files first (deduplicate), with disk cache
|
||||||
|
cache_file = SCRIPT_DIR / "social_posts" / "x_launch" / ".upload_cache.json"
|
||||||
|
media_cache = {}
|
||||||
|
if cache_file.exists():
|
||||||
|
media_cache = json.loads(cache_file.read_text())
|
||||||
|
print(f"Loaded {len(media_cache)} cached uploads from previous run\n")
|
||||||
|
|
||||||
|
media_files = set(p["media"] for p in posts if p["media"])
|
||||||
|
to_upload = [m for m in sorted(media_files) if m not in media_cache]
|
||||||
|
|
||||||
|
if to_upload:
|
||||||
|
print(f"Uploading {len(to_upload)} new media files ({len(media_files) - len(to_upload)} cached)...\n")
|
||||||
|
for media_path in to_upload:
|
||||||
|
full_path = SCRIPT_DIR / media_path
|
||||||
|
if not full_path.exists():
|
||||||
|
print(f" ✗ {media_path} — FILE NOT FOUND, skipping")
|
||||||
|
continue
|
||||||
|
print(f" Uploading {media_path}...", end=" ", flush=True)
|
||||||
|
result = upload_file(full_path)
|
||||||
|
if result:
|
||||||
|
media_cache[media_path] = result
|
||||||
|
cache_file.write_text(json.dumps(media_cache, indent=2))
|
||||||
|
print("✓")
|
||||||
|
else:
|
||||||
|
print("✗ FAILED")
|
||||||
|
time.sleep(3)
|
||||||
|
else:
|
||||||
|
print(f"All {len(media_files)} media files already cached, skipping uploads\n")
|
||||||
|
|
||||||
|
# Schedule posts
|
||||||
|
print(f"\nScheduling {len(posts)} posts to X...\n")
|
||||||
|
|
||||||
|
success = 0
|
||||||
|
failed = 0
|
||||||
|
for i, post in enumerate(posts, 1):
|
||||||
|
media = media_cache.get(post["media"]) if post["media"] else None
|
||||||
|
|
||||||
|
if post["media"] and not media:
|
||||||
|
print(f" {i:2d}. ✗ {post['label']} — media upload missing, skipping")
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" {i:2d}. Scheduling {post['label']}...", end=" ", flush=True)
|
||||||
|
result = schedule_post(post["content"], media, post["time"])
|
||||||
|
if result:
|
||||||
|
print("✓")
|
||||||
|
success += 1
|
||||||
|
else:
|
||||||
|
print("✗")
|
||||||
|
failed += 1
|
||||||
|
# Rate limit: pause between API calls
|
||||||
|
if i < len(posts):
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"Scheduled: {success}/{len(posts)}")
|
||||||
|
if failed:
|
||||||
|
print(f"Failed: {failed}")
|
||||||
|
print(f"\nPosts will appear on @lukeattheroost starting Mon March 17")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user