Turn a long interview into publish-ready vertical clips: cut on sentence boundaries, reframed to 9:16, karaoke subtitles burned in.
Project description
🎬 shortsmaker
A long interview goes in. Publish-ready vertical clips come out.
Cut on sentence boundaries · reframed to 9:16 by body tracking · karaoke subtitles burned in
uvx shortsmaker run interview.mp4
A 15-minute interview becomes four or five postable clips in about five minutes, on a laptop with no GPU, for roughly $0.10 of API credit.
▶ Watch an example clip —
21 seconds, straight out of shortsmaker run, not retouched.
What it is for
The source it is built for is a filmed talking-head interview: a podcast, a long-form YouTube interview, a panel. One or two people, a static camera, a multi-camera edit.
Within that, it does one thing well: it finds the passages worth clipping, cuts them so they open and close on whole sentences, keeps the speaker in frame at 9:16, and burns in captions.
It is not a general-purpose video editor. On a vlog, a product demo or a sketch, the picture carries meaning that this pipeline deliberately ignores — it sends the model the audio, never the video. Expect poor results, and see SPEC.md for why that trade was made.
Install
Requires Python 3.12+, uv, and FFmpeg built with libass
(apt install ffmpeg, brew install ffmpeg — the distro build is fine).
uvx shortsmaker run interview.mp4 # run it without installing anything
uv tool install shortsmaker # or install the command for good
The font and the pose model are vendored inside the package: nothing is downloaded on first run.
Configure
Two keys, read from environment variables:
export OPENAI_API_KEY=sk-... # Whisper — the transcript
export GEMINI_API_KEY=AIza... # Gemini — choosing the passages
Get them from platform.openai.com and aistudio.google.com.
The tool never reads a .env file, and that is deliberate: a CLI runs in whatever directory you
happen to be standing in, so a tool that loads .env from the working directory would silently pick up
a file you did not know was there. Keys come from the environment, and how they get there is your
business — export, a CI secret, a container's --env-file, or direnv loading a
.envrc when you cd into the project. For local work, direnv is the comfortable answer; copy
.envrc.example and run
direnv allow.
What it costs
Only two of the five stages call a paid API, and both cache their result — so re-rendering a dozen times while you tune the look costs nothing.
| Source length | Whisper | Gemini | Total |
|---|---|---|---|
| 15 minutes | ~$0.09 | a few cents | ~$0.10 |
| 1 h 40 | ~$0.61 | a few cents | ~$0.65 |
Whisper is billed on audio duration ($0.006/min). Gemini is negligible beside it, because it is sent the audio and the transcript — never the video.
No key, no spend: --mock fakes both APIs with realistic, deterministic data, and the full pipeline
runs offline. It is how the test suite runs, and the fastest way to see what the tool actually does.
shortsmaker run interview.mp4 --mock
Use
shortsmaker run interview.mp4 # everything → clips/
shortsmaker run interview.mp4 -n 3 # keep only the 3 best-scoring passages
shortsmaker run interview.mp4 -f 1:1 -l en # square, English
What you get
clips/
01-s0.94-le-vrai-luxe.mp4 ← index, score, and the title the model wrote
02-s0.88-pourquoi-il-a-dit-non.mp4
03-s0.71-UNANCHORED-la-premiere-usine.mp4
.shortsmaker/ ← intermediates, cached, safe to delete
transcript.json analysis.json segments.json reframe/
The filenames make a directory listing a ranking: index, score, title. A clip flagged UNANCHORED is
one whose quote could not be located in the transcript, so its bounds fell back to the model's rough
guess — expect a ragged cut. It is kept and flagged, never dropped silently, because the decision to
throw a clip away is yours.
Options
| Default | ||
|---|---|---|
-f --format |
9:16 |
9:16 TikTok/Reels/Shorts · 4:5 Instagram · 1:1 LinkedIn/X · 16:9 keep the source shape (no crop, tracking skipped) |
-l --language |
fr |
The spoken language, and the language of the generated titles. Also en, es, de, it, pt, nl. |
-n --clips |
all | Keep only the N best-scoring passages. |
--min-score |
none | Keep only passages scoring at least this. Combines with -n. |
-o --out |
clips/ |
Where the finished clips land. |
-w --work-dir |
.shortsmaker/ |
Where intermediates are cached. |
-j --jobs |
auto | Clips rendered in parallel. |
--mock |
off | Fake the APIs. No key, no cost. |
--force |
off | Ignore the cache and re-run the stage — including the paid ones. |
-v --verbose |
off | Show the underlying ffmpeg commands and the raw API traffic. |
shortsmaker --help works on every command, and every default carries its reasoning.
One stage at a time
The pipeline is five stages, and they are separate commands for one reason: only the first two cost money. They cache, so everything downstream can be re-run for free.
shortsmaker transcribe interview.mp4 # Whisper → transcript.json ($, cached)
shortsmaker analyze interview.mp4 # Gemini → analysis.json ($, cached)
shortsmaker show interview.mp4 # print what was found, with scores — nothing rendered
shortsmaker reframe interview.mp4 # MediaPipe → the crop plan
shortsmaker render interview.mp4 # FFmpeg → clips/
Re-selecting and restyling never call an API:
shortsmaker analyze interview.mp4 --min-score 0.8 # change your mind — free
shortsmaker render interview.mp4 --size 96 --accent "#00E5FF" # restyle — free
analyze writes everything it found to analysis.json and never narrows it; -n and --min-score
select from that. A stricter filter is a re-read, not a re-analysis.
As a library
Importing the package does not turn it into a CLI: nothing is written to stdout, and the stages log
through the standard logging module, silent until your application adds a handler.
from shortsmaker import Pipeline
def main() -> None:
# A library is TOLD its configuration; it does not go looking for it. Nothing here reads the
# environment — `Settings.from_env()` is the explicit opt-in, and the CLI is its only caller.
p = Pipeline("interview.mp4", openai_api_key="sk-...", gemini_api_key="AIza...")
p.run(clips=3)
# NOT decoration. The reframe tracks in worker processes, and Python starts them by re-importing
# this file — run the pipeline at import time and every worker runs it too. Pass jobs=1 to opt out.
if __name__ == "__main__":
main()
The stages are still yours to drive one at a time, which is the whole point of them: only
transcribe and analyze cost money, both cache, so tuning the look is free.
p = Pipeline("interview.mp4", accent="#FF0055")
p.render(only={3}) # the transcript and the analysis are read back from disk, not paid for again
p.reframe(format="1:1") # re-target the shape, exactly as `-f` does on the CLI
p.render()
Ask for the shape with format=; assigning settings.render.format is not enough. A render nobody
asked a shape for takes the shape of the crop plan on disk — that is what stops 9:16 being
rendered over a plan computed for 1:1 — and it would overwrite the assignment without a word.
The named arguments are the ones the CLI exposes as flags, for the same reason: they are what people
actually change. The other forty knobs — every threshold in analyze, reframe and render — live
on Settings, which Pipeline accepts and the named arguments override:
settings = Settings(openai_api_key="sk-...")
settings.reframe.head_margin = 1.5
p = Pipeline("interview.mp4", settings=settings, accent="#FF0055")
The package docstring explains the rest, including why the stage modules are not imported eagerly.
How it works
Gemini decides what to cut. Whisper decides when. Neither is asked to do the other's job.
Gemini reliably picks the right moments but cannot place them in time — its timestamps drift by around two seconds, enough to open a clip mid-sentence. So it is never asked for a timestamp. It quotes the words, and the quote is looked up in Whisper's word-level transcript. Cut points land on real word boundaries by construction rather than by luck.
The reframe tracks bodies, not faces — a face vanishes in profile, a body does not — decides per shot rather than per frame, and never guesses who is speaking: two people in frame means both are shown.
→ SPEC.md has the flowchart and the reasoning, stage by stage. Four spikes were built and measured before any of this was written, and nearly every rule in the code replaced one that made the clips worse.
Performance
Measured on a 16-core CPU with no GPU: reframing runs at 0.71× realtime, rendering at 0.45×, so local processing costs roughly 1.2× the source duration. Transcription and analysis are network-bound — about 90 seconds for a 15-minute video.
Development
uv sync # runtime deps + pytest
uvx pre-commit install --hook-type pre-commit --hook-type commit-msg # once
uvx pre-commit run --all-files # ruff (lint + format), ty, bandit
uv run pytest # tests
These are the same checks CI runs, though CI calls the tools directly with its own pins rather than going
through pre-commit — it must not depend on a hook someone may never have installed. Run
uvx pre-commit autoupdate to bring the hook versions back in step. pytest is deliberately not a hook:
it needs the whole project installed, and a hook people learn to skip with --no-verify is worse than no
hook.
The conventions these enforce, and the traps worth knowing, are in CLAUDE.md.
What changed between versions: CHANGELOG.md. To cut a release: PUBLISHING.md — the short version is push a tag.
Licence and credits
The code is MIT — see LICENSE.
The example clip is not. It is a short excerpt of an interview with Bernard Arnault from Legend (Guillaume Pley), reproduced solely to illustrate what the tool outputs. All rights remain with its owners; it is not covered by this project's licence and is not redistributable as part of it.
Vendored assets carry their own licences: Lato (Łukasz Dziedzic, SIL Open Font License 1.1) and MediaPipe's pose_landmarker (Google, Apache 2.0).
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file shortsmaker-0.5.0.tar.gz.
File metadata
- Download URL: shortsmaker-0.5.0.tar.gz
- Upload date:
- Size: 5.5 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fa7664236be2fe490d9f443c8a7786b183755065144dcc431688b9202f7ac375
|
|
| MD5 |
ac368d2dc7a9353188eb254576f44d6f
|
|
| BLAKE2b-256 |
13a5dad6622886687b632a30da2c4076cc918b04760a79a46bb399113cc929ed
|
Provenance
The following attestation bundles were made for shortsmaker-0.5.0.tar.gz:
Publisher:
publish.yml on pirocheto/shortsmaker
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
shortsmaker-0.5.0.tar.gz -
Subject digest:
fa7664236be2fe490d9f443c8a7786b183755065144dcc431688b9202f7ac375 - Sigstore transparency entry: 2165334281
- Sigstore integration time:
-
Permalink:
pirocheto/shortsmaker@2b5e541cbaa2fd6c44869b7a879d465fe14487f1 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/pirocheto
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2b5e541cbaa2fd6c44869b7a879d465fe14487f1 -
Trigger Event:
push
-
Statement type:
File details
Details for the file shortsmaker-0.5.0-py3-none-any.whl.
File metadata
- Download URL: shortsmaker-0.5.0-py3-none-any.whl
- Upload date:
- Size: 5.3 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9b4bd90821d756e7c24c1335f6e33d224e9e9b8eb5edf9a59590e9e414de922c
|
|
| MD5 |
8ed3050907536b15b69236c6fe2858ca
|
|
| BLAKE2b-256 |
039bff608df8a7d2c09b3af8c9c30245ad0304f5f129e5eb444e08765cea77b0
|
Provenance
The following attestation bundles were made for shortsmaker-0.5.0-py3-none-any.whl:
Publisher:
publish.yml on pirocheto/shortsmaker
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
shortsmaker-0.5.0-py3-none-any.whl -
Subject digest:
9b4bd90821d756e7c24c1335f6e33d224e9e9b8eb5edf9a59590e9e414de922c - Sigstore transparency entry: 2165334310
- Sigstore integration time:
-
Permalink:
pirocheto/shortsmaker@2b5e541cbaa2fd6c44869b7a879d465fe14487f1 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/pirocheto
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2b5e541cbaa2fd6c44869b7a879d465fe14487f1 -
Trigger Event:
push
-
Statement type: