Explanation — why the pipeline is shaped the way it is¶
This page is about understanding, not doing. If you want step-by-step
instructions, start from the README; this document explains the
ideas behind the design: the four-phase architecture, why run-state.json is the
single resumable source of truth, why everything runs locally on Apple Silicon, why
the upload is never automatic, and how the transcript editor only reruns what an edit
actually invalidated.
The four-phase architecture¶
A single audio file becomes a private YouTube video by passing through four phases, in order:
- Transcribe — WhisperX turns the audio into a word-level transcript
(
<stem>.whisperx.jsonplus<stem>.srt/<stem>.txt), optionally with speaker labels from diarization. - Metadata — a local LLM reads the transcript and writes the YouTube title,
description, chapters and tags (
<stem>.youtube-meta.json/.md). - Render — Remotion composes the audio and transcript into a 1920×1080 MP4 with
a visualiser (
<stem>-<viz>.mp4). - Upload — the YouTube Data API v3 publishes that MP4, as a private (or unlisted) video.
Why four discrete phases rather than one monolithic transform? Because each phase has a fundamentally different cost, failure mode, and trust profile. Transcription is minutes of GPU/Neural-Engine work; metadata generation is a single slow LLM call that can produce malformed output; rendering is heavy Node/Remotion work that can crash; upload is the one irreversible, network-facing, account-touching action. Keeping them separate means each can be skipped, retried, or rerun on its own, and the boundary between "everything so far was local and reversible" and "now we touch the outside world" is drawn cleanly between phase 3 and phase 4.
The phases are also strictly data-coupled, not call-coupled: each writes files into
output/<stem>/, and the next reads them. Metadata reads the transcript files; render
reads the WhisperX JSON and SRT; upload reads the MP4 and the metadata JSON. This is
what makes resuming and partial reruns possible at all — a later phase doesn't need
the earlier phase's Python objects, only its files on disk.
run-state.json as the resumable source of truth¶
Every run writes a run-state.json into its output directory. It is the durable
record of what happened, and it is deliberately the only thing the frontends consult
to decide what a run is and what can be done next. The orchestrator
(pipeline.py) treats each phase as a tiny state machine:
pending → running → done (succeeded)
→ aborted (raised an exception / subprocess exit ≠ 0)
pending → skipped (a skip flag was set; phase never entered)
A phase is marked running the moment it starts and done only after its outputs
exist; a crash or a non-zero subprocess exit flips it to aborted with the error
text attached. There is a subtle but important fourth case the file's own comments
call out: if the process is killed externally (closed terminal, OS kill) a phase can
be left stuck at running, because no finalising step ever ran. The state file is an
honest record of the last transition that completed, not a live heartbeat.
Writes are atomic — the state is written to a .tmp file and then os.replaced over
the real one — so a reader never sees a half-written file even if it polls during a
phase transition. Each transition also stamps updated_at, and preserves the prior
iteration's started_at instead of clobbering it, so timing survives reruns.
Because the file is the source of truth, resuming is just reading it back. The
orchestrator's --skip-* flags mean "this phase is already satisfied on disk, don't
redo it"; combined with the recorded statuses, a run can pick up wherever it left off
without any in-memory continuity. The trade-off is that the state file can drift from
reality if files are deleted by hand or a phase is stuck at running — the system
trusts the file, so manual surgery on output/<stem>/ is effectively editing the
source of truth.
Why the WebGUI derives a variant instead of storing one¶
The WebGUI never stores "this run is ready to upload" as a fact. Instead, each time the run page loads, it derives a single display variant from the current phase statuses, with a fixed precedence:
- aborted — any phase is
aborted. The page shows the error and offers a retry. - done — the upload phase is
done. The video is live (privately) on YouTube. - ready-to-upload — render is
donebut upload is not. The MP4 exists; the human decision is pending. - running — something is
running, or there are only pending phases with no active process (a partial-resume / "needs a nudge" situation, shown with a warning mood rather than an error).
This derive-don't-store choice is what keeps the UI honest. There is no separate "status field" that can disagree with the phases; the variant is a pure function of the phase map, so editing the transcript, rerunning a single phase, or a crash all change what the page shows simply by changing the underlying statuses. The run-history list applies the same principle: filters like done, aborted, unfinished and not-uploaded are computed from the phase statuses across all runs, never from a cached label.
Why local-only, and why Apple Silicon¶
The hard rule of this project is that the only network call is the YouTube upload. Transcription, metadata generation and rendering all run on the machine in front of you. Three reasons drive that:
- Privacy. The full audio and the complete transcript — often unpublished, often containing things you have not decided to make public yet — never leave the machine. The only thing that crosses the network is the finished video you explicitly chose to upload.
- Cost. There is no per-minute transcription bill and no per-token LLM bill. Once
the models are on disk (
download_models.pypre-fetches them for fully offline use of phases 1–3), the marginal cost of a run is electricity and time. - The Neural Engine instead of a cloud LLM. The metadata phase talks to a local
MLX server on
http://localhost:8080/v1over the OpenAI-compatible chat API — not a hosted model. MLX runs the quantised model on Apple Silicon's unified memory and Neural Engine, which is exactly why the project is Mac-only by design: the performance assumptions (and the long, patient 15-minute request timeout for long transcripts) are built around that hardware. PointingMLX_BASE_URL/MLX_MODELelsewhere is possible, but the design centre of gravity is "a model running on this Mac."
The trade-offs are real and chosen deliberately. You provide the hardware and the local server; a cold first run pays the model-download cost; a cloud service would be faster to start and would scale past one machine. The project trades all of that away for keeping your data on your own silicon.
Why the upload is never automatic¶
Phases 1–3 are local and reversible: delete the output directory and it's as if the run never happened. Phase 4 is neither. It pushes a real video to a real Google account through OAuth credentials you authorised once. That asymmetry is why upload is treated as a separate trust moment rather than the natural tail of a pipeline run.
Concretely, the WebGUI's normal flow stops at ready-to-upload and waits. The human
then chooses the visibility (private or unlisted — never fully public from here) and
explicitly triggers the upload. On the CLI the same stance shows up as --skip-upload
being the safe, recommended habit, and the orchestrator additionally refuses to upload
unless a rendered video actually exists. Even the default privacy is private: the
design assumes you will review the result inside YouTube Studio before anyone else
sees it (the metadata phase even appends a post-upload checklist for exactly that
review). Making upload automatic would save one click while removing the one moment
where a human confirms that this video, with this title, should go to this account.
The project considers that a bad trade.
The transcript-editor model: edit between phases, rerun only what changed¶
WhisperX is good but not perfect — it mistypes names, jargon and foreign words, and diarization sometimes mislabels who spoke. The naive fix is to rerun the whole pipeline. The expensive truth is that almost nothing downstream actually depends on re-running transcription again; what's wrong is the transcript text, which you can just correct.
So the editor sits between phases and operates on the transcript artifacts directly. When you save an edit:
- On the first save, a one-time pristine backup is written to
<stem>.whisperx.original.json, so the original Whisper output is never lost. (The diff view compares current against this backup.) - Changed segments are marked
_edited: true, and the.srt/.txtsiblings are regenerated from the JSON in byte-for-byte the same format the transcription phase would have produced — so downstream phases can't tell an edited transcript from a freshly transcribed one. - Every mutation snapshots the prior state into
output/<stem>/snapshots/, trimmed to the newest 20, which is what powers undo.
The interesting design decision is step four: saving invalidates the meta and render
phases. The editor resets those two phases from done back to pending in
run-state.json — and only those two. Transcription stays done (you just hand-fixed
its output; re-running Whisper would only undo your correction). Upload is
deliberately left untouched as well: V1 treats an edit as affecting transcript-derived
artifacts, and re-publishing to YouTube is the trust moment that must stay an explicit
human action, not a side-effect of fixing a typo.
Why invalidate via the state file rather than immediately re-running? Because of the
derive-don't-store principle above. Flipping meta and render to pending is all it
takes: the run page's variant recomputes, the metadata and video previews show as
stale/pending, and a single click re-runs just those phases. Under the hood, that
single-phase rerun is expressed by setting all the orchestrator's skip flags to true
except the one phase being run — the same --skip-* machinery that powers resume is
reused to power "rerun exactly this phase." So the chain is consistent: an edit
changes the transcript, the state file records that meta+render are no longer valid,
and only those phases recompute from the corrected transcript while the costly
transcription and the irreversible upload stay put.
The trade-off here is scope. The editor's invalidation is intentionally coarse — any saved change invalidates the whole metadata and render phases, even if you only fixed one word, because both phases read the transcript holistically (the LLM summarises the whole thing; the renderer lays out every caption). It does not try to patch the MP4 or the title in place; it reruns the cheap-enough downstream phases from clean inputs. That keeps the model simple and the outputs trustworthy: what you see always corresponds to the transcript as it is now, not a partially-patched mix.
See also: the README for setup and the full feature list, and the other manual quadrants for tutorials, how-to guides and reference material.