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,524 @@
|
|
|
1
|
+
"""Resolve Final Cut compound, synchronized, clip, and audition containers.
|
|
2
|
+
|
|
3
|
+
Architecture map
|
|
4
|
+
================
|
|
5
|
+
|
|
6
|
+
``SourceDocument.other_resources`` (preserved ``<media><sequence>`` XML)
|
|
7
|
+
-> strict reusable-resource catalog
|
|
8
|
+
-> reference/range/cycle validation
|
|
9
|
+
-> existing hierarchical ``story_ir`` builder
|
|
10
|
+
-> compiler-facing ``StoryContainerPlan``
|
|
11
|
+
|
|
12
|
+
The catalog is consumed directly by the shared render-source resolver. It does
|
|
13
|
+
not rewrite ``ref-clip`` elements into synthetic clips or spines.
|
|
14
|
+
|
|
15
|
+
Important invariants
|
|
16
|
+
--------------------
|
|
17
|
+
|
|
18
|
+
* A reference is resolved only to a ``media`` resource containing one
|
|
19
|
+
``sequence``. Missing or malformed targets are compile errors.
|
|
20
|
+
* All timing remains ``Fraction``. A ref selection must fit completely inside
|
|
21
|
+
the reusable sequence before either video or audio expansion can proceed.
|
|
22
|
+
* The ``ref-clip`` scope remains outside its internal composition. Ref-level
|
|
23
|
+
transform/opacity/filter controls therefore apply after video composition,
|
|
24
|
+
while gain/role/J-L controls apply around internal audio scheduling.
|
|
25
|
+
* The first audition child is active. Alternatives remain in ``story_ir`` as
|
|
26
|
+
inactive nodes and are never scheduled by the audio engine.
|
|
27
|
+
* ``live-drawing`` is a known explicit ``not_implemented_yet`` finding.
|
|
28
|
+
Any other parser-preserved unknown story element is rejected.
|
|
29
|
+
* Recursion is bounded and resource cycles include their full readable chain.
|
|
30
|
+
|
|
31
|
+
Main callers:
|
|
32
|
+
- The root compiler immediately after ``parse_fcpxml``.
|
|
33
|
+
- The shared A/V source resolver before clip-instance compilation.
|
|
34
|
+
|
|
35
|
+
Why this exists:
|
|
36
|
+
The secure parser intentionally preserves non-multicam ``media`` resources as
|
|
37
|
+
raw XML. Keeping this adapter isolated makes compound execution available now
|
|
38
|
+
without teaching the parser, video compiler, and audio compiler separate and
|
|
39
|
+
potentially inconsistent interpretations of the same resource.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from dataclasses import dataclass, replace
|
|
45
|
+
from fractions import Fraction
|
|
46
|
+
import xml.etree.ElementTree as ET
|
|
47
|
+
from typing import Literal, Mapping, Optional
|
|
48
|
+
|
|
49
|
+
from .errors import FCPXMLParseError
|
|
50
|
+
from .model import SequenceFormatContext, SourceDocument, StoryNode, parse_time
|
|
51
|
+
from .parser import _parse_story_children
|
|
52
|
+
from .story_ir import (
|
|
53
|
+
RenderStoryPlan,
|
|
54
|
+
ResourceStory,
|
|
55
|
+
StoryIRCycleError,
|
|
56
|
+
StoryIRDepthError,
|
|
57
|
+
StoryIRFinding,
|
|
58
|
+
build_render_story,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class StoryContainerError(ValueError):
|
|
63
|
+
"""Base class for resource/container adapter failures."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class StoryContainerResourceError(StoryContainerError):
|
|
67
|
+
"""Raised when a preserved reusable media resource is malformed."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class StoryContainerReferenceError(StoryContainerError):
|
|
71
|
+
"""Raised when an active ref-clip cannot be resolved exactly."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class AudioRoleSelectorHook:
|
|
76
|
+
"""One role selector nested below ``sync-source``.
|
|
77
|
+
|
|
78
|
+
This is an integration/audit record, not a second audio implementation.
|
|
79
|
+
The existing ``audio_ir`` module remains responsible for applying it.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
path: str
|
|
83
|
+
role: str
|
|
84
|
+
enabled: bool
|
|
85
|
+
active: bool
|
|
86
|
+
source_start: Optional[Fraction]
|
|
87
|
+
source_duration: Optional[Fraction]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class SyncAudioSourceHook:
|
|
92
|
+
"""Role-based audio routing for one synchronized-clip source domain."""
|
|
93
|
+
|
|
94
|
+
path: str
|
|
95
|
+
source_id: Literal["storyline", "connected"]
|
|
96
|
+
role_selectors: tuple[AudioRoleSelectorHook, ...]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class CompoundAudioReferenceHook:
|
|
101
|
+
"""Exact source selection that the audio resource expansion must honor."""
|
|
102
|
+
|
|
103
|
+
path: str
|
|
104
|
+
resource_id: str
|
|
105
|
+
selection_start: Fraction
|
|
106
|
+
selection_duration: Fraction
|
|
107
|
+
audio_start: Fraction
|
|
108
|
+
audio_duration: Fraction
|
|
109
|
+
use_audio_subroles: bool
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class ContainerAudioHooks:
|
|
114
|
+
"""Frozen bridge records consumed by audio integration and audits."""
|
|
115
|
+
|
|
116
|
+
synchronized_sources: tuple[SyncAudioSourceHook, ...]
|
|
117
|
+
compound_references: tuple[CompoundAudioReferenceHook, ...]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class CompoundResourceCatalog:
|
|
122
|
+
"""Reusable sequence stories parsed from preserved media resources."""
|
|
123
|
+
|
|
124
|
+
stories: Mapping[str, ResourceStory]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class StoryContainerPlan:
|
|
129
|
+
"""Compiler-facing result for all Wave 3 story containers."""
|
|
130
|
+
|
|
131
|
+
resources: CompoundResourceCatalog
|
|
132
|
+
story: RenderStoryPlan
|
|
133
|
+
audio_hooks: ContainerAudioHooks
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_compound_resource_stories(source: SourceDocument) -> CompoundResourceCatalog:
|
|
137
|
+
"""Parse every preserved ``media/sequence`` into one reusable story.
|
|
138
|
+
|
|
139
|
+
Main callers:
|
|
140
|
+
- ``build_story_container_plan``.
|
|
141
|
+
|
|
142
|
+
Why this exists:
|
|
143
|
+
``parser._parse_resources`` reserves typed handling for multicam and stores
|
|
144
|
+
compound media as ``OtherResource.raw_xml``. This routine deliberately
|
|
145
|
+
reuses the parser's one story-node routine, so timeline and resource
|
|
146
|
+
stories cannot disagree about intrinsic adjustments or exact time syntax.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
stories: dict[str, ResourceStory] = {}
|
|
150
|
+
for preserved in source.other_resources:
|
|
151
|
+
if preserved.kind != "media":
|
|
152
|
+
continue
|
|
153
|
+
if not preserved.id:
|
|
154
|
+
raise StoryContainerResourceError("compound media resource is missing id")
|
|
155
|
+
try:
|
|
156
|
+
media = ET.fromstring(preserved.raw_xml)
|
|
157
|
+
except ET.ParseError as exc:
|
|
158
|
+
raise StoryContainerResourceError(
|
|
159
|
+
f"media resource {preserved.id!r} contains malformed preserved XML"
|
|
160
|
+
) from exc
|
|
161
|
+
if _tag(media) != "media":
|
|
162
|
+
raise StoryContainerResourceError(
|
|
163
|
+
f"resource {preserved.id!r} was recorded as media but contains <{_tag(media)}>"
|
|
164
|
+
)
|
|
165
|
+
sequences = tuple(child for child in media if _tag(child) == "sequence")
|
|
166
|
+
if not sequences:
|
|
167
|
+
# A media object may be a project reference with no inline body.
|
|
168
|
+
# If it becomes active, reference validation below rejects it.
|
|
169
|
+
continue
|
|
170
|
+
if len(sequences) != 1:
|
|
171
|
+
raise StoryContainerResourceError(
|
|
172
|
+
f"media resource {preserved.id!r} must contain exactly one sequence"
|
|
173
|
+
)
|
|
174
|
+
sequence = sequences[0]
|
|
175
|
+
spines = tuple(child for child in sequence if _tag(child) == "spine")
|
|
176
|
+
if len(spines) != 1:
|
|
177
|
+
raise StoryContainerResourceError(
|
|
178
|
+
f"media resource {preserved.id!r} sequence must contain exactly one spine"
|
|
179
|
+
)
|
|
180
|
+
path = f"resources/media[@id='{preserved.id}']/sequence"
|
|
181
|
+
try:
|
|
182
|
+
start = parse_time(
|
|
183
|
+
sequence.get("tcStart"),
|
|
184
|
+
field_name=f"{path} tcStart",
|
|
185
|
+
) or Fraction(0)
|
|
186
|
+
story = tuple(_parse_story_children(spines[0], f"{path}/spine"))
|
|
187
|
+
duration = parse_time(
|
|
188
|
+
sequence.get("duration"),
|
|
189
|
+
field_name=f"{path} duration",
|
|
190
|
+
)
|
|
191
|
+
except (FCPXMLParseError, ValueError) as exc:
|
|
192
|
+
raise StoryContainerResourceError(str(exc)) from exc
|
|
193
|
+
if duration is None:
|
|
194
|
+
duration = _storyline_extent(story, start)
|
|
195
|
+
if duration <= 0:
|
|
196
|
+
raise StoryContainerResourceError(
|
|
197
|
+
f"media resource {preserved.id!r} sequence duration must be positive"
|
|
198
|
+
)
|
|
199
|
+
if preserved.id in stories:
|
|
200
|
+
raise StoryContainerResourceError(
|
|
201
|
+
f"duplicate compound media resource {preserved.id!r}"
|
|
202
|
+
)
|
|
203
|
+
format_id = sequence.get("format")
|
|
204
|
+
sequence_format = source.formats.get(format_id or "")
|
|
205
|
+
if sequence_format is None:
|
|
206
|
+
raise StoryContainerResourceError(
|
|
207
|
+
f"media resource {preserved.id!r} sequence references unknown format {format_id!r}"
|
|
208
|
+
)
|
|
209
|
+
if (
|
|
210
|
+
sequence_format.frame_duration is None
|
|
211
|
+
or not sequence_format.width
|
|
212
|
+
or not sequence_format.height
|
|
213
|
+
):
|
|
214
|
+
raise StoryContainerResourceError(
|
|
215
|
+
f"media resource {preserved.id!r} sequence format is incomplete"
|
|
216
|
+
)
|
|
217
|
+
stories[preserved.id] = ResourceStory(
|
|
218
|
+
resource_id=preserved.id,
|
|
219
|
+
path=path,
|
|
220
|
+
start=start,
|
|
221
|
+
duration=duration,
|
|
222
|
+
story=story,
|
|
223
|
+
format_context=SequenceFormatContext.from_resource(sequence_format),
|
|
224
|
+
)
|
|
225
|
+
return CompoundResourceCatalog(stories=stories)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def build_story_container_plan(
|
|
229
|
+
source: SourceDocument,
|
|
230
|
+
*,
|
|
231
|
+
max_depth: int = 32,
|
|
232
|
+
) -> StoryContainerPlan:
|
|
233
|
+
"""Resolve all supported story containers and preserve explicit findings.
|
|
234
|
+
|
|
235
|
+
Unknown story elements fail before ``story_ir`` construction. The one
|
|
236
|
+
known exception, live drawing/PKDrawing, is retained as an unresolved
|
|
237
|
+
group with a specific compatibility finding rather than a fake visual.
|
|
238
|
+
|
|
239
|
+
Main callers:
|
|
240
|
+
- The root compiler's render-IR v2 integration seam.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
if max_depth < 1:
|
|
244
|
+
raise ValueError("max_depth must be at least 1")
|
|
245
|
+
resources = parse_compound_resource_stories(source)
|
|
246
|
+
live_paths = _validate_known_story_surface(source, resources.stories)
|
|
247
|
+
_validate_references(
|
|
248
|
+
source.spine,
|
|
249
|
+
resources.stories,
|
|
250
|
+
max_depth=max_depth,
|
|
251
|
+
depth=1,
|
|
252
|
+
resource_chain=(),
|
|
253
|
+
)
|
|
254
|
+
story = build_render_story(
|
|
255
|
+
source,
|
|
256
|
+
resource_stories=resources.stories,
|
|
257
|
+
max_depth=max_depth,
|
|
258
|
+
unresolved_policy="report",
|
|
259
|
+
)
|
|
260
|
+
story = replace(
|
|
261
|
+
story,
|
|
262
|
+
findings=tuple(
|
|
263
|
+
_rewrite_live_drawing_finding(finding, live_paths)
|
|
264
|
+
for finding in story.findings
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
return StoryContainerPlan(
|
|
268
|
+
resources=resources,
|
|
269
|
+
story=story,
|
|
270
|
+
audio_hooks=collect_container_audio_hooks(source, resources.stories),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def collect_container_audio_hooks(
|
|
275
|
+
source: SourceDocument,
|
|
276
|
+
resource_stories: Mapping[str, ResourceStory],
|
|
277
|
+
) -> ContainerAudioHooks:
|
|
278
|
+
"""Extract synchronized-source and compound-reference audio seams.
|
|
279
|
+
|
|
280
|
+
Main callers:
|
|
281
|
+
- ``build_story_container_plan`` for compatibility/audit evidence.
|
|
282
|
+
|
|
283
|
+
Why this exists:
|
|
284
|
+
These controls live in raw XML rather than typed ``StoryNode`` fields.
|
|
285
|
+
Freezing them here proves their presence before the expanded source view is
|
|
286
|
+
passed to ``audio_ir``, which performs the actual routing and role match.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
synchronized: list[SyncAudioSourceHook] = []
|
|
290
|
+
references: list[CompoundAudioReferenceHook] = []
|
|
291
|
+
|
|
292
|
+
def visit(nodes: tuple[StoryNode, ...], resource_chain: tuple[str, ...]) -> None:
|
|
293
|
+
for node in nodes:
|
|
294
|
+
try:
|
|
295
|
+
element = ET.fromstring(node.raw_xml)
|
|
296
|
+
except ET.ParseError as exc:
|
|
297
|
+
raise StoryContainerResourceError(
|
|
298
|
+
f"{node.path} contains malformed preserved XML"
|
|
299
|
+
) from exc
|
|
300
|
+
if node.kind == "sync-clip":
|
|
301
|
+
sync_index = 0
|
|
302
|
+
for child in element:
|
|
303
|
+
if _tag(child) != "sync-source":
|
|
304
|
+
continue
|
|
305
|
+
sync_index += 1
|
|
306
|
+
source_id = child.get("sourceID")
|
|
307
|
+
hook_path = f"{node.path}/sync-source[{sync_index}]"
|
|
308
|
+
if source_id not in {"storyline", "connected"}:
|
|
309
|
+
raise StoryContainerResourceError(
|
|
310
|
+
f"{hook_path} has invalid sourceID {source_id!r}"
|
|
311
|
+
)
|
|
312
|
+
roles: list[AudioRoleSelectorHook] = []
|
|
313
|
+
role_index = 0
|
|
314
|
+
for role_source in child:
|
|
315
|
+
if _tag(role_source) != "audio-role-source":
|
|
316
|
+
raise StoryContainerResourceError(
|
|
317
|
+
f"{hook_path} contains unsupported <{_tag(role_source)}>"
|
|
318
|
+
)
|
|
319
|
+
role_index += 1
|
|
320
|
+
role_path = f"{hook_path}/audio-role-source[{role_index}]"
|
|
321
|
+
role = role_source.get("role")
|
|
322
|
+
if not role:
|
|
323
|
+
raise StoryContainerResourceError(f"{role_path} is missing role")
|
|
324
|
+
roles.append(
|
|
325
|
+
AudioRoleSelectorHook(
|
|
326
|
+
path=role_path,
|
|
327
|
+
role=role,
|
|
328
|
+
enabled=_bool_attr(role_source, "enabled", default=True, path=role_path),
|
|
329
|
+
active=_bool_attr(role_source, "active", default=True, path=role_path),
|
|
330
|
+
source_start=_time_attr(role_source, "start", role_path),
|
|
331
|
+
source_duration=_positive_optional_duration(role_source, role_path),
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
synchronized.append(
|
|
335
|
+
SyncAudioSourceHook(
|
|
336
|
+
path=hook_path,
|
|
337
|
+
source_id=source_id,
|
|
338
|
+
role_selectors=tuple(roles),
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
if node.kind == "ref-clip":
|
|
342
|
+
resource = resource_stories.get(node.ref or "")
|
|
343
|
+
if resource is None:
|
|
344
|
+
raise StoryContainerReferenceError(
|
|
345
|
+
f"{node.path} references media resource {node.ref!r} without an inline sequence"
|
|
346
|
+
)
|
|
347
|
+
audio_start = node.audio_start if node.audio_start is not None else node.start
|
|
348
|
+
audio_duration = node.audio_duration if node.audio_duration is not None else node.duration
|
|
349
|
+
references.append(
|
|
350
|
+
CompoundAudioReferenceHook(
|
|
351
|
+
path=node.path,
|
|
352
|
+
resource_id=resource.resource_id,
|
|
353
|
+
selection_start=node.start,
|
|
354
|
+
selection_duration=node.duration,
|
|
355
|
+
audio_start=audio_start,
|
|
356
|
+
audio_duration=audio_duration,
|
|
357
|
+
use_audio_subroles=_bool_attr(
|
|
358
|
+
element,
|
|
359
|
+
"useAudioSubroles",
|
|
360
|
+
default=False,
|
|
361
|
+
path=node.path,
|
|
362
|
+
),
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
if resource.resource_id not in resource_chain:
|
|
366
|
+
visit(resource.story, (*resource_chain, resource.resource_id))
|
|
367
|
+
visit(node.children, resource_chain)
|
|
368
|
+
|
|
369
|
+
visit(source.spine, ())
|
|
370
|
+
return ContainerAudioHooks(
|
|
371
|
+
synchronized_sources=tuple(synchronized),
|
|
372
|
+
compound_references=tuple(references),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _validate_known_story_surface(
|
|
377
|
+
source: SourceDocument,
|
|
378
|
+
resource_stories: Mapping[str, ResourceStory],
|
|
379
|
+
) -> frozenset[str]:
|
|
380
|
+
live_paths: set[str] = set()
|
|
381
|
+
|
|
382
|
+
def visit(nodes: tuple[StoryNode, ...], visited_resources: frozenset[str]) -> None:
|
|
383
|
+
for node in nodes:
|
|
384
|
+
if node.kind in {"live-drawing", "unknown:live-drawing"}:
|
|
385
|
+
live_paths.add(node.path)
|
|
386
|
+
elif node.kind.startswith("unknown:"):
|
|
387
|
+
raise StoryContainerResourceError(
|
|
388
|
+
f"{node.path} has unsupported story element {node.kind.removeprefix('unknown:')!r}"
|
|
389
|
+
)
|
|
390
|
+
visit(node.children, visited_resources)
|
|
391
|
+
if node.kind == "ref-clip" and node.ref in resource_stories and node.ref not in visited_resources:
|
|
392
|
+
visit(resource_stories[node.ref].story, visited_resources | {node.ref})
|
|
393
|
+
|
|
394
|
+
visit(source.spine, frozenset())
|
|
395
|
+
return frozenset(live_paths)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _validate_references(
|
|
399
|
+
nodes: tuple[StoryNode, ...],
|
|
400
|
+
resource_stories: Mapping[str, ResourceStory],
|
|
401
|
+
*,
|
|
402
|
+
max_depth: int,
|
|
403
|
+
depth: int,
|
|
404
|
+
resource_chain: tuple[str, ...],
|
|
405
|
+
) -> None:
|
|
406
|
+
if not nodes:
|
|
407
|
+
return
|
|
408
|
+
if depth > max_depth:
|
|
409
|
+
raise StoryIRDepthError(
|
|
410
|
+
f"story container validation exceeds max_depth={max_depth}"
|
|
411
|
+
)
|
|
412
|
+
for node in nodes:
|
|
413
|
+
if node.kind == "ref-clip":
|
|
414
|
+
resource = resource_stories.get(node.ref or "")
|
|
415
|
+
if resource is None:
|
|
416
|
+
raise StoryContainerReferenceError(
|
|
417
|
+
f"{node.path} references media resource {node.ref!r} without an inline sequence"
|
|
418
|
+
)
|
|
419
|
+
if resource.resource_id in resource_chain:
|
|
420
|
+
chain = " -> ".join((*resource_chain, resource.resource_id))
|
|
421
|
+
raise StoryIRCycleError(
|
|
422
|
+
f"compound resource cycle at {node.path}: {chain}"
|
|
423
|
+
)
|
|
424
|
+
_validate_selection(node, resource)
|
|
425
|
+
_validate_references(
|
|
426
|
+
resource.story,
|
|
427
|
+
resource_stories,
|
|
428
|
+
max_depth=max_depth,
|
|
429
|
+
depth=depth + 1,
|
|
430
|
+
resource_chain=(*resource_chain, resource.resource_id),
|
|
431
|
+
)
|
|
432
|
+
_validate_references(
|
|
433
|
+
node.children,
|
|
434
|
+
resource_stories,
|
|
435
|
+
max_depth=max_depth,
|
|
436
|
+
depth=depth + 1,
|
|
437
|
+
resource_chain=resource_chain,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _validate_selection(node: StoryNode, resource: ResourceStory) -> None:
|
|
442
|
+
selection_end = node.start + node.duration
|
|
443
|
+
resource_end = resource.start + resource.duration
|
|
444
|
+
if node.start < resource.start or selection_end > resource_end:
|
|
445
|
+
raise StoryContainerReferenceError(
|
|
446
|
+
f"{node.path} source range [{node.start}, {selection_end}) exceeds "
|
|
447
|
+
f"resource {resource.resource_id!r} range [{resource.start}, {resource_end})"
|
|
448
|
+
)
|
|
449
|
+
audio_start = node.audio_start if node.audio_start is not None else node.start
|
|
450
|
+
audio_duration = node.audio_duration if node.audio_duration is not None else node.duration
|
|
451
|
+
if audio_duration <= 0:
|
|
452
|
+
raise StoryContainerReferenceError(
|
|
453
|
+
f"{node.path} audio duration must be positive"
|
|
454
|
+
)
|
|
455
|
+
if audio_start < resource.start or audio_start + audio_duration > resource_end:
|
|
456
|
+
raise StoryContainerReferenceError(
|
|
457
|
+
f"{node.path} audio source range [{audio_start}, {audio_start + audio_duration}) "
|
|
458
|
+
f"exceeds resource {resource.resource_id!r} range "
|
|
459
|
+
f"[{resource.start}, {resource_end})"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _rewrite_live_drawing_finding(
|
|
464
|
+
finding: StoryIRFinding,
|
|
465
|
+
live_paths: frozenset[str],
|
|
466
|
+
) -> StoryIRFinding:
|
|
467
|
+
if finding.path not in live_paths or finding.code != "unsupported_story_kind":
|
|
468
|
+
return finding
|
|
469
|
+
return StoryIRFinding(
|
|
470
|
+
code="live_drawing_not_implemented",
|
|
471
|
+
path=finding.path,
|
|
472
|
+
detail=(
|
|
473
|
+
"Final Cut live drawing/PKDrawing data is preserved, but stock FFmpeg "
|
|
474
|
+
"cannot decode the serialized drawing payload"
|
|
475
|
+
),
|
|
476
|
+
disposition="not_implemented_yet",
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _storyline_extent(nodes: tuple[StoryNode, ...], start: Fraction) -> Fraction:
|
|
481
|
+
cursor = start
|
|
482
|
+
maximum_end = start
|
|
483
|
+
for node in nodes:
|
|
484
|
+
offset = node.offset if node.offset is not None else cursor
|
|
485
|
+
maximum_end = max(maximum_end, offset + node.duration)
|
|
486
|
+
cursor = offset + node.duration
|
|
487
|
+
return maximum_end - start
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _tag(element: ET.Element) -> str:
|
|
491
|
+
return element.tag.rsplit("}", 1)[-1]
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _bool_attr(
|
|
495
|
+
element: ET.Element,
|
|
496
|
+
name: str,
|
|
497
|
+
*,
|
|
498
|
+
default: bool,
|
|
499
|
+
path: str,
|
|
500
|
+
) -> bool:
|
|
501
|
+
raw = element.get(name)
|
|
502
|
+
if raw is None:
|
|
503
|
+
return default
|
|
504
|
+
if raw == "1":
|
|
505
|
+
return True
|
|
506
|
+
if raw == "0":
|
|
507
|
+
return False
|
|
508
|
+
raise StoryContainerResourceError(
|
|
509
|
+
f"{path} has invalid {name} value {raw!r}; expected '0' or '1'"
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _time_attr(element: ET.Element, name: str, path: str) -> Optional[Fraction]:
|
|
514
|
+
try:
|
|
515
|
+
return parse_time(element.get(name), field_name=f"{path} {name}")
|
|
516
|
+
except ValueError as exc:
|
|
517
|
+
raise StoryContainerResourceError(str(exc)) from exc
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _positive_optional_duration(element: ET.Element, path: str) -> Optional[Fraction]:
|
|
521
|
+
duration = _time_attr(element, "duration", path)
|
|
522
|
+
if duration is not None and duration <= 0:
|
|
523
|
+
raise StoryContainerResourceError(f"{path} duration must be positive")
|
|
524
|
+
return duration
|