Initial project import

This commit is contained in:
Melbar
2026-05-02 09:07:41 +02:00
commit 8e1bcf142f
38 changed files with 7928 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
"""
tests/test_export.py — Unit tests for timecode conversion and export writers
Tests use synthetic EditTimeline objects (no real video files needed).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from src.export.timecode import (
seconds_to_fcpxml,
seconds_to_smpte,
fcpxml_frame_duration,
fcpxml_format_name,
seconds_to_frame_count,
)
# ---------------------------------------------------------------------------
# Timecode helpers
# ---------------------------------------------------------------------------
class TestSecondsToFcpxml:
def test_zero(self) -> None:
assert seconds_to_fcpxml(0.0, 24.0) == "0s"
def test_one_second_at_24fps(self) -> None:
# 1.0s @ 24fps → 24 frames → 24/24s = 1/1s
result = seconds_to_fcpxml(1.0, 24.0)
assert result == "1/1s"
def test_one_second_at_23976(self) -> None:
# 1s @ 23.976 → 24000/24000 * 1001/1001 = 1001/1000 ... let's just check it's rational
result = seconds_to_fcpxml(1.0, 23.976)
assert result.endswith("s")
assert "/" in result
def test_ten_seconds_at_25fps(self) -> None:
# 10s @ 25fps → 250 frames → 250/25s = 10/1s
result = seconds_to_fcpxml(10.0, 25.0)
assert result == "10/1s"
def test_rational_is_reduced(self) -> None:
# Should never produce 24/24s
result = seconds_to_fcpxml(1.0, 24.0)
num, den = result.rstrip("s").split("/")
from math import gcd
assert gcd(int(num), int(den)) == 1
class TestSecondsToSmpte:
def test_zero(self) -> None:
assert seconds_to_smpte(0.0, 24.0) == "00:00:00:00"
def test_one_minute(self) -> None:
assert seconds_to_smpte(60.0, 25.0) == "00:01:00:00"
def test_one_hour(self) -> None:
assert seconds_to_smpte(3600.0, 24.0) == "01:00:00:00"
def test_frames_overflow(self) -> None:
# 25fps: 26 frames → 1s + 1 frame = 00:00:01:01
result = seconds_to_smpte(26 / 25, 25.0)
assert result == "00:00:01:01"
def test_format_length(self) -> None:
result = seconds_to_smpte(123.456, 23.976)
parts = result.split(":")
assert len(parts) == 4
assert all(len(p) == 2 for p in parts)
class TestFcpxmlHelpers:
def test_frame_duration_24fps(self) -> None:
assert fcpxml_frame_duration(24.0) == "1/24s"
def test_frame_duration_23976(self) -> None:
fd = fcpxml_frame_duration(23.976)
# Should be "1001/24000s"
assert fd == "1001/24000s"
def test_format_name_1080p_2398(self) -> None:
name = fcpxml_format_name(23.976, 1920, 1080)
assert "1080" in name
assert "2398" in name
def test_frame_count_roundtrip(self) -> None:
fps = 25.0
seconds = 10.0
frames = seconds_to_frame_count(seconds, fps)
assert frames == 250
# ---------------------------------------------------------------------------
# EDL writer (string output)
# ---------------------------------------------------------------------------
class TestEdlWriter:
def _make_timeline(self) -> "src.core.models.EditTimeline": # type: ignore
from src.core.models import (
BeatType, EditClip, EditTimeline, MatchResult, TrailerBeat,
)
beat = TrailerBeat(
beat_id=0, trailer_path=Path("trailer.mp4"),
start_s=0.0, end_s=5.0, start_frame=0, end_frame=120,
beat_type=BeatType.HOOK,
)
match = MatchResult(
beat_id=0, scene_id=3,
source_path=Path("movie.mp4"),
in_point_s=30.0, out_point_s=35.0,
in_point_frame=720, match_score=0.88,
)
clip = EditClip(
clip_index=0, beat=beat, match=match,
timeline_start_s=0.0, timeline_end_s=5.0,
)
return EditTimeline(
title="TestTrailer", frame_rate=25.0, clips=(clip,)
)
def test_edl_contains_title(self, tmp_path: Path) -> None:
from src.core.config import load_config
from src.export.edl_writer import write_edl
cfg = load_config()
tl = self._make_timeline()
out = write_edl(tl, cfg, output_path=tmp_path / "test.edl")
text = out.read_text(encoding="utf-8")
assert "TITLE: TestTrailer" in text
def test_edl_has_event_line(self, tmp_path: Path) -> None:
from src.core.config import load_config
from src.export.edl_writer import write_edl
cfg = load_config()
tl = self._make_timeline()
out = write_edl(tl, cfg, output_path=tmp_path / "test.edl")
text = out.read_text(encoding="utf-8")
assert "001" in text # event number
assert "AX" in text # reel name
# ---------------------------------------------------------------------------
# FCPXML writer (XML structure)
# ---------------------------------------------------------------------------
class TestFcpxmlWriter:
def _make_timeline(self) -> "src.core.models.EditTimeline": # type: ignore
from src.core.models import (
BeatType, EditClip, EditTimeline, MatchResult, TrailerBeat,
)
beat = TrailerBeat(
beat_id=0, trailer_path=Path("trailer.mp4"),
start_s=0.0, end_s=5.0, start_frame=0, end_frame=120,
beat_type=BeatType.HOOK,
)
match = MatchResult(
beat_id=0, scene_id=3,
source_path=Path("B:/Proxy/movie.mp4"),
in_point_s=30.0, out_point_s=35.0,
in_point_frame=720, match_score=0.88,
)
clip = EditClip(
clip_index=0, beat=beat, match=match,
timeline_start_s=0.0, timeline_end_s=5.0,
)
return EditTimeline(
title="TestTrailer", frame_rate=25.0, clips=(clip,)
)
def test_fcpxml_is_valid_xml(self, tmp_path: Path) -> None:
from xml.etree import ElementTree as ET
from src.core.config import load_config
from src.export.fcpxml_writer import write_fcpxml
cfg = load_config()
tl = self._make_timeline()
out = write_fcpxml(tl, cfg, output_path=tmp_path / "test.fcpxml")
text = out.read_text(encoding="utf-8")
text_no_doctype = "\n".join(
line for line in text.splitlines()
if not line.strip().startswith("<!DOCTYPE")
)
root = ET.fromstring(text_no_doctype)
# Strip namespace prefix for comparison
local_tag = root.tag.split("}")[-1] if "}" in root.tag else root.tag
assert local_tag == "fcpxml"
def test_fcpxml_has_spine(self, tmp_path: Path) -> None:
from xml.etree import ElementTree as ET
from src.core.config import load_config
from src.export.fcpxml_writer import write_fcpxml
cfg = load_config()
tl = self._make_timeline()
out = write_fcpxml(tl, cfg, output_path=tmp_path / "test.fcpxml")
text = out.read_text(encoding="utf-8")
text_no_doctype = "\n".join(
line for line in text.splitlines()
if not line.strip().startswith("<!DOCTYPE")
)
# Register the FCPXML namespace so find() works
ns = {"fcp": "http://www.apple.com/dt/FCPXML/1_10"}
root = ET.fromstring(text_no_doctype)
spine = root.find(".//fcp:spine", ns)
assert spine is not None
clips = list(spine)
assert len(clips) == 1