"""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 ````'s ``scale`` param (```` + ```` of ````) 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 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 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 . 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 # ========================================================================