Version 0.1.0 #1
12 changed files with 2192 additions and 0 deletions
45
.forgejo/workflows/ci.yml
Normal file
45
.forgejo/workflows/ci.yml
Normal 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
|
||||||
71
.forgejo/workflows/version-bump.yml
Normal file
71
.forgejo/workflows/version-bump.yml
Normal 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
18
.gitignore
vendored
Normal 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
|
||||||
75
README.md
75
README.md
|
|
@ -1,2 +1,77 @@
|
||||||
# cadence
|
# 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
1522
cadence.py
Executable file
File diff suppressed because it is too large
Load diff
2
pytest.ini
Normal file
2
pytest.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
8
ruff.toml
Normal file
8
ruff.toml
Normal 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
43
tests/conftest.py
Normal 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
290
tests/test_core.py
Normal 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
70
tests/test_kdenlive.py
Normal 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
25
tests/test_transcripts.py
Normal 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
23
tests/test_version.py
Normal 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
|
||||||
Loading…
Reference in a new issue