Version 0.1.0 (#1)
All checks were successful
CI / lint-and-test (push) Successful in 11s

Reviewed-on: #1
Co-authored-by: pedro-bento <mail@pbento.pt>
Co-committed-by: pedro-bento <mail@pbento.pt>
This commit is contained in:
pedro-bento 2026-09-30 18:33:59 +01:00 committed by pedro-bento
parent bc23abf677
commit ed8c0145ff
12 changed files with 2192 additions and 0 deletions

45
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,45 @@
name: CI
# Forgejo Actions workflow. Forgejo resolves workflows from
# .forgejo/workflows/ first (then .gitea/workflows/, then .github/workflows/).
#
# `runs-on: docker` matches the label exposed by the standard self-hosted
# Forgejo runner. Change it if your runner was registered with another label.
#
# Actions are pinned to node20-compatible releases. Older forgejo-runner
# versions (e.g. v6.4.0) reject any action whose action.yml declares
# `using: node24` — checkout v4 is the last line that runs on node20.
#
# The pull-request-only version-bump gate lives in its own workflow
# (version-bump.yml) because Forgejo does not reliably skip a job on a
# job-level `if:` and reports the skipped job as a failure instead.
on:
push:
pull_request:
jobs:
lint-and-test:
runs-on: docker
steps:
- name: Checkout
# v4 runs on node20 (v5+ require node24).
uses: https://data.forgejo.org/actions/checkout@v4
- name: Install uv
# Installed from the official script rather than a Node action so the
# job works on runners that only support node20.
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
uv python install 3.13
uv --version
- name: Lint (ruff)
run: uvx --python 3.13 ruff check .
- name: Test (pytest)
# The suite only needs pytest: cadence.py imports nothing third-party
# at module level, so no torch/whisper/ffmpeg is required here.
run: uv run --python 3.13 --with pytest pytest -q

View file

@ -0,0 +1,71 @@
name: Version bump
# Runs only on pull requests: it compares cadence.py's __version__ on the head
# branch against the base branch and fails if the version was not increased.
#
# Kept as a separate workflow rather than a job-level `if:` inside ci.yml:
# forgejo-runner does not reliably skip a job on `if:` and reports the skipped
# job as a failure on push events.
on:
pull_request:
jobs:
version-bump:
runs-on: docker
steps:
- name: Checkout
# v4 runs on node20 (v5+ require node24); full history so the base
# branch is reachable for the comparison.
uses: https://data.forgejo.org/actions/checkout@v4
with:
fetch-depth: 0
- name: Require a version bump
shell: bash
run: |
set -euo pipefail
# `github.base_ref` is the GitHub-compatible alias and is understood
# by Forgejo; `forgejo.base_ref` is the canonical spelling.
BASE="${{ github.base_ref }}"
if [ -z "$BASE" ]; then
echo "No base ref; skipping version check."
exit 0
fi
git fetch --no-tags origin "$BASE"
if [ ! -f cadence.py ]; then
echo "::error::cadence.py is missing from the change."
exit 1
fi
NEW=$(sed -n 's/^__version__[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' cadence.py | head -n1)
if [ -z "$NEW" ]; then
echo "::error::No __version__ found in cadence.py"
exit 1
fi
# The script may be introduced by this very PR: if it does not exist
# on the base branch there is no previous version to bump from.
if ! git cat-file -e "FETCH_HEAD:cadence.py" 2>/dev/null; then
echo "cadence.py is new to '$BASE' (no base version); version check passes."
exit 0
fi
OLD=$(git show "FETCH_HEAD:cadence.py" | sed -n 's/^__version__[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
echo "base __version__: ${OLD:-<none>} head __version__: $NEW"
if [ -z "$OLD" ]; then
echo "Could not read a base version; skipping."
exit 0
fi
# Require NEW to be strictly greater than OLD (version sort).
GREATER=$(printf '%s\n%s\n' "$OLD" "$NEW" | sort -V | tail -n1)
if [ "$NEW" = "$GREATER" ] && [ "$OLD" != "$NEW" ]; then
echo "Version bumped: $OLD -> $NEW"
else
echo "::error::cadence.py __version__ must be bumped above $OLD (currently $NEW)."
exit 1
fi

18
.gitignore vendored Normal file
View file

@ -0,0 +1,18 @@
# Python
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.venv/
venv/
# Files generated by cadence
*.kdenlive
*.srt
*_llm_transcript.txt
*.whisper.json
*.tmp
# OS / editor cruft
.DS_Store
Thumbs.db

View file

@ -1,2 +1,77 @@
# cadence
High-retention auto-editor for raw video recordings. It transcribes your audio, cuts silences and filler words, and writes a native `.kdenlive` project ready for final polish — plus `.srt` captions and an LLM-friendly transcript.
## Quick start
### Linux
```sh
# uv — installs to ~/.local/bin
curl -LsSf https://astral.sh/uv/install.sh | sh
# FFmpeg — pick your distro
sudo apt install ffmpeg # Debian / Ubuntu
sudo dnf swap ffmpeg-free ffmpeg --allowerasing # Fedora
sudo pacman -S ffmpeg # Arch
# Run
uv run cadence.py recording.mp4 --preset balanced --captions
```
> - **uv** installs to `~/.local/bin`; make sure that directory is on your `PATH` (the installer normally adds it — otherwise `export PATH="$HOME/.local/bin:$PATH"` in your shell profile).
> - **Fedora**: enable [RPM Fusion](https://rpmfusion.org/Configuration) first. Fedora ships `ffmpeg-free`, which conflicts with the full `ffmpeg` package, so swap rather than install — `sudo dnf swap ffmpeg-free ffmpeg --allowerasing`.
> - **Nobara**: codecs are managed by Nobara and manual changes are blocked. Run `nobara-sync install-codecs` (or the Codec Wizard) instead of `dnf swap`.
### macOS
```sh
brew install uv ffmpeg
uv run cadence.py recording.mp4 --preset balanced --captions
```
### Windows (PowerShell)
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" # uv
winget install -e --id Gyan.FFmpeg # FFmpeg
uv run cadence.py recording.mp4 --preset balanced --captions
```
## Install as a command (Linux & macOS)
Make `cadence` available everywhere by symlinking the script into a directory on your `PATH`:
```sh
chmod +x cadence.py
mkdir -p ~/.local/bin
ln -sf "$PWD/cadence.py" ~/.local/bin/cadence
```
Now call it from any directory:
```sh
cadence recording.mp4 --preset balanced --captions
```
`~/.local/bin` is added to `PATH` by the uv installer; if it isn't, add `export PATH="$HOME/.local/bin:$PATH"` to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.).
## Output
- `recording.kdenlive` — the edited project.
- `recording.srt` and `recording_llm_transcript.txt` — generated when `--captions` is used.
Presets: `relaxed`, `balanced`, `punchy`. See all options with `uv run cadence.py --help`.
## Development
Run the linter and tests locally:
```sh
uvx ruff check .
uv run --with pytest pytest -q
```
The test suite needs only `pytest` — `cadence.py` imports nothing third-party at module level, so no torch/whisper/ffmpeg is required to run it. CI runs both via Forgejo Actions (`.forgejo/workflows/ci.yml`).

1522
cadence.py Executable file

File diff suppressed because it is too large Load diff

2
pytest.ini Normal file
View file

@ -0,0 +1,2 @@
[pytest]
testpaths = tests

8
ruff.toml Normal file
View file

@ -0,0 +1,8 @@
line-length = 100
target-version = "py313"
[lint]
select = ["E", "F", "I"]
# E501 is intentionally ignored: the file embeds long prose (the LLM prompt
# template) and a few generated-string lines that cannot be wrapped cleanly.
ignore = ["E501"]

43
tests/conftest.py Normal file
View file

@ -0,0 +1,43 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import pytest # noqa: E402
import cadence # noqa: E402
@pytest.fixture
def make_track(tmp_path):
"""Small factory for building VideoTrackData objects in tests."""
def _make(
*,
name: str = "video.mp4",
duration: float = 2.0,
fps_num: int = 30,
fps_den: int = 1,
width: int = 1920,
height: int = 1080,
audio_track_idx: int = 0,
audio_stream_count: int = 1,
words: list | None = None,
timeline: list | None = None,
) -> cadence.VideoTrackData:
if timeline is None:
timeline = [cadence.Segment(0.0, duration, "keep")]
return cadence.VideoTrackData(
video=tmp_path / name,
duration=duration,
fps_num=fps_num,
fps_den=fps_den,
width=width,
height=height,
audio_track_idx=audio_track_idx,
audio_stream_count=audio_stream_count,
words=list(words) if words else [],
timeline=list(timeline),
)
return _make

290
tests/test_core.py Normal file
View file

@ -0,0 +1,290 @@
import json
import sys
import pytest
from cadence import (
Segment,
Word,
_cache_key,
_read_cache,
_write_cache,
build_timeline,
compute_jl_cut_shifts,
frames_to_tc,
map_words_to_edited_timeline,
normalize_token,
parse_args,
)
# ---------------------------------------------------------------------------
# normalize_token
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("raw", "expected"),
[
("Uh...", "uh"),
("—", ""),
('"Hello,"', "hello"),
(" Word? ", "word"),
("...", ""),
("Umm!", "umm"),
],
)
def test_normalize_token(raw, expected):
assert normalize_token(raw) == expected
# ---------------------------------------------------------------------------
# frames_to_tc
# ---------------------------------------------------------------------------
def test_frames_to_tc_zero_and_negative():
assert frames_to_tc(0, 30.0) == "00:00:00.000"
assert frames_to_tc(-7, 30.0) == "00:00:00.000"
def _parse_tc(tc: str) -> float:
h, m, s = tc.split(":")
return int(h) * 3600 + int(m) * 60 + float(s)
@pytest.mark.parametrize("frames", [1, 30, 100, 1000, 30000, 90000])
def test_frames_to_tc_roundtrip_ntsc(frames):
fps = 30000 / 1001
tc = frames_to_tc(frames, fps)
secs = _parse_tc(tc)
back = round(secs * fps)
assert abs(back - frames) <= 1
# ---------------------------------------------------------------------------
# build_timeline
# ---------------------------------------------------------------------------
def _assert_tiling(timeline, duration, min_keep):
assert timeline, "timeline must not be empty"
# Alternating keep/drop actions.
for prev, nxt in zip(timeline, timeline[1:]):
assert prev.action != nxt.action
# Contiguous tiling of [0, duration].
assert timeline[0].start == pytest.approx(0.0)
for prev, nxt in zip(timeline, timeline[1:]):
assert prev.end == pytest.approx(nxt.start)
assert timeline[-1].end == pytest.approx(duration)
keep = sum(s.end - s.start for s in timeline if s.action == "keep")
drop = sum(s.end - s.start for s in timeline if s.action == "drop")
assert keep == pytest.approx(duration - drop)
for seg in timeline:
if seg.action == "keep":
assert (seg.end - seg.start) >= min_keep - 1e-9
@pytest.mark.parametrize(
("cuts", "duration"),
[
([], 10.0),
([(2.0, 3.0, "silence"), (5.0, 6.0, "filler")], 10.0),
([(0.0, 1.0, "x"), (9.5, 10.0, "y")], 10.0),
([(0.0, 2.0, "a"), (2.02, 4.0, "b")], 5.0),
([(1.0, 2.0, "a"), (2.0, 3.0, "b"), (3.0, 4.0, "c")], 4.0),
],
)
def test_build_timeline_invariants(cuts, duration):
min_keep = 0.08
timeline = build_timeline(cuts, duration, min_keep_dur=min_keep)
_assert_tiling(timeline, duration, min_keep)
def test_build_timeline_no_cuts_single_keep():
assert build_timeline([], 5.0) == [Segment(0.0, 5.0, "keep")]
def test_build_timeline_zero_duration_returns_keep():
# Verified real behavior: the empty list falls through to a single keep
# segment spanning [0, 0].
assert build_timeline([], 0.0) == [Segment(0.0, 0.0, "keep")]
# ---------------------------------------------------------------------------
# compute_jl_cut_shifts
# ---------------------------------------------------------------------------
def _keeps(*pairs):
return [Segment(s, e, "keep") for s, e in pairs]
def _assert_jl_bounds(segs, shifts, fps, jl_frames):
for i in range(len(segs) - 1):
cur_in = int(round(segs[i].start * fps))
cur_out = int(round(segs[i].end * fps))
cur_dur = max(1, cur_out - cur_in)
next_in = int(round(segs[i + 1].start * fps))
next_out = int(round(segs[i + 1].end * fps))
next_dur = max(1, next_out - next_in)
handle = max(0, next_in - cur_out - 1)
bound = min(jl_frames, handle, cur_dur // 4, next_dur // 4)
assert abs(shifts[i]) <= bound
assert shifts[-1] == 0
def test_jl_off_and_trivial_cases():
segs = _keeps((0, 1), (2, 3), (4, 5))
assert compute_jl_cut_shifts(segs, 30.0, "off", 10) == [0, 0, 0]
assert compute_jl_cut_shifts(segs, 30.0, "j", 0) == [0, 0, 0]
assert compute_jl_cut_shifts(_keeps((0, 1)), 30.0, "j", 10) == [0]
assert compute_jl_cut_shifts([], 30.0, "j", 10) == []
def test_jl_j_signs_and_bounds():
segs = _keeps((0, 1), (2, 3), (4, 5))
shifts = compute_jl_cut_shifts(segs, 30.0, "j", 2)
assert shifts == [2, 2, 0]
assert all(x >= 0 for x in shifts)
_assert_jl_bounds(segs, shifts, 30.0, 2)
def test_jl_l_signs_and_bounds():
segs = _keeps((0, 1), (2, 3), (4, 5))
shifts = compute_jl_cut_shifts(segs, 30.0, "l", 2)
assert shifts == [-2, -2, 0]
assert all(x <= 0 for x in shifts)
_assert_jl_bounds(segs, shifts, 30.0, 2)
def test_jl_clamped_by_handle():
# Touching segments leave no handle, so even a large request becomes 0.
segs = _keeps((0, 1), (1, 2))
assert compute_jl_cut_shifts(segs, 30.0, "j", 100) == [0, 0]
def test_jl_last_element_always_zero():
segs = _keeps((0, 1), (2, 3), (4, 5), (6, 7))
for mode in ("j", "l"):
assert compute_jl_cut_shifts(segs, 30.0, mode, 2)[-1] == 0
# ---------------------------------------------------------------------------
# map_words_to_edited_timeline
# ---------------------------------------------------------------------------
def test_map_words_to_edited_timeline(make_track):
td1 = make_track(
name="a.mp4",
duration=4.0,
timeline=[
Segment(0.0, 2.0, "keep"),
Segment(2.0, 3.0, "drop"),
Segment(3.0, 4.0, "keep"),
],
words=[
Word("a", 1.0, 1.2),
Word("dropped", 2.1, 2.3),
Word("b", 3.1, 3.3),
],
)
td2 = make_track(
name="b.mp4",
duration=3.0,
timeline=[Segment(0.0, 3.0, "keep")],
words=[Word("c", 0.5, 0.7)],
)
mapped = map_words_to_edited_timeline([td1, td2])
onsets = {text: (s, e) for s, e, text in mapped}
assert "dropped" not in onsets
assert onsets["b"][0] == pytest.approx(2.1)
assert onsets["c"][0] == pytest.approx(3.5)
assert len(mapped) == 3
# ---------------------------------------------------------------------------
# cache helpers
# ---------------------------------------------------------------------------
def test_cache_roundtrip_and_no_version_key(tmp_path):
path = tmp_path / "cache.json"
words = [Word("Hello", 0.0, 0.5), Word("world", 0.5, 1.0)]
_write_cache(path, words)
assert _read_cache(path) == words
payload = json.loads(path.read_text(encoding="utf-8"))
assert "version" not in payload
assert payload["words"][0] == {"text": "Hello", "start": 0.0, "end": 0.5}
def test_write_cache_empty_words_writes_nothing(tmp_path):
path = tmp_path / "empty.json"
_write_cache(path, [])
assert not path.exists()
def test_read_cache_corrupt_returns_none(tmp_path):
path = tmp_path / "bad.json"
path.write_text("{not valid json", encoding="utf-8")
assert _read_cache(path) is None
def test_read_cache_empty_words_returns_empty_list(tmp_path):
path = tmp_path / "empty.json"
path.write_text(json.dumps({"words": []}), encoding="utf-8")
assert _read_cache(path) == []
def test_read_cache_bare_list_returns_none(tmp_path):
path = tmp_path / "list.json"
path.write_text(json.dumps([{"text": "x", "start": 0, "end": 1}]), encoding="utf-8")
assert _read_cache(path) is None
def test_cache_key_varies_with_model_and_language():
base = _cache_key("large-v3", "faster-whisper", "en")
assert base != _cache_key("small", "faster-whisper", "en")
assert base != _cache_key("large-v3", "faster-whisper", "fr")
assert base == _cache_key("large-v3", "faster-whisper", "en")
# ---------------------------------------------------------------------------
# parse_args
# ---------------------------------------------------------------------------
def _parse(monkeypatch, argv):
monkeypatch.setattr(sys, "argv", ["cadence.py", *argv])
return parse_args()
@pytest.mark.parametrize(
"argv",
[
["video.mp4", "--audio-track", "0"],
["video.mp4", "--jl-frames", "-1"],
["video.mp4", "--pad", "-0.1"],
["video.mp4", "--max-silence", "abc"],
],
)
def test_parse_args_rejects_invalid(monkeypatch, argv):
with pytest.raises(SystemExit) as exc:
_parse(monkeypatch, argv)
assert exc.value.code != 0
def test_parse_args_defaults_are_none(monkeypatch):
args = _parse(monkeypatch, ["video.mp4"])
assert args.max_silence is None
assert args.pad is None
assert args.min_keep is None
assert args.jl_frames is None
assert args.preset is None
def test_parse_args_version_exits_zero(monkeypatch):
with pytest.raises(SystemExit) as exc:
_parse(monkeypatch, ["--version"])
assert exc.value.code == 0

70
tests/test_kdenlive.py Normal file
View file

@ -0,0 +1,70 @@
import json
import xml.etree.ElementTree as ET
import cadence
def _write_and_parse(make_track, tmp_path, **kwargs):
td = make_track(**kwargs)
out = tmp_path / "project"
cadence._generate_multi_kdenlive_project([td], out)
kdenlive = out.with_suffix(".kdenlive")
assert kdenlive.exists()
return ET.parse(kdenlive).getroot()
def _sequence_tractor(root):
for tractor in root.findall("tractor"):
if tractor.find("property[@name='kdenlive:uuid']") is not None:
return tractor
raise AssertionError("sequence tractor not found")
def _track_producers(tractor):
return [t.get("producer") for t in tractor.findall("track")]
def _group_track_indices(tractor):
prop = tractor.find("property[@name='kdenlive:sequenceproperties.groups']")
groups = json.loads(prop.text)
return [child["data"].split(":")[0] for group in groups for child in group["children"]]
def test_project_with_system_audio(make_track, tmp_path):
root = _write_and_parse(
make_track, tmp_path, audio_stream_count=2, audio_track_idx=0
)
assert root.find("chain[@id='chain_a2_0']") is not None
seq = _sequence_tractor(root)
assert _track_producers(seq) == ["producer0", "tractor0", "tractor_a2", "tractor1"]
assert _group_track_indices(seq) == ["0", "1", "2"]
def test_project_without_system_audio(make_track, tmp_path):
root = _write_and_parse(
make_track, tmp_path, audio_stream_count=1, audio_track_idx=0
)
assert root.find("chain[@id='chain_a2_0']") is None
seq = _sequence_tractor(root)
assert _track_producers(seq) == ["producer0", "tractor0", "tractor1"]
assert _group_track_indices(seq) == ["0", "1"]
def test_system_stream_wiring_audio_track_0(make_track, tmp_path):
root = _write_and_parse(
make_track, tmp_path, audio_stream_count=2, audio_track_idx=0
)
chain = root.find("chain[@id='chain_a2_0']")
assert chain.find("property[@name='astream']").text == "1"
def test_system_stream_wiring_audio_track_1(make_track, tmp_path):
root = _write_and_parse(
make_track, tmp_path, audio_stream_count=2, audio_track_idx=1
)
chain = root.find("chain[@id='chain_a2_0']")
assert chain.find("property[@name='astream']").text == "0"

25
tests/test_transcripts.py Normal file
View file

@ -0,0 +1,25 @@
import cadence
from cadence import Segment, Word
def test_generate_combined_transcripts(make_track, tmp_path):
td = make_track(
name="video.mp4",
duration=2.0,
timeline=[Segment(0.0, 2.0, "keep")],
words=[Word("Hello.", 0.2, 0.5), Word("world.", 0.6, 0.9)],
)
out = tmp_path / "captions"
cadence.generate_combined_transcripts([td], out)
srt = out.with_suffix(".srt")
assert srt.exists()
assert srt.stat().st_size > 0
llm_transcript = out.with_name(f"{out.stem}_llm_transcript.txt")
assert llm_transcript.exists()
assert llm_transcript.stat().st_size > 0
# Atomic writes must not leave temp files behind.
assert not list(tmp_path.glob("*.tmp"))

23
tests/test_version.py Normal file
View file

@ -0,0 +1,23 @@
"""Version metadata checks.
These keep the CI version-bump gate meaningful: `__version__` must stay a
parseable `MAJOR.MINOR.PATCH` string and be surfaced by `--version`.
"""
import re
import sys
import cadence
def test_version_is_semver():
assert re.fullmatch(r"\d+\.\d+\.\d+", cadence.__version__)
def test_version_flag_reports_version(monkeypatch, capsys):
monkeypatch.setattr(sys, "argv", ["cadence", "--version"])
try:
cadence.parse_args()
except SystemExit as exc:
assert exc.code == 0
assert cadence.__version__ in capsys.readouterr().out