Durable multi-stage media generation pipeline. SQLite state machine, crash-safe resume at stage granularity, deterministic panel seeds, operator veto before publish.
1
stars
0
commits
Python
primary language
Aug 17, 2026
updated
A multi-stage media generation pipeline built to survive interruption. The whole run is a state machine persisted to SQLite, so a crash, a reboot or a killed GPU job resumes from the last completed stage instead of starting over. A stage that raises records its traceback against the job without changing the job's state, so the next scheduled run picks it up again rather than skipping it.
The pipeline itself goes seed title -> LLM-written script -> TTS narration -> word-level timestamps -> AI panel images -> FFmpeg assembly -> operator review over Telegram -> scheduled upload. Nothing publishes without a human approving it.
The interesting parts of this repo are the crash-safe state machine, the operator-in-the-loop review gate, the two-stage long-form-to-shorts extraction, and the fact that it runs GPU-bound work on consumer hardware unattended. It is not a research project - it has shipped real videos on a schedule.
Originally built for @Vision-m1i, a faceless shorts channel that later pivoted to historical biographies. The pipeline is niche-agnostic: swap the seed bank, style suffix, voice preset and prompt templates and it produces a different channel.
Seed bank title
-> Anthropic Claude script (long-form + short-clip extraction)
-> Kokoro TTS narration
-> whisper-timestamped word-level alignment
-> Stable Diffusion XL panel generation (with optional style LoRA)
-> FFmpeg assembly (panels + narration + ambient bed + subtitles)
-> Telegram review (Approve / Edit Title / Reject)
-> YouTube scheduled upload
State machine, persisted in data/jobs.sqlite:
scraped -> scripted -> narrated -> paneled -> assembled
-> awaiting_review -> approved | rejected -> posted
A crashed run resumes at the stage that failed, so completed stages are not re-done. Within a stage the work is repeated rather than skipped, with one exception: extract_short is idempotent, and re-running it on a parent that already has children is a no-op. The DB is also the single source of truth for scheduling (scheduled_for), retries, and posted-video stats.
A complete content-generation pipeline, end-to-end, that runs unattended on a single Windows box. Useful as reference for anyone building:
This was production code for a real channel. The repo includes the full test suite - 123 test functions across 22 files, counted with grep -rE '^\s*(async )?def test_' tests/ - covering the assemble stage, telegram bot, seed bank dedup, prompts, scheduled-slot picker, end-screen render, thumbnail render and YouTube upload, so you can see how every stage is exercised.
| Path | Purpose |
|---|---|
src/horror_shorts/db.py | SQLite schema + connection helpers. Single jobs table is the pipeline's state machine. |
src/horror_shorts/config.py | Static config (model checkpoints, prompts, voice, audio levels) + DB-tunable config table. |
src/horror_shorts/prompts.py | Claude system + user prompts for script generation, title polish, short-clip extraction. |
src/horror_shorts/secrets_loader.py | Reads keys from secrets/ dir. Monkeypatchable for tests. |
src/horror_shorts/stages/seed_bank.py | Picks a fresh seed from data/seed_bank.txt, dedupes against used_seeds table. |
src/horror_shorts/stages/script.py | Anthropic-API-driven script generation with locked style suffix. |
src/horror_shorts/stages/narrate.py | Kokoro TTS (locked voice am_michael, speed 0.95). |
src/horror_shorts/stages/timestamps.py | whisper-timestamped word-level alignment. |
src/horror_shorts/stages/panel_gen.py | SDXL panel generation. Reads data/style_lora.safetensors if present. |
src/horror_shorts/stages/assemble.py | FFmpeg assembly - panels + narration + ambient bed + intro/outro. |
src/horror_shorts/stages/subtitles.py | SRT generation from word-timestamps. |
src/horror_shorts/stages/end_screen.py | Renders the 20-second subscribe-prompt end card. |
src/horror_shorts/stages/thumbnail.py | Selects + composites the thumbnail from the best panel. |
src/horror_shorts/stages/telegram_send.py | Posts a video preview to the operator's Telegram with inline buttons. |
src/horror_shorts/stages/engagement.py | Picks an opener-question comment to seed under each upload. |
src/horror_shorts/stages/playlist_assign.py | Slots a video into the right playlist by run-date and kind. |
src/horror_shorts/stages/poll_views.py | Posted-log poller for view-count tracking. |
src/horror_shorts/stages/yt_upload.py | YouTube Data API uploader with scheduled publishAt. |
src/horror_shorts/stages/extract_short.py | Long-form video -> 60-second short extractor (cold-open + peak-moment). |
src/horror_shorts/telegram_bot.py | Long-polling Telegram bot for the inline-button review callbacks. |
src/horror_shorts/housekeeping.py | GC for old working dirs + stale-job alerter. |
src/horror_shorts/entrypoints/ | Top-level entry points wired up to Windows Task Scheduler. |
Every stage reads its input from the DB and writes its output back, and the job's state only advances when a stage completes. A stage that raises records its traceback against the job and leaves the state untouched (db.transition(..., state=None, error=...)), so the next scheduled run retries that stage instead of the pipeline stalling or silently skipping ahead.
Resume is at stage granularity, not within a stage. If panel-gen dies at panel 18 of 20, the re-run regenerates all 20. What makes that survivable rather than merely slow is that the seed is deterministic: _seed_for(job_id, panel_index) is a sha256 of the job id and the panel index, not Python's per-interpreter salted hash(), so a re-run reproduces the same images rather than a different set. Panel-gen on a 10-minute long-form can take 25+ minutes on one GPU, so this is the stage where that distinction costs the most.
The pipeline produces 2 long-form videos a week and 4 shorts a week. The shorts are not generated independently - they're extracted from the long-form videos by extract_short.py, which uses two complementary heuristics:
This means each long-form generates ~2 shorts of compatible content, all sharing thumbnail style and brand. The shorts act as a discovery funnel; the long-form is where retention + watch-hours accumulate (YPP requirement). One pipeline run produces both.
Each generated video pings a Telegram bot with Approve / Edit Title / Reject buttons. The operator approves in under 30 seconds per video, 5 videos a day = under 10 minutes of human review. This is the right amount of automation: full hands-off generation is too risky (one bad title = one strike), and full manual generation is too slow.
The auto_approve config flag also exists for fully unattended mode - the bot still pings, but the slot is filled and the video uploaded automatically. Used once trust is established.
The whole pipeline (Kokoro TTS, whisper-timestamped, SDXL panel-gen) runs sequentially on a single 12 GB+ GPU. Panel-gen at 28 steps + SDXL base + LoRA fits in 11 GB, leaving headroom for Whisper. The Blackwell card (RTX 5070, sm_120) needs CUDA 12.8+ - earlier wheels are pinned out via pyproject.toml's [tool.uv.sources].
uv.lock is committed. Fresh clones run uv sync --frozen to install exactly what's in the lockfile. This is the only thing that makes the GPU dependency stack reproducible across hosts.
ffmpeg and ffprobe on PATH (winget install Gyan.FFmpeg works)git clone https://github.com/visione4906/durable-media-pipeline.git
cd durable-media-pipeline
uv sync --frozen
uv run python -c "import torch; assert torch.cuda.is_available(); x=torch.randn(1024,1024,device='cuda'); print('kernel ok:', (x@x.T).shape)"
torch is pinned to the cu128 wheel index in pyproject.toml. The RTX 5070 (sm_120 Blackwell) needs CUDA 12.8+; earlier wheels error out at runtime.
data/style_lora.safetensors - optional style LoRA from CivitAI or similar (SDXL-compatible). The pipeline runs without this file; panels just come out in the base SDXL style (less consistent).data/ambient_bed.mp3 - royalty-free ambient track (loopable, ~2 min). Pixabay or YouTube Audio Library both have suitable options.Create secrets/ (gitignored) and populate:
secrets/anthropic_key - Anthropic API key (one line, no JSON)secrets/telegram_bot_token - bot token from @BotFather (one line)secrets/telegram_chat.json - {"operator_chat_id": <your-numeric-chat-id>}
(DM the bot once and read it from https://api.telegram.org/bot<TOKEN>/getUpdates)secrets/youtube_oauth.json - Google Cloud OAuth client secrets (Desktop type)uv run python scripts/bootstrap_oauth.py once to generate secrets/youtube_token.jsonuv run python -c "from horror_shorts.db import init_db, connect; from horror_shorts.config import DB_PATH, seed_default_config; init_db(DB_PATH); seed_default_config(connect(DB_PATH)); print('DB ready')"
uv run pytest -v
Expected: every test passes, with the GPU-gated ones reported as skipped. The skipped set is the smoke tests for narrate, timestamps and panel-gen plus the assemble integration - they need a GPU, so they are opt-in. To run them too:
$env:RUN_GPU_TESTS=1
uv run pytest tests/test_narrate.py tests/test_timestamps.py tests/test_panel_gen.py -v
.\scripts\install_task_scheduler.ps1
Six tasks are created:
DB-table-tunable (no code change, no restart):
uv run python -c "from horror_shorts.db import connect; from horror_shorts.config import DB_PATH, set_config; set_config(connect(DB_PATH), 'posting_slots', '13:00,16:00,19:00,21:00,23:30')"
Available config keys: posting_slots, daily_target, youtube_handle, long_form_post_time_utc, long_form_post_days, short_clip_post_time_utc, short_clip_post_days, auto_approve.
scheduled_for values stored by the slot scheduler use the host's local naive datetime.now(), but the YouTube uploader treats those naive ISO strings as UTC when building the publishAt field. If you configure posting_slots = "14:00,17:00,...", videos publish at 14:00 / 17:00 UTC, not 14:00 / 17:00 local.
For a UK operator on BST (UTC+1) wanting 17:00 BST publish times, configure posting_slots = "16:00,..." (subtract 1h in summer, no offset in winter).
am_michael) is lockedThis is the channel's voice identity. Don't change it without a deliberate brand decision - videos already published establishing the voice association will be wasted on a rebrand.
Some SDXL forks are gated on Hugging Face - operator must accept the license terms once and then huggingface-cli login with a token that has read access. First panel-gen run will fail with a 403 until this is done.
Deliberately excluded:
secrets/ directory (operator-specific credentials)data/jobs.sqlite snapshot (this is runtime state, regenerated on first DB init)MIT.
Built by Sal (Abdulsalam Oladapo). UK-based. Full profile at https://consentleads.uk/sal.
Python
98.7%
PowerShell
1.3%
Durable multi-stage media generation pipeline. SQLite state machine, crash-safe resume at stage granularity, deterministic panel seeds, operator veto before publish.
1
stars
0
commits
Python
primary language
Aug 17, 2026
updated
A multi-stage media generation pipeline built to survive interruption. The whole run is a state machine persisted to SQLite, so a crash, a reboot or a killed GPU job resumes from the last completed stage instead of starting over. A stage that raises records its traceback against the job without changing the job's state, so the next scheduled run picks it up again rather than skipping it.
The pipeline itself goes seed title -> LLM-written script -> TTS narration -> word-level timestamps -> AI panel images -> FFmpeg assembly -> operator review over Telegram -> scheduled upload. Nothing publishes without a human approving it.
The interesting parts of this repo are the crash-safe state machine, the operator-in-the-loop review gate, the two-stage long-form-to-shorts extraction, and the fact that it runs GPU-bound work on consumer hardware unattended. It is not a research project - it has shipped real videos on a schedule.
Originally built for @Vision-m1i, a faceless shorts channel that later pivoted to historical biographies. The pipeline is niche-agnostic: swap the seed bank, style suffix, voice preset and prompt templates and it produces a different channel.
Seed bank title
-> Anthropic Claude script (long-form + short-clip extraction)
-> Kokoro TTS narration
-> whisper-timestamped word-level alignment
-> Stable Diffusion XL panel generation (with optional style LoRA)
-> FFmpeg assembly (panels + narration + ambient bed + subtitles)
-> Telegram review (Approve / Edit Title / Reject)
-> YouTube scheduled upload
State machine, persisted in data/jobs.sqlite:
scraped -> scripted -> narrated -> paneled -> assembled
-> awaiting_review -> approved | rejected -> posted
A crashed run resumes at the stage that failed, so completed stages are not re-done. Within a stage the work is repeated rather than skipped, with one exception: extract_short is idempotent, and re-running it on a parent that already has children is a no-op. The DB is also the single source of truth for scheduling (scheduled_for), retries, and posted-video stats.
A complete content-generation pipeline, end-to-end, that runs unattended on a single Windows box. Useful as reference for anyone building:
This was production code for a real channel. The repo includes the full test suite - 123 test functions across 22 files, counted with grep -rE '^\s*(async )?def test_' tests/ - covering the assemble stage, telegram bot, seed bank dedup, prompts, scheduled-slot picker, end-screen render, thumbnail render and YouTube upload, so you can see how every stage is exercised.
| Path | Purpose |
|---|---|
src/horror_shorts/db.py | SQLite schema + connection helpers. Single jobs table is the pipeline's state machine. |
src/horror_shorts/config.py | Static config (model checkpoints, prompts, voice, audio levels) + DB-tunable config table. |
src/horror_shorts/prompts.py | Claude system + user prompts for script generation, title polish, short-clip extraction. |
src/horror_shorts/secrets_loader.py | Reads keys from secrets/ dir. Monkeypatchable for tests. |
src/horror_shorts/stages/seed_bank.py | Picks a fresh seed from data/seed_bank.txt, dedupes against used_seeds table. |
src/horror_shorts/stages/script.py | Anthropic-API-driven script generation with locked style suffix. |
src/horror_shorts/stages/narrate.py | Kokoro TTS (locked voice am_michael, speed 0.95). |
src/horror_shorts/stages/timestamps.py | whisper-timestamped word-level alignment. |
src/horror_shorts/stages/panel_gen.py | SDXL panel generation. Reads data/style_lora.safetensors if present. |
src/horror_shorts/stages/assemble.py | FFmpeg assembly - panels + narration + ambient bed + intro/outro. |
src/horror_shorts/stages/subtitles.py | SRT generation from word-timestamps. |
src/horror_shorts/stages/end_screen.py | Renders the 20-second subscribe-prompt end card. |
src/horror_shorts/stages/thumbnail.py | Selects + composites the thumbnail from the best panel. |
src/horror_shorts/stages/telegram_send.py | Posts a video preview to the operator's Telegram with inline buttons. |
src/horror_shorts/stages/engagement.py | Picks an opener-question comment to seed under each upload. |
src/horror_shorts/stages/playlist_assign.py | Slots a video into the right playlist by run-date and kind. |
src/horror_shorts/stages/poll_views.py | Posted-log poller for view-count tracking. |
src/horror_shorts/stages/yt_upload.py | YouTube Data API uploader with scheduled publishAt. |
src/horror_shorts/stages/extract_short.py | Long-form video -> 60-second short extractor (cold-open + peak-moment). |
src/horror_shorts/telegram_bot.py | Long-polling Telegram bot for the inline-button review callbacks. |
src/horror_shorts/housekeeping.py | GC for old working dirs + stale-job alerter. |
src/horror_shorts/entrypoints/ | Top-level entry points wired up to Windows Task Scheduler. |
Every stage reads its input from the DB and writes its output back, and the job's state only advances when a stage completes. A stage that raises records its traceback against the job and leaves the state untouched (db.transition(..., state=None, error=...)), so the next scheduled run retries that stage instead of the pipeline stalling or silently skipping ahead.
Resume is at stage granularity, not within a stage. If panel-gen dies at panel 18 of 20, the re-run regenerates all 20. What makes that survivable rather than merely slow is that the seed is deterministic: _seed_for(job_id, panel_index) is a sha256 of the job id and the panel index, not Python's per-interpreter salted hash(), so a re-run reproduces the same images rather than a different set. Panel-gen on a 10-minute long-form can take 25+ minutes on one GPU, so this is the stage where that distinction costs the most.
The pipeline produces 2 long-form videos a week and 4 shorts a week. The shorts are not generated independently - they're extracted from the long-form videos by extract_short.py, which uses two complementary heuristics:
This means each long-form generates ~2 shorts of compatible content, all sharing thumbnail style and brand. The shorts act as a discovery funnel; the long-form is where retention + watch-hours accumulate (YPP requirement). One pipeline run produces both.
Each generated video pings a Telegram bot with Approve / Edit Title / Reject buttons. The operator approves in under 30 seconds per video, 5 videos a day = under 10 minutes of human review. This is the right amount of automation: full hands-off generation is too risky (one bad title = one strike), and full manual generation is too slow.
The auto_approve config flag also exists for fully unattended mode - the bot still pings, but the slot is filled and the video uploaded automatically. Used once trust is established.
The whole pipeline (Kokoro TTS, whisper-timestamped, SDXL panel-gen) runs sequentially on a single 12 GB+ GPU. Panel-gen at 28 steps + SDXL base + LoRA fits in 11 GB, leaving headroom for Whisper. The Blackwell card (RTX 5070, sm_120) needs CUDA 12.8+ - earlier wheels are pinned out via pyproject.toml's [tool.uv.sources].
uv.lock is committed. Fresh clones run uv sync --frozen to install exactly what's in the lockfile. This is the only thing that makes the GPU dependency stack reproducible across hosts.
ffmpeg and ffprobe on PATH (winget install Gyan.FFmpeg works)git clone https://github.com/visione4906/durable-media-pipeline.git
cd durable-media-pipeline
uv sync --frozen
uv run python -c "import torch; assert torch.cuda.is_available(); x=torch.randn(1024,1024,device='cuda'); print('kernel ok:', (x@x.T).shape)"
torch is pinned to the cu128 wheel index in pyproject.toml. The RTX 5070 (sm_120 Blackwell) needs CUDA 12.8+; earlier wheels error out at runtime.
data/style_lora.safetensors - optional style LoRA from CivitAI or similar (SDXL-compatible). The pipeline runs without this file; panels just come out in the base SDXL style (less consistent).data/ambient_bed.mp3 - royalty-free ambient track (loopable, ~2 min). Pixabay or YouTube Audio Library both have suitable options.Create secrets/ (gitignored) and populate:
secrets/anthropic_key - Anthropic API key (one line, no JSON)secrets/telegram_bot_token - bot token from @BotFather (one line)secrets/telegram_chat.json - {"operator_chat_id": <your-numeric-chat-id>}
(DM the bot once and read it from https://api.telegram.org/bot<TOKEN>/getUpdates)secrets/youtube_oauth.json - Google Cloud OAuth client secrets (Desktop type)uv run python scripts/bootstrap_oauth.py once to generate secrets/youtube_token.jsonuv run python -c "from horror_shorts.db import init_db, connect; from horror_shorts.config import DB_PATH, seed_default_config; init_db(DB_PATH); seed_default_config(connect(DB_PATH)); print('DB ready')"
uv run pytest -v
Expected: every test passes, with the GPU-gated ones reported as skipped. The skipped set is the smoke tests for narrate, timestamps and panel-gen plus the assemble integration - they need a GPU, so they are opt-in. To run them too:
$env:RUN_GPU_TESTS=1
uv run pytest tests/test_narrate.py tests/test_timestamps.py tests/test_panel_gen.py -v
.\scripts\install_task_scheduler.ps1
Six tasks are created:
DB-table-tunable (no code change, no restart):
uv run python -c "from horror_shorts.db import connect; from horror_shorts.config import DB_PATH, set_config; set_config(connect(DB_PATH), 'posting_slots', '13:00,16:00,19:00,21:00,23:30')"
Available config keys: posting_slots, daily_target, youtube_handle, long_form_post_time_utc, long_form_post_days, short_clip_post_time_utc, short_clip_post_days, auto_approve.
scheduled_for values stored by the slot scheduler use the host's local naive datetime.now(), but the YouTube uploader treats those naive ISO strings as UTC when building the publishAt field. If you configure posting_slots = "14:00,17:00,...", videos publish at 14:00 / 17:00 UTC, not 14:00 / 17:00 local.
For a UK operator on BST (UTC+1) wanting 17:00 BST publish times, configure posting_slots = "16:00,..." (subtract 1h in summer, no offset in winter).
am_michael) is lockedThis is the channel's voice identity. Don't change it without a deliberate brand decision - videos already published establishing the voice association will be wasted on a rebrand.
Some SDXL forks are gated on Hugging Face - operator must accept the license terms once and then huggingface-cli login with a token that has read access. First panel-gen run will fail with a 403 until this is done.
Deliberately excluded:
secrets/ directory (operator-specific credentials)data/jobs.sqlite snapshot (this is runtime state, regenerated on first DB init)MIT.
Built by Sal (Abdulsalam Oladapo). UK-based. Full profile at https://consentleads.uk/sal.
Python
98.7%
PowerShell
1.3%