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,1042 @@
1
+ """Build the hierarchical, absolute-time story graph used by render IR v2.
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ ``StoryNode`` trees and separately parsed compound resources
7
+ -> exact parent/source-domain time mapping
8
+ -> source-range clipping
9
+ -> ``RenderGroup`` / ``RenderMedia`` / ``RenderGap`` hierarchy
10
+ -> transition-side composition views and explicit resolution findings
11
+
12
+ The main tree owns each source item exactly once. Transition-side groups are
13
+ immutable views of the adjacent main-tree items; they do not flatten or take
14
+ ownership away from the main tree. Every stored timeline coordinate is a
15
+ ``Fraction`` and is already absolute in the project timeline.
16
+
17
+ Important invariants
18
+ --------------------
19
+
20
+ * Container scope is never copied onto a child. ``RenderGroup.scope`` remains
21
+ the one place that owns the container's transform, opacity, filters, and
22
+ audio controls.
23
+ * Compound resource resolution is injected explicitly. The current parser
24
+ preserves compound ``<media><sequence>`` resources as raw XML, so this module
25
+ refuses to pretend an unresolved ``ref-clip`` is ordinary media.
26
+ * Recursive resources are rejected with a readable resource chain. A bounded
27
+ nesting depth also protects the compiler from adversarial documents.
28
+ * Source order is stable. ``document_order`` is assigned before descending
29
+ into a source item and is identical across repeated builds.
30
+
31
+ Main callers:
32
+ - The render-IR v2 integrator, after ``parse_fcpxml`` and compound-resource
33
+ parsing have completed.
34
+
35
+ Why this exists:
36
+ The v1 compiler flattens nested clips while it resolves them. That loses the
37
+ boundary at which Final Cut applies a parent transform, opacity, filter, or
38
+ audio control. This module freezes that boundary before FFmpeg construction.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from dataclasses import dataclass, replace
44
+ from fractions import Fraction
45
+ from typing import Literal, Mapping, Optional, TypeAlias
46
+
47
+ from .model import (
48
+ CropAdjustment,
49
+ FadeEnvelope,
50
+ FilterInstance,
51
+ MaskedFilterInstance,
52
+ Parameter,
53
+ PreservedAdjustment,
54
+ SequenceFormatContext,
55
+ SourceDocument,
56
+ StoryNode,
57
+ TimeMapPoint,
58
+ TransformAdjustment,
59
+ )
60
+
61
+
62
+ UnresolvedPolicy: TypeAlias = Literal["error", "report"]
63
+ ResolutionState: TypeAlias = Literal["not_applicable", "resolved", "deferred", "unresolved"]
64
+ ChildTiming: TypeAlias = Literal["sequential", "anchored", "resource", "choice", "transition_side"]
65
+
66
+
67
+ class StoryIRBuildError(ValueError):
68
+ """Base class for hierarchy construction failures."""
69
+
70
+
71
+ class StoryIRResolutionError(StoryIRBuildError):
72
+ """Raised when an external story resource cannot be resolved safely."""
73
+
74
+
75
+ class StoryIRCycleError(StoryIRBuildError):
76
+ """Raised when compound resources eventually reference themselves."""
77
+
78
+
79
+ class StoryIRDepthError(StoryIRBuildError):
80
+ """Raised when source nesting exceeds the configured safety bound."""
81
+
82
+
83
+ class StoryIRTransitionError(StoryIRBuildError):
84
+ """Raised when a transition does not have both storyline participants."""
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class RenderScope:
89
+ """Controls owned by one item or container, before backend translation.
90
+
91
+ Main callers:
92
+ - ``StoryIRBuilder._scope`` for every parsed story item.
93
+
94
+ Why this exists:
95
+ Keeping scope separate from timing makes it difficult for a later
96
+ compositor to accidentally apply a parent adjustment to only one leaf.
97
+ """
98
+
99
+ transform: Optional[TransformAdjustment] = None
100
+ crop: Optional[CropAdjustment] = None
101
+ conform_type: str = "fit"
102
+ opacity: float = 1.0
103
+ blend_mode: Optional[str] = None
104
+ opacity_fade: Optional[FadeEnvelope] = None
105
+ filters: tuple[FilterInstance | MaskedFilterInstance, ...] = ()
106
+ params: tuple[Parameter, ...] = ()
107
+ preserved_adjustments: tuple[PreservedAdjustment, ...] = ()
108
+ audio_start: Optional[Fraction] = None
109
+ audio_duration: Optional[Fraction] = None
110
+ volume_db: Optional[float] = None
111
+ audio_fade: Optional[FadeEnvelope] = None
112
+ role: Optional[str] = None
113
+ video_role: Optional[str] = None
114
+ audio_role: Optional[str] = None
115
+ src_enable: Optional[str] = None
116
+ time_map: tuple[TimeMapPoint, ...] = ()
117
+ time_map_preserves_pitch: bool = True
118
+ time_map_frame_sampling: Optional[str] = None
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class RenderMedia:
123
+ """One renderable source item on the absolute timeline.
124
+
125
+ ``connected_children`` retain anchored content without claiming that the
126
+ media item's own transform/filter scope applies to those children.
127
+ """
128
+
129
+ id: str
130
+ kind: str
131
+ path: str
132
+ instance_path: str
133
+ name: Optional[str]
134
+ ref: Optional[str]
135
+ absolute_start: Fraction
136
+ duration: Fraction
137
+ source_start: Fraction
138
+ source_duration: Fraction
139
+ unclipped_absolute_start: Fraction
140
+ unclipped_duration: Fraction
141
+ lane: int
142
+ document_order: int
143
+ enabled: bool
144
+ scope: RenderScope
145
+ ancestor_group_ids: tuple[str, ...]
146
+ connected_children: tuple["RenderNode", ...] = ()
147
+
148
+ @property
149
+ def end(self) -> Fraction:
150
+ return self.absolute_start + self.duration
151
+
152
+ @property
153
+ def was_clipped(self) -> bool:
154
+ return self.absolute_start != self.unclipped_absolute_start or self.duration != self.unclipped_duration
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class RenderGap:
159
+ """One transparent/silent interval with separately anchored children."""
160
+
161
+ id: str
162
+ path: str
163
+ instance_path: str
164
+ name: Optional[str]
165
+ absolute_start: Fraction
166
+ duration: Fraction
167
+ source_start: Fraction
168
+ unclipped_absolute_start: Fraction
169
+ unclipped_duration: Fraction
170
+ lane: int
171
+ document_order: int
172
+ enabled: bool
173
+ scope: RenderScope
174
+ ancestor_group_ids: tuple[str, ...]
175
+ connected_children: tuple["RenderNode", ...] = ()
176
+
177
+ @property
178
+ def end(self) -> Fraction:
179
+ return self.absolute_start + self.duration
180
+
181
+ @property
182
+ def was_clipped(self) -> bool:
183
+ return self.absolute_start != self.unclipped_absolute_start or self.duration != self.unclipped_duration
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class RenderGroup:
188
+ """A composition boundary whose children all use absolute project time.
189
+
190
+ ``children`` are the content composed inside this scope.
191
+ ``connected_children`` are temporally anchored to the group but spatially
192
+ composed beside it, so the group's transform does not implicitly affect
193
+ them. ``inactive_children`` preserve unselected audition choices without
194
+ making them renderable.
195
+ """
196
+
197
+ id: str
198
+ kind: str
199
+ path: str
200
+ instance_path: str
201
+ name: Optional[str]
202
+ ref: Optional[str]
203
+ absolute_start: Fraction
204
+ duration: Fraction
205
+ source_start: Fraction
206
+ unclipped_absolute_start: Fraction
207
+ unclipped_duration: Fraction
208
+ lane: int
209
+ document_order: int
210
+ enabled: bool
211
+ scope: RenderScope
212
+ child_timing: ChildTiming
213
+ resolution: ResolutionState
214
+ ancestor_group_ids: tuple[str, ...]
215
+ resource_chain: tuple[str, ...] = ()
216
+ children: tuple["RenderNode", ...] = ()
217
+ connected_children: tuple["RenderNode", ...] = ()
218
+ inactive_children: tuple["RenderNode", ...] = ()
219
+
220
+ @property
221
+ def end(self) -> Fraction:
222
+ return self.absolute_start + self.duration
223
+
224
+ @property
225
+ def was_clipped(self) -> bool:
226
+ return self.absolute_start != self.unclipped_absolute_start or self.duration != self.unclipped_duration
227
+
228
+
229
+ RenderNode: TypeAlias = RenderMedia | RenderGroup | RenderGap
230
+
231
+
232
+ @dataclass(frozen=True)
233
+ class ResourceStory:
234
+ """A parsed reusable ``<media><sequence>`` story supplied to the builder.
235
+
236
+ The current source model stores non-multicam ``media`` resources as raw
237
+ XML. The Wave 3 resource parser should produce this small adapter instead
238
+ of making this module parse XML a second time.
239
+ """
240
+
241
+ resource_id: str
242
+ path: str
243
+ start: Fraction
244
+ duration: Fraction
245
+ story: tuple[StoryNode, ...]
246
+ kind: Literal["compound", "sequence"] = "compound"
247
+ format_context: Optional[SequenceFormatContext] = None
248
+
249
+
250
+ @dataclass(frozen=True)
251
+ class StoryIRFinding:
252
+ """One explicit non-rendering or invalid-resolution decision."""
253
+
254
+ code: str
255
+ path: str
256
+ detail: str
257
+ disposition: Literal["not_implemented_yet", "invalid", "inactive"]
258
+
259
+
260
+ @dataclass(frozen=True)
261
+ class RenderStoryPlan:
262
+ """Frozen hierarchical story graph plus transition views and findings."""
263
+
264
+ root: RenderGroup
265
+ transitions: tuple[RenderGroup, ...]
266
+ findings: tuple[StoryIRFinding, ...]
267
+
268
+
269
+ @dataclass(frozen=True)
270
+ class _BuiltEntry:
271
+ source_index: int
272
+ node: RenderNode
273
+
274
+
275
+ @dataclass(frozen=True)
276
+ class _TransitionEntry:
277
+ source_index: int
278
+ source: StoryNode
279
+ id: str
280
+ instance_path: str
281
+ document_order: int
282
+ absolute_start: Fraction
283
+ duration: Fraction
284
+ source_start: Fraction
285
+ lane: int
286
+ ancestor_group_ids: tuple[str, ...]
287
+
288
+
289
+ class StoryIRBuilder:
290
+ """Resolve a ``SourceDocument`` into a bounded hierarchical story plan.
291
+
292
+ Main callers:
293
+ - ``build_render_story``.
294
+
295
+ Why this exists:
296
+ The mutable counter, transition collection, and resource recursion stack
297
+ are build-lifetime state. Housing them here keeps the public IR immutable.
298
+ """
299
+
300
+ _MEDIA_KINDS = {"asset-clip", "video", "audio", "title", "caption"}
301
+
302
+ def __init__(
303
+ self,
304
+ source: SourceDocument,
305
+ *,
306
+ resource_stories: Optional[Mapping[str, ResourceStory]],
307
+ max_depth: int,
308
+ unresolved_policy: UnresolvedPolicy,
309
+ ) -> None:
310
+ if max_depth < 1:
311
+ raise ValueError("max_depth must be at least 1")
312
+ if unresolved_policy not in {"error", "report"}:
313
+ raise ValueError("unresolved_policy must be 'error' or 'report'")
314
+ self.source = source
315
+ self.resource_stories = dict(resource_stories or {})
316
+ for key, story in self.resource_stories.items():
317
+ if key != story.resource_id:
318
+ raise ValueError(f"resource story key {key!r} does not match resource_id {story.resource_id!r}")
319
+ if story.duration <= 0:
320
+ raise ValueError(f"resource story {key!r} duration must be positive")
321
+ self.max_depth = max_depth
322
+ self.unresolved_policy = unresolved_policy
323
+ self._document_order = 0
324
+ self._transitions: list[RenderGroup] = []
325
+ self._findings: list[StoryIRFinding] = []
326
+
327
+ def build(self) -> RenderStoryPlan:
328
+ """Build one sequence root, preserving exact absolute child times."""
329
+
330
+ window = (Fraction(0), self.source.sequence_duration)
331
+ root_id = "render-root"
332
+ children = self._build_storyline(
333
+ self.source.spine,
334
+ parent_absolute=Fraction(0),
335
+ parent_source=self.source.sequence_tc_start,
336
+ inherited_lane=0,
337
+ ancestor_group_ids=(root_id,),
338
+ instance_prefix="sequence",
339
+ sequential=True,
340
+ window=window,
341
+ depth=1,
342
+ resource_chain=(),
343
+ )
344
+ root = RenderGroup(
345
+ id=root_id,
346
+ kind="sequence",
347
+ path="sequence",
348
+ instance_path="sequence",
349
+ name=self.source.project_name,
350
+ ref=self.source.sequence_format_id,
351
+ absolute_start=Fraction(0),
352
+ duration=self.source.sequence_duration,
353
+ source_start=self.source.sequence_tc_start,
354
+ unclipped_absolute_start=Fraction(0),
355
+ unclipped_duration=self.source.sequence_duration,
356
+ lane=0,
357
+ document_order=-1,
358
+ enabled=True,
359
+ scope=RenderScope(),
360
+ child_timing="sequential",
361
+ resolution="resolved",
362
+ ancestor_group_ids=(),
363
+ children=children,
364
+ )
365
+ return RenderStoryPlan(
366
+ root=root,
367
+ transitions=tuple(sorted(self._transitions, key=lambda item: item.document_order)),
368
+ findings=tuple(self._findings),
369
+ )
370
+
371
+ def _next_identity(self, instance_path: str) -> tuple[str, int]:
372
+ order = self._document_order
373
+ self._document_order += 1
374
+ return f"render-node-{order}", order
375
+
376
+ def _build_storyline(
377
+ self,
378
+ nodes: tuple[StoryNode, ...],
379
+ *,
380
+ parent_absolute: Fraction,
381
+ parent_source: Fraction,
382
+ inherited_lane: int,
383
+ ancestor_group_ids: tuple[str, ...],
384
+ instance_prefix: str,
385
+ sequential: bool,
386
+ window: tuple[Fraction, Fraction],
387
+ depth: int,
388
+ resource_chain: tuple[str, ...],
389
+ ) -> tuple[RenderNode, ...]:
390
+ """Build siblings, then associate transition markers in source order.
391
+
392
+ Main callers:
393
+ - ``build`` for the project spine.
394
+ - ``_build_node`` for nested group and connected stories.
395
+ """
396
+
397
+ self._check_depth(depth, instance_prefix)
398
+ cursor = parent_source
399
+ built: list[_BuiltEntry] = []
400
+ transitions: list[_TransitionEntry] = []
401
+ for source_index, source_node in enumerate(nodes):
402
+ offset = source_node.offset
403
+ if offset is None:
404
+ offset = cursor if sequential else parent_source
405
+ raw_absolute = parent_absolute + offset - parent_source
406
+ lane = inherited_lane + source_node.lane
407
+ instance_path = f"{instance_prefix}/{source_node.path}"
408
+ node_id, order = self._next_identity(instance_path)
409
+ if source_node.kind == "transition":
410
+ clipped = self._clip_interval(raw_absolute, source_node.duration, window)
411
+ if clipped is not None:
412
+ visible_start, visible_duration = clipped
413
+ transitions.append(
414
+ _TransitionEntry(
415
+ source_index=source_index,
416
+ source=source_node,
417
+ id=node_id,
418
+ instance_path=instance_path,
419
+ document_order=order,
420
+ absolute_start=visible_start,
421
+ duration=visible_duration,
422
+ source_start=source_node.start + (visible_start - raw_absolute),
423
+ lane=lane,
424
+ ancestor_group_ids=ancestor_group_ids,
425
+ )
426
+ )
427
+ else:
428
+ render_node = self._build_node(
429
+ source_node,
430
+ node_id=node_id,
431
+ document_order=order,
432
+ raw_absolute=raw_absolute,
433
+ lane=lane,
434
+ ancestor_group_ids=ancestor_group_ids,
435
+ instance_path=instance_path,
436
+ window=window,
437
+ depth=depth,
438
+ resource_chain=resource_chain,
439
+ )
440
+ if render_node is not None:
441
+ built.append(_BuiltEntry(source_index=source_index, node=render_node))
442
+ if sequential:
443
+ cursor = offset + source_node.duration
444
+
445
+ for transition in transitions:
446
+ previous = next((entry.node for entry in reversed(built) if entry.source_index < transition.source_index), None)
447
+ following = next((entry.node for entry in built if entry.source_index > transition.source_index), None)
448
+ self._build_transition_group(transition, previous=previous, following=following)
449
+ return tuple(entry.node for entry in built)
450
+
451
+ def _build_node(
452
+ self,
453
+ node: StoryNode,
454
+ *,
455
+ node_id: str,
456
+ document_order: int,
457
+ raw_absolute: Fraction,
458
+ lane: int,
459
+ ancestor_group_ids: tuple[str, ...],
460
+ instance_path: str,
461
+ window: tuple[Fraction, Fraction],
462
+ depth: int,
463
+ resource_chain: tuple[str, ...],
464
+ ) -> Optional[RenderNode]:
465
+ self._check_depth(depth, instance_path)
466
+ clipped = self._clip_interval(raw_absolute, node.duration, window)
467
+ if clipped is None:
468
+ return None
469
+ absolute_start, duration = clipped
470
+ source_start = node.start + (absolute_start - raw_absolute)
471
+ visible_window = (absolute_start, absolute_start + duration)
472
+ scope = self._scope(node)
473
+
474
+ if node.kind in self._MEDIA_KINDS:
475
+ connected = self._build_storyline(
476
+ node.children,
477
+ parent_absolute=raw_absolute,
478
+ parent_source=node.start,
479
+ inherited_lane=lane,
480
+ ancestor_group_ids=ancestor_group_ids,
481
+ instance_prefix=instance_path,
482
+ sequential=False,
483
+ window=window,
484
+ depth=depth + 1,
485
+ resource_chain=resource_chain,
486
+ ) if node.children else ()
487
+ return RenderMedia(
488
+ id=node_id,
489
+ kind=node.kind,
490
+ path=node.path,
491
+ instance_path=instance_path,
492
+ name=node.name,
493
+ ref=node.ref,
494
+ absolute_start=absolute_start,
495
+ duration=duration,
496
+ source_start=source_start,
497
+ source_duration=duration,
498
+ unclipped_absolute_start=raw_absolute,
499
+ unclipped_duration=node.duration,
500
+ lane=lane,
501
+ document_order=document_order,
502
+ enabled=node.enabled,
503
+ scope=scope,
504
+ ancestor_group_ids=ancestor_group_ids,
505
+ connected_children=connected,
506
+ )
507
+
508
+ if node.kind == "gap":
509
+ connected = self._build_storyline(
510
+ node.children,
511
+ parent_absolute=raw_absolute,
512
+ parent_source=node.start,
513
+ inherited_lane=lane,
514
+ ancestor_group_ids=ancestor_group_ids,
515
+ instance_prefix=instance_path,
516
+ sequential=False,
517
+ window=window,
518
+ depth=depth + 1,
519
+ resource_chain=resource_chain,
520
+ ) if node.children else ()
521
+ return RenderGap(
522
+ id=node_id,
523
+ path=node.path,
524
+ instance_path=instance_path,
525
+ name=node.name,
526
+ absolute_start=absolute_start,
527
+ duration=duration,
528
+ source_start=source_start,
529
+ unclipped_absolute_start=raw_absolute,
530
+ unclipped_duration=node.duration,
531
+ lane=lane,
532
+ document_order=document_order,
533
+ enabled=node.enabled,
534
+ scope=scope,
535
+ ancestor_group_ids=ancestor_group_ids,
536
+ connected_children=connected,
537
+ )
538
+
539
+ if node.kind == "audition":
540
+ return self._build_audition(
541
+ node,
542
+ node_id=node_id,
543
+ document_order=document_order,
544
+ raw_absolute=raw_absolute,
545
+ absolute_start=absolute_start,
546
+ duration=duration,
547
+ source_start=source_start,
548
+ lane=lane,
549
+ scope=scope,
550
+ ancestor_group_ids=ancestor_group_ids,
551
+ instance_path=instance_path,
552
+ visible_window=visible_window,
553
+ depth=depth,
554
+ resource_chain=resource_chain,
555
+ )
556
+
557
+ if node.kind == "ref-clip":
558
+ return self._build_ref_group(
559
+ node,
560
+ node_id=node_id,
561
+ document_order=document_order,
562
+ raw_absolute=raw_absolute,
563
+ absolute_start=absolute_start,
564
+ duration=duration,
565
+ source_start=source_start,
566
+ lane=lane,
567
+ scope=scope,
568
+ ancestor_group_ids=ancestor_group_ids,
569
+ instance_path=instance_path,
570
+ visible_window=visible_window,
571
+ connected_window=window,
572
+ depth=depth,
573
+ resource_chain=resource_chain,
574
+ )
575
+
576
+ if node.kind in {"spine", "clip", "sync-clip"}:
577
+ group_ancestors = (*ancestor_group_ids, node_id)
578
+ if node.kind == "spine":
579
+ inner_nodes = node.children
580
+ connected_nodes: tuple[StoryNode, ...] = ()
581
+ sequential = True
582
+ child_timing: ChildTiming = "sequential"
583
+ else:
584
+ inner_nodes = tuple(child for child in node.children if child.lane == 0)
585
+ connected_nodes = tuple(child for child in node.children if child.lane != 0)
586
+ sequential = False
587
+ child_timing = "anchored"
588
+ children = self._build_storyline(
589
+ inner_nodes,
590
+ parent_absolute=raw_absolute,
591
+ parent_source=node.start,
592
+ inherited_lane=lane,
593
+ ancestor_group_ids=group_ancestors,
594
+ instance_prefix=instance_path,
595
+ sequential=sequential,
596
+ window=visible_window,
597
+ depth=depth + 1,
598
+ resource_chain=resource_chain,
599
+ ) if inner_nodes else ()
600
+ connected = self._build_storyline(
601
+ connected_nodes,
602
+ parent_absolute=raw_absolute,
603
+ parent_source=node.start,
604
+ inherited_lane=lane,
605
+ ancestor_group_ids=ancestor_group_ids,
606
+ instance_prefix=f"{instance_path}/connected",
607
+ sequential=False,
608
+ window=window,
609
+ depth=depth + 1,
610
+ resource_chain=resource_chain,
611
+ ) if connected_nodes else ()
612
+ return RenderGroup(
613
+ id=node_id,
614
+ kind=node.kind,
615
+ path=node.path,
616
+ instance_path=instance_path,
617
+ name=node.name,
618
+ ref=node.ref,
619
+ absolute_start=absolute_start,
620
+ duration=duration,
621
+ source_start=source_start,
622
+ unclipped_absolute_start=raw_absolute,
623
+ unclipped_duration=node.duration,
624
+ lane=lane,
625
+ document_order=document_order,
626
+ enabled=node.enabled,
627
+ scope=scope,
628
+ child_timing=child_timing,
629
+ resolution="resolved",
630
+ ancestor_group_ids=ancestor_group_ids,
631
+ resource_chain=resource_chain,
632
+ children=children,
633
+ connected_children=connected,
634
+ )
635
+
636
+ # Multicam needs a separate selected-angle adapter. Unknown story
637
+ # elements likewise stay visible as an explicit unresolved group.
638
+ code = "multicam_selection_deferred" if node.kind == "mc-clip" else "unsupported_story_kind"
639
+ detail = (
640
+ f"{node.path} requires a selected multicam angle adapter"
641
+ if node.kind == "mc-clip"
642
+ else f"{node.path} has unsupported story kind {node.kind!r}"
643
+ )
644
+ self._unresolved(code=code, path=node.path, detail=detail)
645
+ connected = self._build_storyline(
646
+ node.children,
647
+ parent_absolute=raw_absolute,
648
+ parent_source=node.start,
649
+ inherited_lane=lane,
650
+ ancestor_group_ids=ancestor_group_ids,
651
+ instance_prefix=f"{instance_path}/connected",
652
+ sequential=False,
653
+ window=window,
654
+ depth=depth + 1,
655
+ resource_chain=resource_chain,
656
+ ) if node.children else ()
657
+ return RenderGroup(
658
+ id=node_id,
659
+ kind=node.kind,
660
+ path=node.path,
661
+ instance_path=instance_path,
662
+ name=node.name,
663
+ ref=node.ref,
664
+ absolute_start=absolute_start,
665
+ duration=duration,
666
+ source_start=source_start,
667
+ unclipped_absolute_start=raw_absolute,
668
+ unclipped_duration=node.duration,
669
+ lane=lane,
670
+ document_order=document_order,
671
+ enabled=node.enabled,
672
+ scope=scope,
673
+ child_timing="anchored",
674
+ resolution="deferred" if node.kind == "mc-clip" else "unresolved",
675
+ ancestor_group_ids=ancestor_group_ids,
676
+ resource_chain=resource_chain,
677
+ connected_children=connected,
678
+ )
679
+
680
+ def _build_ref_group(
681
+ self,
682
+ node: StoryNode,
683
+ *,
684
+ node_id: str,
685
+ document_order: int,
686
+ raw_absolute: Fraction,
687
+ absolute_start: Fraction,
688
+ duration: Fraction,
689
+ source_start: Fraction,
690
+ lane: int,
691
+ scope: RenderScope,
692
+ ancestor_group_ids: tuple[str, ...],
693
+ instance_path: str,
694
+ visible_window: tuple[Fraction, Fraction],
695
+ connected_window: tuple[Fraction, Fraction],
696
+ depth: int,
697
+ resource_chain: tuple[str, ...],
698
+ ) -> RenderGroup:
699
+ """Resolve compound content, keeping ref-level controls on the group.
700
+
701
+ Main callers:
702
+ - ``_build_node`` for ``ref-clip``.
703
+ """
704
+
705
+ resource = self.resource_stories.get(node.ref or "")
706
+ resolution: ResolutionState = "resolved"
707
+ children: tuple[RenderNode, ...] = ()
708
+ next_chain = resource_chain
709
+ if resource is None:
710
+ resolution = "unresolved"
711
+ self._unresolved(
712
+ code="compound_resource_unresolved",
713
+ path=node.path,
714
+ detail=f"ref-clip references unparsed media resource {node.ref!r}",
715
+ )
716
+ else:
717
+ if resource.resource_id in resource_chain:
718
+ chain = " -> ".join((*resource_chain, resource.resource_id))
719
+ raise StoryIRCycleError(f"compound resource cycle at {node.path}: {chain}")
720
+ next_chain = (*resource_chain, resource.resource_id)
721
+ selection_end = node.start + node.duration
722
+ resource_end = resource.start + resource.duration
723
+ if node.start < resource.start or selection_end > resource_end:
724
+ self._unresolved(
725
+ code="compound_source_range_out_of_bounds",
726
+ path=node.path,
727
+ detail=(
728
+ f"selected source range [{node.start}, {selection_end}) exceeds "
729
+ f"resource {resource.resource_id!r} range [{resource.start}, {resource_end})"
730
+ ),
731
+ disposition="invalid",
732
+ )
733
+ children = self._build_storyline(
734
+ resource.story,
735
+ parent_absolute=raw_absolute,
736
+ parent_source=node.start,
737
+ inherited_lane=lane,
738
+ ancestor_group_ids=(*ancestor_group_ids, node_id),
739
+ instance_prefix=f"{instance_path}/resource[{resource.resource_id}]",
740
+ sequential=True,
741
+ window=visible_window,
742
+ depth=depth + 1,
743
+ resource_chain=next_chain,
744
+ )
745
+
746
+ connected = self._build_storyline(
747
+ node.children,
748
+ parent_absolute=raw_absolute,
749
+ parent_source=node.start,
750
+ inherited_lane=lane,
751
+ ancestor_group_ids=ancestor_group_ids,
752
+ instance_prefix=f"{instance_path}/connected",
753
+ sequential=False,
754
+ window=connected_window,
755
+ depth=depth + 1,
756
+ resource_chain=resource_chain,
757
+ ) if node.children else ()
758
+ return RenderGroup(
759
+ id=node_id,
760
+ kind="ref-clip",
761
+ path=node.path,
762
+ instance_path=instance_path,
763
+ name=node.name,
764
+ ref=node.ref,
765
+ absolute_start=absolute_start,
766
+ duration=duration,
767
+ source_start=source_start,
768
+ unclipped_absolute_start=raw_absolute,
769
+ unclipped_duration=node.duration,
770
+ lane=lane,
771
+ document_order=document_order,
772
+ enabled=node.enabled,
773
+ scope=scope,
774
+ child_timing="resource",
775
+ resolution=resolution,
776
+ ancestor_group_ids=ancestor_group_ids,
777
+ resource_chain=next_chain,
778
+ children=children,
779
+ connected_children=connected,
780
+ )
781
+
782
+ def _build_audition(
783
+ self,
784
+ node: StoryNode,
785
+ *,
786
+ node_id: str,
787
+ document_order: int,
788
+ raw_absolute: Fraction,
789
+ absolute_start: Fraction,
790
+ duration: Fraction,
791
+ source_start: Fraction,
792
+ lane: int,
793
+ scope: RenderScope,
794
+ ancestor_group_ids: tuple[str, ...],
795
+ instance_path: str,
796
+ visible_window: tuple[Fraction, Fraction],
797
+ depth: int,
798
+ resource_chain: tuple[str, ...],
799
+ ) -> RenderGroup:
800
+ """Build only the first audition choice as active and retain the rest."""
801
+
802
+ active_nodes = node.children[:1]
803
+ inactive_nodes = node.children[1:]
804
+ if not active_nodes:
805
+ self._unresolved(code="audition_without_choice", path=node.path, detail="audition has no active choice", disposition="invalid")
806
+ group_ancestors = (*ancestor_group_ids, node_id)
807
+ active = self._build_storyline(
808
+ active_nodes,
809
+ parent_absolute=raw_absolute,
810
+ parent_source=node.start,
811
+ inherited_lane=lane,
812
+ ancestor_group_ids=group_ancestors,
813
+ instance_prefix=f"{instance_path}/active",
814
+ sequential=False,
815
+ window=visible_window,
816
+ depth=depth + 1,
817
+ resource_chain=resource_chain,
818
+ ) if active_nodes else ()
819
+ inactive = self._build_storyline(
820
+ inactive_nodes,
821
+ parent_absolute=raw_absolute,
822
+ parent_source=node.start,
823
+ inherited_lane=lane,
824
+ ancestor_group_ids=group_ancestors,
825
+ instance_prefix=f"{instance_path}/inactive",
826
+ sequential=False,
827
+ window=visible_window,
828
+ depth=depth + 1,
829
+ resource_chain=resource_chain,
830
+ ) if inactive_nodes else ()
831
+ for choice in inactive_nodes:
832
+ self._findings.append(
833
+ StoryIRFinding(
834
+ code="audition_choice_inactive",
835
+ path=choice.path,
836
+ detail=f"{choice.path} is preserved as an inactive audition alternative",
837
+ disposition="inactive",
838
+ )
839
+ )
840
+ return RenderGroup(
841
+ id=node_id,
842
+ kind="audition",
843
+ path=node.path,
844
+ instance_path=instance_path,
845
+ name=node.name,
846
+ ref=node.ref,
847
+ absolute_start=absolute_start,
848
+ duration=duration,
849
+ source_start=source_start,
850
+ unclipped_absolute_start=raw_absolute,
851
+ unclipped_duration=node.duration,
852
+ lane=lane,
853
+ document_order=document_order,
854
+ enabled=node.enabled,
855
+ scope=scope,
856
+ child_timing="choice",
857
+ resolution="resolved",
858
+ ancestor_group_ids=ancestor_group_ids,
859
+ resource_chain=resource_chain,
860
+ children=active,
861
+ inactive_children=inactive,
862
+ )
863
+
864
+ def _build_transition_group(
865
+ self,
866
+ transition: _TransitionEntry,
867
+ *,
868
+ previous: Optional[RenderNode],
869
+ following: Optional[RenderNode],
870
+ ) -> None:
871
+ if previous is None or following is None:
872
+ detail = f"transition {transition.source.path} requires adjacent outgoing and incoming items"
873
+ if self.unresolved_policy == "error":
874
+ raise StoryIRTransitionError(detail)
875
+ self._findings.append(
876
+ StoryIRFinding(
877
+ code="transition_participant_missing",
878
+ path=transition.source.path,
879
+ detail=detail,
880
+ disposition="invalid",
881
+ )
882
+ )
883
+ side_ancestors = (*transition.ancestor_group_ids, transition.id)
884
+ side_duration = transition.duration
885
+ outgoing = RenderGroup(
886
+ id=f"{transition.id}-outgoing",
887
+ kind="transition-outgoing",
888
+ path=transition.source.path,
889
+ instance_path=f"{transition.instance_path}/outgoing",
890
+ name="outgoing",
891
+ ref=None,
892
+ absolute_start=transition.absolute_start,
893
+ duration=side_duration,
894
+ source_start=transition.source_start,
895
+ unclipped_absolute_start=transition.absolute_start,
896
+ unclipped_duration=side_duration,
897
+ lane=transition.lane,
898
+ document_order=transition.document_order,
899
+ enabled=True,
900
+ scope=RenderScope(),
901
+ child_timing="transition_side",
902
+ resolution="resolved" if previous is not None else "unresolved",
903
+ ancestor_group_ids=side_ancestors,
904
+ children=(previous,) if previous is not None else (),
905
+ )
906
+ incoming = replace(
907
+ outgoing,
908
+ id=f"{transition.id}-incoming",
909
+ kind="transition-incoming",
910
+ instance_path=f"{transition.instance_path}/incoming",
911
+ name="incoming",
912
+ resolution="resolved" if following is not None else "unresolved",
913
+ children=(following,) if following is not None else (),
914
+ )
915
+ self._transitions.append(
916
+ RenderGroup(
917
+ id=transition.id,
918
+ kind="transition",
919
+ path=transition.source.path,
920
+ instance_path=transition.instance_path,
921
+ name=transition.source.name,
922
+ ref=transition.source.ref,
923
+ absolute_start=transition.absolute_start,
924
+ duration=transition.duration,
925
+ source_start=transition.source_start,
926
+ unclipped_absolute_start=transition.absolute_start,
927
+ unclipped_duration=transition.duration,
928
+ lane=transition.lane,
929
+ document_order=transition.document_order,
930
+ enabled=transition.source.enabled,
931
+ scope=self._scope(transition.source),
932
+ child_timing="transition_side",
933
+ resolution="resolved" if previous is not None and following is not None else "unresolved",
934
+ ancestor_group_ids=transition.ancestor_group_ids,
935
+ children=(outgoing, incoming),
936
+ )
937
+ )
938
+
939
+ @staticmethod
940
+ def _scope(node: StoryNode) -> RenderScope:
941
+ return RenderScope(
942
+ transform=node.transform,
943
+ crop=node.crop,
944
+ conform_type=node.conform_type,
945
+ opacity=node.blend_opacity,
946
+ blend_mode=node.blend_mode,
947
+ opacity_fade=node.opacity_fade,
948
+ filters=node.filters,
949
+ params=node.params,
950
+ preserved_adjustments=node.preserved_adjustments,
951
+ audio_start=node.audio_start,
952
+ audio_duration=node.audio_duration,
953
+ volume_db=node.volume_db,
954
+ audio_fade=node.audio_fade,
955
+ role=node.role,
956
+ video_role=node.video_role,
957
+ audio_role=node.audio_role,
958
+ src_enable=node.src_enable,
959
+ time_map=node.time_map,
960
+ time_map_preserves_pitch=node.time_map_preserves_pitch,
961
+ time_map_frame_sampling=node.time_map_frame_sampling,
962
+ )
963
+
964
+ @staticmethod
965
+ def _clip_interval(
966
+ absolute_start: Fraction,
967
+ duration: Fraction,
968
+ window: tuple[Fraction, Fraction],
969
+ ) -> Optional[tuple[Fraction, Fraction]]:
970
+ visible_start = max(absolute_start, window[0])
971
+ visible_end = min(absolute_start + duration, window[1])
972
+ if visible_end <= visible_start:
973
+ return None
974
+ return visible_start, visible_end - visible_start
975
+
976
+ def _check_depth(self, depth: int, path: str) -> None:
977
+ if depth > self.max_depth:
978
+ raise StoryIRDepthError(f"story nesting at {path} exceeds max_depth={self.max_depth}")
979
+
980
+ def _unresolved(
981
+ self,
982
+ *,
983
+ code: str,
984
+ path: str,
985
+ detail: str,
986
+ disposition: Literal["not_implemented_yet", "invalid"] = "not_implemented_yet",
987
+ ) -> None:
988
+ if self.unresolved_policy == "error":
989
+ raise StoryIRResolutionError(detail)
990
+ self._findings.append(
991
+ StoryIRFinding(code=code, path=path, detail=detail, disposition=disposition)
992
+ )
993
+
994
+
995
+ def build_render_story(
996
+ source: SourceDocument,
997
+ *,
998
+ resource_stories: Optional[Mapping[str, ResourceStory]] = None,
999
+ max_depth: int = 32,
1000
+ unresolved_policy: UnresolvedPolicy = "error",
1001
+ ) -> RenderStoryPlan:
1002
+ """Build render IR v2 hierarchy without mutating the parsed document.
1003
+
1004
+ ``resource_stories`` is deliberately mandatory for every referenced
1005
+ compound media object, although the mapping itself may be omitted when the
1006
+ document contains no ``ref-clip``. Set ``unresolved_policy='report'`` only
1007
+ when the caller will surface every returned finding in the compatibility
1008
+ report.
1009
+
1010
+ Main callers:
1011
+ - The future root compiler integration immediately after source parsing.
1012
+ """
1013
+
1014
+ return StoryIRBuilder(
1015
+ source,
1016
+ resource_stories=resource_stories,
1017
+ max_depth=max_depth,
1018
+ unresolved_policy=unresolved_policy,
1019
+ ).build()
1020
+
1021
+
1022
+ def walk_render_nodes(root: RenderNode, *, include_inactive: bool = False) -> tuple[RenderNode, ...]:
1023
+ """Return a deterministic pre-order view for audits and test diagnostics."""
1024
+
1025
+ result: list[RenderNode] = []
1026
+
1027
+ def visit(node: RenderNode) -> None:
1028
+ result.append(node)
1029
+ if isinstance(node, RenderGroup):
1030
+ for child in node.children:
1031
+ visit(child)
1032
+ for child in node.connected_children:
1033
+ visit(child)
1034
+ if include_inactive:
1035
+ for child in node.inactive_children:
1036
+ visit(child)
1037
+ else:
1038
+ for child in node.connected_children:
1039
+ visit(child)
1040
+
1041
+ visit(root)
1042
+ return tuple(result)