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,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)
|