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>
291 lines
10 KiB
Python
291 lines
10 KiB
Python
"""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 <media> resource with a nested <sequence><spine> containing
|
|
the specified clips, then replaces the originals in the main spine
|
|
with a single <ref-clip>.
|
|
|
|
Args:
|
|
clip_ids: IDs of clips in the spine to group.
|
|
name: Name for the compound clip.
|
|
|
|
Returns:
|
|
The created <ref-clip> element.
|
|
"""
|
|
spine = self._get_spine()
|
|
resources = self.root.find('.//resources')
|
|
if resources is None:
|
|
raise ValueError("No <resources> 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 ``<title>``
|
|
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
|
|
|
|
# ========================================================================
|