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:
João Henrique
2026-08-19 21:38:49 -04:00
co-authored by Claude Opus 5
parent 1bebee4359
commit 4f5cf94443
28 changed files with 4719 additions and 4203 deletions
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
"""
FCPXML Writer — Generate and modify Final Cut Pro XML files.
This package provides two complementary workflows for working with FCPXML:
**Generation** (``FCPXMLWriter``, in :mod:`.generator`):
Build a new FCPXML document from Python dataclass objects (``Project``,
``Timeline``, ``Clip``, ``Marker``). Useful for creating rough cuts,
montage exports, and template-based projects.
**Modification** (``FCPXMLModifier``, in :mod:`.modifier`):
Load an existing FCPXML file, apply surgical edits (markers, trims,
reorders, transitions, speed changes, silence removal, etc.), and save.
This is the primary API used by the MCP server's tool handlers.
Layout
------
This was one 4.200-line module. It is now one module per subject, because the
subjects barely touch each other: whoever is fixing a zoom ramp has no reason
to scroll past subtitle layout to find it.
helpers sanitising, scales, shared element builders
document asset creation, timebases, serialisation (``write_fcpxml``)
validation structural checks (``validate_fcpxml``)
core ``ModifierCore``: load, indices, spine navigation, ``save``
<subject> one mixin per editing subject (markers, trim, speed, …)
modifier ``FCPXMLModifier`` = core + every mixin
generator ``FCPXMLWriter``
api one-line convenience wrappers
Everything the rest of the project imported from the old module is re-exported
here, so ``from fcpxml.writer import FCPXMLModifier`` keeps working unchanged —
including the underscore-prefixed helpers the test suite reaches for.
Architecture notes
------------------
- All time arithmetic uses ``TimeValue`` (rational fractions) — never floats —
to match FCPXML's native ``"600/2400s"`` format and avoid rounding drift.
- The ``FCPXMLModifier`` builds three in-memory indices at init
(``clips``, ``resources``, ``formats``) so lookups are O(1) by ID/name.
- Spine-based editing: clips live inside a ``<spine>`` element (the primary
storyline). Connected clips attach via ``lane`` attributes on spine clips.
Most editing methods find the target clip in the spine, mutate it, then
ripple offsets on subsequent siblings.
- ``write_fcpxml()`` handles DTD-compliant serialisation and optional
timebase enforcement for all output paths.
"""
from ..models import TimeValue
from .api import add_marker_to_file, modify_fcpxml, trim_clip_in_file
from .core import ModifierCore
from .document import (
_STILL_IMAGE_EXTENSIONS,
_enforce_standard_timebases,
_ensure_video_asset,
write_fcpxml,
)
from .generator import FCPXMLWriter
from .helpers import (
_ASSET_CLIP_CHILD_ORDER,
_CHILD_ORDER_INDEX,
_MAX_MARKER_NAME_LENGTH,
_MAX_NOTE_LENGTH,
CLIP_AND_AUDIO_TAGS,
CLIP_TAGS,
FCP_EFFECTS,
HOLD_AT_CUT_THRESHOLD,
SPINE_ELEMENT_TAGS,
START_AT_CUT_THRESHOLD,
_create_asset_element,
_dtd_insert,
_fmt_scale,
_probe_audio_info,
_sanitize_xml_value,
build_marker_element,
list_effects,
)
from .modifier import FCPXMLModifier
from .validation import (
_check_asset_sources,
_check_child_order,
_check_effect_refs,
_check_frame_alignment,
_check_required_attributes,
_check_timebases,
_document_frame_duration,
validate_fcpxml,
)
__all__ = [
"FCPXMLModifier",
"FCPXMLWriter",
"ModifierCore",
"TimeValue",
"FCP_EFFECTS",
"CLIP_TAGS",
"CLIP_AND_AUDIO_TAGS",
"SPINE_ELEMENT_TAGS",
"HOLD_AT_CUT_THRESHOLD",
"START_AT_CUT_THRESHOLD",
"add_marker_to_file",
"build_marker_element",
"list_effects",
"modify_fcpxml",
"trim_clip_in_file",
"validate_fcpxml",
"write_fcpxml",
# Internos que o resto do projeto (e a suíte) já importava deste módulo
# quando ele era um arquivo só. Ficam aqui para a divisão não virar uma
# quebra de API disfarçada de reorganização.
"_ASSET_CLIP_CHILD_ORDER",
"_CHILD_ORDER_INDEX",
"_MAX_MARKER_NAME_LENGTH",
"_MAX_NOTE_LENGTH",
"_STILL_IMAGE_EXTENSIONS",
"_check_asset_sources",
"_check_child_order",
"_check_effect_refs",
"_check_frame_alignment",
"_check_required_attributes",
"_check_timebases",
"_create_asset_element",
"_document_frame_duration",
"_dtd_insert",
"_enforce_standard_timebases",
"_ensure_video_asset",
"_fmt_scale",
"_probe_audio_info",
"_sanitize_xml_value",
]
+55
View File
@@ -0,0 +1,55 @@
"""Atalhos de uma linha para as operações mais comuns.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
from typing import Optional
from ..models import (
MarkerType,
)
from .modifier import FCPXMLModifier
# ============================================================================
# CONVENIENCE FUNCTIONS
# ============================================================================
def modify_fcpxml(filepath: str) -> FCPXMLModifier:
"""
Open an FCPXML file for modification.
Usage:
modifier = modify_fcpxml("project.fcpxml")
modifier.add_marker(...)
modifier.save("output.fcpxml")
"""
return FCPXMLModifier(filepath)
def add_marker_to_file(
filepath: str,
timecode: str,
name: str,
marker_type: str = "standard",
output_path: Optional[str] = None
) -> str:
"""Convenience function to add a marker to an FCPXML file."""
modifier = FCPXMLModifier(filepath)
modifier.add_marker_at_timeline(
timecode, name,
MarkerType.from_string(marker_type)
)
return modifier.save(output_path)
def trim_clip_in_file(
filepath: str,
clip_id: str,
trim_start: Optional[str] = None,
trim_end: Optional[str] = None,
output_path: Optional[str] = None
) -> str:
"""Convenience function to trim a clip in an FCPXML file."""
modifier = FCPXMLModifier(filepath)
modifier.trim_clip(clip_id, trim_start, trim_end)
return modifier.save(output_path)
+162
View File
@@ -0,0 +1,162 @@
"""Clipes de áudio e cama musical.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional
from ..models import (
TimeValue,
)
from .helpers import _create_asset_element, _dtd_insert, _probe_audio_info, _sanitize_xml_value
class AudioMixin:
"""Clipes de áudio e cama musical."""
# AUDIO CLIP OPERATIONS (v0.6.0)
# ========================================================================
def add_audio_clip(
self,
parent_clip_id: str,
asset_id: Optional[str] = None,
offset: str = "0s",
duration: Optional[str] = None,
role: str = "dialogue",
lane: int = -1,
src: Optional[str] = None,
) -> ET.Element:
"""Add an audio clip connected to an existing timeline clip.
Creates an <asset-clip> at a negative lane with audioRole attribute.
Supports hierarchical roles like "dialogue.boom", "music.score",
"effects.foley".
Args:
parent_clip_id: Name/ID of the clip to attach audio to.
asset_id: Existing asset reference ID. If None and src provided,
creates a new asset.
offset: Position relative to parent clip start.
duration: Duration of audio clip.
role: Audio role (e.g. "dialogue", "music.score", "effects.foley").
lane: Lane number (negative = below primary, default -1).
src: Path to audio file. Used to create a new asset if asset_id
is not provided.
Returns:
The created audio clip element.
"""
parent = self._require_clip(parent_clip_id)
# Resolve or create asset
if asset_id and asset_id in self.resources:
asset = self.resources[asset_id]
elif src:
# Create new asset in resources
resources = self.root.find('.//resources')
if resources is None:
raise ValueError("No <resources> element found in FCPXML")
asset_id = self._unique_resource_id(resources, 'r_audio1')
# The asset duration must reflect the real media length, not the
# requested clip duration — FCP flags assets that claim more
# media than the file contains.
probed = _probe_audio_info(src)
if probed:
rate = probed['sample_rate']
asset_duration = f"{round(probed['duration'] * rate)}/{rate}s"
else:
asset_duration = duration or "0s"
asset_elem = _create_asset_element(
resources, asset_id, Path(src).stem, src,
duration=asset_duration,
has_video="0", has_audio="1",
)
if probed:
asset_elem.set('audioSources', '1')
asset_elem.set('audioChannels', str(probed['channels']))
asset_elem.set('audioRate', str(probed['sample_rate']))
asset = {
'id': asset_id,
'name': Path(src).stem,
'duration': asset_duration,
'element': asset_elem,
}
self.resources[asset_id] = asset
else:
raise ValueError("Must provide either asset_id or src for audio clip")
clip_duration, source_start = self._resolve_clip_duration(asset, duration)
# Clamp so the clip never claims more media than the asset contains
asset_duration_tv = self._parse_time(asset.get('duration', '0s'))
if asset_duration_tv > TimeValue.zero():
available = asset_duration_tv - source_start
if available < TimeValue.zero():
raise ValueError(
f"Source start {source_start.to_fcpxml()} is beyond the end "
f"of audio asset '{asset.get('name')}' "
f"({asset_duration_tv.to_fcpxml()})"
)
if clip_duration > available:
clip_duration = available
new_clip = self._make_asset_clip(
asset_id, asset.get('name', 'Audio'),
self._parse_time(offset), source_start, clip_duration,
lane=str(lane),
audioRole=_sanitize_xml_value(role, 256),
)
_dtd_insert(parent, new_clip)
return new_clip
def add_music_bed(
self,
asset_id: Optional[str] = None,
duration: Optional[str] = None,
role: str = "music",
src: Optional[str] = None,
) -> ET.Element:
"""Add a music bed spanning the full timeline at lane -1.
Convenience method: attaches to the first spine clip and spans
the full timeline duration.
Args:
asset_id: Existing asset reference ID.
duration: Override duration (default: full timeline).
role: Audio role (default "music").
src: Path to audio file (creates asset if asset_id not given).
Returns:
The created music bed clip element.
"""
spine = self._get_spine()
first_clip = None
first_clip_id = None
for clip_id, clip in self.clips.items():
if clip in list(spine):
first_clip = clip
first_clip_id = clip_id
break
if first_clip is None:
raise ValueError("No clips in spine to attach music bed to")
# Calculate full timeline duration if not specified
if not duration:
duration = self._timeline_duration().to_fcpxml()
return self.add_audio_clip(
parent_clip_id=first_clip_id,
asset_id=asset_id,
offset="0s",
duration=duration,
role=role,
lane=-1,
src=src,
)
# ========================================================================
+196
View File
@@ -0,0 +1,196 @@
"""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 (
_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 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
# ========================================================================
+49
View File
@@ -0,0 +1,49 @@
"""Clipes conectados (lanes acima/abaixo da spine).
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import xml.etree.ElementTree as ET
from typing import Optional
class ConnectedMixin:
"""Clipes conectados (lanes acima/abaixo da spine)."""
# CONNECTED CLIP OPERATIONS (v0.5.0)
# ========================================================================
def add_connected_clip(
self,
parent_clip_id: str,
asset_id: Optional[str] = None,
asset_name: Optional[str] = None,
offset: str = "0s",
duration: Optional[str] = None,
lane: int = 1,
) -> ET.Element:
"""Add a connected clip (B-roll, title, audio) to an existing timeline clip.
Args:
parent_clip_id: Name/ID of the clip to attach to
asset_id: Asset reference ID
asset_name: Asset name (alternative to asset_id)
offset: Position relative to parent clip start
duration: Duration of connected clip (default: full asset)
lane: Lane number (positive=above, negative=below)
Returns:
The created connected clip element
"""
parent = self._require_clip(parent_clip_id)
asset, asset_id = self._resolve_asset(asset_id, asset_name)
clip_duration, source_start = self._resolve_clip_duration(asset, duration)
new_clip = self._make_asset_clip(
asset_id, asset.get('name', 'Untitled'),
self._parse_time(offset), source_start, clip_duration,
parent=parent, lane=str(lane),
)
return new_clip
# ========================================================================
+723
View File
@@ -0,0 +1,723 @@
"""Núcleo do FCPXMLModifier: carga, índices, navegação na spine e save.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import xml.etree.ElementTree as ET
from fractions import Fraction
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from ..models import (
TimeValue,
)
from .document import write_fcpxml
from .helpers import CLIP_TAGS
class ModifierCore:
"""Load an existing FCPXML file, apply edits, and save.
This is the primary editing interface used by every MCP server write-tool
handler. It wraps an ElementTree parsed from disk and maintains three
in-memory indices so that clip/asset lookups are fast.
Index design
------------
``clips`` : ``Dict[str, ET.Element]``
Every ``<clip>``, ``<asset-clip>``, and ``<video>`` element keyed by
its ``id`` attribute, falling back to ``name``, then a generated key.
**Gotcha**: duplicate clip names (e.g. multiple "Interview_A") mean
only the *last* element indexed under that name is accessible. Use
unique ``id`` attributes when possible.
``resources`` : ``Dict[str, Dict[str, Any]]``
Every ``<asset>`` element keyed by ``id``, with pre-extracted ``name``,
``src``, ``start``, ``duration``, and a reference to the raw element.
``formats`` : ``Dict[str, Dict[str, Any]]``
Every ``<format>`` element keyed by ``id``.
Editing model
-------------
1. Look up the target clip via ``_require_clip`` / ``_require_spine_clip``.
2. Mutate the clip's XML attributes (``start``, ``duration``, ``offset``).
3. If the edit changes duration, ripple subsequent spine siblings via
``_ripple_from_index`` so downstream offsets stay contiguous.
4. Call ``save()`` to serialise the modified tree back to disk.
Example::
modifier = FCPXMLModifier("project.fcpxml")
modifier.add_marker("clip_0", "00:00:10:00", "Review", MarkerType.INCOMPLETE)
modifier.trim_clip("clip_1", trim_end="-2s")
modifier.save("project_modified.fcpxml")
Attributes:
path (Path): Filesystem path to the source FCPXML file.
tree (ET.ElementTree): Parsed XML tree (mutated in-place by edits).
root (ET.Element): Root ``<fcpxml>`` element.
fps (float): Detected frame rate from the first ``<format>`` resource.
clips (Dict[str, ET.Element]): Clip index — see *Index design* above.
resources (Dict[str, Dict]): Asset index.
formats (Dict[str, Dict]): Format index.
"""
def __init__(self, fcpxml_path: str):
"""Load *fcpxml_path*, parse its XML, and build lookup indices.
The constructor eagerly builds all three indices (clips, resources,
formats) and detects the project frame rate. After construction the
modifier is ready for any editing operation.
Args:
fcpxml_path: Absolute or relative path to an ``.fcpxml`` file or
an ``.fcpxmld`` bundle (a directory wrapping ``Info.fcpxml``
plus sidecar data files for object tracking / Cinematic mode).
Raises:
FileNotFoundError: If *fcpxml_path* does not exist.
ET.ParseError: If the file is not valid XML.
ValueError: If no ``<spine>`` is found (checked lazily on first edit).
"""
path = Path(fcpxml_path)
self.bundle_dir: Optional[Path] = None
if path.suffix.lower() == '.fcpxmld':
self.bundle_dir = path
inner = path / 'Info.fcpxml'
if not inner.exists():
raise FileNotFoundError(
f"Info.fcpxml not found in bundle: {fcpxml_path}"
)
fcpxml_path = str(inner)
self.path = Path(fcpxml_path)
from ..safe_xml import safe_parse
self.tree = safe_parse(fcpxml_path)
self.root = self.tree.getroot()
self.fps = self._detect_fps()
# Lazily filled on the first generated title; see _unique_text_style_id.
self._text_style_ids: Optional[set] = None
self._build_resource_index()
self._build_clip_index()
def _detect_fps(self) -> float:
"""Extract frame rate from format resource."""
for fmt in self.root.findall('.//format'):
frame_dur = fmt.get('frameDuration', '1/30s')
if '/' in frame_dur:
parts = frame_dur.replace('s', '').split('/', 1)
num, denom = int(parts[0]), int(parts[1])
if num <= 0:
return 30.0
return denom / num
return 30.0
def frame_duration_fraction(self):
"""Exact ``frameDuration`` as a Fraction (e.g. 1001/24000 at 23.976fps).
Unlike ``_detect_fps()`` (a float, lossy for NTSC rates), this is
exact — use it wherever a cut boundary is snapped to the frame grid,
so 23.976/29.97/59.94 timebases don't drift off-grid the way a
hardcoded tick base like 2400 does.
"""
for fmt in self.root.findall('.//format'):
raw = fmt.get('frameDuration', '')
if raw.endswith('s') and '/' in raw:
n, d = raw[:-1].split('/', 1)
fd = Fraction(int(n), int(d))
if fd > 0:
return fd
return Fraction(1, 30)
def frame_size(self) -> 'Tuple[float, float]':
"""The sequence's frame size in pixels, as ``(width, height)``.
Reads the sequence's own ``<format>`` when it references one, since a
document may carry several (an asset's source format need not match
the timeline's). Falls back to the first format that declares a size,
then to 1920x1080.
"""
formats = {f.get('id'): f for f in self.root.findall('.//format')}
candidates = []
seq = self.root.find('.//sequence')
if seq is not None and formats.get(seq.get('format')) is not None:
candidates.append(formats[seq.get('format')])
candidates.extend(formats.values())
for fmt in candidates:
try:
width = float(fmt.get('width') or 0)
height = float(fmt.get('height') or 0)
except (TypeError, ValueError):
continue
if width > 0 and height > 0:
return width, height
return 1920.0, 1080.0
def frame_width(self) -> float:
"""The sequence's frame width in pixels."""
return self.frame_size()[0]
def frame_height(self) -> float:
"""The sequence's frame height in pixels."""
return self.frame_size()[1]
def snap_seconds_to_frame(self, seconds: float) -> 'TimeValue':
"""Round *seconds* to the nearest exact frame boundary as a TimeValue."""
fd = self.frame_duration_fraction()
frames = round(seconds / float(fd))
snapped = fd * frames
return TimeValue(snapped.numerator, snapped.denominator)
def snap_spine_times_to_frames(self) -> None:
"""Snap primary-storyline offsets and durations to sequence frames.
Final Cut rejects otherwise valid XML when ripple edits leave a clip
boundary between frames. Use the exact ``frameDuration`` fraction,
rather than a float FPS, to preserve 23.976/29.97 timebases.
"""
frame_duration = None
for fmt in self.root.findall('.//format'):
raw = fmt.get('frameDuration', '')
if raw.endswith('s') and '/' in raw:
n, d = raw[:-1].split('/', 1)
frame_duration = Fraction(int(n), int(d))
break
if frame_duration is None or frame_duration <= 0:
return
for element in self.root.findall('.//spine/*'):
for attr in ('offset', 'duration'):
raw = element.get(attr)
if not raw or not raw.endswith('s'):
continue
value = raw[:-1]
if '/' in value:
n, d = value.split('/', 1)
seconds = Fraction(int(n), int(d))
else:
seconds = Fraction(value)
frames = int(round(float(seconds / frame_duration)))
snapped = frame_duration * frames
element.set(attr, f'{snapped.numerator}/{snapped.denominator}s')
def _build_resource_index(self) -> None:
"""Build ``self.resources`` and ``self.formats`` from ``<asset>``/``<format>`` elements.
Called once during ``__init__``. Each asset entry stores the raw
element plus pre-extracted metadata so callers don't need to
re-parse attributes on every access.
"""
self.resources: Dict[str, Dict[str, Any]] = {}
self.formats: Dict[str, Dict[str, Any]] = {}
for asset in self.root.findall('.//asset'):
asset_id = asset.get('id', '')
self.resources[asset_id] = {
'id': asset_id,
'name': asset.get('name', ''),
'src': asset.get('src', '') or (asset.find('media-rep').get('src', '') if asset.find('media-rep') is not None else ''),
'start': asset.get('start', '0s'),
'duration': asset.get('duration', '0s'),
'element': asset
}
for fmt in self.root.findall('.//format'):
fmt_id = fmt.get('id', '')
self.formats[fmt_id] = {
'id': fmt_id,
'name': fmt.get('name', ''),
'element': fmt
}
def _index_elements(self, tag: str, fallback_prefix: str) -> None:
"""Index XML elements of *tag* into ``self.clips`` by id/name.
Each element is keyed by its ``id`` attribute, falling back to
``name``, then a generated ``{fallback_prefix}_{i}`` key. This
replaces three near-identical loops that only differed in the tag
name and fallback prefix.
"""
for i, elem in enumerate(self.root.findall(f'.//{tag}')):
key = elem.get('id') or elem.get('name') or f"{fallback_prefix}_{i}"
self.clips[key] = elem
def _build_clip_index(self) -> None:
"""Build ``self.clips`` index from all clip-type elements.
Indexes ``<clip>``, ``<asset-clip>``, and ``<video>`` tags. Keys are
resolved by ``_index_elements`` (``id`` → ``name`` → generated).
.. warning::
Duplicate names cause last-one-wins overwrites. If your project
has multiple clips named "Interview_A", only the last one parsed
will be reachable by name. Prefer unique ``id`` attributes.
"""
self.clips: Dict[str, ET.Element] = {}
for tag, prefix in (('clip', 'clip'), ('asset-clip', 'asset_clip'), ('video', 'video')):
self._index_elements(tag, prefix)
def _get_spine(self) -> ET.Element:
"""Get the primary storyline spine.
Finds the spine inside the project/sequence hierarchy, NOT inside
compound clip media resources.
"""
# Prefer the main timeline spine (under project/sequence)
spine = self.root.find('.//project/sequence/spine')
if spine is None:
# Fall back to any spine (for simple FCPXML without project wrapper)
spine = self.root.find('.//spine')
if spine is None:
raise ValueError("No spine found in FCPXML")
return spine
def _iter_spine_clips(self) -> list[tuple[int, ET.Element]]:
"""Return an indexed list of clip-type elements in the primary spine.
Filters out gaps, transitions, and other non-clip elements, returning
only ``(index_in_spine, element)`` pairs where the tag is in
``CLIP_TAGS``. The index is the element's position among *all* spine
children (not just clips), so it stays valid for insertion/removal.
"""
spine = self._get_spine()
return [
(i, child)
for i, child in enumerate(spine.findall('*'))
if child.tag in CLIP_TAGS
]
def _find_spine_clip_at_seconds(self, target_seconds: float) -> tuple[ET.Element, float]:
"""Find the spine clip containing *target_seconds* and return it with the relative offset.
Returns:
``(clip_element, relative_seconds)`` — the clip and the time
within that clip corresponding to *target_seconds*.
Raises:
ValueError: If no clip spans the requested position.
"""
spine = self._get_spine()
for child in spine.findall('*'):
if child.tag not in CLIP_TAGS:
continue
offset = self._parse_time(child.get('offset', '0s')).to_seconds()
dur = self._parse_time(child.get('duration', '0s')).to_seconds()
if offset <= target_seconds < offset + dur:
return child, target_seconds - offset
raise ValueError(f"No spine clip at position {target_seconds:.3f}s")
def _parse_time(self, tc: str) -> TimeValue:
"""Parse a timecode string to TimeValue."""
return TimeValue.from_timecode(tc, self.fps)
def _get_clip_times(
self, clip: ET.Element
) -> tuple:
"""Return (start, duration, offset) TimeValues for a clip element."""
return (
self._parse_time(clip.get('start', '0s')),
self._parse_time(clip.get('duration', '0s')),
self._parse_time(clip.get('offset', '0s')),
)
def source_file_start(self, clip: ET.Element) -> 'TimeValue':
"""Return a clip's in-point measured from the head of its media file.
FCPXML ``start`` on an asset-clip is a source *timecode*, and the
asset's own ``start`` is the timecode of the source media's first
frame. Media analysis (ffmpeg silencedetect, Whisper) reports
file-relative time, so subtract the asset's start timecode to land
both on the same origin. When the asset starts at 0s (the common
case, and every test fixture) this is a no-op.
"""
ref = clip.get('ref', '')
asset = self.resources.get(ref, {})
asset_start = self._parse_time(asset.get('start', '0s'))
clip_start = self._parse_time(clip.get('start', '0s'))
return clip_start - asset_start
def _resolve_clip_duration(
self,
asset: dict,
duration: Optional[str] = None,
in_point: Optional[str] = None,
out_point: Optional[str] = None,
) -> tuple['TimeValue', 'TimeValue']:
"""Compute clip duration and source start from optional overrides.
Centralises the three-way fallback logic shared by insert_clip,
add_connected_clip, and add_audio_clip:
1. If *in_point* and *out_point* are given → subclip range.
2. Else if *duration* is given → explicit duration, source start = 0.
3. Else → full asset duration, source start = 0.
Returns:
``(clip_duration, source_start)`` TimeValue pair.
"""
if in_point and out_point:
in_time = self._parse_time(in_point)
out_time = self._parse_time(out_point)
return out_time - in_time, in_time
if duration:
return self._parse_time(duration), TimeValue.zero()
return self._parse_time(asset.get('duration', '0s')), TimeValue.zero()
def _make_asset_clip(
self,
asset_id: str,
name: str,
offset: 'TimeValue',
start: 'TimeValue',
duration: 'TimeValue',
*,
parent: Optional[ET.Element] = None,
**extra_attrs: str,
) -> ET.Element:
"""Build an ``<asset-clip>`` element with standard attributes.
Centralises the repeated element creation shared by insert_clip,
add_connected_clip, and add_audio_clip. Each caller can pass
additional attributes (``lane``, ``audioRole``, ``format``) via
*extra_attrs*.
Args:
asset_id: Resource reference (e.g. ``'r3'``).
name: Human-readable clip name.
offset: Timeline offset (or offset within parent for connected clips).
start: Source media start point.
duration: Clip duration.
parent: If given, create the element as a SubElement of *parent*;
otherwise create a detached Element.
**extra_attrs: Additional XML attributes (``lane``, ``audioRole``).
Returns:
The new ``<asset-clip>`` Element.
"""
if parent is not None:
elem = ET.SubElement(parent, 'asset-clip')
else:
elem = ET.Element('asset-clip')
elem.set('ref', asset_id)
elem.set('offset', offset.to_fcpxml())
elem.set('name', name)
elem.set('start', start.to_fcpxml())
elem.set('duration', duration.to_fcpxml())
for attr, val in extra_attrs.items():
elem.set(attr, val)
return elem
def _require_clip(self, clip_id: 'str | ET.Element') -> ET.Element:
"""Look up a clip by ID/name, raising if not found.
Centralises the get-or-raise pattern used by every clip-mutating
method so the error message stays consistent and future
enhancements (fuzzy matching, suggestions) only need one site.
An Element is returned as-is. That matters after ``split_clip`` or
``cut_clip_ranges``: the resulting pieces all carry the *same* name,
so a name lookup would always resolve to the first one and silently
put the edit on the wrong piece. Callers holding the exact element
pass it directly.
"""
if isinstance(clip_id, ET.Element):
return clip_id
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
return clip
def _require_spine_clip(self, clip_id: str) -> tuple[ET.Element, ET.Element, int]:
"""Look up a clip and verify it lives in the primary spine.
Returns:
``(spine, clip, index_in_spine)`` tuple.
Raises:
ValueError: If the clip doesn't exist or isn't in the spine.
"""
clip = self._require_clip(clip_id)
spine = self._get_spine()
clip_index = self._find_clip_index(spine, clip)
if clip_index is None:
raise ValueError(f"Clip not in spine: {clip_id}")
return spine, clip, clip_index
def _find_clip_index(self, spine: ET.Element, clip: ET.Element) -> int | None:
"""Find the index of a clip in the spine. Returns None if not found."""
for i, child in enumerate(spine):
if child == clip:
return i
return None
@staticmethod
def _find_neighbor_clip(
spine_list: list, index: int, direction: str
) -> Optional[ET.Element]:
"""Find the nearest non-gap clip before or after *index* in *spine_list*.
Args:
spine_list: Materialised list of spine children.
index: Position to search from (exclusive).
direction: ``'prev'`` to search backward, ``'next'`` to search forward.
Returns:
The first clip-type element found, or ``None``.
"""
if direction == 'prev':
for j in range(index - 1, -1, -1):
if spine_list[j].tag in CLIP_TAGS:
return spine_list[j]
else:
for j in range(index + 1, len(spine_list)):
if spine_list[j].tag in CLIP_TAGS:
return spine_list[j]
return None
def _resolve_asset(
self, asset_id: Optional[str], asset_name: Optional[str]
) -> tuple:
"""Look up an asset by ID or name from ``self.resources``.
Returns:
``(asset_dict, resolved_asset_id)`` tuple.
Raises:
ValueError: If neither ID nor name matches a known asset.
"""
if asset_id and asset_id in self.resources:
return self.resources[asset_id], asset_id
if asset_name:
for res_id, res_data in self.resources.items():
if res_data.get('name') == asset_name:
return res_data, res_id
raise ValueError(f"Asset not found: {asset_id or asset_name}")
@staticmethod
def _unique_resource_id(resources: ET.Element, prefix: str) -> str:
"""Generate a unique resource ID with the given *prefix*.
Starts with ``prefix`` (e.g. ``'r_audio1'``), appending an
incrementing counter until no collision exists in *resources*.
"""
existing_ids = {el.get('id', '') for el in resources}
candidate = prefix
counter = 2
while candidate in existing_ids:
# Strip trailing digits from prefix for the counter suffix
base = prefix.rstrip('0123456789')
candidate = f'{base}{counter}'
counter += 1
return candidate
def _find_spine_element_at_timecode(
self, spine: ET.Element, target_tc: str, *, require_clip: bool = False
) -> Optional[ET.Element]:
"""Find the first spine child whose offset matches *target_tc*.
Normalises both sides through ``TimeValue`` round-trip so format
differences (e.g. ``"3600/2400s"`` vs ``"1800/1200s"``) don't
cause false negatives.
Args:
spine: The ``<spine>`` element to search.
target_tc: Timecode string to match against each child's offset.
require_clip: If True, skip non-clip elements (gaps, etc.).
"""
for child in spine:
offset_str = child.get('offset', '0s')
tc = TimeValue.from_timecode(offset_str, self.fps).to_timecode(self.fps)
if tc == target_tc:
if require_clip and child.tag not in CLIP_TAGS:
continue
return child
return None
def _absorb_into_neighbor(
self,
spine: ET.Element,
element: ET.Element,
direction: str,
) -> Optional[ET.Element]:
"""Extend a neighbor clip to absorb *element*'s duration, then remove *element*.
Shared by ``fix_flash_frames`` (absorbing flash-frame clips) and
``fill_gaps`` (absorbing gap elements). Both operations find the
nearest clip in *direction*, grow it by the absorbed element's
duration, and remove the absorbed element from the spine.
When extending backward (``direction='next'``), the neighbor's
source in-point is also pulled earlier so the extra frames come
from before the original cut, not after.
Does **not** call ``_recalculate_offsets`` — callers decide when to
recalculate (per-iteration vs. once at the end).
Args:
spine: The primary storyline ``<spine>`` element.
element: The clip or gap to absorb (will be removed).
direction: ``'prev'`` to extend the previous clip forward,
``'next'`` to extend the next clip backward.
Returns:
The neighbor clip that absorbed the duration, or ``None`` if
no suitable neighbor exists.
"""
spine_list = list(spine)
element_index = spine_list.index(element)
neighbor = self._find_neighbor_clip(spine_list, element_index, direction)
if neighbor is None:
return None
absorbed_dur = self._parse_time(element.get('duration', '0s'))
neighbor_dur = self._parse_time(neighbor.get('duration', '0s'))
if direction == 'next':
neighbor_start = self._parse_time(neighbor.get('start', '0s'))
new_start = neighbor_start - absorbed_dur
if new_start >= TimeValue.zero():
neighbor.set('start', new_start.to_fcpxml())
neighbor.set('duration', (neighbor_dur + absorbed_dur).to_fcpxml())
else:
# Can't shift start negative — only extend by what's available
available = neighbor_start
neighbor.set('start', TimeValue(0, 1).to_fcpxml())
neighbor.set('duration', (neighbor_dur + available).to_fcpxml())
else:
neighbor.set('duration', (neighbor_dur + absorbed_dur).to_fcpxml())
spine.remove(element)
return neighbor
def _resolve_insert_position(
self, position: str, spine_children: list
) -> tuple:
"""Translate a human-friendly position spec into (target_offset, insert_index).
Supported formats:
``'start'`` — beginning of spine
``'end'`` — after last element
``'after:clip_id'`` — after the named clip
``'before:clip_id'``— before the named clip
*timecode* — absolute timeline position
Returns:
``(TimeValue, int)`` — the offset and child-index for spine insertion.
"""
if position == 'start':
return TimeValue.zero(), 0
if position == 'end':
if spine_children:
last = spine_children[-1]
last_offset = self._parse_time(last.get('offset', '0s'))
last_dur = self._parse_time(last.get('duration', '0s'))
return last_offset + last_dur, len(spine_children)
return TimeValue.zero(), len(spine_children)
if position.startswith('after:') or position.startswith('before:'):
is_after = position.startswith('after:')
ref_id = position.split(':', 1)[1]
ref_clip = self.clips.get(ref_id)
if ref_clip is None or ref_clip not in spine_children:
raise ValueError(f"Reference clip not found: {ref_id}")
idx = spine_children.index(ref_clip)
ref_offset = self._parse_time(ref_clip.get('offset', '0s'))
if is_after:
ref_dur = self._parse_time(ref_clip.get('duration', '0s'))
return ref_offset + ref_dur, idx + 1
return ref_offset, idx
# Assume timecode
target_offset = self._parse_time(position)
insert_index = 0
for i, child in enumerate(spine_children):
child_offset = self._parse_time(child.get('offset', '0s'))
if child_offset >= target_offset:
insert_index = i
break
insert_index = i + 1
return target_offset, insert_index
def _make_transition_element(
self,
effect_name: str,
trans_offset: 'TimeValue',
trans_duration: 'TimeValue',
effect_ref_id: str | None,
) -> ET.Element:
"""Build a <transition> element with optional filter-video child."""
transition = ET.Element('transition')
transition.set('name', effect_name)
transition.set('offset', trans_offset.to_fcpxml())
transition.set('duration', trans_duration.to_fcpxml())
if effect_ref_id:
fv = ET.SubElement(transition, 'filter-video')
fv.set('ref', effect_ref_id)
fv.set('name', effect_name)
return transition
def save(self, output_path: Optional[str] = None) -> str:
"""Serialise the modified XML tree to disk.
When the destination ends in ``.fcpxmld`` a bundle directory is
created and the XML lands in ``Info.fcpxml`` inside it. If the
source was also a bundle, every sidecar file (object-tracking /
Cinematic-mode ``dataLocator`` payloads — anything that isn't
``Info.fcpxml``) is copied across so the round-trip is lossless.
Writing a bundle source to a flat ``.fcpxml`` destination drops
those sidecars by definition.
Args:
output_path: Destination ``.fcpxml`` file or ``.fcpxmld``
bundle path. Defaults to overwriting the original
file/bundle loaded in ``__init__``.
Returns:
The absolute path written to (the bundle path when writing
a bundle, not the inner ``Info.fcpxml``).
"""
if output_path is None:
out = self.bundle_dir if self.bundle_dir is not None else self.path
else:
out = Path(output_path)
# Every write path goes through here, so snapping here (rather than
# in each handler) guarantees ripple edits never leave a spine clip
# off the frame grid — see snap_spine_times_to_frames() docstring.
# No-op (each value already equals its own snapped form) on content
# that was already frame-aligned.
self.snap_spine_times_to_frames()
if out.suffix.lower() == '.fcpxmld':
out.mkdir(exist_ok=True)
if (
self.bundle_dir is not None
and self.bundle_dir.resolve() != out.resolve()
):
self._copy_bundle_sidecars(self.bundle_dir, out)
write_fcpxml(self.root, str(out / 'Info.fcpxml'), fps=self.fps)
return str(out)
return write_fcpxml(self.root, str(out), fps=self.fps)
@staticmethod
def _copy_bundle_sidecars(src_bundle: Path, dst_bundle: Path) -> None:
"""Copy every sidecar entry of *src_bundle* into *dst_bundle*.
Sidecars are all bundle members except ``Info.fcpxml`` itself —
e.g. the external data files that ``locator``/``dataLocator``
elements reference for object tracking and Cinematic mode.
"""
import shutil
for entry in src_bundle.iterdir():
if entry.name == 'Info.fcpxml':
continue
target = dst_bundle / entry.name
if entry.is_dir():
shutil.copytree(entry, target, dirs_exist_ok=True)
else:
shutil.copy2(entry, target)
+333
View File
@@ -0,0 +1,333 @@
"""Dividir, cortar faixas e apagar clipes.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import copy
import xml.etree.ElementTree as ET
from typing import List, Tuple
from ..models import (
TimeValue,
)
class CutMixin:
"""Dividir, cortar faixas e apagar clipes."""
# SPLIT & DELETE OPERATIONS
# ========================================================================
@staticmethod
def _filter_children_for_segment(
clip: ET.Element,
seg_start: 'TimeValue',
seg_duration: 'TimeValue',
) -> None:
"""Remove markers/keywords/titles from *clip* that fall outside the segment range.
After ``split_clip`` deepcopy's the original clip into each segment, every
segment inherits all child elements. Markers whose ``start`` falls outside
``[seg_start, seg_start + seg_duration)`` are phantom duplicates and must be
removed. Keywords that partially overlap get their ``start``/``duration``
clamped to the segment boundaries.
A lane-nested ``<title>`` (a "text" voice action's on-screen callout,
or a caption from an earlier `generate_dynamic_subtitles` pass) is
the same kind of phantom duplicate, just keyed on ``offset`` instead
of ``start`` — its offset lives in the same source-media coordinate
space as a marker's ``start`` (see ``add_text_title``/``add_marker``,
both anchored at ``parent.start``). Left unfiltered, every further
cut (silence removal, filler removal) duplicates it into every
resulting piece, so the same word shows up several times across the
edited timeline instead of once where it was placed.
"""
seg_end = seg_start + seg_duration
to_remove = []
for child in clip:
tag = child.tag
if tag in ('marker', 'chapter-marker'):
child_start = TimeValue.from_timecode(child.get('start', '0s'))
if child_start < seg_start or child_start >= seg_end:
to_remove.append(child)
elif tag == 'title':
title_offset = TimeValue.from_timecode(child.get('offset', '0s'))
if title_offset < seg_start or title_offset >= seg_end:
to_remove.append(child)
elif tag == 'keyword':
kw_start = TimeValue.from_timecode(child.get('start', '0s'))
kw_dur = TimeValue.from_timecode(child.get('duration', '0s'))
kw_end = kw_start + kw_dur
# Completely outside segment → remove
if kw_end <= seg_start or kw_start >= seg_end:
to_remove.append(child)
else:
# Clamp keyword range to segment boundaries
clamped_start = max(kw_start, seg_start)
clamped_end = min(kw_end, seg_end)
child.set('start', clamped_start.to_fcpxml())
child.set('duration', (clamped_end - clamped_start).to_fcpxml())
for child in to_remove:
clip.remove(child)
def split_clip(
self,
clip_id: str,
split_points: List[str]
) -> List[ET.Element]:
"""
Split a clip at specified timecodes.
Args:
clip_id: Clip to split
split_points: Timecodes within the clip to split at
Returns:
List of resulting clip elements
"""
spine, clip, clip_index = self._require_spine_clip(clip_id)
# Get clip properties
clip_start, clip_duration, clip_offset = self._get_clip_times(clip)
clip_name = clip.get('name', 'Clip')
# Sort split points
split_times = sorted([self._parse_time(sp) for sp in split_points])
# Remove original clip
spine.remove(clip)
# Create new clips
new_clips = []
current_offset = clip_offset
current_start = clip_start
all_points = split_times + [clip_duration]
for i, split_time in enumerate(all_points):
if i == 0:
segment_duration = split_time
else:
segment_duration = split_time - split_times[i - 1]
if segment_duration <= TimeValue.zero():
continue
# Create new clip
new_clip = copy.deepcopy(clip)
new_clip.set('name', clip_name)
new_clip.set('offset', current_offset.to_fcpxml())
new_clip.set('start', current_start.to_fcpxml())
new_clip.set('duration', segment_duration.to_fcpxml())
# Remove markers/keywords that belong to other segments
self._filter_children_for_segment(
new_clip, current_start, segment_duration
)
self._reassign_text_style_ids(new_clip)
spine.insert(clip_index + len(new_clips), new_clip)
new_clips.append(new_clip)
# Update for next iteration
current_offset = current_offset + segment_duration
current_start = current_start + segment_duration
# Update clip index: remove stale original entry, add split entries
self.clips.pop(clip_id, None)
for i, new_clip in enumerate(new_clips):
new_id = f"{clip_id}_split_{i}"
self.clips[new_id] = new_clip
return new_clips
def cut_clip_ranges(
self,
clip: ET.Element,
cut_ranges: List[Tuple['TimeValue', 'TimeValue']],
) -> 'TimeValue':
"""Remove clip-relative time ranges from a spine clip, rippling after.
Element-based on purpose: callers that walk the spine (e.g. media
silence removal) pass the exact element, so duplicate-named clips are
never ambiguous the way name-keyed operations are.
Args:
clip: The spine clip element to cut (must be a direct spine child).
cut_ranges: (start, end) TimeValue pairs measured from the clip's
own head. Overlapping/unsorted ranges are merged; portions
outside [0, clip duration] are clamped. A cut covering the
whole clip removes it entirely.
Returns:
Total removed duration (zero if no effective ranges).
"""
spine = self._get_spine()
clip_start, clip_duration, clip_offset = self._get_clip_times(clip)
clip_index = list(spine).index(clip)
zero = TimeValue.zero()
# Clamp, sort, merge.
clamped = []
for start, end in cut_ranges:
start = start if start > zero else zero
end = end if end < clip_duration else clip_duration
if end > start:
clamped.append((start, end))
clamped.sort(key=lambda r: r[0])
merged: List[Tuple[TimeValue, TimeValue]] = []
for start, end in clamped:
if merged and start <= merged[-1][1]:
if end > merged[-1][1]:
merged[-1] = (merged[-1][0], end)
else:
merged.append((start, end))
if not merged:
return zero
# Keep ranges = complement of the merged cuts.
keeps: List[Tuple[TimeValue, TimeValue]] = []
cursor = zero
for start, end in merged:
if start > cursor:
keeps.append((cursor, start))
cursor = end
if cursor < clip_duration:
keeps.append((cursor, clip_duration))
# A keep segment shorter than a couple frames at the very start or
# end of the clip is just leftover cut padding with no neighboring
# kept audio on its outer side (the silence butts against the clip's
# own edge) — not a real clip. Rather than emit it as its own
# near-invisible micro-clip, fold it into the adjacent real segment,
# which simply starts earlier / ends later to absorb it.
min_keep_seconds = 2 * float(self.frame_duration_fraction())
if len(keeps) > 1:
first_start, first_end = keeps[0]
if (first_end - first_start).to_seconds() < min_keep_seconds:
keeps[1] = (first_start, keeps[1][1])
keeps.pop(0)
if len(keeps) > 1:
last_start, last_end = keeps[-1]
if (last_end - last_start).to_seconds() < min_keep_seconds:
keeps[-2] = (keeps[-2][0], last_end)
keeps.pop()
spine.remove(clip)
new_clips: List[ET.Element] = []
current_offset = clip_offset
kept_total = zero
for keep_start, keep_end in keeps:
seg_duration = keep_end - keep_start
seg_start = clip_start + keep_start
new_clip = copy.deepcopy(clip)
new_clip.set('offset', current_offset.to_fcpxml())
new_clip.set('start', seg_start.to_fcpxml())
new_clip.set('duration', seg_duration.to_fcpxml())
self._filter_children_for_segment(new_clip, seg_start, seg_duration)
self._reassign_text_style_ids(new_clip)
spine.insert(clip_index + len(new_clips), new_clip)
new_clips.append(new_clip)
current_offset = current_offset + seg_duration
kept_total = kept_total + seg_duration
removed = clip_duration - kept_total
self._ripple_from_index(spine, clip_index + len(new_clips), zero - removed)
self._update_sequence_duration()
# Keep the name index coherent, mirroring delete_clip/split_clip.
name = clip.get('id') or clip.get('name') or ''
if name and self.clips.get(name) is clip:
if new_clips:
self.clips[name] = new_clips[0]
else:
remaining = [
sc for _, sc in self._iter_spine_clips()
if (sc.get('id') or sc.get('name') or '') == name
]
if remaining:
self.clips[name] = remaining[0]
else:
self.clips.pop(name, None)
return removed
def remove_trailing_gaps(self) -> None:
"""Remove empty ``<gap>`` elements at the end of the timeline.
Silence removal (and FCP round-trips) can leave a trailing gap holding
the timeline open past the last real clip. This removes only *trailing*
gaps — a gap in the middle is left untouched — and re-syncs the sequence
duration so the exported file ends where the content ends.
"""
spine = self._get_spine()
children = list(spine)
if not children:
return
last = children[-1]
if last.tag != 'gap':
return
spine.remove(last)
self._update_sequence_duration()
def delete_clip(
self,
clip_ids: List[str],
ripple: bool = True
) -> None:
"""
Delete clips from timeline.
Uses spine iteration instead of the name-indexed dict so that
duplicate-named clips (e.g. four ``Interview_A``) are resolved
correctly — always targeting the *first* spine match rather than
the last-indexed entry.
Args:
clip_ids: Clips to delete
ripple: If True, shift subsequent clips. If False, leave gaps.
"""
spine = self._get_spine()
for clip_id in clip_ids:
# Walk spine directly to find the first clip matching this name,
# avoiding the last-one-wins problem in self.clips.
target = None
for _spine_idx, spine_clip in self._iter_spine_clips():
name = spine_clip.get('id') or spine_clip.get('name') or ''
if name == clip_id:
target = spine_clip
break
if target is None:
continue
_, clip_duration, clip_offset = self._get_clip_times(target)
clip_index = list(spine).index(target)
if ripple:
spine.remove(target)
self._ripple_from_index(
spine, clip_index, TimeValue.zero() - clip_duration
)
else:
# Replace with gap
gap = ET.Element('gap')
gap.set('name', 'Gap')
gap.set('offset', clip_offset.to_fcpxml())
gap.set('duration', clip_duration.to_fcpxml())
spine.remove(target)
spine.insert(clip_index, gap)
# Re-index: if other spine clips share this name, point the
# dict entry at the next one; otherwise remove entirely.
remaining = [
sc for _, sc in self._iter_spine_clips()
if (sc.get('id') or sc.get('name') or '') == clip_id
]
if remaining:
self.clips[clip_id] = remaining[0]
else:
self.clips.pop(clip_id, None)
# ========================================================================
+170
View File
@@ -0,0 +1,170 @@
"""Escrita do documento FCPXML: assets de vídeo, timebases e serialização.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import logging
import subprocess
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional
from ..models import (
TimeValue,
)
from .validation import validate_fcpxml
_log = logging.getLogger(__name__)
# ============================================================================
# STILL IMAGE AUTO-CONVERSION (v0.6.0)
# ============================================================================
_STILL_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.tiff', '.tif', '.bmp'}
def _ensure_video_asset(
src_path: str,
duration: float = 10.0,
fps: int = 24,
width: int = 1920,
height: int = 1080,
) -> str:
"""Convert a still image to a video file if needed.
Detects still images by extension and converts them to MOV using ffmpeg.
Video files are returned as-is.
Args:
src_path: Path to the source media file.
duration: Duration in seconds for the still-to-video conversion.
fps: Frame rate for the output video.
width: Output width (even number).
height: Output height (even number).
Returns:
Path to the video file (original path if already video, new .mov path
if converted from still).
Raises:
FileNotFoundError: If ffmpeg is not installed.
"""
# Validate numeric parameters to prevent ffmpeg abuse / resource exhaustion.
if not isinstance(duration, (int, float)) or duration <= 0 or duration > 3600:
raise ValueError(f"duration must be 0 < d <= 3600, got {duration!r}")
if not isinstance(fps, int) or fps < 1 or fps > 240:
raise ValueError(f"fps must be 1–240, got {fps!r}")
if not isinstance(width, int) or width < 2 or width > 7680 or width % 2:
raise ValueError(f"width must be even, 2–7680, got {width!r}")
if not isinstance(height, int) or height < 2 or height > 4320 or height % 2:
raise ValueError(f"height must be even, 2–4320, got {height!r}")
path = Path(src_path)
if path.suffix.lower() not in _STILL_IMAGE_EXTENSIONS:
return src_path
output_path = path.with_suffix('.mov')
if output_path.exists():
return str(output_path)
# Build ffmpeg command: still image → video with specified duration
cmd = [
'ffmpeg', '-y',
'-loop', '1',
'-i', str(path),
'-c:v', 'prores_ks',
'-profile:v', '0',
'-t', str(duration),
'-r', str(fps),
'-vf', f'scale={width}:{height}:force_original_aspect_ratio=decrease,'
f'pad={width}:{height}:(ow-iw)/2:(oh-ih)/2',
'-pix_fmt', 'yuva444p10le',
str(output_path),
]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=120)
except FileNotFoundError:
raise FileNotFoundError(
"ffmpeg not found. Install ffmpeg to use still image auto-conversion: "
"brew install ffmpeg"
)
except subprocess.TimeoutExpired:
raise RuntimeError(
f"Image conversion timed out after 120s: {path}"
)
except subprocess.CalledProcessError as e:
stderr_msg = e.stderr.decode(errors='replace') if e.stderr else str(e)
raise RuntimeError(f"ffmpeg conversion failed: {stderr_msg}")
return str(output_path)
def _enforce_standard_timebases(root: ET.Element) -> None:
"""Walk all elements and snap time attributes to standard FCPXML timebases.
Targets offset, start, duration, and tcStart attributes. Values that
already use a standard denominator are left untouched.
"""
time_attrs = ('offset', 'start', 'duration', 'tcStart')
for elem in root.iter():
for attr in time_attrs:
val = elem.get(attr)
if val and val.endswith('s') and '/' in val:
try:
tv = TimeValue.from_timecode(val)
if not tv.is_standard_timebase():
# Snap to nearest frame at 2400 ticks/sec
snapped = tv.snap_to_frame(24)
elem.set(attr, snapped.to_fcpxml())
except (ValueError, ZeroDivisionError):
pass # Skip unparseable values
def write_fcpxml(
root: ET.Element,
filepath: str,
enforce_timebases: bool = False,
strict: bool = False,
fps: Optional[float] = None,
) -> str:
"""Format an ElementTree root as pretty-printed FCPXML and write to disk.
Handles XML declaration, DOCTYPE insertion, and blank-line cleanup
consistently across all FCPXML output paths (modifier, writer, rough cut).
Args:
root: The <fcpxml> root Element to serialize.
filepath: Destination file path.
enforce_timebases: If True, snap all time values to standard FCPXML
timebases before writing. Default False for backward compat.
strict: If True, raise ValueError on validation errors.
If False (default), log warnings.
fps: Frame rate for the frame-alignment validation check. Defaults
to 24 when omitted — pass the sequence's real (float) rate so
NTSC projects (23.976/29.97/59.94fps) don't get spurious
"not frame-aligned at 24fps" warnings for values that are
exactly aligned at their own true rate.
Returns:
The filepath written to.
"""
if enforce_timebases:
_enforce_standard_timebases(root)
# Auto-validate before writing
issues = validate_fcpxml(root, fps=fps if fps is not None else 24.0)
if issues:
errors = [i for i in issues if i.severity == "error"]
warnings = [i for i in issues if i.severity == "warning"]
for w in warnings:
_log.warning("FCPXML validation: %s", w.message)
if errors and strict:
msg = "; ".join(e.message for e in errors)
raise ValueError(f"FCPXML validation failed: {msg}")
for e in errors:
_log.error("FCPXML validation: %s", e.message)
from ..safe_xml import serialize_xml
return serialize_xml(root, filepath, doctype='<!DOCTYPE fcpxml>')
+147
View File
@@ -0,0 +1,147 @@
"""FCPXMLWriter: gera um documento novo a partir de objetos Python.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import uuid
import xml.etree.ElementTree as ET
from datetime import datetime
from ..models import (
Marker,
Project,
Timecode,
)
from .document import write_fcpxml
from .helpers import build_marker_element
# ============================================================================
# FCPXML GENERATOR - Create from Python objects
# ============================================================================
class FCPXMLWriter:
"""Generate a new FCPXML document from Python dataclass objects.
Converts a ``Project`` (containing ``Timeline`` → ``Clip`` → ``Marker``
hierarchies) into a spec-compliant FCPXML v1.11 element tree and writes
it to disk. Used by ``RoughCutGenerator`` and the ``generate_*`` MCP
tools to create fresh timelines from scratch.
Unlike ``FCPXMLModifier`` (which mutates existing XML), this class
*creates* XML from structured Python objects.
Example::
from fcpxml.models import Project, Timeline, Clip, Timecode
project = Project(name="My Edit", timelines=[...])
writer = FCPXMLWriter()
writer.write_project(project, "output.fcpxml")
"""
def __init__(self, version: str = "1.13"):
"""Initialize writer targeting the given FCPXML version."""
self.version = version
self.resource_counter = 1
def _next_resource_id(self) -> str:
"""Return an auto-incrementing resource ID (r1, r2, ...)."""
rid = f"r{self.resource_counter}"
self.resource_counter += 1
return rid
def _generate_uid(self) -> str:
"""Generate a unique identifier for FCPXML elements."""
return str(uuid.uuid4()).upper()
def _tc_to_rational(self, tc: Timecode) -> str:
"""Convert a Timecode to FCPXML rational time string (e.g. '48/24s')."""
return f"{tc.frames}/{int(tc.frame_rate)}s"
def write_project(self, project: Project, filepath: str):
"""Write a project to an FCPXML file."""
root = self._build_fcpxml(project)
write_fcpxml(root, filepath)
def _build_fcpxml(self, project: Project) -> ET.Element:
"""Build the full FCPXML element tree: fcpxml > resources + library > event > project."""
root = ET.Element('fcpxml', version=self.version)
resources = ET.SubElement(root, 'resources')
resource_map = {}
if project.timelines:
timeline = project.timelines[0]
format_id = self._next_resource_id()
ET.SubElement(resources, 'format',
id=format_id,
name=f"FFVideoFormat{timeline.height}p{int(timeline.frame_rate)}",
frameDuration=f"1/{int(timeline.frame_rate)}s",
width=str(timeline.width), height=str(timeline.height))
resource_map['_format'] = format_id
library = ET.SubElement(root, 'library',
location=f"file:///Users/editor/Movies/{project.name}.fcpbundle/")
event = ET.SubElement(library, 'event', name=project.name, uid=self._generate_uid())
for timeline in project.timelines:
self._add_timeline(event, timeline, resources, resource_map)
return root
def _add_timeline(self, event, timeline, resources, resource_map):
"""Add a timeline as a project > sequence > spine structure under the event."""
project_elem = ET.SubElement(event, 'project',
name=timeline.name, uid=self._generate_uid(),
modDate=datetime.now().strftime("%Y-%m-%d %H:%M:%S -0500"))
format_id = resource_map.get('_format', 'r1')
sequence = ET.SubElement(project_elem, 'sequence',
format=format_id, duration=self._tc_to_rational(timeline.duration),
tcStart="0s", tcFormat="NDF", audioLayout="stereo", audioRate="48k")
spine = ET.SubElement(sequence, 'spine')
for clip in timeline.clips:
self._add_clip(spine, clip, resources, resource_map)
for marker in timeline.markers:
self._add_marker(sequence, marker)
def _add_clip(self, spine, clip, resources, resource_map):
"""Add a clip as an asset-clip element, creating its asset resource if needed."""
if clip.media_path and clip.media_path not in resource_map:
asset_id = self._next_resource_id()
ET.SubElement(resources, 'asset', id=asset_id, name=clip.name,
uid=self._generate_uid(), src=clip.media_path, start="0s",
duration=self._tc_to_rational(clip.duration), hasVideo="1", hasAudio="1")
resource_map[clip.media_path] = asset_id
asset_id = resource_map.get(clip.media_path, 'r1')
format_id = resource_map.get('_format', 'r1')
clip_elem = ET.SubElement(spine, 'asset-clip',
ref=asset_id, offset=self._tc_to_rational(clip.start), name=clip.name,
start=self._tc_to_rational(clip.source_start) if clip.source_start else "0s",
duration=self._tc_to_rational(clip.duration), format=format_id, tcFormat="NDF")
for marker in clip.markers:
self._add_marker(clip_elem, marker)
for keyword in clip.keywords:
self._add_keyword(clip_elem, keyword)
def _add_marker(self, parent: ET.Element, marker: Marker):
"""Add a marker or chapter-marker element to a parent clip or sequence."""
build_marker_element(
parent=parent,
marker_type=marker.marker_type,
start=self._tc_to_rational(marker.start),
duration=self._tc_to_rational(marker.duration) if marker.duration else "1/24s",
name=marker.name,
note=marker.note or None,
)
def _add_keyword(self, parent, keyword):
"""Add a keyword element with optional start/duration range to a parent clip."""
attrs = {'value': keyword.value}
if keyword.start:
attrs['start'] = self._tc_to_rational(keyword.start)
if keyword.duration:
attrs['duration'] = self._tc_to_rational(keyword.duration)
ET.SubElement(parent, 'keyword', **attrs)
+279
View File
@@ -0,0 +1,279 @@
"""Ajudantes de nível de módulo do writer: sanitização, escalas, elementos base.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import subprocess
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..models import (
MarkerType,
)
# Maximum lengths for XML attribute values to prevent memory abuse
_MAX_MARKER_NAME_LENGTH = 1024
_MAX_NOTE_LENGTH = 4096
# ============================================================================
# EFFECT RESOURCE REGISTRY (v0.6.0)
# ============================================================================
# FCP built-in transition/filter effect UUIDs extracted from Filters.bundle.
# Maps slug → (display_name, uuid).
FCP_EFFECTS: Dict[str, tuple] = {
# Dissolves
'cross-dissolve': ('Cross Dissolve', '4731E73A-8DAC-4113-9A30-AE85B1761265'),
'fade': ('Fade', '8154D0DA-C99B-4EF8-8FF8-006FE5ED57F1'),
'dip-to-color': ('Dip to Color', 'F779C565-486D-4633-8035-0374B4DB8F5C'),
'noise-dissolve': ('Noise Dissolve', 'ABFED81E-35D9-429C-AB47-438C1FB5D9DE'),
# Wipes
'edge-wipe': ('Edge Wipe', '857E2FBA-98DB-411B-A88C-CE6ABC1F65D8'),
'slide': ('Slide', '6AAB0D54-FCD8-4EBD-A62D-D352A5ED1648'),
'band-wipe': ('Band Wipe', 'A4E0B8E4-E916-474B-A14C-E3A9E0B1A3C1'),
'center-wipe': ('Center Wipe', 'B3F2D4A1-7C8E-4B9D-A5F6-D1E2C3B4A5D6'),
'checker-wipe': ('Checker Wipe', 'C4D3E2F1-8A7B-4C6D-B5E4-F2A1D3C4B5E6'),
'clock-wipe': ('Clock Wipe', 'D5E4F3A2-9B8C-4D7E-C6F5-A3B2E4D5C6F7'),
'gradient-wipe': ('Gradient Wipe', 'E6F5A4B3-AC9D-4E8F-D7A6-B4C3F5E6D7A8'),
'inset-wipe': ('Inset Wipe', 'F7A6B5C4-BD0E-4F9A-E8B7-C5D4A6F7E8B9'),
'star-wipe': ('Star Wipe', 'A8B7C6D5-CE1F-4A0B-F9C8-D6E5B7A8F9C0'),
# Legacy aliases — map common shorthand to canonical slugs
'fade-to-black': ('Fade', '8154D0DA-C99B-4EF8-8FF8-006FE5ED57F1'),
'fade-from-black': ('Fade', '8154D0DA-C99B-4EF8-8FF8-006FE5ED57F1'),
'wipe': ('Edge Wipe', '857E2FBA-98DB-411B-A88C-CE6ABC1F65D8'),
'dissolve': ('Cross Dissolve', '4731E73A-8DAC-4113-9A30-AE85B1761265'),
}
def list_effects() -> List[Dict[str, str]]:
"""Return a list of all available FCP transition effects.
Each entry contains slug, display_name, and uuid.
Legacy aliases are excluded to avoid duplicates.
"""
seen_uuids: set = set()
effects = []
for slug, (name, uid) in FCP_EFFECTS.items():
if uid in seen_uuids:
continue
seen_uuids.add(uid)
effects.append({'slug': slug, 'name': name, 'uuid': uid})
return effects
# Named constants for clip-tag sets used across operations.
# Using named tuples prevents inconsistent ad-hoc tag lists and ensures
# new clip types only need adding in one place.
CLIP_TAGS = ('clip', 'asset-clip', 'video', 'ref-clip')
CLIP_AND_AUDIO_TAGS = ('clip', 'asset-clip', 'video', 'audio', 'ref-clip')
SPINE_ELEMENT_TAGS = ('clip', 'asset-clip', 'video', 'audio', 'gap', 'transition', 'ref-clip')
def _sanitize_xml_value(value: str, max_length: int = _MAX_MARKER_NAME_LENGTH) -> str:
"""Sanitize a string value before writing it into an XML attribute.
Strips null bytes, control characters (except tab/newline/CR), and
enforces a length limit to prevent memory abuse or malformed XML.
"""
if not isinstance(value, str):
return str(value)
# Remove null bytes and non-printable control characters
cleaned = ''.join(
c for c in value
if c in ('\t', '\n', '\r') or ord(c) >= 32
)
if len(cleaned) > max_length:
cleaned = cleaned[:max_length]
return cleaned
# FCPXML DTD child element ordering for asset-clip / clip elements.
# Elements MUST appear in this order for DTD validation.
# See: https://developer.apple.com/documentation/professional-video-applications/fcpxml-reference
_ASSET_CLIP_CHILD_ORDER = [
'note',
'conform-rate', 'timeMap',
'adjust-crop', 'adjust-corners', 'adjust-conform', 'adjust-transform',
'adjust-blend', 'adjust-stabilization', 'adjust-rollingShutter',
'adjust-360-transform', 'adjust-reorient', 'adjust-orientation',
'adjust-volume', 'adjust-panner',
# anchor items (connected clips, titles, etc.)
'audio', 'video', 'clip', 'title', 'caption',
'mc-clip', 'ref-clip', 'sync-clip', 'asset-clip', 'audition', 'spine',
# marker items
'marker', 'chapter-marker', 'rating', 'keyword', 'analysis-marker',
# trailing
'audio-channel-source',
'filter-video', 'filter-video-mask',
'filter-audio',
'metadata',
]
# Build a priority lookup: tag → index for fast comparison
_CHILD_ORDER_INDEX = {tag: i for i, tag in enumerate(_ASSET_CLIP_CHILD_ORDER)}
# How close to the end of a clip a zoom must finish for the return to be
# skipped. Within this margin the cut arrives before the eye registers the
# move back, so the return reads as a twitch rather than a resolution.
HOLD_AT_CUT_THRESHOLD = 1.0
# How close to the start of a clip a zoom must begin for the ramp-in to be
# skipped and the shot to simply open already zoomed. Tighter than the end
# margin on purpose: at the end the cut hides an unfinished return, but at
# the start a ramp is visible from frame one and reads as the shot settling.
START_AT_CUT_THRESHOLD = 0.5
def _fmt_scale(value: float) -> str:
"""Format a scale factor without trailing float noise (1.0 -> "1")."""
return f"{value:.6f}".rstrip("0").rstrip(".") or "0"
def _dtd_insert(parent: ET.Element, child: ET.Element) -> ET.Element:
"""Insert a child element into parent at the correct DTD-ordered position.
Instead of blindly appending (which can violate DTD ordering),
this finds the right insertion point based on the FCPXML DTD's
required element sequence for asset-clip / clip elements.
Unknown tags are appended at the end.
"""
child_priority = _CHILD_ORDER_INDEX.get(child.tag, len(_ASSET_CLIP_CHILD_ORDER))
# Find the first existing child whose priority is greater than ours
insert_idx = len(parent)
for i, existing in enumerate(parent):
existing_priority = _CHILD_ORDER_INDEX.get(existing.tag, len(_ASSET_CLIP_CHILD_ORDER))
if existing_priority > child_priority:
insert_idx = i
break
parent.insert(insert_idx, child)
return child
def build_marker_element(
parent: ET.Element,
marker_type: MarkerType,
start: str,
duration: str,
name: str,
note: Optional[str] = None,
) -> ET.Element:
"""Create a marker or chapter-marker XML element under *parent*.
Single source of truth for marker element construction — used by both
FCPXMLModifier (edit-existing workflow) and FCPXMLWriter (generate-new
workflow). Centralises tag selection, type-specific attributes, note
guards, and input sanitization so changes only need to happen once.
"""
elem = ET.Element(marker_type.xml_tag)
elem.set('start', start)
elem.set('duration', duration)
elem.set('value', _sanitize_xml_value(name, _MAX_MARKER_NAME_LENGTH))
for attr, val in marker_type.xml_attrs.items():
elem.set(attr, val)
if note and marker_type != MarkerType.CHAPTER:
elem.set('note', _sanitize_xml_value(note, _MAX_NOTE_LENGTH))
_dtd_insert(parent, elem)
return elem
def _create_asset_element(
resources: ET.Element,
asset_id: str,
name: str,
src: str,
duration: str = "0s",
start: str = "0s",
has_video: str = "1",
has_audio: str = "1",
uid: Optional[str] = None,
) -> ET.Element:
"""Create an <asset> element with <media-rep> child instead of src attribute.
FCP's DTD prefers <media-rep kind="original-media" src="..."/> children
over the src attribute on <asset>. This helper produces the preferred form.
Args:
resources: Parent <resources> element to append to.
asset_id: Resource ID (e.g. "r3").
name: Human-readable asset name.
src: File path or URL for the media source.
duration: Asset duration in FCPXML rational format.
start: Asset start time.
has_video: "1" if asset has video track.
has_audio: "1" if asset has audio track.
uid: Optional UUID; auto-generated if not provided.
Returns:
The created <asset> Element.
"""
import uuid as _uuid
asset = ET.SubElement(resources, 'asset')
asset.set('id', asset_id)
asset.set('name', _sanitize_xml_value(name, 512))
asset.set('uid', uid or str(_uuid.uuid4()).upper())
asset.set('start', start)
asset.set('duration', duration)
asset.set('hasVideo', has_video)
asset.set('hasAudio', has_audio)
# Use media-rep child instead of src attribute
media_rep = ET.SubElement(asset, 'media-rep')
media_rep.set('kind', 'original-media')
media_rep.set('src', src)
return asset
def _probe_audio_info(src: str) -> Optional[Dict[str, Any]]:
"""Probe an audio file for its real duration, sample rate, and channels.
Tries ffprobe first, then falls back to the stdlib ``wave`` module for
.wav files. Returns ``None`` when the file can't be probed, so callers
can fall back to caller-supplied durations.
Returns:
``{'duration': float, 'sample_rate': int, 'channels': int}`` or None.
"""
path = Path(src)
if not path.is_file():
return None
try:
result = subprocess.run(
['ffprobe', '-v', 'error', '-select_streams', 'a:0',
'-show_entries', 'stream=sample_rate,channels,duration',
'-show_entries', 'format=duration',
'-of', 'json', str(path)],
capture_output=True, text=True, timeout=15,
)
if result.returncode == 0:
import json
data = json.loads(result.stdout)
streams = data.get('streams') or [{}]
stream = streams[0]
duration = stream.get('duration') or data.get('format', {}).get('duration')
if duration:
return {
'duration': float(duration),
'sample_rate': int(stream.get('sample_rate') or 48000),
'channels': int(stream.get('channels') or 2),
}
except (OSError, subprocess.TimeoutExpired, ValueError):
pass
if path.suffix.lower() == '.wav':
try:
import wave
with wave.open(str(path), 'rb') as wf:
rate = wf.getframerate()
if rate > 0:
return {
'duration': wf.getnframes() / rate,
'sample_rate': rate,
'channels': wf.getnchannels(),
}
except (OSError, wave.Error, EOFError):
pass
return None
+78
View File
@@ -0,0 +1,78 @@
"""Inserir clipes na spine.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import xml.etree.ElementTree as ET
from typing import Optional
class InsertMixin:
"""Inserir clipes na spine."""
# INSERT CLIP OPERATIONS
# ========================================================================
def insert_clip(
self,
position: str,
asset_id: Optional[str] = None,
asset_name: Optional[str] = None,
duration: Optional[str] = None,
in_point: Optional[str] = None,
out_point: Optional[str] = None,
ripple: bool = True
) -> ET.Element:
"""
Insert a library clip onto the timeline.
Args:
position: Where to insert - 'start', 'end', timecode, or 'after:clip_id'
asset_id: Asset reference ID (e.g., 'r3')
asset_name: Asset name (alternative to asset_id)
duration: Duration of clip (if not using in/out points)
in_point: Source in-point for subclip
out_point: Source out-point for subclip
ripple: Whether to shift subsequent clips
Returns:
The created clip element
"""
asset, asset_id = self._resolve_asset(asset_id, asset_name)
clip_duration, source_start = self._resolve_clip_duration(
asset, duration, in_point, out_point
)
# Get spine and calculate insert position
spine = self._get_spine()
spine_children = list(spine)
target_offset, insert_index = self._resolve_insert_position(
position, spine_children
)
# Build extra attrs — include format from first available format
extra: dict[str, str] = {}
for fmt_id in self.formats:
extra['format'] = fmt_id
break
new_clip = self._make_asset_clip(
asset_id, asset.get('name', 'Untitled'),
target_offset, source_start, clip_duration,
**extra,
)
# Insert into spine
spine.insert(insert_index, new_clip)
# Ripple subsequent clips if needed
if ripple and insert_index < len(spine_children):
self._ripple_from_index(spine, insert_index + 1, clip_duration)
# Add to clip index
clip_id = f"inserted_{len(self.clips)}"
self.clips[clip_id] = new_clip
return new_clip
# ========================================================================
+165
View File
@@ -0,0 +1,165 @@
"""Marcadores: um, por timecode, e em lote.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import xml.etree.ElementTree as ET
from typing import Any, Dict, List, Optional
from ..models import (
MarkerColor,
MarkerType,
TimeValue,
)
from .helpers import build_marker_element
class MarkersMixin:
"""Marcadores: um, por timecode, e em lote."""
# ========================================================================
# MARKER OPERATIONS
# ========================================================================
def add_marker(
self,
clip_id: 'str | ET.Element',
timecode: str,
name: str,
marker_type: "MarkerType | str" = MarkerType.STANDARD,
color: Optional[MarkerColor] = None,
note: Optional[str] = None
) -> ET.Element:
"""
Add a marker to a clip.
Args:
clip_id: Target clip identifier (name or ID)
timecode: Position within clip (relative to clip start)
name: Marker label
marker_type: STANDARD, TODO, COMPLETED, or CHAPTER (enum or string)
color: Optional marker color
note: Optional marker note
Returns:
The created marker element
"""
clip = self._require_clip(clip_id)
if isinstance(marker_type, str):
marker_type = MarkerType.from_string(marker_type)
time_value = self._parse_time(timecode)
return build_marker_element(
parent=clip,
marker_type=marker_type,
start=time_value.to_fcpxml(),
duration=f"1/{int(self.fps)}s",
name=name,
note=note,
)
def add_marker_at_timeline(
self,
timecode: str,
name: str,
marker_type: "MarkerType | str" = MarkerType.STANDARD,
color: Optional[MarkerColor] = None,
note: Optional[str] = None
) -> ET.Element:
"""Add a marker at a timeline position (finds the containing clip).
Uses ``_find_spine_clip_at_seconds`` to walk the spine directly,
avoiding the name-indexed ``self.clips`` dict which silently drops
duplicate-named clips.
"""
if isinstance(marker_type, str):
marker_type = MarkerType.from_string(marker_type)
time_value = self._parse_time(timecode)
target_seconds = time_value.to_seconds()
clip, relative_seconds = self._find_spine_clip_at_seconds(target_seconds)
relative_tc = TimeValue.from_seconds(relative_seconds, self.fps)
return build_marker_element(
parent=clip,
marker_type=marker_type,
start=relative_tc.to_fcpxml(),
duration=f"1/{int(self.fps)}s",
name=name,
note=note,
)
def batch_add_markers(
self,
markers: List[Dict[str, Any]],
auto_at_cuts: bool = False,
auto_at_intervals: Optional[str] = None
) -> List[ET.Element]:
"""
Add multiple markers at once.
Args:
markers: List of marker specs [{timecode, name, marker_type, color}]
auto_at_cuts: Add marker at every cut point
auto_at_intervals: Add markers at regular intervals (e.g., "00:00:30:00")
Returns:
List of created marker elements
"""
created = []
# Handle explicit markers
for m in markers:
marker = self.add_marker_at_timeline(
timecode=m['timecode'],
name=m['name'],
marker_type=MarkerType.from_string(m.get('marker_type', 'standard')),
color=MarkerColor[m['color'].upper()] if m.get('color') else None,
note=m.get('note')
)
created.append(marker)
# Auto-detect at cuts — add a marker at the start of every spine clip.
if auto_at_cuts:
for i, clip in self._iter_spine_clips():
clip_start = clip.get('start', '0s')
marker = build_marker_element(
parent=clip,
marker_type=MarkerType.STANDARD,
start=clip_start,
duration=f"1/{int(self.fps)}s",
name=f"Cut {i+1}",
)
created.append(marker)
# Auto-detect at intervals — place markers at regular time steps.
if auto_at_intervals:
interval = self._parse_time(auto_at_intervals).to_seconds()
total_duration = self._timeline_duration().to_seconds()
if total_duration > 0:
current = interval
count = 1
while current < total_duration:
try:
clip, relative = self._find_spine_clip_at_seconds(current)
except ValueError:
current += interval
count += 1
continue
rel_tv = TimeValue.from_seconds(relative, self.fps)
marker = build_marker_element(
parent=clip,
marker_type=MarkerType.STANDARD,
start=rel_tv.to_fcpxml(),
duration=f"1/{int(self.fps)}s",
name=f"Marker {count}",
)
created.append(marker)
current += interval
count += 1
return created
+64
View File
@@ -0,0 +1,64 @@
"""FCPXMLModifier — a edição de FCPXML montada a partir de um mixin por assunto.
A classe era um bloco de 3.300 linhas com dezoito assuntos dentro. Ela continua
sendo uma classe só para quem chama — `modifier.add_marker(...)` não mudou — mas
cada assunto agora mora no seu próprio arquivo e pode ser lido inteiro sem rolar
por marcadores, velocidade e legendas até achar o trecho procurado.
Mixins em vez de objetos separados por uma razão concreta: todas essas operações
mexem no *mesmo* documento e dependem dos mesmos índices e da mesma navegação na
spine (`_require_clip`, `_iter_spine_clips`, `_ripple_after_clip`). Separá-las em
objetos independentes obrigaria cada um a carregar uma referência de volta ao
documento e transformaria toda chamada interna em travessia de fronteira, sem
nada em troca — a divisão que importa aqui é de *leitura*, não de estado.
A ordem abaixo é irrelevante para o comportamento: nenhum mixin sobrescreve
método de outro; cada um contribui com um conjunto disjunto de operações.
"""
from .audio import AudioMixin
from .compound import CompoundMixin
from .connected import ConnectedMixin
from .core import ModifierCore
from .cut import CutMixin
from .insert import InsertMixin
from .markers import MarkersMixin
from .rapid import RapidMixin
from .reformat import ReformatMixin
from .relink import RelinkMixin
from .reorder import ReorderMixin
from .roles import RolesMixin
from .selection import SelectionMixin
from .silence import SilenceMixin
from .speed import SpeedMixin
from .titles import TitlesMixin
from .transitions import TransitionsMixin
from .trim import TrimMixin
class FCPXMLModifier(
RelinkMixin,
MarkersMixin,
TrimMixin,
ReorderMixin,
TransitionsMixin,
SpeedMixin,
CutMixin,
RapidMixin,
SelectionMixin,
InsertMixin,
ConnectedMixin,
TitlesMixin,
AudioMixin,
CompoundMixin,
RolesMixin,
ReformatMixin,
SilenceMixin,
ModifierCore,
):
"""Carrega um FCPXML, aplica edições cirúrgicas e salva.
Interface de escrita usada por todos os handlers do servidor MCP. A
documentação de cada operação está no mixin correspondente; o
carregamento, os índices e o `save` estão em `core.ModifierCore`.
"""
+240
View File
@@ -0,0 +1,240 @@
"""Corte rápido: flash frames, rapid trim, preencher buracos.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
from typing import Any, Dict, List, Optional
class RapidMixin:
"""Corte rápido: flash frames, rapid trim, preencher buracos."""
# SPEED CUTTING OPERATIONS (v0.3.0)
# ========================================================================
def fix_flash_frames(
self,
mode: str = 'auto',
threshold_frames: int = 6,
critical_threshold_frames: int = 2
) -> List[Dict[str, Any]]:
"""
Automatically fix flash frames (ultra-short clips).
Args:
mode: How to fix flash frames:
- 'extend_previous': Extend the previous clip to cover the flash frame
- 'extend_next': Extend the next clip backward to cover the flash frame
- 'delete': Remove the flash frame entirely (ripple)
- 'auto': Use smart logic (extend prev for critical, delete for warning)
threshold_frames: Frames below this are considered flash frames
critical_threshold_frames: Frames below this are critical (default: 2)
Returns:
List of fixed flash frames with details
"""
spine = self._get_spine()
fixed = []
# Collect flash frames first (can't modify while iterating)
flash_frames = []
for i, clip in self._iter_spine_clips():
duration = self._parse_time(clip.get('duration', '0s'))
duration_frames = duration.to_frames(self.fps)
if duration_frames < threshold_frames:
is_critical = duration_frames < critical_threshold_frames
flash_frames.append({
'index': i,
'clip': clip,
'clip_id': clip.get('name') or clip.get('id') or f"clip_{i}",
'duration_frames': duration_frames,
'is_critical': is_critical
})
# Process in reverse order to maintain indices
for ff in reversed(flash_frames):
clip = ff['clip']
_, _, clip_offset = self._get_clip_times(clip)
# Determine actual mode
actual_mode = mode
if mode == 'auto':
# Critical: try to extend previous, otherwise delete
# Warning: delete
actual_mode = 'extend_previous' if ff['is_critical'] else 'delete'
result = {
'clip_name': ff['clip_id'],
'duration_frames': ff['duration_frames'],
'was_critical': ff['is_critical'],
'action': actual_mode,
'timecode': clip_offset.to_timecode(self.fps)
}
direction = {'extend_previous': 'prev', 'extend_next': 'next'}.get(actual_mode)
if direction:
neighbor = self._absorb_into_neighbor(spine, clip, direction)
if neighbor is not None:
self._recalculate_offsets(spine)
result['extended_clip'] = neighbor.get('name', direction.title())
else:
spine.remove(clip)
self._recalculate_offsets(spine)
else: # delete
spine.remove(clip)
self._recalculate_offsets(spine)
fixed.append(result)
# Rebuild clip index
self._build_clip_index()
return fixed
def rapid_trim(
self,
max_duration: Optional[str] = None,
min_duration: Optional[str] = None,
keywords: Optional[List[str]] = None,
trim_from: str = 'end'
) -> List[Dict[str, Any]]:
"""
Batch trim clips to enforce duration limits.
Args:
max_duration: Maximum clip duration (e.g., '2s', '00:00:02:00')
min_duration: Minimum clip duration (clips shorter are extended/left alone)
keywords: Only trim clips with these keywords (None = all clips)
trim_from: Where to trim - 'start', 'end', or 'center'
Returns:
List of trimmed clips with before/after durations
"""
trimmed = []
max_dur = self._parse_time(max_duration) if max_duration else None
min_dur = self._parse_time(min_duration) if min_duration else None
for _i, clip in self._iter_spine_clips():
clip_name = clip.get('name') or clip.get('id') or 'Unknown'
# Check keyword filter
if keywords:
clip_keywords = set()
for kw_elem in clip.findall('keyword'):
clip_keywords.add(kw_elem.get('value', ''))
if not clip_keywords.intersection(set(keywords)):
continue
current_start, current_duration, _ = self._get_clip_times(clip)
original_duration = current_duration.to_seconds()
# Skip clips shorter than min_duration (leave them alone)
if min_dur and current_duration < min_dur:
continue
# Check max duration
if max_dur and current_duration > max_dur:
excess = current_duration - max_dur
if trim_from == 'end':
# Keep start, reduce duration
clip.set('duration', max_dur.to_fcpxml())
elif trim_from == 'start':
# Increase start, reduce duration
new_start = current_start + excess
clip.set('start', new_start.to_fcpxml())
clip.set('duration', max_dur.to_fcpxml())
elif trim_from == 'center':
# Trim equal amounts from both ends
half_excess = excess * 0.5
new_start = current_start + half_excess
clip.set('start', new_start.to_fcpxml())
clip.set('duration', max_dur.to_fcpxml())
trimmed.append({
'clip_name': clip_name,
'original_duration': original_duration,
'new_duration': max_dur.to_seconds(),
'trim_from': trim_from,
'action': 'trimmed'
})
# Recalculate offsets
self._recalculate_offsets(self._get_spine())
return trimmed
def fill_gaps(
self,
mode: str = 'extend_previous',
max_gap: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
Fill gaps in the timeline.
Args:
mode: How to fill gaps:
- 'extend_previous': Extend previous clip to fill gap
- 'extend_next': Extend next clip backward to fill gap
- 'delete': Remove gap elements and ripple
max_gap: Only fill gaps smaller than this (None = all gaps)
Returns:
List of filled gaps with details
"""
spine = self._get_spine()
filled = []
max_gap_time = self._parse_time(max_gap) if max_gap else None
# Find all gaps
gaps_to_process = []
for i, child in enumerate(list(spine)):
if child.tag == 'gap':
gap_duration = self._parse_time(child.get('duration', '0s'))
gap_offset = self._parse_time(child.get('offset', '0s'))
# Check max_gap filter
if max_gap_time and gap_duration > max_gap_time:
continue
gaps_to_process.append({
'element': child,
'index': i,
'duration': gap_duration,
'offset': gap_offset
})
# Process in reverse to maintain indices
for gap_info in reversed(gaps_to_process):
gap = gap_info['element']
gap_duration = gap_info['duration']
gap_offset = gap_info['offset']
result = {
'timecode': gap_offset.to_timecode(self.fps),
'duration_frames': gap_duration.to_frames(self.fps),
'duration_seconds': gap_duration.to_seconds(),
'action': mode
}
direction = {'extend_previous': 'prev', 'extend_next': 'next'}.get(mode)
if direction:
neighbor = self._absorb_into_neighbor(spine, gap, direction)
if neighbor is not None:
result['extended_clip'] = neighbor.get('name', direction.title())
filled.append(result)
else: # delete
spine.remove(gap)
filled.append(result)
# Recalculate offsets
self._recalculate_offsets(spine)
return filled
# ========================================================================
+43
View File
@@ -0,0 +1,43 @@
"""Reenquadrar a resolução do projeto.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
class ReformatMixin:
"""Reenquadrar a resolução do projeto."""
# REFORMAT OPERATIONS (v0.5.0)
# ========================================================================
SOCIAL_FORMATS = {
"9:16": (1080, 1920),
"1:1": (1080, 1080),
"4:5": (1080, 1350),
"16:9": (1920, 1080),
"4:3": (1440, 1080),
}
def reformat_resolution(self, width: int, height: int) -> None:
"""Change the timeline format to a new resolution.
Updates the format resource dimensions. FCP handles spatial
conforming (letterbox/pillarbox) on import.
Args:
width: Target width in pixels
height: Target height in pixels
"""
for fmt in self.root.findall('.//format'):
fmt.set('width', str(width))
fmt.set('height', str(height))
old_name = fmt.get('name', '')
if old_name:
fmt.set('name', f"FFVideoFormat{width}x{height}")
sequence = self.root.find('.//sequence')
if sequence is not None and sequence.get('format'):
pass # format ref stays the same, dimensions updated in-place
# ========================================================================
+94
View File
@@ -0,0 +1,94 @@
"""Repontar a mídia de um projeto para novos arquivos.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
from pathlib import Path
from typing import Any, Dict
class RelinkMixin:
"""Repontar a mídia de um projeto para novos arquivos."""
# ========================================================================
# MEDIA RELINK
# ========================================================================
def relink_media(
self,
find: str,
replace: str,
dry_run: bool = False,
) -> Dict[str, Any]:
"""Bulk-rewrite media source paths (programmatic relink).
Rewrites the ``src`` of every ``<asset>`` / ``<media-rep>`` whose
path starts with *find*, substituting *replace* — the standard
technique for relinking a moved or renamed media folder without
opening Final Cut Pro. FCP relinks via the ``media-rep`` file URL
on import; the device-specific bookmark blob is left untouched
(FCP regenerates it).
*find* / *replace* accept plain paths (``/Volumes/OldDrive``) or
``file://`` URLs; percent-encoding in existing URLs is handled.
Matching is prefix-based on whole path segments, so ``/Media/A``
matches ``/Media/A/clip.mov`` but not ``/Media/AB/clip.mov``.
Args:
find: Old path prefix to match.
replace: New path prefix to substitute.
dry_run: When True, report what would change without
mutating the tree.
Returns:
Summary dict: ``total_assets``, ``relinked`` (reference
count), ``dry_run``, and ``changes`` — a list of
``{asset, old, new, target_exists}`` entries
(``target_exists`` checks the new path on this machine).
"""
from urllib.parse import quote, unquote, urlparse
def _to_path(value: str) -> str:
if value.startswith('file://'):
return unquote(urlparse(value).path)
return value
find_path = _to_path(find).rstrip('/')
replace_path = _to_path(replace).rstrip('/')
if not find_path:
raise ValueError("relink_media: 'find' must be a non-empty path prefix")
changes = []
for asset_id, info in self.resources.items():
elem = info['element']
targets = [(elem, elem.get('src'))]
media_rep = elem.find('media-rep')
if media_rep is not None:
targets.append((media_rep, media_rep.get('src')))
for node, old_src in targets:
if not old_src:
continue
was_url = old_src.startswith('file://')
old_path = _to_path(old_src)
if old_path != find_path and not old_path.startswith(find_path + '/'):
continue
new_path = replace_path + old_path[len(find_path):]
new_src = 'file://' + quote(new_path) if was_url else new_path
if not dry_run:
node.set('src', new_src)
info['src'] = new_src
changes.append({
'asset': info.get('name') or asset_id,
'old': old_src,
'new': new_src,
'target_exists': Path(new_path).exists(),
})
return {
'total_assets': len(self.resources),
'relinked': len(changes),
'dry_run': dry_run,
'changes': changes,
}
+126
View File
@@ -0,0 +1,126 @@
"""Reordenar clipes e recalcular offsets/duração.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import xml.etree.ElementTree as ET
from typing import List
from ..models import (
TimeValue,
)
from .helpers import SPINE_ELEMENT_TAGS
class ReorderMixin:
"""Reordenar clipes e recalcular offsets/duração."""
# REORDER OPERATIONS
# ========================================================================
def reorder_clips(
self,
clip_ids: List[str],
target_position: str,
ripple: bool = True
) -> None:
"""
Move clips to a new position in the timeline.
Args:
clip_ids: Clips to move (maintains relative order)
target_position: 'start', 'end', timecode, or 'after:clip_id'/'before:clip_id'
ripple: Whether to shift other clips
"""
spine = self._get_spine()
# Collect clips to move
clips_to_move = []
for clip_id in clip_ids:
clip = self.clips.get(clip_id)
if clip is not None and clip in list(spine):
clips_to_move.append(clip)
if not clips_to_move:
raise ValueError(f"No clips found matching: {clip_ids}")
# Calculate total duration of moving clips
total_duration = TimeValue.zero()
for clip in clips_to_move:
dur = self._parse_time(clip.get('duration', '0s'))
total_duration = total_duration + dur
# Remove clips from current positions
for clip in clips_to_move:
spine.remove(clip)
# Determine target offset and insert index
spine_children = list(spine)
target_offset, insert_index = self._resolve_insert_position(
target_position, spine_children
)
# Insert clips at new position
current_offset = target_offset
for clip in clips_to_move:
clip.set('offset', current_offset.to_fcpxml())
spine.insert(insert_index, clip)
insert_index += 1
dur = self._parse_time(clip.get('duration', '0s'))
current_offset = current_offset + dur
# Recalculate all offsets if ripple
if ripple:
self._recalculate_offsets(spine)
def _recalculate_offsets(self, spine: ET.Element) -> None:
"""Recalculate all clip offsets sequentially."""
current_offset = TimeValue.zero()
for child in spine:
if child.tag in SPINE_ELEMENT_TAGS:
child.set('offset', current_offset.to_fcpxml())
duration_str = child.get('duration', '0s')
duration = self._parse_time(duration_str)
current_offset = current_offset + duration
def _timeline_duration(self) -> 'TimeValue':
"""Return the total timeline duration as a TimeValue.
Reads from the ``<sequence>`` element when available, falling back
to summing all spine element durations. Extracted from
``add_music_bed`` and ``batch_add_markers`` which both computed
this independently.
"""
sequence = self.root.find('.//sequence')
if sequence is not None:
dur_str = sequence.get('duration')
if dur_str:
return self._parse_time(dur_str)
spine = self._get_spine()
total = TimeValue.zero()
for child in spine:
if child.tag in SPINE_ELEMENT_TAGS:
total = total + self._parse_time(child.get('duration', '0s'))
return total
def _update_sequence_duration(self) -> None:
"""Recompute the ``<sequence>`` duration from the spine content.
Ripple edits (``cut_clip_ranges``, ``delete_clip``, ``split_clip``)
change the total timeline length without rewriting the sequence
element, so an exported file kept advertising the pre-edit duration —
a 326.78s sequence still claimed 326.78s after 71s of silence was
removed. This helper re-syncs the attribute to the actual spine sum.
"""
sequence = self.root.find('.//sequence')
if sequence is None:
return
spine = self._get_spine()
total = TimeValue.zero()
for child in spine:
if child.tag in SPINE_ELEMENT_TAGS:
total = total + self._parse_time(child.get('duration', '0s'))
sequence.set('duration', total.to_fcpxml())
# ========================================================================
+43
View File
@@ -0,0 +1,43 @@
"""Atribuir roles de vídeo/áudio.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import xml.etree.ElementTree as ET
from typing import Optional
from .helpers import _sanitize_xml_value
class RolesMixin:
"""Atribuir roles de vídeo/áudio."""
# ROLE OPERATIONS (v0.5.0)
# ========================================================================
def assign_role(
self,
clip_id: str,
audio_role: Optional[str] = None,
video_role: Optional[str] = None,
) -> ET.Element:
"""Set the audio/video role on a clip.
Args:
clip_id: Name/ID of the clip
audio_role: Audio role (e.g., "dialogue", "music", "effects")
video_role: Video role (e.g., "video", "titles")
Returns:
The modified clip element
"""
clip = self._require_clip(clip_id)
if audio_role is not None:
clip.set('audioRole', _sanitize_xml_value(audio_role, 256))
if video_role is not None:
clip.set('videoRole', _sanitize_xml_value(video_role, 256))
return clip
# ========================================================================
+57
View File
@@ -0,0 +1,57 @@
"""Selecionar clipes por palavra-chave.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
from typing import List
class SelectionMixin:
"""Selecionar clipes por palavra-chave."""
# SELECTION OPERATIONS
# ========================================================================
def select_by_keyword(
self,
keywords: List[str],
match_mode: str = 'any',
favorites_only: bool = False,
exclude_rejected: bool = True
) -> List[str]:
"""
Find clips matching keywords.
Args:
keywords: Keywords to match
match_mode: 'any' (OR), 'all' (AND), 'none' (exclude)
favorites_only: Only return favorited clips
exclude_rejected: Exclude rejected clips
Returns:
List of matching clip IDs
"""
matches = []
for clip_id, clip in self.clips.items():
clip_keywords = set()
for kw_elem in clip.findall('keyword'):
clip_keywords.add(kw_elem.get('value', ''))
# Check keyword match
keyword_set = set(keywords)
if match_mode == 'any':
match = bool(clip_keywords & keyword_set)
elif match_mode == 'all':
match = keyword_set <= clip_keywords
elif match_mode == 'none':
match = not bool(clip_keywords & keyword_set)
else:
match = True
if match:
matches.append(clip_id)
return matches
# ========================================================================
+185
View File
@@ -0,0 +1,185 @@
"""Detectar e remover silêncio.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
from typing import Any, Dict, List, Optional
from ..models import (
MarkerType,
TimeValue,
)
from .helpers import CLIP_TAGS, build_marker_element
class SilenceMixin:
"""Detectar e remover silêncio."""
# SILENCE DETECTION OPERATIONS (v0.5.0)
# ========================================================================
def detect_silence_candidates(
self,
min_gap_seconds: float = 0.5,
patterns: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Detect potential silence regions using timeline heuristics.
Checks for:
1. Gap elements in spine (high confidence)
2. Ultra-short clips < 0.5s (medium confidence)
3. Clips matching name patterns like "silence", "room tone" (high)
4. Duration anomalies > 2 std dev from mean (low-medium)
Args:
min_gap_seconds: Minimum gap duration to flag
patterns: Name patterns to match (default: gap, silence, room tone)
Returns:
List of silence candidate dicts
"""
if patterns is None:
patterns = ['gap', 'silence', 'room tone', 'dead air', 'blank']
spine = self._get_spine()
candidates = []
durations = []
clip_index = 0
# First pass: collect durations for anomaly detection
for child in spine:
if child.tag in CLIP_TAGS:
dur = self._parse_time(child.get('duration', '0s'))
durations.append(dur.to_seconds())
# Calculate stats for anomaly detection
mean_dur = sum(durations) / len(durations) if durations else 0
variance = (sum((d - mean_dur) ** 2 for d in durations) / len(durations)
if len(durations) > 1 else 0)
std_dev = variance ** 0.5
# Second pass: detect candidates
for child in spine:
tag = child.tag
offset = child.get('offset', '0s')
dur = self._parse_time(child.get('duration', '0s'))
dur_secs = dur.to_seconds()
tc = TimeValue.from_timecode(offset, self.fps).to_timecode(self.fps)
if tag == 'gap' and dur_secs >= min_gap_seconds:
candidates.append({
'start_timecode': tc,
'duration_seconds': dur_secs,
'reason': 'gap',
'confidence': 0.9,
'clip_name': None,
'clip_index': None,
})
elif tag in CLIP_TAGS:
name = child.get('name', '').lower()
# Name pattern match
for pat in patterns:
if pat.lower() in name:
candidates.append({
'start_timecode': tc,
'duration_seconds': dur_secs,
'reason': 'name_match',
'confidence': 0.85,
'clip_name': child.get('name', ''),
'clip_index': clip_index,
})
break
# Ultra-short clip
if dur_secs < 0.5:
candidates.append({
'start_timecode': tc,
'duration_seconds': dur_secs,
'reason': 'ultra_short',
'confidence': 0.6,
'clip_name': child.get('name', ''),
'clip_index': clip_index,
})
# Duration anomaly (> 2 std dev longer than mean)
if std_dev > 0 and dur_secs > mean_dur + 2 * std_dev:
candidates.append({
'start_timecode': tc,
'duration_seconds': dur_secs,
'reason': 'duration_anomaly',
'confidence': 0.4,
'clip_name': child.get('name', ''),
'clip_index': clip_index,
})
clip_index += 1
return candidates
def remove_silence_candidates(
self,
mode: str = "mark",
min_gap_seconds: float = 0.5,
min_confidence: float = 0.7,
patterns: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Remove or mark detected silence candidates.
Args:
mode: "delete" removes clips/gaps, "mark" adds red markers,
"shorten" trims to minimum
min_gap_seconds: Minimum gap to consider
min_confidence: Only act on candidates above this threshold
patterns: Name patterns to match
Returns:
List of actions taken
"""
candidates = self.detect_silence_candidates(min_gap_seconds, patterns)
candidates = [c for c in candidates if c['confidence'] >= min_confidence]
spine = self._get_spine()
actions = []
if mode == "mark":
for c in candidates:
child = self._find_spine_element_at_timecode(
spine, c['start_timecode'], require_clip=True
)
if child is not None:
build_marker_element(
parent=child,
marker_type=MarkerType.STANDARD,
start=child.get('start', '0s'),
duration=f"1/{int(self.fps)}s",
name=f"SILENCE: {c['reason']}",
)
actions.append({
'action': 'marked',
'clip_name': c.get('clip_name', 'gap'),
'reason': c['reason'],
})
elif mode == "delete":
elements_to_remove = []
for c in candidates:
child = self._find_spine_element_at_timecode(
spine, c['start_timecode']
)
if child is not None:
elements_to_remove.append(child)
actions.append({
'action': 'deleted',
'clip_name': c.get('clip_name', 'gap'),
'reason': c['reason'],
})
for elem in elements_to_remove:
spine.remove(elem)
if elements_to_remove:
self._recalculate_offsets(spine)
return actions
+297
View File
@@ -0,0 +1,297 @@
"""Velocidade e zoom (punch-in) por janela.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import xml.etree.ElementTree as ET
from fractions import Fraction
from typing import Optional
from .helpers import HOLD_AT_CUT_THRESHOLD, START_AT_CUT_THRESHOLD, _dtd_insert, _fmt_scale
class SpeedMixin:
"""Velocidade e zoom (punch-in) por janela."""
# SPEED OPERATIONS
# ========================================================================
def change_speed(
self,
clip_id: str,
speed: float,
preserve_pitch: bool = True
) -> ET.Element:
"""
Change clip playback speed.
Args:
clip_id: Target clip
speed: Speed multiplier (0.5 = half speed, 2.0 = double)
preserve_pitch: Maintain audio pitch
Returns:
Modified clip element
"""
if speed <= 0:
raise ValueError(f"Speed must be positive, got {speed}")
clip = self._require_clip(clip_id)
current_duration = self._parse_time(clip.get('duration', '0s'))
# Use rational arithmetic to avoid floating-point time values.
# FCPXML requires rational fractions with a consistent timebase,
# not decimal floats like "2.6666666666666665s".
denom = current_duration.denominator if current_duration.denominator > 0 else int(self.fps)
source_num = current_duration.numerator
speed_frac = Fraction(speed).limit_denominator(1000)
raw_num = source_num * speed_frac.denominator
raw_denom = denom * speed_frac.numerator
# Snap to frame boundary in a standard timebase (2400 ticks/sec).
# Each frame at Nfps = 2400/N ticks (e.g. 24fps → 100 ticks/frame).
fps_int = int(self.fps) if self.fps else 24
ticks_per_frame = 2400 // fps_int
dur_ticks = round(raw_num / raw_denom * 2400)
dur_ticks = round(dur_ticks / ticks_per_frame) * ticks_per_frame
new_num = dur_ticks
new_denom = 2400
# Remove any existing timeMap/conform-rate from a prior speed change
# to prevent duplicate children that produce invalid FCPXML.
for stale_tag in ('timeMap', 'conform-rate'):
for stale in clip.findall(stale_tag):
clip.remove(stale)
# Create timeMap for speed change (DTD-ordered insertion)
timemap = ET.Element('timeMap')
_dtd_insert(clip, timemap)
# Start keyframe
tp1 = ET.SubElement(timemap, 'timept')
tp1.set('time', '0s')
tp1.set('value', '0s')
tp1.set('interp', 'linear')
# End keyframe — use rational time, not floats
tp2 = ET.SubElement(timemap, 'timept')
tp2.set('time', f"{new_num}/{new_denom}s")
tp2.set('value', f"{source_num}/{denom}s")
tp2.set('interp', 'linear')
# Update clip duration (rational, not simplified to arbitrary denominator)
clip.set('duration', f"{new_num}/{new_denom}s")
# Add conform-rate (DTD-ordered insertion)
conform = ET.Element('conform-rate')
conform.set('scaleEnabled', '1')
conform.set('srcFrameRate', str(int(self.fps)))
_dtd_insert(clip, conform)
return clip
def add_zoom(
self,
clip_id: 'str | ET.Element',
start: float,
end: float,
scale: float = 1.3,
ease: float = 0.25,
position: str = "0 0",
ease_out: Optional[float] = None,
hold_at_end: Optional[bool] = None,
start_at_peak: Optional[bool] = None,
) -> ET.Element:
"""Add a punch-in zoom to a clip, snapping back to its framing at the end.
Animates ``<adjust-transform>``'s ``scale`` param (``<param>`` +
``<keyframeAnimation>`` of ``<keyframe>``) from the clip's current
scale up to *scale* times it, holds, then returns — all within
``[start, end]`` — clip-relative seconds (same convention as
``cut_clip_ranges``).
The two ends are deliberately asymmetric. *ease* ramps the zoom
**in** over half a second by default, fast enough to land with the
emphasised word. The way **out** is instant — a single frame — so
the moment the impact phrase ends the shot is simply back to its
normal framing and the video resumes its flow, with no drift
drawing attention to itself. Pass *ease_out* to ramp the return
gradually instead.
*hold_at_end* keeps the peak instead of returning, and
*start_at_peak* opens already zoomed with no ramp. Left as ``None``
both decide on their own from how close the window sits to the
clip's edges: a cut is itself the transition, so ramping away from
one — or back toward one — is motion the viewer reads as a wobble
rather than as emphasis.
"""
if end <= start:
raise ValueError(f"end ({end}) must be greater than start ({start})")
if ease <= 0:
raise ValueError(f"ease must be positive, got {ease}")
if scale <= 0:
raise ValueError(f"scale must be positive, got {scale}")
frame = float(self.frame_duration_fraction())
ramp_out = frame if ease_out is None else ease_out
if ramp_out <= 0:
raise ValueError(f"ease_out must be positive, got {ease_out}")
clip = self._require_clip(clip_id)
clip_duration = self._parse_time(clip.get('duration', '0s')).to_seconds()
if start < 0 or end > clip_duration:
raise ValueError(
f"zoom window [{start}, {end}]s must fall within the clip's "
f"duration (0 to {clip_duration:.3f}s)"
)
# Replace a prior zoom, but never the clip's framing. A clip can
# already carry an <adjust-transform> holding the editor's own
# reframe — rotation for footage shot sideways, position, a scale
# that makes the shot work at all. Dropping it outright (the old
# behaviour) silently destroyed that framing; on real footage the
# zoomed section came back rotated. So: keep the static attributes,
# and animate *relative to* the existing scale.
base_x, base_y = 1.0, 1.0
carried: dict = {}
old_keyframes: list = []
for stale in clip.findall('adjust-transform'):
carried = {k: v for k, v in stale.attrib.items() if k != 'scale'}
parts = (stale.get('scale') or '').split()
if len(parts) == 2:
try:
base_x, base_y = float(parts[0]), float(parts[1])
except ValueError:
base_x, base_y = 1.0, 1.0
else:
# No static attribute — a PRIOR zoom on this same clip left
# an animated <param name="scale"> instead, and the true
# resting framing lives in its keyframes, not in 1.0.
# Reading it as 1.0 here doesn't just miss the framing: it
# replaces the earlier zoom's whole animation with a wrong
# one, since this loop unconditionally removes `stale`
# right after. The rest value is recoverable without
# knowing which keyframe it is: MIN_ZOOM_SCALE == 1.0 means
# every keyframed value is >= the rest scale, so the
# smallest one keyframed is the rest value, peak or not.
for old_param in stale.findall("param[@name='scale']"):
xs, ys = [], []
for kf in old_param.findall('.//keyframe'):
kv = (kf.get('value') or '').split()
if len(kv) == 2:
try:
xs.append(float(kv[0]))
ys.append(float(kv[1]))
except ValueError:
pass
# Kept for merging: a second zoom on the same clip
# (two emphatic beats a cut didn't separate) should
# stack alongside the first, not erase it — the
# earlier peak is still a real editorial decision.
old_keyframes.append((kf.get('time', '0s'), kf.get('value', '')))
if xs and ys:
base_x, base_y = min(xs), min(ys)
clip.remove(stale)
transform = ET.Element('adjust-transform')
for key, value in carried.items():
transform.set(key, value)
scale_param = ET.SubElement(transform, 'param')
scale_param.set('name', 'scale')
anim = ET.SubElement(scale_param, 'keyframeAnimation')
# Keyframe times live in the clip's SOURCE timebase — the same origin
# as its own ``start`` — not in clip-relative seconds. A clip whose
# media starts at, say, 3109.9s of timecode looks for the animation
# there; keyframes written at 0-5s land outside the clip entirely and
# Final Cut imports the zoom as nothing at all, silently. Matches what
# add_text_title already does, and only shows up on footage whose
# start isn't 0s — every synthetic fixture starts at 0s and hides it.
media_origin = self._parse_time(clip.get('start', '0s'))
rest_value = f"{_fmt_scale(base_x)} {_fmt_scale(base_y)}"
scale_value = f"{_fmt_scale(base_x * scale)} {_fmt_scale(base_y * scale)}"
# A return that lands right before a cut is wasted motion: the next
# clip begins on its own framing anyway, so all the viewer sees is a
# twitch on the way out. When the zoom runs to the end of the clip,
# hold the peak and let the cut do the resetting.
holds_to_cut = (
hold_at_end
if hold_at_end is not None
else (clip_duration - end) <= HOLD_AT_CUT_THRESHOLD
)
opens_at_peak = (
start_at_peak
if start_at_peak is not None
else start <= START_AT_CUT_THRESHOLD
)
# Only the ramps actually written have to fit in the window: a zoom
# that opens at the peak spends no time ramping in, and one held to
# the cut spends none ramping out.
needed = (0.0 if opens_at_peak else ease) + (0.0 if holds_to_cut else ramp_out)
if needed > (end - start):
raise ValueError(
f"the ramps ({needed}s) don't fit in the zoom window "
f"({end - start}s) — shorten them or widen start/end"
)
if opens_at_peak:
# The cut already delivered the change of framing; ramping up
# from it just looks like the shot settling.
keyframes = [(start, scale_value)]
else:
keyframes = [(start, rest_value), (start + ease, scale_value)]
if holds_to_cut:
keyframes.append((end, scale_value))
else:
# Hold the peak right up to the end, then drop back on the very
# next frame — the snap-back the edit wants, not a slow drift.
keyframes.append((end - ramp_out, scale_value))
keyframes.append((end, rest_value))
new_entries = [
((media_origin + self.snap_seconds_to_frame(seconds)), value)
for seconds, value in keyframes
]
new_start_time = new_entries[0][0]
new_end_time = new_entries[-1][0]
# Two calls on the same clip mean two different things depending on
# whether their windows overlap. Overlapping = redoing the *same*
# zoom with new numbers — the old keyframes are stale and all of
# them go. Disjoint = a second, separate beat that a cut didn't
# separate onto its own clip — that one stacks alongside the first
# instead of erasing it, since both are real editorial decisions.
old_times = [self._parse_time(t) for t, _ in old_keyframes]
old_span_overlaps_new = bool(old_times) and not (
max(old_times) < new_start_time or min(old_times) > new_end_time
)
if old_span_overlaps_new:
surviving_old: list = []
else:
surviving_old = [(self._parse_time(t), v) for t, v in old_keyframes]
all_entries = sorted(surviving_old + new_entries, key=lambda e: e[0])
for time_value, value in all_entries:
kf = ET.SubElement(anim, 'keyframe')
kf.set('time', time_value.to_fcpxml())
kf.set('value', value)
# Only 'time' and 'value' — no 'interp', no 'curve'. The DTD allows
# both, but Final Cut rejected 'interp' on this vector param
# ("does not support the interpolation attribute") and discarded
# the whole <param>. A hand-made zoom exported from FCP itself
# writes bare keyframes and relies on the DTD default
# (curve="smooth"), so we match that export exactly rather than
# guess which attributes survive its importer.
if position != "0 0":
pos_param = ET.SubElement(transform, 'param')
pos_param.set('name', 'position')
pos_param.set('value', position)
_dtd_insert(clip, transform)
return clip
# ========================================================================
+600
View File
@@ -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,
)
# ========================================================================
+94
View File
@@ -0,0 +1,94 @@
"""Transições entre clipes vizinhos.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import xml.etree.ElementTree as ET
from ..models import (
TimeValue,
)
from .helpers import FCP_EFFECTS
class TransitionsMixin:
"""Transições entre clipes vizinhos."""
# TRANSITION OPERATIONS
# ========================================================================
def add_transition(
self,
clip_id: str,
position: str = 'end',
transition_type: str = 'cross-dissolve',
duration: str = '00:00:00:15'
) -> ET.Element:
"""
Add a transition to a clip.
Args:
clip_id: Target clip
position: 'start', 'end', or 'both'
transition_type: Type of transition
duration: Transition duration
Returns:
Created transition element(s)
"""
spine, clip, clip_index = self._require_spine_clip(clip_id)
trans_duration = self._parse_time(duration)
# Effect name and FCP built-in effect UID lookup via registry
effect_name, effect_uid = FCP_EFFECTS.get(
transition_type,
FCP_EFFECTS['cross-dissolve']
)
# Ensure effect resource exists in <resources>
effect_ref_id = None
if effect_uid:
root = self.tree.getroot()
resources = root.find('.//resources')
if resources is not None:
for eff in resources.findall('effect'):
if eff.get('uid') == effect_uid:
effect_ref_id = eff.get('id')
break
if effect_ref_id is None:
effect_ref_id = self._unique_resource_id(resources, 'r_dissolve')
eff_el = ET.SubElement(resources, 'effect')
eff_el.set('id', effect_ref_id)
eff_el.set('name', effect_name)
eff_el.set('uid', effect_uid)
transitions_added = []
_, clip_dur, clip_offset = self._get_clip_times(clip)
half_dur = trans_duration * 0.5
if position in ('end', 'both'):
end_offset = clip_offset + clip_dur - half_dur
transition = self._make_transition_element(
effect_name, end_offset, trans_duration, effect_ref_id
)
spine.insert(clip_index + 1, transition)
transitions_added.append(transition)
if position in ('start', 'both'):
start_offset = clip_offset - half_dur
if start_offset < TimeValue.zero():
raise ValueError(
f"Transition at start would produce negative offset "
f"({start_offset.to_seconds():.3f}s) for clip '{clip_id}'"
)
transition = self._make_transition_element(
effect_name, start_offset, trans_duration, effect_ref_id
)
spine.insert(clip_index, transition)
transitions_added.append(transition)
return transitions_added[0] if len(transitions_added) == 1 else transitions_added
# ========================================================================
+125
View File
@@ -0,0 +1,125 @@
"""Aparar clipes e propagar o ripple pela spine.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import xml.etree.ElementTree as ET
from typing import Optional
from ..models import (
TimeValue,
)
from .helpers import SPINE_ELEMENT_TAGS
class TrimMixin:
"""Aparar clipes e propagar o ripple pela spine."""
# ========================================================================
# TRIM OPERATIONS
# ========================================================================
def trim_clip(
self,
clip_id: str,
trim_start: Optional[str] = None,
trim_end: Optional[str] = None,
ripple: bool = True
) -> ET.Element:
"""
Trim a clip's in-point and/or out-point.
Args:
clip_id: Target clip
trim_start: New in-point or delta ('+1s', '-10f')
trim_end: New out-point or delta
ripple: Whether to shift subsequent clips
Returns:
Modified clip element
"""
clip = self._require_clip(clip_id)
current_start, current_duration, _ = self._get_clip_times(clip)
original_duration = current_duration
# Handle trim_start
if trim_start:
if trim_start.startswith('+') or trim_start.startswith('-'):
delta = self._parse_time(trim_start[1:])
if trim_start.startswith('-'):
# Extend earlier
new_start = current_start - delta
new_duration = current_duration + delta
else:
# Trim later
new_start = current_start + delta
new_duration = current_duration - delta
else:
new_start = self._parse_time(trim_start)
diff = new_start - current_start
new_duration = current_duration - diff
clip.set('start', new_start.to_fcpxml())
current_start = new_start
current_duration = new_duration
# Handle trim_end
if trim_end:
if trim_end.startswith('+') or trim_end.startswith('-'):
delta = self._parse_time(trim_end[1:])
if trim_end.startswith('-'):
new_duration = current_duration - delta
else:
new_duration = current_duration + delta
else:
end_point = self._parse_time(trim_end)
new_duration = end_point - current_start
current_duration = new_duration
if current_duration <= TimeValue.zero():
raise ValueError(
f"Trim would produce non-positive duration "
f"({current_duration.to_seconds():.3f}s) for clip '{clip_id}'"
)
clip.set('duration', current_duration.to_fcpxml())
# Ripple subsequent clips if needed
if ripple:
duration_change = current_duration - original_duration
if duration_change != TimeValue.zero():
self._ripple_after_clip(clip, duration_change)
return clip
def _ripple_from_index(
self, spine: ET.Element, start_index: int, delta: 'TimeValue'
) -> None:
"""Shift the offset of every spine element from *start_index* onward by *delta*.
Consolidates the ripple loops previously duplicated across
``_ripple_after_clip``, ``delete_clip``, and ``insert_clip``.
Args:
spine: The primary storyline ``<spine>`` element.
start_index: First child index to adjust (inclusive).
delta: Signed time shift (positive = later, negative = earlier).
"""
children = list(spine)
for child in children[start_index:]:
if child.tag in SPINE_ELEMENT_TAGS:
current_offset = self._parse_time(child.get('offset', '0s'))
new_offset = current_offset + delta
child.set('offset', new_offset.to_fcpxml())
def _ripple_after_clip(self, target_clip: ET.Element, delta: TimeValue) -> None:
"""Shift all clips after the given clip by delta."""
spine = self._get_spine()
clip_index = self._find_clip_index(spine, target_clip)
if clip_index is not None:
self._ripple_from_index(spine, clip_index + 1, delta)
# ========================================================================
+232
View File
@@ -0,0 +1,232 @@
"""Verificações estruturais do FCPXML antes de salvar.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import logging
import xml.etree.ElementTree as ET
from fractions import Fraction
from typing import List, Optional
from ..models import (
_FCPXML_STANDARD_TIMEBASES,
TimeValue,
ValidationIssue,
ValidationIssueType,
)
from .helpers import _ASSET_CLIP_CHILD_ORDER, _CHILD_ORDER_INDEX
# ============================================================================
# PRE-EXPORT DTD VALIDATOR (v0.6.0)
# ============================================================================
_log = logging.getLogger(__name__)
def _check_child_order(root: ET.Element) -> List[ValidationIssue]:
"""Check that child elements follow DTD-mandated ordering."""
issues = []
for parent in root.iter():
if parent.tag not in ('clip', 'asset-clip', 'video', 'audio', 'ref-clip'):
continue
children = list(parent)
if len(children) < 2:
continue
prev_priority = -1
for child in children:
priority = _CHILD_ORDER_INDEX.get(child.tag, len(_ASSET_CLIP_CHILD_ORDER))
if priority < prev_priority:
issues.append(ValidationIssue(
issue_type=ValidationIssueType.ELEMENT_ORDER,
severity="warning",
message=(
f"<{child.tag}> appears after a higher-priority sibling "
f"in <{parent.tag}> '{parent.get('name', '')}'."
),
clip_name=parent.get('name'),
))
break # One issue per parent is enough
prev_priority = priority
return issues
def _check_required_attributes(root: ET.Element) -> List[ValidationIssue]:
"""Check that key elements have their required attributes."""
issues = []
required_map = {
'filter-video': ['ref'],
'transition': ['name', 'offset', 'duration'],
'asset-clip': ['ref', 'duration'],
'format': ['id'],
}
for elem in root.iter():
attrs = required_map.get(elem.tag)
if not attrs:
continue
for attr in attrs:
if not elem.get(attr):
issues.append(ValidationIssue(
issue_type=ValidationIssueType.MISSING_ATTRIBUTE,
severity="error",
message=f"<{elem.tag}> missing required attribute '{attr}'.",
clip_name=elem.get('name'),
))
return issues
def _check_timebases(root: ET.Element) -> List[ValidationIssue]:
"""Flag time values with non-standard denominators."""
issues = []
time_attrs = ('offset', 'start', 'duration')
seen: set = set()
for elem in root.iter():
for attr in time_attrs:
val = elem.get(attr)
if val and val.endswith('s') and '/' in val:
try:
tv = TimeValue.from_timecode(val)
denom = tv.simplify().denominator
if denom not in _FCPXML_STANDARD_TIMEBASES:
key = (elem.tag, attr, val)
if key not in seen:
seen.add(key)
issues.append(ValidationIssue(
issue_type=ValidationIssueType.INVALID_TIMEBASE,
severity="warning",
message=(
f"Non-standard timebase denominator {denom} "
f"in <{elem.tag}> {attr}=\"{val}\"."
),
clip_name=elem.get('name'),
))
except (ValueError, ZeroDivisionError):
pass
return issues
def _document_frame_duration(root: ET.Element) -> Optional[Fraction]:
"""The sequence's exact ``frameDuration`` as a fraction, if declared.
Read from the format the ``<sequence>`` references (falling back to the
first declared format), so the value is the document's own timebase
rather than an assumed rate.
"""
formats = {f.get('id'): f for f in root.findall('.//format') if f.get('id')}
sequence = root.find('.//sequence')
fmt = formats.get(sequence.get('format')) if sequence is not None else None
if fmt is None:
fmt = next(iter(formats.values()), None)
if fmt is None:
return None
raw = fmt.get('frameDuration', '')
if not (raw.endswith('s') and '/' in raw):
return None
numerator, denominator = raw[:-1].split('/', 1)
try:
value = Fraction(int(numerator), int(denominator))
except (ValueError, ZeroDivisionError):
return None
return value if value > 0 else None
def _check_frame_alignment(root: ET.Element, fps: float = 24.0) -> List[ValidationIssue]:
"""Check that durations are integer multiples of the frame duration.
Uses the document's exact ``frameDuration`` fraction and rational
arithmetic. Comparing against an integer fps instead would flag every
NTSC project as broken: at 1001/24000s (23.976fps) a perfectly aligned
duration is not an integer number of "24fps" frames, so whole timelines
would be reported misaligned when nothing is wrong.
"""
issues = []
frame_duration = _document_frame_duration(root)
label = f"{1 / float(frame_duration):.3f}".rstrip('0').rstrip('.') if frame_duration else str(fps)
for elem in root.iter():
dur_str = elem.get('duration')
if not dur_str or not dur_str.endswith('s'):
continue
if elem.tag not in ('clip', 'asset-clip', 'video', 'audio', 'ref-clip', 'gap'):
continue
try:
tv = TimeValue.from_timecode(dur_str)
if frame_duration is not None:
frames = Fraction(tv.numerator, tv.denominator) / frame_duration
aligned = frames.denominator == 1
else:
approx = tv.to_seconds() * fps
aligned = abs(approx - round(approx)) <= 0.01
if not aligned:
issues.append(ValidationIssue(
issue_type=ValidationIssueType.FRAME_MISALIGNMENT,
severity="warning",
message=(
f"Duration {dur_str} in <{elem.tag}> "
f"'{elem.get('name', '')}' is not frame-aligned at {label}fps."
),
clip_name=elem.get('name'),
))
except (ValueError, ZeroDivisionError):
pass
return issues
def _check_effect_refs(root: ET.Element) -> List[ValidationIssue]:
"""Verify filter-video refs point to existing effect resources."""
issues = []
resource_ids = set()
for res in root.iter():
rid = res.get('id')
if rid and res.tag in ('effect', 'format', 'asset', 'media'):
resource_ids.add(rid)
for fv in root.iter('filter-video'):
ref = fv.get('ref')
if ref and ref not in resource_ids:
issues.append(ValidationIssue(
issue_type=ValidationIssueType.MISSING_EFFECT_REF,
severity="error",
message=f"<filter-video> ref=\"{ref}\" has no matching resource.",
))
return issues
def _check_asset_sources(root: ET.Element) -> List[ValidationIssue]:
"""Verify assets have either src attribute or media-rep child."""
issues = []
for asset in root.iter('asset'):
src = asset.get('src', '')
media_rep = asset.find('media-rep')
if not src and media_rep is None:
issues.append(ValidationIssue(
issue_type=ValidationIssueType.MISSING_MEDIA_REP,
severity="warning",
message=(
f"<asset id=\"{asset.get('id', '?')}\" "
f"name=\"{asset.get('name', '')}\"> "
f"has no src attribute and no <media-rep> child."
),
clip_name=asset.get('name'),
))
return issues
def validate_fcpxml(root: ET.Element, fps: float = 24.0) -> List[ValidationIssue]:
"""Run all DTD validation checks on an FCPXML element tree.
Args:
root: The <fcpxml> root Element to validate.
fps: Frame rate for alignment checks (default 24).
Returns:
List of ValidationIssue objects. Empty list = clean.
"""
issues: List[ValidationIssue] = []
issues.extend(_check_child_order(root))
issues.extend(_check_required_attributes(root))
issues.extend(_check_timebases(root))
issues.extend(_check_frame_alignment(root, fps))
issues.extend(_check_effect_refs(root))
issues.extend(_check_asset_sources(root))
return issues