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,1848 @@
1
+ """Build and execute a fail-closed stock-FFmpeg audio graph.
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ ``AudioRenderPlan``
7
+ Independent timeline items produced by :mod:`audio_ir`.
8
+
9
+ ``probe_audio_asset`` / caller-owned bindings
10
+ Resolve an FCP asset ID to one local file and enumerate every audio stream.
11
+
12
+ ``build_audio_execution_plan``
13
+ Resolve streams and channels, then emit this fixed operation order for
14
+ every audible item::
15
+
16
+ source trim -> channel route -> gain/keyframes/fades -> mutes -> pan
17
+ -> piecewise retime -> resample -> sample-exact timeline delay
18
+
19
+ Descendants of a file, compound, or multicam source instance are mixed
20
+ into one local pad first when needed. The ordinary retime kernel changes
21
+ that pad once, clip controls run once, and the result returns to its
22
+ parent/root mix.
23
+
24
+ The item outputs are mixed with ``amix normalize=0`` over an explicit
25
+ sequence-length silence bed. No limiter or loudness normalizer is added.
26
+
27
+ ``AudioExecutionPlan.command``
28
+ Produces an ordinary FFmpeg invocation for isolated validation or future
29
+ central-executor integration.
30
+
31
+ Important invariants
32
+ --------------------
33
+
34
+ * Source stream IDs are resolved against a complete, deterministic probe.
35
+ Missing assets, streams, and channels are errors.
36
+ * An omitted ``srcCh`` is resolved late to every channel in the chosen stream.
37
+ An omitted ``outCh`` follows the documented default routing matrix below.
38
+ * Control layers remain ordered. They are never collapsed into one guessed
39
+ value, and opaque/unimplemented enhancements stop graph construction.
40
+ * Audio retiming reuses :mod:`retime_execution`; reverse and pitch behavior are
41
+ explicit, and freeze segments emit calibrated silence matching Final Cut.
42
+ * A clip-instance timeMap owns its completed source. It is never copied onto each
43
+ descendant source, which would retime angle switches and mixes separately.
44
+ * Video transitions are absent from this API. They cannot accidentally add
45
+ an audio crossfade.
46
+
47
+ Main callers:
48
+ - The central FFmpeg invocation builder.
49
+ - Experimental core renderer tests.
50
+
51
+ Why this exists:
52
+ The legacy FFmpeg builder schedules audio from video clips and assumes the
53
+ first stream. That makes J/L edits, split multicam audio, channel routing, and
54
+ role controls impossible to represent faithfully. This module validates the
55
+ replacement engine without putting experimental behavior in production CI.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ from dataclasses import dataclass
61
+ from decimal import Decimal, localcontext
62
+ from fractions import Fraction
63
+ import hashlib
64
+ import json
65
+ import math
66
+ from pathlib import Path
67
+ import re
68
+ import subprocess
69
+ from typing import Any, Mapping, Sequence, Union
70
+
71
+ from .audio_enhancements import (
72
+ AudioEnhancementError,
73
+ build_audio_enhancement_plan,
74
+ )
75
+ from .audio_ir import (
76
+ AnimatedAudioScalar,
77
+ AudioControlLayer,
78
+ AudioFade,
79
+ AudioMuteRange,
80
+ AudioPanner,
81
+ AudioRenderPlan,
82
+ AudioSourceInstance,
83
+ RenderAudioItem,
84
+ )
85
+ from .errors import RenderCapabilityError
86
+ from .render_sources import rebase_source_retime
87
+ from .retime import RetimeMap, RetimePoint
88
+ from .retime_execution import (
89
+ AudioFreezeBehaviorBlocked,
90
+ AudioFreezePolicy,
91
+ build_audio_filtergraph_segments,
92
+ build_retime_execution_plan,
93
+ required_stock_filters as required_retime_filters,
94
+ )
95
+
96
+
97
+ class AudioExecutionError(ValueError):
98
+ """Base error for an invalid or unavailable audio execution plan."""
99
+
100
+
101
+ class AudioAssetBindingError(AudioExecutionError):
102
+ """An asset has no binding or its binding is ambiguous/invalid."""
103
+
104
+
105
+ class AudioStreamResolutionError(AudioExecutionError):
106
+ """An item requests a stream or channel absent from the media probe."""
107
+
108
+
109
+ class UnsupportedAudioControlError(AudioExecutionError):
110
+ """A preserved control has no honest stock-FFmpeg implementation yet."""
111
+
112
+
113
+ class AudioFreezeCalibrationError(AudioExecutionError):
114
+ """A retime contains freeze audio whose Final Cut behavior is uncalibrated."""
115
+
116
+
117
+ _FCP_FREEZE_SILENCE_CALIBRATION = "fcp-12.3-ui-hold-silence-20260812"
118
+
119
+
120
+ class MissingFFmpegAudioCapability(AudioExecutionError):
121
+ """The selected FFmpeg executable lacks a filter required by the plan."""
122
+
123
+
124
+ _OUTPUT_LAYOUTS: Mapping[str, tuple[str, tuple[str, ...]]] = {
125
+ "mono": ("mono", ("FC",)),
126
+ "stereo": ("stereo", ("FL", "FR")),
127
+ "surround": ("5.1", ("FL", "FR", "FC", "LFE", "SL", "SR")),
128
+ }
129
+ _FCP_OUTPUT_TO_FFMPEG = {
130
+ "L": "FL",
131
+ "R": "FR",
132
+ "C": "FC",
133
+ "LFE": "LFE",
134
+ "Ls": "SL",
135
+ "Rs": "SR",
136
+ }
137
+ _KNOWN_SOURCE_LAYOUTS: Mapping[str, tuple[str, ...]] = {
138
+ "mono": ("FC",),
139
+ "stereo": ("FL", "FR"),
140
+ "2.1": ("FL", "FR", "LFE"),
141
+ "3.0": ("FL", "FR", "FC"),
142
+ "4.0": ("FL", "FR", "FC", "BC"),
143
+ "5.0": ("FL", "FR", "FC", "BL", "BR"),
144
+ "5.0(side)": ("FL", "FR", "FC", "SL", "SR"),
145
+ "5.1": ("FL", "FR", "FC", "LFE", "BL", "BR"),
146
+ "5.1(side)": ("FL", "FR", "FC", "LFE", "SL", "SR"),
147
+ }
148
+ _FILTER_LABEL_RE = re.compile(r"^[A-Za-z0-9_]+$")
149
+
150
+
151
+ def reject_unsupported_output_layout(layout: str) -> None:
152
+ """Loudly reject a sequence audio output layout the renderer cannot deliver.
153
+
154
+ What it does:
155
+ If ``layout`` is ``"surround"`` (Final Cut's 5.1 output request) raise a
156
+ ``RenderCapabilityError`` naming the construct. ``"mono"`` and ``"stereo"``
157
+ pass through untouched; any other value is left for the caller's own layout
158
+ validation so this guard only ever speaks about the one unsupported case.
159
+
160
+ Why this exists:
161
+ Final Cut's ``sequence/@audioLayout="surround"`` asks for a 5.1 delivery. The
162
+ audio graph would build a stereo->5.1 ``pan`` upmix whose surround channels
163
+ are named ``Ls``/``Rs`` -> ``SL``/``SR``, but FFmpeg 8's ``5.1`` channel
164
+ layout uses ``BL``/``BR`` -- so libav rejects the graph DEEP in execution with
165
+ a cryptic ``ValueError [Errno 22]`` (ffmpeg exit 234, "Channel SL does not
166
+ exist in the chosen layout"). We do NOT implement the 5.1 channel mapping;
167
+ instead we reject surround output UP FRONT, at plan time, as an explicit
168
+ capability gap -- the same loud-reject discipline used for other unsupported
169
+ constructs (e.g. the shear/skew transform reject). This is a clean capability
170
+ rejection, not a crash, and it NEVER silently downmixes to stereo.
171
+
172
+ Main callers:
173
+ - ``build_audio_execution_plan`` (before any audio graph node is built).
174
+ - ``tensor.audio_delivery`` resolvers (before any frame is rendered).
175
+ """
176
+ if layout == "surround":
177
+ raise RenderCapabilityError(
178
+ "surround (5.1) audio output is not supported: the renderer delivers "
179
+ "only mono and stereo audio layouts. Set the sequence audioLayout to "
180
+ "stereo (or mono) to render this project."
181
+ )
182
+
183
+
184
+ @dataclass(frozen=True)
185
+ class AudioStreamBinding:
186
+ """One probed audio stream addressable by FCP's one-based ``srcID``."""
187
+
188
+ source_stream_id: str
189
+ audio_ordinal: int
190
+ channels: int
191
+ sample_rate: int
192
+ channel_layout: str | None = None
193
+
194
+ def __post_init__(self) -> None:
195
+ if not self.source_stream_id or not self.source_stream_id.strip():
196
+ raise AudioAssetBindingError("audio stream ID must not be empty")
197
+ if isinstance(self.audio_ordinal, bool) or self.audio_ordinal < 0:
198
+ raise AudioAssetBindingError("audio stream ordinal must be non-negative")
199
+ if isinstance(self.channels, bool) or self.channels <= 0:
200
+ raise AudioAssetBindingError("audio stream channel count must be positive")
201
+ if isinstance(self.sample_rate, bool) or self.sample_rate <= 0:
202
+ raise AudioAssetBindingError("audio stream sample rate must be positive")
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class AudioAssetBinding:
207
+ """One local asset file plus the complete audio-stream probe."""
208
+
209
+ asset_id: str
210
+ path: Path
211
+ streams: tuple[AudioStreamBinding, ...]
212
+
213
+ def __post_init__(self) -> None:
214
+ object.__setattr__(self, "path", Path(self.path))
215
+ object.__setattr__(self, "streams", tuple(self.streams))
216
+ if not self.asset_id:
217
+ raise AudioAssetBindingError("asset binding requires an asset ID")
218
+ if not self.streams:
219
+ raise AudioAssetBindingError(
220
+ f"asset {self.asset_id!r} has no probed audio streams"
221
+ )
222
+ ids = [stream.source_stream_id for stream in self.streams]
223
+ ordinals = [stream.audio_ordinal for stream in self.streams]
224
+ if len(set(ids)) != len(ids):
225
+ raise AudioAssetBindingError(
226
+ f"asset {self.asset_id!r} has duplicate source stream IDs"
227
+ )
228
+ if len(set(ordinals)) != len(ordinals):
229
+ raise AudioAssetBindingError(
230
+ f"asset {self.asset_id!r} has duplicate audio ordinals"
231
+ )
232
+
233
+ def stream(self, source_stream_id: str) -> AudioStreamBinding:
234
+ matches = tuple(
235
+ stream
236
+ for stream in self.streams
237
+ if stream.source_stream_id == source_stream_id
238
+ )
239
+ if len(matches) != 1:
240
+ known = ", ".join(stream.source_stream_id for stream in self.streams)
241
+ raise AudioStreamResolutionError(
242
+ f"asset {self.asset_id!r} has no unique audio stream "
243
+ f"{source_stream_id!r}; known streams: {known}"
244
+ )
245
+ return matches[0]
246
+
247
+
248
+ @dataclass(frozen=True)
249
+ class AudioItemExecution:
250
+ """Resolved, deterministic execution details for one audible item."""
251
+
252
+ item_id: str
253
+ asset_id: str
254
+ input_index: int
255
+ audio_ordinal: int
256
+ source_channels: tuple[int, ...]
257
+ output_routes: tuple[tuple[int, str, Fraction], ...]
258
+ start_sample: int
259
+ output_label: str
260
+ retimed: bool
261
+ enhancement_plans: tuple[Mapping[str, Any], ...] = ()
262
+
263
+ def manifest(self) -> dict[str, Any]:
264
+ return {
265
+ "item_id": self.item_id,
266
+ "asset_id": self.asset_id,
267
+ "input_index": self.input_index,
268
+ "audio_ordinal": self.audio_ordinal,
269
+ "source_channels": list(self.source_channels),
270
+ "output_routes": [
271
+ {
272
+ "source_channel": source,
273
+ "output_channel": output,
274
+ "coefficient": _fraction_text(coefficient),
275
+ }
276
+ for source, output, coefficient in self.output_routes
277
+ ],
278
+ "start_sample": self.start_sample,
279
+ "output_label": self.output_label,
280
+ "retimed": self.retimed,
281
+ "enhancements": [dict(plan) for plan in self.enhancement_plans],
282
+ }
283
+
284
+
285
+ @dataclass(frozen=True)
286
+ class AudioSourceExecution:
287
+ """Audit record for one virtual source consumed by a clip instance."""
288
+
289
+ path: str
290
+ source_id: str
291
+ input_count: int
292
+ output_label: str
293
+ start_sample: int
294
+ source_origin: Fraction
295
+ source_duration: Fraction
296
+
297
+ def manifest(self) -> dict[str, Any]:
298
+ return {
299
+ "path": self.path,
300
+ "source_id": self.source_id,
301
+ "input_count": self.input_count,
302
+ "output_label": self.output_label,
303
+ "start_sample": self.start_sample,
304
+ "source_origin": _fraction_text(self.source_origin),
305
+ "source_duration": _fraction_text(self.source_duration),
306
+ }
307
+
308
+
309
+ @dataclass(frozen=True)
310
+ class FFmpegAudioCapabilityReport:
311
+ executable: str
312
+ version_line: str
313
+ required_filters: tuple[str, ...]
314
+ available_filters: tuple[str, ...]
315
+ missing_filters: tuple[str, ...]
316
+
317
+ @property
318
+ def supported(self) -> bool:
319
+ return not self.missing_filters
320
+
321
+ def require_supported(self) -> None:
322
+ if self.missing_filters:
323
+ raise MissingFFmpegAudioCapability(
324
+ "FFmpeg is missing audio filters: " + ", ".join(self.missing_filters)
325
+ )
326
+
327
+
328
+ @dataclass(frozen=True)
329
+ class AudioExecutionPlan:
330
+ """A complete stock-FFmpeg graph and audit manifest.
331
+
332
+ Main callers:
333
+ - ``build_audio_execution_plan`` returns this to the isolated runner.
334
+ - The future central executor can merge ``inputs`` and ``filter_complex``
335
+ into the combined video/audio invocation.
336
+ """
337
+
338
+ schema_version: int
339
+ source_sha256: str
340
+ sample_rate: int
341
+ layout: str
342
+ ffmpeg_layout: str
343
+ sequence_duration: Fraction
344
+ inputs: tuple[Path, ...]
345
+ input_asset_ids: tuple[str, ...]
346
+ filter_complex: str
347
+ output_label: str
348
+ items: tuple[AudioItemExecution, ...]
349
+ required_filters: tuple[str, ...]
350
+ source_instances: tuple[AudioSourceExecution, ...] = ()
351
+ # Structured node/edge form of ``filter_complex`` (serialises back to it
352
+ # byte-for-byte). The PyAV port (plan chunk 2) builds the libav graph from
353
+ # this; the golden gate proves ``serialize_segments(graph_segments) ==
354
+ # filter_complex``. Defaulted so existing direct constructors still work.
355
+ graph_segments: tuple[AudioGraphSegmentLike, ...] = ()
356
+
357
+ def manifest(self) -> dict[str, Any]:
358
+ return {
359
+ "schema": "fcpxml_audio_execution.v1",
360
+ "source_sha256": self.source_sha256,
361
+ "sample_rate": self.sample_rate,
362
+ "layout": self.layout,
363
+ "ffmpeg_layout": self.ffmpeg_layout,
364
+ "sequence_duration": _fraction_text(self.sequence_duration),
365
+ "inputs": [
366
+ {"asset_id": asset_id, "path": str(path)}
367
+ for asset_id, path in zip(self.input_asset_ids, self.inputs)
368
+ ],
369
+ "output_label": self.output_label,
370
+ "required_filters": list(self.required_filters),
371
+ "items": [item.manifest() for item in self.items],
372
+ "source_instances": [
373
+ instance.manifest() for instance in self.source_instances
374
+ ],
375
+ "mix": {
376
+ "filter": "amix",
377
+ "normalize": False,
378
+ "hidden_limiter": False,
379
+ "hidden_loudness_normalization": False,
380
+ "video_transition_audio": False,
381
+ },
382
+ }
383
+
384
+ @property
385
+ def manifest_sha256(self) -> str:
386
+ payload = json.dumps(
387
+ self.manifest(), sort_keys=True, separators=(",", ":")
388
+ ).encode("utf-8")
389
+ return hashlib.sha256(payload).hexdigest()
390
+
391
+ def command(
392
+ self,
393
+ output_path: str | Path,
394
+ *,
395
+ ffmpeg_path: str | Path = "ffmpeg",
396
+ codec: str = "pcm_s24le",
397
+ overwrite: bool = True,
398
+ ) -> tuple[str, ...]:
399
+ """Return an argv tuple; no shell parsing or caller-provided filters."""
400
+
401
+ command = [str(ffmpeg_path), "-hide_banner", "-nostdin"]
402
+ if overwrite:
403
+ command.append("-y")
404
+ else:
405
+ command.append("-n")
406
+ for path in self.inputs:
407
+ command.extend(("-i", str(path)))
408
+ command.extend(
409
+ (
410
+ "-filter_complex",
411
+ self.filter_complex,
412
+ "-map",
413
+ f"[{self.output_label}]",
414
+ "-vn",
415
+ "-c:a",
416
+ codec,
417
+ "-ar",
418
+ str(self.sample_rate),
419
+ "-channel_layout",
420
+ self.ffmpeg_layout,
421
+ str(output_path),
422
+ )
423
+ )
424
+ return tuple(command)
425
+
426
+
427
+ def probe_audio_asset(
428
+ asset_id: str,
429
+ path: str | Path,
430
+ *,
431
+ ffprobe_path: str | Path = "ffprobe",
432
+ ) -> AudioAssetBinding:
433
+ """Enumerate every audio stream in one asset with stock ``ffprobe``.
434
+
435
+ Stream IDs are deliberately one-based audio ordinals (``"1"``, ``"2"``)
436
+ because that is the stable form consumed by ``AudioRenderPlan``. The
437
+ container's global stream index is not used as an FCP stream ID.
438
+
439
+ Main callers:
440
+ - Asset-binding setup before ``build_audio_execution_plan``.
441
+ """
442
+
443
+ media_path = Path(path)
444
+ try:
445
+ completed = subprocess.run(
446
+ (
447
+ str(ffprobe_path),
448
+ "-v",
449
+ "error",
450
+ "-select_streams",
451
+ "a",
452
+ "-show_entries",
453
+ "stream=index,channels,sample_rate,channel_layout",
454
+ "-of",
455
+ "json",
456
+ str(media_path),
457
+ ),
458
+ check=True,
459
+ capture_output=True,
460
+ text=True,
461
+ timeout=30,
462
+ )
463
+ payload = json.loads(completed.stdout)
464
+ except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as error:
465
+ raise AudioAssetBindingError(
466
+ f"could not probe audio asset {asset_id!r} at {media_path}: {error}"
467
+ ) from error
468
+ raw_streams = payload.get("streams")
469
+ if not isinstance(raw_streams, list) or not raw_streams:
470
+ raise AudioAssetBindingError(f"asset {asset_id!r} has no audio streams")
471
+ streams: list[AudioStreamBinding] = []
472
+ for ordinal, raw in enumerate(raw_streams):
473
+ if not isinstance(raw, dict):
474
+ raise AudioAssetBindingError(
475
+ f"asset {asset_id!r} returned malformed audio stream metadata"
476
+ )
477
+ try:
478
+ channels = int(raw["channels"])
479
+ sample_rate = int(raw["sample_rate"])
480
+ except (KeyError, TypeError, ValueError) as error:
481
+ raise AudioAssetBindingError(
482
+ f"asset {asset_id!r} stream {ordinal + 1} lacks channel/rate metadata"
483
+ ) from error
484
+ layout = raw.get("channel_layout")
485
+ streams.append(
486
+ AudioStreamBinding(
487
+ source_stream_id=str(ordinal + 1),
488
+ audio_ordinal=ordinal,
489
+ channels=channels,
490
+ sample_rate=sample_rate,
491
+ channel_layout=str(layout) if layout else None,
492
+ )
493
+ )
494
+ return AudioAssetBinding(asset_id=asset_id, path=media_path, streams=tuple(streams))
495
+
496
+
497
+ @dataclass(frozen=True)
498
+ class AudioFilterNode:
499
+ """One FFmpeg filter as its raw ``name`` or ``name=args`` text.
500
+
501
+ Serialisation is the identity on ``raw`` so a structured graph reproduces
502
+ the calibrated ``filter_complex`` byte-for-byte. ``name``/``args`` are
503
+ derived read-only by splitting on the FIRST ``=`` only -- filter names are
504
+ ``[a-z0-9_]+`` and never contain ``=``, so this never corrupts an argument
505
+ that itself contains ``=`` (e.g. ``anullsrc=r=48000:cl=stereo``). The PyAV
506
+ port (plan chunk 2) consumes ``name``/``args`` to build one libav filter per
507
+ node; this file only ever serialises ``raw``.
508
+ """
509
+
510
+ raw: str
511
+
512
+ @property
513
+ def name(self) -> str:
514
+ return self.raw.split("=", 1)[0]
515
+
516
+ @property
517
+ def args(self) -> str:
518
+ parts = self.raw.split("=", 1)
519
+ return parts[1] if len(parts) == 2 else ""
520
+
521
+
522
+ @dataclass(frozen=True)
523
+ class AudioGraphSegment:
524
+ """One FFmpeg filterchain: ``[in..] f1,f2,.. [out..]``.
525
+
526
+ Multi-input (``amix``, ``join``) and multi-output (``asplit``,
527
+ ``channelsplit``) chains are expressed by the label tuples; a source-only
528
+ chain (``anullsrc``) has empty ``in_labels``.
529
+ """
530
+
531
+ in_labels: tuple[str, ...]
532
+ filters: tuple[AudioFilterNode, ...]
533
+ out_labels: tuple[str, ...]
534
+
535
+ def serialize(self) -> str:
536
+ return (
537
+ "".join(f"[{label}]" for label in self.in_labels)
538
+ + ",".join(node.raw for node in self.filters)
539
+ + "".join(f"[{label}]" for label in self.out_labels)
540
+ )
541
+
542
+
543
+ @dataclass(frozen=True)
544
+ class AudioRawSegment:
545
+ """An already-serialised filtergraph segment spliced verbatim.
546
+
547
+ Used only for retime sub-graphs produced by ``build_audio_filtergraph``,
548
+ which are ``;``-joined and re-split here. Chunk 2 must give that helper a
549
+ structured accessor; until then this preserves byte-identity without parsing
550
+ FFmpeg filterchain syntax.
551
+ """
552
+
553
+ text: str
554
+
555
+ def serialize(self) -> str:
556
+ return self.text
557
+
558
+
559
+ AudioGraphSegmentLike = Union[AudioGraphSegment, AudioRawSegment]
560
+
561
+
562
+ def serialize_segments(segments: Sequence[AudioGraphSegmentLike]) -> str:
563
+ """Join structured segments into a stock ``filter_complex`` string.
564
+
565
+ Main callers:
566
+ - ``AudioExecutionPlan.filter_complex`` construction (this file).
567
+ - ``test_audio_graph_serialize_golden`` (Stage 2a byte-identity gate).
568
+ """
569
+
570
+ return ";".join(segment.serialize() for segment in segments)
571
+
572
+
573
+ def _filter_nodes(filters: Sequence[str]) -> tuple[AudioFilterNode, ...]:
574
+ return tuple(AudioFilterNode(spec) for spec in filters)
575
+
576
+
577
+ class _GraphBuilder:
578
+ """Structured audio filtergraph accumulator + graph-pad label allocator.
579
+
580
+ Holds an ordered list of typed ``segments`` instead of raw strings, so the
581
+ same graph can serialise to a stock ``filter_complex`` (this file) or be
582
+ rebuilt node-by-node in PyAV (plan chunk 2). ``label`` still prevents
583
+ accidental pad reuse.
584
+
585
+ Why this exists:
586
+ PyAV 16 exposes no ``filter_complex`` string parser, so the graph must be
587
+ consumed as structured nodes/edges. Building it structured at the source --
588
+ where filter name/args and pad labels are already separate -- avoids ever
589
+ parsing FFmpeg filterchain syntax back out of a string.
590
+ """
591
+
592
+ def __init__(self) -> None:
593
+ self.segments: list[AudioGraphSegmentLike] = []
594
+ self._counter = 0
595
+
596
+ def label(self, stem: str) -> str:
597
+ safe = re.sub(r"[^A-Za-z0-9_]", "_", stem)
598
+ self._counter += 1
599
+ return f"a_{safe}_{self._counter}"
600
+
601
+ def add(
602
+ self,
603
+ in_labels: Sequence[str],
604
+ filters: Sequence[str],
605
+ out_labels: Sequence[str],
606
+ ) -> None:
607
+ """Append one structured filterchain of any input/output arity."""
608
+
609
+ self.segments.append(
610
+ AudioGraphSegment(
611
+ tuple(in_labels), _filter_nodes(filters), tuple(out_labels)
612
+ )
613
+ )
614
+
615
+ def add_raw(self, text: str) -> None:
616
+ """Splice an already-serialised sub-graph segment verbatim."""
617
+
618
+ self.segments.append(AudioRawSegment(text))
619
+
620
+ def chain(self, source: str, filters: Sequence[str], *, stem: str) -> str:
621
+ """Single-input/single-output labelled chain; returns the output label."""
622
+
623
+ output = self.label(stem)
624
+ self.add((source,), filters, (output,))
625
+ return output
626
+
627
+ def serialize(self) -> str:
628
+ return serialize_segments(self.segments)
629
+
630
+
631
+ @dataclass(frozen=True)
632
+ class _AudioPad:
633
+ """One scheduled audio pad plus the unresolved source paths owning it."""
634
+
635
+ label: str
636
+ ancestor_paths: tuple[str, ...]
637
+
638
+
639
+ def build_audio_execution_plan(
640
+ audio_plan: AudioRenderPlan,
641
+ bindings: Mapping[str, AudioAssetBinding],
642
+ *,
643
+ output_label: str = "audio_out",
644
+ freeze_audio_policy: AudioFreezePolicy | None = None,
645
+ input_index_offset: int = 0,
646
+ ) -> AudioExecutionPlan:
647
+ """Resolve an audio IR plan into one deterministic stock-FFmpeg graph.
648
+
649
+ Main callers:
650
+ - Experimental AUDIO-2 tests.
651
+ - Future central executor integration.
652
+
653
+ Why this exists:
654
+ This is the sole point that joins logical FCP source IDs to physical media
655
+ streams. Keeping that decision here makes missing bindings and invalid
656
+ channels visible before FFmpeg starts.
657
+ """
658
+
659
+ if not isinstance(audio_plan, AudioRenderPlan):
660
+ raise AudioExecutionError("audio_plan must be an AudioRenderPlan")
661
+ if audio_plan.schema_version != 2:
662
+ raise AudioExecutionError(
663
+ f"unsupported audio plan schema {audio_plan.schema_version!r}"
664
+ )
665
+ if audio_plan.layout not in _OUTPUT_LAYOUTS:
666
+ raise AudioExecutionError(f"unsupported output layout {audio_plan.layout!r}")
667
+ # Reject surround (5.1) output loudly here, before a single graph node is
668
+ # built, so the render fails with a clear capability message instead of the
669
+ # cryptic libav ``Errno 22`` this plan's 5.1 ``pan`` upmix would trigger deep
670
+ # in execution. See ``reject_unsupported_output_layout``.
671
+ reject_unsupported_output_layout(audio_plan.layout)
672
+ if audio_plan.sample_rate <= 0 or audio_plan.sequence_duration <= 0:
673
+ raise AudioExecutionError("audio sample rate and sequence duration must be positive")
674
+ if not _FILTER_LABEL_RE.fullmatch(output_label):
675
+ raise AudioExecutionError("output label must contain only letters, digits, or underscore")
676
+ if (
677
+ isinstance(input_index_offset, bool)
678
+ or not isinstance(input_index_offset, int)
679
+ or input_index_offset < 0
680
+ ):
681
+ raise AudioExecutionError("input_index_offset must be a non-negative integer")
682
+
683
+ ffmpeg_layout, output_positions = _OUTPUT_LAYOUTS[audio_plan.layout]
684
+ audible_items = tuple(item for item in audio_plan.items if item.audible)
685
+ used_asset_ids = tuple(sorted({item.asset_id for item in audible_items}))
686
+ for asset_id in used_asset_ids:
687
+ if asset_id not in bindings:
688
+ raise AudioAssetBindingError(
689
+ f"audible audio item references unresolved asset {asset_id!r}"
690
+ )
691
+ binding = bindings[asset_id]
692
+ if binding.asset_id != asset_id:
693
+ raise AudioAssetBindingError(
694
+ f"binding key {asset_id!r} contains asset {binding.asset_id!r}"
695
+ )
696
+ inputs = tuple(bindings[asset_id].path for asset_id in used_asset_ids)
697
+ input_indices = {
698
+ asset_id: input_index_offset + index
699
+ for index, asset_id in enumerate(used_asset_ids)
700
+ }
701
+
702
+ resolved: list[tuple[RenderAudioItem, AudioAssetBinding, AudioStreamBinding, tuple[int, ...], tuple[tuple[int, str, Fraction], ...]]] = []
703
+ source_use_counts: dict[tuple[int, int], int] = {}
704
+ for item in audible_items:
705
+ binding = bindings[item.asset_id]
706
+ stream = binding.stream(item.source_stream_id)
707
+ source_channels = item.source_channels or tuple(range(1, stream.channels + 1))
708
+ invalid = tuple(channel for channel in source_channels if channel > stream.channels)
709
+ if invalid:
710
+ raise AudioStreamResolutionError(
711
+ f"{item.path} requests source channels {invalid} from "
712
+ f"{stream.channels}-channel stream {item.source_stream_id!r}"
713
+ )
714
+ routes = _resolve_output_routes(
715
+ item,
716
+ stream,
717
+ source_channels,
718
+ output_positions,
719
+ )
720
+ input_index = input_indices[item.asset_id]
721
+ key = (input_index, stream.audio_ordinal)
722
+ source_use_counts[key] = source_use_counts.get(key, 0) + 1
723
+ resolved.append((item, binding, stream, source_channels, routes))
724
+
725
+ builder = _GraphBuilder()
726
+ source_labels: dict[tuple[int, int], list[str]] = {}
727
+ for key in sorted(source_use_counts):
728
+ count = source_use_counts[key]
729
+ source = f"{key[0]}:a:{key[1]}"
730
+ if count == 1:
731
+ source_labels[key] = [source]
732
+ continue
733
+ labels = [builder.label(f"source_{key[0]}_{key[1]}") for _ in range(count)]
734
+ builder.add((source,), (f"asplit={count}",), labels)
735
+ source_labels[key] = labels
736
+
737
+ source_offsets = {key: 0 for key in source_labels}
738
+ pending_pads: list[_AudioPad] = []
739
+ item_execution: list[AudioItemExecution] = []
740
+ required = {"anullsrc", "atrim", "asetpts", "aresample", "amix"}
741
+ if any(count > 1 for count in source_use_counts.values()):
742
+ required.add("asplit")
743
+ for item, binding, stream, source_channels, routes in resolved:
744
+ key = (input_indices[item.asset_id], stream.audio_ordinal)
745
+ source = source_labels[key][source_offsets[key]]
746
+ source_offsets[key] += 1
747
+ item_label, item_required, retimed, enhancement_manifests = _build_item_graph(
748
+ builder,
749
+ item,
750
+ source,
751
+ routes=routes,
752
+ output_layout=audio_plan.layout,
753
+ ffmpeg_layout=ffmpeg_layout,
754
+ sample_rate=audio_plan.sample_rate,
755
+ freeze_audio_policy=freeze_audio_policy
756
+ or AudioFreezePolicy.calibrated_silence(
757
+ _FCP_FREEZE_SILENCE_CALIBRATION
758
+ ),
759
+ )
760
+ required.update(item_required)
761
+ pending_pads.append(
762
+ _AudioPad(label=item_label, ancestor_paths=item.ancestor_paths)
763
+ )
764
+ item_execution.append(
765
+ AudioItemExecution(
766
+ item_id=item.id,
767
+ asset_id=item.asset_id,
768
+ input_index=key[0],
769
+ audio_ordinal=key[1],
770
+ source_channels=source_channels,
771
+ output_routes=routes,
772
+ start_sample=_time_to_sample(item.absolute_start, audio_plan.sample_rate),
773
+ output_label=item_label,
774
+ retimed=retimed,
775
+ enhancement_plans=enhancement_manifests,
776
+ )
777
+ )
778
+
779
+ pending_pads, source_execution, source_required = (
780
+ _apply_audio_source_instances(
781
+ builder,
782
+ pending_pads,
783
+ audio_plan.source_instances,
784
+ sequence_duration=audio_plan.sequence_duration,
785
+ sample_rate=audio_plan.sample_rate,
786
+ ffmpeg_layout=ffmpeg_layout,
787
+ output_layout=audio_plan.layout,
788
+ freeze_audio_policy=freeze_audio_policy
789
+ or AudioFreezePolicy.calibrated_silence(
790
+ _FCP_FREEZE_SILENCE_CALIBRATION
791
+ ),
792
+ )
793
+ )
794
+ required.update(source_required)
795
+ duration = _number_text(audio_plan.sequence_duration)
796
+ silence = builder.label("sequence_silence")
797
+ builder.add(
798
+ (),
799
+ (
800
+ f"anullsrc=r={audio_plan.sample_rate}:cl={ffmpeg_layout}",
801
+ f"atrim=duration={duration}",
802
+ "asetpts=PTS-STARTPTS",
803
+ ),
804
+ (silence,),
805
+ )
806
+ mix_inputs = [silence, *(pad.label for pad in pending_pads)]
807
+ builder.add(
808
+ mix_inputs,
809
+ (
810
+ f"amix=inputs={len(mix_inputs)}:duration=first:dropout_transition=0:normalize=0",
811
+ f"atrim=duration={duration}",
812
+ "asetpts=PTS-STARTPTS",
813
+ ),
814
+ (output_label,),
815
+ )
816
+ if _terminate_unconsumed_outputs(builder, output_label=output_label):
817
+ required.add("anullsink")
818
+
819
+ return AudioExecutionPlan(
820
+ schema_version=1,
821
+ source_sha256=audio_plan.source_sha256,
822
+ sample_rate=audio_plan.sample_rate,
823
+ layout=audio_plan.layout,
824
+ ffmpeg_layout=ffmpeg_layout,
825
+ sequence_duration=audio_plan.sequence_duration,
826
+ inputs=inputs,
827
+ input_asset_ids=used_asset_ids,
828
+ filter_complex=builder.serialize(),
829
+ graph_segments=tuple(builder.segments),
830
+ output_label=output_label,
831
+ items=tuple(item_execution),
832
+ required_filters=tuple(sorted(required)),
833
+ source_instances=source_execution,
834
+ )
835
+
836
+
837
+ def _terminate_unconsumed_outputs(
838
+ builder: _GraphBuilder,
839
+ *,
840
+ output_label: str,
841
+ ) -> int:
842
+ """Connect abandoned audio branches to explicit sinks.
843
+
844
+ A fully frozen virtual source produces calibrated silence and therefore
845
+ does not read the descendant media mix that was already assembled for it.
846
+ FFmpeg and PyAV both reject that otherwise harmless, unconnected branch.
847
+ Ending every unconsumed pad at ``anullsink`` keeps the structured graph
848
+ valid without changing the final mix.
849
+
850
+ Main callers:
851
+ - ``build_audio_execution_plan`` after the root output has been assembled.
852
+ """
853
+
854
+ original_segments = tuple(builder.segments)
855
+ consumed = {
856
+ label
857
+ for segment in original_segments
858
+ for label in segment.in_labels
859
+ }
860
+ consumed.add(output_label)
861
+ dangling = tuple(
862
+ label
863
+ for segment in original_segments
864
+ for label in segment.out_labels
865
+ if label not in consumed
866
+ )
867
+ for label in dangling:
868
+ builder.add((label,), ("anullsink",), ())
869
+ return len(dangling)
870
+
871
+
872
+ def _apply_audio_source_instances(
873
+ builder: _GraphBuilder,
874
+ pads: list[_AudioPad],
875
+ instances: tuple[AudioSourceInstance, ...],
876
+ *,
877
+ sequence_duration: Fraction,
878
+ sample_rate: int,
879
+ ffmpeg_layout: str,
880
+ output_layout: str,
881
+ freeze_audio_policy: AudioFreezePolicy,
882
+ ) -> tuple[list[_AudioPad], tuple[AudioSourceExecution, ...], set[str]]:
883
+ """Mix each virtual source locally, then execute its ordinary clip timing.
884
+
885
+ Main callers:
886
+ - ``build_audio_execution_plan`` after ordinary descendant item graphs are
887
+ complete and before the sequence root mix.
888
+
889
+ Sources are processed deepest-first. An inner completed pad therefore
890
+ becomes one input to its parent's submix, matching the video group fold and
891
+ preventing an outer timeMap from being copied into every source item.
892
+ """
893
+
894
+ remaining = list(pads)
895
+ executions: list[AudioSourceExecution] = []
896
+ required: set[str] = set()
897
+ seen_paths: set[str] = set()
898
+ ordered = sorted(
899
+ instances,
900
+ key=lambda instance: (len(instance.ancestor_paths), instance.path),
901
+ reverse=True,
902
+ )
903
+ for index, instance in enumerate(ordered):
904
+ timing = instance.timing
905
+ if instance.path in seen_paths:
906
+ raise AudioExecutionError(
907
+ f"duplicate audio source instance path {instance.path!r}"
908
+ )
909
+ seen_paths.add(instance.path)
910
+ members = tuple(
911
+ pad for pad in remaining if instance.path in pad.ancestor_paths
912
+ )
913
+ remaining = [
914
+ pad for pad in remaining if instance.path not in pad.ancestor_paths
915
+ ]
916
+ if not members:
917
+ # Audio IR only permits this shape when the requested source
918
+ # interval is completely covered by explicit gaps.
919
+ continue
920
+ composition_duration = max(
921
+ sequence_duration,
922
+ timing.absolute_start + timing.source_duration,
923
+ )
924
+ base = builder.label(f"source_instance_{index}_silence")
925
+ builder.add(
926
+ (),
927
+ (
928
+ f"anullsrc=r={sample_rate}:cl={ffmpeg_layout}",
929
+ f"atrim=duration={_number_text(composition_duration)}",
930
+ "asetpts=PTS-STARTPTS",
931
+ ),
932
+ (base,),
933
+ )
934
+ mixed = builder.label(f"source_instance_{index}_mixed")
935
+ inputs = (base, *(member.label for member in members))
936
+ builder.add(
937
+ inputs,
938
+ (
939
+ f"amix=inputs={len(inputs)}:duration=first:dropout_transition=0:normalize=0",
940
+ f"atrim=duration={_number_text(composition_duration)}",
941
+ "asetpts=PTS-STARTPTS",
942
+ ),
943
+ (mixed,),
944
+ )
945
+ try:
946
+ rebased = rebase_source_retime(
947
+ timing.retime_map,
948
+ source_origin=timing.source_start,
949
+ stream_start=timing.absolute_start,
950
+ )
951
+ execution = build_retime_execution_plan(
952
+ rebased,
953
+ video_frame_duration=Fraction(1, 30),
954
+ include_audio=True,
955
+ audio_sample_rate=sample_rate,
956
+ audio_channel_layout=ffmpeg_layout,
957
+ preserve_audio_pitch=instance.preserves_pitch,
958
+ freeze_audio_policy=freeze_audio_policy,
959
+ )
960
+ retimed = builder.label(f"source_instance_{index}_retimed")
961
+ prefix = builder.label(f"source_instance_{index}_retime_prefix")
962
+ retime_segments = build_audio_filtergraph_segments(
963
+ execution,
964
+ input_label=mixed,
965
+ output_label=retimed,
966
+ label_prefix=prefix,
967
+ )
968
+ except (ValueError, AudioFreezeBehaviorBlocked) as error:
969
+ raise AudioExecutionError(
970
+ f"{instance.path} has an unexecutable virtual-source audio retime: {error}"
971
+ ) from error
972
+ # Splice each retime chain as a STRUCTURED segment (not add_raw): the
973
+ # node/edge form serialises back to the exact same string, so the byte
974
+ # gate holds, while the PyAV port gets real filter nodes to build.
975
+ for in_labels, filters, out_labels in retime_segments:
976
+ builder.add(in_labels, filters, out_labels)
977
+ audio_retime_filters = {
978
+ "anullsrc",
979
+ "areverse",
980
+ "aresample",
981
+ "asetpts",
982
+ "asetrate",
983
+ "asplit",
984
+ "atempo",
985
+ "atrim",
986
+ "concat",
987
+ }
988
+ required.update(
989
+ name
990
+ for name in required_retime_filters(execution)
991
+ if name in audio_retime_filters
992
+ )
993
+ retimed, control_required = _apply_source_instance_controls(
994
+ builder,
995
+ retimed,
996
+ instance,
997
+ output_layout=output_layout,
998
+ ffmpeg_layout=ffmpeg_layout,
999
+ )
1000
+ required.update(control_required)
1001
+ delayed = retimed
1002
+ delay_samples = _time_to_sample(timing.absolute_start, sample_rate)
1003
+ if delay_samples:
1004
+ delayed = builder.chain(
1005
+ retimed,
1006
+ (f"adelay=delays={delay_samples}S:all=1",),
1007
+ stem=f"source_instance_{index}_timeline",
1008
+ )
1009
+ required.add("adelay")
1010
+ remaining.append(
1011
+ _AudioPad(
1012
+ label=delayed,
1013
+ ancestor_paths=instance.ancestor_paths,
1014
+ )
1015
+ )
1016
+ executions.append(
1017
+ AudioSourceExecution(
1018
+ path=instance.path,
1019
+ source_id=instance.source_id,
1020
+ input_count=len(members),
1021
+ output_label=delayed,
1022
+ start_sample=delay_samples,
1023
+ source_origin=timing.source_start,
1024
+ source_duration=timing.source_duration,
1025
+ )
1026
+ )
1027
+ return remaining, tuple(executions), required
1028
+
1029
+
1030
+ def _apply_source_instance_controls(
1031
+ builder: _GraphBuilder,
1032
+ source: str,
1033
+ instance: AudioSourceInstance,
1034
+ *,
1035
+ output_layout: str,
1036
+ ffmpeg_layout: str,
1037
+ ) -> tuple[str, set[str]]:
1038
+ """Apply ordinary clip audio controls after its source timing.
1039
+
1040
+ Static/animated gain, fades, stereo panning, and calibrated enhancements
1041
+ reuse the ordinary item routines. Source-window role controls and mute
1042
+ ranges remain invalid here because they require a second source-domain
1043
+ clock rather than the completed output clock.
1044
+ """
1045
+
1046
+ timing = instance.timing
1047
+ if not instance.controls:
1048
+ return source, set()
1049
+ proxy = RenderAudioItem(
1050
+ id="source_instance",
1051
+ path=instance.path,
1052
+ name=None,
1053
+ absolute_start=Fraction(0),
1054
+ duration=timing.duration,
1055
+ source_start=Fraction(0),
1056
+ source_duration=timing.duration,
1057
+ asset_id="<renderable-source>",
1058
+ asset_uid=None,
1059
+ source_stream_id="1",
1060
+ source_sample_rate=None,
1061
+ source_channels=(),
1062
+ output_channels=None,
1063
+ role=None,
1064
+ enabled=True,
1065
+ active=True,
1066
+ control_layers=instance.controls,
1067
+ retime=(),
1068
+ preserves_pitch=instance.preserves_pitch,
1069
+ )
1070
+ current = source
1071
+ required: set[str] = set()
1072
+ for layer_index, layer in enumerate(instance.controls):
1073
+ _validate_control_layer(layer, proxy)
1074
+ if layer.mutes or layer.source_start is not None or layer.source_duration is not None:
1075
+ raise UnsupportedAudioControlError(
1076
+ f"{layer.path} uses a source-window audio control on a retimed "
1077
+ "renderable source"
1078
+ )
1079
+ if layer.gain is not None:
1080
+ expression = _gain_amplitude_expression(layer.gain, proxy)
1081
+ current = builder.chain(
1082
+ current,
1083
+ (f"volume='{expression}':eval=frame",),
1084
+ stem=f"source_instance_gain_{layer_index}",
1085
+ )
1086
+ required.add("volume")
1087
+ current = _apply_fades(
1088
+ builder,
1089
+ current,
1090
+ layer.gain.fades,
1091
+ timing.duration,
1092
+ stem=f"source_instance_gainfade_{layer_index}",
1093
+ )
1094
+ if layer.gain.fades:
1095
+ required.add("afade")
1096
+ if layer.panner is not None:
1097
+ current = _apply_panner(
1098
+ builder,
1099
+ current,
1100
+ layer.panner,
1101
+ proxy,
1102
+ output_layout=output_layout,
1103
+ ffmpeg_layout=ffmpeg_layout,
1104
+ stem=f"source_instance_panner_{layer_index}",
1105
+ )
1106
+ required.update(("channelsplit", "volume", "join"))
1107
+ if layer.enhancements:
1108
+ try:
1109
+ plan = build_audio_enhancement_plan(layer.enhancements)
1110
+ plan.require_executable()
1111
+ except AudioEnhancementError as error:
1112
+ raise UnsupportedAudioControlError(
1113
+ f"{layer.path} has an unexecutable source-instance "
1114
+ f"audio enhancement: {error}"
1115
+ ) from error
1116
+ if plan.filters:
1117
+ current = builder.chain(
1118
+ current,
1119
+ plan.filters,
1120
+ stem=f"source_instance_enhancement_{layer_index}",
1121
+ )
1122
+ required.update(plan.required_filters)
1123
+ return current, required
1124
+
1125
+
1126
+ def _resolve_output_routes(
1127
+ item: RenderAudioItem,
1128
+ stream: AudioStreamBinding,
1129
+ source_channels: tuple[int, ...],
1130
+ output_positions: tuple[str, ...],
1131
+ ) -> tuple[tuple[int, str, Fraction], ...]:
1132
+ if item.output_channels is not None:
1133
+ if len(item.output_channels) != len(source_channels):
1134
+ raise AudioStreamResolutionError(
1135
+ f"{item.path} maps {len(source_channels)} source channels to "
1136
+ f"{len(item.output_channels)} output channels"
1137
+ )
1138
+ routes = []
1139
+ for source, raw_output in zip(source_channels, item.output_channels):
1140
+ if raw_output == "X":
1141
+ continue
1142
+ output = _FCP_OUTPUT_TO_FFMPEG.get(raw_output)
1143
+ if output is None or output not in output_positions:
1144
+ raise AudioStreamResolutionError(
1145
+ f"{item.path} routes to unavailable output channel {raw_output!r}"
1146
+ )
1147
+ routes.append((source, output, Fraction(1)))
1148
+ return tuple(routes)
1149
+
1150
+ count = len(source_channels)
1151
+ if output_positions == ("FC",):
1152
+ coefficient = Fraction(1, count)
1153
+ return tuple((source, "FC", coefficient) for source in source_channels)
1154
+ if output_positions == ("FL", "FR"):
1155
+ if count == 1:
1156
+ return (
1157
+ (source_channels[0], "FL", Fraction(1)),
1158
+ (source_channels[0], "FR", Fraction(1)),
1159
+ )
1160
+ semantic = _source_channel_positions(stream)
1161
+ selected = tuple((source, semantic[source - 1]) for source in source_channels)
1162
+ routes: list[tuple[int, str, Fraction]] = []
1163
+ for source, position in selected:
1164
+ if position == "FL":
1165
+ routes.append((source, "FL", Fraction(1)))
1166
+ elif position == "FR":
1167
+ routes.append((source, "FR", Fraction(1)))
1168
+ elif position == "FC":
1169
+ routes.extend(
1170
+ ((source, "FL", Fraction(707, 1000)), (source, "FR", Fraction(707, 1000)))
1171
+ )
1172
+ elif position in {"SL", "BL", "BC"}:
1173
+ routes.append((source, "FL", Fraction(707, 1000)))
1174
+ elif position in {"SR", "BR"}:
1175
+ routes.append((source, "FR", Fraction(707, 1000)))
1176
+ elif position == "LFE":
1177
+ # Final Cut's default LFE downmix is not guessed.
1178
+ continue
1179
+ else:
1180
+ raise AudioStreamResolutionError(
1181
+ f"{item.path} has unsupported source position {position!r}"
1182
+ )
1183
+ return tuple(routes)
1184
+ if output_positions == ("FL", "FR", "FC", "LFE", "SL", "SR"):
1185
+ if count == 1:
1186
+ return ((source_channels[0], "FC", Fraction(1)),)
1187
+ semantic = _source_channel_positions(stream)
1188
+ routes = []
1189
+ aliases = {"BL": "SL", "BR": "SR", "BC": "FC"}
1190
+ for source in source_channels:
1191
+ position = aliases.get(semantic[source - 1], semantic[source - 1])
1192
+ if position not in output_positions:
1193
+ raise AudioStreamResolutionError(
1194
+ f"{item.path} cannot default-route source position {position!r} to 5.1"
1195
+ )
1196
+ routes.append((source, position, Fraction(1)))
1197
+ return tuple(routes)
1198
+ raise AudioExecutionError("unreachable output layout")
1199
+
1200
+
1201
+ def _source_channel_positions(stream: AudioStreamBinding) -> tuple[str, ...]:
1202
+ if stream.channel_layout in _KNOWN_SOURCE_LAYOUTS:
1203
+ positions = _KNOWN_SOURCE_LAYOUTS[stream.channel_layout]
1204
+ if len(positions) == stream.channels:
1205
+ return positions
1206
+ if stream.channels == 1:
1207
+ return ("FC",)
1208
+ if stream.channels == 2:
1209
+ return ("FL", "FR")
1210
+ raise AudioStreamResolutionError(
1211
+ f"stream {stream.source_stream_id!r} has {stream.channels} channels but no "
1212
+ "recognized channel layout; explicit outCh routing is required"
1213
+ )
1214
+
1215
+
1216
+ def _build_item_graph(
1217
+ builder: _GraphBuilder,
1218
+ item: RenderAudioItem,
1219
+ source_label: str,
1220
+ *,
1221
+ routes: tuple[tuple[int, str, Fraction], ...],
1222
+ output_layout: str,
1223
+ ffmpeg_layout: str,
1224
+ sample_rate: int,
1225
+ freeze_audio_policy: AudioFreezePolicy,
1226
+ ) -> tuple[str, set[str], bool, tuple[Mapping[str, Any], ...]]:
1227
+ if item.duration <= 0 or item.source_duration <= 0 or item.source_start < 0:
1228
+ raise AudioExecutionError(f"{item.path} has an invalid audio interval")
1229
+ for layer in item.control_layers:
1230
+ _validate_control_layer(layer, item)
1231
+
1232
+ required = {"atrim", "asetpts", "aresample"}
1233
+ enhancement_plans = []
1234
+
1235
+ # Decide up front whether this item reads ANY source media. A fully frozen
1236
+ # item -- every retime segment is a freeze that Final Cut renders as
1237
+ # calibrated silence (``_FCP_FREEZE_SILENCE_CALIBRATION``) -- produces pure
1238
+ # ``anullsrc`` silence and consumes nothing from the source. Building the
1239
+ # source ``atrim`` + ``pan`` route in that case leaves the ``pan`` output pad
1240
+ # unconnected, which FFmpeg (exit 234) and the in-process PyAV graph (Errno
1241
+ # 22) both reject. So skip the media route entirely for a pure-silence item:
1242
+ # silence through any gain/mute/panner control is still silence, so nothing is
1243
+ # lost, and only fully-frozen items take this branch (every item with >= 1
1244
+ # media segment, including play-then-freeze, keeps its exact media route byte
1245
+ # for byte).
1246
+ #
1247
+ # NOTE: "freeze audio == silence" is the current MVP assumption; it is not yet
1248
+ # verified against real Final Cut and may be revisited post-MVP.
1249
+ retimed = bool(item.retime)
1250
+ retime_execution = (
1251
+ _build_item_retime_execution(
1252
+ item,
1253
+ sample_rate=sample_rate,
1254
+ ffmpeg_layout=ffmpeg_layout,
1255
+ freeze_audio_policy=freeze_audio_policy,
1256
+ )
1257
+ if retimed
1258
+ else None
1259
+ )
1260
+ consumes_media = retime_execution is None or any(
1261
+ segment.operation == "media" for segment in retime_execution.audio_segments
1262
+ )
1263
+
1264
+ current = None
1265
+ if consumes_media:
1266
+ required.add("pan")
1267
+ source_end = item.source_start + item.source_duration
1268
+ current = builder.chain(
1269
+ source_label,
1270
+ (
1271
+ f"atrim=start={_number_text(item.source_start)}:end={_number_text(source_end)}",
1272
+ "asetpts=PTS-STARTPTS",
1273
+ ),
1274
+ stem=f"{item.id}_trim",
1275
+ )
1276
+ current = builder.chain(
1277
+ current,
1278
+ (_pan_filter(routes, ffmpeg_layout),),
1279
+ stem=f"{item.id}_route",
1280
+ )
1281
+
1282
+ for layer_index, layer in enumerate(item.control_layers):
1283
+ if layer.gain is not None:
1284
+ expression = _gain_amplitude_expression(layer.gain, item)
1285
+ current = builder.chain(
1286
+ current,
1287
+ (f"volume='{expression}':eval=frame",),
1288
+ stem=f"{item.id}_gain_{layer_index}",
1289
+ )
1290
+ required.add("volume")
1291
+ current = _apply_fades(
1292
+ builder,
1293
+ current,
1294
+ layer.gain.fades,
1295
+ item.source_duration,
1296
+ stem=f"{item.id}_gainfade_{layer_index}",
1297
+ )
1298
+ if layer.gain.fades:
1299
+ required.add("afade")
1300
+ for mute_index, mute in enumerate(layer.mutes):
1301
+ expression = _mute_amplitude_expression(mute, item)
1302
+ current = builder.chain(
1303
+ current,
1304
+ (f"volume='{expression}':eval=frame",),
1305
+ stem=f"{item.id}_mute_{layer_index}_{mute_index}",
1306
+ )
1307
+ required.add("volume")
1308
+ if layer.panner is not None:
1309
+ current = _apply_panner(
1310
+ builder,
1311
+ current,
1312
+ layer.panner,
1313
+ item,
1314
+ output_layout=output_layout,
1315
+ ffmpeg_layout=ffmpeg_layout,
1316
+ stem=f"{item.id}_panner_{layer_index}",
1317
+ )
1318
+ required.update(("channelsplit", "volume", "join"))
1319
+ if layer.source_start is not None or layer.source_duration is not None:
1320
+ expression = _role_window_expression(layer, item)
1321
+ current = builder.chain(
1322
+ current,
1323
+ (f"volume='{expression}':eval=frame",),
1324
+ stem=f"{item.id}_role_window_{layer_index}",
1325
+ )
1326
+ required.add("volume")
1327
+
1328
+ if retimed:
1329
+ current, retime_required = _apply_retime(
1330
+ builder,
1331
+ current if current is not None else source_label,
1332
+ item,
1333
+ retime_execution,
1334
+ )
1335
+ required.update(retime_required)
1336
+ else:
1337
+ current = builder.chain(
1338
+ current,
1339
+ (
1340
+ f"atrim=duration={_number_text(item.duration)}",
1341
+ "asetpts=PTS-STARTPTS",
1342
+ ),
1343
+ stem=f"{item.id}_duration",
1344
+ )
1345
+
1346
+ # Final Cut applies these intrinsic cleanup controls to the already
1347
+ # routed/editorially retimed signal. Keep their order across control
1348
+ # scopes and fail the whole item if one active adjustment is opaque.
1349
+ for layer_index, layer in enumerate(item.control_layers):
1350
+ if not layer.enhancements:
1351
+ continue
1352
+ try:
1353
+ enhancement_plan = build_audio_enhancement_plan(layer.enhancements)
1354
+ enhancement_plan.require_executable()
1355
+ except AudioEnhancementError as error:
1356
+ raise UnsupportedAudioControlError(
1357
+ f"{layer.path} has an unexecutable audio enhancement: {error}"
1358
+ ) from error
1359
+ if enhancement_plan.filters:
1360
+ current = builder.chain(
1361
+ current,
1362
+ enhancement_plan.filters,
1363
+ stem=f"{item.id}_enhancements_{layer_index}",
1364
+ )
1365
+ required.update(enhancement_plan.required_filters)
1366
+ enhancement_plans.append(enhancement_plan.manifest())
1367
+
1368
+ delay_samples = _time_to_sample(item.absolute_start, sample_rate)
1369
+ filters = [f"aresample={sample_rate}"]
1370
+ if delay_samples:
1371
+ filters.append(f"adelay=delays={delay_samples}S:all=1")
1372
+ required.add("adelay")
1373
+ current = builder.chain(
1374
+ current,
1375
+ tuple(filters),
1376
+ stem=f"{item.id}_timeline",
1377
+ )
1378
+ return current, required, retimed, tuple(enhancement_plans)
1379
+
1380
+
1381
+ def _validate_control_layer(layer: AudioControlLayer, item: RenderAudioItem) -> None:
1382
+ if not layer.enabled or not layer.active:
1383
+ raise AudioExecutionError(
1384
+ f"audible item {item.path} contains inactive control layer {layer.path}"
1385
+ )
1386
+ if layer.gain is not None and layer.gain.unit != "dB":
1387
+ raise UnsupportedAudioControlError(
1388
+ f"{layer.path} gain unit {layer.gain.unit!r} is unsupported"
1389
+ )
1390
+ if layer.panner is not None and layer.panner.amount.unit != "normalized":
1391
+ raise UnsupportedAudioControlError(
1392
+ f"{layer.path} panner unit {layer.panner.amount.unit!r} is unsupported"
1393
+ )
1394
+
1395
+
1396
+ def _pan_filter(
1397
+ routes: tuple[tuple[int, str, Fraction], ...], ffmpeg_layout: str
1398
+ ) -> str:
1399
+ terms: dict[str, list[str]] = {}
1400
+ for source_channel, output_channel, coefficient in routes:
1401
+ if coefficient == 0:
1402
+ continue
1403
+ rendered = f"c{source_channel - 1}"
1404
+ if coefficient != 1:
1405
+ rendered = f"{_number_text(coefficient)}*{rendered}"
1406
+ terms.setdefault(output_channel, []).append(rendered)
1407
+ assignments = []
1408
+ for output in _OUTPUT_LAYOUTS[
1409
+ "mono" if ffmpeg_layout == "mono" else "stereo" if ffmpeg_layout == "stereo" else "surround"
1410
+ ][1]:
1411
+ expression = "+".join(terms.get(output, ())) or "0*c0"
1412
+ assignments.append(f"{output}={expression}")
1413
+ return f"pan={ffmpeg_layout}|" + "|".join(assignments)
1414
+
1415
+
1416
+ def _gain_amplitude_expression(
1417
+ gain: AnimatedAudioScalar, item: RenderAudioItem
1418
+ ) -> str:
1419
+ db_expression = _animated_expression(
1420
+ gain,
1421
+ item,
1422
+ value_name="gain",
1423
+ minimum=None,
1424
+ maximum=None,
1425
+ )
1426
+ return f"pow(10,({db_expression})/20)"
1427
+
1428
+
1429
+ def _animated_expression(
1430
+ animated: AnimatedAudioScalar,
1431
+ item: RenderAudioItem,
1432
+ *,
1433
+ value_name: str,
1434
+ minimum: float | None,
1435
+ maximum: float | None,
1436
+ ) -> str:
1437
+ values = [animated.initial, *(point.value for point in animated.keyframes)]
1438
+ if any(not math.isfinite(value) for value in values):
1439
+ raise UnsupportedAudioControlError(
1440
+ f"{item.path} has non-finite {value_name} automation"
1441
+ )
1442
+ if minimum is not None and any(value < minimum for value in values):
1443
+ raise UnsupportedAudioControlError(
1444
+ f"{item.path} has {value_name} below {minimum}"
1445
+ )
1446
+ if maximum is not None and any(value > maximum for value in values):
1447
+ raise UnsupportedAudioControlError(
1448
+ f"{item.path} has {value_name} above {maximum}"
1449
+ )
1450
+ for point in animated.keyframes:
1451
+ if point.aux_value is not None:
1452
+ raise UnsupportedAudioControlError(
1453
+ f"{item.path} has uncalibrated {value_name} auxValue"
1454
+ )
1455
+ points = tuple(animated.keyframes)
1456
+ if not points:
1457
+ return _float_text(animated.initial)
1458
+ relative = tuple((point.time - item.source_start, point) for point in points)
1459
+ expression = _float_text(points[-1].value)
1460
+ for (left_time, left), (right_time, right) in reversed(tuple(zip(relative, relative[1:]))):
1461
+ if right_time <= left_time:
1462
+ raise UnsupportedAudioControlError(
1463
+ f"{item.path} has duplicate or reversed {value_name} keyframes"
1464
+ )
1465
+ progress = f"((t-{_number_text(left_time)})/{_number_text(right_time - left_time)})"
1466
+ shaped = _interpolation_expression(progress, left.interp, left.curve, item.path)
1467
+ value = (
1468
+ f"({_float_text(left.value)}+({_float_text(right.value)}-"
1469
+ f"{_float_text(left.value)})*{shaped})"
1470
+ )
1471
+ expression = f"if(lt(t,{_number_text(right_time)}),{value},{expression})"
1472
+ first_time, first = relative[0]
1473
+ return f"if(lt(t,{_number_text(first_time)}),{_float_text(first.value)},{expression})"
1474
+
1475
+
1476
+ def _interpolation_expression(
1477
+ progress: str, interp: str, curve: str, path: str
1478
+ ) -> str:
1479
+ name = (interp or curve or "linear").replace("-", "").casefold()
1480
+ if name == "linear":
1481
+ return progress
1482
+ if name in {"ease", "smooth"}:
1483
+ return f"(({progress})*({progress})*(3-2*({progress})))"
1484
+ if name == "easein":
1485
+ return f"(({progress})*({progress}))"
1486
+ if name == "easeout":
1487
+ return f"(1-(1-({progress}))*(1-({progress})))"
1488
+ raise UnsupportedAudioControlError(
1489
+ f"{path} requests unsupported audio interpolation {interp!r}/{curve!r}"
1490
+ )
1491
+
1492
+
1493
+ def _apply_fades(
1494
+ builder: _GraphBuilder,
1495
+ source: str,
1496
+ fades: tuple[AudioFade, ...],
1497
+ duration: Fraction,
1498
+ *,
1499
+ stem: str,
1500
+ ) -> str:
1501
+ current = source
1502
+ for index, fade in enumerate(fades):
1503
+ if fade.duration < 0 or fade.duration > duration:
1504
+ raise UnsupportedAudioControlError(
1505
+ f"audio fade duration {fade.duration} is outside item duration {duration}"
1506
+ )
1507
+ curve = _fade_curve(fade.curve)
1508
+ if fade.kind == "in":
1509
+ spec = f"afade=t=in:st=0:d={_number_text(fade.duration)}:curve={curve}"
1510
+ elif fade.kind == "out":
1511
+ start = duration - fade.duration
1512
+ spec = f"afade=t=out:st={_number_text(start)}:d={_number_text(fade.duration)}:curve={curve}"
1513
+ else:
1514
+ raise UnsupportedAudioControlError(f"unknown fade kind {fade.kind!r}")
1515
+ current = builder.chain(current, (spec,), stem=f"{stem}_{index}")
1516
+ return current
1517
+
1518
+
1519
+ def _fade_curve(name: str) -> str:
1520
+ normalized = name.replace("-", "").casefold()
1521
+ mapping = {
1522
+ "linear": "tri",
1523
+ "tri": "tri",
1524
+ "ease": "qsin",
1525
+ "easein": "qsin",
1526
+ "easeout": "qsin",
1527
+ "easeinout": "hsin",
1528
+ "smooth": "hsin",
1529
+ }
1530
+ try:
1531
+ return mapping[normalized]
1532
+ except KeyError as error:
1533
+ raise UnsupportedAudioControlError(
1534
+ f"unsupported audio fade curve {name!r}"
1535
+ ) from error
1536
+
1537
+
1538
+ def _mute_amplitude_expression(mute: AudioMuteRange, item: RenderAudioItem) -> str:
1539
+ source_start = (
1540
+ mute.source_start if mute.source_start is not None else item.source_start
1541
+ )
1542
+ start = source_start - item.source_start
1543
+ end = item.source_duration if mute.duration is None else start + mute.duration
1544
+ start = max(Fraction(0), start)
1545
+ end = min(item.source_duration, end)
1546
+ if end <= start:
1547
+ return "1"
1548
+ fade_in = next((fade for fade in mute.fades if fade.kind == "in"), None)
1549
+ fade_out = next((fade for fade in mute.fades if fade.kind == "out"), None)
1550
+ if len([fade for fade in mute.fades if fade.kind == "in"]) > 1 or len(
1551
+ [fade for fade in mute.fades if fade.kind == "out"]
1552
+ ) > 1:
1553
+ raise UnsupportedAudioControlError(f"{item.path} has duplicate mute fades")
1554
+ expression = f"if(between(t,{_number_text(start)},{_number_text(end)}),0,1)"
1555
+ if fade_in is not None and fade_in.duration > 0:
1556
+ ramp_start = max(Fraction(0), start - fade_in.duration)
1557
+ width = start - ramp_start
1558
+ if width > 0:
1559
+ ramp = f"(1-(t-{_number_text(ramp_start)})/{_number_text(width)})"
1560
+ expression = (
1561
+ f"if(between(t,{_number_text(ramp_start)},{_number_text(start)}),"
1562
+ f"{ramp},{expression})"
1563
+ )
1564
+ if fade_out is not None and fade_out.duration > 0:
1565
+ ramp_end = min(item.source_duration, end + fade_out.duration)
1566
+ width = ramp_end - end
1567
+ if width > 0:
1568
+ ramp = f"((t-{_number_text(end)})/{_number_text(width)})"
1569
+ expression = (
1570
+ f"if(between(t,{_number_text(end)},{_number_text(ramp_end)}),"
1571
+ f"{ramp},{expression})"
1572
+ )
1573
+ return expression
1574
+
1575
+
1576
+ def _apply_panner(
1577
+ builder: _GraphBuilder,
1578
+ source: str,
1579
+ panner: AudioPanner,
1580
+ item: RenderAudioItem,
1581
+ *,
1582
+ output_layout: str,
1583
+ ffmpeg_layout: str,
1584
+ stem: str,
1585
+ ) -> str:
1586
+ mode = (panner.mode or "stereo").casefold()
1587
+ if mode not in {"stereo", "balance"}:
1588
+ raise UnsupportedAudioControlError(
1589
+ f"{item.path} requests unsupported panner mode {panner.mode!r}"
1590
+ )
1591
+ amount = _animated_expression(
1592
+ panner.amount,
1593
+ item,
1594
+ value_name="pan",
1595
+ minimum=-1.0,
1596
+ maximum=1.0,
1597
+ )
1598
+ if output_layout != "stereo":
1599
+ if all(value == 0 for value in [panner.amount.initial, *(point.value for point in panner.amount.keyframes)]):
1600
+ return source
1601
+ raise UnsupportedAudioControlError(
1602
+ f"{item.path} has non-zero stereo panning in {output_layout} output"
1603
+ )
1604
+ left = builder.label(f"{stem}_left")
1605
+ right = builder.label(f"{stem}_right")
1606
+ builder.add(
1607
+ (source,),
1608
+ (f"channelsplit=channel_layout={ffmpeg_layout}",),
1609
+ (left, right),
1610
+ )
1611
+ # Final Cut's Stereo Left/Right panner uses a constant-power curve. A
1612
+ # genuine Final Cut 12.3 export at amount=65 measured -11.23 dB between
1613
+ # the channels; cos/sin quarter-circle panning predicts -10.99 dB. The
1614
+ # former linear balance expression predicted only -9.12 dB.
1615
+ left_gain = f"cos((({amount})+1)*PI/4)"
1616
+ right_gain = f"sin((({amount})+1)*PI/4)"
1617
+ left_out = builder.chain(
1618
+ left, (f"volume='{left_gain}':eval=frame",), stem=f"{stem}_left_gain"
1619
+ )
1620
+ right_out = builder.chain(
1621
+ right, (f"volume='{right_gain}':eval=frame",), stem=f"{stem}_right_gain"
1622
+ )
1623
+ joined = builder.label(f"{stem}_join")
1624
+ builder.add(
1625
+ (left_out, right_out),
1626
+ ("join=inputs=2:channel_layout=stereo:map=0.0-FL|1.0-FR",),
1627
+ (joined,),
1628
+ )
1629
+ return joined
1630
+
1631
+
1632
+ def _role_window_expression(layer: AudioControlLayer, item: RenderAudioItem) -> str:
1633
+ source_start = (
1634
+ layer.source_start if layer.source_start is not None else item.source_start
1635
+ )
1636
+ start = source_start - item.source_start
1637
+ duration = (
1638
+ layer.source_duration
1639
+ if layer.source_duration is not None
1640
+ else item.source_duration
1641
+ )
1642
+ end = start + duration
1643
+ return f"if(between(t,{_number_text(start)},{_number_text(end)}),1,0)"
1644
+
1645
+
1646
+ def _build_item_retime_execution(
1647
+ item: RenderAudioItem,
1648
+ *,
1649
+ sample_rate: int,
1650
+ ffmpeg_layout: str,
1651
+ freeze_audio_policy: AudioFreezePolicy,
1652
+ ):
1653
+ """Build one item's retime execution plan without emitting any graph.
1654
+
1655
+ Split out from ``_apply_retime`` so ``_build_item_graph`` can inspect the
1656
+ plan's segments -- specifically whether ANY segment reads source media --
1657
+ before it decides to build the source ``atrim``/``pan`` route. A fully
1658
+ frozen item consumes no media and must skip that route (otherwise its ``pan``
1659
+ output is left unconnected and the graph is rejected). Touches nothing on the
1660
+ builder, so calling it before the media route does not perturb label order.
1661
+ """
1662
+ points = tuple(
1663
+ RetimePoint(
1664
+ timeline_time=point.output_time,
1665
+ source_time=point.source_time - item.source_start,
1666
+ interpolation=point.interp,
1667
+ )
1668
+ for point in item.retime
1669
+ )
1670
+ try:
1671
+ retime_map = RetimeMap.from_points_visible(points, item.duration)
1672
+ except ValueError as error:
1673
+ raise UnsupportedAudioControlError(
1674
+ f"{item.path} has invalid or nonlinear audio retiming: {error}"
1675
+ ) from error
1676
+ for segment in retime_map.segments:
1677
+ low = min(segment.source_start, segment.source_end)
1678
+ high = max(segment.source_start, segment.source_end)
1679
+ if low < 0 or high > item.source_duration:
1680
+ raise UnsupportedAudioControlError(
1681
+ f"{item.path} retime source range [{low}, {high}] is outside "
1682
+ f"the bound source duration {item.source_duration}"
1683
+ )
1684
+ return build_retime_execution_plan(
1685
+ retime_map,
1686
+ video_frame_duration=Fraction(1, 30),
1687
+ include_audio=True,
1688
+ audio_sample_rate=sample_rate,
1689
+ audio_channel_layout=ffmpeg_layout,
1690
+ preserve_audio_pitch=item.preserves_pitch,
1691
+ freeze_audio_policy=freeze_audio_policy,
1692
+ )
1693
+
1694
+
1695
+ def _apply_retime(
1696
+ builder: _GraphBuilder,
1697
+ source: str,
1698
+ item: RenderAudioItem,
1699
+ execution,
1700
+ ) -> tuple[str, tuple[str, ...]]:
1701
+ """Emit the retime segment graph for a precomputed ``execution``.
1702
+
1703
+ ``source`` is the upstream media label. A pure-silence (fully frozen)
1704
+ execution reads nothing from ``source`` -- the caller passes the raw source
1705
+ label as an unused placeholder and skips building the media route.
1706
+ """
1707
+ output = builder.label(f"{item.id}_retime")
1708
+ prefix = builder.label(f"{item.id}_retime_prefix")
1709
+ try:
1710
+ retime_segments = build_audio_filtergraph_segments(
1711
+ execution,
1712
+ input_label=source,
1713
+ output_label=output,
1714
+ label_prefix=prefix,
1715
+ )
1716
+ except AudioFreezeBehaviorBlocked as error:
1717
+ raise AudioFreezeCalibrationError(
1718
+ f"{item.path} contains freeze audio without a calibration policy"
1719
+ ) from error
1720
+ # Structured splice (see the source-instance retime above): byte-identical
1721
+ # to the old add_raw(graph.split(";")) but exposes filter nodes to PyAV.
1722
+ for in_labels, filters, out_labels in retime_segments:
1723
+ builder.add(in_labels, filters, out_labels)
1724
+ audio_only_filters = {
1725
+ "anullsrc",
1726
+ "areverse",
1727
+ "aresample",
1728
+ "asetpts",
1729
+ "asetrate",
1730
+ "asplit",
1731
+ "atempo",
1732
+ "atrim",
1733
+ "concat",
1734
+ }
1735
+ return output, tuple(
1736
+ name for name in required_retime_filters(execution) if name in audio_only_filters
1737
+ )
1738
+
1739
+
1740
+ def probe_stock_ffmpeg_audio_capabilities(
1741
+ plan: AudioExecutionPlan,
1742
+ *,
1743
+ ffmpeg_path: str | Path = "ffmpeg",
1744
+ ) -> FFmpegAudioCapabilityReport:
1745
+ """Read one installed FFmpeg's filters and compare the exact requirement set."""
1746
+
1747
+ try:
1748
+ version = subprocess.run(
1749
+ (str(ffmpeg_path), "-hide_banner", "-version"),
1750
+ check=True,
1751
+ capture_output=True,
1752
+ text=True,
1753
+ timeout=15,
1754
+ )
1755
+ filters = subprocess.run(
1756
+ (str(ffmpeg_path), "-hide_banner", "-filters"),
1757
+ check=True,
1758
+ capture_output=True,
1759
+ text=True,
1760
+ timeout=15,
1761
+ )
1762
+ except (OSError, subprocess.SubprocessError) as error:
1763
+ raise MissingFFmpegAudioCapability(
1764
+ f"could not inspect FFmpeg executable {str(ffmpeg_path)!r}: {error}"
1765
+ ) from error
1766
+ available = _parse_filter_names(filters.stdout)
1767
+ missing = tuple(name for name in plan.required_filters if name not in available)
1768
+ return FFmpegAudioCapabilityReport(
1769
+ executable=str(ffmpeg_path),
1770
+ version_line=version.stdout.splitlines()[0] if version.stdout else "unknown",
1771
+ required_filters=plan.required_filters,
1772
+ available_filters=tuple(sorted(available)),
1773
+ missing_filters=missing,
1774
+ )
1775
+
1776
+
1777
+ def run_audio_execution_plan(
1778
+ plan: AudioExecutionPlan,
1779
+ output_path: str | Path,
1780
+ *,
1781
+ ffmpeg_path: str | Path = "ffmpeg",
1782
+ codec: str = "pcm_s24le",
1783
+ timeout_seconds: float = 120,
1784
+ ) -> subprocess.CompletedProcess[str]:
1785
+ """Run a frozen plan without a shell after checking its stock filters."""
1786
+
1787
+ capability = probe_stock_ffmpeg_audio_capabilities(plan, ffmpeg_path=ffmpeg_path)
1788
+ capability.require_supported()
1789
+ try:
1790
+ return subprocess.run(
1791
+ plan.command(output_path, ffmpeg_path=ffmpeg_path, codec=codec),
1792
+ check=True,
1793
+ capture_output=True,
1794
+ text=True,
1795
+ timeout=timeout_seconds,
1796
+ )
1797
+ except (OSError, subprocess.SubprocessError) as error:
1798
+ raise AudioExecutionError(f"FFmpeg audio render failed: {error}") from error
1799
+
1800
+
1801
+ def _time_to_sample(value: Fraction, sample_rate: int) -> int:
1802
+ exact = value * sample_rate
1803
+ if exact < 0:
1804
+ raise AudioExecutionError("audio timeline delay cannot be negative")
1805
+ return (exact.numerator * 2 + exact.denominator) // (2 * exact.denominator)
1806
+
1807
+
1808
+ def _number_text(value: Fraction) -> str:
1809
+ if value.denominator == 1:
1810
+ return str(value.numerator)
1811
+ with localcontext() as context:
1812
+ context.prec = 24
1813
+ rendered = format(Decimal(value.numerator) / Decimal(value.denominator), "f")
1814
+ return rendered.rstrip("0").rstrip(".")
1815
+
1816
+
1817
+ def _fraction_text(value: Fraction) -> str:
1818
+ return str(value.numerator) if value.denominator == 1 else f"{value.numerator}/{value.denominator}"
1819
+
1820
+
1821
+ def _float_text(value: float) -> str:
1822
+ if not math.isfinite(value):
1823
+ raise UnsupportedAudioControlError("audio expression value must be finite")
1824
+ return format(value, ".12g")
1825
+
1826
+
1827
+ def _parse_filter_names(output: str) -> set[str]:
1828
+ pattern = re.compile(r"^\s*[.TSCAX|]{2,6}\s+([A-Za-z0-9_]+)\s+", re.MULTILINE)
1829
+ return {match.group(1) for match in pattern.finditer(output)}
1830
+
1831
+
1832
+ __all__ = [
1833
+ "AudioAssetBinding",
1834
+ "AudioAssetBindingError",
1835
+ "AudioExecutionError",
1836
+ "AudioExecutionPlan",
1837
+ "AudioFreezeCalibrationError",
1838
+ "AudioItemExecution",
1839
+ "AudioStreamBinding",
1840
+ "AudioStreamResolutionError",
1841
+ "FFmpegAudioCapabilityReport",
1842
+ "MissingFFmpegAudioCapability",
1843
+ "UnsupportedAudioControlError",
1844
+ "build_audio_execution_plan",
1845
+ "probe_audio_asset",
1846
+ "probe_stock_ffmpeg_audio_capabilities",
1847
+ "run_audio_execution_plan",
1848
+ ]