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>
163 lines
5.8 KiB
Python
163 lines
5.8 KiB
Python
"""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,
|
|
)
|
|
|
|
# ========================================================================
|