Zero-cost local Python pipeline that writes, narrates, edits and publishes one family-friendly faceless YouTube Short per day. Halal by construction: no music, no faces, compliance gates before render.
Python
0
3 commits
updated Sep 15, 2026
A zero-cost, fully local Python pipeline that writes, narrates, edits and publishes one family-friendly YouTube Short per day.
analytics ──▶ script ──▶ voice ──▶ footage ──▶ assemble ──▶ upload
▲ │
└──────────── retention data reshapes tomorrow's prompt ◀───┘
Output: 1080×1920 vertical MP4, H.264/AAC, under 60 seconds, with kinetic word-by-word captions inside the Shorts safe zone.
This channel is built to be Halal by construction, not by review:
| Rule | How it is enforced |
|---|---|
| No musical instruments | Nothing but the edge-tts voiceover is ever mixed in. An optional ambient bed is off by default and documented for natural sound only. |
| No faces, no people | config.MEDIA_BLOCKLIST screens every stock clip's tags and URL before download; searches are restricted to nature, sky, space, architecture and texture. |
| No gossip, politics or sectarianism | script_generator.BANNED_PATTERNS rejects a script before it is ever voiced. |
| No scripture quoted or attributed | Blocked by the same filter — the scripts reflect, they do not issue rulings. |
| No misinformation | The prompt forbids statistics the model is unsure of; the offline bank is hand-checked. |
Every generated script must clear check_compliance() or it is regenerated. Run
python selftest.py to verify the whole policy layer at once.
Requires Python 3.10+. No system ffmpeg install is needed — imageio-ffmpeg
ships a binary and the pipeline finds it automatically.
pip install -r requirements.txt
or, explicitly:
pip install moviepy edge-tts requests google-api-python-client \
google-auth-oauthlib google-auth-httplib2 Pillow numpy imageio-ffmpeg
Then verify:
python selftest.py
You should see all checks pass. If "caption font resolves to a TrueType face"
fails, drop any .ttf (a bold face works best) into assets/fonts/ — a strong
free choice is Inter or
Montserrat Bold.
Two extra models unlock the offline voice and the depth-parallax editor. Neither is required — the pipeline runs with no models at all.
python setup_models.py # both (about 430 MB, one time)
python setup_models.py --check # report what is present, download nothing
| Model | Size | Unlocks | Extra pip |
|---|---|---|---|
| Kokoro-82M | 337 MB | TTS_ENGINE=kokoro — offline voice, 54 speakers, no quota | kokoro-onnx soundfile |
| Depth-Anything-V2-Small | 94 MB | story_editor.py, episode_render.py — 2.5D parallax | onnxruntime |
Both run on CPU. Neither needs a GPU or a PyTorch install.
cp .env.example .env # Windows: copy .env.example .env
Everything in .env is optional. With no keys at all the pipeline still
produces a complete video, using the curated offline script bank and a
generated gradient background. Keys make it better, not functional.
After editing .env, verify every credential by actually calling each service:
python check_keys.py
It reports each key as ok / -- (not set) / FAIL (set but rejected), and
tells you what the pipeline can currently do.
| Provider | Where to get a free key | Put it in |
|---|---|---|
| Groq (recommended) | https://console.groq.com/keys | GROQ_API_KEY |
| Google Gemini | https://aistudio.google.com/app/apikey | GEMINI_API_KEY |
OpenRouter (:free models) | https://openrouter.ai/keys | OPENROUTER_API_KEY |
| Ollama (local, no account) | https://ollama.com then ollama pull llama3.1:8b | nothing — just run it |
LLM_PROVIDERS sets the order they are tried. The last entry, offline, is the
local bank in data/topics.json and always succeeds, so a dead API never breaks
the daily run.
.env as PEXELS_API_KEY=...Optionally add a Pixabay key (https://pixabay.com/api/docs/) as
PIXABAY_API_KEY — it is used as a second source when Pexels has nothing safe.
Downloaded clips are cached in cache/ and pruned to MEDIA_CACHE_MAX files.
Go to https://console.cloud.google.com/ and create a project.
APIs & Services → Library → enable both:
Google Auth Platform → Branding → set only App name, User support email and Developer contact email. Leave everything else blank — especially the logo: uploading one forces the project into Google's verification review, and a homepage / privacy-policy / terms URL then drags in an authorised-domain check you would have to prove in Search Console. None of it is needed for a project that only touches your own channel.
Google Auth Platform → Audience (formerly OAuth consent screen) → choose External.
On that same page, click Publish app. This matters twice over:
Error 403: access_denied.Publishing is free. Because the YouTube scopes are sensitive, the consent screen will warn that Google has not verified the app — click Advanced → Go to <app> (unsafe). Verification is only required if you distribute the app to other people; for your own channel it is not.
APIs & Services → Credentials → Create credentials → OAuth client ID → Desktop app. Download the JSON.
Save it in this folder as client_secret.json.
Authorise once:
python youtube_uploader.py --auth
A browser opens; approve access. The refresh token is cached in
state/token.json and every later run is fully unattended.
client_secret.json,.envandstate/token.jsonare all in.gitignore. Never commit them.
If your Google account owns more than one channel — a personal one plus a Brand Account — the consent screen offers a channel picker, and it is easy to approve the wrong one. Nothing fails; uploads just land on the wrong channel.
Confirm before you upload anything:
python check_keys.py # prints "channel: <name> (<n> subscribers)"
If that is not the channel you meant, the cached token is pointing at the wrong
place and re-running --auth alone will not fix it. Delete the token first:
rm state/token.json && python youtube_uploader.py --auth
YOUTUBE_SCOPES in config.py decides what the token may do:
| Scope | Grants |
|---|---|
youtube.upload | Creating videos only. It cannot edit anything. |
youtube.force-ssl | Editing: privacy changes, cancelling or moving a scheduled release, fixing a title. |
yt-analytics.readonly | Retention and view data for the feedback loop. |
Two things that cost real debugging time:
youtube.upload cannot edit a video. Changing privacy or cancelling a
schedule needs force-ssl. This is not obvious from the error.No valid cached credentials on every call — including reads that worked a
minute earlier. It looks like the token broke. It did not; it is simply no
longer sufficient. Same fix:
rm state/token.json && python youtube_uploader.py --auth.| Credential | Required? | Free? | Where |
|---|---|---|---|
| Text model key (Groq / Gemini / OpenRouter) | No — offline bank is the fallback | Yes | see 2a |
| Ollama | No | Yes, local | https://ollama.com |
PEXELS_API_KEY | No — falls back to a generated gradient | Yes | https://www.pexels.com/api/ |
PIXABAY_API_KEY | No — second footage source | Yes | https://pixabay.com/api/docs/ |
client_secret.json | Yes, to upload | Yes | see 2c |
Everything this pipeline needs has a free tier. There is no paid dependency anywhere in the default configuration — see Cost.
python main.py # full pipeline: generate → render → upload
python main.py --no-upload # build output/output_short.mp4, publish nothing
python main.py --dry-run # render + print the exact metadata, publish nothing
python main.py --theme history # override the topic lane for this run
python main.py --skip-analytics # skip the feedback query (faster)
First run is best done as:
python main.py --dry-run
Inspect output/output_short.mp4, then drop --dry-run when you are happy.
Each module also runs standalone, which is the fastest way to iterate on one stage:
python script_generator.py --theme ethics
python voice_generator.py --voices en- # list available voices
python voice_generator.py
python media_fetcher.py "mountain aerial"
python video_assembler.py
python youtube_uploader.py --dry-run
python analytics_feedback.py
schtasks /create /tn "HalalShorts" /tr "python C:\Users\user\Documents\test-dev\main.py" /sc daily /st 07:00
Or via the GUI: Task Scheduler → Create Basic Task → Daily, action Start a
program, program python, arguments main.py, Start in
C:\Users\user\Documents\test-dev (this field matters — without it the relative
paths and .env will not resolve).
0 7 * * * cd /path/to/test-dev && /usr/bin/python3 main.py >> state/cron.log 2>&1
Run python youtube_uploader.py --auth once interactively first — a scheduled
task cannot open a browser.
After each upload, analytics_feedback.py queries the channel's own analytics:
audienceWatchRatio across elapsedVideoTimeRatio,
converted into the second at which viewers first drop off.Those numbers become concrete instructions written to state/insights.json, for
example:
Viewers typically drop off around 2.5 seconds in. That drop is inside the hook itself. Make the first sentence shorter than 10 words and lead with the most counter-intuitive detail.
script_generator.py appends that guidance to the model prompt, and
apply_insights() also adapts the target word count and the speech rate. So the
channel structurally tightens its hooks and pacing on its own.
BACKGROUND_STYLE chooses what the footage search looks for:
nature (default) — calm scenery matched to the script's own theme.satisfying — abstract "oddly satisfying" motion loops (ink diffusion,
smoke, fluid marbling, light) chosen for retention. The script's keywords are
deliberately ignored: the format pairs a spoken fact with an unrelated
hypnotic visual.mixed — one or the other at random each run.python main.py --style satisfying --no-upload
The obvious queries are unusable for a faceless channel, and this was measured
rather than assumed. On Pexels, asmr returns almost nothing but women at
microphones (6 of the first 6 results); slime, soap cutting, honey pouring
and wax melting are majority hands and presenters.
SATISFYING_QUERIES in media_fetcher.py therefore contains only abstract
motion — ink, smoke, fluid, light — which needs no performer. 95% of results
(133/140) pass the safety filter, versus roughly 30% for the naive terms. The
blocklist also now rejects microphones, mouths, hands and bodies.
If you add a query, audit it first:
python media_fetcher.py --style satisfying # then check what it matched
Note that only the visuals come from these clips — source audio is always stripped, so nothing about this affects the no-music policy.
Abstract loops are often much brighter than nature footage, and white captions
wash out on pale backgrounds. The assembler samples the clip's luminance and
raises the dim automatically until the background reaches CAPTION_BG_TARGET
(0.30), so a near-white clip is darkened far more than BG_DIM alone would.
batch.py renders a set in one run, rotating the topic lane and the background
treatment so the videos don't look or sound like the same one repeated.
python batch.py --count 5 # rotate themes and backgrounds
python batch.py --count 3 --themes science,history
python batch.py --count 4 --combo montage:tactile # pin one treatment
The compliance gates catch banned topics, filler, length and structure. They cannot fact-check, and in practice a meaningful share of generated scripts need a human edit — usually a number that does not survive checking. Rather than asking the model again and hoping, hand-edit the script and re-render exactly it:
python batch.py --script-json output/fixes/my-script.json --index 2 --combo montage:nature
The file is one script package:
{
"title": "Mail That Crossed an Empire in Weeks",
"slug": "mongol-postal-network",
"theme": "history",
"hook": "A single message crossed the Mongol Empire in weeks...",
"body": "...the rest of the script...",
"script": "<hook + body, exactly as spoken>",
"keywords": ["steppe", "grassland", "mountain pass"],
"clips": ["cache/pexels_38624197.mp4", "cache/pixabay_178078.mp4"]
}
output/batch/manifest.json by slug, so the
other videos in the batch keep their records.clips is optional and pins the footage. Stock search substitutes silently —
a "steppe" query has returned a tulip wood, an ocean cliff and an airliner's
wing — so once you have looked at a set of clips, pin them and skip search
entirely.Look at your footage before you publish. One still per clip is enough:
ffmpeg -ss 2 -i cache/<clip>.mp4 -frames:v 1 -vf scale=190:-1 sheet_1.png
ffmpeg -i sheet_%d.png -vf tile=5x2 -frames:v 1 sheet.png
If ffmpeg is not on your PATH, imageio-ffmpeg ships a binary — find it with
python -c "import imageio_ffmpeg; print(imageio_ffmpeg.get_ffmpeg_exe())".
Rotation for a default run:
| # | Theme | Background |
|---|---|---|
| 1 | science | montage / tactile |
| 2 | history | single / satisfying |
| 3 | social | montage / satisfying |
| 4 | tafakkur | single / nature |
| 5 | language | montage / mixed |
| 6 | ethics | single / tactile |
Videos land in output/batch/ with a manifest.json recording each script,
theme, background treatment and the clips used. Nothing is uploaded — review
the set, then publish the ones you want:
python youtube_uploader.py --video output/batch/01_<slug>.mp4
Titles generated earlier in the batch are fed back into later prompts, so the
videos don't converge on the same fact. (history.json only grows on upload, so
without that a batch of five would produce five near-identical scripts.)
Budget about three minutes per video. A failure in one video is logged and the batch continues.
| Lane | Content |
|---|---|
science | Physics, biology, chemistry, astronomy, the human body |
history | World and Islamic civilisational history |
social | Replicated findings about how people think and relate |
tafakkur | Wonders of creation, framed for reflection |
language | Etymology and the origins of everyday words |
ethics | Practical moral wisdom |
BATCH_THEMES in .env sets which lanes a batch rotates through.
Note the hook rule still applies: scripts never literally open with "Did you know" — that phrasing tests poorly for retention because it delays the payoff. The lanes cover the same "did you know" subject matter with a stronger opening line.
A montage of stock clips is the wrong shape for a story. Two modules handle the longer format, and both need only the depth model — no GPU.
python setup_models.py --depth
pip install onnxruntime
story_editor.py — stills into an edited sequencepython story_editor.py --shots ./my_images --out sequence.mp4
python story_editor.py --shots ./anime --out out.mp4 --parallax 38
Point it at a folder of images. It applies the four things that separate a human cut from an automated one:
Use --parallax 38 for illustration. Painted art has about 34% less depth
separation than photography (measured spread 0.557 vs 0.843), because most of
the frame is distant sky, so it needs a stronger push.
episode_render.py — a full 16:9 episode with dialoguepython episode_render.py --timeline timeline.json --shots ./shots --out episode.mp4
1920×1080, one still per beat, small subtitle-style captions at the bottom (44 px on a dark scrim, ~14% of frame height) so the art stays visible, and a mixed dialogue track. Shot length comes from the measured audio, not a guess.
timeline.json is a list of beats:
{"lead": 1.6, "tail": 2.6, "total": 144.9,
"beats": [{"id": "01", "line": "spoken text", "audio": "audio/01.wav",
"audio_s": 4.1, "start": 1.6, "dur": 5.0, "move": "push"}]}
move is one of push, driftL, driftR, tiltU, tiltD.
Generate the audio per beat with voice_generator, use each clip's real
duration for audio_s, and give different speakers different Kokoro voices —
am_michael, bm_george and am_adam are three distinguishable male voices.
| Subject | Best source | Why |
|---|---|---|
| A specific named real place | Wikimedia Commons | An invented building presented as a real one is the same class of error as an invented statistic |
| A generic setting | Generated (see notebooks/) | Stock substitutes silently; generation is deterministic |
| Abstract science | Reviewed stock | Generation is weak here — "carbon lattice" returns a grey diamond |
Free GPU for generation is in notebooks/ — Kaggle gives
30 GPU-hours a week against roughly 5 GPU-minutes a day from Hugging
Face Spaces. That directory also documents the two Kaggle traps (phone
verification for internet, and the P100/PyTorch mismatch) that otherwise cost
hours.
Rendering is slow: about 13 seconds per second of finished video at 1080×1920 (frame generation ~10s, encoding ~3s). It is memory-bandwidth bound across the per-frame passes, so a 20-second clip takes roughly 4½ minutes. Treat it as a batch cost, not something to tune — micro-optimising the warp and the grain bought only 13%.
Shorts replay automatically, so the replay boundary is a retention leak: a hard
jump there reads as "the video ended" and the viewer swipes. LOOP_SEAMLESS=1
(default) makes the background carry straight through the loop point.
The technique is a tail-into-head cross-dissolve. The background is built
LOOP_BLEND_SECONDS longer than needed, then:
body = source[blend : duration]
bridge = crossfade( source[duration : duration+blend] -> source[0 : blend] )
output = body + bridge
The bridge finishes on exactly the frame the output started from, so the frame after the last one is the frame that naturally follows it. Matching the first and last frame is not the goal - for moving footage they should differ by one frame of motion. Continuity of motion is the goal.
Two details that matter more than they look:
t = blend.
Dividing by blend leaves the last frame at 1 - 1/(fps x blend) weight, so a
few percent of the outgoing shot is still visible - and it is usually a
completely different clip. Fixing this alone took the seam from 5.0x to 3.0x.LOOP_TAIL_GAP clears the final card before the
end. Otherwise the seam flashes a caption off and on.The generated gradient background does not need any of this: its pan is a triangle wave, out and back, so the last frame is identical to the first.
selftest.py checks the seam against the video's own typical frame-to-frame
change, which is the only fair comparison:
video loops without a visible seam seam 6.4 vs typical 2.2 (2.9x)
Measured on the same script rendered both ways:
| seam delta | typical delta | ratio | |
|---|---|---|---|
LOOP_SEAMLESS=0 | 82.2 | 2.8 | 29.2x |
LOOP_SEAMLESS=1 | 6.4 | 2.1 | 3.0x |
A 9.6x smaller jump at the replay point. The residual is mostly H.264 keyframe artifacts; visually the boundary is indistinguishable from any other frame.
| Setting | Effect |
|---|---|
LOOP_SEAMLESS | Turn the cross-dissolve on or off |
LOOP_BLEND_SECONDS | Length of the dissolve (default 0.6s) |
LOOP_TAIL_GAP | Caption-free tail before the end (default 0.2s) |
OUTRO_PAD | Silence after the last word (default 0.3s - kept short, it is dead air on replay) |
BACKGROUND_MODE=montage cuts several different clips together instead of
looping one, which is the format used by "did you know + satisfying" channels.
python main.py --mode montage --style tactile --no-upload
Each clip contributes MONTAGE_SEGMENT_SECONDS (default 5s) and the sequence
cycles until the voiceover is covered. Revisiting a clip advances further into
it, so a small pool does not replay the same seconds.
tactile style (soap/fruit/clay cutting, slime) requires
MEDIA_PEOPLE_POLICY=hands, because hands are unavoidable in cutting footage.
Be clear about what that setting can and cannot do. An audit of 48 results across the cutting queries found:
| Labelled | Count |
|---|---|
| explicitly male | 1 |
| explicitly female | 3 |
| unlabelled hands | 4 |
| "a person ..." (gender unknown) | 20 |
| no person mentioned | 20 |
Stock metadata almost never records whose hands are in a clip. hands policy
removes clips explicitly labelled female and everything showing faces, bodies or
makeup — but on roughly two thirds of results it is guessing. A test fetch of six
"clean" clips returned three showing bare female forearms and one with bottles
visible on the counter, none of which the slug disclosed.
If the footage must meet a standard the metadata cannot express, use the approved pool.
curate.py)python curate.py --fetch 15 --style tactile # download candidates
# open assets/bgpool/pending/_contact_sheet.jpg — every clip as one image
# delete any .mp4 in pending/ you don't want
python curate.py --promote # approve the survivors
python curate.py --list # check pool size
Then set BACKGROUND_STYLE=pool and BACKGROUND_MODE=montage. The pipeline
then draws only from clips you have personally looked at, and never touches the
footage APIs at render time.
Budget roughly one review pass per month: at 5s segments you need about 10 approved clips for a repeat-free 50-second Short, and expect to reject well over half of what is fetched.
Two engines, set with TTS_ENGINE in .env.
edge (default) — Microsoft edge-ttsNetwork call, instant, unlimited, free, no account.
kokoro — Kokoro-82M, fully localpython setup_models.py --kokoro
pip install kokoro-onnx soundfile
TTS_ENGINE=kokoro
KOKORO_VOICE=am_michael # 54 voices; am_adam, am_michael, bm_george are deep males
KOKORO_SPEED=1.0
Runs on CPU in about 5–6 seconds per script. No account, no network at runtime,
no rate limit. Measured against the alternatives on identical text, am_adam
had the darkest timbre of everything tested (2212 Hz spectral centroid, below
both ElevenLabs voices and well below Edge's 2745–2858 Hz).
Kokoro emits no word-boundary events, which the kinetic captions depend on. The pipeline works around this by synthesising one sentence at a time, so each sentence's true duration is known and words are distributed inside it by length. Sentence boundaries are therefore exact and only intra-sentence timing is interpolated. Verified frame-by-frame: the highlighted caption word matches the spoken word throughout.
Kokoro is slower per word than Edge at +40%, so a script that ran 12.9s on
Edge runs about 19s on Kokoro at speed 1.0. Raise KOKORO_SPEED to compensate.
A note on ElevenLabs: its free tier sounds excellent but does not grant commercial rights — attribution is required and commercial use is separately prohibited, including monetised YouTube. Commercial use starts on a paid plan.
The edge default is en-US-ChristopherNeural at +12% — a deep US narrator
(Microsoft tags it News, Novel / Reliable, Authority) at roughly 155 wpm, brisk
enough for fast-fact Shorts while staying clear.
Rate is words-per-minute pacing, not pitch: +25% is about 173 wpm, +0% about
138 wpm, -8% about 127 wpm. Samples for every step are generated into
output/voice_samples/ by the snippet in this repo's history, or roll your own
with python voice_generator.py --text "..." after changing TTS_RATE.
Other strong free options in the same engine:
| Voice | Character |
|---|---|
en-US-ChristopherNeural | Deep, authoritative, documentary (default) |
en-US-SteffanNeural | Calm, rational, steady |
en-US-AndrewNeural | Warm and conversational; newest engine, least synthetic |
en-US-EricNeural | Measured, understated |
en-GB-RyanNeural / en-GB-ThomasNeural | British, articulate |
en-US-AriaNeural / en-US-JennyNeural | Female, warm narrative |
Note there is no en-GB-OliverNeural in the catalogue — Ryan and Thomas are the
only British male voices. Confirm anything you pick still exists:
python voice_generator.py --voices en-
Negative TTS_RATE values slow the delivery for a more reflective read; positive
values tighten it. A 45-word script runs about 14s at +12% and about 18s at
-8%, both far inside the 58-second ceiling.
The analytics loop may nudge the pace when retention moves, but only within
TTS_RATE_ADAPT_MAX percentage points of your configured rate, so an automatic
tweak can never undo a deliberate editorial choice.
main.py orchestrator — run this
config.py every tunable + .env loading
utils.py logging, JSON state, ffmpeg discovery, retries
script_generator.py free LLM providers + compliance gate + offline bank
voice_generator.py voiceover + per-word timings (edge-tts or local Kokoro)
media_fetcher.py Pexels/Pixabay search, safety screen, download cache
video_assembler.py MoviePy/ffmpeg render, PIL kinetic captions
youtube_uploader.py OAuth2, SEO metadata, resumable upload, quota ledger
analytics_feedback.py retention analysis → prompt guidance
selftest.py verification suite (run after any change)
OPERATIONS.md runbook + log of what was learned running this
curate.py build a hand-approved pool of background clips
batch.py render several videos at once, varied themes/backgrounds
check_keys.py validates every API key and the YouTube auth
setup_models.py downloads the optional local models (voice, depth)
story_editor.py stills -> edited sequence with depth parallax (no GPU)
episode_render.py full 16:9 episode: parallax + subtitles + dialogue track
notebooks/ Kaggle scripts for free-GPU image and video generation
data/topics.json curated offline script bank (28 entries, 3 themes)
output/ script.json, voiceover.mp3, output_short.mp4
state/ token.json, history.json, quota.json, insights.json
cache/ downloaded clips, plus tts/ and depth/ model weights
A default Google Cloud project gets 10,000 units/day. videos.insert costs
1,600, so roughly 6 uploads/day is the ceiling — one per day is
comfortable. state/quota.json tracks spend locally and refuses a job that would
exceed the allowance rather than failing mid-transfer. The quota resets at
midnight Pacific time.
| Want to change | Set in .env |
|---|---|
| Topic lane | CONTENT_THEME=tafakkur|history|ethics |
| Voice | TTS_VOICE (see python voice_generator.py --voices) |
| How far analytics may shift the pace | TTS_RATE_ADAPT_MAX=8 (percentage points) |
| Speaking speed | TTS_RATE=+12% (negative is slower) |
| Caption size / grouping | CAPTION_FONT_SIZE, CAPTION_MAX_WORDS_PER_CARD |
| Safe zones | SAFE_TOP, SAFE_BOTTOM, SAFE_SIDE, CAPTION_ANCHOR |
| Background darkness | BG_DIM (0 = untouched, 1 = black) |
| Background visual style | BACKGROUND_STYLE=nature|satisfying|tactile|pool|mixed |
| Single clip or montage | BACKGROUND_MODE=single|montage |
| Whether hands may appear | MEDIA_PEOPLE_POLICY=none|hands |
| Render speed vs size | ENCODE_PRESET=veryfast|medium|slow |
| Upload visibility | YOUTUBE_PRIVACY=public|unlisted|private |
Captions default to a band from 20% to 74% of frame height, horizontally centred with a 9% side margin — clear of the Shorts title, the channel row, the description and the right-hand action rail.
| Symptom | Fix |
|---|---|
| Captions render as a tiny bitmap font | Drop a .ttf into assets/fonts/ |
edge-tts returned no audio | Check the connection, or try another TTS_VOICE |
| Every script comes from the offline bank | No LLM key is set, or all endpoints are unreachable — check the warnings above the fallback line |
| Background is always a gradient | PEXELS_API_KEY is unset, or nothing passed the safety screen for those keywords |
Error 403: access_denied / "app has not completed verification" | The OAuth app is still in Testing and your account is not a listed tester. Google Auth Platform → Audience → either add your email under Test users, or Publish app (preferred — see below) |
| "Google hasn't verified this app" warning | Expected for a published app using sensitive YouTube scopes. Click Advanced → Go to <app> (unsafe). Verification is only required to distribute the app to other people |
invalid_grant on upload | Token expired because the app is still in "Testing" mode (7-day limit) — publish the OAuth app, delete state/token.json, re-run --auth |
| "authorised, but this Google account has no YouTube channel" | You approved the wrong Google account — delete state/token.json and re-run --auth |
| Render feels slow | ENCODE_PRESET=ultrafast and/or FPS=24 |
$0. edge-tts, Pexels, Pixabay, Groq/Gemini/OpenRouter free tiers, the YouTube
APIs, MoviePy and ffmpeg are all free at this volume. Nothing here requires a
paid subscription.
Zero-cost local Python pipeline that writes, narrates, edits and publishes one family-friendly faceless YouTube Short per day. Halal by construction: no music, no faces, compliance gates before render.
Python
0
3 commits
updated Sep 15, 2026
A zero-cost, fully local Python pipeline that writes, narrates, edits and publishes one family-friendly YouTube Short per day.
analytics ──▶ script ──▶ voice ──▶ footage ──▶ assemble ──▶ upload
▲ │
└──────────── retention data reshapes tomorrow's prompt ◀───┘
Output: 1080×1920 vertical MP4, H.264/AAC, under 60 seconds, with kinetic word-by-word captions inside the Shorts safe zone.
This channel is built to be Halal by construction, not by review:
| Rule | How it is enforced |
|---|---|
| No musical instruments | Nothing but the edge-tts voiceover is ever mixed in. An optional ambient bed is off by default and documented for natural sound only. |
| No faces, no people | config.MEDIA_BLOCKLIST screens every stock clip's tags and URL before download; searches are restricted to nature, sky, space, architecture and texture. |
| No gossip, politics or sectarianism | script_generator.BANNED_PATTERNS rejects a script before it is ever voiced. |
| No scripture quoted or attributed | Blocked by the same filter — the scripts reflect, they do not issue rulings. |
| No misinformation | The prompt forbids statistics the model is unsure of; the offline bank is hand-checked. |
Every generated script must clear check_compliance() or it is regenerated. Run
python selftest.py to verify the whole policy layer at once.
Requires Python 3.10+. No system ffmpeg install is needed — imageio-ffmpeg
ships a binary and the pipeline finds it automatically.
pip install -r requirements.txt
or, explicitly:
pip install moviepy edge-tts requests google-api-python-client \
google-auth-oauthlib google-auth-httplib2 Pillow numpy imageio-ffmpeg
Then verify:
python selftest.py
You should see all checks pass. If "caption font resolves to a TrueType face"
fails, drop any .ttf (a bold face works best) into assets/fonts/ — a strong
free choice is Inter or
Montserrat Bold.
Two extra models unlock the offline voice and the depth-parallax editor. Neither is required — the pipeline runs with no models at all.
python setup_models.py # both (about 430 MB, one time)
python setup_models.py --check # report what is present, download nothing
| Model | Size | Unlocks | Extra pip |
|---|---|---|---|
| Kokoro-82M | 337 MB | TTS_ENGINE=kokoro — offline voice, 54 speakers, no quota | kokoro-onnx soundfile |
| Depth-Anything-V2-Small | 94 MB | story_editor.py, episode_render.py — 2.5D parallax | onnxruntime |
Both run on CPU. Neither needs a GPU or a PyTorch install.
cp .env.example .env # Windows: copy .env.example .env
Everything in .env is optional. With no keys at all the pipeline still
produces a complete video, using the curated offline script bank and a
generated gradient background. Keys make it better, not functional.
After editing .env, verify every credential by actually calling each service:
python check_keys.py
It reports each key as ok / -- (not set) / FAIL (set but rejected), and
tells you what the pipeline can currently do.
| Provider | Where to get a free key | Put it in |
|---|---|---|
| Groq (recommended) | https://console.groq.com/keys | GROQ_API_KEY |
| Google Gemini | https://aistudio.google.com/app/apikey | GEMINI_API_KEY |
OpenRouter (:free models) | https://openrouter.ai/keys | OPENROUTER_API_KEY |
| Ollama (local, no account) | https://ollama.com then ollama pull llama3.1:8b | nothing — just run it |
LLM_PROVIDERS sets the order they are tried. The last entry, offline, is the
local bank in data/topics.json and always succeeds, so a dead API never breaks
the daily run.
.env as PEXELS_API_KEY=...Optionally add a Pixabay key (https://pixabay.com/api/docs/) as
PIXABAY_API_KEY — it is used as a second source when Pexels has nothing safe.
Downloaded clips are cached in cache/ and pruned to MEDIA_CACHE_MAX files.
Go to https://console.cloud.google.com/ and create a project.
APIs & Services → Library → enable both:
Google Auth Platform → Branding → set only App name, User support email and Developer contact email. Leave everything else blank — especially the logo: uploading one forces the project into Google's verification review, and a homepage / privacy-policy / terms URL then drags in an authorised-domain check you would have to prove in Search Console. None of it is needed for a project that only touches your own channel.
Google Auth Platform → Audience (formerly OAuth consent screen) → choose External.
On that same page, click Publish app. This matters twice over:
Error 403: access_denied.Publishing is free. Because the YouTube scopes are sensitive, the consent screen will warn that Google has not verified the app — click Advanced → Go to <app> (unsafe). Verification is only required if you distribute the app to other people; for your own channel it is not.
APIs & Services → Credentials → Create credentials → OAuth client ID → Desktop app. Download the JSON.
Save it in this folder as client_secret.json.
Authorise once:
python youtube_uploader.py --auth
A browser opens; approve access. The refresh token is cached in
state/token.json and every later run is fully unattended.
client_secret.json,.envandstate/token.jsonare all in.gitignore. Never commit them.
If your Google account owns more than one channel — a personal one plus a Brand Account — the consent screen offers a channel picker, and it is easy to approve the wrong one. Nothing fails; uploads just land on the wrong channel.
Confirm before you upload anything:
python check_keys.py # prints "channel: <name> (<n> subscribers)"
If that is not the channel you meant, the cached token is pointing at the wrong
place and re-running --auth alone will not fix it. Delete the token first:
rm state/token.json && python youtube_uploader.py --auth
YOUTUBE_SCOPES in config.py decides what the token may do:
| Scope | Grants |
|---|---|
youtube.upload | Creating videos only. It cannot edit anything. |
youtube.force-ssl | Editing: privacy changes, cancelling or moving a scheduled release, fixing a title. |
yt-analytics.readonly | Retention and view data for the feedback loop. |
Two things that cost real debugging time:
youtube.upload cannot edit a video. Changing privacy or cancelling a
schedule needs force-ssl. This is not obvious from the error.No valid cached credentials on every call — including reads that worked a
minute earlier. It looks like the token broke. It did not; it is simply no
longer sufficient. Same fix:
rm state/token.json && python youtube_uploader.py --auth.| Credential | Required? | Free? | Where |
|---|---|---|---|
| Text model key (Groq / Gemini / OpenRouter) | No — offline bank is the fallback | Yes | see 2a |
| Ollama | No | Yes, local | https://ollama.com |
PEXELS_API_KEY | No — falls back to a generated gradient | Yes | https://www.pexels.com/api/ |
PIXABAY_API_KEY | No — second footage source | Yes | https://pixabay.com/api/docs/ |
client_secret.json | Yes, to upload | Yes | see 2c |
Everything this pipeline needs has a free tier. There is no paid dependency anywhere in the default configuration — see Cost.
python main.py # full pipeline: generate → render → upload
python main.py --no-upload # build output/output_short.mp4, publish nothing
python main.py --dry-run # render + print the exact metadata, publish nothing
python main.py --theme history # override the topic lane for this run
python main.py --skip-analytics # skip the feedback query (faster)
First run is best done as:
python main.py --dry-run
Inspect output/output_short.mp4, then drop --dry-run when you are happy.
Each module also runs standalone, which is the fastest way to iterate on one stage:
python script_generator.py --theme ethics
python voice_generator.py --voices en- # list available voices
python voice_generator.py
python media_fetcher.py "mountain aerial"
python video_assembler.py
python youtube_uploader.py --dry-run
python analytics_feedback.py
schtasks /create /tn "HalalShorts" /tr "python C:\Users\user\Documents\test-dev\main.py" /sc daily /st 07:00
Or via the GUI: Task Scheduler → Create Basic Task → Daily, action Start a
program, program python, arguments main.py, Start in
C:\Users\user\Documents\test-dev (this field matters — without it the relative
paths and .env will not resolve).
0 7 * * * cd /path/to/test-dev && /usr/bin/python3 main.py >> state/cron.log 2>&1
Run python youtube_uploader.py --auth once interactively first — a scheduled
task cannot open a browser.
After each upload, analytics_feedback.py queries the channel's own analytics:
audienceWatchRatio across elapsedVideoTimeRatio,
converted into the second at which viewers first drop off.Those numbers become concrete instructions written to state/insights.json, for
example:
Viewers typically drop off around 2.5 seconds in. That drop is inside the hook itself. Make the first sentence shorter than 10 words and lead with the most counter-intuitive detail.
script_generator.py appends that guidance to the model prompt, and
apply_insights() also adapts the target word count and the speech rate. So the
channel structurally tightens its hooks and pacing on its own.
BACKGROUND_STYLE chooses what the footage search looks for:
nature (default) — calm scenery matched to the script's own theme.satisfying — abstract "oddly satisfying" motion loops (ink diffusion,
smoke, fluid marbling, light) chosen for retention. The script's keywords are
deliberately ignored: the format pairs a spoken fact with an unrelated
hypnotic visual.mixed — one or the other at random each run.python main.py --style satisfying --no-upload
The obvious queries are unusable for a faceless channel, and this was measured
rather than assumed. On Pexels, asmr returns almost nothing but women at
microphones (6 of the first 6 results); slime, soap cutting, honey pouring
and wax melting are majority hands and presenters.
SATISFYING_QUERIES in media_fetcher.py therefore contains only abstract
motion — ink, smoke, fluid, light — which needs no performer. 95% of results
(133/140) pass the safety filter, versus roughly 30% for the naive terms. The
blocklist also now rejects microphones, mouths, hands and bodies.
If you add a query, audit it first:
python media_fetcher.py --style satisfying # then check what it matched
Note that only the visuals come from these clips — source audio is always stripped, so nothing about this affects the no-music policy.
Abstract loops are often much brighter than nature footage, and white captions
wash out on pale backgrounds. The assembler samples the clip's luminance and
raises the dim automatically until the background reaches CAPTION_BG_TARGET
(0.30), so a near-white clip is darkened far more than BG_DIM alone would.
batch.py renders a set in one run, rotating the topic lane and the background
treatment so the videos don't look or sound like the same one repeated.
python batch.py --count 5 # rotate themes and backgrounds
python batch.py --count 3 --themes science,history
python batch.py --count 4 --combo montage:tactile # pin one treatment
The compliance gates catch banned topics, filler, length and structure. They cannot fact-check, and in practice a meaningful share of generated scripts need a human edit — usually a number that does not survive checking. Rather than asking the model again and hoping, hand-edit the script and re-render exactly it:
python batch.py --script-json output/fixes/my-script.json --index 2 --combo montage:nature
The file is one script package:
{
"title": "Mail That Crossed an Empire in Weeks",
"slug": "mongol-postal-network",
"theme": "history",
"hook": "A single message crossed the Mongol Empire in weeks...",
"body": "...the rest of the script...",
"script": "<hook + body, exactly as spoken>",
"keywords": ["steppe", "grassland", "mountain pass"],
"clips": ["cache/pexels_38624197.mp4", "cache/pixabay_178078.mp4"]
}
output/batch/manifest.json by slug, so the
other videos in the batch keep their records.clips is optional and pins the footage. Stock search substitutes silently —
a "steppe" query has returned a tulip wood, an ocean cliff and an airliner's
wing — so once you have looked at a set of clips, pin them and skip search
entirely.Look at your footage before you publish. One still per clip is enough:
ffmpeg -ss 2 -i cache/<clip>.mp4 -frames:v 1 -vf scale=190:-1 sheet_1.png
ffmpeg -i sheet_%d.png -vf tile=5x2 -frames:v 1 sheet.png
If ffmpeg is not on your PATH, imageio-ffmpeg ships a binary — find it with
python -c "import imageio_ffmpeg; print(imageio_ffmpeg.get_ffmpeg_exe())".
Rotation for a default run:
| # | Theme | Background |
|---|---|---|
| 1 | science | montage / tactile |
| 2 | history | single / satisfying |
| 3 | social | montage / satisfying |
| 4 | tafakkur | single / nature |
| 5 | language | montage / mixed |
| 6 | ethics | single / tactile |
Videos land in output/batch/ with a manifest.json recording each script,
theme, background treatment and the clips used. Nothing is uploaded — review
the set, then publish the ones you want:
python youtube_uploader.py --video output/batch/01_<slug>.mp4
Titles generated earlier in the batch are fed back into later prompts, so the
videos don't converge on the same fact. (history.json only grows on upload, so
without that a batch of five would produce five near-identical scripts.)
Budget about three minutes per video. A failure in one video is logged and the batch continues.
| Lane | Content |
|---|---|
science | Physics, biology, chemistry, astronomy, the human body |
history | World and Islamic civilisational history |
social | Replicated findings about how people think and relate |
tafakkur | Wonders of creation, framed for reflection |
language | Etymology and the origins of everyday words |
ethics | Practical moral wisdom |
BATCH_THEMES in .env sets which lanes a batch rotates through.
Note the hook rule still applies: scripts never literally open with "Did you know" — that phrasing tests poorly for retention because it delays the payoff. The lanes cover the same "did you know" subject matter with a stronger opening line.
A montage of stock clips is the wrong shape for a story. Two modules handle the longer format, and both need only the depth model — no GPU.
python setup_models.py --depth
pip install onnxruntime
story_editor.py — stills into an edited sequencepython story_editor.py --shots ./my_images --out sequence.mp4
python story_editor.py --shots ./anime --out out.mp4 --parallax 38
Point it at a folder of images. It applies the four things that separate a human cut from an automated one:
Use --parallax 38 for illustration. Painted art has about 34% less depth
separation than photography (measured spread 0.557 vs 0.843), because most of
the frame is distant sky, so it needs a stronger push.
episode_render.py — a full 16:9 episode with dialoguepython episode_render.py --timeline timeline.json --shots ./shots --out episode.mp4
1920×1080, one still per beat, small subtitle-style captions at the bottom (44 px on a dark scrim, ~14% of frame height) so the art stays visible, and a mixed dialogue track. Shot length comes from the measured audio, not a guess.
timeline.json is a list of beats:
{"lead": 1.6, "tail": 2.6, "total": 144.9,
"beats": [{"id": "01", "line": "spoken text", "audio": "audio/01.wav",
"audio_s": 4.1, "start": 1.6, "dur": 5.0, "move": "push"}]}
move is one of push, driftL, driftR, tiltU, tiltD.
Generate the audio per beat with voice_generator, use each clip's real
duration for audio_s, and give different speakers different Kokoro voices —
am_michael, bm_george and am_adam are three distinguishable male voices.
| Subject | Best source | Why |
|---|---|---|
| A specific named real place | Wikimedia Commons | An invented building presented as a real one is the same class of error as an invented statistic |
| A generic setting | Generated (see notebooks/) | Stock substitutes silently; generation is deterministic |
| Abstract science | Reviewed stock | Generation is weak here — "carbon lattice" returns a grey diamond |
Free GPU for generation is in notebooks/ — Kaggle gives
30 GPU-hours a week against roughly 5 GPU-minutes a day from Hugging
Face Spaces. That directory also documents the two Kaggle traps (phone
verification for internet, and the P100/PyTorch mismatch) that otherwise cost
hours.
Rendering is slow: about 13 seconds per second of finished video at 1080×1920 (frame generation ~10s, encoding ~3s). It is memory-bandwidth bound across the per-frame passes, so a 20-second clip takes roughly 4½ minutes. Treat it as a batch cost, not something to tune — micro-optimising the warp and the grain bought only 13%.
Shorts replay automatically, so the replay boundary is a retention leak: a hard
jump there reads as "the video ended" and the viewer swipes. LOOP_SEAMLESS=1
(default) makes the background carry straight through the loop point.
The technique is a tail-into-head cross-dissolve. The background is built
LOOP_BLEND_SECONDS longer than needed, then:
body = source[blend : duration]
bridge = crossfade( source[duration : duration+blend] -> source[0 : blend] )
output = body + bridge
The bridge finishes on exactly the frame the output started from, so the frame after the last one is the frame that naturally follows it. Matching the first and last frame is not the goal - for moving footage they should differ by one frame of motion. Continuity of motion is the goal.
Two details that matter more than they look:
t = blend.
Dividing by blend leaves the last frame at 1 - 1/(fps x blend) weight, so a
few percent of the outgoing shot is still visible - and it is usually a
completely different clip. Fixing this alone took the seam from 5.0x to 3.0x.LOOP_TAIL_GAP clears the final card before the
end. Otherwise the seam flashes a caption off and on.The generated gradient background does not need any of this: its pan is a triangle wave, out and back, so the last frame is identical to the first.
selftest.py checks the seam against the video's own typical frame-to-frame
change, which is the only fair comparison:
video loops without a visible seam seam 6.4 vs typical 2.2 (2.9x)
Measured on the same script rendered both ways:
| seam delta | typical delta | ratio | |
|---|---|---|---|
LOOP_SEAMLESS=0 | 82.2 | 2.8 | 29.2x |
LOOP_SEAMLESS=1 | 6.4 | 2.1 | 3.0x |
A 9.6x smaller jump at the replay point. The residual is mostly H.264 keyframe artifacts; visually the boundary is indistinguishable from any other frame.
| Setting | Effect |
|---|---|
LOOP_SEAMLESS | Turn the cross-dissolve on or off |
LOOP_BLEND_SECONDS | Length of the dissolve (default 0.6s) |
LOOP_TAIL_GAP | Caption-free tail before the end (default 0.2s) |
OUTRO_PAD | Silence after the last word (default 0.3s - kept short, it is dead air on replay) |
BACKGROUND_MODE=montage cuts several different clips together instead of
looping one, which is the format used by "did you know + satisfying" channels.
python main.py --mode montage --style tactile --no-upload
Each clip contributes MONTAGE_SEGMENT_SECONDS (default 5s) and the sequence
cycles until the voiceover is covered. Revisiting a clip advances further into
it, so a small pool does not replay the same seconds.
tactile style (soap/fruit/clay cutting, slime) requires
MEDIA_PEOPLE_POLICY=hands, because hands are unavoidable in cutting footage.
Be clear about what that setting can and cannot do. An audit of 48 results across the cutting queries found:
| Labelled | Count |
|---|---|
| explicitly male | 1 |
| explicitly female | 3 |
| unlabelled hands | 4 |
| "a person ..." (gender unknown) | 20 |
| no person mentioned | 20 |
Stock metadata almost never records whose hands are in a clip. hands policy
removes clips explicitly labelled female and everything showing faces, bodies or
makeup — but on roughly two thirds of results it is guessing. A test fetch of six
"clean" clips returned three showing bare female forearms and one with bottles
visible on the counter, none of which the slug disclosed.
If the footage must meet a standard the metadata cannot express, use the approved pool.
curate.py)python curate.py --fetch 15 --style tactile # download candidates
# open assets/bgpool/pending/_contact_sheet.jpg — every clip as one image
# delete any .mp4 in pending/ you don't want
python curate.py --promote # approve the survivors
python curate.py --list # check pool size
Then set BACKGROUND_STYLE=pool and BACKGROUND_MODE=montage. The pipeline
then draws only from clips you have personally looked at, and never touches the
footage APIs at render time.
Budget roughly one review pass per month: at 5s segments you need about 10 approved clips for a repeat-free 50-second Short, and expect to reject well over half of what is fetched.
Two engines, set with TTS_ENGINE in .env.
edge (default) — Microsoft edge-ttsNetwork call, instant, unlimited, free, no account.
kokoro — Kokoro-82M, fully localpython setup_models.py --kokoro
pip install kokoro-onnx soundfile
TTS_ENGINE=kokoro
KOKORO_VOICE=am_michael # 54 voices; am_adam, am_michael, bm_george are deep males
KOKORO_SPEED=1.0
Runs on CPU in about 5–6 seconds per script. No account, no network at runtime,
no rate limit. Measured against the alternatives on identical text, am_adam
had the darkest timbre of everything tested (2212 Hz spectral centroid, below
both ElevenLabs voices and well below Edge's 2745–2858 Hz).
Kokoro emits no word-boundary events, which the kinetic captions depend on. The pipeline works around this by synthesising one sentence at a time, so each sentence's true duration is known and words are distributed inside it by length. Sentence boundaries are therefore exact and only intra-sentence timing is interpolated. Verified frame-by-frame: the highlighted caption word matches the spoken word throughout.
Kokoro is slower per word than Edge at +40%, so a script that ran 12.9s on
Edge runs about 19s on Kokoro at speed 1.0. Raise KOKORO_SPEED to compensate.
A note on ElevenLabs: its free tier sounds excellent but does not grant commercial rights — attribution is required and commercial use is separately prohibited, including monetised YouTube. Commercial use starts on a paid plan.
The edge default is en-US-ChristopherNeural at +12% — a deep US narrator
(Microsoft tags it News, Novel / Reliable, Authority) at roughly 155 wpm, brisk
enough for fast-fact Shorts while staying clear.
Rate is words-per-minute pacing, not pitch: +25% is about 173 wpm, +0% about
138 wpm, -8% about 127 wpm. Samples for every step are generated into
output/voice_samples/ by the snippet in this repo's history, or roll your own
with python voice_generator.py --text "..." after changing TTS_RATE.
Other strong free options in the same engine:
| Voice | Character |
|---|---|
en-US-ChristopherNeural | Deep, authoritative, documentary (default) |
en-US-SteffanNeural | Calm, rational, steady |
en-US-AndrewNeural | Warm and conversational; newest engine, least synthetic |
en-US-EricNeural | Measured, understated |
en-GB-RyanNeural / en-GB-ThomasNeural | British, articulate |
en-US-AriaNeural / en-US-JennyNeural | Female, warm narrative |
Note there is no en-GB-OliverNeural in the catalogue — Ryan and Thomas are the
only British male voices. Confirm anything you pick still exists:
python voice_generator.py --voices en-
Negative TTS_RATE values slow the delivery for a more reflective read; positive
values tighten it. A 45-word script runs about 14s at +12% and about 18s at
-8%, both far inside the 58-second ceiling.
The analytics loop may nudge the pace when retention moves, but only within
TTS_RATE_ADAPT_MAX percentage points of your configured rate, so an automatic
tweak can never undo a deliberate editorial choice.
main.py orchestrator — run this
config.py every tunable + .env loading
utils.py logging, JSON state, ffmpeg discovery, retries
script_generator.py free LLM providers + compliance gate + offline bank
voice_generator.py voiceover + per-word timings (edge-tts or local Kokoro)
media_fetcher.py Pexels/Pixabay search, safety screen, download cache
video_assembler.py MoviePy/ffmpeg render, PIL kinetic captions
youtube_uploader.py OAuth2, SEO metadata, resumable upload, quota ledger
analytics_feedback.py retention analysis → prompt guidance
selftest.py verification suite (run after any change)
OPERATIONS.md runbook + log of what was learned running this
curate.py build a hand-approved pool of background clips
batch.py render several videos at once, varied themes/backgrounds
check_keys.py validates every API key and the YouTube auth
setup_models.py downloads the optional local models (voice, depth)
story_editor.py stills -> edited sequence with depth parallax (no GPU)
episode_render.py full 16:9 episode: parallax + subtitles + dialogue track
notebooks/ Kaggle scripts for free-GPU image and video generation
data/topics.json curated offline script bank (28 entries, 3 themes)
output/ script.json, voiceover.mp3, output_short.mp4
state/ token.json, history.json, quota.json, insights.json
cache/ downloaded clips, plus tts/ and depth/ model weights
A default Google Cloud project gets 10,000 units/day. videos.insert costs
1,600, so roughly 6 uploads/day is the ceiling — one per day is
comfortable. state/quota.json tracks spend locally and refuses a job that would
exceed the allowance rather than failing mid-transfer. The quota resets at
midnight Pacific time.
| Want to change | Set in .env |
|---|---|
| Topic lane | CONTENT_THEME=tafakkur|history|ethics |
| Voice | TTS_VOICE (see python voice_generator.py --voices) |
| How far analytics may shift the pace | TTS_RATE_ADAPT_MAX=8 (percentage points) |
| Speaking speed | TTS_RATE=+12% (negative is slower) |
| Caption size / grouping | CAPTION_FONT_SIZE, CAPTION_MAX_WORDS_PER_CARD |
| Safe zones | SAFE_TOP, SAFE_BOTTOM, SAFE_SIDE, CAPTION_ANCHOR |
| Background darkness | BG_DIM (0 = untouched, 1 = black) |
| Background visual style | BACKGROUND_STYLE=nature|satisfying|tactile|pool|mixed |
| Single clip or montage | BACKGROUND_MODE=single|montage |
| Whether hands may appear | MEDIA_PEOPLE_POLICY=none|hands |
| Render speed vs size | ENCODE_PRESET=veryfast|medium|slow |
| Upload visibility | YOUTUBE_PRIVACY=public|unlisted|private |
Captions default to a band from 20% to 74% of frame height, horizontally centred with a 9% side margin — clear of the Shorts title, the channel row, the description and the right-hand action rail.
| Symptom | Fix |
|---|---|
| Captions render as a tiny bitmap font | Drop a .ttf into assets/fonts/ |
edge-tts returned no audio | Check the connection, or try another TTS_VOICE |
| Every script comes from the offline bank | No LLM key is set, or all endpoints are unreachable — check the warnings above the fallback line |
| Background is always a gradient | PEXELS_API_KEY is unset, or nothing passed the safety screen for those keywords |
Error 403: access_denied / "app has not completed verification" | The OAuth app is still in Testing and your account is not a listed tester. Google Auth Platform → Audience → either add your email under Test users, or Publish app (preferred — see below) |
| "Google hasn't verified this app" warning | Expected for a published app using sensitive YouTube scopes. Click Advanced → Go to <app> (unsafe). Verification is only required to distribute the app to other people |
invalid_grant on upload | Token expired because the app is still in "Testing" mode (7-day limit) — publish the OAuth app, delete state/token.json, re-run --auth |
| "authorised, but this Google account has no YouTube channel" | You approved the wrong Google account — delete state/token.json and re-run --auth |
| Render feels slow | ENCODE_PRESET=ultrafast and/or FPS=24 |
$0. edge-tts, Pexels, Pixabay, Groq/Gemini/OpenRouter free tiers, the YouTube
APIs, MoviePy and ffmpeg are all free at this volume. Nothing here requires a
paid subscription.