bladeworks 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bladeworks/__init__.py +56 -0
- bladeworks/__main__.py +4 -0
- bladeworks/cli.py +728 -0
- bladeworks/core/__init__.py +34 -0
- bladeworks/core/animation.py +893 -0
- bladeworks/core/animation_execution.py +817 -0
- bladeworks/core/audio_enhancements.py +673 -0
- bladeworks/core/audio_execution.py +1848 -0
- bladeworks/core/audio_ir.py +1971 -0
- bladeworks/core/basic_effects.py +168 -0
- bladeworks/core/bindings.py +196 -0
- bladeworks/core/capabilities.py +257 -0
- bladeworks/core/cohort_effects.py +702 -0
- bladeworks/core/color.py +324 -0
- bladeworks/core/compiler.py +3142 -0
- bladeworks/core/composition_diagnostics.py +629 -0
- bladeworks/core/composition_ir.py +3154 -0
- bladeworks/core/composition_planning.py +708 -0
- bladeworks/core/composition_shadow.py +687 -0
- bladeworks/core/compositor.py +1186 -0
- bladeworks/core/effect_parameter_semantics.py +165 -0
- bladeworks/core/effect_parameters.py +193 -0
- bladeworks/core/errors.py +21 -0
- bladeworks/core/filter_text.py +48 -0
- bladeworks/core/geometry.py +1711 -0
- bladeworks/core/group_surface.py +664 -0
- bladeworks/core/keyer.py +280 -0
- bladeworks/core/masks.py +453 -0
- bladeworks/core/missing_media.py +130 -0
- bladeworks/core/model.py +985 -0
- bladeworks/core/multicam_execution.py +496 -0
- bladeworks/core/output_color.py +52 -0
- bladeworks/core/parser.py +1257 -0
- bladeworks/core/pixel_domains.py +433 -0
- bladeworks/core/render_profile.py +311 -0
- bladeworks/core/render_sources.py +475 -0
- bladeworks/core/report.py +118 -0
- bladeworks/core/retime.py +967 -0
- bladeworks/core/retime_execution.py +1515 -0
- bladeworks/core/spatial_intrinsics.py +1749 -0
- bladeworks/core/story_containers.py +524 -0
- bladeworks/core/story_ir.py +1042 -0
- bladeworks/core/text.py +1032 -0
- bladeworks/core/text_templates.py +934 -0
- bladeworks/data/FCPXML_RENDER_CAPABILITIES.yaml +1797 -0
- bladeworks/effects/__init__.py +1 -0
- bladeworks/effects/contract.py +162 -0
- bladeworks/examples/__init__.py +80 -0
- bladeworks/examples/color.fcpxmld/Info.fcpxml +17 -0
- bladeworks/examples/color.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Info.fcpxml +13 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Media/b.mp4 +0 -0
- bladeworks/examples/manual_qa/generate_2h_qa_fixtures.py +237 -0
- bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Info.fcpxml +28 -0
- bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Media/night_signal.mp4 +0 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Info.fcpxml +44 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/city_grid.mp4 +0 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/golden_hour.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Info.fcpxml +113 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/city_grid.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/golden_hour.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/night_signal.mp4 +0 -0
- bladeworks/examples/single_clip.fcpxmld/Info.fcpxml +9 -0
- bladeworks/examples/single_clip.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Info.fcpxml +13 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Media/b.mp4 +0 -0
- bladeworks/examples/transform.fcpxmld/Info.fcpxml +11 -0
- bladeworks/examples/transform.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/executor.py +293 -0
- bladeworks/preview/__init__.py +11 -0
- bladeworks/preview/application.py +84 -0
- bladeworks/preview/audio.py +104 -0
- bladeworks/preview/capabilities.py +457 -0
- bladeworks/preview/contracts.py +157 -0
- bladeworks/preview/events.py +55 -0
- bladeworks/preview/export.py +57 -0
- bladeworks/preview/history.py +197 -0
- bladeworks/preview/main.py +19 -0
- bladeworks/preview/media.py +23 -0
- bladeworks/preview/media_library.py +381 -0
- bladeworks/preview/media_reporting.py +55 -0
- bladeworks/preview/media_routes.py +155 -0
- bladeworks/preview/media_visuals.py +225 -0
- bladeworks/preview/producer.py +211 -0
- bladeworks/preview/provider.py +74 -0
- bladeworks/preview/rawframe.py +218 -0
- bladeworks/preview/render_jobs.py +311 -0
- bladeworks/preview/routes.py +375 -0
- bladeworks/preview/runner.py +349 -0
- bladeworks/preview/security.py +61 -0
- bladeworks/preview/service.py +234 -0
- bladeworks/preview/session.py +457 -0
- bladeworks/preview/source.py +603 -0
- bladeworks/preview/source_routes.py +151 -0
- bladeworks/preview/studio.py +77 -0
- bladeworks/preview/webrtc.py +295 -0
- bladeworks/spatial_luts/manifest.json +17 -0
- bladeworks/spatial_luts/rec2020_hlg_to_rec709_sdr_v1.cube +4917 -0
- bladeworks/spatial_luts/rec2020_pq_to_rec709_sdr_v1.cube +4917 -0
- bladeworks/studio_static/__init__.py +1 -0
- bladeworks/studio_static/app.js +6519 -0
- bladeworks/studio_static/build-id.js +1 -0
- bladeworks/studio_static/capability-ui.js +68 -0
- bladeworks/studio_static/clip-state.js +335 -0
- bladeworks/studio_static/fcpxml.js +3313 -0
- bladeworks/studio_static/fixtures.js +393 -0
- bladeworks/studio_static/history.js +50 -0
- bladeworks/studio_static/index.html +24 -0
- bladeworks/studio_static/magnetic-timeline.js +1194 -0
- bladeworks/studio_static/mask-ui.js +189 -0
- bladeworks/studio_static/media-visuals.js +224 -0
- bladeworks/studio_static/mock-capabilities.js +409 -0
- bladeworks/studio_static/retime-ui.js +151 -0
- bladeworks/studio_static/runtime.js +1652 -0
- bladeworks/studio_static/scope-ui.js +150 -0
- bladeworks/studio_static/styles.css +1558 -0
- bladeworks/studio_static/templates/catalog.js +77 -0
- bladeworks/studio_static/templates/icons.js +84 -0
- bladeworks/studio_static/templates/inspector.js +553 -0
- bladeworks/studio_static/templates/library.js +61 -0
- bladeworks/studio_static/templates/media.js +185 -0
- bladeworks/studio_static/templates/shared.js +43 -0
- bladeworks/studio_static/templates/shell.js +94 -0
- bladeworks/studio_static/templates/timeline.js +377 -0
- bladeworks/studio_static/templates/topbar.js +83 -0
- bladeworks/studio_static/templates/types.js +7 -0
- bladeworks/studio_static/templates/viewer.js +174 -0
- bladeworks/studio_static/templates.js +31 -0
- bladeworks/studio_static/types.js +18 -0
- bladeworks/studio_static/ui.js +97 -0
- bladeworks/studio_static/viewer-geometry.js +55 -0
- bladeworks/tensor/__init__.py +131 -0
- bladeworks/tensor/audio_delivery.py +280 -0
- bladeworks/tensor/audio_pyav.py +360 -0
- bladeworks/tensor/blend.py +217 -0
- bladeworks/tensor/color.py +53 -0
- bladeworks/tensor/composite.py +33 -0
- bladeworks/tensor/decode.py +1309 -0
- bladeworks/tensor/decode_policy.py +363 -0
- bladeworks/tensor/effects.py +322 -0
- bladeworks/tensor/encode.py +614 -0
- bladeworks/tensor/errors.py +32 -0
- bladeworks/tensor/expr.py +775 -0
- bladeworks/tensor/fx_basic.py +946 -0
- bladeworks/tensor/fx_branched.py +640 -0
- bladeworks/tensor/fx_cohort.py +404 -0
- bladeworks/tensor/fx_color.py +851 -0
- bladeworks/tensor/fx_keyer.py +140 -0
- bladeworks/tensor/fx_mask.py +352 -0
- bladeworks/tensor/fx_warp.py +337 -0
- bladeworks/tensor/hdr.py +122 -0
- bladeworks/tensor/pipeline.py +401 -0
- bladeworks/tensor/plan.py +2231 -0
- bladeworks/tensor/renderer.py +1199 -0
- bladeworks/tensor/requirements.txt +28 -0
- bladeworks/tensor/resolution.py +246 -0
- bladeworks/tensor/sampler.py +598 -0
- bladeworks/tensor/support.py +169 -0
- bladeworks/tensor/swscale_fixedpoint.py +181 -0
- bladeworks/tensor/tr_equirect.py +420 -0
- bladeworks/tensor/tr_handlers.py +443 -0
- bladeworks/tensor/tr_phase5.py +697 -0
- bladeworks/tensor/transitions.py +424 -0
- bladeworks/transitions/__init__.py +10 -0
- bladeworks/transitions/carryovers.py +299 -0
- bladeworks/transitions/cohort.py +981 -0
- bladeworks/transitions/contract.py +490 -0
- bladeworks/transitions/equirectangular.py +915 -0
- bladeworks/transitions/light_deco.py +141 -0
- bladeworks/transitions/panel_motion.py +495 -0
- bladeworks/transitions/stock.py +270 -0
- bladeworks-0.1.0.dist-info/METADATA +66 -0
- bladeworks-0.1.0.dist-info/RECORD +178 -0
- bladeworks-0.1.0.dist-info/WHEEL +5 -0
- bladeworks-0.1.0.dist-info/entry_points.txt +2 -0
- bladeworks-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1971 @@
|
|
|
1
|
+
"""Compile parsed FCPXML into a video-independent audio render plan.
|
|
2
|
+
|
|
3
|
+
Architecture map
|
|
4
|
+
================
|
|
5
|
+
|
|
6
|
+
``SourceDocument`` plus resolved ``RenderableAVSource`` records
|
|
7
|
+
-> ordinary clip-instance stream timing (including J/L edits and retime)
|
|
8
|
+
-> recursively composed file/compound/multicam source pads
|
|
9
|
+
-> asset stream and component validation
|
|
10
|
+
-> channel/role selection
|
|
11
|
+
-> ordered gain, pan, mute, enhancement, and retime controls
|
|
12
|
+
-> immutable ``AudioRenderPlan`` for the stock-FFmpeg audio engine
|
|
13
|
+
|
|
14
|
+
Important invariants
|
|
15
|
+
--------------------
|
|
16
|
+
|
|
17
|
+
* Audio intervals are selected independently in the shared source-time map.
|
|
18
|
+
``audioStart`` and ``audioDuration`` never create a second clock.
|
|
19
|
+
* Every timeline and source-time coordinate remains a ``Fraction``. Only
|
|
20
|
+
non-time control values use floats.
|
|
21
|
+
* A missing stream choice on a multi-stream asset is an error. Invalid source
|
|
22
|
+
channels, output channels, and role selectors are errors too; the compiler
|
|
23
|
+
never guesses another component.
|
|
24
|
+
* Disabled clips and inactive components remain in the plan with
|
|
25
|
+
``audible == False``. The executor can create deterministic silence without
|
|
26
|
+
losing the reason that the component is silent.
|
|
27
|
+
* Controls stay in ordered layers. This preserves the distinction between an
|
|
28
|
+
angle component adjustment, a multicam role adjustment, and a clip-level
|
|
29
|
+
adjustment instead of prematurely combining their automation curves.
|
|
30
|
+
|
|
31
|
+
Main callers:
|
|
32
|
+
- The central compiler after source resolution.
|
|
33
|
+
- The stock-FFmpeg audio graph builder.
|
|
34
|
+
- Experimental core tests and the editorial A/B manifest generator.
|
|
35
|
+
|
|
36
|
+
Why this exists:
|
|
37
|
+
The legacy video-owned audio path cannot represent split edits, multiple
|
|
38
|
+
streams, role-based multicam audio, or per-component controls. This IR keeps
|
|
39
|
+
those decisions exact while sharing source timing with video.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import math
|
|
45
|
+
import xml.etree.ElementTree as ET
|
|
46
|
+
from dataclasses import dataclass, replace
|
|
47
|
+
from fractions import Fraction
|
|
48
|
+
from typing import TYPE_CHECKING, Literal, Mapping, Optional
|
|
49
|
+
|
|
50
|
+
from .model import AssetResource, SourceDocument, StoryNode, parse_time
|
|
51
|
+
from .render_sources import (
|
|
52
|
+
InstanceStreamTiming,
|
|
53
|
+
RenderableAVSource,
|
|
54
|
+
RenderSourceError,
|
|
55
|
+
resolve_compound_source,
|
|
56
|
+
resolve_instance_stream_timing,
|
|
57
|
+
source_bound_tolerance,
|
|
58
|
+
source_range_within_bounds,
|
|
59
|
+
source_window_for_retime,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if TYPE_CHECKING:
|
|
63
|
+
from .story_ir import ResourceStory
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
AudioLayout = Literal["mono", "stereo", "surround"]
|
|
67
|
+
FindingDisposition = Literal["preserved", "inactive", "not_implemented_yet"]
|
|
68
|
+
|
|
69
|
+
_OUTPUT_CHANNELS: Mapping[AudioLayout, tuple[str, ...]] = {
|
|
70
|
+
"mono": ("C",),
|
|
71
|
+
"stereo": ("L", "R"),
|
|
72
|
+
"surround": ("L", "R", "C", "LFE", "Ls", "Rs"),
|
|
73
|
+
}
|
|
74
|
+
_ENHANCEMENT_KINDS = {
|
|
75
|
+
"adjust-loudness",
|
|
76
|
+
"adjust-noiseReduction",
|
|
77
|
+
"adjust-humReduction",
|
|
78
|
+
"adjust-EQ",
|
|
79
|
+
"adjust-matchEQ",
|
|
80
|
+
"adjust-voiceIsolation",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AudioIRCompileError(ValueError):
|
|
85
|
+
"""Base class for invalid or ambiguous audio plans."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AudioIRAmbiguityError(AudioIRCompileError):
|
|
89
|
+
"""Raised when FCPXML does not identify one source stream/component."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AudioIRReferenceError(AudioIRCompileError):
|
|
93
|
+
"""Raised for an unknown asset, channel, output, role, or multicam angle."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AudioIRValidationError(AudioIRCompileError):
|
|
97
|
+
"""Raised when a preserved audio value is malformed or non-finite."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class AudioRole:
|
|
102
|
+
"""A Final Cut primary role and optional subrole."""
|
|
103
|
+
|
|
104
|
+
primary: str
|
|
105
|
+
subrole: Optional[str] = None
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def qualified(self) -> str:
|
|
109
|
+
return (
|
|
110
|
+
self.primary if self.subrole is None else f"{self.primary}.{self.subrole}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class AudioAutomationPoint:
|
|
116
|
+
"""One typed scalar control point in source-media time."""
|
|
117
|
+
|
|
118
|
+
time: Fraction
|
|
119
|
+
value: float
|
|
120
|
+
interp: str
|
|
121
|
+
curve: str
|
|
122
|
+
aux_value: Optional[str] = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class AudioFade:
|
|
127
|
+
kind: Literal["in", "out"]
|
|
128
|
+
duration: Fraction
|
|
129
|
+
curve: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class AnimatedAudioScalar:
|
|
134
|
+
"""A scalar plus exact-time automation and Final Cut fade handles."""
|
|
135
|
+
|
|
136
|
+
initial: float
|
|
137
|
+
unit: Literal["dB", "normalized", "raw"]
|
|
138
|
+
keyframes: tuple[AudioAutomationPoint, ...] = ()
|
|
139
|
+
fades: tuple[AudioFade, ...] = ()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class AudioPanner:
|
|
144
|
+
mode: Optional[str]
|
|
145
|
+
amount: AnimatedAudioScalar
|
|
146
|
+
parameters: Mapping[str, float | str]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class AudioMuteRange:
|
|
151
|
+
"""A mute in source-media time; omitted bounds retain Final Cut defaults."""
|
|
152
|
+
|
|
153
|
+
source_start: Optional[Fraction]
|
|
154
|
+
duration: Optional[Fraction]
|
|
155
|
+
fades: tuple[AudioFade, ...] = ()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class AudioEnhancement:
|
|
160
|
+
kind: str
|
|
161
|
+
attributes: Mapping[str, str]
|
|
162
|
+
parameters: Mapping[str, str]
|
|
163
|
+
backend_status: Literal["pending_audio_3", "not_implemented_yet"]
|
|
164
|
+
opaque_data: Optional[str] = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class AudioControlLayer:
|
|
169
|
+
"""Controls applied together at one FCPXML scope, in document order."""
|
|
170
|
+
|
|
171
|
+
path: str
|
|
172
|
+
gain: Optional[AnimatedAudioScalar] = None
|
|
173
|
+
panner: Optional[AudioPanner] = None
|
|
174
|
+
mutes: tuple[AudioMuteRange, ...] = ()
|
|
175
|
+
enhancements: tuple[AudioEnhancement, ...] = ()
|
|
176
|
+
role_selector: Optional[AudioRole] = None
|
|
177
|
+
source_start: Optional[Fraction] = None
|
|
178
|
+
source_duration: Optional[Fraction] = None
|
|
179
|
+
enabled: bool = True
|
|
180
|
+
active: bool = True
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def is_empty(self) -> bool:
|
|
184
|
+
return (
|
|
185
|
+
self.gain is None
|
|
186
|
+
and self.panner is None
|
|
187
|
+
and not self.mutes
|
|
188
|
+
and not self.enhancements
|
|
189
|
+
and self.role_selector is None
|
|
190
|
+
and self.source_start is None
|
|
191
|
+
and self.source_duration is None
|
|
192
|
+
and self.enabled
|
|
193
|
+
and self.active
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True)
|
|
198
|
+
class AudioRetimePoint:
|
|
199
|
+
output_time: Fraction
|
|
200
|
+
source_time: Fraction
|
|
201
|
+
interp: Optional[str]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class RenderAudioItem:
|
|
206
|
+
"""One independently scheduled source component.
|
|
207
|
+
|
|
208
|
+
``source_channels`` are one-based channel indices in ``source_stream_id``.
|
|
209
|
+
An empty tuple means the document omitted ``audioChannels`` and did not
|
|
210
|
+
request explicit routing, so AUDIO-2 must resolve the stream's complete
|
|
211
|
+
channel set from the media probe before graph construction.
|
|
212
|
+
``output_channels is None`` means Final Cut's layout-dependent default is
|
|
213
|
+
intentionally still unspecified; an explicit ``outCh`` is never lost.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
id: str
|
|
217
|
+
path: str
|
|
218
|
+
name: Optional[str]
|
|
219
|
+
absolute_start: Fraction
|
|
220
|
+
duration: Fraction
|
|
221
|
+
source_start: Fraction
|
|
222
|
+
source_duration: Fraction
|
|
223
|
+
asset_id: str
|
|
224
|
+
asset_uid: Optional[str]
|
|
225
|
+
source_stream_id: str
|
|
226
|
+
source_sample_rate: Optional[int]
|
|
227
|
+
source_channels: tuple[int, ...]
|
|
228
|
+
output_channels: Optional[tuple[str, ...]]
|
|
229
|
+
role: Optional[AudioRole]
|
|
230
|
+
enabled: bool
|
|
231
|
+
active: bool
|
|
232
|
+
control_layers: tuple[AudioControlLayer, ...]
|
|
233
|
+
retime: tuple[AudioRetimePoint, ...]
|
|
234
|
+
preserves_pitch: bool
|
|
235
|
+
ancestor_paths: tuple[str, ...] = ()
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def end(self) -> Fraction:
|
|
239
|
+
return self.absolute_start + self.duration
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def audible(self) -> bool:
|
|
243
|
+
return self.enabled and self.active
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class AudioSourceInstance:
|
|
248
|
+
"""One ordinary clip instance consuming a file or composed audio source."""
|
|
249
|
+
|
|
250
|
+
path: str
|
|
251
|
+
source_id: str
|
|
252
|
+
ancestor_paths: tuple[str, ...]
|
|
253
|
+
timing: InstanceStreamTiming
|
|
254
|
+
preserves_pitch: bool
|
|
255
|
+
controls: tuple[AudioControlLayer, ...] = ()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass(frozen=True)
|
|
259
|
+
class AudioIRFinding:
|
|
260
|
+
code: str
|
|
261
|
+
path: str
|
|
262
|
+
disposition: FindingDisposition
|
|
263
|
+
detail: str
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass(frozen=True)
|
|
267
|
+
class AudioRenderPlan:
|
|
268
|
+
schema_version: int
|
|
269
|
+
source_sha256: str
|
|
270
|
+
sequence_duration: Fraction
|
|
271
|
+
sample_rate: int
|
|
272
|
+
layout: AudioLayout
|
|
273
|
+
output_channels: tuple[str, ...]
|
|
274
|
+
items: tuple[RenderAudioItem, ...]
|
|
275
|
+
findings: tuple[AudioIRFinding, ...]
|
|
276
|
+
source_instances: tuple[AudioSourceInstance, ...] = ()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True)
|
|
280
|
+
class _Component:
|
|
281
|
+
path: str
|
|
282
|
+
source_stream_id: Optional[str]
|
|
283
|
+
source_channels: Optional[tuple[int, ...]]
|
|
284
|
+
output_channels: Optional[tuple[str, ...]]
|
|
285
|
+
role: Optional[AudioRole]
|
|
286
|
+
source_start: Optional[Fraction]
|
|
287
|
+
duration: Optional[Fraction]
|
|
288
|
+
enabled: bool
|
|
289
|
+
active: bool
|
|
290
|
+
controls: AudioControlLayer
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass(frozen=True)
|
|
294
|
+
class _RoleControl:
|
|
295
|
+
selector: AudioRole
|
|
296
|
+
controls: AudioControlLayer
|
|
297
|
+
path: str
|
|
298
|
+
active: bool
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True)
|
|
302
|
+
class _AudioScope:
|
|
303
|
+
"""One nested audio scope, stored outer-to-inner while traversing."""
|
|
304
|
+
|
|
305
|
+
path: str
|
|
306
|
+
controls: AudioControlLayer
|
|
307
|
+
role_controls: tuple[_RoleControl, ...] = ()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class _AudioIRBuilder:
|
|
311
|
+
"""Stateful exact-time compiler used by ``compile_audio_ir``.
|
|
312
|
+
|
|
313
|
+
Main callers:
|
|
314
|
+
- ``compile_audio_ir`` once per parsed document.
|
|
315
|
+
|
|
316
|
+
Why this exists:
|
|
317
|
+
Item IDs, explicit findings, and role-selector match counts are build-time
|
|
318
|
+
state. Keeping them outside the frozen result makes repeated compilation
|
|
319
|
+
deterministic.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
def __init__(
|
|
323
|
+
self,
|
|
324
|
+
source: SourceDocument,
|
|
325
|
+
*,
|
|
326
|
+
resource_stories: Mapping[str, "ResourceStory"],
|
|
327
|
+
render_sources: Mapping[str, RenderableAVSource],
|
|
328
|
+
) -> None:
|
|
329
|
+
self.source = source
|
|
330
|
+
self.resource_stories = resource_stories
|
|
331
|
+
self.render_sources = render_sources
|
|
332
|
+
self.items: list[RenderAudioItem] = []
|
|
333
|
+
self.findings: list[AudioIRFinding] = []
|
|
334
|
+
self.source_instances: list[AudioSourceInstance] = []
|
|
335
|
+
self._item_counter = 0
|
|
336
|
+
self._role_match_counts: dict[str, int] = {}
|
|
337
|
+
|
|
338
|
+
def build(self) -> AudioRenderPlan:
|
|
339
|
+
layout = self.source.sequence_audio_layout
|
|
340
|
+
if layout not in _OUTPUT_CHANNELS:
|
|
341
|
+
raise AudioIRValidationError(
|
|
342
|
+
f"sequence has invalid audio layout {layout!r}"
|
|
343
|
+
)
|
|
344
|
+
if self.source.sequence_audio_rate <= 0:
|
|
345
|
+
raise AudioIRValidationError("sequence audio sample rate must be positive")
|
|
346
|
+
self._walk_storyline(
|
|
347
|
+
self.source.spine,
|
|
348
|
+
parent_absolute=Fraction(0),
|
|
349
|
+
parent_source=self.source.sequence_tc_start,
|
|
350
|
+
sequential=True,
|
|
351
|
+
inherited_enabled=True,
|
|
352
|
+
scopes=(),
|
|
353
|
+
component_overrides=(),
|
|
354
|
+
audio_window=None,
|
|
355
|
+
ancestor_paths=("sequence",),
|
|
356
|
+
)
|
|
357
|
+
for instance in self.source_instances:
|
|
358
|
+
if not any(
|
|
359
|
+
instance.path in item.ancestor_paths for item in self.items
|
|
360
|
+
) and not self._instance_is_explicit_silence(instance):
|
|
361
|
+
raise AudioIRReferenceError(
|
|
362
|
+
f"{instance.path} resolved audio source {instance.source_id!r} "
|
|
363
|
+
"has no audio stream in its requested range"
|
|
364
|
+
)
|
|
365
|
+
for selector_path, count in self._role_match_counts.items():
|
|
366
|
+
if count == 0 and not any(
|
|
367
|
+
selector_path.startswith(f"{instance.path}/")
|
|
368
|
+
and self._instance_is_explicit_silence(instance)
|
|
369
|
+
for instance in self.source_instances
|
|
370
|
+
):
|
|
371
|
+
raise AudioIRReferenceError(
|
|
372
|
+
f"{selector_path} does not match any emitted audio component"
|
|
373
|
+
)
|
|
374
|
+
items = tuple(
|
|
375
|
+
sorted(self.items, key=lambda item: (item.absolute_start, item.id))
|
|
376
|
+
)
|
|
377
|
+
return AudioRenderPlan(
|
|
378
|
+
schema_version=2,
|
|
379
|
+
source_sha256=self.source.source_sha256,
|
|
380
|
+
sequence_duration=self.source.sequence_duration,
|
|
381
|
+
sample_rate=self.source.sequence_audio_rate,
|
|
382
|
+
layout=layout,
|
|
383
|
+
output_channels=_OUTPUT_CHANNELS[layout],
|
|
384
|
+
items=items,
|
|
385
|
+
findings=tuple(self.findings),
|
|
386
|
+
source_instances=tuple(self.source_instances),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def _instance_is_explicit_silence(
|
|
390
|
+
self,
|
|
391
|
+
instance: AudioSourceInstance,
|
|
392
|
+
) -> bool:
|
|
393
|
+
"""Return whether an empty virtual-source interval is authored as gaps.
|
|
394
|
+
|
|
395
|
+
Main callers:
|
|
396
|
+
- ``build`` before treating a source instance with no emitted audio as
|
|
397
|
+
a broken reference.
|
|
398
|
+
|
|
399
|
+
Why this exists:
|
|
400
|
+
- A valid multicam angle can begin with an explicit ``gap``. Selecting
|
|
401
|
+
only that interval means intentional silence, not a missing stream.
|
|
402
|
+
The interval must be completely covered by finite direct gaps; an
|
|
403
|
+
unknown or partially covered interval still fails loudly.
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
source = next(
|
|
407
|
+
(
|
|
408
|
+
candidate
|
|
409
|
+
for candidate in self.render_sources.values()
|
|
410
|
+
if candidate.id == instance.source_id
|
|
411
|
+
),
|
|
412
|
+
None,
|
|
413
|
+
)
|
|
414
|
+
if source is None:
|
|
415
|
+
return False
|
|
416
|
+
requested_start = instance.timing.source_start
|
|
417
|
+
requested_end = requested_start + instance.timing.source_duration
|
|
418
|
+
cursor = source.source_start
|
|
419
|
+
gaps: list[tuple[Fraction, Fraction]] = []
|
|
420
|
+
for node in source.audio_story:
|
|
421
|
+
offset = node.offset if node.offset is not None else cursor
|
|
422
|
+
end = offset + node.duration
|
|
423
|
+
if node.kind == "gap" and node.enabled:
|
|
424
|
+
gaps.append((offset, end))
|
|
425
|
+
cursor = end
|
|
426
|
+
covered_until = requested_start
|
|
427
|
+
for gap_start, gap_end in sorted(gaps):
|
|
428
|
+
if gap_end <= covered_until:
|
|
429
|
+
continue
|
|
430
|
+
if gap_start > covered_until:
|
|
431
|
+
return False
|
|
432
|
+
covered_until = max(covered_until, gap_end)
|
|
433
|
+
if covered_until >= requested_end:
|
|
434
|
+
return True
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
def _walk_storyline(
|
|
438
|
+
self,
|
|
439
|
+
nodes: tuple[StoryNode, ...],
|
|
440
|
+
*,
|
|
441
|
+
parent_absolute: Fraction,
|
|
442
|
+
parent_source: Fraction,
|
|
443
|
+
sequential: bool,
|
|
444
|
+
inherited_enabled: bool,
|
|
445
|
+
scopes: tuple[_AudioScope, ...],
|
|
446
|
+
component_overrides: tuple[_Component, ...],
|
|
447
|
+
audio_window: Optional[tuple[Fraction, Fraction]],
|
|
448
|
+
ancestor_paths: tuple[str, ...],
|
|
449
|
+
) -> None:
|
|
450
|
+
"""Schedule siblings using the same exact source-domain rule as FCPXML."""
|
|
451
|
+
|
|
452
|
+
cursor = parent_source
|
|
453
|
+
for node in nodes:
|
|
454
|
+
offset = (
|
|
455
|
+
node.offset
|
|
456
|
+
if node.offset is not None
|
|
457
|
+
else (cursor if sequential else parent_source)
|
|
458
|
+
)
|
|
459
|
+
absolute = parent_absolute + offset - parent_source
|
|
460
|
+
enabled = inherited_enabled and node.enabled
|
|
461
|
+
element = _xml_element(node.raw_xml, node.path)
|
|
462
|
+
direct_layer = _control_layer(element, node.path)
|
|
463
|
+
own_role_controls = _role_controls(element, node.path)
|
|
464
|
+
self._register_role_controls(own_role_controls)
|
|
465
|
+
own_scope = _AudioScope(node.path, direct_layer, own_role_controls)
|
|
466
|
+
local_scopes = scopes + (
|
|
467
|
+
() if direct_layer.is_empty and not own_role_controls else (own_scope,)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
if node.kind in {"asset-clip", "audio"}:
|
|
471
|
+
asset = self.source.assets.get(node.ref or "")
|
|
472
|
+
emits_audio = (
|
|
473
|
+
asset is not None
|
|
474
|
+
and asset.has_audio
|
|
475
|
+
and node.kind != "video"
|
|
476
|
+
and node.src_enable != "video"
|
|
477
|
+
)
|
|
478
|
+
if node.time_map and emits_audio:
|
|
479
|
+
self._compile_file_source_instance(
|
|
480
|
+
node,
|
|
481
|
+
element,
|
|
482
|
+
absolute=absolute,
|
|
483
|
+
enabled=enabled,
|
|
484
|
+
scopes=scopes,
|
|
485
|
+
direct_layer=direct_layer,
|
|
486
|
+
role_controls=own_role_controls,
|
|
487
|
+
component_overrides=component_overrides,
|
|
488
|
+
audio_window=audio_window,
|
|
489
|
+
ancestor_paths=ancestor_paths,
|
|
490
|
+
)
|
|
491
|
+
else:
|
|
492
|
+
self._compile_asset_node(
|
|
493
|
+
node,
|
|
494
|
+
element,
|
|
495
|
+
absolute=absolute,
|
|
496
|
+
enabled=enabled,
|
|
497
|
+
scopes=local_scopes,
|
|
498
|
+
component_overrides=component_overrides,
|
|
499
|
+
audio_window=audio_window,
|
|
500
|
+
ancestor_paths=ancestor_paths,
|
|
501
|
+
)
|
|
502
|
+
# Connected items are siblings of the media's own audio; the
|
|
503
|
+
# media's gain/pan scope does not implicitly own them.
|
|
504
|
+
if node.children:
|
|
505
|
+
self._walk_storyline(
|
|
506
|
+
node.children,
|
|
507
|
+
parent_absolute=absolute,
|
|
508
|
+
parent_source=node.start,
|
|
509
|
+
sequential=False,
|
|
510
|
+
inherited_enabled=enabled,
|
|
511
|
+
scopes=scopes,
|
|
512
|
+
component_overrides=(),
|
|
513
|
+
audio_window=None,
|
|
514
|
+
# Connected items are timeline siblings of this file
|
|
515
|
+
# source. They must stay inside any enclosing source
|
|
516
|
+
# module, but must not become members of this source's
|
|
517
|
+
# completed pad or inherit its post-mix controls.
|
|
518
|
+
ancestor_paths=ancestor_paths,
|
|
519
|
+
)
|
|
520
|
+
elif node.kind == "mc-clip":
|
|
521
|
+
resolved = self.render_sources.get(node.path)
|
|
522
|
+
if resolved is None:
|
|
523
|
+
raise AudioIRReferenceError(
|
|
524
|
+
f"{node.path} has no resolved multicam A/V source"
|
|
525
|
+
)
|
|
526
|
+
self._compile_virtual_source(
|
|
527
|
+
node,
|
|
528
|
+
element,
|
|
529
|
+
resolved,
|
|
530
|
+
absolute=absolute,
|
|
531
|
+
enabled=enabled,
|
|
532
|
+
scopes=scopes,
|
|
533
|
+
direct_layer=direct_layer,
|
|
534
|
+
role_controls=own_role_controls,
|
|
535
|
+
audio_window=audio_window,
|
|
536
|
+
ancestor_paths=ancestor_paths,
|
|
537
|
+
)
|
|
538
|
+
if node.children:
|
|
539
|
+
self._walk_storyline(
|
|
540
|
+
node.children,
|
|
541
|
+
parent_absolute=absolute,
|
|
542
|
+
parent_source=node.start,
|
|
543
|
+
sequential=False,
|
|
544
|
+
inherited_enabled=enabled,
|
|
545
|
+
scopes=scopes,
|
|
546
|
+
component_overrides=(),
|
|
547
|
+
audio_window=None,
|
|
548
|
+
# A connected role/component is composed beside the
|
|
549
|
+
# multicam result. Only the selected angle story is
|
|
550
|
+
# folded into the multicam source instance below.
|
|
551
|
+
ancestor_paths=ancestor_paths,
|
|
552
|
+
)
|
|
553
|
+
elif node.kind in {"clip", "sync-clip", "spine", "gap"}:
|
|
554
|
+
primary = tuple(
|
|
555
|
+
child
|
|
556
|
+
for child in node.children
|
|
557
|
+
if node.kind == "spine" or child.lane == 0
|
|
558
|
+
)
|
|
559
|
+
connected = tuple(
|
|
560
|
+
child
|
|
561
|
+
for child in node.children
|
|
562
|
+
if node.kind != "spine" and child.lane != 0
|
|
563
|
+
)
|
|
564
|
+
own_components = _channel_components(
|
|
565
|
+
element,
|
|
566
|
+
node,
|
|
567
|
+
self.source.sequence_audio_layout,
|
|
568
|
+
)
|
|
569
|
+
primary_components = own_components or component_overrides
|
|
570
|
+
primary_scopes = local_scopes
|
|
571
|
+
connected_scopes = scopes
|
|
572
|
+
primary_window = audio_window
|
|
573
|
+
primary_parent_source = node.start
|
|
574
|
+
if node.kind == "clip" and node.time_map:
|
|
575
|
+
try:
|
|
576
|
+
timing = resolve_instance_stream_timing(
|
|
577
|
+
node,
|
|
578
|
+
absolute_start=absolute,
|
|
579
|
+
stream="audio",
|
|
580
|
+
)
|
|
581
|
+
except RenderSourceError as error:
|
|
582
|
+
raise AudioIRValidationError(str(error)) from error
|
|
583
|
+
virtual = RenderableAVSource(
|
|
584
|
+
id=f"inline:{node.path}",
|
|
585
|
+
kind="compound",
|
|
586
|
+
resource_id=node.path,
|
|
587
|
+
source_start=timing.source_start,
|
|
588
|
+
duration=timing.source_duration,
|
|
589
|
+
format_context=None,
|
|
590
|
+
has_video=True,
|
|
591
|
+
has_audio=True,
|
|
592
|
+
video_story=primary,
|
|
593
|
+
audio_story=primary,
|
|
594
|
+
)
|
|
595
|
+
self._compile_virtual_source(
|
|
596
|
+
node,
|
|
597
|
+
element,
|
|
598
|
+
virtual,
|
|
599
|
+
absolute=absolute,
|
|
600
|
+
enabled=enabled,
|
|
601
|
+
scopes=scopes,
|
|
602
|
+
direct_layer=direct_layer,
|
|
603
|
+
role_controls=own_role_controls,
|
|
604
|
+
audio_window=audio_window,
|
|
605
|
+
ancestor_paths=ancestor_paths,
|
|
606
|
+
)
|
|
607
|
+
primary = ()
|
|
608
|
+
elif node.audio_start is not None or node.audio_duration is not None:
|
|
609
|
+
range_start = (
|
|
610
|
+
node.audio_start if node.audio_start is not None else node.start
|
|
611
|
+
)
|
|
612
|
+
range_duration = (
|
|
613
|
+
node.audio_duration
|
|
614
|
+
if node.audio_duration is not None
|
|
615
|
+
else node.duration
|
|
616
|
+
)
|
|
617
|
+
if range_duration <= 0:
|
|
618
|
+
raise AudioIRValidationError(
|
|
619
|
+
f"{node.path} audio duration must be positive"
|
|
620
|
+
)
|
|
621
|
+
own_window_start = absolute + range_start - node.start
|
|
622
|
+
primary_window = _intersect_windows(
|
|
623
|
+
primary_window,
|
|
624
|
+
(own_window_start, own_window_start + range_duration),
|
|
625
|
+
)
|
|
626
|
+
if node.kind == "sync-clip":
|
|
627
|
+
storyline_roles = _sync_role_controls(
|
|
628
|
+
element, node.path, "storyline"
|
|
629
|
+
)
|
|
630
|
+
connected_source_roles = _sync_role_controls(
|
|
631
|
+
element, node.path, "connected"
|
|
632
|
+
)
|
|
633
|
+
self._register_role_controls(storyline_roles)
|
|
634
|
+
self._register_role_controls(connected_source_roles)
|
|
635
|
+
if storyline_roles:
|
|
636
|
+
primary_scopes = (
|
|
637
|
+
*primary_scopes,
|
|
638
|
+
_AudioScope(
|
|
639
|
+
f"{node.path}/sync-source[@sourceID='storyline']",
|
|
640
|
+
AudioControlLayer(
|
|
641
|
+
path=f"{node.path}/sync-source[@sourceID='storyline']"
|
|
642
|
+
),
|
|
643
|
+
storyline_roles,
|
|
644
|
+
),
|
|
645
|
+
)
|
|
646
|
+
if connected_source_roles:
|
|
647
|
+
connected_scopes = (
|
|
648
|
+
*connected_scopes,
|
|
649
|
+
_AudioScope(
|
|
650
|
+
f"{node.path}/sync-source[@sourceID='connected']",
|
|
651
|
+
AudioControlLayer(
|
|
652
|
+
path=f"{node.path}/sync-source[@sourceID='connected']"
|
|
653
|
+
),
|
|
654
|
+
connected_source_roles,
|
|
655
|
+
),
|
|
656
|
+
)
|
|
657
|
+
if primary:
|
|
658
|
+
self._walk_storyline(
|
|
659
|
+
primary,
|
|
660
|
+
parent_absolute=absolute,
|
|
661
|
+
parent_source=primary_parent_source,
|
|
662
|
+
sequential=node.kind == "spine",
|
|
663
|
+
inherited_enabled=enabled,
|
|
664
|
+
scopes=primary_scopes,
|
|
665
|
+
component_overrides=primary_components,
|
|
666
|
+
audio_window=primary_window,
|
|
667
|
+
ancestor_paths=(*ancestor_paths, node.path),
|
|
668
|
+
)
|
|
669
|
+
if connected:
|
|
670
|
+
self._walk_storyline(
|
|
671
|
+
connected,
|
|
672
|
+
parent_absolute=absolute,
|
|
673
|
+
parent_source=node.start,
|
|
674
|
+
sequential=False,
|
|
675
|
+
inherited_enabled=enabled,
|
|
676
|
+
scopes=connected_scopes,
|
|
677
|
+
component_overrides=(),
|
|
678
|
+
audio_window=None,
|
|
679
|
+
# Connected children do not cross the containing
|
|
680
|
+
# clip's source-instance boundary. Preserve outer
|
|
681
|
+
# module membership while excluding this clip only.
|
|
682
|
+
ancestor_paths=ancestor_paths,
|
|
683
|
+
)
|
|
684
|
+
elif node.kind == "audition":
|
|
685
|
+
if node.children:
|
|
686
|
+
self._walk_storyline(
|
|
687
|
+
node.children[:1],
|
|
688
|
+
parent_absolute=absolute,
|
|
689
|
+
parent_source=node.start,
|
|
690
|
+
sequential=False,
|
|
691
|
+
inherited_enabled=enabled,
|
|
692
|
+
scopes=local_scopes,
|
|
693
|
+
component_overrides=component_overrides,
|
|
694
|
+
audio_window=audio_window,
|
|
695
|
+
ancestor_paths=(*ancestor_paths, node.path),
|
|
696
|
+
)
|
|
697
|
+
for choice in node.children[1:]:
|
|
698
|
+
self.findings.append(
|
|
699
|
+
AudioIRFinding(
|
|
700
|
+
code="audition_audio_inactive",
|
|
701
|
+
path=choice.path,
|
|
702
|
+
disposition="inactive",
|
|
703
|
+
detail="alternative audition audio is preserved but not scheduled",
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
elif node.kind == "ref-clip":
|
|
707
|
+
resolved = self.render_sources.get(node.path)
|
|
708
|
+
if resolved is None:
|
|
709
|
+
resource = self.resource_stories.get(node.ref or "")
|
|
710
|
+
if resource is None:
|
|
711
|
+
raise AudioIRReferenceError(
|
|
712
|
+
f"{node.path} references unknown compound source {node.ref!r}"
|
|
713
|
+
)
|
|
714
|
+
has_video, has_audio = _resource_stream_capabilities(
|
|
715
|
+
self.source,
|
|
716
|
+
resource,
|
|
717
|
+
self.resource_stories,
|
|
718
|
+
resource_chain=(resource.resource_id,),
|
|
719
|
+
)
|
|
720
|
+
resolved = resolve_compound_source(
|
|
721
|
+
resource,
|
|
722
|
+
has_video=has_video,
|
|
723
|
+
has_audio=has_audio,
|
|
724
|
+
)
|
|
725
|
+
self._compile_virtual_source(
|
|
726
|
+
node,
|
|
727
|
+
element,
|
|
728
|
+
resolved,
|
|
729
|
+
absolute=absolute,
|
|
730
|
+
enabled=enabled,
|
|
731
|
+
scopes=scopes,
|
|
732
|
+
direct_layer=direct_layer,
|
|
733
|
+
role_controls=own_role_controls,
|
|
734
|
+
audio_window=audio_window,
|
|
735
|
+
ancestor_paths=ancestor_paths,
|
|
736
|
+
)
|
|
737
|
+
if node.children:
|
|
738
|
+
self._walk_storyline(
|
|
739
|
+
node.children,
|
|
740
|
+
parent_absolute=absolute,
|
|
741
|
+
parent_source=node.start,
|
|
742
|
+
sequential=False,
|
|
743
|
+
inherited_enabled=enabled,
|
|
744
|
+
scopes=scopes,
|
|
745
|
+
component_overrides=(),
|
|
746
|
+
audio_window=None,
|
|
747
|
+
# A connected item is outside the referenced
|
|
748
|
+
# composition's completed audio pad and its controls.
|
|
749
|
+
ancestor_paths=ancestor_paths,
|
|
750
|
+
)
|
|
751
|
+
elif node.kind not in {"gap", "transition", "title", "caption", "video"}:
|
|
752
|
+
self.findings.append(
|
|
753
|
+
AudioIRFinding(
|
|
754
|
+
code="audio_story_kind_deferred",
|
|
755
|
+
path=node.path,
|
|
756
|
+
disposition="not_implemented_yet",
|
|
757
|
+
detail=f"audio scheduling for story kind {node.kind!r} is not implemented yet",
|
|
758
|
+
)
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
if sequential:
|
|
762
|
+
cursor = offset + node.duration
|
|
763
|
+
|
|
764
|
+
def _compile_virtual_source(
|
|
765
|
+
self,
|
|
766
|
+
node: StoryNode,
|
|
767
|
+
element: ET.Element,
|
|
768
|
+
source: RenderableAVSource,
|
|
769
|
+
*,
|
|
770
|
+
absolute: Fraction,
|
|
771
|
+
enabled: bool,
|
|
772
|
+
scopes: tuple[_AudioScope, ...],
|
|
773
|
+
direct_layer: AudioControlLayer,
|
|
774
|
+
role_controls: tuple[_RoleControl, ...],
|
|
775
|
+
audio_window: Optional[tuple[Fraction, Fraction]],
|
|
776
|
+
ancestor_paths: tuple[str, ...],
|
|
777
|
+
) -> None:
|
|
778
|
+
"""Schedule one resolved compound/multicam like an ordinary A/V source.
|
|
779
|
+
|
|
780
|
+
The source story is composed on its own source clock. The resulting
|
|
781
|
+
pad receives the instance timing and direct controls once; role-based
|
|
782
|
+
selectors stay inside the source so they can address real components.
|
|
783
|
+
"""
|
|
784
|
+
|
|
785
|
+
if not source.has_audio:
|
|
786
|
+
return
|
|
787
|
+
try:
|
|
788
|
+
timing = resolve_instance_stream_timing(
|
|
789
|
+
node,
|
|
790
|
+
absolute_start=absolute,
|
|
791
|
+
stream="audio",
|
|
792
|
+
)
|
|
793
|
+
except RenderSourceError as error:
|
|
794
|
+
raise AudioIRValidationError(str(error)) from error
|
|
795
|
+
source_frame_duration = (
|
|
796
|
+
source.format_context.frame_duration
|
|
797
|
+
if source.format_context is not None
|
|
798
|
+
else self.source.formats[
|
|
799
|
+
self.source.sequence_format_id
|
|
800
|
+
].frame_duration
|
|
801
|
+
)
|
|
802
|
+
if source_frame_duration is None:
|
|
803
|
+
raise AudioIRValidationError(
|
|
804
|
+
f"{node.path} source has no frame cadence for stream timing"
|
|
805
|
+
)
|
|
806
|
+
try:
|
|
807
|
+
required_window = source_window_for_retime(
|
|
808
|
+
timing.retime_map,
|
|
809
|
+
frame_duration=source_frame_duration,
|
|
810
|
+
)
|
|
811
|
+
except RenderSourceError as error:
|
|
812
|
+
raise AudioIRValidationError(str(error)) from error
|
|
813
|
+
source_end = source.end
|
|
814
|
+
if source_end is None:
|
|
815
|
+
raise AudioIRReferenceError(
|
|
816
|
+
f"{node.path} {source.kind} source has no finite duration"
|
|
817
|
+
)
|
|
818
|
+
source_tolerance = source_bound_tolerance(
|
|
819
|
+
has_video=False,
|
|
820
|
+
frame_duration=None,
|
|
821
|
+
has_audio=source.has_audio,
|
|
822
|
+
audio_rate=self.source.sequence_audio_rate,
|
|
823
|
+
)
|
|
824
|
+
if not source_range_within_bounds(
|
|
825
|
+
requested_start=required_window.start,
|
|
826
|
+
requested_end=required_window.end,
|
|
827
|
+
source_start=source.source_start,
|
|
828
|
+
source_end=source_end,
|
|
829
|
+
tolerance=source_tolerance,
|
|
830
|
+
):
|
|
831
|
+
raise AudioIRReferenceError(
|
|
832
|
+
f"{node.path} audio source range [{required_window.start}, "
|
|
833
|
+
f"{required_window.end}) is outside {source.kind} source "
|
|
834
|
+
f"[{source.source_start}, {source_end})"
|
|
835
|
+
)
|
|
836
|
+
timing = replace(
|
|
837
|
+
timing,
|
|
838
|
+
source_start=required_window.start,
|
|
839
|
+
source_duration=required_window.duration,
|
|
840
|
+
)
|
|
841
|
+
if audio_window is not None:
|
|
842
|
+
requested = (timing.absolute_start, timing.absolute_end)
|
|
843
|
+
intersection = _intersect_windows(audio_window, requested)
|
|
844
|
+
if intersection != requested:
|
|
845
|
+
raise AudioIRValidationError(
|
|
846
|
+
f"{node.path} virtual source is partially clipped by an outer "
|
|
847
|
+
"audio window; nested stream-map slicing is required"
|
|
848
|
+
)
|
|
849
|
+
self.source_instances.append(
|
|
850
|
+
AudioSourceInstance(
|
|
851
|
+
path=node.path,
|
|
852
|
+
source_id=source.id,
|
|
853
|
+
ancestor_paths=ancestor_paths,
|
|
854
|
+
timing=timing,
|
|
855
|
+
preserves_pitch=node.time_map_preserves_pitch,
|
|
856
|
+
controls=(() if direct_layer.is_empty else (direct_layer,)),
|
|
857
|
+
)
|
|
858
|
+
)
|
|
859
|
+
source_scopes = scopes
|
|
860
|
+
if role_controls:
|
|
861
|
+
source_scopes = (
|
|
862
|
+
*source_scopes,
|
|
863
|
+
_AudioScope(
|
|
864
|
+
node.path,
|
|
865
|
+
AudioControlLayer(path=node.path),
|
|
866
|
+
role_controls,
|
|
867
|
+
),
|
|
868
|
+
)
|
|
869
|
+
if source.audio_scope is not None:
|
|
870
|
+
scope_element = _xml_element(
|
|
871
|
+
source.audio_scope.raw_xml,
|
|
872
|
+
source.audio_scope.path,
|
|
873
|
+
)
|
|
874
|
+
scope_layer = _control_layer(scope_element, source.audio_scope.path)
|
|
875
|
+
scope_roles = _role_controls(scope_element, source.audio_scope.path)
|
|
876
|
+
self._register_role_controls(scope_roles)
|
|
877
|
+
if not scope_layer.is_empty or scope_roles:
|
|
878
|
+
source_scopes = (
|
|
879
|
+
*source_scopes,
|
|
880
|
+
_AudioScope(source.audio_scope.path, scope_layer, scope_roles),
|
|
881
|
+
)
|
|
882
|
+
local_window = (
|
|
883
|
+
timing.absolute_start,
|
|
884
|
+
timing.absolute_start + required_window.duration,
|
|
885
|
+
)
|
|
886
|
+
self._walk_storyline(
|
|
887
|
+
source.audio_story,
|
|
888
|
+
parent_absolute=timing.absolute_start,
|
|
889
|
+
parent_source=required_window.start,
|
|
890
|
+
sequential=True,
|
|
891
|
+
inherited_enabled=enabled,
|
|
892
|
+
scopes=source_scopes,
|
|
893
|
+
component_overrides=(),
|
|
894
|
+
audio_window=local_window,
|
|
895
|
+
ancestor_paths=(*ancestor_paths, node.path),
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
def _compile_file_source_instance(
|
|
899
|
+
self,
|
|
900
|
+
node: StoryNode,
|
|
901
|
+
element: ET.Element,
|
|
902
|
+
*,
|
|
903
|
+
absolute: Fraction,
|
|
904
|
+
enabled: bool,
|
|
905
|
+
scopes: tuple[_AudioScope, ...],
|
|
906
|
+
direct_layer: AudioControlLayer,
|
|
907
|
+
role_controls: tuple[_RoleControl, ...],
|
|
908
|
+
component_overrides: tuple[_Component, ...],
|
|
909
|
+
audio_window: Optional[tuple[Fraction, Fraction]],
|
|
910
|
+
ancestor_paths: tuple[str, ...],
|
|
911
|
+
) -> None:
|
|
912
|
+
"""Apply the common source-instance timing boundary to a file asset.
|
|
913
|
+
|
|
914
|
+
The raw file interval is scheduled as one descendant pad. Its clip
|
|
915
|
+
timeMap then runs on that completed pad, followed by direct gain,
|
|
916
|
+
panner, fades, and enhancements exactly once—the same order used for
|
|
917
|
+
compound and multicam sources.
|
|
918
|
+
"""
|
|
919
|
+
|
|
920
|
+
if not node.ref or node.ref not in self.source.assets:
|
|
921
|
+
raise AudioIRReferenceError(
|
|
922
|
+
f"{node.path} references unknown file source {node.ref!r}"
|
|
923
|
+
)
|
|
924
|
+
asset = self.source.assets[node.ref]
|
|
925
|
+
try:
|
|
926
|
+
timing = resolve_instance_stream_timing(
|
|
927
|
+
node,
|
|
928
|
+
absolute_start=absolute,
|
|
929
|
+
stream="audio",
|
|
930
|
+
)
|
|
931
|
+
except RenderSourceError as error:
|
|
932
|
+
raise AudioIRValidationError(str(error)) from error
|
|
933
|
+
source_format = (
|
|
934
|
+
self.source.formats.get(asset.format_id)
|
|
935
|
+
if asset.format_id is not None
|
|
936
|
+
else None
|
|
937
|
+
)
|
|
938
|
+
frame_duration = (
|
|
939
|
+
source_format.frame_duration
|
|
940
|
+
if source_format is not None
|
|
941
|
+
else self.source.formats[
|
|
942
|
+
self.source.sequence_format_id
|
|
943
|
+
].frame_duration
|
|
944
|
+
)
|
|
945
|
+
if frame_duration is None:
|
|
946
|
+
raise AudioIRValidationError(
|
|
947
|
+
f"{node.path} file source has no frame cadence"
|
|
948
|
+
)
|
|
949
|
+
required_window = source_window_for_retime(
|
|
950
|
+
timing.retime_map,
|
|
951
|
+
frame_duration=frame_duration,
|
|
952
|
+
)
|
|
953
|
+
asset_end = (
|
|
954
|
+
asset.start + asset.duration
|
|
955
|
+
if asset.duration is not None and asset.duration > 0
|
|
956
|
+
else None
|
|
957
|
+
)
|
|
958
|
+
source_tolerance = source_bound_tolerance(
|
|
959
|
+
has_video=asset.has_video,
|
|
960
|
+
frame_duration=frame_duration,
|
|
961
|
+
has_audio=asset.has_audio,
|
|
962
|
+
audio_rate=asset.audio_rate,
|
|
963
|
+
)
|
|
964
|
+
if not source_range_within_bounds(
|
|
965
|
+
requested_start=required_window.start,
|
|
966
|
+
requested_end=required_window.end,
|
|
967
|
+
source_start=asset.start,
|
|
968
|
+
source_end=asset_end if asset_end is not None else required_window.end,
|
|
969
|
+
tolerance=source_tolerance,
|
|
970
|
+
):
|
|
971
|
+
raise AudioIRReferenceError(
|
|
972
|
+
f"{node.path} audio source range [{required_window.start}, "
|
|
973
|
+
f"{required_window.end}) is outside file source range"
|
|
974
|
+
)
|
|
975
|
+
timing = replace(
|
|
976
|
+
timing,
|
|
977
|
+
source_start=required_window.start,
|
|
978
|
+
source_duration=required_window.duration,
|
|
979
|
+
)
|
|
980
|
+
self.source_instances.append(
|
|
981
|
+
AudioSourceInstance(
|
|
982
|
+
path=node.path,
|
|
983
|
+
source_id=f"file:{asset.id}",
|
|
984
|
+
ancestor_paths=ancestor_paths,
|
|
985
|
+
timing=timing,
|
|
986
|
+
preserves_pitch=node.time_map_preserves_pitch,
|
|
987
|
+
controls=(() if direct_layer.is_empty else (direct_layer,)),
|
|
988
|
+
)
|
|
989
|
+
)
|
|
990
|
+
source_scopes = scopes
|
|
991
|
+
if role_controls:
|
|
992
|
+
source_scopes = (
|
|
993
|
+
*source_scopes,
|
|
994
|
+
_AudioScope(
|
|
995
|
+
node.path,
|
|
996
|
+
AudioControlLayer(path=node.path),
|
|
997
|
+
role_controls,
|
|
998
|
+
),
|
|
999
|
+
)
|
|
1000
|
+
raw_node = replace(
|
|
1001
|
+
node,
|
|
1002
|
+
time_map=(),
|
|
1003
|
+
audio_start=None,
|
|
1004
|
+
audio_duration=None,
|
|
1005
|
+
)
|
|
1006
|
+
self._compile_asset_node(
|
|
1007
|
+
raw_node,
|
|
1008
|
+
element,
|
|
1009
|
+
absolute=timing.absolute_start,
|
|
1010
|
+
enabled=enabled,
|
|
1011
|
+
scopes=source_scopes,
|
|
1012
|
+
component_overrides=component_overrides,
|
|
1013
|
+
audio_window=audio_window,
|
|
1014
|
+
ancestor_paths=(*ancestor_paths, node.path),
|
|
1015
|
+
forced_source_start=required_window.start,
|
|
1016
|
+
forced_duration=required_window.duration,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
def _compile_asset_node(
|
|
1020
|
+
self,
|
|
1021
|
+
node: StoryNode,
|
|
1022
|
+
element: ET.Element,
|
|
1023
|
+
*,
|
|
1024
|
+
absolute: Fraction,
|
|
1025
|
+
enabled: bool,
|
|
1026
|
+
scopes: tuple[_AudioScope, ...],
|
|
1027
|
+
component_overrides: tuple[_Component, ...],
|
|
1028
|
+
audio_window: Optional[tuple[Fraction, Fraction]],
|
|
1029
|
+
ancestor_paths: tuple[str, ...],
|
|
1030
|
+
forced_source_start: Optional[Fraction] = None,
|
|
1031
|
+
forced_duration: Optional[Fraction] = None,
|
|
1032
|
+
) -> None:
|
|
1033
|
+
if node.kind == "asset-clip" and node.src_enable == "video":
|
|
1034
|
+
return
|
|
1035
|
+
if not node.ref:
|
|
1036
|
+
raise AudioIRReferenceError(f"{node.path} has no asset ref")
|
|
1037
|
+
asset = self.source.assets.get(node.ref)
|
|
1038
|
+
if asset is None:
|
|
1039
|
+
raise AudioIRReferenceError(
|
|
1040
|
+
f"{node.path} references unknown audio asset {node.ref!r}"
|
|
1041
|
+
)
|
|
1042
|
+
if not asset.has_audio:
|
|
1043
|
+
if node.kind == "audio" or node.src_enable == "audio":
|
|
1044
|
+
raise AudioIRReferenceError(
|
|
1045
|
+
f"{node.path} requests audio from asset {asset.id!r} without audio"
|
|
1046
|
+
)
|
|
1047
|
+
return
|
|
1048
|
+
components = component_overrides or _components(
|
|
1049
|
+
element,
|
|
1050
|
+
node,
|
|
1051
|
+
asset,
|
|
1052
|
+
self.source.sequence_audio_layout,
|
|
1053
|
+
)
|
|
1054
|
+
base_start = (
|
|
1055
|
+
forced_source_start
|
|
1056
|
+
if forced_source_start is not None
|
|
1057
|
+
else (node.audio_start if node.audio_start is not None else node.start)
|
|
1058
|
+
)
|
|
1059
|
+
base_duration = (
|
|
1060
|
+
forced_duration
|
|
1061
|
+
if forced_duration is not None
|
|
1062
|
+
else (
|
|
1063
|
+
node.audio_duration
|
|
1064
|
+
if node.audio_duration is not None
|
|
1065
|
+
else node.duration
|
|
1066
|
+
)
|
|
1067
|
+
)
|
|
1068
|
+
base_absolute = (
|
|
1069
|
+
absolute
|
|
1070
|
+
if forced_source_start is not None
|
|
1071
|
+
else absolute + base_start - node.start
|
|
1072
|
+
)
|
|
1073
|
+
if base_duration <= 0:
|
|
1074
|
+
raise AudioIRValidationError(f"{node.path} audio duration must be positive")
|
|
1075
|
+
|
|
1076
|
+
for component in components:
|
|
1077
|
+
stream_id = _resolve_stream_id(component, asset, node.path)
|
|
1078
|
+
source_channels = _resolve_source_channels(component, asset, node.path)
|
|
1079
|
+
if not source_channels and asset.audio_channels is None:
|
|
1080
|
+
self.findings.append(
|
|
1081
|
+
AudioIRFinding(
|
|
1082
|
+
code="audio_channels_probe_required",
|
|
1083
|
+
path=component.path,
|
|
1084
|
+
disposition="preserved",
|
|
1085
|
+
detail=(
|
|
1086
|
+
"FCPXML omitted explicit srcCh; the complete source "
|
|
1087
|
+
"channel set is resolved from bound media metadata"
|
|
1088
|
+
),
|
|
1089
|
+
)
|
|
1090
|
+
)
|
|
1091
|
+
source_start, duration, item_absolute = _component_interval(
|
|
1092
|
+
component,
|
|
1093
|
+
base_start=base_start,
|
|
1094
|
+
base_duration=base_duration,
|
|
1095
|
+
base_absolute=base_absolute,
|
|
1096
|
+
)
|
|
1097
|
+
if audio_window is not None:
|
|
1098
|
+
windowed = _clip_to_window(
|
|
1099
|
+
item_absolute,
|
|
1100
|
+
source_start,
|
|
1101
|
+
duration,
|
|
1102
|
+
audio_window,
|
|
1103
|
+
)
|
|
1104
|
+
if windowed is None:
|
|
1105
|
+
continue
|
|
1106
|
+
item_absolute, source_start, duration = windowed
|
|
1107
|
+
schedule_limit = self.source.sequence_duration
|
|
1108
|
+
for instance in self.source_instances:
|
|
1109
|
+
if instance.path in ancestor_paths:
|
|
1110
|
+
schedule_limit = max(
|
|
1111
|
+
schedule_limit,
|
|
1112
|
+
instance.timing.absolute_start
|
|
1113
|
+
+ instance.timing.source_duration,
|
|
1114
|
+
)
|
|
1115
|
+
clipped = _clip_to_sequence(
|
|
1116
|
+
item_absolute,
|
|
1117
|
+
source_start,
|
|
1118
|
+
duration,
|
|
1119
|
+
schedule_limit,
|
|
1120
|
+
)
|
|
1121
|
+
if clipped is None:
|
|
1122
|
+
continue
|
|
1123
|
+
item_absolute, source_start, duration = clipped
|
|
1124
|
+
source_format = (
|
|
1125
|
+
self.source.formats.get(asset.format_id)
|
|
1126
|
+
if asset.format_id is not None
|
|
1127
|
+
else None
|
|
1128
|
+
)
|
|
1129
|
+
_validate_asset_range(
|
|
1130
|
+
asset,
|
|
1131
|
+
source_start,
|
|
1132
|
+
duration,
|
|
1133
|
+
component.path,
|
|
1134
|
+
frame_duration=(
|
|
1135
|
+
source_format.frame_duration if source_format is not None else None
|
|
1136
|
+
),
|
|
1137
|
+
)
|
|
1138
|
+
matched_controls: list[_RoleControl] = []
|
|
1139
|
+
component_layers = (
|
|
1140
|
+
() if component.controls.is_empty else (component.controls,)
|
|
1141
|
+
)
|
|
1142
|
+
ordered_scope_layers: list[AudioControlLayer] = []
|
|
1143
|
+
for scope in reversed(scopes):
|
|
1144
|
+
for role_control in scope.role_controls:
|
|
1145
|
+
if _role_matches(role_control.selector, component.role):
|
|
1146
|
+
self._role_match_counts[role_control.path] += 1
|
|
1147
|
+
matched_controls.append(role_control)
|
|
1148
|
+
if not role_control.controls.is_empty:
|
|
1149
|
+
ordered_scope_layers.append(role_control.controls)
|
|
1150
|
+
if not scope.controls.is_empty:
|
|
1151
|
+
ordered_scope_layers.append(scope.controls)
|
|
1152
|
+
control_layers = (*component_layers, *ordered_scope_layers)
|
|
1153
|
+
self._append_item(
|
|
1154
|
+
path=component.path,
|
|
1155
|
+
name=node.name or asset.name,
|
|
1156
|
+
absolute_start=item_absolute,
|
|
1157
|
+
duration=duration,
|
|
1158
|
+
source_start=source_start,
|
|
1159
|
+
asset=asset,
|
|
1160
|
+
source_stream_id=stream_id,
|
|
1161
|
+
source_channels=source_channels,
|
|
1162
|
+
output_channels=component.output_channels,
|
|
1163
|
+
role=component.role,
|
|
1164
|
+
enabled=enabled and component.enabled,
|
|
1165
|
+
active=component.active
|
|
1166
|
+
and all(control.active for control in matched_controls),
|
|
1167
|
+
control_layers=control_layers,
|
|
1168
|
+
node=node,
|
|
1169
|
+
ancestor_paths=ancestor_paths,
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
def _register_role_controls(self, controls: tuple[_RoleControl, ...]) -> None:
|
|
1173
|
+
for control in controls:
|
|
1174
|
+
if control.path in self._role_match_counts:
|
|
1175
|
+
raise AudioIRValidationError(
|
|
1176
|
+
f"duplicate role-control path {control.path!r}"
|
|
1177
|
+
)
|
|
1178
|
+
self._role_match_counts[control.path] = 0
|
|
1179
|
+
|
|
1180
|
+
def _append_item(
|
|
1181
|
+
self,
|
|
1182
|
+
*,
|
|
1183
|
+
path: str,
|
|
1184
|
+
name: Optional[str],
|
|
1185
|
+
absolute_start: Fraction,
|
|
1186
|
+
duration: Fraction,
|
|
1187
|
+
source_start: Fraction,
|
|
1188
|
+
asset: AssetResource,
|
|
1189
|
+
source_stream_id: str,
|
|
1190
|
+
source_channels: tuple[int, ...],
|
|
1191
|
+
output_channels: Optional[tuple[str, ...]],
|
|
1192
|
+
role: Optional[AudioRole],
|
|
1193
|
+
enabled: bool,
|
|
1194
|
+
active: bool,
|
|
1195
|
+
control_layers: tuple[AudioControlLayer, ...],
|
|
1196
|
+
node: StoryNode,
|
|
1197
|
+
ancestor_paths: tuple[str, ...],
|
|
1198
|
+
) -> None:
|
|
1199
|
+
self._item_counter += 1
|
|
1200
|
+
source_origin = asset.start
|
|
1201
|
+
retime = tuple(
|
|
1202
|
+
AudioRetimePoint(
|
|
1203
|
+
point.time,
|
|
1204
|
+
point.value - source_origin,
|
|
1205
|
+
point.interp,
|
|
1206
|
+
)
|
|
1207
|
+
for point in node.time_map
|
|
1208
|
+
)
|
|
1209
|
+
local_source_start = source_start - source_origin
|
|
1210
|
+
source_duration = duration
|
|
1211
|
+
if retime:
|
|
1212
|
+
source_duration = max(
|
|
1213
|
+
source_duration,
|
|
1214
|
+
max(point.source_time for point in retime) - local_source_start,
|
|
1215
|
+
)
|
|
1216
|
+
item = RenderAudioItem(
|
|
1217
|
+
id=f"audio-{self._item_counter}",
|
|
1218
|
+
path=path,
|
|
1219
|
+
name=name,
|
|
1220
|
+
absolute_start=absolute_start,
|
|
1221
|
+
duration=duration,
|
|
1222
|
+
source_start=local_source_start,
|
|
1223
|
+
source_duration=source_duration,
|
|
1224
|
+
asset_id=asset.id,
|
|
1225
|
+
asset_uid=asset.uid,
|
|
1226
|
+
source_stream_id=source_stream_id,
|
|
1227
|
+
source_sample_rate=asset.audio_rate,
|
|
1228
|
+
source_channels=source_channels,
|
|
1229
|
+
output_channels=output_channels,
|
|
1230
|
+
role=role,
|
|
1231
|
+
enabled=enabled,
|
|
1232
|
+
active=active,
|
|
1233
|
+
control_layers=control_layers,
|
|
1234
|
+
retime=retime,
|
|
1235
|
+
preserves_pitch=node.time_map_preserves_pitch,
|
|
1236
|
+
ancestor_paths=ancestor_paths,
|
|
1237
|
+
)
|
|
1238
|
+
self.items.append(item)
|
|
1239
|
+
if not item.audible:
|
|
1240
|
+
self.findings.append(
|
|
1241
|
+
AudioIRFinding(
|
|
1242
|
+
code="audio_component_silent",
|
|
1243
|
+
path=path,
|
|
1244
|
+
disposition="inactive",
|
|
1245
|
+
detail="disabled clip or inactive component produces silence",
|
|
1246
|
+
)
|
|
1247
|
+
)
|
|
1248
|
+
for layer in control_layers:
|
|
1249
|
+
for enhancement in layer.enhancements:
|
|
1250
|
+
if enhancement.backend_status == "not_implemented_yet":
|
|
1251
|
+
self.findings.append(
|
|
1252
|
+
AudioIRFinding(
|
|
1253
|
+
code="audio_enhancement_not_implemented",
|
|
1254
|
+
path=layer.path,
|
|
1255
|
+
disposition="not_implemented_yet",
|
|
1256
|
+
detail=f"{enhancement.kind} is preserved but has no portable backend",
|
|
1257
|
+
)
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _resource_stream_capabilities(
|
|
1262
|
+
source: SourceDocument,
|
|
1263
|
+
resource: "ResourceStory",
|
|
1264
|
+
resources: Mapping[str, "ResourceStory"],
|
|
1265
|
+
*,
|
|
1266
|
+
resource_chain: tuple[str, ...],
|
|
1267
|
+
) -> tuple[bool, bool]:
|
|
1268
|
+
"""Resolve compound capabilities for standalone audio-IR callers."""
|
|
1269
|
+
|
|
1270
|
+
def visit(
|
|
1271
|
+
nodes: tuple[StoryNode, ...],
|
|
1272
|
+
chain: tuple[str, ...],
|
|
1273
|
+
) -> tuple[bool, bool]:
|
|
1274
|
+
has_video = False
|
|
1275
|
+
has_audio = False
|
|
1276
|
+
for node in nodes:
|
|
1277
|
+
if not node.enabled or node.src_enable == "none":
|
|
1278
|
+
continue
|
|
1279
|
+
if node.kind in {"asset-clip", "video", "audio"} and node.ref:
|
|
1280
|
+
asset = source.assets.get(node.ref)
|
|
1281
|
+
if asset is not None:
|
|
1282
|
+
has_video = has_video or (
|
|
1283
|
+
asset.has_video
|
|
1284
|
+
and node.kind != "audio"
|
|
1285
|
+
and node.src_enable != "audio"
|
|
1286
|
+
)
|
|
1287
|
+
has_audio = has_audio or (
|
|
1288
|
+
asset.has_audio
|
|
1289
|
+
and node.kind != "video"
|
|
1290
|
+
and node.src_enable != "video"
|
|
1291
|
+
)
|
|
1292
|
+
elif node.kind in {"title", "caption"}:
|
|
1293
|
+
has_video = True
|
|
1294
|
+
elif node.kind == "ref-clip":
|
|
1295
|
+
child = resources.get(node.ref or "")
|
|
1296
|
+
if child is None:
|
|
1297
|
+
raise AudioIRReferenceError(
|
|
1298
|
+
f"{node.path} references unknown compound source {node.ref!r}"
|
|
1299
|
+
)
|
|
1300
|
+
if child.resource_id in chain:
|
|
1301
|
+
readable = " -> ".join((*chain, child.resource_id))
|
|
1302
|
+
raise AudioIRReferenceError(
|
|
1303
|
+
f"recursive render source reference: {readable}"
|
|
1304
|
+
)
|
|
1305
|
+
child_video, child_audio = visit(
|
|
1306
|
+
child.story,
|
|
1307
|
+
(*chain, child.resource_id),
|
|
1308
|
+
)
|
|
1309
|
+
has_video = has_video or child_video
|
|
1310
|
+
has_audio = has_audio or child_audio
|
|
1311
|
+
if node.children:
|
|
1312
|
+
child_video, child_audio = visit(node.children, chain)
|
|
1313
|
+
has_video = has_video or child_video
|
|
1314
|
+
has_audio = has_audio or child_audio
|
|
1315
|
+
return has_video, has_audio
|
|
1316
|
+
|
|
1317
|
+
return visit(resource.story, resource_chain)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def compile_audio_ir(
|
|
1321
|
+
source: SourceDocument,
|
|
1322
|
+
*,
|
|
1323
|
+
resource_stories: Optional[Mapping[str, "ResourceStory"]] = None,
|
|
1324
|
+
render_sources: Optional[Mapping[str, RenderableAVSource]] = None,
|
|
1325
|
+
) -> AudioRenderPlan:
|
|
1326
|
+
"""Compile the parsed source model into independent audio IR v2.
|
|
1327
|
+
|
|
1328
|
+
Main callers:
|
|
1329
|
+
- The future ``AUDIO-2`` FFmpeg audio engine.
|
|
1330
|
+
- Experimental source/IR audits.
|
|
1331
|
+
|
|
1332
|
+
This routine performs no media I/O and changes no shared renderer state.
|
|
1333
|
+
"""
|
|
1334
|
+
|
|
1335
|
+
return _AudioIRBuilder(
|
|
1336
|
+
source,
|
|
1337
|
+
resource_stories=resource_stories or {},
|
|
1338
|
+
render_sources=render_sources or {},
|
|
1339
|
+
).build()
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _xml_element(raw_xml: str, path: str) -> ET.Element:
|
|
1343
|
+
try:
|
|
1344
|
+
return ET.fromstring(raw_xml)
|
|
1345
|
+
except ET.ParseError as exc:
|
|
1346
|
+
raise AudioIRValidationError(
|
|
1347
|
+
f"{path} has invalid preserved XML: {exc}"
|
|
1348
|
+
) from exc
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def _tag(element: ET.Element) -> str:
|
|
1352
|
+
return element.tag.rsplit("}", 1)[-1]
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def _direct_children(element: ET.Element, *kinds: str) -> tuple[ET.Element, ...]:
|
|
1356
|
+
accepted = set(kinds)
|
|
1357
|
+
return tuple(child for child in element if _tag(child) in accepted)
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _bool_attribute(element: ET.Element, name: str, *, default: bool) -> bool:
|
|
1361
|
+
raw = element.get(name)
|
|
1362
|
+
if raw is None:
|
|
1363
|
+
return default
|
|
1364
|
+
if raw not in {"0", "1"}:
|
|
1365
|
+
raise AudioIRValidationError(f"<{_tag(element)}> has invalid {name}={raw!r}")
|
|
1366
|
+
return raw == "1"
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def _time_attribute(element: ET.Element, name: str, path: str) -> Optional[Fraction]:
|
|
1370
|
+
try:
|
|
1371
|
+
value = parse_time(element.get(name), field_name=f"{path} {name}")
|
|
1372
|
+
except ValueError as exc:
|
|
1373
|
+
raise AudioIRValidationError(str(exc)) from exc
|
|
1374
|
+
return value
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def _float_value(
|
|
1378
|
+
raw: Optional[str], *, path: str, default: float, suffix: Optional[str] = None
|
|
1379
|
+
) -> float:
|
|
1380
|
+
if raw is None:
|
|
1381
|
+
return default
|
|
1382
|
+
text = raw.strip()
|
|
1383
|
+
if suffix and text.endswith(suffix):
|
|
1384
|
+
text = text[: -len(suffix)]
|
|
1385
|
+
try:
|
|
1386
|
+
value = float(text)
|
|
1387
|
+
except ValueError as exc:
|
|
1388
|
+
raise AudioIRValidationError(
|
|
1389
|
+
f"{path} has invalid numeric value {raw!r}"
|
|
1390
|
+
) from exc
|
|
1391
|
+
if not math.isfinite(value):
|
|
1392
|
+
raise AudioIRValidationError(f"{path} has non-finite numeric value {raw!r}")
|
|
1393
|
+
return value
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _role(raw: Optional[str], path: str) -> Optional[AudioRole]:
|
|
1397
|
+
if raw is None:
|
|
1398
|
+
return None
|
|
1399
|
+
text = raw.strip()
|
|
1400
|
+
if not text:
|
|
1401
|
+
raise AudioIRValidationError(f"{path} has an empty role")
|
|
1402
|
+
primary, separator, subrole = text.partition(".")
|
|
1403
|
+
if not primary.strip() or (separator and not subrole.strip()):
|
|
1404
|
+
raise AudioIRValidationError(f"{path} has invalid role {raw!r}")
|
|
1405
|
+
return AudioRole(primary.strip(), subrole.strip() if separator else None)
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _role_matches(selector: AudioRole, role: Optional[AudioRole]) -> bool:
|
|
1409
|
+
if role is None or selector.primary != role.primary:
|
|
1410
|
+
return False
|
|
1411
|
+
if selector.subrole is None or selector.subrole == role.subrole:
|
|
1412
|
+
return True
|
|
1413
|
+
return (
|
|
1414
|
+
role.subrole is None
|
|
1415
|
+
and selector.subrole == f"{role.primary}-1"
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _channel_numbers(raw: str, path: str) -> tuple[int, ...]:
|
|
1420
|
+
values: list[int] = []
|
|
1421
|
+
for piece in raw.split(","):
|
|
1422
|
+
try:
|
|
1423
|
+
value = int(piece.strip())
|
|
1424
|
+
except ValueError as exc:
|
|
1425
|
+
raise AudioIRValidationError(
|
|
1426
|
+
f"{path} has invalid source channel list {raw!r}"
|
|
1427
|
+
) from exc
|
|
1428
|
+
if value < 1:
|
|
1429
|
+
raise AudioIRValidationError(
|
|
1430
|
+
f"{path} source channels are one-based, got {value}"
|
|
1431
|
+
)
|
|
1432
|
+
values.append(value)
|
|
1433
|
+
if not values or len(set(values)) != len(values):
|
|
1434
|
+
raise AudioIRValidationError(
|
|
1435
|
+
f"{path} has empty or duplicate source channels {raw!r}"
|
|
1436
|
+
)
|
|
1437
|
+
return tuple(values)
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _output_channel_names(raw: str, layout: AudioLayout, path: str) -> tuple[str, ...]:
|
|
1441
|
+
values = tuple(piece.strip() for piece in raw.split(",") if piece.strip())
|
|
1442
|
+
allowed = {*_OUTPUT_CHANNELS[layout], "X"}
|
|
1443
|
+
if not values or any(value not in allowed for value in values):
|
|
1444
|
+
raise AudioIRReferenceError(
|
|
1445
|
+
f"{path} has output channels {raw!r} outside {layout} layout {sorted(allowed)}"
|
|
1446
|
+
)
|
|
1447
|
+
return values
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _fades(parent: ET.Element, path: str) -> tuple[AudioFade, ...]:
|
|
1451
|
+
fades: list[AudioFade] = []
|
|
1452
|
+
containers = [parent]
|
|
1453
|
+
for parameter in _direct_children(parent, "param"):
|
|
1454
|
+
if (parameter.get("name") or "").casefold() != "amount":
|
|
1455
|
+
continue
|
|
1456
|
+
if not _bool_attribute(parameter, "enabled", default=True):
|
|
1457
|
+
continue
|
|
1458
|
+
containers.append(parameter)
|
|
1459
|
+
for container in containers:
|
|
1460
|
+
for child in container:
|
|
1461
|
+
kind = _tag(child)
|
|
1462
|
+
if kind not in {"fadeIn", "fadeOut"}:
|
|
1463
|
+
continue
|
|
1464
|
+
duration = _time_attribute(child, "duration", f"{path}/{kind}")
|
|
1465
|
+
if duration is None or duration < 0:
|
|
1466
|
+
raise AudioIRValidationError(
|
|
1467
|
+
f"{path}/{kind} requires a non-negative duration"
|
|
1468
|
+
)
|
|
1469
|
+
default_curve = "easeIn" if kind == "fadeIn" else "easeOut"
|
|
1470
|
+
fades.append(
|
|
1471
|
+
AudioFade(
|
|
1472
|
+
"in" if kind == "fadeIn" else "out",
|
|
1473
|
+
duration,
|
|
1474
|
+
child.get("type") or default_curve,
|
|
1475
|
+
)
|
|
1476
|
+
)
|
|
1477
|
+
return tuple(fades)
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _automation(
|
|
1481
|
+
parent: ET.Element,
|
|
1482
|
+
*,
|
|
1483
|
+
parameter_name: str,
|
|
1484
|
+
path: str,
|
|
1485
|
+
initial: float,
|
|
1486
|
+
unit: Literal["dB", "normalized", "raw"],
|
|
1487
|
+
suffix: Optional[str] = None,
|
|
1488
|
+
) -> AnimatedAudioScalar:
|
|
1489
|
+
points: list[AudioAutomationPoint] = []
|
|
1490
|
+
for parameter in _direct_children(parent, "param"):
|
|
1491
|
+
if (parameter.get("name") or "").casefold() != parameter_name.casefold():
|
|
1492
|
+
continue
|
|
1493
|
+
if not _bool_attribute(parameter, "enabled", default=True):
|
|
1494
|
+
continue
|
|
1495
|
+
for animation in _direct_children(parameter, "keyframeAnimation"):
|
|
1496
|
+
for keyframe in _direct_children(animation, "keyframe"):
|
|
1497
|
+
time = _time_attribute(keyframe, "time", f"{path}/keyframe")
|
|
1498
|
+
if time is None:
|
|
1499
|
+
raise AudioIRValidationError(f"{path}/keyframe is missing time")
|
|
1500
|
+
value = _float_value(
|
|
1501
|
+
keyframe.get("value"),
|
|
1502
|
+
path=f"{path}/keyframe@value",
|
|
1503
|
+
default=initial,
|
|
1504
|
+
suffix=suffix,
|
|
1505
|
+
)
|
|
1506
|
+
points.append(
|
|
1507
|
+
AudioAutomationPoint(
|
|
1508
|
+
time=time,
|
|
1509
|
+
value=value,
|
|
1510
|
+
interp=keyframe.get("interp") or "linear",
|
|
1511
|
+
curve=keyframe.get("curve") or "smooth",
|
|
1512
|
+
aux_value=keyframe.get("auxValue"),
|
|
1513
|
+
)
|
|
1514
|
+
)
|
|
1515
|
+
points.sort(key=lambda point: point.time)
|
|
1516
|
+
if len({point.time for point in points}) != len(points):
|
|
1517
|
+
raise AudioIRValidationError(f"{path} has duplicate automation times")
|
|
1518
|
+
return AnimatedAudioScalar(
|
|
1519
|
+
initial=initial, unit=unit, keyframes=tuple(points), fades=_fades(parent, path)
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def _control_layer(element: ET.Element, path: str) -> AudioControlLayer:
|
|
1524
|
+
gain: Optional[AnimatedAudioScalar] = None
|
|
1525
|
+
panner: Optional[AudioPanner] = None
|
|
1526
|
+
mutes: list[AudioMuteRange] = []
|
|
1527
|
+
enhancements: list[AudioEnhancement] = []
|
|
1528
|
+
for child in element:
|
|
1529
|
+
kind = _tag(child)
|
|
1530
|
+
child_path = f"{path}/{kind}"
|
|
1531
|
+
if kind == "adjust-volume":
|
|
1532
|
+
if gain is not None:
|
|
1533
|
+
raise AudioIRAmbiguityError(
|
|
1534
|
+
f"{path} has more than one direct adjust-volume"
|
|
1535
|
+
)
|
|
1536
|
+
initial = _float_value(
|
|
1537
|
+
child.get("amount"),
|
|
1538
|
+
path=f"{child_path}@amount",
|
|
1539
|
+
default=0.0,
|
|
1540
|
+
suffix="dB",
|
|
1541
|
+
)
|
|
1542
|
+
gain = _automation(
|
|
1543
|
+
child,
|
|
1544
|
+
parameter_name="amount",
|
|
1545
|
+
path=child_path,
|
|
1546
|
+
initial=initial,
|
|
1547
|
+
unit="dB",
|
|
1548
|
+
suffix="dB",
|
|
1549
|
+
)
|
|
1550
|
+
elif kind == "adjust-panner":
|
|
1551
|
+
if panner is not None:
|
|
1552
|
+
raise AudioIRAmbiguityError(
|
|
1553
|
+
f"{path} has more than one direct adjust-panner"
|
|
1554
|
+
)
|
|
1555
|
+
raw_mode = child.get("mode")
|
|
1556
|
+
mode = raw_mode
|
|
1557
|
+
amount_scale = 1.0
|
|
1558
|
+
if raw_mode and raw_mode.strip() in {"1", "1 (Stereo Left/Right)"}:
|
|
1559
|
+
# Final Cut's canonical stereo token is
|
|
1560
|
+
# ``1 (Stereo Left/Right)`` and its amount is a percentage in
|
|
1561
|
+
# [-100, 100]. Normalize it once at the source/IR boundary so
|
|
1562
|
+
# execution continues to consume the typed [-1, 1] contract.
|
|
1563
|
+
mode = "stereo"
|
|
1564
|
+
amount_scale = 0.01
|
|
1565
|
+
amount = _float_value(
|
|
1566
|
+
child.get("amount"), path=f"{child_path}@amount", default=0.0
|
|
1567
|
+
) * amount_scale
|
|
1568
|
+
parameters: dict[str, float | str] = {}
|
|
1569
|
+
for name, raw in child.attrib.items():
|
|
1570
|
+
if name in {"mode", "amount"}:
|
|
1571
|
+
continue
|
|
1572
|
+
try:
|
|
1573
|
+
parameters[name] = _float_value(
|
|
1574
|
+
raw, path=f"{child_path}@{name}", default=0.0
|
|
1575
|
+
)
|
|
1576
|
+
except AudioIRValidationError:
|
|
1577
|
+
parameters[name] = raw
|
|
1578
|
+
automation = _automation(
|
|
1579
|
+
child,
|
|
1580
|
+
parameter_name="amount",
|
|
1581
|
+
path=child_path,
|
|
1582
|
+
initial=amount / amount_scale if amount_scale != 1.0 else amount,
|
|
1583
|
+
unit="normalized",
|
|
1584
|
+
)
|
|
1585
|
+
if amount_scale != 1.0:
|
|
1586
|
+
automation = AnimatedAudioScalar(
|
|
1587
|
+
initial=automation.initial * amount_scale,
|
|
1588
|
+
unit=automation.unit,
|
|
1589
|
+
keyframes=tuple(
|
|
1590
|
+
AudioAutomationPoint(
|
|
1591
|
+
time=point.time,
|
|
1592
|
+
value=point.value * amount_scale,
|
|
1593
|
+
interp=point.interp,
|
|
1594
|
+
curve=point.curve,
|
|
1595
|
+
aux_value=point.aux_value,
|
|
1596
|
+
)
|
|
1597
|
+
for point in automation.keyframes
|
|
1598
|
+
),
|
|
1599
|
+
fades=automation.fades,
|
|
1600
|
+
)
|
|
1601
|
+
panner = AudioPanner(
|
|
1602
|
+
mode=mode,
|
|
1603
|
+
amount=automation,
|
|
1604
|
+
parameters=parameters,
|
|
1605
|
+
)
|
|
1606
|
+
elif kind == "mute":
|
|
1607
|
+
duration = _time_attribute(child, "duration", child_path)
|
|
1608
|
+
if duration is not None and duration < 0:
|
|
1609
|
+
raise AudioIRValidationError(
|
|
1610
|
+
f"{child_path} duration must not be negative"
|
|
1611
|
+
)
|
|
1612
|
+
mutes.append(
|
|
1613
|
+
AudioMuteRange(
|
|
1614
|
+
source_start=_time_attribute(child, "start", child_path),
|
|
1615
|
+
duration=duration,
|
|
1616
|
+
fades=_fades(child, child_path),
|
|
1617
|
+
)
|
|
1618
|
+
)
|
|
1619
|
+
elif kind in _ENHANCEMENT_KINDS:
|
|
1620
|
+
parameters = {
|
|
1621
|
+
(param.get("key") or param.get("name") or ""): param.get("value") or ""
|
|
1622
|
+
for param in _direct_children(child, "param")
|
|
1623
|
+
}
|
|
1624
|
+
data_element = next(
|
|
1625
|
+
(entry for entry in child if _tag(entry) == "data"), None
|
|
1626
|
+
)
|
|
1627
|
+
enhancements.append(
|
|
1628
|
+
AudioEnhancement(
|
|
1629
|
+
kind=kind,
|
|
1630
|
+
attributes=dict(child.attrib),
|
|
1631
|
+
parameters=parameters,
|
|
1632
|
+
backend_status="not_implemented_yet"
|
|
1633
|
+
if kind == "adjust-matchEQ"
|
|
1634
|
+
else "pending_audio_3",
|
|
1635
|
+
opaque_data=(data_element.text or "")
|
|
1636
|
+
if data_element is not None
|
|
1637
|
+
else None,
|
|
1638
|
+
)
|
|
1639
|
+
)
|
|
1640
|
+
elif kind == "filter-audio":
|
|
1641
|
+
enhancements.append(
|
|
1642
|
+
AudioEnhancement(
|
|
1643
|
+
kind=kind,
|
|
1644
|
+
attributes=dict(child.attrib),
|
|
1645
|
+
parameters={},
|
|
1646
|
+
backend_status="not_implemented_yet",
|
|
1647
|
+
)
|
|
1648
|
+
)
|
|
1649
|
+
return AudioControlLayer(
|
|
1650
|
+
path=path,
|
|
1651
|
+
gain=gain,
|
|
1652
|
+
panner=panner,
|
|
1653
|
+
mutes=tuple(mutes),
|
|
1654
|
+
enhancements=tuple(enhancements),
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
def _role_controls(element: ET.Element, path: str) -> tuple[_RoleControl, ...]:
|
|
1659
|
+
controls: list[_RoleControl] = []
|
|
1660
|
+
for index, child in enumerate(
|
|
1661
|
+
_direct_children(element, "audio-role-source"), start=1
|
|
1662
|
+
):
|
|
1663
|
+
child_path = f"{path}/audio-role-source[{index}]"
|
|
1664
|
+
selector = _role(child.get("role"), child_path)
|
|
1665
|
+
if selector is None:
|
|
1666
|
+
raise AudioIRValidationError(f"{child_path} is missing role")
|
|
1667
|
+
enabled = _bool_attribute(child, "enabled", default=True)
|
|
1668
|
+
active = _bool_attribute(child, "active", default=True)
|
|
1669
|
+
parsed_layer = _control_layer(child, child_path)
|
|
1670
|
+
source_duration = _time_attribute(child, "duration", child_path)
|
|
1671
|
+
if source_duration is not None and source_duration <= 0:
|
|
1672
|
+
raise AudioIRValidationError(f"{child_path} duration must be positive")
|
|
1673
|
+
layer = AudioControlLayer(
|
|
1674
|
+
path=parsed_layer.path,
|
|
1675
|
+
gain=parsed_layer.gain,
|
|
1676
|
+
panner=parsed_layer.panner,
|
|
1677
|
+
mutes=parsed_layer.mutes,
|
|
1678
|
+
enhancements=parsed_layer.enhancements,
|
|
1679
|
+
role_selector=selector,
|
|
1680
|
+
source_start=_time_attribute(child, "start", child_path),
|
|
1681
|
+
source_duration=source_duration,
|
|
1682
|
+
enabled=enabled,
|
|
1683
|
+
active=active,
|
|
1684
|
+
)
|
|
1685
|
+
controls.append(
|
|
1686
|
+
_RoleControl(
|
|
1687
|
+
selector=selector,
|
|
1688
|
+
controls=layer,
|
|
1689
|
+
path=child_path,
|
|
1690
|
+
active=enabled and active,
|
|
1691
|
+
)
|
|
1692
|
+
)
|
|
1693
|
+
return tuple(controls)
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
def _sync_role_controls(
|
|
1697
|
+
element: ET.Element,
|
|
1698
|
+
path: str,
|
|
1699
|
+
source_id: Literal["storyline", "connected"],
|
|
1700
|
+
) -> tuple[_RoleControl, ...]:
|
|
1701
|
+
"""Parse role selectors for one synchronized-clip source domain."""
|
|
1702
|
+
|
|
1703
|
+
controls: list[_RoleControl] = []
|
|
1704
|
+
for index, source in enumerate(_direct_children(element, "sync-source"), start=1):
|
|
1705
|
+
source_path = f"{path}/sync-source[{index}]"
|
|
1706
|
+
raw_source_id = source.get("sourceID")
|
|
1707
|
+
if raw_source_id not in {"storyline", "connected"}:
|
|
1708
|
+
raise AudioIRValidationError(
|
|
1709
|
+
f"{source_path} has invalid sourceID {raw_source_id!r}"
|
|
1710
|
+
)
|
|
1711
|
+
if raw_source_id == source_id:
|
|
1712
|
+
controls.extend(_role_controls(source, source_path))
|
|
1713
|
+
return tuple(controls)
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def _channel_components(
|
|
1717
|
+
element: ET.Element,
|
|
1718
|
+
node: StoryNode,
|
|
1719
|
+
layout: AudioLayout,
|
|
1720
|
+
) -> tuple[_Component, ...]:
|
|
1721
|
+
"""Parse explicit channel components owned by a clip or asset item."""
|
|
1722
|
+
|
|
1723
|
+
channel_sources = _direct_children(element, "audio-channel-source")
|
|
1724
|
+
components: list[_Component] = []
|
|
1725
|
+
for index, child in enumerate(channel_sources, start=1):
|
|
1726
|
+
path = f"{node.path}/audio-channel-source[{index}]"
|
|
1727
|
+
source_channels = _channel_numbers(child.get("srcCh") or "", path)
|
|
1728
|
+
output_channels = None
|
|
1729
|
+
if child.get("outCh") is not None:
|
|
1730
|
+
output_channels = _output_channel_names(
|
|
1731
|
+
child.get("outCh") or "", layout, path
|
|
1732
|
+
)
|
|
1733
|
+
if len(output_channels) != len(source_channels):
|
|
1734
|
+
raise AudioIRValidationError(
|
|
1735
|
+
f"{path} maps {len(source_channels)} source channels to {len(output_channels)} outputs"
|
|
1736
|
+
)
|
|
1737
|
+
components.append(
|
|
1738
|
+
_Component(
|
|
1739
|
+
path=path,
|
|
1740
|
+
source_stream_id=None,
|
|
1741
|
+
source_channels=source_channels,
|
|
1742
|
+
output_channels=output_channels,
|
|
1743
|
+
role=_role(child.get("role") or node.audio_role or node.role, path),
|
|
1744
|
+
source_start=_time_attribute(child, "start", path),
|
|
1745
|
+
duration=_time_attribute(child, "duration", path),
|
|
1746
|
+
enabled=_bool_attribute(child, "enabled", default=True),
|
|
1747
|
+
active=_bool_attribute(child, "active", default=True),
|
|
1748
|
+
controls=_control_layer(child, path),
|
|
1749
|
+
)
|
|
1750
|
+
)
|
|
1751
|
+
return tuple(components)
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
def _components(
|
|
1755
|
+
element: ET.Element,
|
|
1756
|
+
node: StoryNode,
|
|
1757
|
+
asset: AssetResource,
|
|
1758
|
+
layout: AudioLayout,
|
|
1759
|
+
) -> tuple[_Component, ...]:
|
|
1760
|
+
channel_components = _channel_components(element, node, layout)
|
|
1761
|
+
if channel_components:
|
|
1762
|
+
return channel_components
|
|
1763
|
+
|
|
1764
|
+
source_channels = None
|
|
1765
|
+
output_channels = None
|
|
1766
|
+
if element.get("srcCh") is not None:
|
|
1767
|
+
source_channels = _channel_numbers(element.get("srcCh") or "", node.path)
|
|
1768
|
+
if element.get("outCh") is not None:
|
|
1769
|
+
output_channels = _output_channel_names(
|
|
1770
|
+
element.get("outCh") or "", layout, node.path
|
|
1771
|
+
)
|
|
1772
|
+
if source_channels is None:
|
|
1773
|
+
raise AudioIRValidationError(f"{node.path} has outCh without srcCh")
|
|
1774
|
+
if len(output_channels) != len(source_channels):
|
|
1775
|
+
raise AudioIRValidationError(
|
|
1776
|
+
f"{node.path} maps {len(source_channels)} source channels to {len(output_channels)} outputs"
|
|
1777
|
+
)
|
|
1778
|
+
return (
|
|
1779
|
+
_Component(
|
|
1780
|
+
path=node.path,
|
|
1781
|
+
source_stream_id=element.get("srcID"),
|
|
1782
|
+
source_channels=source_channels,
|
|
1783
|
+
output_channels=output_channels,
|
|
1784
|
+
role=_role(element.get("role") or node.audio_role or node.role, node.path),
|
|
1785
|
+
source_start=None,
|
|
1786
|
+
duration=None,
|
|
1787
|
+
enabled=True,
|
|
1788
|
+
active=True,
|
|
1789
|
+
controls=AudioControlLayer(path=node.path),
|
|
1790
|
+
),
|
|
1791
|
+
)
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def _resolve_stream_id(component: _Component, asset: AssetResource, path: str) -> str:
|
|
1795
|
+
count = asset.audio_sources
|
|
1796
|
+
if count is not None and count <= 0:
|
|
1797
|
+
raise AudioIRReferenceError(
|
|
1798
|
+
f"{path} requests audio from asset {asset.id!r} with audioSources={count}"
|
|
1799
|
+
)
|
|
1800
|
+
raw = component.source_stream_id
|
|
1801
|
+
if raw is None:
|
|
1802
|
+
if count is not None and count > 1:
|
|
1803
|
+
raise AudioIRAmbiguityError(
|
|
1804
|
+
f"{path} references asset {asset.id!r} with {count} audio streams but has no srcID"
|
|
1805
|
+
)
|
|
1806
|
+
return "1"
|
|
1807
|
+
stream_id = raw.strip()
|
|
1808
|
+
if not stream_id:
|
|
1809
|
+
raise AudioIRValidationError(f"{path} has an empty srcID")
|
|
1810
|
+
if count is not None:
|
|
1811
|
+
try:
|
|
1812
|
+
numeric_id = int(stream_id)
|
|
1813
|
+
except ValueError:
|
|
1814
|
+
numeric_id = None
|
|
1815
|
+
if numeric_id is not None and not (1 <= numeric_id <= count):
|
|
1816
|
+
raise AudioIRReferenceError(
|
|
1817
|
+
f"{path} selects srcID {stream_id!r}, but asset {asset.id!r} has {count} audio streams"
|
|
1818
|
+
)
|
|
1819
|
+
return stream_id
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
def _resolve_source_channels(
|
|
1823
|
+
component: _Component, asset: AssetResource, path: str
|
|
1824
|
+
) -> tuple[int, ...]:
|
|
1825
|
+
count = asset.audio_channels
|
|
1826
|
+
if component.source_channels is None:
|
|
1827
|
+
if count is not None and count <= 0:
|
|
1828
|
+
raise AudioIRReferenceError(
|
|
1829
|
+
f"{path} requests audio from asset {asset.id!r} with audioChannels={count}"
|
|
1830
|
+
)
|
|
1831
|
+
return ()
|
|
1832
|
+
if count is None or count <= 0:
|
|
1833
|
+
raise AudioIRAmbiguityError(
|
|
1834
|
+
f"{path} cannot validate srcCh because asset {asset.id!r} has no positive audioChannels"
|
|
1835
|
+
)
|
|
1836
|
+
invalid = tuple(channel for channel in component.source_channels if channel > count)
|
|
1837
|
+
if invalid:
|
|
1838
|
+
raise AudioIRReferenceError(
|
|
1839
|
+
f"{path} selects channels {invalid}, but asset {asset.id!r} has {count} channels"
|
|
1840
|
+
)
|
|
1841
|
+
return component.source_channels
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
def _component_interval(
|
|
1845
|
+
component: _Component,
|
|
1846
|
+
*,
|
|
1847
|
+
base_start: Fraction,
|
|
1848
|
+
base_duration: Fraction,
|
|
1849
|
+
base_absolute: Fraction,
|
|
1850
|
+
) -> tuple[Fraction, Fraction, Fraction]:
|
|
1851
|
+
base_end = base_start + base_duration
|
|
1852
|
+
component_start = (
|
|
1853
|
+
component.source_start if component.source_start is not None else base_start
|
|
1854
|
+
)
|
|
1855
|
+
component_end = component_start + (
|
|
1856
|
+
component.duration if component.duration is not None else base_duration
|
|
1857
|
+
)
|
|
1858
|
+
source_start = max(base_start, component_start)
|
|
1859
|
+
source_end = min(base_end, component_end)
|
|
1860
|
+
if source_end <= source_start:
|
|
1861
|
+
raise AudioIRReferenceError(
|
|
1862
|
+
f"{component.path} component range does not intersect its clip audio range"
|
|
1863
|
+
)
|
|
1864
|
+
return (
|
|
1865
|
+
source_start,
|
|
1866
|
+
source_end - source_start,
|
|
1867
|
+
base_absolute + source_start - base_start,
|
|
1868
|
+
)
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
def _intersect_windows(
|
|
1872
|
+
first: Optional[tuple[Fraction, Fraction]],
|
|
1873
|
+
second: tuple[Fraction, Fraction],
|
|
1874
|
+
) -> tuple[Fraction, Fraction]:
|
|
1875
|
+
"""Intersect absolute half-open audio windows without using floats."""
|
|
1876
|
+
|
|
1877
|
+
if first is None:
|
|
1878
|
+
return second
|
|
1879
|
+
return max(first[0], second[0]), min(first[1], second[1])
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def _clip_to_window(
|
|
1883
|
+
absolute_start: Fraction,
|
|
1884
|
+
source_start: Fraction,
|
|
1885
|
+
duration: Fraction,
|
|
1886
|
+
window: tuple[Fraction, Fraction],
|
|
1887
|
+
) -> Optional[tuple[Fraction, Fraction, Fraction]]:
|
|
1888
|
+
"""Clip one linear item to an ancestor's independent audio interval."""
|
|
1889
|
+
|
|
1890
|
+
item_end = absolute_start + duration
|
|
1891
|
+
overlap_start = max(absolute_start, window[0])
|
|
1892
|
+
overlap_end = min(item_end, window[1])
|
|
1893
|
+
if overlap_end <= overlap_start:
|
|
1894
|
+
return None
|
|
1895
|
+
source_start += overlap_start - absolute_start
|
|
1896
|
+
return overlap_start, source_start, overlap_end - overlap_start
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
def _clip_to_sequence(
|
|
1900
|
+
absolute_start: Fraction,
|
|
1901
|
+
source_start: Fraction,
|
|
1902
|
+
duration: Fraction,
|
|
1903
|
+
sequence_duration: Fraction,
|
|
1904
|
+
) -> Optional[tuple[Fraction, Fraction, Fraction]]:
|
|
1905
|
+
if absolute_start < 0:
|
|
1906
|
+
trim = -absolute_start
|
|
1907
|
+
source_start += trim
|
|
1908
|
+
duration -= trim
|
|
1909
|
+
absolute_start = Fraction(0)
|
|
1910
|
+
if absolute_start + duration > sequence_duration:
|
|
1911
|
+
duration = sequence_duration - absolute_start
|
|
1912
|
+
if duration <= 0:
|
|
1913
|
+
return None
|
|
1914
|
+
return absolute_start, source_start, duration
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
def _validate_asset_range(
|
|
1918
|
+
asset: AssetResource,
|
|
1919
|
+
source_start: Fraction,
|
|
1920
|
+
duration: Fraction,
|
|
1921
|
+
path: str,
|
|
1922
|
+
*,
|
|
1923
|
+
frame_duration: Optional[Fraction],
|
|
1924
|
+
) -> None:
|
|
1925
|
+
source_end = (
|
|
1926
|
+
asset.start + asset.duration
|
|
1927
|
+
if asset.duration is not None
|
|
1928
|
+
else source_start + duration
|
|
1929
|
+
)
|
|
1930
|
+
tolerance = source_bound_tolerance(
|
|
1931
|
+
has_video=asset.has_video,
|
|
1932
|
+
frame_duration=frame_duration,
|
|
1933
|
+
has_audio=asset.has_audio,
|
|
1934
|
+
audio_rate=asset.audio_rate,
|
|
1935
|
+
)
|
|
1936
|
+
if not source_range_within_bounds(
|
|
1937
|
+
requested_start=source_start,
|
|
1938
|
+
requested_end=source_start + duration,
|
|
1939
|
+
source_start=asset.start,
|
|
1940
|
+
source_end=source_end,
|
|
1941
|
+
tolerance=tolerance,
|
|
1942
|
+
):
|
|
1943
|
+
if source_start < asset.start:
|
|
1944
|
+
raise AudioIRReferenceError(
|
|
1945
|
+
f"{path} source starts at {source_start}, before asset {asset.id!r} start {asset.start}"
|
|
1946
|
+
)
|
|
1947
|
+
raise AudioIRReferenceError(
|
|
1948
|
+
f"{path} source range ends at {source_start + duration}, after asset {asset.id!r} "
|
|
1949
|
+
f"end {source_end}"
|
|
1950
|
+
)
|
|
1951
|
+
|
|
1952
|
+
|
|
1953
|
+
__all__ = [
|
|
1954
|
+
"AnimatedAudioScalar",
|
|
1955
|
+
"AudioAutomationPoint",
|
|
1956
|
+
"AudioControlLayer",
|
|
1957
|
+
"AudioEnhancement",
|
|
1958
|
+
"AudioFade",
|
|
1959
|
+
"AudioIRAmbiguityError",
|
|
1960
|
+
"AudioIRCompileError",
|
|
1961
|
+
"AudioIRFinding",
|
|
1962
|
+
"AudioIRReferenceError",
|
|
1963
|
+
"AudioIRValidationError",
|
|
1964
|
+
"AudioMuteRange",
|
|
1965
|
+
"AudioPanner",
|
|
1966
|
+
"AudioRenderPlan",
|
|
1967
|
+
"AudioRetimePoint",
|
|
1968
|
+
"AudioRole",
|
|
1969
|
+
"RenderAudioItem",
|
|
1970
|
+
"compile_audio_ir",
|
|
1971
|
+
]
|