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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user