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>
298 lines
13 KiB
Python
298 lines
13 KiB
Python
"""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
|
|
|
|
# ========================================================================
|