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