Sermon Pipeline

Architecture Reference

A serverless weekly pipeline that detects each Sunday's sermon livestream, finds where the sermon starts and ends inside the full service, trims it losslessly, and publishes the clip to YouTube, Google Drive, and an email list. No VPS app, no database, no always-on process. State is per-sermon JSON committed to the repo; git history is the audit log. Every irreversible step waits behind a one-comment human gate.

Orientation

The week, in two lanes

The same picture that opens How It Works. Everything below is this diagram with the hosts, credentials, and failure paths filled in.

  1. Before Sunday · The pastor

    Enters the sermon title and the scripture passages in the church calendar

  2. Sunday

    The service streams live on YouTube, exactly as it does today

  3. Monday

    Finds that Sunday’s service video and reads the captions YouTube made for it

  4. Monday

    Marks where the sermon starts and ends, and says how confident it is

  5. sends a link
    1Approval 1 · Abraham or John

    Confirm the start and end on a phone. Nothing has been downloaded or uploaded yet

  6. After approval 1

    Trims the sermon out of the service, levels the audio, saves to Drive, and stages the video unlisted

    approves
  7. ready to check
    2Approval 2 · Abraham or John

    Approve publishing, once the staged version looks right

  8. After approval 2

    Public on YouTube, emailed to the congregation and to VietChristian, added to this site and the podcast

    approves
  9. Then

    Proposes short vertical clips taken from the same sermon

  10. offers clips
    3Approval 3 · Abraham or John

    Pick which clips are worth making, then approve each finished clip

  11. After approval 3

    The chosen clips are made in the church’s style and go public

    picks
Automatic A person decides 3 approvals in a week

Data flow

Services and the data between them

Read left to right, then down into the Shorts band. The week runs in four phases split by approval gates (/approve, /publish, then one per clip) where a reviewer signs off before anything irreversible happens. Inside each phase the Runner on the church Mac is the hub, calling out to the boxed services beside it; every numbered arrow is one flow of data, keyed to the legend below. The Runner lives on a residential IP because YouTube blocks video downloads from datacenter addresses. Follow the numbers 1 to 20 in order.

PHASE 1 · DETECT AUTOMATED · EVERY MONDAY PHASE 2 · PROCESS AFTER /APPROVE PHASE 3 · PUBLISH AFTER /PUBLISH PHASE 4 · SHORTS OPTIONAL · AFTER THE SERMON IS PUBLIC 1 2 3 4 4b 5 6 7 8 9 10 11 12 13 14 15 /approve /publish per clip once public GITHUB GitHub Actions weekly cron · Mon 03:00 CHURCH MAC Runner detect job yt-dlp · captions The Runner is the hub. It sits on a residential IP so YouTube allows video downloads. GOOGLE YouTube find livestream + captions CHURCH vietgrace.org · Drupal sermon title + passages ANTHROPIC Claude AI boundary detection LOCAL · FALLBACK Whisper transcript, no-caption weeks GITHUB Repo + state/ write record + open Issue TELEGRAM Telegram → Reviewers review link to Abraham / John CHURCH MAC Runner process job download · trim · upload GOOGLE YouTube download sermon span LOCAL ffmpeg lossless trim + level GOOGLE YouTube upload UNLISTED (OAuth) GOOGLE Google Drive store mp3 + video GITHUB GitHub Actions publish.yml hosted runner API only, no video GOOGLE YouTube public + playlist ANTHROPIC Claude sermon summary (VN + EN) GMAIL SMTP → VietChristian mp3 email (publisher) GMAIL SMTP → Congregation sermon email CHURCH VPS sermons.vietgrace.org table + sermon pages ANTHROPIC Claude · propose ~30s candidates, hook + quote theological quality gate TELEGRAM · A PERSON Reviewer picks owner-only inline buttons nothing renders until a pick CHURCH MAC Render scene cuts · speaker / slide reframe + captions, one pass GOOGLE YouTube each clip UNLISTED reviewable before anyone sees it GITHUB YouTube approved clip → public separate from the sermon gate 16 17 18 19 20
Legend · each number is one data flowdatahuman control (approval)
  1. ▮Phase 1 · Detectautomated, every Monday
  2. 1GitHub Actions → RunnerMonday 03:00 cron starts the detect job
  3. 2Runner ↔ YouTubefind the Sunday livestream (Data API) and pull captions (yt-dlp)
  4. 3Drupal → Runnersermon title + scripture passages from vietgrace.org
  5. 4Runner ↔ Claudesend transcript, get sermon start / end + confidence
  6. 4bRunner ↔ Whisperlocal transcript, only when YouTube has no captions
  7. 5Runner → Repo + state/write the sermon record and open the review Issue
  8. 6Runner → Telegram → Reviewerssend the review link
  9. ◆/approve · gatea reviewer accepts the detected boundaries before any processing
  10. ▮Phase 2 · Processafter /approve
  11. 7YouTube → Runnerdownload just the sermon span with yt-dlp
  12. 8Runner ↔ ffmpeglossless trim + loudness level, local, no external call
  13. 9Runner → YouTubeupload the sermon UNLISTED via OAuth
  14. 10Runner → Google Drivestore the mp3 + video
  15. ◆/publish · gatea reviewer approves going public after the unlisted preview looks right
  16. ▮Phase 3 · Publishafter /publish
  17. 11GitHub Actions → YouTubeflip to public + add to the Sermons playlist
  18. 12GitHub Actions → Claudegenerate the bilingual (VN + EN) summary shown on the sermon page
  19. 13GitHub Actions → Gmail → VietChristiansend the mp3 email to the publisher
  20. 14GitHub Actions → Gmail → Congregationsend the sermon email to the list
  21. 15GitHub Actions → sermons.vietgrace.orgrsync the rebuilt table + sermon pages
  22. ▮Phase 4 · Shortsoptional, after the sermon is public
  23. 16Runner ↔ Claudetranscript → ~30s candidates (hook + verbatim quote), then a theological quality gate drops the weak and the context-dependent
  24. 17Runner → Telegram → Reviewerscandidates arrive as owner-only inline buttons; a reviewer picks which to make
  25. 18Runner ↔ ffmpeg + visionscene-cut segmentation, speaker / slide classification, adaptive reframe + burned-in captions in one pass
  26. 19Runner → YouTubeupload each finished clip UNLISTED
  27. ◆per clip · gatea reviewer watches the unlisted render and approves that clip, separately from the sermon’s own /publish
  28. 20GitHub Actions → YouTubeflip the approved clip public

Reference

Where each piece runs

GitHubcloud
Repo + per-sermon state JSON, Actions cron and comment triggers, review Issues, the light publish / reconcile / retry / ci runners.
Church Macself-hosted
Residential-IP runner for the heavy steps: yt-dlp span + captions, ffmpeg trim, Whisper, Shorts render, site-deploy, and the Telegram bot.
Googlecloud
YouTube Data API (detect), YouTube OAuth (unlisted / public / playlist), Drive (mp3 + video), Gmail SMTP (VietChristian + list).
Anthropiccloud
Claude proposes the sermon start / end at detect, writes the bilingual (VN + EN) summary at publish, and proposes Shorts candidates. Heuristic fallback if absent.
Telegramcloud
Sends status and review links, receives the owner-only approval commands.
Church infravietgrace.org
Drupal planning calendar (title + scripture) and the VPS that serves the public sermons.vietgrace.org page.

Phase 4, in detail

Shorts: propose, produce, publish

A published sermon can fan out into short vertical clips. The subsystem is three jobs behind two of its own human gates, and it never touches the sermon record's own lifecycle. Propose reads the published sermon's transcript and asks Claude for roughly 30-second candidates, each with a hook and the verbatim quote it is built around; a theological quality gate then drops candidates that are weak or that only hold together with the surrounding context, so what survives stands alone and represents the message fairly. Produce renders the picked candidates to 1080×1920 and uploads them UNLISTED. Publish is a second, separate human step that flips a reviewed clip public. Nothing in this path goes public on its own.

Selection happens from the phone: the candidates arrive as Telegram inline buttons (the same owner-only dispatcher used for /approve), or as typed commands for anyone who prefers them. Candidates are cached in a per-sermon sidecar, so a re-propose never re-pays the model calls.

Adaptive reframing

Grace's service is filmed on a locked-off camera that cuts between the preacher and the projected slides, so a single fixed crop is wrong for half of any given clip. Instead, scene-cut detection splits each clip into segments and one keyframe per segment is classified speaker vs slide by a vision model. Speaker segments are zoomed and cropped to fill the vertical frame; slide segments are fit whole, so a slide is never cropped. Both happen inside one clip, in a single encode pass. Behind a fitted slide the backdrop is a blurred still of the preacher, taken from the nearest speaker segment, so the clip still reads as him speaking. The fit leaves a small margin because the Shorts player crops slightly on some phones. Video is rendered at a constant 30 fps so the overlay animation stays smooth, and the end card carries a subscribe call-to-action. Any vision or detection failure falls back to fitting the whole frame, the one behavior that can never crop a slide.

Captions are burned in from the verified quote, never from raw machine transcription. If the words cannot be aligned to the audio with confidence, the captions are held back entirely rather than rendered at the wrong moment.

Shortsphase 4
Propose
Transcript → Claude candidates (~30s, hook + verbatim quote) → theological quality gate → sidecar. Telegram inline buttons for selection. Read-only: no render, no upload.
Produce
Church Mac. Scene-cut segmentation, keyframe shot classification, adaptive reframe + burned-in captions in one ffmpeg pass, then UNLISTED upload via the existing YouTube OAuth creds.
Publish
Owner-only, per clip, after watching the unlisted render. Flips privacy to public. Separate from the sermon's own /publish gate.

Media

Fetch, trim, and the audio chain

The multi-GB service video never crosses a job boundary, so every stage fetches only the bytes it needs. Processing pulls the sermon span alone with yt-dlp --download-sections and --force-keyframes-at-cuts, never the full stream and never a second pass for audio. The trim is a stream copy (-c:v copy -c:a copy -avoid_negative_ts make_zero), not a re-encode: it replaced an libx264 pass that took an hour per sermon, it runs in seconds, and the picture is bit-for-bit what was broadcast. YouTube re-encodes on ingest anyway, so the extra generation bought nothing but loss.

A short fetch that exits zero is the dangerous case, because it looks like success. Every clip is probed against the requested span and rejected under DOWNLOAD_MIN_FRACTION, and the retry deletes the partial file first, since yt-dlp skips a destination that already exists and would otherwise re-probe the same broken download three times.

Sunday captures are quiet and uneven: measured raw integrated loudness across real sermons runs from about −42 to −28 LUFS. A fixed pre-gain plus a single loudnorm pass cannot close that range; it landed around −16.5 LUFS and drew complaints that the audio was too quiet. The chain is ordered instead as adaptive leveler → speech EQ → compressor → EBU R128 normalization, so the final stage only makes a small, accurate correction regardless of how quiet the capture was.

Audioprofile: sermon
Denoise
DeepFilterNet in its own venv, then highpass=f=80 for rumble and afftdn for steady room noise.
Level
dynaudnorm as an adaptive per-frame leveler, so a −42 LUFS capture and a −28 LUFS one both reach the same working level before anything else acts.
Shape
Presence EQ plus a shelf at 6 kHz to take the edge off sibilance, then acompressor at 2:1 to tame the wide crest factor of speech so quiet passages stay audible. Measured LRA falls from 8–12 raw to about 3.5–5.
Normalize
loudnorm runs last, so its 4×-oversampled true-peak limiter governs inter-sample peaks. Sermon target −18 LUFS with a −2.0 dBTP ceiling, leaving headroom for MP3 inter-sample overshoot.
Verified
Landings measured on real sermons, single pass: −13.95 / −14.17 / −14.01 LUFS at −1.92 / −2.42 / −2.18 dBTP on the boosted profile. The targets are set slightly hot to cancel single-pass undershoot.

Constrained AI

The Shorts council

A clip lifted out of a sermon loses the context that held it steady, so a true sentence can land as something the preacher never meant. The gate that decides this is deliberately not one broad prompt: a model asked "is this good enough to publish?" tends to agree with whoever asked. The judgement is decomposed into narrow questions with thresholds instead, and every verdict is written to the sermon's record, so a rejection can be read back months later with its reason.

Stage one is the proposer's own marks (hook, weight, stands-alone) which decide what is worth considering. Stage two is the council: one batched call scoring every surviving candidate on three lenses, each shown the clip plus 40 seconds of padding on either side so out-of-context risk is judged against what actually surrounded it.

Councilunion veto
Lenses and floors
doctrine ≥ 0.75, context ≥ 0.70, power ≥ 0.65. Any lens below its floor cuts the candidate.
Weakest link, not average
The overall mark is min(lenses), so two strong answers can never outvote one bad one. Ranking on the minimum is what makes the gate a skeptic rather than a scorer.
Fails closed
If the gate cannot run, or a verdict is missing or unparseable, the candidate is cut, never surfaced unvetted. The failure mode of an AI safety gate has to be silence, not a shrug.
Then a human
Survivors are usually two or three from a whole sermon. They are still only candidates: a person picks, and approves each finished clip before it is public.

Artifacts

Drive publishing and link integrity

Processing writes the mp3 and the mp4 into the church Google Drive. That write alone does not produce a shareable link: a separate resolve step makes each file publicly shareable, then records the real share URL back onto the sermon record. A receipt that still holds a local mount path is a 404 waiting to happen, so the resolve job runs on a daily schedule rather than by hand, and a link can no longer sit broken between one manual run and the next.

Downstream of that, two hard gates make a broken link unshippable rather than merely reported. The public sermons page refuses to render a download link until the receipt holds a real id with a working share URL, so a visitor sees no link instead of a dead one. The VietChristian mp3 email will not send at all without a working public mp3 link. A repair path can rebuild a published sermon's Drive files without regressing its status, without re-uploading to YouTube, and without sending any email, so fixing an old sermon's artifacts is safe at any time.

Live

System status

A heartbeat for each moving part, read from status.json. Grey means the check has not run recently.

    State

    Status machine

    There is no database. Each sermon is one JSON record in the repo, and git history is the audit log. A job checks the record's status at the top and refuses work not in its expected predecessor state, so re-running tomorrow is always safe.

    detected→ reviewed→ pending_upload→ processed→ published + *_failed download_blocked

    The one deliberate exception is repair: re-running the process step on a sermon that is already scheduled or published rebuilds its artifacts and does not regress its lifecycle stage. Fixing a Drive file or a bad render never un-publishes a sermon, re-uploads it to YouTube, or re-sends an email.

    Rationale

    Design decisions

    Serverless, state in git

    There is no VPS app and no database. State is per-sermon JSON committed to the repo; the multi-GB video never crosses a job boundary. Git history is the audit trail, and a repo clone is the backup. The old FastAPI + SQLite service is retired one slice at a time.

    A residential runner for the heavy jobs

    YouTube bot-walls video downloads from datacenter IPs, which takes out both GitHub-hosted runners and the church VPS. The fix is a self-hosted runner on a Mac at the church, on a residential IP. Only the light, API-only jobs stay on cloud runners.

    Read-only vs production write secrets

    Detection runs with read-only keys. The credentials that can upload, go public, or send email live in a protected production Environment, branch-restricted to main and readable only by approve and publish. A runaway scan cannot touch the channel or the mailing list.

    Idempotency by receipt and probe

    Before every irreversible call, the job checks a per-effect receipt and then probes the destination itself (a marker in the YouTube description, a Drive name lookup, the email flag). A torn or stale commit cannot cause a duplicate upload or a double-send. Re-running tomorrow is always safe.

    Captions first, Whisper as fallback

    Free YouTube captions are the primary transcript, so a normal week finishes in minutes. Whisper is off by default and runs only on request, locally on the Mac, so a no-caption week degrades gracefully instead of blowing the time budget.

    Human gates before anything irreversible

    Nothing goes public without a person. The upload is unlisted first; an owner-only /approve then /publish stands between detection and the congregation, and each Short carries its own separate gate. Auto-pilot, off by default, can only ever produce the unlisted preview.

    Approval without a server

    The Telegram bot is a webhook-free dispatcher: it long-polls for updates, so there is no inbound port and nothing to host. An owner message is validated (chat id, then a regex on the video id and timestamps) and turned into a gh workflow run via an argument list, never a shell string, so a chat message cannot inject a command. The same review loop runs from the phone or from a GitHub Issue comment.

    Quota deferral, not failure

    YouTube's upload quota is small. When it is exhausted mid-run the record parks at processed_pending_upload instead of erroring; a daily job drains those back into an upload as quota returns. The status gate plus per-effect receipts make every step re-runnable with no duplicate upload or double email.

    Auto-remediation over alerting

    The weekly audit digest reported the broken-download-link condition correctly, every week, for months, and nobody acted on it: a recurring advisory becomes background noise. The fix was not another alert. The repair itself now runs on a schedule, and hard gates block the bad outcome at the point of effect, so the site cannot render a dead link and the publisher email cannot send without a working one. An alert asks a human to remember; a gate does not.

    Definition of done, not just status

    A sermon's status field read published while its download artifacts were unusable, and every status-shaped check agreed the week was fine. The audit now tests explicit completion criteria instead: a YouTube upload plus both Drive receipts holding real ids with working share URLs. Anything short of that is reported as incomplete despite published, so a green status can no longer hide broken artifacts.