Files
gart/code/fcpxml/writer/compound.py
T
João HenriqueandClaude Opus 5 4f5cf94443 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>
2026-08-19 21:38:49 -04:00

197 lines
6.4 KiB
Python

"""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
# ========================================================================