mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
* 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>
557 lines
20 KiB
Python
557 lines
20 KiB
Python
"""Editable timeline emission: the distilled cut rhythm, handed to a real NLE.
|
|
|
|
A style pack knows *where a reference cuts* (``cadence.py``) and *what it looks
|
|
like* (``grade.py`` / ``look.cube``). Neither survives as a rendered mp4 - the
|
|
moment you hand someone a flat file, the pacing becomes unnegotiable and the
|
|
grade becomes baked. This module closes that gap by writing the cut list out as
|
|
a project file, so the rhythm arrives in DaVinci Resolve / Premiere / Final Cut
|
|
as *editable events* that a human can still push around.
|
|
|
|
Two formats, deliberately:
|
|
|
|
* **FCPXML** - the rich one. Carries per-clip source references, frame-exact
|
|
offsets, and format metadata. DaVinci Resolve imports it directly
|
|
(File > Import > Timeline).
|
|
* **EDL (CMX3600)** - the dumb, universal one. No media references, just
|
|
timecode. It is the fallback that works when FCPXML round-tripping does not.
|
|
|
|
The single most important detail in here is time representation. **FCPXML
|
|
times are rational strings, not decimal seconds.** ``"1001/30000s"`` is one
|
|
frame at 29.97; ``"1.001s"`` is a rounding error waiting to desync a timeline.
|
|
Every time value written by this module goes through :func:`seconds_to_rational`
|
|
or :func:`frames_to_rational`, which quantise to whole frames at the sequence
|
|
timebase and emit an exact reduced fraction. Durations are accumulated in
|
|
*integer frames*, never in floats, so the sequence duration is exactly the sum
|
|
of its clips no matter how long the timeline runs.
|
|
|
|
Self-check::
|
|
|
|
python3 taste/timeline.py
|
|
|
|
Deliberately stdlib-only, so it can be run as a script without dragging in the
|
|
numpy/opencv half of the package.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import xml.etree.ElementTree as ET
|
|
from fractions import Fraction
|
|
from pathlib import Path
|
|
from typing import Iterable, Sequence
|
|
from xml.dom import minidom
|
|
|
|
__all__ = [
|
|
"fps_fraction",
|
|
"frame_duration",
|
|
"seconds_to_frames",
|
|
"frames_to_rational",
|
|
"seconds_to_rational",
|
|
"frames_to_timecode",
|
|
"build_fcpxml",
|
|
"build_edl",
|
|
"write_timeline",
|
|
]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# timebase
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# NTSC-family rates are *not* the decimals people write them as. 29.97 is
|
|
# exactly 30000/1001, and a timeline built on the decimal drifts by ~3.6s per
|
|
# hour. Anything within this tolerance of a known NTSC rate snaps to the exact
|
|
# fraction; everything else is taken at face value.
|
|
_NTSC: dict[float, Fraction] = {
|
|
23.976: Fraction(24000, 1001),
|
|
29.97: Fraction(30000, 1001),
|
|
47.952: Fraction(48000, 1001),
|
|
59.94: Fraction(60000, 1001),
|
|
119.88: Fraction(120000, 1001),
|
|
}
|
|
_NTSC_TOL = 0.02
|
|
|
|
# CMX3600 signals drop-frame with the `FCM:` header line rather than with the
|
|
# timecode separator; some houses also swap ':' for ';'. We emit the spec form
|
|
# (FCM header, ':' separators) because that is what Resolve's EDL parser keys on.
|
|
EDL_DROP_SEPARATOR = ":"
|
|
|
|
|
|
def fps_fraction(fps: float | Fraction) -> Fraction:
|
|
"""Exact frame rate as a :class:`Fraction`, snapping NTSC decimals.
|
|
|
|
>>> fps_fraction(29.97)
|
|
Fraction(30000, 1001)
|
|
>>> fps_fraction(24)
|
|
Fraction(24, 1)
|
|
"""
|
|
if isinstance(fps, Fraction):
|
|
return fps
|
|
fps = float(fps)
|
|
if fps <= 0:
|
|
raise ValueError(f"fps must be positive, got {fps!r}")
|
|
for nominal, exact in _NTSC.items():
|
|
if abs(fps - nominal) < _NTSC_TOL:
|
|
return exact
|
|
if abs(fps - round(fps)) < 1e-9:
|
|
return Fraction(int(round(fps)), 1)
|
|
return Fraction(fps).limit_denominator(100000)
|
|
|
|
|
|
def frame_duration(fps: float | Fraction) -> Fraction:
|
|
"""Duration of one frame, in seconds, as an exact fraction."""
|
|
return 1 / fps_fraction(fps)
|
|
|
|
|
|
def seconds_to_frames(seconds: float, fps: float | Fraction) -> int:
|
|
"""Quantise ``seconds`` to the nearest whole frame at ``fps``.
|
|
|
|
Rounds half away from zero rather than using banker's rounding, so a clip
|
|
asked for at exactly half a frame does not silently vanish.
|
|
"""
|
|
f = fps_fraction(fps)
|
|
exact = Fraction(float(seconds)).limit_denominator(1_000_000) * f
|
|
floor = exact.numerator // exact.denominator
|
|
rem = exact - floor
|
|
return int(floor + (1 if rem >= Fraction(1, 2) else 0))
|
|
|
|
|
|
def frames_to_rational(frames: int, fps: float | Fraction) -> str:
|
|
"""Whole frames -> an FCPXML time string, e.g. ``"1001/30000s"``.
|
|
|
|
The value is ``frames * frame_duration`` reduced to lowest terms. FCPXML
|
|
accepts a bare integer form for whole seconds (``"5s"``), which is what
|
|
Fraction reduction naturally produces when the denominator collapses to 1.
|
|
|
|
>>> frames_to_rational(1, 29.97)
|
|
'1001/30000s'
|
|
>>> frames_to_rational(30, 29.97)
|
|
'1001/1000s'
|
|
>>> frames_to_rational(120, 24)
|
|
'5s'
|
|
"""
|
|
value = Fraction(int(frames), 1) * frame_duration(fps)
|
|
if value.denominator == 1:
|
|
return f"{value.numerator}s"
|
|
return f"{value.numerator}/{value.denominator}s"
|
|
|
|
|
|
def seconds_to_rational(seconds: float, fps: float | Fraction) -> str:
|
|
"""Seconds -> a frame-quantised FCPXML rational time string.
|
|
|
|
This is the function that keeps Resolve happy. Writing ``"2.5s"`` where a
|
|
rational is expected either fails validation outright or silently re-times
|
|
the import; writing ``"60/24s"`` does not.
|
|
|
|
>>> seconds_to_rational(2.5, 24)
|
|
'5/2s'
|
|
>>> seconds_to_rational(1.0, 29.97)
|
|
'30030/30000s' # doctest: +SKIP
|
|
"""
|
|
return frames_to_rational(seconds_to_frames(seconds, fps), fps)
|
|
|
|
|
|
def _is_drop_frame(fps: float | Fraction) -> bool:
|
|
"""Drop-frame applies to the 30/60-family NTSC rates, not to 23.976."""
|
|
f = fps_fraction(fps)
|
|
return f in (Fraction(30000, 1001), Fraction(60000, 1001))
|
|
|
|
|
|
def frames_to_timecode(
|
|
frames: int, fps: float | Fraction, drop: bool | None = None
|
|
) -> str:
|
|
"""Whole frames -> ``HH:MM:SS:FF`` timecode.
|
|
|
|
``drop`` defaults to auto: on for 29.97 and 59.94, off everywhere else.
|
|
Drop-frame skips frame *numbers* (never actual frames) at the top of every
|
|
minute except every tenth, which is what keeps 29.97 timecode agreeing with
|
|
a wall clock.
|
|
|
|
>>> frames_to_timecode(1800, 29.97)
|
|
'00:01:00:02'
|
|
>>> frames_to_timecode(17982, 29.97)
|
|
'00:10:00:00'
|
|
>>> frames_to_timecode(24, 24)
|
|
'00:00:01:00'
|
|
"""
|
|
frames = int(frames)
|
|
if drop is None:
|
|
drop = _is_drop_frame(fps)
|
|
rate = int(round(float(fps_fraction(fps))))
|
|
|
|
if drop:
|
|
dropped = int(round(float(fps_fraction(fps)) * 0.066666)) # 2 @ 29.97, 4 @ 59.94
|
|
per_10min = int(round(float(fps_fraction(fps)) * 600)) # 17982 @ 29.97
|
|
per_min = rate * 60 - dropped # 1798 @ 29.97
|
|
tens, rem = divmod(frames, per_10min)
|
|
if rem > dropped:
|
|
frames += dropped * 9 * tens + dropped * ((rem - dropped) // per_min)
|
|
else:
|
|
frames += dropped * 9 * tens
|
|
sep = EDL_DROP_SEPARATOR
|
|
else:
|
|
sep = ":"
|
|
|
|
ff = frames % rate
|
|
total_s = frames // rate
|
|
ss = total_s % 60
|
|
mm = (total_s // 60) % 60
|
|
hh = (total_s // 3600) % 24
|
|
return f"{hh:02d}:{mm:02d}:{ss:02d}{sep}{ff:02d}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# clip normalisation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _normalise(clips: Iterable[dict], fps: float | Fraction) -> list[dict]:
|
|
"""Validate clips and pre-compute integer frame counts and offsets.
|
|
|
|
Returns dicts with ``path``, ``name``, ``frames`` (int, >= 1) and
|
|
``offset_frames`` (int). Working in frames from here down is what makes the
|
|
sequence duration exactly the sum of the clip durations.
|
|
"""
|
|
out: list[dict] = []
|
|
offset = 0
|
|
for i, c in enumerate(clips):
|
|
path = str(c.get("path") or "")
|
|
if not path:
|
|
raise ValueError(f"clip {i} has no 'path'")
|
|
dur = float(c.get("duration") or 0.0)
|
|
if dur <= 0:
|
|
raise ValueError(f"clip {i} ({path}) has non-positive duration {dur!r}")
|
|
frames = max(1, seconds_to_frames(dur, fps)) # never emit a zero-length event
|
|
name = str(c.get("name") or Path(path).stem)
|
|
out.append(
|
|
{
|
|
"path": path,
|
|
"name": name,
|
|
"frames": frames,
|
|
"offset_frames": offset,
|
|
"seconds": dur,
|
|
}
|
|
)
|
|
offset += frames
|
|
if not out:
|
|
raise ValueError("no clips to write - a timeline needs at least one event")
|
|
return out
|
|
|
|
|
|
def _file_uri(path: str) -> str:
|
|
"""Absolute ``file://`` URI. Works for paths that do not exist yet."""
|
|
p = Path(path)
|
|
if not p.is_absolute():
|
|
p = Path.cwd() / p
|
|
# as_uri() percent-escapes correctly; normalise away '..' without resolving
|
|
# symlinks or requiring the file to exist.
|
|
return Path(str(p)).absolute().as_uri()
|
|
|
|
|
|
def _format_name(width: int, height: int, fps: float | Fraction) -> str:
|
|
f = fps_fraction(fps)
|
|
rate = float(f)
|
|
label = f"{rate:.2f}".rstrip("0").rstrip(".").replace(".", "")
|
|
return f"FFVideoFormat{height}p{label}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FCPXML
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def build_fcpxml(
|
|
clips: Sequence[dict],
|
|
fps: float = 24.0,
|
|
title: str = "taste-forge",
|
|
width: int = 1920,
|
|
height: int = 1080,
|
|
version: str = "1.9",
|
|
) -> str:
|
|
"""Build an FCPXML 1.9 document for ``clips``.
|
|
|
|
Each clip is ``{"path": str, "duration": float, "name": str}``.
|
|
|
|
Document shape (this is what Resolve's importer walks)::
|
|
|
|
<fcpxml version="1.9">
|
|
<resources>
|
|
<format id="r0" frameDuration="1/24s" width= height=/>
|
|
<asset id="r1" hasVideo="1" format="r0" duration="...">
|
|
<media-rep kind="original-media" src="file:///..."/>
|
|
</asset>
|
|
</resources>
|
|
<library>
|
|
<event><project><sequence format="r0"><spine>
|
|
<asset-clip ref="r1" offset= duration= start=/>
|
|
</spine></sequence></project></event>
|
|
</library>
|
|
</fcpxml>
|
|
|
|
``offset`` is the clip's position on the timeline, ``start`` is its in-point
|
|
inside the source media (0 here - we always take from the head of each
|
|
generated clip), and ``duration`` is the same on both the asset and the
|
|
asset-clip because each generated clip is used whole.
|
|
"""
|
|
items = _normalise(clips, fps)
|
|
total_frames = sum(c["frames"] for c in items)
|
|
fd = frame_duration(fps)
|
|
|
|
fcpxml = ET.Element("fcpxml", {"version": version})
|
|
resources = ET.SubElement(fcpxml, "resources")
|
|
|
|
fmt_id = "r0"
|
|
ET.SubElement(
|
|
resources,
|
|
"format",
|
|
{
|
|
"id": fmt_id,
|
|
"name": _format_name(width, height, fps),
|
|
"frameDuration": f"{fd.numerator}/{fd.denominator}s"
|
|
if fd.denominator != 1
|
|
else f"{fd.numerator}s",
|
|
"width": str(int(width)),
|
|
"height": str(int(height)),
|
|
"colorSpace": "1-1-1 (Rec. 709)",
|
|
},
|
|
)
|
|
|
|
for i, c in enumerate(items):
|
|
asset_id = f"r{i + 1}"
|
|
c["asset_id"] = asset_id
|
|
asset = ET.SubElement(
|
|
resources,
|
|
"asset",
|
|
{
|
|
"id": asset_id,
|
|
"name": c["name"],
|
|
# uid must be stable per source so re-imports relink instead of
|
|
# duplicating media in the pool.
|
|
"uid": f"{title}-{i:04d}",
|
|
"start": "0s",
|
|
"duration": frames_to_rational(c["frames"], fps),
|
|
"hasVideo": "1",
|
|
"videoSources": "1",
|
|
"format": fmt_id,
|
|
},
|
|
)
|
|
ET.SubElement(
|
|
asset,
|
|
"media-rep",
|
|
{"kind": "original-media", "src": _file_uri(c["path"])},
|
|
)
|
|
|
|
library = ET.SubElement(fcpxml, "library")
|
|
event = ET.SubElement(library, "event", {"name": title})
|
|
project = ET.SubElement(event, "project", {"name": title})
|
|
sequence = ET.SubElement(
|
|
project,
|
|
"sequence",
|
|
{
|
|
"format": fmt_id,
|
|
"duration": frames_to_rational(total_frames, fps),
|
|
"tcStart": "0s",
|
|
"tcFormat": "DF" if _is_drop_frame(fps) else "NDF",
|
|
"audioLayout": "stereo",
|
|
"audioRate": "48k",
|
|
},
|
|
)
|
|
spine = ET.SubElement(sequence, "spine")
|
|
|
|
for c in items:
|
|
ET.SubElement(
|
|
spine,
|
|
"asset-clip",
|
|
{
|
|
"ref": c["asset_id"],
|
|
"offset": frames_to_rational(c["offset_frames"], fps),
|
|
"name": c["name"],
|
|
"start": "0s",
|
|
"duration": frames_to_rational(c["frames"], fps),
|
|
"format": fmt_id,
|
|
"tcFormat": "DF" if _is_drop_frame(fps) else "NDF",
|
|
},
|
|
)
|
|
|
|
raw = ET.tostring(fcpxml, encoding="unicode")
|
|
pretty = minidom.parseString(raw).documentElement.toprettyxml(indent=" ")
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
"<!DOCTYPE fcpxml>\n" + pretty.rstrip() + "\n"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EDL (CMX3600)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def build_edl(
|
|
clips: Sequence[dict],
|
|
fps: float = 24.0,
|
|
title: str = "taste-forge",
|
|
reel: str = "AX",
|
|
) -> str:
|
|
"""Build a CMX3600 EDL - the fallback when FCPXML round-tripping fails.
|
|
|
|
An EDL carries no media references, only cut points, so the importing NLE
|
|
has to relink by clip name. That is a real downgrade, which is exactly why
|
|
FCPXML is the default; but every NLE ever made reads a CMX3600.
|
|
|
|
Column layout is the fixed-width classic: event number, reel, channel,
|
|
transition, then source-in / source-out / record-in / record-out.
|
|
"""
|
|
items = _normalise(clips, fps)
|
|
drop = _is_drop_frame(fps)
|
|
|
|
lines = [
|
|
f"TITLE: {title.upper()}",
|
|
f"FCM: {'DROP FRAME' if drop else 'NON-DROP FRAME'}",
|
|
"",
|
|
]
|
|
for i, c in enumerate(items):
|
|
src_in = frames_to_timecode(0, fps, drop)
|
|
src_out = frames_to_timecode(c["frames"], fps, drop)
|
|
rec_in = frames_to_timecode(c["offset_frames"], fps, drop)
|
|
rec_out = frames_to_timecode(c["offset_frames"] + c["frames"], fps, drop)
|
|
lines.append(
|
|
f"{i + 1:03d} {reel:<9}{'V':<6}{'C':<9}"
|
|
f"{src_in} {src_out} {rec_in} {rec_out}"
|
|
)
|
|
lines.append(f"* FROM CLIP NAME: {Path(c['path']).name}")
|
|
lines.append("")
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def write_timeline(
|
|
clips: Sequence[dict],
|
|
fps: float,
|
|
out_path: str | Path,
|
|
fmt: str = "fcpxml",
|
|
title: str | None = None,
|
|
width: int = 1920,
|
|
height: int = 1080,
|
|
) -> Path:
|
|
"""Write ``clips`` to ``out_path`` as ``fcpxml`` or ``edl``. Returns the path."""
|
|
out_path = Path(out_path)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
name = title or out_path.stem
|
|
|
|
fmt = fmt.lower().lstrip(".")
|
|
if fmt == "fcpxml":
|
|
text = build_fcpxml(clips, fps=fps, title=name, width=width, height=height)
|
|
elif fmt == "edl":
|
|
text = build_edl(clips, fps=fps, title=name)
|
|
else:
|
|
raise ValueError(f"unknown timeline format {fmt!r} - use 'fcpxml' or 'edl'")
|
|
|
|
out_path.write_text(text, encoding="utf-8")
|
|
return out_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# self-check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
if __name__ == "__main__":
|
|
import tempfile
|
|
|
|
# --- rational arithmetic, the part that breaks imports when wrong --------
|
|
assert fps_fraction(29.97) == Fraction(30000, 1001)
|
|
assert fps_fraction(23.976) == Fraction(24000, 1001)
|
|
assert fps_fraction(24) == Fraction(24, 1)
|
|
assert frames_to_rational(1, 29.97) == "1001/30000s", frames_to_rational(1, 29.97)
|
|
assert frames_to_rational(0, 24) == "0s"
|
|
assert frames_to_rational(120, 24) == "5s"
|
|
assert frames_to_rational(60, 24) == "5/2s"
|
|
assert seconds_to_rational(2.5, 24) == "5/2s"
|
|
# one second at 29.97 is 30 frames = 30 * 1001/30000 = 30030/30000 = 1001/1000
|
|
assert seconds_to_rational(1.0, 29.97) == "1001/1000s", seconds_to_rational(1.0, 29.97)
|
|
# a rational time is always an exact multiple of the frame duration
|
|
for f in (23.976, 24, 25, 29.97, 30, 59.94, 60):
|
|
for n in (0, 1, 7, 1000):
|
|
s = frames_to_rational(n, f)
|
|
num, den = s.rstrip("s").split("/") if "/" in s else (s.rstrip("s"), "1")
|
|
assert Fraction(int(num), int(den)) == n * frame_duration(f)
|
|
|
|
# --- timecode -----------------------------------------------------------
|
|
assert frames_to_timecode(24, 24) == "00:00:01:00"
|
|
assert frames_to_timecode(0, 24) == "00:00:00:00"
|
|
# frame 1799 is the last of the first minute; 1800 skips labels ;00 and ;01
|
|
assert frames_to_timecode(1799, 29.97) == "00:00:59:29"
|
|
assert frames_to_timecode(1800, 29.97) == "00:01:00:02" # drop-frame skip
|
|
assert frames_to_timecode(17982, 29.97) == "00:10:00:00" # tenth minute, no skip
|
|
assert frames_to_timecode(1800, 30, drop=False) == "00:01:00:00"
|
|
|
|
# --- a real five-clip timeline ------------------------------------------
|
|
FPS = 23.976
|
|
durations = [1.4167, 0.8333, 2.125, 1.2917, 3.125] # flashethereal-ish cadence
|
|
clips = [
|
|
{"path": f"/tmp/taste_forge_shot_{i:02d}.mp4", "duration": d, "name": f"shot_{i:02d}"}
|
|
for i, d in enumerate(durations)
|
|
]
|
|
|
|
xml_text = build_fcpxml(clips, fps=FPS, title="selfcheck", width=1920, height=1080)
|
|
|
|
root = ET.fromstring(xml_text) # parses => well-formed
|
|
assert root.tag == "fcpxml" and root.get("version") == "1.9"
|
|
assets = root.findall("./resources/asset")
|
|
assert len(assets) == 5, len(assets)
|
|
assert all(a.get("hasVideo") == "1" and a.get("format") == "r0" for a in assets)
|
|
assert len(root.findall("./resources/asset/media-rep")) == 5
|
|
|
|
seq = root.find("./library/event/project/sequence")
|
|
assert seq is not None
|
|
spine_clips = seq.findall("./spine/asset-clip")
|
|
assert len(spine_clips) == 5
|
|
|
|
def _sec(t: str) -> Fraction:
|
|
t = t.rstrip("s")
|
|
return Fraction(*(int(x) for x in t.split("/"))) if "/" in t else Fraction(int(t))
|
|
|
|
# total duration == sum of clip durations, exactly (integer-frame accumulation)
|
|
summed = sum(_sec(c.get("duration")) for c in spine_clips)
|
|
assert _sec(seq.get("duration")) == summed, (seq.get("duration"), summed)
|
|
|
|
# offsets are contiguous: each clip starts where the previous one ended
|
|
running = Fraction(0)
|
|
for c in spine_clips:
|
|
assert _sec(c.get("offset")) == running, (c.get("offset"), running)
|
|
assert c.get("start") == "0s"
|
|
running += _sec(c.get("duration"))
|
|
assert running == summed
|
|
|
|
# and it still tracks the float durations we asked for, to within half a frame
|
|
fd = frame_duration(FPS)
|
|
assert abs(float(summed) - sum(durations)) <= float(fd) * len(durations) / 2
|
|
|
|
# --- EDL ----------------------------------------------------------------
|
|
edl = build_edl(clips, fps=FPS, title="selfcheck")
|
|
assert edl.startswith("TITLE: SELFCHECK")
|
|
assert "FCM: NON-DROP FRAME" in edl
|
|
edl_events = [ln for ln in edl.splitlines() if ln[:3].isdigit()]
|
|
assert len(edl_events) == 5, edl_events
|
|
last_rec_out = edl_events[-1].split()[-1]
|
|
assert last_rec_out == frames_to_timecode(
|
|
sum(seconds_to_frames(d, FPS) for d in durations), FPS
|
|
), last_rec_out
|
|
|
|
# --- round-trip through write_timeline ----------------------------------
|
|
with tempfile.TemporaryDirectory() as td:
|
|
p1 = write_timeline(clips, FPS, Path(td) / "sc.fcpxml", "fcpxml")
|
|
p2 = write_timeline(clips, FPS, Path(td) / "sc.edl", "edl")
|
|
ET.parse(p1)
|
|
assert p2.read_text(encoding="utf-8").startswith("TITLE:")
|
|
|
|
print("timeline self-check OK")
|
|
print(f" 5 clips @ {FPS} fps ({fps_fraction(FPS)})")
|
|
print(f" frame duration : {frame_duration(FPS).numerator}/"
|
|
f"{frame_duration(FPS).denominator}s")
|
|
print(f" sequence duration : {seq.get('duration')} "
|
|
f"({float(summed):.4f}s, requested {sum(durations):.4f}s)")
|
|
print(f" 1 frame @ 29.97 : {frames_to_rational(1, 29.97)}")
|
|
print(f" last EDL record out : {last_rec_out}")
|