chore: atualização geral
This commit is contained in:
+275
-4
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user