bladeworks 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bladeworks/__init__.py +56 -0
- bladeworks/__main__.py +4 -0
- bladeworks/cli.py +728 -0
- bladeworks/core/__init__.py +34 -0
- bladeworks/core/animation.py +893 -0
- bladeworks/core/animation_execution.py +817 -0
- bladeworks/core/audio_enhancements.py +673 -0
- bladeworks/core/audio_execution.py +1848 -0
- bladeworks/core/audio_ir.py +1971 -0
- bladeworks/core/basic_effects.py +168 -0
- bladeworks/core/bindings.py +196 -0
- bladeworks/core/capabilities.py +257 -0
- bladeworks/core/cohort_effects.py +702 -0
- bladeworks/core/color.py +324 -0
- bladeworks/core/compiler.py +3142 -0
- bladeworks/core/composition_diagnostics.py +629 -0
- bladeworks/core/composition_ir.py +3154 -0
- bladeworks/core/composition_planning.py +708 -0
- bladeworks/core/composition_shadow.py +687 -0
- bladeworks/core/compositor.py +1186 -0
- bladeworks/core/effect_parameter_semantics.py +165 -0
- bladeworks/core/effect_parameters.py +193 -0
- bladeworks/core/errors.py +21 -0
- bladeworks/core/filter_text.py +48 -0
- bladeworks/core/geometry.py +1711 -0
- bladeworks/core/group_surface.py +664 -0
- bladeworks/core/keyer.py +280 -0
- bladeworks/core/masks.py +453 -0
- bladeworks/core/missing_media.py +130 -0
- bladeworks/core/model.py +985 -0
- bladeworks/core/multicam_execution.py +496 -0
- bladeworks/core/output_color.py +52 -0
- bladeworks/core/parser.py +1257 -0
- bladeworks/core/pixel_domains.py +433 -0
- bladeworks/core/render_profile.py +311 -0
- bladeworks/core/render_sources.py +475 -0
- bladeworks/core/report.py +118 -0
- bladeworks/core/retime.py +967 -0
- bladeworks/core/retime_execution.py +1515 -0
- bladeworks/core/spatial_intrinsics.py +1749 -0
- bladeworks/core/story_containers.py +524 -0
- bladeworks/core/story_ir.py +1042 -0
- bladeworks/core/text.py +1032 -0
- bladeworks/core/text_templates.py +934 -0
- bladeworks/data/FCPXML_RENDER_CAPABILITIES.yaml +1797 -0
- bladeworks/effects/__init__.py +1 -0
- bladeworks/effects/contract.py +162 -0
- bladeworks/examples/__init__.py +80 -0
- bladeworks/examples/color.fcpxmld/Info.fcpxml +17 -0
- bladeworks/examples/color.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Info.fcpxml +13 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Media/b.mp4 +0 -0
- bladeworks/examples/manual_qa/generate_2h_qa_fixtures.py +237 -0
- bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Info.fcpxml +28 -0
- bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Media/night_signal.mp4 +0 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Info.fcpxml +44 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/city_grid.mp4 +0 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/golden_hour.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Info.fcpxml +113 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/city_grid.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/golden_hour.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/night_signal.mp4 +0 -0
- bladeworks/examples/single_clip.fcpxmld/Info.fcpxml +9 -0
- bladeworks/examples/single_clip.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Info.fcpxml +13 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Media/b.mp4 +0 -0
- bladeworks/examples/transform.fcpxmld/Info.fcpxml +11 -0
- bladeworks/examples/transform.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/executor.py +293 -0
- bladeworks/preview/__init__.py +11 -0
- bladeworks/preview/application.py +84 -0
- bladeworks/preview/audio.py +104 -0
- bladeworks/preview/capabilities.py +457 -0
- bladeworks/preview/contracts.py +157 -0
- bladeworks/preview/events.py +55 -0
- bladeworks/preview/export.py +57 -0
- bladeworks/preview/history.py +197 -0
- bladeworks/preview/main.py +19 -0
- bladeworks/preview/media.py +23 -0
- bladeworks/preview/media_library.py +381 -0
- bladeworks/preview/media_reporting.py +55 -0
- bladeworks/preview/media_routes.py +155 -0
- bladeworks/preview/media_visuals.py +225 -0
- bladeworks/preview/producer.py +211 -0
- bladeworks/preview/provider.py +74 -0
- bladeworks/preview/rawframe.py +218 -0
- bladeworks/preview/render_jobs.py +311 -0
- bladeworks/preview/routes.py +375 -0
- bladeworks/preview/runner.py +349 -0
- bladeworks/preview/security.py +61 -0
- bladeworks/preview/service.py +234 -0
- bladeworks/preview/session.py +457 -0
- bladeworks/preview/source.py +603 -0
- bladeworks/preview/source_routes.py +151 -0
- bladeworks/preview/studio.py +77 -0
- bladeworks/preview/webrtc.py +295 -0
- bladeworks/spatial_luts/manifest.json +17 -0
- bladeworks/spatial_luts/rec2020_hlg_to_rec709_sdr_v1.cube +4917 -0
- bladeworks/spatial_luts/rec2020_pq_to_rec709_sdr_v1.cube +4917 -0
- bladeworks/studio_static/__init__.py +1 -0
- bladeworks/studio_static/app.js +6519 -0
- bladeworks/studio_static/build-id.js +1 -0
- bladeworks/studio_static/capability-ui.js +68 -0
- bladeworks/studio_static/clip-state.js +335 -0
- bladeworks/studio_static/fcpxml.js +3313 -0
- bladeworks/studio_static/fixtures.js +393 -0
- bladeworks/studio_static/history.js +50 -0
- bladeworks/studio_static/index.html +24 -0
- bladeworks/studio_static/magnetic-timeline.js +1194 -0
- bladeworks/studio_static/mask-ui.js +189 -0
- bladeworks/studio_static/media-visuals.js +224 -0
- bladeworks/studio_static/mock-capabilities.js +409 -0
- bladeworks/studio_static/retime-ui.js +151 -0
- bladeworks/studio_static/runtime.js +1652 -0
- bladeworks/studio_static/scope-ui.js +150 -0
- bladeworks/studio_static/styles.css +1558 -0
- bladeworks/studio_static/templates/catalog.js +77 -0
- bladeworks/studio_static/templates/icons.js +84 -0
- bladeworks/studio_static/templates/inspector.js +553 -0
- bladeworks/studio_static/templates/library.js +61 -0
- bladeworks/studio_static/templates/media.js +185 -0
- bladeworks/studio_static/templates/shared.js +43 -0
- bladeworks/studio_static/templates/shell.js +94 -0
- bladeworks/studio_static/templates/timeline.js +377 -0
- bladeworks/studio_static/templates/topbar.js +83 -0
- bladeworks/studio_static/templates/types.js +7 -0
- bladeworks/studio_static/templates/viewer.js +174 -0
- bladeworks/studio_static/templates.js +31 -0
- bladeworks/studio_static/types.js +18 -0
- bladeworks/studio_static/ui.js +97 -0
- bladeworks/studio_static/viewer-geometry.js +55 -0
- bladeworks/tensor/__init__.py +131 -0
- bladeworks/tensor/audio_delivery.py +280 -0
- bladeworks/tensor/audio_pyav.py +360 -0
- bladeworks/tensor/blend.py +217 -0
- bladeworks/tensor/color.py +53 -0
- bladeworks/tensor/composite.py +33 -0
- bladeworks/tensor/decode.py +1309 -0
- bladeworks/tensor/decode_policy.py +363 -0
- bladeworks/tensor/effects.py +322 -0
- bladeworks/tensor/encode.py +614 -0
- bladeworks/tensor/errors.py +32 -0
- bladeworks/tensor/expr.py +775 -0
- bladeworks/tensor/fx_basic.py +946 -0
- bladeworks/tensor/fx_branched.py +640 -0
- bladeworks/tensor/fx_cohort.py +404 -0
- bladeworks/tensor/fx_color.py +851 -0
- bladeworks/tensor/fx_keyer.py +140 -0
- bladeworks/tensor/fx_mask.py +352 -0
- bladeworks/tensor/fx_warp.py +337 -0
- bladeworks/tensor/hdr.py +122 -0
- bladeworks/tensor/pipeline.py +401 -0
- bladeworks/tensor/plan.py +2231 -0
- bladeworks/tensor/renderer.py +1199 -0
- bladeworks/tensor/requirements.txt +28 -0
- bladeworks/tensor/resolution.py +246 -0
- bladeworks/tensor/sampler.py +598 -0
- bladeworks/tensor/support.py +169 -0
- bladeworks/tensor/swscale_fixedpoint.py +181 -0
- bladeworks/tensor/tr_equirect.py +420 -0
- bladeworks/tensor/tr_handlers.py +443 -0
- bladeworks/tensor/tr_phase5.py +697 -0
- bladeworks/tensor/transitions.py +424 -0
- bladeworks/transitions/__init__.py +10 -0
- bladeworks/transitions/carryovers.py +299 -0
- bladeworks/transitions/cohort.py +981 -0
- bladeworks/transitions/contract.py +490 -0
- bladeworks/transitions/equirectangular.py +915 -0
- bladeworks/transitions/light_deco.py +141 -0
- bladeworks/transitions/panel_motion.py +495 -0
- bladeworks/transitions/stock.py +270 -0
- bladeworks-0.1.0.dist-info/METADATA +66 -0
- bladeworks-0.1.0.dist-info/RECORD +178 -0
- bladeworks-0.1.0.dist-info/WHEEL +5 -0
- bladeworks-0.1.0.dist-info/entry_points.txt +2 -0
- bladeworks-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
]
|