Files
gart/code/fcpxml/writer/generator.py
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

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)