Point it at a feature video and it produces short captioned clips: silent 854x480 MP4s, 9-15 seconds each, assembled from segments cut out of different parts of the video, with a caption burned into the video. Originally built for Telegram, where "GIFs" are really silent MP4s.
cutlist demo
cutlist review
demo synthesises a source video, cuts three clips from it, and leaves them
ready to rate. No input file, no download. cutlist ships no video and never
will -- you supply your own source, and nothing is redistributed.
Requires Python 3.12 and ffmpeg/ffprobe on PATH.
python -m venv .venv
.venv\Scripts\python.exe -m pip install -e ".[dev]"
(source .venv/bin/activate instead of the .venv\Scripts\... prefix on
Linux/macOS.) Once installed, cutlist is on the venv's PATH as its own
command. Run the tests with:
.venv\Scripts\python.exe -m pytest
cutlist demo [--count N] [--root DIR] [--seed N]
cutlist probe <video>
cutlist shots <video> [--json]
cutlist draft <video> --preset <preset.yaml> [--count N] [--caption "..."] [--root DIR] [--seed N] [--keep-shots]
cutlist extract <video> [--root DIR] [--crf N]
cutlist library [--video HASH] [--json]
cutlist assemble <ids> --preset <preset.yaml> [--caption "..."] [--root DIR]
cutlist review [--video HASH] [--preset NAME] [--port N] [--host ADDR] [--all] [--no-open]
cutlist rate <clip-path> <fire|ok|no> [--segments "1:good,3:veto"]
cutlist rerender <clip-path>
cutlist ratings [--json]
cutlist fonts [--search TEXT]
demo needs no input file -- see Start here above.
probe reports what ffprobe makes of the file: dimensions, fps, duration,
whether it has audio.
shots runs scene detection and reports the cuts found.
draft is the one that produces output from a video of your own. It detects
shots, picks segments according to the preset's rhythm rules, and renders
--count clips (10 by default) to output/<video>/<preset>/<run-id>/. Each
draft gets its own run directory, so re-drafting the same video and preset
never overwrites an earlier run's clips -- the ratings recorded against them
stay attached to the footage they were made about. Example:
cutlist draft "input/my-video.mp4" \
--preset presets/sample_preset.yaml \
--count 5 \
--caption "SAMPLE CAPTION"
--seed fixes the RNG for a reproducible draft. --root sets where
input/, cache/ and output/ live (defaults to the current
directory).
review, rate, ratings and rerender are covered below, under
Rating.
video ──> probe ────> dimensions, fps, duration
└─> detect ───> shots (cuts found by content delta)
│
▼
extract ──> library/ whole shots, source quality,
│ no caption -- reusable anywhere
▼
select ──> segments, subject to the preset's rhythm
│ (or `assemble`, from ids you choose)
│
▼
render ──> one encode pass per segment, caption burnt in
│ then a stream copy concat
▼
clip ────> output/<video>/<preset>/<run-id>/NN.mp4
│
▼
cutlist.sqlite
│
run, seed, resolved preset, every segment's timecodes and its parent
shot's, a thumbnail per segment, and every verdict and mark
The database is the master; the files are a cache. Everything except the
ratings can be regenerated -- cutlist rerender rebuilds a deleted clip from
its record, so cleaning up output/ costs nothing.
A preset is a YAML file with three blocks: caption, rhythm, output.
See presets/sample_preset.yaml for a working example.
name: sample_preset
caption:
text: "SAMPLE CAPTION" # overridable with --caption
# font: "Impact" # family name or .ttf path; omit for the default
position: top_center # top_center | bottom_center
size_frac: 0.125 # fraction of output height
fill: "#FFFFFF"
outline: "#000000"
outline_frac: 0.009
# font: "Impact" # family name or .ttf path; omit for the platform default
rhythm:
segments: {min: 4, max: 10}
seg_duration: {min: 1.2, target: 2.0, max: 2.8}
total: {min: 9, max: 15}
output:
width: 854
height: 480
fps: 25
crf: 20rhythm controls how many segments a clip has, how long each one is, and
the total clip duration. All three must be mutually reachable -- a preset
asking for a total no segment count can actually hit is rejected at load
time with an explanation of what totals are achievable.
To add a preset, copy sample_preset.yaml, change the caption and rhythm
numbers, and pass its path to --preset. No code changes needed.
Only the sample preset is tracked; presets/ is otherwise ignored, so your own
presets stay yours.
extract cuts a video into its shots and keeps them:
cutlist extract "input/my-video.mp4"
cutlist library
cutlist assemble 2,3,4,17 --preset presets/sample_preset.yaml
A library clip is a whole detected shot, at source resolution, with no caption and no letterbox, and audio kept when the source has it. Those last three matter: these are masters for reuse, and burning in a caption or downscaling to 854x480 are decisions belonging to one finished clip, not to the footage it came from.
They are whole shots rather than the trimmed pieces a draft picks, because a shot is the same shot whichever run found it. Trimmed picks would fill the library with near-duplicates of the same footage at slightly different in and out points, and an id would stop meaning anything durable.
Files land in library/<video>__<hash>/<timecode>__<duration>s.mp4. They are
ordinary MP4s in an ordinary directory -- browse it, and drag whatever you want
into another project. The hash in the directory name is not decoration: two
videos that happen to share a filename would otherwise overwrite each other's
masters. cutlist library maps ids to paths for the times an id is what you
need.
assemble builds a video from the ids you give, in the order you give them,
repeats allowed -- the order is the edit. It applies the preset's caption and
output blocks but ignores rhythm: you chose those clips deliberately,
and a duration rule that dropped some of them would be answering a question you
did not ask.
An assembled clip records the original video and timecodes, not the library
file, so it decomposes exactly like a drafted one and a rating on it means the
same thing -- including cutlist rerender, which rebuilds it from those
records even when the ids you named came from several different videos.
draft --keep-shots also files the shots it cut, for when you are drafting
something you know you will want to reuse. It is off by default.
Extraction costs what a transcode costs. Every shot of a feature-length
video is effectively re-encoding the whole film, and can use more disk than the
source did, because each clip needs its own keyframe. extract prints the shot
count and an estimate before it starts.
draft records what each clip was made of into cutlist.sqlite at the
workspace root: the run's seed and resolved preset, every clip, and every
segment with both its own timecodes and those of the shot it came from.
cutlist review serves a local page for watching a batch and rating it --
f/o/n for the clip verdict, 1-9 then g/b/v to mark individual
segments, z to undo, ? for the full list. It opens a browser unless you
pass --no-open, and refuses a port that is already taken. cutlist rate
does the same from the terminal.
Thumbnails are captured into the database as each clip is recorded, not
generated on demand -- a segment mark has to stay legible after the source
video is deleted, and a frame from a deleted file cannot be recovered.
draft captures them from the source; assemble captures them from the
library master it encoded, which is the same footage and survives the source
being cleared out.
A clip whose file you have deleted shows as missing in the review queue and
refuses a verdict: rating a clip you cannot watch would put a corrupt row in
the one table that cannot be regenerated. In the review page its segment marks
still work, since those describe footage in the source rather than the clip
file. cutlist rate is all-or-nothing by contrast: its verdict argument is
required, so a rate against a missing clip is refused whole and its
--segments marks are not recorded either -- a refused verdict must not leave
the marks it came with half-applied. cutlist rerender <clip-path> rebuilds
the file from the recorded segments and preset, writing back to the same path
so the ratings it already carries still describe it. Each segment is cut from
its own source, so a clip assembled across several videos rebuilds like any
other. It refuses to rebuild from a source that only matches by name: same
name, different bytes is not the footage that was rated.
Nothing consumes the ratings yet. This release collects them; scoring uses them later.
docker build -t cutlist .
docker run --rm -v "$PWD:/work" cutlist demo
docker run --rm -v "$PWD:/work" -p 8731:8731 cutlist review --host 0.0.0.0 --no-open
The workspace is mounted at /work, so input/, output/ and
cutlist.sqlite are the ones on your host.
--host 0.0.0.0 is required inside a container -- the default binds the
container's own loopback, which a published port cannot reach. It exposes an
unauthenticated server that reads files from the workspace to your local
network; the server refuses paths outside the workspace, but it authenticates
nobody.
On Windows, run natively rather than in Docker. SQLite's file locking is unreliable over Docker Desktop's bind mounts, and the failure mode is a corrupted ratings database -- the one artifact with no backup. If you need Docker on Windows anyway, keep the database in a named volume rather than a bind mount.
AGPL-3.0-only. Use it, modify it, run it. If you modify it and let other people use it over a network, you have to publish your source too.
If that does not suit your situation, ask me about a commercial licence.
Segment selection is currently random, subject only to the rhythm constraints in the preset -- it does not look at the footage at all. This is a deliberate walking skeleton: it proves out shot detection, captioning and rendering end to end before content-aware scoring (saliency, diversity, an agent doing the judging) is layered on top in a later phase.
Local embedding models for content-aware scoring would need a much heavier image with GPU support; that is a later phase and deliberately not in this one.
There is deliberately no single run command. The design this is heading
toward involves an agent judging shortlisted footage between two separate
commands; a run command would hide that seam.