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>
This commit is contained in:
João Henrique
2026-08-26 16:35:21 -04:00
co-authored by Claude Opus 5
parent 635d1bb553
commit 688bdeddb6
3 changed files with 157 additions and 0 deletions
+94
View File
@@ -12,6 +12,7 @@ from ..models import (
TimeValue,
)
from .helpers import (
_dtd_insert,
_sanitize_xml_value,
)
@@ -122,6 +123,99 @@ class CompoundMixin:
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,