Turn a PDF, a web article, or a block of text into a narrated, captioned short-form video — vertical for Shorts/Reels/TikTok, square, or landscape.
videogen generate paper.pdf --voice Rachel --music bed.mp3The pipeline reads your source material, writes a script with an LLM, generates a background image per scene, narrates it with text-to-speech, and renders an H.264 MP4 with word-synchronised captions, Ken Burns motion, crossfades, and an auto-ducked music bed. It also emits SRT/VTT subtitles, a thumbnail, and publishing metadata.
- Install
- Quick start
- How it works
- CLI
- Python API
- Web UI
- Configuration
- Providers
- Architecture
- Development
- What changed in v2
Requires Python 3.10+ and ffmpeg.
git clone https://github.com/NabiBukhsh-AI/VideoGenerator.git
cd VideoGenerator
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[all]"ffmpeg (needed for rendering, not for script generation):
| Platform | Command |
|---|---|
| Windows | winget install Gyan.FFmpeg |
| macOS | brew install ffmpeg |
| Debian/Ubuntu | sudo apt install ffmpeg |
Then check your setup:
videogen doctorInstall only what you need instead of [all]:
pip install -e ".[openai,pdf]" # OpenAI + PDF reading
pip install -e ".[anthropic]" # Claude for script generation
pip install -e ".[elevenlabs]" # ElevenLabs narration
pip install -e ".[web]" # read articles from URLs
pip install -e ".[ui]" # Streamlit interfaceEvery provider has an offline stub, so you can see the whole pipeline work before spending anything:
videogen generate --dry-run "The deep ocean is the least explored place on Earth. \
Sunlight fades below one thousand metres. Creatures there make their own light."That produces a real MP4 with placeholder images and silent narration — enough to check framing, captions, timing, and encoding.
videogen init # writes .envEdit .env, then:
videogen doctor # confirms what's usablevideogen generate article.pdf --voice Rachel
videogen generate https://example.com/blog-post --aspect 16:9
videogen generate notes.md --tone urgent --scenes 5 --music bed.mp3 source script assets render
┌─────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────────┐
│ PDF │ │ │ │ images ────┼──┐ │ Ken Burns │
│ URL ├─────▶│ LLM ├─────▶│ (parallel) │ ├──▶│ crossfades │──▶ .mp4
│ text │ │ + JSON │ │ narration ──┼──┘ │ captions │ .srt
│ .md │ │ schema │ │ (parallel) │ │ ducked music │ .jpg
└─────────┘ └──────────┘ └──────────────┘ └────────────────┘
- Ingest — extract text from a PDF (stripping running headers and repairing hyphenation), an article URL, a Markdown/text file, or a raw string.
- Script — one LLM call returns a validated JSON object: title, description, hashtags, and a list of scenes each with narration and an image prompt.
- Assets — images and narration are generated concurrently and cached by content hash, so a re-run only pays for what changed.
- Align — each word gets a start/end time, either heuristically (free) or from
Whisper timestamps (
VIDEOGEN_ALIGNER=whisper). - Render — frames are generated in memory and piped straight into ffmpeg as raw
RGB, encoded once to H.264/
yuv420pMP4.
videogen generate SOURCE [OPTIONS]
videogen doctor # check keys, deps, ffmpeg
videogen providers # what's ready to run
videogen ui # launch the web interface
videogen init # scaffold .env
| Option | Default | Description |
|---|---|---|
--aspect |
9:16 |
9:16, 1:1, or 16:9 |
--voice |
provider default | TTS voice name or ID |
--scenes / -n |
6 |
Maximum number of scenes |
--music / -m |
— | Background track, auto-ducked under narration |
--music-gain |
-18.0 |
Music bed level in dB |
--tone |
— | urgent, wry, warm, … |
--language |
— | Narration language |
--focus |
— | Angle to emphasise |
--motion |
auto |
zoom_in, pan_left, none, … |
--transition |
crossfade |
crossfade, cut, fade_to_black |
--no-captions |
off | Skip burned-in captions |
--caption-color |
#FFFFFF |
Caption text colour |
--highlight-color |
#FFD400 |
Colour of the word being spoken |
--font |
auto-detected | Path to a .ttf |
--llm/--image/--tts |
from .env |
Override a provider for one run |
--crf |
20 |
H.264 quality; lower is better |
--dry-run |
off | Offline providers only, no cost |
--script-only |
off | Stop after writing the script |
--resume |
— | Continue an existing project directory |
# Preview the script before spending anything on images or narration
videogen generate report.pdf --script-only
# A run died halfway. Resume it - cached assets are reused.
videogen generate report.pdf --resume output/20260827-143022-report
# Mix providers: Claude writes, OpenAI draws, ElevenLabs speaks
videogen generate notes.md --llm anthropic --image openai --tts elevenlabs
# Landscape explainer with static framing and larger caption chunks
videogen generate paper.pdf --aspect 16:9 --motion none --scenes 8from videogen import GenerationRequest, generate
from videogen.models import AspectRatio, KenBurnsKind, VideoSpec
result = generate(
GenerationRequest(
source="research.pdf",
spec=VideoSpec(
aspect=AspectRatio.VERTICAL,
ken_burns=KenBurnsKind.ZOOM_IN,
caption_highlight_color="#00E5FF",
),
voice="Rachel",
tone="curious",
)
)
print(result.video_path) # output/.../title.mp4
print(result.subtitle_path) # .srt
print(f"${result.usage.estimated_cost_usd:.3f}")Two-step flow — review the script before generating assets:
from videogen.config import get_settings
from videogen.ingest import load_source
from videogen.providers import build_llm
from videogen.script.generator import ScriptGenerator, ScriptRequest
from videogen.pipeline import GenerationRequest, Pipeline
settings = get_settings()
document = load_source("research.pdf")
script, usage = ScriptGenerator(build_llm(settings), settings).generate(
ScriptRequest(document=document)
)
script.scenes[0].narration = "A punchier opening line." # edit freely
result = Pipeline(settings).run(
GenerationRequest(source="research.pdf", script=script)
)videogen ui # http://localhost:8501Upload a file, paste a URL, or type text. The UI generates the script first and lets you edit every scene before committing to image and narration generation.
Settings resolve from environment variables, then .env, then defaults. See
.env.example for the full list.
Most-used values:
| Variable | Default | Purpose |
|---|---|---|
OPENAI_API_KEY |
— | Script, images, TTS, Whisper alignment |
ANTHROPIC_API_KEY |
— | Script generation via Claude |
ELEVENLABS_API_KEY |
— | Narration |
VIDEOGEN_MAX_SCENES |
6 |
Scene cap |
VIDEOGEN_IMAGE_STYLE |
cinematic… | Style appended to every image prompt |
VIDEOGEN_MAX_WORKERS |
4 |
Concurrent API calls |
VIDEOGEN_ALIGNER |
heuristic |
heuristic or whisper |
VIDEOGEN_CACHE_ENABLED |
true |
Reuse assets across runs |
VIDEOGEN_FONT_PATH |
auto | Caption font |
Secrets never belong in source.
.envis gitignored. If a key has ever been committed — to this repo or any other — treat it as public and rotate it.
Every capability is an interface with at least one online and one offline implementation.
| Kind | Online | Offline stub |
|---|---|---|
| LLM | openai (structured output), anthropic (tool-forced JSON) |
echo |
| Image | openai (gpt-image-1, dall-e-3) |
placeholder |
| TTS | elevenlabs, openai |
silent |
The stubs are what make --dry-run and the test suite work with no keys and no network.
Implement the interface and register it:
# videogen/providers/image/my_provider.py
from videogen.providers.base import ImageProvider
class MyImage(ImageProvider):
name = "mine"
def generate(self, *, prompt: str, output_path: Path, size: str) -> Path:
...
return output_pathAdd one branch to videogen/providers/registry.py and a name to KNOWN. Nothing else
changes — the pipeline only knows the interface.
videogen/
├── config.py Settings via pydantic-settings; secrets as SecretStr
├── models.py Validated domain types (Script, Scene, VideoSpec, …)
├── pipeline.py Stage orchestration
├── cli.py Typer CLI
├── errors.py Exception hierarchy, each with a fix hint
├── ingest/ PDF, Markdown/text, HTML, URL, raw text loaders
├── script/ Prompts, JSON schema, script generation
├── providers/ LLM / image / TTS interfaces + implementations
│ ├── base.py the three ABCs
│ └── registry.py name → instance
├── render/
│ ├── ffmpeg.py binary wrapper, raw-frame pipe, probing
│ ├── frames.py Ken Burns, transitions, the timeline
│ ├── captions.py layout, word highlighting, cached overlays
│ ├── align.py heuristic and Whisper word timing
│ ├── audio.py narration concat, sidechain-ducked music
│ ├── subtitles.py SRT / VTT
│ └── compositor.py render orchestration
├── storage/ Project directories, manifests, content-hash caching
└── ui/ Streamlit app
Design rules. The pipeline depends only on the provider ABCs, never on a vendor SDK.
Optional dependencies are imported inside the functions that use them, so a partial
install still works. Every error is a VideoGenError carrying a hint. Nothing is
written to disk twice.
pip install -e ".[all,dev]"
pytest # full suite
pytest -m "not ffmpeg" # skip tests needing the binary
pytest --cov=videogen
ruff check videogen tests
ruff format videogen tests
mypy videogenTests run entirely offline — no keys, no network, no cost. Tests that need ffmpeg are
marked ffmpeg and skip automatically when it is absent.
The original was a working prototype: a single Streamlit script with the whole flow inlined twice. This release rebuilds it around a modular core. The substantive fixes:
Security
- API keys were hardcoded in three source files and committed. All credentials now come
from the environment, are held as
SecretStr, and.envis gitignored.
Correctness
response_text.replace(...)discarded its result, so smart quotes reached the TTS engine unchanged. Text normalisation now returns the cleaned value.- Caption lines were centred by measuring the whole string but drawn word-by-word with
a fixed
+10advance, so every line drifted right and long lines ran off-frame. Layout now measures the real space width in the actual font. - Scene durations subtracted the fade length inconsistently, so picture and narration drifted apart. The timeline now places scenes at exact audio durations.
cv2.addWeighted(image1, 1, image2, 0, 0)was a no-op that computed nothing.- Background music used
10 * log10(1/strength), which divides by zero at strength 0. - The Streamlit music slider was created inside the button handler, so it could never return anything but its default.
Modernisation
elevenlabs.set_api_key/generate/savewere removed in ElevenLabs 1.0;moviepy.editorwas removed in MoviePy 2.0. Both are gone.- Output was XVID
.avi. It is now H.264 MP4 withyuv420pand+faststart. - Script parsing relied on line prefixes (
Narrator:,[), which broke on any reformat. The LLM now returns a schema-validated JSON object. - OpenCV Hershey fonts (ASCII-only) replaced with PIL TrueType rendering.
New
- CLI, offline dry-run mode, provider abstraction with Anthropic support, URL and
Markdown ingestion, Ken Burns motion, sidechain music ducking, Whisper caption
alignment, SRT/VTT export, thumbnails, publishing metadata, content-hash caching with
--resume, concurrent asset generation, cost estimation, structured logging, and a test suite.
MIT — see LICENSE.