Files
ECC/skills/taste-application/tests/test_timeline_export.py
T
928c1dea72 feat(tasteforge): package reusable workflows and preserve native edits (#3033)
* feat: bundle standalone taste distillation and application workflows

* docs: fix imported taste skill markdown lint

* docs: align Turkish agent catalog with taste skills

* refactor: make ECC the canonical reusable video engine

* fix: preserve video duration when applying image overlays

* fix: preserve background colors in image compositing

* fix: report best-effort duration targets and shortfalls

* feat: ship verified Fusion presets with compatibility provenance

* feat(tasteforge): preserve native edits in application bundles

* feat(tasteforge): compile local preservation without hosted input

* fix: update js-yaml to patched 4.3.2

* test: report bounded Stop wrapper failure diagnostics

* fix(tasteforge): fail closed on unsafe output names, missing overlays and cadence

- cli: default report and spec paths are derived from pack name and profile
  genre; require the manifest's name pattern before using either as a
  filename part so a traversal string cannot write outside cwd/out.
- apply_local: a pack without cadence.json, or with no measured shots and
  no explicit mean_shot, raises instead of silently planning 1.0s shots and
  reporting a measured cadence.
- legacy apply: a missing overlay aborts before any paid upload; forge()
  would have rejected it after every take was generated.
- requirements-live: pin fal-client>=0.13.0, the first release whose
  subscribe() accepts client_timeout.

Addresses the five P1 findings from the independent review of #3033.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fxHRsydPqEcYngGbqkgt1

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 15:31:36 +01:00

115 lines
4.5 KiB
Python

"""Failing-first tests for the timeline timebase and EDL/FCPXML export.
Numeric expectations are canonicalized from the recovered gen4
``taste/timeline.py`` self-checks and the recovered ``flashethereal-cut.edl``.
"""
from __future__ import annotations
import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
from fractions import Fraction
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(REPO_ROOT))
from tasteforge import export, timeline # noqa: E402
CLIPS = [
{"path": "/tmp/media/a.mov", "duration": 1.5, "name": "alpha"},
{"path": "/tmp/media/b.mov", "duration": 2.25, "name": "beta"},
{"path": "/tmp/media/c.mov", "duration": 0.75, "name": "gamma"},
]
class TimebaseTests(unittest.TestCase):
def test_ntsc_snap(self):
self.assertEqual(timeline.fps_fraction(29.97), Fraction(30000, 1001))
self.assertEqual(timeline.fps_fraction(23.976), Fraction(24000, 1001))
self.assertEqual(timeline.fps_fraction(24), Fraction(24, 1))
def test_negative_fps_rejected(self):
with self.assertRaises(ValueError):
timeline.fps_fraction(-3)
def test_seconds_to_frames_rounds_half_away_from_zero(self):
self.assertEqual(timeline.seconds_to_frames(0.5, 24), 12)
self.assertEqual(timeline.seconds_to_frames(1.25, 24), 30)
def test_rational_time_strings(self):
self.assertEqual(timeline.frames_to_rational(1, 29.97), "1001/30000s")
self.assertEqual(timeline.frames_to_rational(30, 29.97), "1001/1000s")
self.assertEqual(timeline.frames_to_rational(120, 24), "5s")
self.assertEqual(timeline.seconds_to_rational(2.5, 24), "5/2s")
def test_timecode_drop_frame(self):
self.assertEqual(timeline.frames_to_timecode(1800, 29.97), "00:01:00:02")
self.assertEqual(timeline.frames_to_timecode(17982, 29.97), "00:10:00:00")
self.assertEqual(timeline.frames_to_timecode(24, 24), "00:00:01:00")
class EDLTests(unittest.TestCase):
def test_build_edl_header_and_events(self):
text = export.build_edl(CLIPS, fps=24.0, title="unittest-cut")
lines = text.splitlines()
self.assertEqual(lines[0], "TITLE: UNITTEST-CUT")
self.assertIn("FCM: NON-DROP FRAME", lines)
event_lines = [ln for ln in lines if ln[:1].isdigit()]
self.assertEqual(len(event_lines), 3)
self.assertIn("* FROM CLIP NAME: a.mov", text)
def test_edl_roundtrip_parses(self):
text = export.build_edl(CLIPS, fps=24.0, title="rt")
events = export.parse_edl(text)
self.assertEqual(len(events), 3)
self.assertEqual(events[0]["name"], "a.mov")
self.assertEqual(events[-1]["record_in"], 90) # 1.5+2.25 s at 24fps
self.assertEqual(events[-1]["duration_frames"], 18)
def test_ntsc_edl_is_drop_frame(self):
text = export.build_edl(CLIPS, fps=29.97, title="ntsc")
self.assertIn("FCM: DROP FRAME", text)
class FCPXMLTests(unittest.TestCase):
def test_build_fcpxml_structure(self):
text = export.build_fcpxml(CLIPS, fps=24.0, title="tf-test")
root = ET.fromstring(text)
self.assertEqual(root.tag, "fcpxml")
self.assertIn("version", root.attrib)
assets = root.findall("./resources/asset")
self.assertEqual(len(assets), 3)
clips = root.findall(".//asset-clip")
self.assertEqual(len(clips), 3)
def test_fcpxml_durations_are_rational_and_exact(self):
text = export.build_fcpxml(CLIPS, fps=24.0, title="tf-test")
root = ET.fromstring(text)
clips = root.findall(".//asset-clip")
# 1.5s @24 -> "7/2s"? No: quantized frames=36 -> "3/2s"
self.assertEqual(clips[0].get("duration"), "3/2s")
total = sum(Fraction(c.get("duration").rstrip("s")) for c in clips)
self.assertEqual(total, Fraction(int(4.5 * 24), 24))
def test_zero_or_negative_duration_rejected(self):
with self.assertRaises(ValueError):
export.build_fcpxml([{"path": "x", "duration": 0}], fps=24)
with self.assertRaises(ValueError):
export.build_edl([], fps=24)
class WriteTimelineTests(unittest.TestCase):
def test_write_timeline_emits_both_formats(self):
with tempfile.TemporaryDirectory() as td:
edl, fcpxml = export.write_timeline(CLIPS, out_dir=td, title="wt")
self.assertTrue(Path(edl).exists())
self.assertTrue(Path(fcpxml).exists())
self.assertIn("TITLE: WT", Path(edl).read_text())
if __name__ == "__main__":
unittest.main()