Season Media Pipeline — Setup Guide
The Season Media Pipeline produces a movie poster, character portraits, podcast audio, and a YouTube video for each completed Story Season.
Stages:
- Visual assets — DALL-E movie poster + per-cast portraits
- Voice casting — auto-assigns ElevenLabs voices to each persona
- Script generator — narrator overview + per-entry segments + transitions
- Audio render — ElevenLabs TTS + ffmpeg compilation w/ chapter markers
- Video render — Ken Burns slideshow on portraits + lower-thirds + outro
- Distribution — YouTube Data API upload + podcast RSS feed
One-time setup (after first deploy)
1. Apply migration 094_season_media_assets.sql
Open the Supabase SQL Editor and run the contents of .supabase/migrations/094_season_media_assets.sql
This adds:
story_seasons.poster_url,narrator_voice_id,assets_generated_at,auto_publishstory_cast.portrait_url,voice_id,voice_nameseason_audio_segmentstable (ordered script per season)season_publishingstable (YouTube + podcast distribution status)
2. Create the season-assets storage bucket
In the Supabase Dashboard → Storage:
- Click New bucket
- Name:
season-assets - Public: Yes (poster + portraits are shown on public season pages)
- Click Create bucket
Then under Policies, add:
-- Anyone can read (public bucket already implies this, but explicit is safer)
CREATE POLICY "Public read season assets"
ON storage.objects FOR SELECT
USING (bucket_id = 'season-assets');
-- Only the backend service role writes to this bucket (no client-side upload)
CREATE POLICY "Service role manages season assets"
ON storage.objects FOR ALL
TO service_role
USING (bucket_id = 'season-assets');
3. Environment variables
Required by stage:
| Stage | Env var | Notes |
|---|---|---|
| 1. Visuals | OPENAI_API_KEY | Already set |
| 3. Script | OPENAI_API_KEY | Already set |
| 4. Audio | ELEVENLABS_API_KEY | Creator plan: $22/mo, ~100 min/mo (~1 season). Pro: $99/mo, ~500 min/mo (~5 seasons). |
| 4.5. Music | REPLICATE_API_TOKEN | MusicGen-stereo-large via replicate.com — ~$0.50/season. Pay-as-you-go billing, no monthly minimum. |
| Distribution → YouTube | YOUTUBE_OAUTH_CLIENT_ID, YOUTUBE_OAUTH_CLIENT_SECRET, YOUTUBE_OAUTH_REFRESH_TOKEN | See section below |
Stages 2, 5, and the podcast RSS feed don't need any extra keys.
4. YouTube OAuth setup (only needed for the YouTube upload button)
- Google Cloud Console: create a new project (or pick an existing one) → APIs & Services → enable YouTube Data API v3.
- OAuth consent screen: choose External, fill in app name + your email, add scope
https://www.googleapis.com/auth/youtube.upload. Add yourself as a test user. - Credentials → Create Credentials → OAuth client ID → Application type "Web application". Add
https://developers.google.com/oauthplaygroundas an authorized redirect URI. Copy the Client ID and Client Secret. - OAuth Playground (developers.google.com/oauthplayground) → gear icon (top right) → check "Use your own OAuth credentials" and paste your client ID + secret. In the left sidebar paste this scope:
https://www.googleapis.com/auth/youtube.upload. Click Authorize APIs, complete the Google login, then click Exchange authorization code for tokens. Copy the Refresh token. - Set env vars on Railway (or
.envlocally):YOUTUBE_OAUTH_CLIENT_ID=... YOUTUBE_OAUTH_CLIENT_SECRET=... YOUTUBE_OAUTH_REFRESH_TOKEN=... - The first upload publishes as unlisted by default — you can flip it to public on YouTube once you've reviewed it.
5. Submitting the podcast feed
Once the backend is deployed, the public RSS feed is at:
https://<your-backend-host>/api/podcast/rss
Apple Podcasts: podcastsconnect.apple.com → "+" → New Show → paste the RSS URL.
Spotify for Podcasters: podcasters.spotify.com → Add a podcast → Existing podcast → paste the RSS URL.
The feed updates automatically as you publish new seasons (clicking the 🎧 Podcast button on each season). Cache TTL is 5 minutes.
Per-season usage
In /dashboard/admin/seasons, each season row has these stage-trigger buttons:
| Button | Stage | Cost | Time |
|---|---|---|---|
| 🎨 Visuals | 1: poster + portraits | ~$0.88 | ~60s |
| 🎤 Cast Voices | 2: assign ElevenLabs voices | $0 | <1s |
| ✏️ Edit Voices | manual override modal | $0 | manual |
| 📝 Script | 3: build narrator + cast script | <$0.01 | ~5s |
| 🎙️ Render Audio | 4: ElevenLabs TTS + ffmpeg concat | ~$25 (75 min) | ~5+ min |
| 🎵 Score Music | 4.5: mood scoring + MusicGen cluster cues + per-character leitmotifs + sidechain mix | ~$0.80 | ~7-12 min |
| 🎼 Themes | preview each character's leitmotif (visible after Score Music runs) | $0 | instant |
| 🎬 Render Video | 5: Ken Burns slideshow MP4 | $0 (CPU only) | ~5-15 min |
| 🎧 Podcast | distribution: publish/unpublish | $0 | <1s |
| ▶️ YouTube | distribution: unlisted upload | $0 | minutes (depends on size) |
Each row also has an Auto-publish on complete checkbox — when set, the full pipeline runs automatically when you click ▶ Next Episode on the final episode (or any other action that flips status to completed).
Stages can be re-run independently. The pipeline is idempotent — re-running visuals when a poster already exists will prompt before regenerating.
Cost expectations
| Item | Cost |
|---|---|
| Movie poster (DALL-E 3 HD, 1024×1792) | $0.08 |
| Each character portrait (DALL-E 3 HD, 1024×1024) | $0.08 |
| Typical 10-cast season visuals | ~$0.88 |
| ElevenLabs Creator plan (Stage 4) | $22/mo, ~100 min audio/mo |
| ElevenLabs Pro plan (Stage 4) | $99/mo, ~500 min audio/mo |
Pre-launch checklist (before submitting to Apple/Spotify)
Before clicking "Submit" in any podcast directory, verify:
- At least one episode published — Apple rejects empty feeds. Render audio for one season and click 🎧 Podcast to publish it.
- Channel cover art uploaded — 3000×3000 PNG/JPG, sRGB, max 500KB. Upload to
season-assets/podcast/cover-3000.pngand updatePODCAST_FALLBACK_IMAGEinto the public URL. (See P1 task: "Podcast: design 3000×3000 cover art".)apps/backend/src/services/season-distribution.ts - Show description set — currently
PODCAST_DESCRIPTIONdefaults to a generic blurb. Update to your final 200-300 char show pitch. - Category chosen —
PODCAST_CATEGORYdefaults to Fiction → Drama. Apple's full category list: help.apple.com/itc/podcasts_connect/#/itcb54353390. - Owner email valid — Apple emails the
<itunes:owner><itunes:email>address to confirm ownership. Currently set tosandonjurowski@gmail.com. - Feed validates — paste the RSS URL into castfeedvalidator.com or podba.se/validate and resolve any errors before submitting.
- (Recommended) Trailer episode — Apple favors shows with a trailer. Render a 1-2 min intro and add it before submission.
Submission walkthrough
Once the checklist is complete, the RSS feed URL is:
https://hivejournalbackend-production.up.railway.app/api/podcast/rss
(Replace with your actual Railway hostname if different — check railway status.)
Apple Podcasts (1-3 day approval)
- Go to podcastsconnect.apple.com, sign in with your Apple ID.
- Click + → New Show → Add a show with an RSS feed.
- Paste the RSS URL, click Add. Apple validates the feed.
- Apple sends a verification email to the
<itunes:owner><itunes:email>address. Click the link to confirm ownership. - Choose category (auto-filled from your feed), language, content rights.
- Click Publish. Approval takes 1-3 business days; you'll be emailed when it's live.
Spotify for Podcasters (hours)
- Go to podcasters.spotify.com.
- Add a podcast → Existing podcast (RSS) → paste the RSS URL.
- Spotify auto-validates and pulls the metadata. Approval is usually under 24 hours.
- Spotify auto-distributes to Amazon Music since they share infrastructure — no separate submission needed there.
Other directories (extends reach, not required)
| Directory | URL | Approval | Notes |
|---|---|---|---|
| Pocket Casts | pocketcasts.com/submit | <24 hrs | Free, popular among podcast power users |
| Overcast | (auto) | — | Indexes from Apple Podcasts automatically once approved there |
| Castbox | castbox.fm/dashboard | 1-2 days | Heavy in Asia + Android markets |
| iHeartRadio | iheart.com/podcast/submit | 1-2 weeks | Slow but high-traffic |
| Stitcher | (deprecated) | — | Shut down 2023 — listeners moved to SiriusXM Podcasts |
| Google Podcasts | (deprecated) | — | Shut down 2024 — replaced by YouTube Music |
Apple + Spotify cover ~80% of podcast listeners. The others are diminishing returns.
Failure modes
- DALL-E refusal — content policy blocks dark/violent prompts. The service logs and stores
nullfor that asset; re-run the generate-visuals endpoint to retry with adjusted prompts. - Bucket missing — uploads fail silently with a logged error and
portrait_url/poster_urlstaynull. Check the storage bucket exists (Step 2 above). - OpenAI rate limit — portraits are batched at concurrency 3 to stay under per-minute limits. If you hit limits anyway, wait a minute and re-run.
- ElevenLabs 401 — API key wrong, revoked, or wrong permissions. The key only needs Text to Speech: Access enabled.
- ElevenLabs 429 — character quota exhausted for the month. Upgrade plan or wait for the next billing cycle. Plans: elevenlabs.io/pricing.
- ffmpeg concat fails with "non-monotonous DTS" — happens when segment MP3s have inconsistent encoding. Currently the pipeline uses
-c copy; if this becomes a problem, switch to-c:a libmp3lame -b:a 128kto re-encode (slower but bulletproof). - YouTube upload "quotaExceeded" — YouTube Data API has a default quota of 10,000 units/day; an upload costs 1,600 units. So ~6 uploads per day max on the free tier. Apply for higher quota at console.cloud.google.com/iam-admin/quotas if you scale.
- Apple feed rejection: "missing artwork" — your
<itunes:image href>URL must be publicly accessible (not behind auth) and resolve to an image file with proper Content-Type. The Supabase public-bucket URLs satisfy this. - Apple feed rejection: "duplicate GUID" — each
<item><guid>must be unique forever. We useseason-{uuid}; never reuse a season UUID for a different episode. - "Failed to upload compiled video" — almost always the
season-assetsbucket's per-file size limit. Default Supabase limit is 50MB; rendered season videos are typically 80-200MB. Fix: Supabase dashboard → Storage →season-assetsbucket → settings → set "File size limit" to 500MB or higher. The pipeline uses-preset veryfast -crf 28to keep output file sizes reasonable, but a 75-min season at 1080p still lands around 120MB.