219 lines
7.3 KiB
Python
219 lines
7.3 KiB
Python
"""
|
|
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
|