refactor: writer.py vira pacote, um módulo por assunto
O writer tinha 4.199 linhas, das quais 3.300 numa única classe com dezoito
assuntos dentro. Achar o trecho de zoom exigia rolar por marcadores,
velocidade e legendas.
Agora é o pacote fcpxml/writer/, com um arquivo por assunto e o
FCPXMLModifier montado por composição de mixins. Mixins, e não objetos
separados, porque todas essas operações mexem no mesmo documento e nos
mesmos índices — separá-las em objetos independentes transformaria toda
chamada interna em travessia de fronteira sem nada em troca. A divisão que
importa aqui é de leitura, não de estado.
Nenhuma mudança de comportamento e nenhuma alteração nos ~50 pontos que
importam do writer: o __init__ re-exporta tudo, inclusive os nomes com
underscore que a suíte já usava.
core 723 carga, índices, navegação na spine, save
titles 600 títulos e legendas dinâmicas
cut 333 dividir, cortar faixas, apagar
speed 297 velocidade e zoom
(+ 20 módulos menores)
Único ajuste de chamada: quatro testes faziam patch em
fcpxml.writer.subprocess, que agora mora em writer.document (ver
Engine/docs/05_EXPERIENCIAS.md #23).
Lint zerado, 1441 testes passando.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1bebee4359
commit
4f5cf94443
@@ -0,0 +1,600 @@
|
||||
"""Títulos de texto e legendas dinâmicas.
|
||||
|
||||
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..collision import blocking, validate_titles
|
||||
from ..models import (
|
||||
DynamicSubtitleConfig,
|
||||
TimeValue,
|
||||
)
|
||||
from ..text_layout import (
|
||||
TEXT_TEMPLATE_FONT_SCALE,
|
||||
LayoutBox,
|
||||
compose_sentence,
|
||||
layout_sentence,
|
||||
)
|
||||
from ..transcribe import group_words_by_segment
|
||||
from .helpers import _dtd_insert, _sanitize_xml_value
|
||||
|
||||
|
||||
class TitlesMixin:
|
||||
"""Títulos de texto e legendas dinâmicas."""
|
||||
|
||||
# DYNAMIC (KARAOKE-STYLE) SUBTITLES
|
||||
# ========================================================================
|
||||
|
||||
# The "Text" (Basic Text) template — the ONLY simple title template that
|
||||
# Final Cut actually renders. Copied verbatim from the user's own FCP
|
||||
# exports ("teste.fcpxmld" and "posição.fcpxmld", FCP 1.14 in English):
|
||||
# a single "<text>" run, one "<text-style-def>", and a fixed param block
|
||||
# with the margins/alignment/speed the template ships with. Every prior
|
||||
# title template we generated ("Essencial - Título", "Título Básico")
|
||||
# imported cleanly but never appeared — their Motion uids did not resolve
|
||||
# to a real, drawable template in FCP, which discards the clip silently.
|
||||
# "Text" is what FCP itself writes when the user adds a title by hand, so
|
||||
# it is the ground truth. See Engine/docs/05_EXPERIENCIAS.md, 2026-08-17.
|
||||
_TEXT_TITLE_UID = (
|
||||
'.../Titles.localized/Basic Text.localized/'
|
||||
'Text.localized/Text.moti'
|
||||
)
|
||||
_TEXT_TITLE_START = '86486400/24000s'
|
||||
# The Inspector's Position field, and the one this code overrides per
|
||||
# title so two titles never stack on top of each other. Verified in
|
||||
# "posição.fcpxmld": each hand-dragged title carries a distinct "x y"
|
||||
# value here while every other param stays identical.
|
||||
_TEXT_POSITION_KEY = '9999/10003/13260/3296672360/1/100/101'
|
||||
# Layout params the "Text" template ships with. These keys are the
|
||||
# template's own defaults and never vary between instances.
|
||||
#
|
||||
# "Build Out" is the one deliberate override: with "Apply Speed" set to
|
||||
# "2 (Per Object)" below, the template's whole built-in animation (build
|
||||
# in + build out) is always compressed to exactly fill the title's own
|
||||
# on-screen duration — so on a short word-length clip, build out was
|
||||
# eating time that build in needed to finish revealing the text before
|
||||
# the cut. Disabling build out hands that entire compressed window to
|
||||
# build in alone, which is what "sempre acelerado" turned out to mean:
|
||||
# no separate speed knob needed. Value captured from a real FCP export
|
||||
# with "Build Out" unchecked in the Inspector (see chat, 2026-08-18).
|
||||
_TEXT_TITLE_PARAMS = (
|
||||
('Build Out', '9999/10000/2/102', '0'),
|
||||
('Layout Method', '9999/10003/13260/3296672360/2/314', '1 (Paragraph)'),
|
||||
('Left Margin', '9999/10003/13260/3296672360/2/323', '-1210'),
|
||||
('Right Margin', '9999/10003/13260/3296672360/2/324', '1210'),
|
||||
('Top Margin', '9999/10003/13260/3296672360/2/325', '2160'),
|
||||
('Bottom Margin', '9999/10003/13260/3296672360/2/326', '-2160'),
|
||||
('Alignment', '9999/10003/13260/3296672360/2/354/3296667315/401', '1 (Center)'),
|
||||
('Line Spacing', '9999/10003/13260/3296672360/2/354/3296667315/404', '-19'),
|
||||
('Auto-Shrink', '9999/10003/13260/3296672360/2/370', '3 (To All Margins)'),
|
||||
('Alignment', '9999/10003/13260/3296672360/2/373', '0 (Left) 1 (Middle)'),
|
||||
('Opacity', '9999/10003/13260/3296672360/4/3296673134/1000/1044', '0'),
|
||||
('Speed', '9999/10003/13260/3296672360/4/3296673134/201/208', '6 (Custom)'),
|
||||
('Apply Speed', '9999/10003/13260/3296672360/4/3296673134/201/211', '2 (Per Object)'),
|
||||
)
|
||||
# "Custom Speed" sits between "Speed" and "Apply Speed" and carries a
|
||||
# <keyframeAnimation> child rather than a plain value attribute. Its two
|
||||
# keyframes are the template's own absolute nominal times, constant across
|
||||
# every instance, so they are safe to replay verbatim.
|
||||
_TEXT_CUSTOM_SPEED_KEY = '9999/10003/13260/3296672360/4/3296673134/201/209'
|
||||
_TEXT_CUSTOM_SPEED_KEYFRAMES = (
|
||||
('-469658744/1000000000s', '0'),
|
||||
('12328542033/1000000000s', '1'),
|
||||
)
|
||||
_TEXT_SIZE_KEY = '9999/10003/13260/3296672360/5/3296672362/3'
|
||||
|
||||
def _ensure_text_title_effect(self, resources: ET.Element) -> str:
|
||||
"""Return the resource id of the "Text" (Basic Text) effect, creating it if absent."""
|
||||
return self._ensure_effect(resources, self._TEXT_TITLE_UID, 'Text', 'r_text')
|
||||
|
||||
def _ensure_effect(
|
||||
self,
|
||||
resources: ET.Element,
|
||||
uid: str,
|
||||
name: str,
|
||||
id_prefix: str,
|
||||
) -> str:
|
||||
"""Return the id of the effect resource with *uid*, creating it if absent."""
|
||||
for eff in resources.findall('effect'):
|
||||
if eff.get('uid') == uid:
|
||||
return eff.get('id')
|
||||
effect_id = self._unique_resource_id(resources, id_prefix)
|
||||
eff_el = ET.SubElement(resources, 'effect')
|
||||
eff_el.set('id', effect_id)
|
||||
eff_el.set('name', name)
|
||||
eff_el.set('uid', uid)
|
||||
return effect_id
|
||||
|
||||
# <text-style-def id> / <text-style ref> are DTD type ID/IDREF, so the
|
||||
# value must be a valid XML Name: letters, digits, "_", "-", "." only,
|
||||
# never starting with a digit. Title names are built from the caption
|
||||
# text ("Olá mundo - Text"), which carries spaces, accents and often a
|
||||
# leading digit — xmllint rejected the whole document with "Syntax of
|
||||
# value for attribute id of text-style-def is not valid".
|
||||
_TEXT_STYLE_ID_UNSAFE = re.compile(r'[^A-Za-z0-9_.-]+')
|
||||
|
||||
def _unique_text_style_id(self, base: str) -> str:
|
||||
"""Return a document-unique, DTD-valid XML ID for a ``<text-style-def>``."""
|
||||
folded = unicodedata.normalize('NFKD', base).encode('ascii', 'ignore').decode('ascii')
|
||||
slug = self._TEXT_STYLE_ID_UNSAFE.sub('_', folded).strip('_.-')[:48]
|
||||
stem = f"ts_{slug}" if slug else "ts"
|
||||
|
||||
if self._text_style_ids is None:
|
||||
self._text_style_ids = {
|
||||
sd.get('id') for sd in self.root.findall('.//text-style-def')
|
||||
}
|
||||
candidate = f"{stem}_0"
|
||||
counter = 0
|
||||
while candidate in self._text_style_ids:
|
||||
counter += 1
|
||||
candidate = f"{stem}_{counter}"
|
||||
self._text_style_ids.add(candidate)
|
||||
return candidate
|
||||
|
||||
def _reassign_text_style_ids(self, clip: ET.Element) -> None:
|
||||
"""Give every ``<text-style-def>`` inside a just-deepcopy'd *clip* a
|
||||
fresh document-unique id, repointing any ``<text-style ref="...">``
|
||||
in the same subtree that pointed at the old one.
|
||||
|
||||
``split_clip``/``cut_clip_ranges`` deepcopy the clip once per
|
||||
resulting segment, so a clip carrying a ``<title>`` (from a "text"
|
||||
voice action) keeps the exact same ``text-style-def id`` in every
|
||||
copy. A single cut is harmless — but the batch chain re-cuts the
|
||||
same clip at each step (silence removal, filler removal, dynamic
|
||||
subtitles), and every pass multiplies the duplicate, so the DTD
|
||||
validator eventually rejects the file with "ID ... already
|
||||
defined". Regenerating here, at the only place copies are made,
|
||||
fixes it for every caller instead of each one having to remember to.
|
||||
"""
|
||||
for style_def in clip.findall('.//text-style-def'):
|
||||
old_id = style_def.get('id')
|
||||
if not old_id:
|
||||
continue
|
||||
slug = old_id[3:] if old_id.startswith('ts_') else old_id
|
||||
slug = re.sub(r'_\d+$', '', slug) # drop a prior _<N> counter
|
||||
new_id = self._unique_text_style_id(slug)
|
||||
if new_id == old_id:
|
||||
continue
|
||||
style_def.set('id', new_id)
|
||||
for ref_el in clip.findall(f".//text-style[@ref='{old_id}']"):
|
||||
ref_el.set('ref', new_id)
|
||||
|
||||
def _make_text_title_clip(
|
||||
self,
|
||||
effect_id: str,
|
||||
text: str,
|
||||
offset: 'TimeValue',
|
||||
duration: 'TimeValue',
|
||||
*,
|
||||
lane: int,
|
||||
name: str,
|
||||
position: Optional[str] = None,
|
||||
font: str = 'Helvetica Neue',
|
||||
font_size: int = 196,
|
||||
font_color: str = '1 1 1 1',
|
||||
bold: bool = True,
|
||||
face: Optional[str] = None,
|
||||
kerning: Optional[float] = None,
|
||||
font_scale: float = TEXT_TEMPLATE_FONT_SCALE,
|
||||
animated: bool = True,
|
||||
size_param: Optional[float] = None,
|
||||
) -> ET.Element:
|
||||
"""Build a standalone ``<title>`` clip from the "Text" (Basic Text) template.
|
||||
|
||||
Reproduces FCP's own output for a hand-added title exactly — the only
|
||||
template we have verified renders in Final Cut ("teste.fcpxmld" and
|
||||
"posição.fcpxmld"). *position* ("x y" canvas points) is the Inspector
|
||||
Position value; omit it to keep the template's centred default. Unlike
|
||||
the animated templates, this carries no animation switch, so the text
|
||||
stays put and visible for its whole duration.
|
||||
"""
|
||||
elem = ET.Element('title')
|
||||
elem.set('ref', effect_id)
|
||||
elem.set('lane', str(lane))
|
||||
elem.set('offset', offset.to_fcpxml())
|
||||
elem.set('name', _sanitize_xml_value(name, 256))
|
||||
elem.set('start', self._TEXT_TITLE_START)
|
||||
elem.set('duration', duration.to_fcpxml())
|
||||
|
||||
if position:
|
||||
param = ET.SubElement(elem, 'param')
|
||||
param.set('name', 'Position')
|
||||
param.set('key', self._TEXT_POSITION_KEY)
|
||||
param.set('value', position)
|
||||
|
||||
def _add_param(name: str, key: str, value: str) -> None:
|
||||
param = ET.SubElement(elem, 'param')
|
||||
param.set('name', name)
|
||||
param.set('key', key)
|
||||
param.set('value', value)
|
||||
|
||||
animation_params = {'Opacity', 'Speed', 'Apply Speed'}
|
||||
for param_name, param_key, param_value in self._TEXT_TITLE_PARAMS:
|
||||
if not animated and param_name in animation_params:
|
||||
continue
|
||||
_add_param(param_name, param_key, param_value)
|
||||
if animated and param_name == 'Speed':
|
||||
# "Custom Speed" lands between "Speed" and "Apply Speed" and
|
||||
# carries a <keyframeAnimation> child instead of a value.
|
||||
cs = ET.SubElement(elem, 'param')
|
||||
cs.set('name', 'Custom Speed')
|
||||
cs.set('key', self._TEXT_CUSTOM_SPEED_KEY)
|
||||
anim = ET.SubElement(cs, 'keyframeAnimation')
|
||||
for kf_time, kf_value in self._TEXT_CUSTOM_SPEED_KEYFRAMES:
|
||||
kf = ET.SubElement(anim, 'keyframe')
|
||||
kf.set('time', kf_time)
|
||||
kf.set('value', kf_value)
|
||||
|
||||
if size_param is not None:
|
||||
_add_param('Size', self._TEXT_SIZE_KEY, f"{float(size_param):g}")
|
||||
|
||||
text_el = ET.SubElement(elem, 'text')
|
||||
ts_id = self._unique_text_style_id(name)
|
||||
run = ET.SubElement(text_el, 'text-style')
|
||||
run.set('ref', ts_id)
|
||||
run.text = _sanitize_xml_value(text, 256)
|
||||
|
||||
style_def = ET.SubElement(elem, 'text-style-def')
|
||||
style_def.set('id', ts_id)
|
||||
text_style = ET.SubElement(style_def, 'text-style')
|
||||
text_style.set('font', font)
|
||||
# Text.moti sizes type in frame pixels but positions in canvas points.
|
||||
# See TEXT_TEMPLATE_FONT_SCALE: layout measures in points, so only the
|
||||
# emitted size (and its kerning, to keep the same letter spacing) is
|
||||
# converted here.
|
||||
scale = float(font_scale) or 1.0
|
||||
text_style.set('fontSize', f"{float(font_size) * scale:g}")
|
||||
text_style.set('fontColor', font_color)
|
||||
# FCP represents bold weight as the bold attribute — never as a
|
||||
# fontFace. Writing ``bold="0" fontFace="Bold"`` (the previous
|
||||
# behaviour) is contradictory and FCP refuses to render the text.
|
||||
# Italic, by contrast, IS a face: FCP writes both ``fontFace`` and
|
||||
# ``italic="1"``. See Engine/docs/05_EXPERIENCIAS.md, entry 2026-08-19.
|
||||
face_lower = (face or '').strip().lower()
|
||||
if face_lower == 'bold':
|
||||
text_style.set('bold', '1')
|
||||
elif 'italic' in face_lower:
|
||||
text_style.set('fontFace', face)
|
||||
text_style.set('italic', '1')
|
||||
else:
|
||||
if bold:
|
||||
text_style.set('bold', '1')
|
||||
if face:
|
||||
text_style.set('fontFace', face)
|
||||
if kerning:
|
||||
text_style.set('kerning', f"{float(kerning) * scale:g}")
|
||||
text_style.set('alignment', 'center')
|
||||
text_style.set('lineSpacing', '-19')
|
||||
|
||||
return elem
|
||||
|
||||
def add_text_title(
|
||||
self,
|
||||
parent_clip: 'str | ET.Element',
|
||||
text: str,
|
||||
*,
|
||||
offset: str = '0s',
|
||||
duration: str = '1s',
|
||||
lane: int = 1,
|
||||
position: Optional[str] = None,
|
||||
font: str = 'Helvetica Neue',
|
||||
font_size: int = 196,
|
||||
font_color: str = '1 1 1 1',
|
||||
bold: bool = True,
|
||||
face: Optional[str] = None,
|
||||
animated: bool = True,
|
||||
font_scale: float = TEXT_TEMPLATE_FONT_SCALE,
|
||||
size_param: Optional[float] = None,
|
||||
) -> ET.Element:
|
||||
"""Add a single static "Text" (Basic Text) title over *parent_clip*.
|
||||
|
||||
Anchored in SOURCE media coordinates (parent's ``start`` + *offset*),
|
||||
matching FCP's own output, so the title lands on screen instead of at
|
||||
~0s of the media (which FCP silently drops). *offset* and *duration*
|
||||
accept any FCPXML rational-time string; *position* is an optional
|
||||
"x y" canvas-point string to keep two titles from stacking.
|
||||
|
||||
Returns:
|
||||
The created ``<title>`` element, already inserted into the parent
|
||||
in DTD order.
|
||||
"""
|
||||
parent = parent_clip if isinstance(parent_clip, ET.Element) else self._require_clip(parent_clip)
|
||||
resources = self.root.find('.//resources')
|
||||
if resources is None:
|
||||
raise ValueError("No <resources> element found in FCPXML")
|
||||
effect_id = self._ensure_text_title_effect(resources)
|
||||
|
||||
media_origin = self._parse_time(parent.get('start', '0s'))
|
||||
relative = self._parse_time(offset)
|
||||
title = self._make_text_title_clip(
|
||||
effect_id,
|
||||
text,
|
||||
media_origin + relative,
|
||||
self._parse_time(duration),
|
||||
lane=lane,
|
||||
name=f"{text} - Text",
|
||||
position=position,
|
||||
font=font,
|
||||
font_size=font_size,
|
||||
font_color=font_color,
|
||||
bold=bold,
|
||||
face=face,
|
||||
animated=animated,
|
||||
font_scale=font_scale,
|
||||
size_param=size_param,
|
||||
)
|
||||
_dtd_insert(parent, title)
|
||||
return title
|
||||
|
||||
def generate_dynamic_subtitles(
|
||||
self,
|
||||
parent_clip: 'str | ET.Element',
|
||||
words: List[Dict[str, Any]],
|
||||
config: Optional['DynamicSubtitleConfig'] = None,
|
||||
segments: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> List[ET.Element]:
|
||||
"""Generate progressive-reveal subtitle titles, one per word.
|
||||
|
||||
Groups *words* into sentences (by *segments*' time windows), lays each
|
||||
sentence out as a compact typographic block, and emits one standalone
|
||||
``<title>`` per word, positioned at its place in that block. Words
|
||||
appear one by one as they are spoken and accumulate on screen; every
|
||||
word of a block then clears at the same instant, so the sentence
|
||||
vanishes as a whole before the next one builds up.
|
||||
|
||||
Each word gets its own lane, since a block's words are all on screen
|
||||
together. Lanes restart with each block. Size, colour, font and face
|
||||
cycle through ``config.style.rhythm``, reproducing the typography of
|
||||
the calibration export the user built in Final Cut.
|
||||
|
||||
A sentence too tall for the band is split into successive blocks, so a
|
||||
long sentence never spills off screen.
|
||||
|
||||
Args:
|
||||
parent_clip: The spine clip to attach titles to — either its
|
||||
Name/ID (resolved via ``_require_clip``, kept for backward
|
||||
compatibility) or the ``ET.Element`` itself. **Callers
|
||||
iterating multiple spine clips must pass the element, not
|
||||
the name**: after any ripple-cut/silence-removal operation,
|
||||
every fragment of an originally-named clip keeps that same
|
||||
``name``, so ``self.clips`` (keyed by name) only retains the
|
||||
last-indexed one — a name lookup then silently resolves
|
||||
every call to the SAME wrong clip, stacking every line from
|
||||
every distinct clip's transcript onto one spine element (see
|
||||
Engine/docs/05_EXPERIENCIAS.md, entry 2026-08-17).
|
||||
words: ``[{'word': str, 'start': float, 'end': float}, ...]``
|
||||
with ``start``/``end`` in seconds *relative to the parent
|
||||
clip's own start* (same convention as ``add_connected_clip``'s
|
||||
``offset``).
|
||||
config: Styling/layout options; defaults to ``DynamicSubtitleConfig()``.
|
||||
segments: Whisper sentence segments ``[{'start', 'end', ...}]``, on
|
||||
the same relative timebase as *words*. Omitted, every word
|
||||
falls into a single sentence, which the block layout then
|
||||
splits by height alone.
|
||||
|
||||
Returns:
|
||||
The list of created ``<title>`` elements, in chronological order.
|
||||
"""
|
||||
if config is None:
|
||||
config = DynamicSubtitleConfig()
|
||||
if not words:
|
||||
return []
|
||||
|
||||
parent = parent_clip if isinstance(parent_clip, ET.Element) else self._require_clip(parent_clip)
|
||||
resources = self.root.find('.//resources')
|
||||
if resources is None:
|
||||
raise ValueError("No <resources> element found in FCPXML")
|
||||
effect_id = self._ensure_text_title_effect(resources)
|
||||
|
||||
# A connected title is NOT trimmed by its parent clip's out-point —
|
||||
# Final Cut keeps drawing it over whatever clip follows. A word that
|
||||
# starts after the cut would therefore only ever be seen on top of the
|
||||
# NEXT clip's own captions, so it is dropped rather than placed.
|
||||
parent_limit = self._parse_time(parent.get('duration', '0s'))
|
||||
has_limit = TimeValue(0, 1) < parent_limit
|
||||
if has_limit:
|
||||
limit_seconds = parent_limit.to_seconds()
|
||||
words = [
|
||||
w for w in words
|
||||
if float(w.get('start', 0.0)) < limit_seconds
|
||||
]
|
||||
if not words:
|
||||
return []
|
||||
|
||||
# Split into sentences, then lay each one out as a block. A sentence
|
||||
# too tall for the band comes back with overflow, which becomes the
|
||||
# next block — the sub-sentence split that keeps long sentences from
|
||||
# spilling off screen.
|
||||
sentences = group_words_by_segment(words, segments or [])
|
||||
box = LayoutBox.for_frame(
|
||||
self.frame_width(), self.frame_height(),
|
||||
band_height=config.band_height,
|
||||
center_y=config.block_center_y,
|
||||
)
|
||||
# "phrase" is the progressive composition the reference reel uses: one
|
||||
# title per LINE ("que vão" / "melhorar" / "sua legenda"), the key word
|
||||
# set large in a display italic. "word" is the older one-title-per-word
|
||||
# rhythm, kept for callers that want every word to land on its own.
|
||||
phrase_mode = getattr(config, 'granularity', 'phrase') == 'phrase'
|
||||
|
||||
def lay_out(pending: List[Dict]):
|
||||
"""Place what fits; return (units, still-unplaced words)."""
|
||||
if phrase_mode:
|
||||
composition = compose_sentence(
|
||||
pending, config.style, box, line_gap=config.line_gap,
|
||||
)
|
||||
return composition.blocks, composition.overflow
|
||||
layout = layout_sentence(pending, config.style, box)
|
||||
return layout.placed, layout.overflow
|
||||
|
||||
blocks: List[List[Any]] = []
|
||||
for sentence in sentences:
|
||||
remaining = list(sentence)
|
||||
while remaining:
|
||||
units, remaining = lay_out(remaining)
|
||||
if not units:
|
||||
break
|
||||
blocks.append(units)
|
||||
if not blocks:
|
||||
return []
|
||||
|
||||
# Never emit a zero-duration frame (rounds to 0 at the sequence's fps
|
||||
# and FCP rejects it as "unexpected value found").
|
||||
min_dur_tv = self.snap_seconds_to_frame(
|
||||
float(self.frame_duration_fraction())
|
||||
)
|
||||
|
||||
# Every word of a block clears at the same instant: when the next block
|
||||
# starts, or at the last word's end for the final block. That is what
|
||||
# makes a sentence build up and then vanish all at once.
|
||||
block_starts = [
|
||||
self.snap_seconds_to_frame(min(unit.start for unit in units))
|
||||
for units in blocks
|
||||
]
|
||||
# Whisper's word end can also run past the cut, so a last block would
|
||||
# linger over the next clip's first block. Nothing may outlive the
|
||||
# clip it was written for.
|
||||
block_ends: List[TimeValue] = []
|
||||
for i, units in enumerate(blocks):
|
||||
if i + 1 < len(blocks):
|
||||
end = block_starts[i + 1]
|
||||
else:
|
||||
end = self.snap_seconds_to_frame(
|
||||
max(unit.end for unit in units)
|
||||
)
|
||||
if end - block_starts[i] < min_dur_tv:
|
||||
end = block_starts[i] + min_dur_tv
|
||||
if has_limit and parent_limit < end:
|
||||
end = parent_limit
|
||||
block_ends.append(end)
|
||||
|
||||
# Anchored titles are positioned in the parent clip's SOURCE media
|
||||
# coordinates: a title's offset is the parent clip's `start` plus its
|
||||
# timeline-relative position. Verified against FCP's own output in
|
||||
# "exemplo de arquivos.fcpxmld", where the hand-made "Essencial -
|
||||
# Título" sits at offset 226040815/24000s on a parent starting at
|
||||
# 226007782/24000s — 1.376s into a 1.835s clip. Writing a plain
|
||||
# relative offset instead would drop the title to ~0s of the media,
|
||||
# before the clip's own in-point, so it lands outside the clip and FCP
|
||||
# never shows it.
|
||||
media_origin = self._parse_time(parent.get('start', '0s'))
|
||||
|
||||
created: List[ET.Element] = []
|
||||
for units, block_end in zip(blocks, block_ends):
|
||||
for index, unit in enumerate(units):
|
||||
relative_offset = self.snap_seconds_to_frame(unit.start)
|
||||
duration = block_end - relative_offset
|
||||
if duration < min_dur_tv:
|
||||
duration = min_dur_tv
|
||||
|
||||
# Units of one block are all on screen together, so no two may
|
||||
# share a lane. Lanes restart each block, which is free — the
|
||||
# previous block has already cleared.
|
||||
lane = index + 1
|
||||
|
||||
offset = media_origin + relative_offset
|
||||
title = self._make_text_title_clip(
|
||||
effect_id,
|
||||
unit.text,
|
||||
offset,
|
||||
duration,
|
||||
lane=lane,
|
||||
name=f"caption_{uuid.uuid4().hex[:8]}",
|
||||
position=unit.position_param(config.text_scale),
|
||||
font=unit.font or config.style.font,
|
||||
font_size=int(round(unit.font_size)),
|
||||
font_color=unit.color or config.style.active_color,
|
||||
bold=config.style.bold,
|
||||
face=unit.face,
|
||||
kerning=unit.kerning,
|
||||
font_scale=config.text_scale,
|
||||
)
|
||||
_dtd_insert(parent, title)
|
||||
created.append(title)
|
||||
|
||||
if getattr(config, 'validate', False):
|
||||
report = self.validate_subtitle_layout()
|
||||
if blocking(report["severity"]):
|
||||
raise ValueError(
|
||||
"Subtitle layout validation failed: "
|
||||
+ str(report["summary"])
|
||||
)
|
||||
|
||||
return created
|
||||
|
||||
def validate_subtitle_layout(
|
||||
self,
|
||||
*,
|
||||
safe_margin_x: float = 0.05,
|
||||
safe_margin_y: float = 0.05,
|
||||
min_font_size: Optional[float] = None,
|
||||
min_distance: Optional[float] = None,
|
||||
max_distance: Optional[float] = None,
|
||||
) -> dict:
|
||||
"""Re-measure every ``<title>`` in the document and report collisions.
|
||||
|
||||
Reconstructs each title's on-screen box from the values the writer
|
||||
emitted (``fontSize``/``kerning``/``Position`` are already in template
|
||||
space), then checks for temporal+spatial collisions, frame/safe-area
|
||||
containment, and font fallbacks. This is the spec-16 validation pass the
|
||||
layout engine does not do on its own — it only guarantees non-overlap
|
||||
*by construction* while composing, and cannot see a hand-edited title.
|
||||
|
||||
Returns the ``collision.validate_titles`` report: ``severity`` (worst
|
||||
bucket), ``issues`` (spec-16 occurrences) and ``summary`` (counts).
|
||||
"""
|
||||
titles = []
|
||||
for elem in self.root.iter('title'):
|
||||
text_el = elem.find('text/text-style')
|
||||
text = (text_el.text or '').strip() if text_el is not None else ''
|
||||
style = elem.find('text-style-def/text-style')
|
||||
font = style.get('font') if style is not None else None
|
||||
face = style.get('fontFace') if style is not None else None
|
||||
font_size = (
|
||||
float(style.get('fontSize', '0')) if style is not None else 0.0
|
||||
)
|
||||
kerning = (
|
||||
float(style.get('kerning', '0') or 0)
|
||||
if style is not None else 0.0
|
||||
)
|
||||
|
||||
x = y = 0.0
|
||||
for param in elem.findall('param'):
|
||||
if param.get('name') == 'Position' and param.get('value'):
|
||||
parts = param.get('value').split()
|
||||
if len(parts) >= 2:
|
||||
x, y = float(parts[0]), float(parts[1])
|
||||
|
||||
start = self._parse_time(elem.get('offset', '0s')).to_seconds()
|
||||
duration = self._parse_time(elem.get('duration', '0s')).to_seconds()
|
||||
|
||||
titles.append({
|
||||
'text': text,
|
||||
'font': font,
|
||||
'face': face,
|
||||
'font_size': font_size,
|
||||
'kerning': kerning,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'start': start,
|
||||
'end': start + duration,
|
||||
'group': start + duration,
|
||||
})
|
||||
|
||||
return validate_titles(
|
||||
titles,
|
||||
self.frame_width(),
|
||||
self.frame_height(),
|
||||
safe_margin_x=safe_margin_x,
|
||||
safe_margin_y=safe_margin_y,
|
||||
min_font_size=min_font_size,
|
||||
min_distance=min_distance,
|
||||
max_distance=max_distance,
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
Reference in New Issue
Block a user