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