I automated a faceless YouTube channel end to end on a home Windows PC: idea file in, published
video out. It shipped 156 videos across 4 pipeline variants, and its longest streak with zero human
input was 8+ days. No cloud, no subscriptions — local LLM (Ollama) or an Anthropic API key for
scripts, Piper TTS for narration, Remotion for rendering, the YouTube Data API for uploads, and
Windows Task Scheduler holding it all together.
Going in, I assumed the hard parts would be script quality and rendering. Both turned out to be
commodity problems. The two that actually cost me weeks were audio/visual timing and
YouTube's quota system — and almost nothing written about "YouTube automation" mentions either.
Problem 1: TTS timing is the difference between a video and a slideshow
The naive pipeline everyone builds first: generate a script, synthesize the whole narration as one
audio file, divide the visuals evenly across its duration. The result instantly reads as robotic —
images change mid-sentence, a visual lingers while the narrator has moved on two ideas ago.
The fix is structural: stop treating narration as one blob. My scripts are generated as beats —
one idea, one visual, one narration chunk. This is the actual type from the pipeline:
// pipeline/lib/types.ts
export interface NarrationBeat {
text: string; // one sentence/clause of narration
imageQuery: string; // image search query for the fact/subject of this beat
// Optional animated-board spec (generated by 1-generate-script.ts for
// Connect-Four-topic videos only; schema lives in render/src/HobbyVideo.tsx
// boardSpecSchema). When set, the renderer draws the board instead of this beat's photo.
board?: Record<string, unknown>;
}
The audio stage then synthesizes each part separately, measures the real duration of every
generated WAV, and only then concatenates — so the render stage works with what the audio actually
does, not an estimate. The source comment states the intent plainly: parts are "synthesized as
separate clips and concatenated, so we get each part's real spoken duration for stage 3 instead
of estimating it from text length."
// pipeline/2-generate-audio.ts
if (seg.parts) {
console.log(`Synthesizing ${seg.id} (${seg.parts.length} parts)...`);
const partPaths = seg.parts.map((_, i) => path.join(audioDir, `${seg.id}-part-${i}.wav`));
for (let i = 0; i < seg.parts.length; i++) {
await runPiper(seg.parts[i], partPaths[i], piperModel);
}
partDurationsSec = await Promise.all(partPaths.map(ffprobeDuration));
await concatWavs(partPaths, wavPath);
for (const p of partPaths) fs.unlinkSync(p);
The Remotion composition consumes those measured durations: each beat's visual holds exactly as
long as its narration plays, transitions land on beat boundaries, and captions are baked in against
the same timing data. Nothing is synced "by feel" — the timeline is derived from the audio that
will actually play.
Two practical notes if you build this:
-
Measure the WAV, don't trust the synthesizer. Note the measurement is
ffprobeDuration— ffprobe reading the real file, not Piper's own output metadata. Piper is local and fast enough that per-part synthesis costs nothing, and measuring the artifact you'll actually play removes a whole class of drift bugs. - Per-beat synthesis makes localization nearly free. My pipeline optionally builds a Spanish twin of every video: translate beats, re-synthesize with a Spanish voice, reuse the visuals, and the chapter markers recompute from the new durations — frame-accurate, automatically.
Problem 2: YouTube's API quota shapes your entire architecture
The YouTube Data API gives you 10,000 quota units per day by default, and per Google's documented
costs a single videos.insert is 1,600 units — six uploads a day before you've spent a unit on
anything else. My pipeline doesn't do unit accounting (I tried; a --limit N flag plus treating
quota errors as fatal turned out to be simpler and just as effective). But quota still bent the
whole architecture:
- Duplicate detection is mandatory, not defensive. A retry after an ambiguous failure can double-post. Before uploading anything, the pipeline pulls every live title on the channel — via the uploads playlist, since that's the cheap way to enumerate your own videos:
// pipeline/7-upload-youtube.ts
// Fetch every live video title on the channel so we can refuse to upload
// anything that looks like a duplicate of a video that's already public.
async function fetchLiveTitles(): Promise<string[]> {
const channelRes = await youtube.channels.list({ part: ['contentDetails'], mine: true } as any);
const uploadsPlaylistId = channelRes.data.items?.[0]?.contentDetails?.relatedPlaylists?.uploads;
if (!uploadsPlaylistId) return [];
...
The match is a fuzzy title-overlap score with a 0.7 threshold, not an exact comparison — a
re-rendered video whose title shifted by a word must still be refused.
- Never retry blindly. An escalating retry ladder against a quota error is a machine for converting one failure into a day of failures. In the uploader, quota errors kill the run on the spot and the queue resumes tomorrow:
// pipeline/7-upload-youtube.ts
} catch (err: any) {
// Quota exceeded — stop immediately, don't burn retries
if (err?.code === 403 || err?.message?.includes('quota')) {
console.error(`\nQuota/auth error: [${err?.code}] ${err?.message}`);
console.error(`Quota exceeded after ${done} uploads. Re-run tomorrow to resume.`);
break;
}
- Watch for the undocumented ceilings. Uploading a video and setting its thumbnail are separate calls with separate limits — and thumbnail updates hit an account-level cap far lower than the documented unit math suggests (I measured roughly a dozen per day before hard rejections). I found that one in production logs, not documentation. Assume every write endpoint has a second, quieter limit and let your scheduler respect it.
The rule that made unattended operation safe
Eight days of hands-off uploads sounds like the scary part, but it was governed by the most boring
component in the system: a QA gate. Nothing uploads until I've watched the rendered video and
dropped a qa-approved.txt into its job folder. The uploader hard-skips anything unapproved —
every run, forever, until a human puts the file there:
// pipeline/7-upload-youtube.ts
// QA gate: a video only enters the upload queue after a manual watch-through
// (see PRODUCTION.md) — the reviewer drops qa-approved.txt into the job dir.
const qaFile = path.join(jobDir, 'qa-approved.txt');
...
if (!fs.existsSync(qaFile)) {
console.log(`[${i + 1}/${jobs.length}] ${jobName} — awaiting manual QA (no qa-approved.txt), skipping`);
skipped++;
continue;
}
That one file turns "automation posts to my channel" into "automation prepares work; I approve
releases." Generation being automated is fine; publication being automated without a human gate
is how channels die. The unattended streak worked because approvals were queued up ahead of it —
the machine was never deciding what my channel says.
A health check runs before every scheduled cycle and verifies each credential, binary, and path the
run will need — an OAuth token that expired overnight should fail the run in second one, loudly,
not in stage six after an hour of rendering.
What automation doesn't fix
Honest closing, because this space is full of the opposite: automation did not make the channel
successful, and tooling never will. Niche selection, idea quality, and YouTube's recommendation
system decide that. What the pipeline bought me was marginal cost — once an idea was chosen and
approved, production and publication cost ~$0 and ~0 minutes. That's the correct promise of
automation: it makes shipping cheap, not popular.
I packaged this pipeline — full TypeScript source, setup guide from blank Windows PC to first
authenticated upload, the per-video QA checklist, and my idea-sourcing method — as a one-time
purchase: channel-kit on Gumroad. Self-hosted,
your API keys, no subscription, and no income promises for the reasons above.
Top comments (1)
Unattended pipelines usually fail in the unglamorous places: state, recovery, and knowing when not to publish. The impressive part is not running for eight days; it is having enough observability to understand what happened during those days.