bladeworks 0.1.0__py3-none-any.whl

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