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.
Files changed (178) hide show
  1. bladeworks/__init__.py +56 -0
  2. bladeworks/__main__.py +4 -0
  3. bladeworks/cli.py +728 -0
  4. bladeworks/core/__init__.py +34 -0
  5. bladeworks/core/animation.py +893 -0
  6. bladeworks/core/animation_execution.py +817 -0
  7. bladeworks/core/audio_enhancements.py +673 -0
  8. bladeworks/core/audio_execution.py +1848 -0
  9. bladeworks/core/audio_ir.py +1971 -0
  10. bladeworks/core/basic_effects.py +168 -0
  11. bladeworks/core/bindings.py +196 -0
  12. bladeworks/core/capabilities.py +257 -0
  13. bladeworks/core/cohort_effects.py +702 -0
  14. bladeworks/core/color.py +324 -0
  15. bladeworks/core/compiler.py +3142 -0
  16. bladeworks/core/composition_diagnostics.py +629 -0
  17. bladeworks/core/composition_ir.py +3154 -0
  18. bladeworks/core/composition_planning.py +708 -0
  19. bladeworks/core/composition_shadow.py +687 -0
  20. bladeworks/core/compositor.py +1186 -0
  21. bladeworks/core/effect_parameter_semantics.py +165 -0
  22. bladeworks/core/effect_parameters.py +193 -0
  23. bladeworks/core/errors.py +21 -0
  24. bladeworks/core/filter_text.py +48 -0
  25. bladeworks/core/geometry.py +1711 -0
  26. bladeworks/core/group_surface.py +664 -0
  27. bladeworks/core/keyer.py +280 -0
  28. bladeworks/core/masks.py +453 -0
  29. bladeworks/core/missing_media.py +130 -0
  30. bladeworks/core/model.py +985 -0
  31. bladeworks/core/multicam_execution.py +496 -0
  32. bladeworks/core/output_color.py +52 -0
  33. bladeworks/core/parser.py +1257 -0
  34. bladeworks/core/pixel_domains.py +433 -0
  35. bladeworks/core/render_profile.py +311 -0
  36. bladeworks/core/render_sources.py +475 -0
  37. bladeworks/core/report.py +118 -0
  38. bladeworks/core/retime.py +967 -0
  39. bladeworks/core/retime_execution.py +1515 -0
  40. bladeworks/core/spatial_intrinsics.py +1749 -0
  41. bladeworks/core/story_containers.py +524 -0
  42. bladeworks/core/story_ir.py +1042 -0
  43. bladeworks/core/text.py +1032 -0
  44. bladeworks/core/text_templates.py +934 -0
  45. bladeworks/data/FCPXML_RENDER_CAPABILITIES.yaml +1797 -0
  46. bladeworks/effects/__init__.py +1 -0
  47. bladeworks/effects/contract.py +162 -0
  48. bladeworks/examples/__init__.py +80 -0
  49. bladeworks/examples/color.fcpxmld/Info.fcpxml +17 -0
  50. bladeworks/examples/color.fcpxmld/Media/a.mp4 +0 -0
  51. bladeworks/examples/crop_or_transition.fcpxmld/Info.fcpxml +13 -0
  52. bladeworks/examples/crop_or_transition.fcpxmld/Media/a.mp4 +0 -0
  53. bladeworks/examples/crop_or_transition.fcpxmld/Media/b.mp4 +0 -0
  54. bladeworks/examples/manual_qa/generate_2h_qa_fixtures.py +237 -0
  55. bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Info.fcpxml +28 -0
  56. bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Media/night_signal.mp4 +0 -0
  57. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Info.fcpxml +44 -0
  58. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/city_grid.mp4 +0 -0
  59. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/golden_hour.mp4 +0 -0
  60. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Info.fcpxml +113 -0
  61. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/city_grid.mp4 +0 -0
  62. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/golden_hour.mp4 +0 -0
  63. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/night_signal.mp4 +0 -0
  64. bladeworks/examples/single_clip.fcpxmld/Info.fcpxml +9 -0
  65. bladeworks/examples/single_clip.fcpxmld/Media/a.mp4 +0 -0
  66. bladeworks/examples/spine_and_lane.fcpxmld/Info.fcpxml +13 -0
  67. bladeworks/examples/spine_and_lane.fcpxmld/Media/a.mp4 +0 -0
  68. bladeworks/examples/spine_and_lane.fcpxmld/Media/b.mp4 +0 -0
  69. bladeworks/examples/transform.fcpxmld/Info.fcpxml +11 -0
  70. bladeworks/examples/transform.fcpxmld/Media/a.mp4 +0 -0
  71. bladeworks/executor.py +293 -0
  72. bladeworks/preview/__init__.py +11 -0
  73. bladeworks/preview/application.py +84 -0
  74. bladeworks/preview/audio.py +104 -0
  75. bladeworks/preview/capabilities.py +457 -0
  76. bladeworks/preview/contracts.py +157 -0
  77. bladeworks/preview/events.py +55 -0
  78. bladeworks/preview/export.py +57 -0
  79. bladeworks/preview/history.py +197 -0
  80. bladeworks/preview/main.py +19 -0
  81. bladeworks/preview/media.py +23 -0
  82. bladeworks/preview/media_library.py +381 -0
  83. bladeworks/preview/media_reporting.py +55 -0
  84. bladeworks/preview/media_routes.py +155 -0
  85. bladeworks/preview/media_visuals.py +225 -0
  86. bladeworks/preview/producer.py +211 -0
  87. bladeworks/preview/provider.py +74 -0
  88. bladeworks/preview/rawframe.py +218 -0
  89. bladeworks/preview/render_jobs.py +311 -0
  90. bladeworks/preview/routes.py +375 -0
  91. bladeworks/preview/runner.py +349 -0
  92. bladeworks/preview/security.py +61 -0
  93. bladeworks/preview/service.py +234 -0
  94. bladeworks/preview/session.py +457 -0
  95. bladeworks/preview/source.py +603 -0
  96. bladeworks/preview/source_routes.py +151 -0
  97. bladeworks/preview/studio.py +77 -0
  98. bladeworks/preview/webrtc.py +295 -0
  99. bladeworks/spatial_luts/manifest.json +17 -0
  100. bladeworks/spatial_luts/rec2020_hlg_to_rec709_sdr_v1.cube +4917 -0
  101. bladeworks/spatial_luts/rec2020_pq_to_rec709_sdr_v1.cube +4917 -0
  102. bladeworks/studio_static/__init__.py +1 -0
  103. bladeworks/studio_static/app.js +6519 -0
  104. bladeworks/studio_static/build-id.js +1 -0
  105. bladeworks/studio_static/capability-ui.js +68 -0
  106. bladeworks/studio_static/clip-state.js +335 -0
  107. bladeworks/studio_static/fcpxml.js +3313 -0
  108. bladeworks/studio_static/fixtures.js +393 -0
  109. bladeworks/studio_static/history.js +50 -0
  110. bladeworks/studio_static/index.html +24 -0
  111. bladeworks/studio_static/magnetic-timeline.js +1194 -0
  112. bladeworks/studio_static/mask-ui.js +189 -0
  113. bladeworks/studio_static/media-visuals.js +224 -0
  114. bladeworks/studio_static/mock-capabilities.js +409 -0
  115. bladeworks/studio_static/retime-ui.js +151 -0
  116. bladeworks/studio_static/runtime.js +1652 -0
  117. bladeworks/studio_static/scope-ui.js +150 -0
  118. bladeworks/studio_static/styles.css +1558 -0
  119. bladeworks/studio_static/templates/catalog.js +77 -0
  120. bladeworks/studio_static/templates/icons.js +84 -0
  121. bladeworks/studio_static/templates/inspector.js +553 -0
  122. bladeworks/studio_static/templates/library.js +61 -0
  123. bladeworks/studio_static/templates/media.js +185 -0
  124. bladeworks/studio_static/templates/shared.js +43 -0
  125. bladeworks/studio_static/templates/shell.js +94 -0
  126. bladeworks/studio_static/templates/timeline.js +377 -0
  127. bladeworks/studio_static/templates/topbar.js +83 -0
  128. bladeworks/studio_static/templates/types.js +7 -0
  129. bladeworks/studio_static/templates/viewer.js +174 -0
  130. bladeworks/studio_static/templates.js +31 -0
  131. bladeworks/studio_static/types.js +18 -0
  132. bladeworks/studio_static/ui.js +97 -0
  133. bladeworks/studio_static/viewer-geometry.js +55 -0
  134. bladeworks/tensor/__init__.py +131 -0
  135. bladeworks/tensor/audio_delivery.py +280 -0
  136. bladeworks/tensor/audio_pyav.py +360 -0
  137. bladeworks/tensor/blend.py +217 -0
  138. bladeworks/tensor/color.py +53 -0
  139. bladeworks/tensor/composite.py +33 -0
  140. bladeworks/tensor/decode.py +1309 -0
  141. bladeworks/tensor/decode_policy.py +363 -0
  142. bladeworks/tensor/effects.py +322 -0
  143. bladeworks/tensor/encode.py +614 -0
  144. bladeworks/tensor/errors.py +32 -0
  145. bladeworks/tensor/expr.py +775 -0
  146. bladeworks/tensor/fx_basic.py +946 -0
  147. bladeworks/tensor/fx_branched.py +640 -0
  148. bladeworks/tensor/fx_cohort.py +404 -0
  149. bladeworks/tensor/fx_color.py +851 -0
  150. bladeworks/tensor/fx_keyer.py +140 -0
  151. bladeworks/tensor/fx_mask.py +352 -0
  152. bladeworks/tensor/fx_warp.py +337 -0
  153. bladeworks/tensor/hdr.py +122 -0
  154. bladeworks/tensor/pipeline.py +401 -0
  155. bladeworks/tensor/plan.py +2231 -0
  156. bladeworks/tensor/renderer.py +1199 -0
  157. bladeworks/tensor/requirements.txt +28 -0
  158. bladeworks/tensor/resolution.py +246 -0
  159. bladeworks/tensor/sampler.py +598 -0
  160. bladeworks/tensor/support.py +169 -0
  161. bladeworks/tensor/swscale_fixedpoint.py +181 -0
  162. bladeworks/tensor/tr_equirect.py +420 -0
  163. bladeworks/tensor/tr_handlers.py +443 -0
  164. bladeworks/tensor/tr_phase5.py +697 -0
  165. bladeworks/tensor/transitions.py +424 -0
  166. bladeworks/transitions/__init__.py +10 -0
  167. bladeworks/transitions/carryovers.py +299 -0
  168. bladeworks/transitions/cohort.py +981 -0
  169. bladeworks/transitions/contract.py +490 -0
  170. bladeworks/transitions/equirectangular.py +915 -0
  171. bladeworks/transitions/light_deco.py +141 -0
  172. bladeworks/transitions/panel_motion.py +495 -0
  173. bladeworks/transitions/stock.py +270 -0
  174. bladeworks-0.1.0.dist-info/METADATA +66 -0
  175. bladeworks-0.1.0.dist-info/RECORD +178 -0
  176. bladeworks-0.1.0.dist-info/WHEEL +5 -0
  177. bladeworks-0.1.0.dist-info/entry_points.txt +2 -0
  178. 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
+ ]