refactor: writer.py vira pacote, um módulo por assunto
O writer tinha 4.199 linhas, das quais 3.300 numa única classe com dezoito
assuntos dentro. Achar o trecho de zoom exigia rolar por marcadores,
velocidade e legendas.
Agora é o pacote fcpxml/writer/, com um arquivo por assunto e o
FCPXMLModifier montado por composição de mixins. Mixins, e não objetos
separados, porque todas essas operações mexem no mesmo documento e nos
mesmos índices — separá-las em objetos independentes transformaria toda
chamada interna em travessia de fronteira sem nada em troca. A divisão que
importa aqui é de leitura, não de estado.
Nenhuma mudança de comportamento e nenhuma alteração nos ~50 pontos que
importam do writer: o __init__ re-exporta tudo, inclusive os nomes com
underscore que a suíte já usava.
core 723 carga, índices, navegação na spine, save
titles 600 títulos e legendas dinâmicas
cut 333 dividir, cortar faixas, apagar
speed 297 velocidade e zoom
(+ 20 módulos menores)
Único ajuste de chamada: quatro testes faziam patch em
fcpxml.writer.subprocess, que agora mora em writer.document (ver
Engine/docs/05_EXPERIENCIAS.md #23).
Lint zerado, 1441 testes passando.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1bebee4359
commit
4f5cf94443
@@ -0,0 +1,297 @@
|
||||
"""Velocidade e zoom (punch-in) por janela.
|
||||
|
||||
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from fractions import Fraction
|
||||
from typing import Optional
|
||||
|
||||
from .helpers import HOLD_AT_CUT_THRESHOLD, START_AT_CUT_THRESHOLD, _dtd_insert, _fmt_scale
|
||||
|
||||
|
||||
class SpeedMixin:
|
||||
"""Velocidade e zoom (punch-in) por janela."""
|
||||
|
||||
# SPEED OPERATIONS
|
||||
# ========================================================================
|
||||
|
||||
def change_speed(
|
||||
self,
|
||||
clip_id: str,
|
||||
speed: float,
|
||||
preserve_pitch: bool = True
|
||||
) -> ET.Element:
|
||||
"""
|
||||
Change clip playback speed.
|
||||
|
||||
Args:
|
||||
clip_id: Target clip
|
||||
speed: Speed multiplier (0.5 = half speed, 2.0 = double)
|
||||
preserve_pitch: Maintain audio pitch
|
||||
|
||||
Returns:
|
||||
Modified clip element
|
||||
"""
|
||||
if speed <= 0:
|
||||
raise ValueError(f"Speed must be positive, got {speed}")
|
||||
|
||||
clip = self._require_clip(clip_id)
|
||||
|
||||
current_duration = self._parse_time(clip.get('duration', '0s'))
|
||||
|
||||
# Use rational arithmetic to avoid floating-point time values.
|
||||
# FCPXML requires rational fractions with a consistent timebase,
|
||||
# not decimal floats like "2.6666666666666665s".
|
||||
denom = current_duration.denominator if current_duration.denominator > 0 else int(self.fps)
|
||||
source_num = current_duration.numerator
|
||||
speed_frac = Fraction(speed).limit_denominator(1000)
|
||||
raw_num = source_num * speed_frac.denominator
|
||||
raw_denom = denom * speed_frac.numerator
|
||||
|
||||
# Snap to frame boundary in a standard timebase (2400 ticks/sec).
|
||||
# Each frame at Nfps = 2400/N ticks (e.g. 24fps → 100 ticks/frame).
|
||||
fps_int = int(self.fps) if self.fps else 24
|
||||
ticks_per_frame = 2400 // fps_int
|
||||
dur_ticks = round(raw_num / raw_denom * 2400)
|
||||
dur_ticks = round(dur_ticks / ticks_per_frame) * ticks_per_frame
|
||||
new_num = dur_ticks
|
||||
new_denom = 2400
|
||||
|
||||
# Remove any existing timeMap/conform-rate from a prior speed change
|
||||
# to prevent duplicate children that produce invalid FCPXML.
|
||||
for stale_tag in ('timeMap', 'conform-rate'):
|
||||
for stale in clip.findall(stale_tag):
|
||||
clip.remove(stale)
|
||||
|
||||
# Create timeMap for speed change (DTD-ordered insertion)
|
||||
timemap = ET.Element('timeMap')
|
||||
_dtd_insert(clip, timemap)
|
||||
|
||||
# Start keyframe
|
||||
tp1 = ET.SubElement(timemap, 'timept')
|
||||
tp1.set('time', '0s')
|
||||
tp1.set('value', '0s')
|
||||
tp1.set('interp', 'linear')
|
||||
|
||||
# End keyframe — use rational time, not floats
|
||||
tp2 = ET.SubElement(timemap, 'timept')
|
||||
tp2.set('time', f"{new_num}/{new_denom}s")
|
||||
tp2.set('value', f"{source_num}/{denom}s")
|
||||
tp2.set('interp', 'linear')
|
||||
|
||||
# Update clip duration (rational, not simplified to arbitrary denominator)
|
||||
clip.set('duration', f"{new_num}/{new_denom}s")
|
||||
|
||||
# Add conform-rate (DTD-ordered insertion)
|
||||
conform = ET.Element('conform-rate')
|
||||
conform.set('scaleEnabled', '1')
|
||||
conform.set('srcFrameRate', str(int(self.fps)))
|
||||
_dtd_insert(clip, conform)
|
||||
|
||||
return clip
|
||||
|
||||
def add_zoom(
|
||||
self,
|
||||
clip_id: 'str | ET.Element',
|
||||
start: float,
|
||||
end: float,
|
||||
scale: float = 1.3,
|
||||
ease: float = 0.25,
|
||||
position: str = "0 0",
|
||||
ease_out: Optional[float] = None,
|
||||
hold_at_end: Optional[bool] = None,
|
||||
start_at_peak: Optional[bool] = None,
|
||||
) -> ET.Element:
|
||||
"""Add a punch-in zoom to a clip, snapping back to its framing at the end.
|
||||
|
||||
Animates ``<adjust-transform>``'s ``scale`` param (``<param>`` +
|
||||
``<keyframeAnimation>`` of ``<keyframe>``) from the clip's current
|
||||
scale up to *scale* times it, holds, then returns — all within
|
||||
``[start, end]`` — clip-relative seconds (same convention as
|
||||
``cut_clip_ranges``).
|
||||
|
||||
The two ends are deliberately asymmetric. *ease* ramps the zoom
|
||||
**in** over half a second by default, fast enough to land with the
|
||||
emphasised word. The way **out** is instant — a single frame — so
|
||||
the moment the impact phrase ends the shot is simply back to its
|
||||
normal framing and the video resumes its flow, with no drift
|
||||
drawing attention to itself. Pass *ease_out* to ramp the return
|
||||
gradually instead.
|
||||
|
||||
*hold_at_end* keeps the peak instead of returning, and
|
||||
*start_at_peak* opens already zoomed with no ramp. Left as ``None``
|
||||
both decide on their own from how close the window sits to the
|
||||
clip's edges: a cut is itself the transition, so ramping away from
|
||||
one — or back toward one — is motion the viewer reads as a wobble
|
||||
rather than as emphasis.
|
||||
"""
|
||||
if end <= start:
|
||||
raise ValueError(f"end ({end}) must be greater than start ({start})")
|
||||
if ease <= 0:
|
||||
raise ValueError(f"ease must be positive, got {ease}")
|
||||
if scale <= 0:
|
||||
raise ValueError(f"scale must be positive, got {scale}")
|
||||
|
||||
frame = float(self.frame_duration_fraction())
|
||||
ramp_out = frame if ease_out is None else ease_out
|
||||
if ramp_out <= 0:
|
||||
raise ValueError(f"ease_out must be positive, got {ease_out}")
|
||||
clip = self._require_clip(clip_id)
|
||||
clip_duration = self._parse_time(clip.get('duration', '0s')).to_seconds()
|
||||
if start < 0 or end > clip_duration:
|
||||
raise ValueError(
|
||||
f"zoom window [{start}, {end}]s must fall within the clip's "
|
||||
f"duration (0 to {clip_duration:.3f}s)"
|
||||
)
|
||||
|
||||
# Replace a prior zoom, but never the clip's framing. A clip can
|
||||
# already carry an <adjust-transform> holding the editor's own
|
||||
# reframe — rotation for footage shot sideways, position, a scale
|
||||
# that makes the shot work at all. Dropping it outright (the old
|
||||
# behaviour) silently destroyed that framing; on real footage the
|
||||
# zoomed section came back rotated. So: keep the static attributes,
|
||||
# and animate *relative to* the existing scale.
|
||||
base_x, base_y = 1.0, 1.0
|
||||
carried: dict = {}
|
||||
old_keyframes: list = []
|
||||
for stale in clip.findall('adjust-transform'):
|
||||
carried = {k: v for k, v in stale.attrib.items() if k != 'scale'}
|
||||
parts = (stale.get('scale') or '').split()
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
base_x, base_y = float(parts[0]), float(parts[1])
|
||||
except ValueError:
|
||||
base_x, base_y = 1.0, 1.0
|
||||
else:
|
||||
# No static attribute — a PRIOR zoom on this same clip left
|
||||
# an animated <param name="scale"> instead, and the true
|
||||
# resting framing lives in its keyframes, not in 1.0.
|
||||
# Reading it as 1.0 here doesn't just miss the framing: it
|
||||
# replaces the earlier zoom's whole animation with a wrong
|
||||
# one, since this loop unconditionally removes `stale`
|
||||
# right after. The rest value is recoverable without
|
||||
# knowing which keyframe it is: MIN_ZOOM_SCALE == 1.0 means
|
||||
# every keyframed value is >= the rest scale, so the
|
||||
# smallest one keyframed is the rest value, peak or not.
|
||||
for old_param in stale.findall("param[@name='scale']"):
|
||||
xs, ys = [], []
|
||||
for kf in old_param.findall('.//keyframe'):
|
||||
kv = (kf.get('value') or '').split()
|
||||
if len(kv) == 2:
|
||||
try:
|
||||
xs.append(float(kv[0]))
|
||||
ys.append(float(kv[1]))
|
||||
except ValueError:
|
||||
pass
|
||||
# Kept for merging: a second zoom on the same clip
|
||||
# (two emphatic beats a cut didn't separate) should
|
||||
# stack alongside the first, not erase it — the
|
||||
# earlier peak is still a real editorial decision.
|
||||
old_keyframes.append((kf.get('time', '0s'), kf.get('value', '')))
|
||||
if xs and ys:
|
||||
base_x, base_y = min(xs), min(ys)
|
||||
clip.remove(stale)
|
||||
|
||||
transform = ET.Element('adjust-transform')
|
||||
for key, value in carried.items():
|
||||
transform.set(key, value)
|
||||
scale_param = ET.SubElement(transform, 'param')
|
||||
scale_param.set('name', 'scale')
|
||||
anim = ET.SubElement(scale_param, 'keyframeAnimation')
|
||||
|
||||
# Keyframe times live in the clip's SOURCE timebase — the same origin
|
||||
# as its own ``start`` — not in clip-relative seconds. A clip whose
|
||||
# media starts at, say, 3109.9s of timecode looks for the animation
|
||||
# there; keyframes written at 0-5s land outside the clip entirely and
|
||||
# Final Cut imports the zoom as nothing at all, silently. Matches what
|
||||
# add_text_title already does, and only shows up on footage whose
|
||||
# start isn't 0s — every synthetic fixture starts at 0s and hides it.
|
||||
media_origin = self._parse_time(clip.get('start', '0s'))
|
||||
|
||||
rest_value = f"{_fmt_scale(base_x)} {_fmt_scale(base_y)}"
|
||||
scale_value = f"{_fmt_scale(base_x * scale)} {_fmt_scale(base_y * scale)}"
|
||||
|
||||
# A return that lands right before a cut is wasted motion: the next
|
||||
# clip begins on its own framing anyway, so all the viewer sees is a
|
||||
# twitch on the way out. When the zoom runs to the end of the clip,
|
||||
# hold the peak and let the cut do the resetting.
|
||||
holds_to_cut = (
|
||||
hold_at_end
|
||||
if hold_at_end is not None
|
||||
else (clip_duration - end) <= HOLD_AT_CUT_THRESHOLD
|
||||
)
|
||||
opens_at_peak = (
|
||||
start_at_peak
|
||||
if start_at_peak is not None
|
||||
else start <= START_AT_CUT_THRESHOLD
|
||||
)
|
||||
|
||||
# Only the ramps actually written have to fit in the window: a zoom
|
||||
# that opens at the peak spends no time ramping in, and one held to
|
||||
# the cut spends none ramping out.
|
||||
needed = (0.0 if opens_at_peak else ease) + (0.0 if holds_to_cut else ramp_out)
|
||||
if needed > (end - start):
|
||||
raise ValueError(
|
||||
f"the ramps ({needed}s) don't fit in the zoom window "
|
||||
f"({end - start}s) — shorten them or widen start/end"
|
||||
)
|
||||
|
||||
if opens_at_peak:
|
||||
# The cut already delivered the change of framing; ramping up
|
||||
# from it just looks like the shot settling.
|
||||
keyframes = [(start, scale_value)]
|
||||
else:
|
||||
keyframes = [(start, rest_value), (start + ease, scale_value)]
|
||||
if holds_to_cut:
|
||||
keyframes.append((end, scale_value))
|
||||
else:
|
||||
# Hold the peak right up to the end, then drop back on the very
|
||||
# next frame — the snap-back the edit wants, not a slow drift.
|
||||
keyframes.append((end - ramp_out, scale_value))
|
||||
keyframes.append((end, rest_value))
|
||||
|
||||
new_entries = [
|
||||
((media_origin + self.snap_seconds_to_frame(seconds)), value)
|
||||
for seconds, value in keyframes
|
||||
]
|
||||
new_start_time = new_entries[0][0]
|
||||
new_end_time = new_entries[-1][0]
|
||||
|
||||
# Two calls on the same clip mean two different things depending on
|
||||
# whether their windows overlap. Overlapping = redoing the *same*
|
||||
# zoom with new numbers — the old keyframes are stale and all of
|
||||
# them go. Disjoint = a second, separate beat that a cut didn't
|
||||
# separate onto its own clip — that one stacks alongside the first
|
||||
# instead of erasing it, since both are real editorial decisions.
|
||||
old_times = [self._parse_time(t) for t, _ in old_keyframes]
|
||||
old_span_overlaps_new = bool(old_times) and not (
|
||||
max(old_times) < new_start_time or min(old_times) > new_end_time
|
||||
)
|
||||
if old_span_overlaps_new:
|
||||
surviving_old: list = []
|
||||
else:
|
||||
surviving_old = [(self._parse_time(t), v) for t, v in old_keyframes]
|
||||
all_entries = sorted(surviving_old + new_entries, key=lambda e: e[0])
|
||||
|
||||
for time_value, value in all_entries:
|
||||
kf = ET.SubElement(anim, 'keyframe')
|
||||
kf.set('time', time_value.to_fcpxml())
|
||||
kf.set('value', value)
|
||||
# Only 'time' and 'value' — no 'interp', no 'curve'. The DTD allows
|
||||
# both, but Final Cut rejected 'interp' on this vector param
|
||||
# ("does not support the interpolation attribute") and discarded
|
||||
# the whole <param>. A hand-made zoom exported from FCP itself
|
||||
# writes bare keyframes and relies on the DTD default
|
||||
# (curve="smooth"), so we match that export exactly rather than
|
||||
# guess which attributes survive its importer.
|
||||
|
||||
if position != "0 0":
|
||||
pos_param = ET.SubElement(transform, 'param')
|
||||
pos_param.set('name', 'position')
|
||||
pos_param.set('value', position)
|
||||
|
||||
_dtd_insert(clip, transform)
|
||||
return clip
|
||||
|
||||
# ========================================================================
|
||||
Reference in New Issue
Block a user