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

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,
)
# ========================================================================