refactor: writer.py vira pacote, um módulo por assunto
O writer tinha 4.199 linhas, das quais 3.300 numa única classe com dezoito
assuntos dentro. Achar o trecho de zoom exigia rolar por marcadores,
velocidade e legendas.
Agora é o pacote fcpxml/writer/, com um arquivo por assunto e o
FCPXMLModifier montado por composição de mixins. Mixins, e não objetos
separados, porque todas essas operações mexem no mesmo documento e nos
mesmos índices — separá-las em objetos independentes transformaria toda
chamada interna em travessia de fronteira sem nada em troca. A divisão que
importa aqui é de leitura, não de estado.
Nenhuma mudança de comportamento e nenhuma alteração nos ~50 pontos que
importam do writer: o __init__ re-exporta tudo, inclusive os nomes com
underscore que a suíte já usava.
core 723 carga, índices, navegação na spine, save
titles 600 títulos e legendas dinâmicas
cut 333 dividir, cortar faixas, apagar
speed 297 velocidade e zoom
(+ 20 módulos menores)
Único ajuste de chamada: quatro testes faziam patch em
fcpxml.writer.subprocess, que agora mora em writer.document (ver
Engine/docs/05_EXPERIENCIAS.md #23).
Lint zerado, 1441 testes passando.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1bebee4359
commit
4f5cf94443
@@ -1228,6 +1228,33 @@ o outro; percentil entrega um punhado útil nos dois casos.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 23 — 2026-08-19 — Dividir um módulo em pacote quebra quem faz `patch` nele
|
||||||
|
|
||||||
|
- **Sintoma:** ao transformar `fcpxml/writer.py` (4.199 linhas) no pacote
|
||||||
|
`fcpxml/writer/`, quatro testes passaram a falhar com
|
||||||
|
`AttributeError: module 'fcpxml.writer' has no attribute 'subprocess'` —
|
||||||
|
embora nenhuma linha de lógica tivesse mudado.
|
||||||
|
- **Causa raiz:** os testes usavam `@patch('fcpxml.writer.subprocess.run')`.
|
||||||
|
Isso não depende da API pública, e sim de *onde o import mora*: com o
|
||||||
|
módulo dividido, `subprocess` passou a ser importado por
|
||||||
|
`fcpxml/writer/document.py`, então o alvo do patch deixou de existir.
|
||||||
|
Re-exportar no `__init__` não resolveria — substituir
|
||||||
|
`fcpxml.writer.subprocess` não afeta a referência que `document` já tem.
|
||||||
|
- **Solução adotada:** apontar o patch para o módulo real
|
||||||
|
(`fcpxml.writer.document.subprocess.run`). Duas armadilhas do tipo foram
|
||||||
|
evitadas antes: imports relativos precisam de um ponto a mais ao descer um
|
||||||
|
nível (`from .models` → `from ..models`), inclusive os que ficam *dentro*
|
||||||
|
de funções, e o `__all__` precisa listar os nomes com underscore que o
|
||||||
|
resto do projeto já importava, senão a divisão vira quebra de API.
|
||||||
|
- **Aprendizado:** a suíte protege comportamento, não localização. Antes de
|
||||||
|
dividir um módulo, procure por `patch('<modulo>.` e por imports relativos
|
||||||
|
escondidos dentro de funções — são as duas coisas que uma refatoração
|
||||||
|
puramente mecânica quebra em silêncio, e as únicas que os testes pegam
|
||||||
|
tarde.
|
||||||
|
- **Estado:** `resolvido`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Resumo rápido (índice)
|
## Resumo rápido (índice)
|
||||||
|
|
||||||
| # | Data | Problema | Estado |
|
| # | Data | Problema | Estado |
|
||||||
@@ -1252,5 +1279,6 @@ o outro; percentil entrega um punhado útil nos dois casos.
|
|||||||
| 20 | 2026-08-19 | `apply_voice_actions` ausente da ponte e do encadeamento do app — dava para analisar e legendar, não para cortar | `resolvido` |
|
| 20 | 2026-08-19 | `apply_voice_actions` ausente da ponte e do encadeamento do app — dava para analisar e legendar, não para cortar | `resolvido` |
|
||||||
| 21 | 2026-08-19 | Teste ainda afirmava o default `zoom scale=1.3` removido do parser (agora vem do `zoom_scale` do usuário) | `resolvido` |
|
| 21 | 2026-08-19 | Teste ainda afirmava o default `zoom scale=1.3` removido do parser (agora vem do `zoom_scale` do usuário) | `resolvido` |
|
||||||
| 22 | 2026-08-19 | `VideoPlayer` (AVKit) aborta em runtime no app compilado por `swiftc` — etapa 5 fechava o app; trocado por `AVPlayerLayer` | `resolvido` |
|
| 22 | 2026-08-19 | `VideoPlayer` (AVKit) aborta em runtime no app compilado por `swiftc` — etapa 5 fechava o app; trocado por `AVPlayerLayer` | `resolvido` |
|
||||||
|
| 23 | 2026-08-19 | Dividir `writer.py` em pacote quebrou `@patch('fcpxml.writer.subprocess')` — a suíte protege comportamento, não localização | `resolvido` |
|
||||||
|
|
||||||
> Mantenha o índice acima sempre sincronizado com as entradas mais recentes.
|
> Mantenha o índice acima sempre sincronizado com as entradas mais recentes.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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>')
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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`.
|
||||||
|
"""
|
||||||
@@ -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
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -419,7 +419,7 @@ class TestStillImageConversion:
|
|||||||
result = _ensure_video_asset('/path/to/clip.mxf')
|
result = _ensure_video_asset('/path/to/clip.mxf')
|
||||||
assert result == '/path/to/clip.mxf'
|
assert result == '/path/to/clip.mxf'
|
||||||
|
|
||||||
@patch('fcpxml.writer.subprocess.run')
|
@patch('fcpxml.writer.document.subprocess.run')
|
||||||
def test_png_triggers_conversion(self, mock_run):
|
def test_png_triggers_conversion(self, mock_run):
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
|
||||||
@@ -436,7 +436,7 @@ class TestStillImageConversion:
|
|||||||
if os.path.exists(mov_path):
|
if os.path.exists(mov_path):
|
||||||
os.unlink(mov_path)
|
os.unlink(mov_path)
|
||||||
|
|
||||||
@patch('fcpxml.writer.subprocess.run', side_effect=FileNotFoundError)
|
@patch('fcpxml.writer.document.subprocess.run', side_effect=FileNotFoundError)
|
||||||
def test_missing_ffmpeg_raises(self, mock_run):
|
def test_missing_ffmpeg_raises(self, mock_run):
|
||||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as f:
|
||||||
jpg_path = f.name
|
jpg_path = f.name
|
||||||
@@ -446,7 +446,7 @@ class TestStillImageConversion:
|
|||||||
finally:
|
finally:
|
||||||
os.unlink(jpg_path)
|
os.unlink(jpg_path)
|
||||||
|
|
||||||
@patch('fcpxml.writer.subprocess.run',
|
@patch('fcpxml.writer.document.subprocess.run',
|
||||||
side_effect=subprocess.TimeoutExpired(cmd='ffmpeg', timeout=120))
|
side_effect=subprocess.TimeoutExpired(cmd='ffmpeg', timeout=120))
|
||||||
def test_ffmpeg_timeout_raises_runtime_error(self, mock_run):
|
def test_ffmpeg_timeout_raises_runtime_error(self, mock_run):
|
||||||
"""Timed-out ffmpeg must raise RuntimeError, not propagate raw TimeoutExpired."""
|
"""Timed-out ffmpeg must raise RuntimeError, not propagate raw TimeoutExpired."""
|
||||||
@@ -458,7 +458,7 @@ class TestStillImageConversion:
|
|||||||
finally:
|
finally:
|
||||||
os.unlink(png_path)
|
os.unlink(png_path)
|
||||||
|
|
||||||
@patch('fcpxml.writer.subprocess.run',
|
@patch('fcpxml.writer.document.subprocess.run',
|
||||||
side_effect=subprocess.CalledProcessError(
|
side_effect=subprocess.CalledProcessError(
|
||||||
1, 'ffmpeg', stderr=b'Invalid codec'))
|
1, 'ffmpeg', stderr=b'Invalid codec'))
|
||||||
def test_ffmpeg_failure_raises_runtime_error(self, mock_run):
|
def test_ffmpeg_failure_raises_runtime_error(self, mock_run):
|
||||||
|
|||||||
Reference in New Issue
Block a user