Version 0.1.0
This commit is contained in:
parent
bc23abf677
commit
2b5d0ed3ad
11 changed files with 2169 additions and 0 deletions
93
.forgejo/workflows/ci.yml
Normal file
93
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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.
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout
|
||||
# Fully qualified so a changed DEFAULT_ACTIONS_URL can't break the run.
|
||||
uses: https://data.forgejo.org/actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: https://data.forgejo.org/astral-sh/setup-uv@v10
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Lint (ruff)
|
||||
run: uvx 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 --with pytest pytest -q
|
||||
|
||||
version-bump:
|
||||
# Only meaningful on pull requests, where there is a base branch to diff
|
||||
# against. On `push` there is nothing to compare, so the job is skipped.
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://data.forgejo.org/actions/checkout@v6
|
||||
with:
|
||||
# Full history so the base branch is reachable for the comparison.
|
||||
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 (not a pull request); 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
|
||||
|
||||
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