Files
gart/code/fcpxml/writer/cut.py
João HenriqueandClaude Opus 5 688bdeddb6 feat(legendas): sub-frases por vírgula e empacotamento em compound clip
split_into_subphrases divide a frase na vírgula — onde a fala respira —
mas funde de volta o pedaço curto ("né?", "Então..."), que lê como parte
da frase anterior e não como bloco próprio.

wrap_titles_in_compound empacota os títulos de uma sub-frase num compound
clip, replicando a estrutura que o próprio Final Cut produz: o primeiro
título vira âncora do spine em offset 0, os demais penduram nele por lane,
e um ref-clip toma o lugar deles na lane original. Os offsets dos filhos
são rebaseados para o espaço de tempo da âncora, senão cada palavra
escorregaria pela diferença entre os dois start.

Junto: _filter_children_for_segment passa a filtrar também o <video> do
Clipe de Ajuste. Sem isso, cada corte subsequente duplicava o zoom em
todos os pedaços resultantes com o offset original intacto, e as cópias
desenhavam empilhadas na mesma posição da timeline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:35:21 -04:00

368 lines
15 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.
A lane-nested ``<video>`` zoom (the "Clipe de Ajuste" adjustment
layer ``add_zoom`` creates, ``role`` starting with ``"adjustments."``)
is the exact same phantom-duplicate case, keyed on ``offset``+
``duration`` like a keyword. Left unfiltered, every further cut
duplicates the zoom into every resulting piece with its original
offset untouched — each copy then draws at the same absolute
position, so two "Clipe de Ajuste" bars appear stacked on top of
each other in the timeline instead of the one real zoom window.
"""
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 == 'video' and (child.get('role') or '').startswith('adjustments.'):
v_offset = TimeValue.from_timecode(child.get('offset', '0s'))
v_dur = TimeValue.from_timecode(child.get('duration', '0s'))
v_end = v_offset + v_dur
if v_end <= seg_start or v_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)
self._reassign_tracking_shape_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 MIN_KEEP_SECONDS is leftover between
# two cuts, not a real clip — at the very start/end of the clip it's
# cut padding with no kept audio on the outer side; in the interior
# it's the pause BETWEEN two things that were both cut (e.g. two
# consecutive deactivated phrases in the voice-editing flow), which
# belongs to neither side by construction. At the edges we fold it
# into the one neighboring KEEP segment there is, which simply starts
# earlier / ends later to absorb it. In the interior both neighbors
# are CUT, not keep, so there is nothing to fold into — it is just
# dropped, extending the surrounding cut across it instead of
# surviving as a third near-invisible micro-clip.
#
# The threshold is bigger than one frame on purpose: measured on a
# real voice-edit (0.07-0.23s residues), a single frame did not catch
# them — this is pause/padding leftover, not intentional short
# content, so treating anything under a third of a second this way
# is safe for this cut path.
min_keep_seconds = max(6 * float(self.frame_duration_fraction()), 0.3)
i = 0
while len(keeps) > 1 and i < len(keeps):
start, end = keeps[i]
if (end - start).to_seconds() >= min_keep_seconds:
i += 1
continue
if i == 0:
keeps[1] = (start, keeps[1][1])
keeps.pop(0)
elif i == len(keeps) - 1:
keeps[i - 1] = (keeps[i - 1][0], end)
keeps.pop(i)
else:
keeps.pop(i)
# Re-check the same index: the segment now there might itself be
# short enough to absorb again (two short keeps in a row).
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)
self._reassign_tracking_shape_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)
# ========================================================================