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:
co-authored by
Claude Sonnet 5
parent
32d78d0f8d
commit
0fdfe33613
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user