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:
João Henrique
2026-08-19 21:38:49 -04:00
co-authored by Claude Opus 5
parent 1bebee4359
commit 4f5cf94443
28 changed files with 4719 additions and 4203 deletions
+333
View File
@@ -0,0 +1,333 @@
"""Dividir, cortar faixas e apagar clipes.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import copy
import xml.etree.ElementTree as ET
from typing import List, Tuple
from ..models import (
TimeValue,
)
class CutMixin:
"""Dividir, cortar faixas e apagar clipes."""
# SPLIT & DELETE OPERATIONS
# ========================================================================
@staticmethod
def _filter_children_for_segment(
clip: ET.Element,
seg_start: 'TimeValue',
seg_duration: 'TimeValue',
) -> None:
"""Remove markers/keywords/titles from *clip* that fall outside the segment range.
After ``split_clip`` deepcopy's the original clip into each segment, every
segment inherits all child elements. Markers whose ``start`` falls outside
``[seg_start, seg_start + seg_duration)`` are phantom duplicates and must be
removed. Keywords that partially overlap get their ``start``/``duration``
clamped to the segment boundaries.
A lane-nested ``<title>`` (a "text" voice action's on-screen callout,
or a caption from an earlier `generate_dynamic_subtitles` pass) is
the same kind of phantom duplicate, just keyed on ``offset`` instead
of ``start`` — its offset lives in the same source-media coordinate
space as a marker's ``start`` (see ``add_text_title``/``add_marker``,
both anchored at ``parent.start``). Left unfiltered, every further
cut (silence removal, filler removal) duplicates it into every
resulting piece, so the same word shows up several times across the
edited timeline instead of once where it was placed.
"""
seg_end = seg_start + seg_duration
to_remove = []
for child in clip:
tag = child.tag
if tag in ('marker', 'chapter-marker'):
child_start = TimeValue.from_timecode(child.get('start', '0s'))
if child_start < seg_start or child_start >= seg_end:
to_remove.append(child)
elif tag == 'title':
title_offset = TimeValue.from_timecode(child.get('offset', '0s'))
if title_offset < seg_start or title_offset >= seg_end:
to_remove.append(child)
elif tag == 'keyword':
kw_start = TimeValue.from_timecode(child.get('start', '0s'))
kw_dur = TimeValue.from_timecode(child.get('duration', '0s'))
kw_end = kw_start + kw_dur
# Completely outside segment → remove
if kw_end <= seg_start or kw_start >= seg_end:
to_remove.append(child)
else:
# Clamp keyword range to segment boundaries
clamped_start = max(kw_start, seg_start)
clamped_end = min(kw_end, seg_end)
child.set('start', clamped_start.to_fcpxml())
child.set('duration', (clamped_end - clamped_start).to_fcpxml())
for child in to_remove:
clip.remove(child)
def split_clip(
self,
clip_id: str,
split_points: List[str]
) -> List[ET.Element]:
"""
Split a clip at specified timecodes.
Args:
clip_id: Clip to split
split_points: Timecodes within the clip to split at
Returns:
List of resulting clip elements
"""
spine, clip, clip_index = self._require_spine_clip(clip_id)
# Get clip properties
clip_start, clip_duration, clip_offset = self._get_clip_times(clip)
clip_name = clip.get('name', 'Clip')
# Sort split points
split_times = sorted([self._parse_time(sp) for sp in split_points])
# Remove original clip
spine.remove(clip)
# Create new clips
new_clips = []
current_offset = clip_offset
current_start = clip_start
all_points = split_times + [clip_duration]
for i, split_time in enumerate(all_points):
if i == 0:
segment_duration = split_time
else:
segment_duration = split_time - split_times[i - 1]
if segment_duration <= TimeValue.zero():
continue
# Create new clip
new_clip = copy.deepcopy(clip)
new_clip.set('name', clip_name)
new_clip.set('offset', current_offset.to_fcpxml())
new_clip.set('start', current_start.to_fcpxml())
new_clip.set('duration', segment_duration.to_fcpxml())
# Remove markers/keywords that belong to other segments
self._filter_children_for_segment(
new_clip, current_start, segment_duration
)
self._reassign_text_style_ids(new_clip)
spine.insert(clip_index + len(new_clips), new_clip)
new_clips.append(new_clip)
# Update for next iteration
current_offset = current_offset + segment_duration
current_start = current_start + segment_duration
# Update clip index: remove stale original entry, add split entries
self.clips.pop(clip_id, None)
for i, new_clip in enumerate(new_clips):
new_id = f"{clip_id}_split_{i}"
self.clips[new_id] = new_clip
return new_clips
def cut_clip_ranges(
self,
clip: ET.Element,
cut_ranges: List[Tuple['TimeValue', 'TimeValue']],
) -> 'TimeValue':
"""Remove clip-relative time ranges from a spine clip, rippling after.
Element-based on purpose: callers that walk the spine (e.g. media
silence removal) pass the exact element, so duplicate-named clips are
never ambiguous the way name-keyed operations are.
Args:
clip: The spine clip element to cut (must be a direct spine child).
cut_ranges: (start, end) TimeValue pairs measured from the clip's
own head. Overlapping/unsorted ranges are merged; portions
outside [0, clip duration] are clamped. A cut covering the
whole clip removes it entirely.
Returns:
Total removed duration (zero if no effective ranges).
"""
spine = self._get_spine()
clip_start, clip_duration, clip_offset = self._get_clip_times(clip)
clip_index = list(spine).index(clip)
zero = TimeValue.zero()
# Clamp, sort, merge.
clamped = []
for start, end in cut_ranges:
start = start if start > zero else zero
end = end if end < clip_duration else clip_duration
if end > start:
clamped.append((start, end))
clamped.sort(key=lambda r: r[0])
merged: List[Tuple[TimeValue, TimeValue]] = []
for start, end in clamped:
if merged and start <= merged[-1][1]:
if end > merged[-1][1]:
merged[-1] = (merged[-1][0], end)
else:
merged.append((start, end))
if not merged:
return zero
# Keep ranges = complement of the merged cuts.
keeps: List[Tuple[TimeValue, TimeValue]] = []
cursor = zero
for start, end in merged:
if start > cursor:
keeps.append((cursor, start))
cursor = end
if cursor < clip_duration:
keeps.append((cursor, clip_duration))
# A keep segment shorter than a couple frames at the very start or
# end of the clip is just leftover cut padding with no neighboring
# kept audio on its outer side (the silence butts against the clip's
# own edge) — not a real clip. Rather than emit it as its own
# near-invisible micro-clip, fold it into the adjacent real segment,
# which simply starts earlier / ends later to absorb it.
min_keep_seconds = 2 * float(self.frame_duration_fraction())
if len(keeps) > 1:
first_start, first_end = keeps[0]
if (first_end - first_start).to_seconds() < min_keep_seconds:
keeps[1] = (first_start, keeps[1][1])
keeps.pop(0)
if len(keeps) > 1:
last_start, last_end = keeps[-1]
if (last_end - last_start).to_seconds() < min_keep_seconds:
keeps[-2] = (keeps[-2][0], last_end)
keeps.pop()
spine.remove(clip)
new_clips: List[ET.Element] = []
current_offset = clip_offset
kept_total = zero
for keep_start, keep_end in keeps:
seg_duration = keep_end - keep_start
seg_start = clip_start + keep_start
new_clip = copy.deepcopy(clip)
new_clip.set('offset', current_offset.to_fcpxml())
new_clip.set('start', seg_start.to_fcpxml())
new_clip.set('duration', seg_duration.to_fcpxml())
self._filter_children_for_segment(new_clip, seg_start, seg_duration)
self._reassign_text_style_ids(new_clip)
spine.insert(clip_index + len(new_clips), new_clip)
new_clips.append(new_clip)
current_offset = current_offset + seg_duration
kept_total = kept_total + seg_duration
removed = clip_duration - kept_total
self._ripple_from_index(spine, clip_index + len(new_clips), zero - removed)
self._update_sequence_duration()
# Keep the name index coherent, mirroring delete_clip/split_clip.
name = clip.get('id') or clip.get('name') or ''
if name and self.clips.get(name) is clip:
if new_clips:
self.clips[name] = new_clips[0]
else:
remaining = [
sc for _, sc in self._iter_spine_clips()
if (sc.get('id') or sc.get('name') or '') == name
]
if remaining:
self.clips[name] = remaining[0]
else:
self.clips.pop(name, None)
return removed
def remove_trailing_gaps(self) -> None:
"""Remove empty ``<gap>`` elements at the end of the timeline.
Silence removal (and FCP round-trips) can leave a trailing gap holding
the timeline open past the last real clip. This removes only *trailing*
gaps — a gap in the middle is left untouched — and re-syncs the sequence
duration so the exported file ends where the content ends.
"""
spine = self._get_spine()
children = list(spine)
if not children:
return
last = children[-1]
if last.tag != 'gap':
return
spine.remove(last)
self._update_sequence_duration()
def delete_clip(
self,
clip_ids: List[str],
ripple: bool = True
) -> None:
"""
Delete clips from timeline.
Uses spine iteration instead of the name-indexed dict so that
duplicate-named clips (e.g. four ``Interview_A``) are resolved
correctly — always targeting the *first* spine match rather than
the last-indexed entry.
Args:
clip_ids: Clips to delete
ripple: If True, shift subsequent clips. If False, leave gaps.
"""
spine = self._get_spine()
for clip_id in clip_ids:
# Walk spine directly to find the first clip matching this name,
# avoiding the last-one-wins problem in self.clips.
target = None
for _spine_idx, spine_clip in self._iter_spine_clips():
name = spine_clip.get('id') or spine_clip.get('name') or ''
if name == clip_id:
target = spine_clip
break
if target is None:
continue
_, clip_duration, clip_offset = self._get_clip_times(target)
clip_index = list(spine).index(target)
if ripple:
spine.remove(target)
self._ripple_from_index(
spine, clip_index, TimeValue.zero() - clip_duration
)
else:
# Replace with gap
gap = ET.Element('gap')
gap.set('name', 'Gap')
gap.set('offset', clip_offset.to_fcpxml())
gap.set('duration', clip_duration.to_fcpxml())
spine.remove(target)
spine.insert(clip_index, gap)
# Re-index: if other spine clips share this name, point the
# dict entry at the next one; otherwise remove entirely.
remaining = [
sc for _, sc in self._iter_spine_clips()
if (sc.get('id') or sc.get('name') or '') == clip_id
]
if remaining:
self.clips[clip_id] = remaining[0]
else:
self.clips.pop(clip_id, None)
# ========================================================================