- 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
/publishgate.
Sermon Pipeline
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.
- Before Sunday · The pastor
Enters the sermon title and the scripture passages in the church calendar
- Sunday
The service streams live on YouTube, exactly as it does today
- Monday
Finds that Sunday’s service video and reads the captions YouTube made for it
- Monday
Marks where the sermon starts and ends, and says how confident it is
- sends a link1Approval 1 · Abraham or John
Confirm the start and end on a phone. Nothing has been downloaded or uploaded yet
- After approval 1
Trims the sermon out of the service, levels the audio, saves to Drive, and stages the video unlisted
approves - ready to check2Approval 2 · Abraham or John
Approve publishing, once the staged version looks right
- After approval 2
Public on YouTube, emailed to the congregation and to VietChristian, added to this site and the podcast
approves - Then
Proposes short vertical clips taken from the same sermon
- offers clips3Approval 3 · Abraham or John
Pick which clips are worth making, then approve each finished clip
- After approval 3
The chosen clips are made in the church’s style and go public
picks
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 · Detectautomated, every Monday
- 1GitHub Actions → RunnerMonday 03:00 cron starts the detect job
- 2Runner ↔ YouTubefind the Sunday livestream (Data API) and pull captions (yt-dlp)
- 3Drupal → Runnersermon title + scripture passages from vietgrace.org
- 4Runner ↔ Claudesend transcript, get sermon start / end + confidence
- 4bRunner ↔ Whisperlocal transcript, only when YouTube has no captions
- 5Runner → Repo + state/write the sermon record and open the review Issue
- 6Runner → Telegram → Reviewerssend the review link
- ◆/approve · gatea reviewer accepts the detected boundaries before any processing
- ▮Phase 2 · Processafter /approve
- 7YouTube → Runnerdownload just the sermon span with yt-dlp
- 8Runner ↔ ffmpeglossless trim + loudness level, local, no external call
- 9Runner → YouTubeupload the sermon UNLISTED via OAuth
- 10Runner → Google Drivestore the mp3 + video
- ◆/publish · gatea reviewer approves going public after the unlisted preview looks right
- ▮Phase 3 · Publishafter /publish
- 11GitHub Actions → YouTubeflip to public + add to the Sermons playlist
- 12GitHub Actions → Claudegenerate the bilingual (VN + EN) summary shown on the sermon page
- 13GitHub Actions → Gmail → VietChristiansend the mp3 email to the publisher
- 14GitHub Actions → Gmail → Congregationsend the sermon email to the list
- 15GitHub Actions → sermons.vietgrace.orgrsync the rebuilt table + sermon pages
- ▮Phase 4 · Shortsoptional, after the sermon is public
- 16Runner ↔ Claudetranscript → ~30s candidates (hook + verbatim quote), then a theological quality gate drops the weak and the context-dependent
- 17Runner → Telegram → Reviewerscandidates arrive as owner-only inline buttons; a reviewer picks which to make
- 18Runner ↔ ffmpeg + visionscene-cut segmentation, speaker / slide classification, adaptive reframe + burned-in captions in one pass
- 19Runner → YouTubeupload each finished clip UNLISTED
- ◆per clip · gatea reviewer watches the unlisted render and approves that clip, separately from the sermon’s own /publish
- 20GitHub Actions → YouTubeflip the approved clip public
Reference
Where each piece runs
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.
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.
- Denoise
- DeepFilterNet in its own venv, then
highpass=f=80for rumble andafftdnfor steady room noise. - Level
dynaudnormas 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
acompressorat 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
loudnormruns 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.
- 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.
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.
