"""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 ```` (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) # ========================================================================