chore: atualização geral

This commit is contained in:
João Henrique
2026-08-19 16:35:29 -04:00
parent 8fca456ceb
commit e7748c2c58
66 changed files with 13037 additions and 4237 deletions
+275 -4
View File
@@ -652,6 +652,7 @@ def test_add_zoom_creates_keyframed_transform(temp_fcpxml):
"""4 keyframes: 100% -> scale -> scale -> 100%, all within [start, end]."""
modifier = FCPXMLModifier(temp_fcpxml)
clip = modifier.add_zoom(clip_id='Broll_Studio', start=1.0, end=3.0, scale=1.3, ease=0.5)
frame = float(modifier.frame_duration_fraction())
transform = clip.find('adjust-transform')
assert transform is not None
@@ -660,9 +661,23 @@ def test_add_zoom_creates_keyframed_transform(temp_fcpxml):
keyframes = param.find('keyframeAnimation').findall('keyframe')
assert len(keyframes) == 4
assert [kf.get('value') for kf in keyframes] == ['1 1', '1.3 1.3', '1.3 1.3', '1 1']
assert all(kf.get('interp') == 'ease' for kf in keyframes)
# Bare keyframes — only time and value — matching a zoom exported from
# FCP itself. It rejects 'interp' on this vector param (discarding the
# whole <param>), and its own export writes no 'curve' either.
assert not any(kf.get('interp') for kf in keyframes)
assert not any(kf.get('curve') for kf in keyframes)
assert all(set(kf.attrib) == {'time', 'value'} for kf in keyframes)
# Keyframe times are anchored in the clip's SOURCE timebase (its own
# `start`), not clip-relative. This fixture starts at 10s, so a zoom over
# clip seconds 1-3 must be written at 11-13s. Writing 1-3s here would put
# the animation outside the clip and FCP imports it as nothing.
origin = modifier._parse_time(clip.get('start', '0s')).to_seconds()
times = [modifier._parse_time(kf.get('time')).to_seconds() for kf in keyframes]
assert times == pytest.approx([1.0, 1.5, 2.5, 3.0], abs=0.05)
assert origin > 0, "fixture must start off zero, or this asserts nothing"
# in over 0.5s, hold, then snap back on the very next frame
assert times == pytest.approx(
[origin + 1.0, origin + 1.5, origin + 3.0 - frame, origin + 3.0], abs=0.05
)
assert times == sorted(times)
@@ -699,8 +714,9 @@ def test_add_zoom_window_outside_clip_duration_raises(temp_fcpxml):
def test_add_zoom_ease_too_long_for_window_raises(temp_fcpxml):
modifier = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="doesn't fit"):
modifier.add_zoom(clip_id='Broll_Studio', start=0.0, end=1.0, ease=1.0)
# start away from the clip head so the ramp-in is actually written
with pytest.raises(ValueError, match="don't fit"):
modifier.add_zoom(clip_id='Broll_Studio', start=1.0, end=2.0, ease=1.5)
def test_change_speed_twice_no_duplicate_elements(temp_fcpxml):
@@ -2067,3 +2083,258 @@ def test_remove_trailing_gaps_noop_without_gap():
assert len(children) == 1
assert children[0].tag == 'asset-clip'
Path(f.name).unlink(missing_ok=True)
class TestNTSCFrameAlignment:
"""23.976/29.97 timebases must not be reported as misaligned.
Regression: the check used int(fps), so an exactly frame-aligned NTSC
duration (a whole multiple of 1001/24000s) was flagged as broken —
every NTSC project produced spurious warnings that buried real ones.
"""
NTSC_DOC = """<?xml version="1.0" encoding="UTF-8"?>
<fcpxml version="1.13">
<resources>
<format id="r1" name="FFVideoFormat1080p2398" frameDuration="1001/24000s" width="1920" height="1080"/>
<asset id="a1" name="v" start="0s" duration="24437413/24000s" hasVideo="1" format="r1">
<media-rep kind="original-media" src="file:///v.mov"/>
</asset>
</resources>
<library><event name="E"><project name="P">
<sequence format="r1" duration="24437413/24000s" tcStart="0s">
<spine><asset-clip name="v" ref="a1" offset="0s" start="0s" duration="24437413/24000s"/></spine>
</sequence>
</project></event></library>
</fcpxml>
"""
def _issues(self, xml):
from fcpxml.safe_xml import safe_fromstring
from fcpxml.writer import validate_fcpxml
root = safe_fromstring(xml)
return [
i for i in (validate_fcpxml(root) or [])
if "frame" in str(i.issue_type.value)
]
def test_aligned_ntsc_duration_is_not_flagged(self):
# 24437413/24000s is exactly 24413 frames of 1001/24000s
assert self._issues(self.NTSC_DOC) == []
def test_genuinely_misaligned_duration_is_still_flagged(self):
broken = self.NTSC_DOC.replace(
'<asset-clip name="v" ref="a1" offset="0s" start="0s" duration="24437413/24000s"/>',
'<asset-clip name="v" ref="a1" offset="0s" start="0s" duration="500/24000s"/>',
)
assert len(self._issues(broken)) == 1
def test_message_names_the_real_rate_not_a_rounded_one(self):
broken = self.NTSC_DOC.replace('duration="24437413/24000s"/>', 'duration="500/24000s"/>')
issues = self._issues(broken)
assert issues and "23.976fps" in issues[0].message
class TestZoomPreservesExistingFraming:
"""A clip may already carry the editor's reframe — rotation for footage
shot sideways, position, a base scale. add_zoom used to delete it, which
on real footage brought the zoomed section back rotated."""
FRAMED = """<?xml version="1.0" encoding="UTF-8"?>
<fcpxml version="1.13">
<resources>
<format id="r1" frameDuration="100/3000s" width="1920" height="1080"/>
<asset id="a1" name="v" start="0s" duration="600/30s" hasVideo="1" format="r1">
<media-rep kind="original-media" src="file:///v.mov"/>
</asset>
</resources>
<library><event name="E"><project name="P">
<sequence format="r1" duration="600/30s" tcStart="0s">
<spine>
<asset-clip name="v" ref="a1" offset="0s" start="0s" duration="600/30s">
<adjust-transform position="0.16 0.66" rotation="90.1" scale="1.77311 1.77311"/>
</asset-clip>
</spine>
</sequence>
</project></event></library>
</fcpxml>
"""
def _zoomed(self, tmp_path, scale=1.2):
from fcpxml.writer import FCPXMLModifier
path = tmp_path / "framed.fcpxml"
path.write_text(self.FRAMED)
m = FCPXMLModifier(str(path))
m.add_zoom(clip_id="v", start=1.0, end=5.0, scale=scale)
return m.root.find(".//adjust-transform")
def test_rotation_and_position_survive(self, tmp_path):
t = self._zoomed(tmp_path)
assert t.get("rotation") == "90.1"
assert t.get("position") == "0.16 0.66"
def test_animation_rests_at_the_existing_scale(self, tmp_path):
kfs = self._zoomed(tmp_path).findall(".//keyframe")
# first and last keyframe return to the clip's own framing, not to 1
assert kfs[0].get("value").split()[0].startswith("1.77")
assert kfs[-1].get("value").split()[0].startswith("1.77")
def test_peak_multiplies_the_existing_scale(self, tmp_path):
kfs = self._zoomed(tmp_path, scale=2.0).findall(".//keyframe")
peak = float(kfs[1].get("value").split()[0])
assert peak == pytest.approx(1.77311 * 2.0, rel=1e-4)
def test_only_one_transform_remains(self, tmp_path):
from fcpxml.writer import FCPXMLModifier
path = tmp_path / "framed.fcpxml"
path.write_text(self.FRAMED)
m = FCPXMLModifier(str(path))
m.add_zoom(clip_id="v", start=1.0, end=5.0, scale=1.2)
m.add_zoom(clip_id="v", start=6.0, end=9.0, scale=1.4)
assert len(m.root.findall(".//adjust-transform")) == 1
def test_second_zoom_on_same_clip_keeps_the_real_base_scale(self, tmp_path):
"""Found on real footage: two zoom actions landing on disjoint
windows of the same post-cut clip. The second add_zoom call used to
see the already-animated <param name="scale"> from the first zoom
instead of a static attribute, read that as "no framing", and
default the base to 1.0 — silently shrinking the shot back to its
unframed size for the whole clip wherever no keyframe applied, and
discarding the first zoom's animation in the process."""
from fcpxml.writer import FCPXMLModifier
path = tmp_path / "framed.fcpxml"
path.write_text(self.FRAMED)
m = FCPXMLModifier(str(path))
m.add_zoom(clip_id="v", start=1.0, end=5.0, scale=1.2)
m.add_zoom(clip_id="v", start=6.0, end=9.0, scale=1.4)
kfs = m.root.findall(".//keyframe")
# Every rest keyframe returns to the clip's real base scale, never 1.0.
rest_values = {kf.get("value") for kf in (kfs[0], kfs[3], kfs[4], kfs[-1])}
assert rest_values == {"1.77311 1.77311"}
# Both peaks survive — the second call didn't erase the first.
peaks = sorted(float(kf.get("value").split()[0]) for kf in (kfs[1], kfs[5]))
assert peaks[0] == pytest.approx(1.77311 * 1.2, rel=1e-4)
assert peaks[1] == pytest.approx(1.77311 * 1.4, rel=1e-4)
def test_overlapping_zoom_on_same_clip_replaces_instead_of_stacking(self, tmp_path):
"""Two windows that OVERLAP mean "redo this zoom", not "add another
one" — the old keyframes are stale and all of them go, matching
test_add_zoom_replaces_existing_zoom's contract."""
from fcpxml.writer import FCPXMLModifier
path = tmp_path / "framed.fcpxml"
path.write_text(self.FRAMED)
m = FCPXMLModifier(str(path))
m.add_zoom(clip_id="v", start=1.0, end=5.0, scale=1.2)
m.add_zoom(clip_id="v", start=3.0, end=7.0, scale=1.5)
values = [kf.get("value") for kf in m.root.findall(".//keyframe")]
assert not any(v.startswith("2.1277") for v in values) # 1.77311*1.2 gone
assert any(v.startswith("2.6596") for v in values) # 1.77311*1.5 present
def test_unframed_clip_still_rests_at_one(self, tmp_path):
from fcpxml.writer import FCPXMLModifier
path = tmp_path / "plain.fcpxml"
path.write_text(self.FRAMED.replace(
'<adjust-transform position="0.16 0.66" rotation="90.1" scale="1.77311 1.77311"/>', ''))
m = FCPXMLModifier(str(path))
m.add_zoom(clip_id="v", start=1.0, end=5.0, scale=1.3)
kfs = m.root.findall(".//keyframe")
assert kfs[0].get("value") == "1 1"
assert kfs[1].get("value") == "1.3 1.3"
class TestZoomShapeIsAsymmetric:
"""The editorial shape: ramp in fast to land with the emphasised word,
hold through the impact phrase, then snap back in a single frame so the
video resumes its normal framing without a drift that draws the eye."""
def _times(self, temp_fcpxml, **kw):
from fcpxml.writer import FCPXMLModifier
m = FCPXMLModifier(temp_fcpxml)
# Broll_Studio is 5s long; end=3.0 keeps clear of the hold-at-cut
# margin so these exercise the ordinary return-to-framing shape.
kw.setdefault('end', 3.0)
clip = m.add_zoom(clip_id='Broll_Studio', start=1.0, **kw)
origin = m._parse_time(clip.get('start', '0s')).to_seconds()
kfs = clip.find('adjust-transform').find('param').find('keyframeAnimation')
return m, origin, [m._parse_time(k.get('time')).to_seconds() - origin
for k in kfs.findall('keyframe')]
def test_return_takes_a_single_frame(self, temp_fcpxml):
m, _, t = self._times(temp_fcpxml, scale=1.2)
frame = float(m.frame_duration_fraction())
assert t[3] - t[2] == pytest.approx(frame, abs=0.005)
def test_ramp_in_is_quick(self, temp_fcpxml):
"""Fast enough to land with the emphasised word rather than drift."""
_, _, t = self._times(temp_fcpxml, scale=1.2)
assert t[1] - t[0] == pytest.approx(0.25, abs=0.05)
def test_peak_is_held_until_the_return(self, temp_fcpxml):
_, _, t = self._times(temp_fcpxml, scale=1.2)
# hold spans from the top of the ramp to one frame before the end
assert t[2] - t[1] > 1.4
def test_zoom_opening_at_a_cut_starts_already_zoomed(self, temp_fcpxml):
"""The cut is the transition — ramping up from it reads as the shot
settling rather than as emphasis."""
from fcpxml.writer import FCPXMLModifier
m = FCPXMLModifier(temp_fcpxml)
clip = m.add_zoom(clip_id='Broll_Studio', start=0.1, end=3.0, scale=1.2)
kfs = clip.find('adjust-transform').find('param').find('keyframeAnimation')
values = [k.get('value') for k in kfs.findall('keyframe')]
assert values[0] != values[-1] # opens zoomed, returns to framing
assert values[0] == values[-2] # ...and was at the peak from frame one
def test_opening_at_peak_can_be_forced_off(self, temp_fcpxml):
from fcpxml.writer import FCPXMLModifier
m = FCPXMLModifier(temp_fcpxml)
clip = m.add_zoom(
clip_id='Broll_Studio', start=0.1, end=3.0, scale=1.2, start_at_peak=False
)
kfs = clip.find('adjust-transform').find('param').find('keyframeAnimation')
assert len(kfs.findall('keyframe')) == 4
def test_ease_out_can_be_made_gradual(self, temp_fcpxml):
_, _, t = self._times(temp_fcpxml, scale=1.2, ease_out=1.0)
assert t[3] - t[2] == pytest.approx(1.0, abs=0.05)
def test_zoom_reaching_the_cut_holds_instead_of_returning(self, temp_fcpxml):
"""Returning right before a cut is wasted motion — the next clip
opens on its own framing, so the move back reads as a twitch."""
_, _, t = self._times(temp_fcpxml, scale=1.2, end=5.0)
assert len(t) == 3 # rest, peak, still peak at the cut
def test_hold_can_be_forced_off_at_a_cut(self, temp_fcpxml):
_, _, t = self._times(temp_fcpxml, scale=1.2, end=5.0, hold_at_end=False)
assert len(t) == 4
def test_hold_can_be_forced_on_mid_clip(self, temp_fcpxml):
_, _, t = self._times(temp_fcpxml, scale=1.2, end=3.0, hold_at_end=True)
assert len(t) == 3
def test_held_zoom_stays_at_the_peak(self, temp_fcpxml):
from fcpxml.writer import FCPXMLModifier
m = FCPXMLModifier(temp_fcpxml)
clip = m.add_zoom(clip_id='Broll_Studio', start=1.0, end=5.0, scale=1.2)
kfs = clip.find('adjust-transform').find('param').find('keyframeAnimation')
values = [k.get('value') for k in kfs.findall('keyframe')]
assert values[-1] == values[-2] != values[0]
def test_window_too_short_for_the_ramp_is_rejected(self, temp_fcpxml):
from fcpxml.writer import FCPXMLModifier
m = FCPXMLModifier(temp_fcpxml)
with pytest.raises(ValueError, match="don't fit"):
m.add_zoom(clip_id='Broll_Studio', start=1.0, end=1.2, scale=1.2, ease=1.5)