Rayen-Slouma/halal-shorts-pipeline

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

See the code

README

Halal Faceless Shorts — Automated Daily Pipeline

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.


Content and audio policy

This channel is built to be Halal by construction, not by review:

RuleHow it is enforced
No musical instrumentsNothing 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 peopleconfig.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 sectarianismscript_generator.BANNED_PATTERNS rejects a script before it is ever voiced.
No scripture quoted or attributedBlocked by the same filter — the scripts reflect, they do not issue rulings.
No misinformationThe 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.


1. Install

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.


Optional local models

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
ModelSizeUnlocksExtra pip
Kokoro-82M337 MBTTS_ENGINE=kokoro — offline voice, 54 speakers, no quotakokoro-onnx soundfile
Depth-Anything-V2-Small94 MBstory_editor.py, episode_render.py — 2.5D parallaxonnxruntime

Both run on CPU. Neither needs a GPU or a PyTorch install.


2. Configure

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.

2a. Text model (free tiers — pick one)

ProviderWhere to get a free keyPut it in
Groq (recommended)https://console.groq.com/keysGROQ_API_KEY
Google Geminihttps://aistudio.google.com/app/apikeyGEMINI_API_KEY
OpenRouter (:free models)https://openrouter.ai/keysOPENROUTER_API_KEY
Ollama (local, no account)https://ollama.com then ollama pull llama3.1:8bnothing — 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.

2b. Stock footage (free)

  1. Create a free Pexels account and request an API key: https://www.pexels.com/api/
  2. Put it in .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.

2c. YouTube Data API v3 (free)

  1. Go to https://console.cloud.google.com/ and create a project.

  2. APIs & Services → Library → enable both:

    • YouTube Data API v3
    • YouTube Analytics API
  3. 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.

  4. Google Auth Platform → Audience (formerly OAuth consent screen) → choose External.

  5. On that same page, click Publish app. This matters twice over:

    • While the app is in Testing, only accounts explicitly listed under Test users can authorise at all — otherwise you get Error 403: access_denied.
    • Testing-mode refresh tokens expire after 7 days, which silently kills a scheduled daily run once a week.

    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.

  6. APIs & Services → Credentials → Create credentials → OAuth client ID → Desktop app. Download the JSON.

  7. Save it in this folder as client_secret.json.

  8. 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, .env and state/token.json are all in .gitignore. Never commit them.

2d. Check you authorised the right channel

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

2e. Scopes, and what happens when you change one

YOUTUBE_SCOPES in config.py decides what the token may do:

ScopeGrants
youtube.uploadCreating videos only. It cannot edit anything.
youtube.force-sslEditing: privacy changes, cancelling or moving a scheduled release, fixing a title.
yt-analytics.readonlyRetention 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.
  • Adding a scope invalidates the cached consent, and the first symptom is 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.

Credentials at a glance

CredentialRequired?Free?Where
Text model key (Groq / Gemini / OpenRouter)No — offline bank is the fallbackYessee 2a
OllamaNoYes, localhttps://ollama.com
PEXELS_API_KEYNo — falls back to a generated gradientYeshttps://www.pexels.com/api/
PIXABAY_API_KEYNo — second footage sourceYeshttps://pixabay.com/api/docs/
client_secret.jsonYes, to uploadYessee 2c

Everything this pipeline needs has a free tier. There is no paid dependency anywhere in the default configuration — see Cost.


3. Run

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

4. Schedule it daily

Windows (Task Scheduler)

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).

macOS / Linux (cron)

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.


5. How the feedback loop works

After each upload, analytics_feedback.py queries the channel's own analytics:

  • Per-video summary — views, average view percentage, average view duration.
  • Retention curve — 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

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

Why "ASMR" is not a search term here

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.

Caption contrast

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.


Making several videos at once

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

Re-rendering a corrected script

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"]
}
  • The compliance gate still runs on a hand-edited script; only generation is skipped.
  • The result is merged into 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:

#ThemeBackground
1sciencemontage / tactile
2historysingle / satisfying
3socialmontage / satisfying
4tafakkursingle / nature
5languagemontage / mixed
6ethicssingle / 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.

Topic lanes

LaneContent
sciencePhysics, biology, chemistry, astronomy, the human body
historyWorld and Islamic civilisational history
socialReplicated findings about how people think and relate
tafakkurWonders of creation, framed for reflection
languageEtymology and the origins of everyday words
ethicsPractical 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.


Story videos and full episodes

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 sequence

python 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:

  1. Depth parallax — near pixels move more than far ones. A flat pan across a still is the most recognisable "made by a script" tell; parallax is not.
  2. Handheld drift — two incommensurate sines per axis. No operator is perfectly steady, and locked-off perfection reads as CG.
  3. Film grain — breaks the plastic smoothness of generated frames.
  4. Varied shot length and eased moves — a fixed segment length and linear motion both give the game away.

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 dialogue

python 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.

Where the pictures come from

SubjectBest sourceWhy
A specific named real placeWikimedia CommonsAn invented building presented as a real one is the same class of error as an invented statistic
A generic settingGenerated (see notebooks/)Stock substitutes silently; generation is deterministic
Abstract scienceReviewed stockGeneration 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%.


Seamless looping

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:

  • The fade must complete on the final rendered frame, not at 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.
  • The caption layer has to loop too. The video opens with no caption (the first word lands ~0.1s in), so 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.

Measuring it

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 deltatypical deltaratio
LOOP_SEAMLESS=082.22.829.2x
LOOP_SEAMLESS=16.42.13.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.

SettingEffect
LOOP_SEAMLESSTurn the cross-dissolve on or off
LOOP_BLEND_SECONDSLength of the dissolve (default 0.6s)
LOOP_TAIL_GAPCaption-free tail before the end (default 0.2s)
OUTRO_PADSilence after the last word (default 0.3s - kept short, it is dead air on replay)

ASMR-compilation backgrounds

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.

The hands problem, measured

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:

LabelledCount
explicitly male1
explicitly female3
unlabelled hands4
"a person ..." (gender unknown)20
no person mentioned20

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.

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.


Voice

Two engines, set with TTS_ENGINE in .env.

edge (default) — Microsoft edge-tts

Network call, instant, unlimited, free, no account.

kokoro — Kokoro-82M, fully local

python 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.

Rate and pitch (edge engine)

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:

VoiceCharacter
en-US-ChristopherNeuralDeep, authoritative, documentary (default)
en-US-SteffanNeuralCalm, rational, steady
en-US-AndrewNeuralWarm and conversational; newest engine, least synthetic
en-US-EricNeuralMeasured, understated
en-GB-RyanNeural / en-GB-ThomasNeuralBritish, articulate
en-US-AriaNeural / en-US-JennyNeuralFemale, 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.


Project layout

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

Quota

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.


Tuning

Want to changeSet in .env
Topic laneCONTENT_THEME=tafakkur|history|ethics
VoiceTTS_VOICE (see python voice_generator.py --voices)
How far analytics may shift the paceTTS_RATE_ADAPT_MAX=8 (percentage points)
Speaking speedTTS_RATE=+12% (negative is slower)
Caption size / groupingCAPTION_FONT_SIZE, CAPTION_MAX_WORDS_PER_CARD
Safe zonesSAFE_TOP, SAFE_BOTTOM, SAFE_SIDE, CAPTION_ANCHOR
Background darknessBG_DIM (0 = untouched, 1 = black)
Background visual styleBACKGROUND_STYLE=nature|satisfying|tactile|pool|mixed
Single clip or montageBACKGROUND_MODE=single|montage
Whether hands may appearMEDIA_PEOPLE_POLICY=none|hands
Render speed vs sizeENCODE_PRESET=veryfast|medium|slow
Upload visibilityYOUTUBE_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.


Troubleshooting

SymptomFix
Captions render as a tiny bitmap fontDrop a .ttf into assets/fonts/
edge-tts returned no audioCheck the connection, or try another TTS_VOICE
Every script comes from the offline bankNo LLM key is set, or all endpoints are unreachable — check the warnings above the fallback line
Background is always a gradientPEXELS_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" warningExpected 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 uploadToken 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 slowENCODE_PRESET=ultrafast and/or FPS=24

Cost

$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.

Rayen-Slouma/halal-shorts-pipeline

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

See the code

README

Halal Faceless Shorts — Automated Daily Pipeline

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.


Content and audio policy

This channel is built to be Halal by construction, not by review:

RuleHow it is enforced
No musical instrumentsNothing 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 peopleconfig.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 sectarianismscript_generator.BANNED_PATTERNS rejects a script before it is ever voiced.
No scripture quoted or attributedBlocked by the same filter — the scripts reflect, they do not issue rulings.
No misinformationThe 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.


1. Install

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.


Optional local models

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
ModelSizeUnlocksExtra pip
Kokoro-82M337 MBTTS_ENGINE=kokoro — offline voice, 54 speakers, no quotakokoro-onnx soundfile
Depth-Anything-V2-Small94 MBstory_editor.py, episode_render.py — 2.5D parallaxonnxruntime

Both run on CPU. Neither needs a GPU or a PyTorch install.


2. Configure

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.

2a. Text model (free tiers — pick one)

ProviderWhere to get a free keyPut it in
Groq (recommended)https://console.groq.com/keysGROQ_API_KEY
Google Geminihttps://aistudio.google.com/app/apikeyGEMINI_API_KEY
OpenRouter (:free models)https://openrouter.ai/keysOPENROUTER_API_KEY
Ollama (local, no account)https://ollama.com then ollama pull llama3.1:8bnothing — 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.

2b. Stock footage (free)

  1. Create a free Pexels account and request an API key: https://www.pexels.com/api/
  2. Put it in .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.

2c. YouTube Data API v3 (free)

  1. Go to https://console.cloud.google.com/ and create a project.

  2. APIs & Services → Library → enable both:

    • YouTube Data API v3
    • YouTube Analytics API
  3. 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.

  4. Google Auth Platform → Audience (formerly OAuth consent screen) → choose External.

  5. On that same page, click Publish app. This matters twice over:

    • While the app is in Testing, only accounts explicitly listed under Test users can authorise at all — otherwise you get Error 403: access_denied.
    • Testing-mode refresh tokens expire after 7 days, which silently kills a scheduled daily run once a week.

    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.

  6. APIs & Services → Credentials → Create credentials → OAuth client ID → Desktop app. Download the JSON.

  7. Save it in this folder as client_secret.json.

  8. 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, .env and state/token.json are all in .gitignore. Never commit them.

2d. Check you authorised the right channel

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

2e. Scopes, and what happens when you change one

YOUTUBE_SCOPES in config.py decides what the token may do:

ScopeGrants
youtube.uploadCreating videos only. It cannot edit anything.
youtube.force-sslEditing: privacy changes, cancelling or moving a scheduled release, fixing a title.
yt-analytics.readonlyRetention 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.
  • Adding a scope invalidates the cached consent, and the first symptom is 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.

Credentials at a glance

CredentialRequired?Free?Where
Text model key (Groq / Gemini / OpenRouter)No — offline bank is the fallbackYessee 2a
OllamaNoYes, localhttps://ollama.com
PEXELS_API_KEYNo — falls back to a generated gradientYeshttps://www.pexels.com/api/
PIXABAY_API_KEYNo — second footage sourceYeshttps://pixabay.com/api/docs/
client_secret.jsonYes, to uploadYessee 2c

Everything this pipeline needs has a free tier. There is no paid dependency anywhere in the default configuration — see Cost.


3. Run

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

4. Schedule it daily

Windows (Task Scheduler)

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).

macOS / Linux (cron)

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.


5. How the feedback loop works

After each upload, analytics_feedback.py queries the channel's own analytics:

  • Per-video summary — views, average view percentage, average view duration.
  • Retention curve — 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

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

Why "ASMR" is not a search term here

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.

Caption contrast

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.


Making several videos at once

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

Re-rendering a corrected script

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"]
}
  • The compliance gate still runs on a hand-edited script; only generation is skipped.
  • The result is merged into 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:

#ThemeBackground
1sciencemontage / tactile
2historysingle / satisfying
3socialmontage / satisfying
4tafakkursingle / nature
5languagemontage / mixed
6ethicssingle / 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.

Topic lanes

LaneContent
sciencePhysics, biology, chemistry, astronomy, the human body
historyWorld and Islamic civilisational history
socialReplicated findings about how people think and relate
tafakkurWonders of creation, framed for reflection
languageEtymology and the origins of everyday words
ethicsPractical 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.


Story videos and full episodes

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 sequence

python 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:

  1. Depth parallax — near pixels move more than far ones. A flat pan across a still is the most recognisable "made by a script" tell; parallax is not.
  2. Handheld drift — two incommensurate sines per axis. No operator is perfectly steady, and locked-off perfection reads as CG.
  3. Film grain — breaks the plastic smoothness of generated frames.
  4. Varied shot length and eased moves — a fixed segment length and linear motion both give the game away.

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 dialogue

python 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.

Where the pictures come from

SubjectBest sourceWhy
A specific named real placeWikimedia CommonsAn invented building presented as a real one is the same class of error as an invented statistic
A generic settingGenerated (see notebooks/)Stock substitutes silently; generation is deterministic
Abstract scienceReviewed stockGeneration 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%.


Seamless looping

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:

  • The fade must complete on the final rendered frame, not at 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.
  • The caption layer has to loop too. The video opens with no caption (the first word lands ~0.1s in), so 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.

Measuring it

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 deltatypical deltaratio
LOOP_SEAMLESS=082.22.829.2x
LOOP_SEAMLESS=16.42.13.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.

SettingEffect
LOOP_SEAMLESSTurn the cross-dissolve on or off
LOOP_BLEND_SECONDSLength of the dissolve (default 0.6s)
LOOP_TAIL_GAPCaption-free tail before the end (default 0.2s)
OUTRO_PADSilence after the last word (default 0.3s - kept short, it is dead air on replay)

ASMR-compilation backgrounds

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.

The hands problem, measured

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:

LabelledCount
explicitly male1
explicitly female3
unlabelled hands4
"a person ..." (gender unknown)20
no person mentioned20

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.

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.


Voice

Two engines, set with TTS_ENGINE in .env.

edge (default) — Microsoft edge-tts

Network call, instant, unlimited, free, no account.

kokoro — Kokoro-82M, fully local

python 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.

Rate and pitch (edge engine)

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:

VoiceCharacter
en-US-ChristopherNeuralDeep, authoritative, documentary (default)
en-US-SteffanNeuralCalm, rational, steady
en-US-AndrewNeuralWarm and conversational; newest engine, least synthetic
en-US-EricNeuralMeasured, understated
en-GB-RyanNeural / en-GB-ThomasNeuralBritish, articulate
en-US-AriaNeural / en-US-JennyNeuralFemale, 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.


Project layout

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

Quota

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.


Tuning

Want to changeSet in .env
Topic laneCONTENT_THEME=tafakkur|history|ethics
VoiceTTS_VOICE (see python voice_generator.py --voices)
How far analytics may shift the paceTTS_RATE_ADAPT_MAX=8 (percentage points)
Speaking speedTTS_RATE=+12% (negative is slower)
Caption size / groupingCAPTION_FONT_SIZE, CAPTION_MAX_WORDS_PER_CARD
Safe zonesSAFE_TOP, SAFE_BOTTOM, SAFE_SIDE, CAPTION_ANCHOR
Background darknessBG_DIM (0 = untouched, 1 = black)
Background visual styleBACKGROUND_STYLE=nature|satisfying|tactile|pool|mixed
Single clip or montageBACKGROUND_MODE=single|montage
Whether hands may appearMEDIA_PEOPLE_POLICY=none|hands
Render speed vs sizeENCODE_PRESET=veryfast|medium|slow
Upload visibilityYOUTUBE_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.


Troubleshooting

SymptomFix
Captions render as a tiny bitmap fontDrop a .ttf into assets/fonts/
edge-tts returned no audioCheck the connection, or try another TTS_VOICE
Every script comes from the offline bankNo LLM key is set, or all endpoints are unreachable — check the warnings above the fallback line
Background is always a gradientPEXELS_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" warningExpected 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 uploadToken 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 slowENCODE_PRESET=ultrafast and/or FPS=24

Cost

$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.