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)
|
||||
|
||||
| # | 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` |
|
||||
| 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` |
|
||||
| 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.
|
||||
|
||||
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')
|
||||
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):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
|
||||
@@ -436,7 +436,7 @@ class TestStillImageConversion:
|
||||
if os.path.exists(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):
|
||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as f:
|
||||
jpg_path = f.name
|
||||
@@ -446,7 +446,7 @@ class TestStillImageConversion:
|
||||
finally:
|
||||
os.unlink(jpg_path)
|
||||
|
||||
@patch('fcpxml.writer.subprocess.run',
|
||||
@patch('fcpxml.writer.document.subprocess.run',
|
||||
side_effect=subprocess.TimeoutExpired(cmd='ffmpeg', timeout=120))
|
||||
def test_ffmpeg_timeout_raises_runtime_error(self, mock_run):
|
||||
"""Timed-out ffmpeg must raise RuntimeError, not propagate raw TimeoutExpired."""
|
||||
@@ -458,7 +458,7 @@ class TestStillImageConversion:
|
||||
finally:
|
||||
os.unlink(png_path)
|
||||
|
||||
@patch('fcpxml.writer.subprocess.run',
|
||||
@patch('fcpxml.writer.document.subprocess.run',
|
||||
side_effect=subprocess.CalledProcessError(
|
||||
1, 'ffmpeg', stderr=b'Invalid codec'))
|
||||
def test_ffmpeg_failure_raises_runtime_error(self, mock_run):
|
||||
|
||||
Reference in New Issue
Block a user