"""Compound clips: criar e achatar. Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto. """ import copy import uuid import xml.etree.ElementTree as ET from typing import List from ..models import ( TimeValue, ) from .helpers import ( _dtd_insert, _sanitize_xml_value, ) class CompoundMixin: """Compound clips: criar e achatar.""" # COMPOUND CLIP OPERATIONS (v0.6.0) # ======================================================================== def create_compound_clip( self, clip_ids: List[str], name: str = "Compound Clip", ) -> ET.Element: """Group spine clips into a compound clip. Creates a resource with a nested containing the specified clips, then replaces the originals in the main spine with a single . Args: clip_ids: IDs of clips in the spine to group. name: Name for the compound clip. Returns: The created element. """ spine = self._get_spine() resources = self.root.find('.//resources') if resources is None: raise ValueError("No element found in FCPXML") # Collect clips and validate they're in spine spine_children = list(spine) clips_to_group = [] for cid in clip_ids: clip = self._require_clip(cid) if clip not in spine_children: raise ValueError(f"Clip not in spine: {cid}") clips_to_group.append((cid, clip)) if not clips_to_group: raise ValueError("No valid clips to group") # Sort by offset so the compound maintains order clips_to_group.sort( key=lambda c: self._parse_time(c[1].get('offset', '0s')) ) # Calculate compound duration and starting offset first_offset = self._parse_time(clips_to_group[0][1].get('offset', '0s')) total_duration = TimeValue.zero() for _, clip in clips_to_group: total_duration = total_duration + self._parse_time(clip.get('duration', '0s')) # Get format ref format_id = None for fmt_id in self.formats: format_id = fmt_id break # Create media resource with nested sequence media_id = self._unique_resource_id(resources, 'r_compound1') media = ET.SubElement(resources, 'media') media.set('id', media_id) media.set('name', _sanitize_xml_value(name, 512)) media.set('uid', str(uuid.uuid4()).upper()) seq = ET.SubElement(media, 'sequence') seq.set('format', format_id or 'r1') seq.set('duration', total_duration.to_fcpxml()) seq.set('tcStart', '0s') seq.set('tcFormat', 'NDF') inner_spine = ET.SubElement(seq, 'spine') # Move clips into the compound's inner spine inner_offset = TimeValue.zero() for _, clip in clips_to_group: new_clip = copy.deepcopy(clip) new_clip.set('offset', inner_offset.to_fcpxml()) inner_spine.append(new_clip) inner_offset = inner_offset + self._parse_time(clip.get('duration', '0s')) # Get the insert position (where first clip was) spine_children = list(spine) insert_idx = spine_children.index(clips_to_group[0][1]) # Remove originals from spine for cid, clip in clips_to_group: spine.remove(clip) if cid in self.clips: del self.clips[cid] # Create ref-clip in main spine ref_clip = ET.Element('ref-clip') ref_clip.set('ref', media_id) ref_clip.set('offset', first_offset.to_fcpxml()) ref_clip.set('name', _sanitize_xml_value(name, 512)) ref_clip.set('duration', total_duration.to_fcpxml()) spine.insert(insert_idx, ref_clip) # Index the new ref-clip compound_id = f"compound_{name}" self.clips[compound_id] = ref_clip return ref_clip def wrap_titles_in_compound( self, parent_clip: ET.Element, titles: List[ET.Element], name: str = "Legenda", ) -> ET.Element: """Pack lane-nested *titles* of *parent_clip* into one compound clip. A dynamic-subtitle sub-phrase is a dozen overlapping ```` elements stacked across as many lanes — legible on screen, unreadable in the timeline. Collapsing each sub-phrase into a single compound gives one bar per phrase to drag, mute or retime as a unit. Mirrors the structure Final Cut itself produces for "New Compound Clip" over stacked titles: the earliest title becomes the compound's spine anchor at offset 0, the rest hang off it as lane children, and a ``<ref-clip>`` takes their place in *parent_clip* on the anchor's original lane. Child offsets are rebased from *parent_clip*'s source-time space onto the anchor's, since a lane child is anchored at its parent's ``start`` — leaving them untouched would shift every word of the phrase by the gap between the two starts. Args: parent_clip: The spine clip the titles currently hang off. titles: The ``<title>`` elements to pack; must all be direct children of *parent_clip*. name: Name for the resulting compound clip. Returns: The created ``<ref-clip>`` element, now in *parent_clip*. """ if not titles: raise ValueError("No titles to wrap") resources = self.root.find('.//resources') if resources is None: raise ValueError("No <resources> element found in FCPXML") ordered = sorted( titles, key=lambda t: self._parse_time(t.get('offset', '0s')) ) anchor = ordered[0] anchor_offset = self._parse_time(anchor.get('offset', '0s')) anchor_start = self._parse_time(anchor.get('start', '0s')) anchor_lane = anchor.get('lane') total = TimeValue.zero() for title in ordered: rel = self._parse_time(title.get('offset', '0s')) - anchor_offset end = rel + self._parse_time(title.get('duration', '0s')) if end > total: total = end format_id = next(iter(self.formats), None) or 'r1' media_id = self._unique_resource_id(resources, 'r_compound1') media = ET.SubElement(resources, 'media') media.set('id', media_id) media.set('name', _sanitize_xml_value(name, 512)) media.set('uid', str(uuid.uuid4()).upper()) seq = ET.SubElement(media, 'sequence') seq.set('format', format_id) seq.set('duration', total.to_fcpxml()) seq.set('tcStart', '0s') seq.set('tcFormat', 'NDF') inner_spine = ET.SubElement(seq, 'spine') for title in ordered: parent_clip.remove(title) anchor.set('offset', '0s') if anchor_lane is not None: del anchor.attrib['lane'] inner_spine.append(anchor) for lane, title in enumerate(ordered[1:], start=1): rel = self._parse_time(title.get('offset', '0s')) - anchor_offset title.set('offset', (anchor_start + rel).to_fcpxml()) title.set('lane', str(lane)) anchor.append(title) ref_clip = ET.Element('ref-clip') ref_clip.set('ref', media_id) if anchor_lane is not None: ref_clip.set('lane', anchor_lane) ref_clip.set('offset', anchor_offset.to_fcpxml()) ref_clip.set('name', _sanitize_xml_value(name, 512)) ref_clip.set('duration', total.to_fcpxml()) _dtd_insert(parent_clip, ref_clip) return ref_clip def flatten_compound_clip( self, ref_clip_id: str, ) -> List[ET.Element]: """Flatten a compound clip back into individual spine clips. Extracts clips from the compound's inner sequence and places them back in the main spine at the ref-clip's position. Args: ref_clip_id: ID of the ref-clip to flatten. Returns: List of extracted clip elements now in the main spine. """ spine = self._get_spine() ref_clip = self._require_clip(ref_clip_id) if ref_clip.tag != 'ref-clip': raise ValueError(f"Element is not a ref-clip: {ref_clip_id}") media_ref = ref_clip.get('ref', '') ref_offset = self._parse_time(ref_clip.get('offset', '0s')) # Find the media resource resources = self.root.find('.//resources') media_elem = None if resources is not None: for m in resources.findall('media'): if m.get('id') == media_ref: media_elem = m break if media_elem is None: raise ValueError(f"Media resource not found for ref: {media_ref}") inner_spine = media_elem.find('.//spine') if inner_spine is None: raise ValueError("No spine found in compound clip media") # Get insert position spine_children = list(spine) insert_idx = spine_children.index(ref_clip) # Remove ref-clip from spine spine.remove(ref_clip) if ref_clip_id in self.clips: del self.clips[ref_clip_id] # Extract clips from inner spine into main spine extracted = [] current_offset = ref_offset for child in list(inner_spine): new_clip = copy.deepcopy(child) new_clip.set('offset', current_offset.to_fcpxml()) spine.insert(insert_idx, new_clip) insert_idx += 1 extracted.append(new_clip) current_offset = current_offset + self._parse_time( child.get('duration', '0s') ) # Index the extracted clip clip_name = new_clip.get('name') or new_clip.get('id') or f"flat_{len(self.clips)}" self.clips[clip_name] = new_clip # Clean up media resource if resources is not None: resources.remove(media_elem) return extracted # ========================================================================