fix(legendas): impede legenda comum sob composição dinâmica e duplicação ao regerar

Marca cada título gerado (dynamic/plain) em metadata para que regenerar
substitua a saída anterior em vez de empilhar, e usa os spans de ênfase
revisados (não os segmentos brutos do Whisper) como janela da composição
dinâmica, evitando que ela invada o trecho de legenda comum seguinte.
suppress_plain_under_dynamic corta qualquer sobra visível como rede de
segurança.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
João Henrique
2026-09-22 21:51:02 -04:00
co-authored by Claude Sonnet 5
parent 32d78d0f8d
commit 0fdfe33613
5 changed files with 479 additions and 10 deletions
+93 -1
View File
@@ -28,6 +28,92 @@ from .helpers import _dtd_insert, _sanitize_xml_value
class TitlesMixin:
"""Títulos de texto e legendas dinâmicas."""
_SUBTITLE_METADATA_KEY = 'com.gart.subtitle.kind'
def mark_generated_subtitle(self, element: ET.Element, kind: str) -> None:
metadata = element.find('metadata')
if metadata is None:
metadata = ET.Element('metadata')
_dtd_insert(element, metadata)
ET.SubElement(metadata, 'md', key=self._SUBTITLE_METADATA_KEY, value=kind)
def _generated_subtitle_kind(self, element: ET.Element) -> Optional[str]:
marker = element.find(f"metadata/md[@key='{self._SUBTITLE_METADATA_KEY}']")
if marker is not None:
return marker.get('value')
# Recognize the exact signature of older G-ART exports. A role alone
# is not ownership: users also assign these roles to manual titles.
if element.tag == 'title':
if element.get('start') != self._TEXT_TITLE_START:
return None
effect = self.root.find(f".//resources/effect[@id='{element.get('ref')}']")
if effect is None or effect.get('uid') != self._TEXT_TITLE_UID:
return None
if re.fullmatch(r'caption_[0-9a-f]{8}', element.get('name', '')):
return 'dynamic'
text = ''.join(element.findtext('text/text-style', ''))
if (element.get('role') == 'titles.convencionais'
and element.get('lane') == '20'
and element.get('name') == f'{text} - Text'):
return 'plain'
elif element.tag == 'ref-clip':
media = self.root.find(f".//resources/media[@id='{element.get('ref')}']")
if media is not None:
titles = media.findall('.//title')
if titles and all(self._generated_subtitle_kind(t) == 'dynamic' for t in titles):
return 'dynamic'
return None
def remove_generated_subtitles(self, parent: ET.Element, kinds: tuple) -> None:
"""Replace only our own captions, preserving unrelated graphics."""
resources = self.root.find('.//resources')
for child in list(parent):
if self._generated_subtitle_kind(child) not in kinds:
continue
parent.remove(child)
if child.tag == 'ref-clip' and resources is not None:
ref = child.get('ref')
if not self.root.findall(f".//ref-clip[@ref='{ref}']"):
media = resources.find(f"media[@id='{ref}']")
if media is not None:
resources.remove(media)
def suppress_plain_under_dynamic(self, parent: ET.Element) -> None:
"""Keep generated plain titles only on frames without dynamic text."""
import copy
windows = []
for child in parent:
if self._generated_subtitle_kind(child) == 'dynamic':
start = self._parse_time(child.get('offset', '0s'))
windows.append((start, start + self._parse_time(child.get('duration', '0s'))))
for title in list(parent):
if self._generated_subtitle_kind(title) != 'plain':
continue
start = self._parse_time(title.get('offset', '0s'))
end = start + self._parse_time(title.get('duration', '0s'))
remaining = [(start, end)]
for lo, hi in windows:
parts = []
for a, b in remaining:
if a < hi and lo < b:
if a < lo:
parts.append((a, lo))
if hi < b:
parts.append((hi, b))
else:
parts.append((a, b))
remaining = parts
if remaining == [(start, end)]:
continue
parent.remove(title)
for a, b in remaining:
part = copy.deepcopy(title)
self._reassign_text_style_ids(part)
part.set('offset', a.to_fcpxml())
part.set('duration', (b - a).to_fcpxml())
_dtd_insert(parent, part)
# DYNAMIC (KARAOKE-STYLE) SUBTITLES
# ========================================================================
@@ -385,6 +471,7 @@ class TitlesMixin:
configs: Optional[List['DynamicSubtitleConfig']] = None,
compound_subphrases: bool = False,
subphrase_min_words: int = 3,
hold_between_sentences: bool = True,
) -> List[ET.Element]:
"""Generate progressive-reveal subtitle titles, one per word.
@@ -538,6 +625,9 @@ class TitlesMixin:
for i, units in enumerate(blocks):
if i + 1 < len(blocks):
end = block_starts[i + 1]
if not hold_between_sentences and block_sentences[i] != block_sentences[i + 1]:
spoken_end = self.snap_seconds_to_frame(max(unit.end for unit in units))
end = min(end, spoken_end)
else:
end = self.snap_seconds_to_frame(
max(unit.end for unit in units)
@@ -599,6 +689,7 @@ class TitlesMixin:
role=block_role,
)
_dtd_insert(parent, title)
self.mark_generated_subtitle(title, 'dynamic')
created.append(title)
by_sentence.setdefault(sentence_index, []).append(title)
@@ -611,9 +702,10 @@ class TitlesMixin:
str(w.get('word') or w.get('text') or '')
for w in sentences[sentence_index]
).strip()
self.wrap_titles_in_compound(
compound = self.wrap_titles_in_compound(
parent, group, name=label[:60] or "Legenda"
)
self.mark_generated_subtitle(compound, 'dynamic')
if any(getattr(cfg, 'validate', False) for cfg in layout_configs):
report = self.validate_subtitle_layout()