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>
148 lines
6.1 KiB
Python
148 lines
6.1 KiB
Python
"""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)
|
|
|
|
|