curu-caption
What is this
curu-caption makes LoRA training sets from videos and photographs, on your own computer. It
removes duplicate and blank frames, finds the items that you ask for, and captions only those
items. The result is media and .txt caption pairs, with the configuration that
AI-Toolkit trains from. It is for a person who trains
LoRAs and does not want to extract, sort and caption frames by hand. Each step runs locally by
default, and no step needs a cloud account.
What you can do
| Task | Command | Section |
|---|---|---|
| Make a corpus from a video or a folder of videos | curu-caption ingest --source episode.mp4 --out corpus/ |
Make a corpus from a video |
| Make a corpus from a folder of photographs | curu-caption ingest --source photos/ --out photo-corpus/ |
Make a corpus from photographs |
| Index a corpus again | curu-caption index --corpus corpus/ |
Index a corpus again |
| Find the items that match a description | curu-caption cut --corpus corpus/ --out training/ --want "a wide shot" --dry-run |
Find items by meaning |
| Make a training set for one model | curu-caption cut --corpus corpus/ --out training/ --projection wan-t2v --caption |
Make a training set |
| Make a training set for MiniMax H3 | curu-caption cut --corpus clips-corpus/ --out h3-training/ --projection h3 --caption |
Make a training set for MiniMax H3 |
| Make an audio-visual bundle for amoral | curu-caption cut --corpus clips-corpus/ --out bundle/ --trainer amoral-av --golden golden-v1/ --estate-root media/ |
Make a bundle for amoral |
| Look at a corpus or a cut, and correct it | curu-caption review --corpus corpus/ |
Review a corpus or a cut |
| Caption each file in a folder | curu-caption caption photos/ |
Caption a folder |
| Find near-duplicate files in a folder | curu-caption curate photos/ |
Find near-duplicates in a folder |
| Keep or reject each file against your criteria | curu-caption judge photos/ --criteria "no blurry shots" |
Judge a folder |
Write dataset.yaml for a captioned folder |
curu-caption dataset-config photos/ --trigger-word ohwx |
Write dataset.yaml for a folder |
| Read the ComfyUI workflow in a generated image | curu-caption comfy inspect generated.png |
Read a ComfyUI workflow |
| Caption on a computer that has a GPU | curu-caption caption photos/ --ollama-url http://gpu-box:11434 |
Caption on another computer |
| Work on a network share (NAS) | curu-caption ingest --source videos/ --out corpus/ --stage-locally |
Work on a network share |
| Set the caption model for each command | export CURU_CAPTION_CAPTION_MODEL=saracen9/amoral-qwen3.8-27b |
Set the caption model and other settings |
| Find out why a command stopped | curu-caption cut --prune corpus/ || echo "exit code $?" |
Handle errors |
Install
You need Python 3.12 or later, uv, a Rust toolchain (rustup), ffmpeg and Ollama.
To install the released package from PyPI, you need only Python 3.12 or later, uv, ffmpeg and Ollama:
uv tool install "curu-caption[audio]" # curu-caption and curu-caption-tui on the PATH
The wheels are for Linux x86_64 and macOS (arm64 and x86_64). They include the Rust extension
and the review program. On a CUDA computer, use "curu-caption[audio,gpu]".
To develop, install from a clone of the repository. Then you also need the Rust toolchain, and on Linux a C compiler:
git clone https://github.com/darth-veitcher/curu-caption.git
cd curu-caption
uv sync --extra audio
cargo build --release -p curu-tui && scripts/stage-tui.sh # the review program
source .venv/bin/activate # puts curu-caption on the PATH
uv syncalso compiles the Rust extensioncuru_caption._core.- Give
--extra audioeach time that you runuv sync. A plainuv syncremoves it. It makes thesound,speechandmusicfacts.ltxneeds thesoundfact, andh3uses all three when it has them. - The second line builds the
reviewprogram. No other command needs it. - No model downloads at install time. Each model downloads the first time that a command needs it (see Technical notes).
Then get a caption model for Ollama. The amoral-qwen3.5-9B project makes the three caption models. The default model needs about 32 GB of memory (unified memory or VRAM):
ollama pull saracen9/amoral-qwen3.5-9b
| Model | Memory | Use it when |
|---|---|---|
saracen9/amoral-qwen3.5-9b |
about 32 GB | The default. It is correct for most datasets |
saracen9/amoral-qwen3.8-27b |
about 48 GB | You need fine detail or the subject matter is unusual |
saracen9/amoral-muse-glimmer-30b-abliterated |
about 64 GB | The smaller models do not describe the subject matter plainly |
The full setup, with each platform, cache and fix, is in docs/setup.md.
Quickstart
Make a training set for a text-to-video LoRA from one video, episode.mp4:
curu-caption ingest --source episode.mp4 --out corpus/
curu-caption cut --corpus corpus/ --out training/ --projection wan-t2v \
--want "a person smiling" --limit 60 --dry-run
curu-caption cut --corpus corpus/ --out training/ --projection wan-t2v \
--want "a person smiling" --limit 60 --caption --trigger ohwx
ingestextracts one frame each second, removes the duplicate and blank frames, and indexes the frames that it keeps. It does not caption. The result is the corpus,corpus/.cut --dry-runshows the 60 frames that match "a person smiling", and the facts that each frame does not have yet. It writes nothing and it costs nothing.cut --captioncaptions those 60 frames only, and writes them totraining/.
training/ then holds frame_<ms>.png and frame_<ms>.txt pairs and a dataset.yaml.
Each caption starts with the trigger word ohwx. Copy training/ to the computer that trains,
and give dataset.yaml to AI-Toolkit.
Make the corpus one time, and cut it many times. A second LoRA from the same video is a
second cut, not a second ingest. Each command refuses an --out folder that is not empty,
so no run writes over an earlier result.
Make a corpus from a video
ingest reads a video, or each video in a folder, and makes a corpus:
curu-caption ingest --source videos/ --out corpus/
It reads the videos in each subfolder too. At the end, it prints corpus ready with the
number of items, and the next command to run.
- Frames. By default,
ingestextracts one frame each second (--interval 1.0). Each frame is a PNG named for its time in milliseconds, for exampleframe_0000004000.png. - The cull.
ingestremoves near-duplicate frames and blank frames. A blank frame has a sharpness (Laplacian variance) below--min-sharpness, 1.0 by default. Of each group of near-duplicates, it keeps the sharpest frame. - The index.
ingestthen indexes each frame that it keeps, so thatcut --wantcan find it. It also records a quality score for each item (see Index a corpus again). - Many sources. In a corpus of many videos, each video has its own subfolder, with the
path of the video under
--source. In a cut, each file name starts with its source folder, so theclip_000.mp4of each episode stays. - Some videos of a folder. Give
--sourcemore than once, for example--source s01/ep01.mkv --source s01/ep02.mkv. A video that you name directly gets a subfolder with its file name, without the extension, for exampleep01. If two videos have the same subfolder name,ingeststops before it reads a video. Then give their parent folder as--source.
The corpus is the result that you keep. Each item holds its facts in the media file itself, in PNG text chunks or MP4 metadata. Thus you can move or copy a corpus as one folder, and a single item that you copy out keeps its facts.
Clips for a video LoRA. Add --clips to cut the video into MP4 clips, one for each shot,
with their audio. Use --interval 1 or less with it: ingest finds each shot from the gaps
between the frames that it samples. With a wide interval, no clip is long enough, and ingest
prints this reason. The frames that ingest used to find the shots stay in the corpus, marked
detection-only, so a later detection costs nothing. A cut never selects them, and says
N detection-only item(s) — kept as evidence, never selected.
Before it finds the shots of a video, ingest prints detecting shots in N frame(s). N is the
number of frames that it extracted from that video. The next line, with the clip count, comes
after it finds the shots and cuts the clips.
The audio of a clip is FLAC. It keeps each channel and the channel layout of the source, for example 5.1.
| Option | Default | What a higher value does |
|---|---|---|
--interval |
1.0 s | Fewer frames, and a faster ingest. With --clips, ingest finds fewer shots |
--phash-distance |
8 | More frames count as near-duplicates, and the cull removes more |
--min-sharpness |
1.0 | More frames count as blank. Keep it low: it measures detail, not focus |
--phash-distance is the number of bits, from 0 to 64, that differ between the perceptual
hashes of two frames. At 0, only frames that look the same are near-duplicates. On real TV
footage, the default 8 removed 37% of the frames, and each group was one static shot.
Make a corpus from photographs
ingest also reads a folder of photographs, with its subfolders:
curu-caption ingest --source photos/ --out photo-corpus/
- Each photograph becomes one item.
ingestconverts it to PNG, because a PNG file can hold facts and a JPEG file cannot. - The name of each item comes from its path under
--source. Thus two photographs with the same file name do not write over each other. ingestignores the files that are not media, for exampleThumbs.db,.DS_Storeand.zipfiles.ingestrefuses--clipsfor photographs, because a photograph has no shots and no audio. It refuses before the ingest starts, not later atcuttime.
Path under --source |
Item in the corpus |
|---|---|
man/man_0001.jpg |
man_0001.png |
2019/IMG_0001.jpg |
2019_IMG_0001.png |
2024/IMG_0001.jpg |
2024_IMG_0001.png |
ingest leaves out a folder name when the file name already starts with it, as for
man_0001.jpg.
Index a corpus again
index runs the index step of ingest again on a corpus that exists. Use it after a change to
an index model, or on a corpus that you made with --no-index or --no-quality:
curu-caption ingest --source photos/ --out photo-corpus/ --no-quality
curu-caption index --corpus photo-corpus/ # adds the quality scores
curu-caption index --corpus photo-corpus/ --tags # also adds WD14 tags and rating
index --no-quality makes only the index. On a corpus of 11 items, of which ingest kept 7, a
second run of index prints:
index 7 kept item(s) in photo-corpus (4 culled, excluded or detection-only, not indexed)
indexing with EmbeddingGemma 2 (curu-vision)
judging quality with LiquidAI/d1-3B — a review pre-sort, never a gate
embedding: 0 extracted, 7 already current
quality: 0 extracted, 7 already current
index ready — photo-corpus
index reads only the kept items. It does not make a fact again when the fact is current for
its model and parameters. Thus a second run does nothing, as in the example output.
Run index again on each corpus after a change to the index model. The embedding fact records the model and
the version of curu-vision. After you update curu-vision, each vector is stale. cut --want does
not use a stale vector, and index makes it again. curu-vision puts each transparent part of a
picture onto white before the embedding.
The quality fact holds two probabilities from 0 to 1. The model d1-3B gives them from three frames of each clip, at 10%, 50% and 90%, or from the one picture of a still:
p_keepis the probability that the item is good training data: sharp, one shot, and nothing broken.p_personis the probability that a person is visible.
A higher value is more likely to be true. The scores only sort items for review. No command
removes, culls or selects an item on these numbers. On 101 labelled clips, d1-3B put a good clip
above a rejected clip 72% of the time (AUROC 0.72; 0.5 is chance). That is useful to order a
review, and too weak to make the decision alone. A cut records the scores of each item that it
selects in cut-selection.json.
--tags adds two facts to each photograph, from one run of the WD14 tagger. A clip gets no
tags yet (see Limits):
- The tags fact holds the descriptive tags, the tag with the highest score first, for
example
solo, 1girl, outdoors. A tag for a thing in the picture needs a score above 0.35, and the name of a character needs a score above 0.70. It never holds a rating label. - The rating fact holds the four WD14 rating scores, each from 0 to 1, for example
{"explicit": 0.002, "general": 0.736, "questionable": 0.011, "sensitive": 0.286}.generalis safe content, andexplicitis sexual content. A higher score means that the model finds that rating more probable. Each score is independent of the other three, so the four do not add up to 1.
No command reads the rating yet. It is a fact in each media file, for your own sort or filter. No command removes, culls or selects an item on it. WD14 learned from anime pictures, so its rating of live-action footage is not accurate.
| Step | On a CUDA GPU | On a CPU or Apple silicon |
|---|---|---|
| Index (EmbeddingGemma 2) | not measured; it needs the gpu extra |
about 5.4 s for each 1280x720 frame (Apple M1 CPU) |
| Quality (d1-3B) | about 0.25 s for each item (RTX 3090) | about 2 minutes for each item (16 GB Apple M1) |
WD14 tags and rating (--tags) |
— | about 4.5 s for each item (the --tags help) |
On a Mac, use --no-quality and run index later on a computer with a GPU.
Find items by meaning
cut --want searches the index for the items that match a description in plain English.
With --dry-run, it shows the result and writes nothing:
curu-caption ingest --source videos/ --out corpus/
curu-caption cut --corpus corpus/ --out training/ --want "a wide shot" --limit 60 --dry-run
The command prints the number of matches, and the highest and lowest score that it kept. The score of each item is the cosine similarity between the description and the item, from -1 to 1. A higher score is a closer match, but use the order, not the number. Over 377 clips, the scores of each query were between 0.64 and 0.79. The score where good matches stop changes with the query, so no fixed limit is correct for all queries.
--limitkeeps the best matches, 100 by default.--limit 0keeps each match. This is the control to use.--thresholdremoves each match with a score below the value. It is off by default.- Without
--want, the cut takes all the items of the corpus. With a--limitabove 0, it takes the first--limititems, in name order. cut-selection.jsonrecords the description and the lowest score that the cut kept.
Make a training set
cut selects items from a corpus and writes a training set (a cut) for one model. With
--caption, it captions the selected items only, not the full corpus:
curu-caption ingest --source episode.mp4 --out corpus/
curu-caption cut --corpus corpus/ --out training/ --projection wan-t2v --caption --trigger ohwx
training/
├── frame_0000004000.png the media, rendered for --projection
├── frame_0000004000.txt "ohwx, a woman turns from the window as the door opens"
├── frame_0000004000.avdm.json the scene document that the caption came from
├── dataset.yaml the trainer config (job.yaml for h3 and h3-prose)
├── cut.json the projection, trainer, trigger and the source of each fact
└── cut-selection.json the selected items, the lowest score kept and the quality scores
A projection is the form of caption that one model trains on. Select it for the model that you
train. The default is ltx.
--projection |
The caption | Frame count of a clip |
|---|---|---|
wan-t2v |
Text-to-video: the full scene and its motion, with no audio | aligned to n % 4 == 1 |
wan-i2v |
Image-to-video: the change from the first frame. It needs a corpus of clips | aligned to n % 4 == 1 |
ltx |
One paragraph of the picture and the sound | aligned to n % 8 == 1 |
h3 |
Three fields: the description with dialogue, the soundscape, and the music | 17k + 5 frames at 24 fps |
h3-prose |
Prose of the picture and the motion, with no audio | as h3 |
- Captions. The caption model fills in a fixed form of claims about the middle frame of the item. Then d1-3B checks each claim with a yes or no question. The caption keeps only the checked claims, one plain sentence for each.
- Missing facts.
--dry-runnames each fact that the projection needs and the corpus does not have.ltxneeds asoundfact, andh3uses one when it has it.--captionmakes it when theaudioextra is installed. - The trainer.
--trainer ai-toolkit(the default) writesdataset.yaml, orjob.yamlforh3andh3-prose.--trainer musubiwrites a.wavfile beside each clip, and no AI-Toolkit file. Anltx,wan-t2vorwan-i2vcut copies each clip with its FLAC audio. For LTX-2, AI-Toolkit mixes 6 or 8 channels down to stereo. musubi-tuner mixes the audio down to mono or stereo. WAN 2.2 reads no audio. - Portable.
folder_pathin the config is., the cut folder itself. Thus the cut works on the computer that trains, and the corpus stays on yours.
The result table of cut counts the items:
| Row | Meaning |
|---|---|
renderable |
The item has each fact that the projection needs |
written |
The item is in the cut, with its caption |
unrenderable |
A fact is missing. The lines below the table name it. Add --caption to make it |
Each fact records its producer and parameters. A second cut does not make a fact again when its producer and parameters did not change. A second cut of the same two items finished in less than four seconds, after the first cut used minutes for the captions. The full reference is in docs/cut.md.
Make a training set for MiniMax H3
MiniMax H3 trains on clips. Make a corpus of clips, then cut it with --projection h3:
curu-caption ingest --source episode.mp4 --out clips-corpus/ --clips --interval 1
curu-caption cut --corpus clips-corpus/ --out h3-training/ --projection h3 --caption --trigger ohwx
The cut encodes each clip again to 24 fps, to a frame count of 17k + 5 (5, 22, ... 345), and to
sides that are multiples of 32. It then measures each clip. A clip that does not agree with
all three rules goes in the report, not in the cut. The audio becomes stereo, because H3 trains
on stereo audio. The cut also writes a complete AI-Toolkit
job.yaml. To train, run python <ai-toolkit>/run.py job.yaml in h3-training/.
| Option | Default | Effect |
|---|---|---|
--resolution |
512 | The short side in pixels, rounded down to a multiple of 32. A clip never gets larger |
--min-frames |
73 (about 3 s) | A shorter clip goes in the report, before the cut captions anything |
--max-frames |
345 (the H3 maximum) | The longest clip. Use a lower value to train with less VRAM |
--steps |
3000 | The training steps in job.yaml |
These options apply to h3 and h3-prose only. The other projections copy each clip as it is.
Make a bundle for amoral
--trainer amoral-av writes a training bundle for the omni model of
amoral, which hears and sees together. The bundle
has clips and derived clips, and no captions:
curu-caption cut --corpus clips-corpus/ --out bundle/ --trainer amoral-av \
--golden golden-v1/ --estate-root media/ --want "two people talking" --limit 200 \
--derive 1 --seed 0
- The corpus must come from
ingest --clips, which records the source file of each clip. - The cut writes nothing if a source file is also a source file of golden-v1, the evaluation
set. It lists each such file. To keep these files out of the corpus, give
ingestthe option--golden golden-v1/too. --derive Nmakes N changed copies of each clip, for example with the audio of another clip.- Open
review.htmlin the bundle to look at and listen to each derived clip.
The bundle format, the five changes and the clips that the cut leaves out are in docs/amoral-av.md.
Review a corpus or a cut
review opens a corpus or a cut in the terminal. Look at each item, correct its caption, and
mark the items that you do not want:
curu-caption review --corpus corpus/
curu-caption review --corpus training/ --source corpus/
curu-caption cut --prune training/
┌ Corpus (2/6) ⚠ 1 empty ────┐┌ Preview ────────────────────────────────────┐
│[!] frame_0000001000 ││ │
│ frame_0000002000 ││ [ frame_0000002000.png ] │
│[X] frame_0000003000 ││ │
│[clip] clip_001 ││ │
└────────────────────────────┘└─────────────────────────────────────────────┘
┌ Caption ────────────────────────────────────┐
│ a woman turns from the window as the door │
│ opens │
└─────────────────────────────────────────────┘
j/k ↑↓ nav e edit V visual R find-replace P prefix S set x exclude ? help q quit
The title says Corpus or Cut. What you opened sets what x (exclude) means:
| You opened | x means |
It applies to |
|---|---|---|
| a corpus | "poor quality: do not show me this again" | the item, and each future cut |
| a cut | "correct, but not for this training set" | this cut only |
- Reject in the corpus from a cut. Open the cut with
--source, which names its corpus. ThenXrejects the item in the corpus too. - Remove the rejected items. The trainer config names the full cut folder, so a marked file
that is still in the folder still trains.
cut --prunedeletes the marked files and writes the config again. - Safe to prune. Each file in a cut is a copy, and
cut-selection.jsonrecords the selection.cut --prunerefuses a folder with nocut.json, because a corpus holds the only copy of its media. ⚠ N emptyin the title and[!]on a row show captions that are empty or duplicated.
Open a folder of corpora to work through each one in a queue. The keys, the batch editing and the queue are in docs/tui.md.
Caption a folder
caption captions each image and video in a folder, or one file, without a corpus. It writes a
.txt file with the same name beside each file:
curu-caption caption photos/
curu-caption caption photos/ --dry-run --hint "a set of family birthday photos"
captionreads the images (.png,.jpg,.jpeg) and videos (.mp4,.mkv,.mov,.avi,.webm) in the folder. It captions a video from one frame.- It does not caption a file that has a caption of 10 characters or more. Use
--forceto caption it again. --hintgives context for each caption in the run.--dry-runprints the captions and writes no file.- It shows the progress of each file. If one file fails, it continues. At the end, it exits with a code that is not 0.
Add WD14 tags, local and with no network, with --tags. With --no-prose, a caption has only
the tags, and no caption model is necessary:
curu-caption caption photos/ --no-prose --tags
WD14 tags are short Danbooru-style tags, for example 1girl, outdoors, smile. With prose and
tags, the .txt file has the prose, an empty line, and then the tags.
Find near-duplicates in a folder
curate reports the groups of near-duplicate images and clips in a folder. It does not change
or delete a file:
curu-caption curate photos/
Found 1 near-duplicate group(s):
Group 1: IMG_0001.jpg, IMG_0004.jpg
- Two files are near-duplicates when their perceptual hashes differ by 5 bits or fewer. Set a
different value with
--threshold. A higher value puts more files in each group. curatecompares PNG and JPEG stills and clips in one pass. It compares a clip on its middle frame. A clip costs 150 to 200 times more than a still, so a few thousand clips take minutes.curateskips caption files and manifests, and does not report them.- If all the files go in one group, the threshold is too high. Then
curatesays so, and reports no groups.
Judge a folder
judge asks the caption model to keep or reject each image against your criteria. It does
not change a file:
curu-caption judge photos/ --criteria "no blurry shots, subject clearly visible"
Judged 4 image(s): 3 keep, 1 reject.
KEEP IMG_0001.jpg: <the reason of the model>
REJECT IMG_0004.jpg: <the reason of the model>
Each line gives the verdict and the reason of the model. If the model rejects all the images,
judge changes each verdict to keep and says so. Then the criteria are too strict or not
clear.
Write dataset.yaml for a folder
dataset-config writes the dataset.yaml that AI-Toolkit trains from, for a folder of media
and .txt captions:
curu-caption dataset-config photos/ --trigger-word ohwx
It writes photos/dataset.yaml:
datasets:
- folder_path: .
caption_ext: txt
num_frames: 1
resolution:
- 256
- 256
num_repeats: 1
trigger_word: ohwx
sample_prompts:
- a test pattern, IMG_0001
folder_path is ., so run AI-Toolkit from the folder. --num-repeats sets the weight of the
dataset, 1 by default. resolution is the size of the first image, or 512 × 512 when there is
no image. sample_prompts holds up to five different captions from the folder.
Read a ComfyUI workflow
comfy inspect prints each node of the ComfyUI workflow in a generated PNG image:
curu-caption comfy inspect generated.png
Node 3 (KSampler)
seed: 851616030078638
steps: 20
...
Node 6 (CLIPTextEncode)
text: masterpiece best quality girl
clip: -> node 10 (output 1)
To use the text of a node as context for a caption, give its id to
caption --comfy-node.
Caption on another computer
Captioning is the one step that needs a GPU. Keep the corpus on your computer, and send the captions to Ollama on a computer that has a GPU:
curu-caption caption photos/ --ollama-url http://gpu-box:11434
curu-caption cut --corpus corpus/ --out training/ --projection wan-t2v --caption \
--ollama-url http://gpu-box:11434 --model saracen9/amoral-qwen3.8-27b
curu-caption cut --corpus corpus/ --out training/ --projection wan-t2v --caption \
--pod curu-caption-vlm
cut --captionchecks the endpoint before it selects or writes anything. If the computer does not have the model, the error names the models that it has.--podnames a RunPod pod thatcuru-inframade. The command gets its address and its token fromcuru-infra.- For an Ollama behind a token, set
CURU_CAPTION_OLLAMA_TOKEN. Do not put a token on the command line.
SSH tunnels, pods and the error messages are in docs/remote-captioning.md.
Work on a network share
On a network share (NAS), add --stage-locally to ingest and cut. The command works on the
local disk and copies the result to the share in one pass at the end:
curu-caption ingest --source videos/ --out corpus/ --stage-locally
- A network share is slow for thousands of small reads and writes. Staging changes them into one copy.
- Both commands show
sync-back: <done>/<total>while they copy. If the counter stops, the mount has stopped. captionstages its writes by default.--no-stage-writesstops this for a local folder.
The rules, the free disk space and the fix for a stopped macOS mount are in docs/nas.md.
Set the caption model and other settings
Most settings are options. Set the caption model one time for each command:
export CURU_CAPTION_CAPTION_MODEL=saracen9/amoral-qwen3.8-27b
The command-line option comes first, then the environment variable, then the configuration file
~/.config/curu-caption/config.toml, then the default:
# ~/.config/curu-caption/config.toml
[caption]
model = "saracen9/amoral-qwen3.8-27b"
[ort]
providers = "cpu"
Each setting, environment variable and file on disk is in docs/configuration.md.
Handle errors
A command that cannot do its work stops before it writes, prints the reason, and exits with a code that is not 0:
curu-caption ingest --source photos/ --out photo-corpus/ --no-index
curu-caption ingest --source photos/ --out photo-corpus/ --no-index || echo "exit code $?"
curu-caption cut --prune photo-corpus/ || echo "exit code $?"
refusing: photo-corpus is not empty
exit code 1
Error: photo-corpus has no cut.json -- it is not a cut. Pruning a corpus would delete the only copy of its media, so this refuses rather than guesses.
exit code 1
- Before the cost.
cut --captionchecks the caption endpoint before it selects, copies or captions. With--stage-locally, a command checks the free disk space before it copies. - One bad file.
caption,curateandjudgereport a file that fails and continue. At the end, they exit with a code that is not 0. - Missing facts.
cutreports an item that it cannot caption. It does not write a caption that it cannot check. - A failed encode.
cut --trainer amoral-avwrites each clip that it can encode. It shows the first 5 failures with the reason from ffmpeg, and the number of failures. Then it exits with 1. - Disk space.
ingeststops to wait when the disk of the corpus has less than 2 GB free. It writes a message on stderr when the wait starts, and again each 30 seconds. The message gives the free space and the minimum. After 10 minutes, it stops with an error.
Limits
--tagsdoes not work on clips. On a corpus made with--clips, thetagsandratingfacts fail for each clip (#170).- The
soundfact is not heard. The caption model writes it from the sound captioner's caption, the words, and one frame. On 40 test clips with the default 9B caption model, 9 of 30 claims beyond "a voice speaks" were correct and 15 were false. Most false claims are sources that the captioner names wrongly, for example a siren, a horse or a cow. Forh3, this text becomes the soundscape (overall_soundscape). Read it before you train, or train video-only withh3-prose(#93). - Dialogue does not say who speaks. Each line starts with
Someone says:, so a clip with two speakers does not tell them apart. A fix needs speaker diarization in the captions. - The picture is checked, not proven. On 40 test frames, 88 of the 898 claims that the check kept were false (precision 0.90, against 0.81 for the old prose captions). Most errors are upper garments, the direction that a person faces, and headwear. The check of helmets is poor, and an invented person keeps its claims.
--provider openrouterignores the corpus prompts. It does not get the claims form or the motion and sound prompts. Use Ollama for the captions of a cut.cut --captionhas no cloud provider.reviewand clips.xon a clip inreviewdoes not stopcutfrom using the clip.Xdoes not find a clip in a corpus of many sources (#87).reviewdoes not sort by quality, because it cannot read the facts of a clip yet (#87).reviewhas no mouse (#101).- d1-3B is slow without CUDA. On a Mac, the quality scores and the claim check run on the CPU, and take minutes for each item.
- It does not train. curu-caption makes datasets. AI-Toolkit trains.
Technical notes
- Version. 3.0.2, set in
pyproject.toml(project.version) and inCargo.toml(workspace.package.version). The changes are in CHANGELOG.md. - Build.
uv syncbuildscrates/curu-coreintocuru_caption._corewith maturin.crates/curu-tuiis thereviewprogram, andcrates/curu-ondiskwrites PNG text chunks. After a change to the Rust code of the extension, runuv sync --extra audioagain.[tool.uv] cache-keysinpyproject.tomllists the files that cause a new build. On Linux, the extension contains its own copy of liblzma, a compression library, so it needs no system copy. - Platforms. macOS and Linux. On Apple silicon, the models run on the CPU: CoreML was 1.6 to 5.6 times slower for WD14.
- ffmpeg. On x86_64, the Rust core downloads ffmpeg when it is not on the
PATH. On other platforms, install it. - Models. Each model downloads on first use, at a pinned revision. EmbeddingGemma 2 (1.8 GB) is the index, and d1-3B (6.2 GB) gives the quality scores and the claim check. WD14 (455 MB) gives the tags and the rating. EmbeddingGemma 2 and WD14 come from curu-vision, which checks their SHA-256.
- Audio models. The sound captioner (about 1 GB) and AST (346 MB) download into the cache of this project. AST is an audio tagger that gives a probability for each AudioSet sound label. Speech comes from curu-vision: MOSS (1.8 GB) on a computer with CUDA, which labels speakers, and Parakeet (2.4 GB) on other computers.
- Tutorials. The records of real training runs use the commands of version 2.0, which do not exist now. They are in project-management/archive/tutorials-v2/.
- Where it fits. curu-catalog finds media, curu-caption makes training data, curu generates, and curu-vision supplies shared models. See docs/ecosystem.md.
- Decisions. The decisions are in project-management/ADRs/.
The main ones:
- Facts in the media file: ADR-083
- The corpus and the cut: ADR-084
- H3 through AI-Toolkit: ADR-085
- The cull thresholds: ADR-080
- Search ranks and has no threshold: ADR-081
- AVDM, the scene document: ADR-088
- EmbeddingGemma 2, the index: ADR-089
- The quality scores sort and do not decide: ADR-090
- Audio facts of the trained window: ADR-091
- Captions from checked claims: ADR-092
- The bundle for amoral: ADR-093
- Speech from curu-vision: ADR-094
- The user files under XDG: ADR-064
- The local quality gate, with no CI: ADR-059
Develop
Run the quality gate before you push. There is no CI, so this local gate is the only gate:
uv tool install beacon-framework # BEACON 1.18 or later: the docs check and beacon doctor
uv sync --extra audio
scripts/check.sh # ruff, ty, cargo, the tests, the docs check and beacon doctor
scripts/check.sh --full # also a build from a new clone, before a release or a merge
uv run pytestruns the default tests, with no model and no endpoint.uv run pytest -m modelanduv run pytest -m vlmrun the tests that need weights or Ollama.- Write
README.md,docs/andAGENTS.mdto the standard in docs/WRITING.md.beacon docs checkchecks them. - The workflow, the tests and the release are in CONTRIBUTING.md. The rules for AI assistants are in AGENTS.md.
- The project uses the BEACON framework and Spec Kit. The problem statement, the architecture, the decisions and the roadmap are in project-management/.
Glossary
- AAC: a lossy audio format: the decoded audio is not the same as the audio before the encode. An MP4 file usually holds AAC audio.
- AI-Toolkit: the trainer that reads a
dataset.yamlor ajob.yaml(ostris/ai-toolkit). - AST: an audio tagger that gives a probability for each AudioSet sound label. The
musicfact comes from it. - AUROC: the probability that a score puts a good item above a bad item. 0.5 is chance, and 1.0 is always correct.
- AVDM: the scene document that each caption comes from (the
avdmpackage). - Channel layout: the position of each audio channel. For example, 5.1 is front left, front right, centre, LFE (the low bass channel) and two rear channels. In 5.1(side), the last two channels are at the sides.
- Claim check: d1-3B's yes or no answer to a question about each claim of the caption model.
- Clip: an MP4 of one shot, with its audio as FLAC, made by
ingest --clips. - Corpus: the folder that
ingestmakes. Each item holds its facts. - Cosine similarity: a measure of how close two embeddings are, from -1 to 1. A higher value is closer.
- Cull: the step of
ingestthat removes near-duplicate and blank frames. - Cut: a training set that
cutwrites from a corpus. Each file in it is a copy. - d1-3B: a LiquidAI model that gives the quality scores and the claim check.
- dBFS: the level of a sound sample in decibels (dB), where 0 dBFS is full scale. A lower (more negative) value is quieter.
- Diarization: a step that finds who speaks when, in an audio track.
- Embedding: a list of numbers for a picture or a text. Items with a similar meaning have similar embeddings.
- Fact: a value that a step measured or made for one item, with its provenance.
- FLAC: a lossless audio format. The decoded audio is the same as the audio before the encode.
- Full scale: the loudest sample value that a digital audio format can hold. The format holds a louder sample at full scale, and you hear distortion.
- Item: one frame, clip or photograph in a corpus.
- Laplacian variance: a measure of the detail in a picture. A blank frame is near 0.
- LoRA: a small set of extra weights that trains a model on one subject or style.
- musubi-tuner: the trainer that reads the files of
--trainer musubi(kohya-ss/musubi-tuner). - Near-duplicate: a frame or file that looks almost the same as another.
- Ollama: the local program that runs the caption model.
- Perceptual hash (pHash): a 64-bit summary of how a picture looks. Similar pictures have hashes that differ in few bits.
- Projection: the form of caption and media that one model trains on.
- Provenance: the record of which producer made a fact, and with which parameters.
- Staging: work on the local disk, then one copy to the destination.
- Trigger word: the word at the start of each caption that calls the LoRA, for example
ohwx. - VLM: a vision-language model, here the caption model in Ollama.
- WD14 rating: four scores from the WD14 tagger,
general,sensitive,questionableandexplicit, each from 0 to 1. Each score is the probability that the picture has that content rating, fromgeneral(safe) toexplicit. - WD14 tags: short Danbooru-style tags from the local WD14 tagger.
Metadata
Release files for curu-caption 3.0.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| curu_caption-3.0.2.tar.gz | 1.5 MB | With provenance | Details for curu_caption-3.0.2.tar.gz |
Built distributions (wheels)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| curu_caption-3.0.2-cp312-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl | CPython 3.12 | abi3 | Linux glibc 2.17+ x86-64 | Details for curu_caption-3.0.2-cp312-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl |
| curu_caption-3.0.2-cp312-abi3-macosx_11_0_arm64.whl | CPython 3.12 | abi3 | macOS 11.0+ ARM64 | Details for curu_caption-3.0.2-cp312-abi3-macosx_11_0_arm64.whl |
| curu_caption-3.0.2-cp312-abi3-macosx_10_12_x86_64.whl | CPython 3.12 | abi3 | macOS 10.12+ x86-64 | Details for curu_caption-3.0.2-cp312-abi3-macosx_10_12_x86_64.whl |
Total release size: 26.3 MB
Release files / curu_caption-3.0.2.tar.gz
| Download URL | curu_caption-3.0.2.tar.gz |
|---|---|
| Size | 1.5 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2fd2d756ede7b9bf6fea9b36b085f88912ff9e68e8a25d027f8ed0c59d50c6a3
|
|
BLAKE2b-256 checksum How to use checksums |
88a66d1afdd337c8925e19a21e8a00064dee15b0a7655fe4fbd567f8648ff7cf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / curu_caption-3.0.2-cp312-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
| Download URL | curu_caption-3.0.2-cp312-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl |
|---|---|
| Size | 10.4 MB |
| Tags | CPython 3.12 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
ea149a4199a27529b16f5e9a2b6a1350303df9e11b87c7023a57a2ef1e7dca01
|
|
BLAKE2b-256 checksum How to use checksums |
59b409a611be2363e78a6eba25723349a8a69d9130ced8ba755a4c2ff2cf431c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / curu_caption-3.0.2-cp312-abi3-macosx_11_0_arm64.whl
| Download URL | curu_caption-3.0.2-cp312-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 7.0 MB |
| Tags | CPython 3.12 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
acf48581f5673e9e19b0aa5ccc4537caebe54c865f88bd8ed1a45ed28e6c64b3
|
|
BLAKE2b-256 checksum How to use checksums |
0572d2f2a3219c3e5f696aeb2e8b5cdf8936a1857630e52ec66efc1b91d26296
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / curu_caption-3.0.2-cp312-abi3-macosx_10_12_x86_64.whl
| Download URL | curu_caption-3.0.2-cp312-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 7.4 MB |
| Tags | CPython 3.12 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
dc17fbb29b7db4e55ced9c5f0434352f0355ceabb923842a7765924a9d458623
|
|
BLAKE2b-256 checksum How to use checksums |
58bae842d5b743bb790aee8288128a7b311b6ff200330b5db5168651f135cdde
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logSecurity for curu-caption 3.0.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 4 files in this release have been uploaded with provenance metadata.
Attestation for curu_caption-3.0.2.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.