Object-tracker/tracking-shape (dado de rastreamento de objeto preservado do asset original) mantinha o mesmo id em cada deepcopy feito por split_clip/cut_clip_ranges, e o FCP acabava rejeitando o arquivo com "ID tr1 already defined" depois de vários cortes. Mesmo mecanismo do bug já corrigido para text-style-def, agora coberto também para tracking-shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
353 lines
14 KiB
Python
353 lines
14 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)
|
|
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)
|
|
|
|
# ========================================================================
|