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,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)
|
||||
|
||||
# ========================================================================
|
||||
Reference in New Issue
Block a user