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,893 @@
|
|
|
1
|
+
"""Typed, deterministic animation tracks for the experimental renderer IR.
|
|
2
|
+
|
|
3
|
+
Architecture map
|
|
4
|
+
================
|
|
5
|
+
|
|
6
|
+
``model.Keyframe`` / ``model.Parameter``
|
|
7
|
+
Parser-owned strings and exact keyframe times enter through explicit
|
|
8
|
+
``from_keyframes`` and ``from_parameter`` adapters.
|
|
9
|
+
|
|
10
|
+
``ScalarControlPoint`` / ``Vec2ControlPoint``
|
|
11
|
+
Validate the value shape, interpolation names, exact ``Fraction`` time,
|
|
12
|
+
and preserved ``auxValue`` tangent metadata.
|
|
13
|
+
|
|
14
|
+
``AnimatedScalar`` / ``AnimatedVec2``
|
|
15
|
+
Evaluate source-time animation. Values hold outside the keyframe range;
|
|
16
|
+
the interval ending at a keyframe uses that destination keyframe's
|
|
17
|
+
``interp`` and ``curve`` settings.
|
|
18
|
+
|
|
19
|
+
``TimelineAnimatedScalar`` / ``TimelineAnimatedVec2``
|
|
20
|
+
Evaluate a source-time track on the output timeline by first applying a
|
|
21
|
+
``RetimeMap``. This keeps reverse, freeze, variable-rate, and repeated
|
|
22
|
+
source occurrences exact without rewriting their easing curves.
|
|
23
|
+
|
|
24
|
+
``ken_burns_progress`` / ``ken_burns_progress_expression``
|
|
25
|
+
Share the measured Final Cut Pan-camera progress curve between numeric
|
|
26
|
+
diagnostics and FFmpeg lowering. All four camera edges must consume this
|
|
27
|
+
one value; independent edge clocks would warp the camera rectangle.
|
|
28
|
+
|
|
29
|
+
Important invariants
|
|
30
|
+
--------------------
|
|
31
|
+
|
|
32
|
+
* Control-point times are exact ``fractions.Fraction`` values. Floats are
|
|
33
|
+
rejected at the timing boundary.
|
|
34
|
+
* Control points are strictly ordered and duplicate times are invalid.
|
|
35
|
+
* ``smooth`` value curves use monotone cubic interpolation independently per
|
|
36
|
+
component, so a segment cannot overshoot either endpoint.
|
|
37
|
+
* ``auxValue`` is preserved and reported as uncalibrated. It never silently
|
|
38
|
+
changes the evaluated curve.
|
|
39
|
+
* Lossless retimed evaluation is the primary API. Materializing ordinary
|
|
40
|
+
timeline points is intentionally limited to one forward linear retime
|
|
41
|
+
segment; reverse, freeze, and multi-segment maps fail explicitly.
|
|
42
|
+
|
|
43
|
+
Why this exists
|
|
44
|
+
---------------
|
|
45
|
+
|
|
46
|
+
Final Cut stores genuine animation inside ``keyframeAnimation`` and may place
|
|
47
|
+
those source-local keyframes under retimed clips. The old renderer reduced
|
|
48
|
+
animation to ad-hoc FFmpeg expressions and could silently treat a reverse or
|
|
49
|
+
repeated source range as forward playback. This module freezes a typed,
|
|
50
|
+
testable animation contract before the shared compiler and FFmpeg builder are
|
|
51
|
+
changed.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
from dataclasses import dataclass
|
|
57
|
+
from fractions import Fraction
|
|
58
|
+
import math
|
|
59
|
+
from typing import Iterable, Literal, Sequence, TypeAlias
|
|
60
|
+
|
|
61
|
+
from .model import Keyframe, Parameter
|
|
62
|
+
from .retime import RetimeMap, RetimeSegment
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Interpolation: TypeAlias = Literal["linear", "ease", "ease-in", "ease-out"]
|
|
66
|
+
ValueCurve: TypeAlias = Literal["linear", "smooth"]
|
|
67
|
+
Vec2: TypeAlias = tuple[float, float]
|
|
68
|
+
AnimationValue: TypeAlias = float | Vec2
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Final Cut Pan progress measured from the BT.709 1080p Stage 5 landmark oracle.
|
|
72
|
+
# Each point is a normalized output time and the corresponding normalized
|
|
73
|
+
# camera-window progress recovered from robust source-to-output affine scale.
|
|
74
|
+
# The same monotone curve drives all four rect edges.
|
|
75
|
+
# Piecewise interpolation avoids fitting an attractive but measurably wrong
|
|
76
|
+
# stock easing curve; private reference movies remain in the evidence tree.
|
|
77
|
+
KEN_BURNS_PROGRESS_CALIBRATION_ID = "xyzt-20260812-fcp-pan-trajectory-1080p-v3"
|
|
78
|
+
KEN_BURNS_PROGRESS_MAX_RESIDUAL = 0.0015
|
|
79
|
+
KEN_BURNS_PROGRESS_POINTS: tuple[tuple[float, float], ...] = (
|
|
80
|
+
(0.0, 0.0),
|
|
81
|
+
(0.050847, 0.007683),
|
|
82
|
+
(0.101695, 0.027583),
|
|
83
|
+
(0.152542, 0.061088),
|
|
84
|
+
(0.203390, 0.107125),
|
|
85
|
+
(0.254237, 0.167228),
|
|
86
|
+
(0.305085, 0.233681),
|
|
87
|
+
(0.355932, 0.300808),
|
|
88
|
+
(0.406780, 0.367519),
|
|
89
|
+
(0.457627, 0.435341),
|
|
90
|
+
(0.508475, 0.501657),
|
|
91
|
+
(0.559322, 0.567544),
|
|
92
|
+
(0.610169, 0.634175),
|
|
93
|
+
(0.661017, 0.700629),
|
|
94
|
+
(0.711864, 0.767585),
|
|
95
|
+
(0.762712, 0.834247),
|
|
96
|
+
(0.813559, 0.893769),
|
|
97
|
+
(0.864407, 0.940151),
|
|
98
|
+
(0.915254, 0.974251),
|
|
99
|
+
(0.966102, 0.993884),
|
|
100
|
+
(1.0, 1.0),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class AnimationError(ValueError):
|
|
105
|
+
"""Base error for invalid or unsupported animation contracts."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AnimationValidationError(AnimationError):
|
|
109
|
+
"""A source track is malformed and cannot be evaluated deterministically."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class UnsupportedAnimationMappingError(AnimationError):
|
|
113
|
+
"""A requested lossy timeline-point conversion would be ambiguous."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _exact_time(value: object, *, field_name: str) -> Fraction:
|
|
117
|
+
"""Accept only exact rational animation times.
|
|
118
|
+
|
|
119
|
+
Main callers:
|
|
120
|
+
- Control-point construction.
|
|
121
|
+
- Public ``value_at`` methods.
|
|
122
|
+
|
|
123
|
+
Why this exists:
|
|
124
|
+
- ``Fraction(0.1)`` preserves a binary floating-point approximation, not
|
|
125
|
+
the exact FCPXML rational time. Timing floats therefore fail here.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
if isinstance(value, bool):
|
|
129
|
+
raise AnimationValidationError(
|
|
130
|
+
f"{field_name} must be an exact Fraction, not bool"
|
|
131
|
+
)
|
|
132
|
+
if isinstance(value, Fraction):
|
|
133
|
+
return value
|
|
134
|
+
if isinstance(value, int):
|
|
135
|
+
return Fraction(value)
|
|
136
|
+
if isinstance(value, float):
|
|
137
|
+
if not math.isfinite(value):
|
|
138
|
+
raise AnimationValidationError(f"{field_name} must be finite")
|
|
139
|
+
raise AnimationValidationError(
|
|
140
|
+
f"{field_name} must be an exact Fraction, not float"
|
|
141
|
+
)
|
|
142
|
+
raise AnimationValidationError(
|
|
143
|
+
f"{field_name} must be an exact Fraction, got {type(value).__name__}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _finite_number(value: object, *, field_name: str) -> float:
|
|
148
|
+
if isinstance(value, bool):
|
|
149
|
+
raise AnimationValidationError(
|
|
150
|
+
f"{field_name} must be a finite number, not bool"
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
result = float(value)
|
|
154
|
+
except (TypeError, ValueError) as exc:
|
|
155
|
+
raise AnimationValidationError(f"{field_name} must be a finite number") from exc
|
|
156
|
+
if not math.isfinite(result):
|
|
157
|
+
raise AnimationValidationError(f"{field_name} must be finite")
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _normalize_interpolation(raw: str | None) -> Interpolation:
|
|
162
|
+
compact = (raw or "linear").strip().lower().replace("-", "").replace("_", "")
|
|
163
|
+
values: dict[str, Interpolation] = {
|
|
164
|
+
"linear": "linear",
|
|
165
|
+
"ease": "ease",
|
|
166
|
+
"easein": "ease-in",
|
|
167
|
+
"easeout": "ease-out",
|
|
168
|
+
}
|
|
169
|
+
try:
|
|
170
|
+
return values[compact]
|
|
171
|
+
except KeyError as exc:
|
|
172
|
+
raise AnimationValidationError(
|
|
173
|
+
f"unsupported keyframe interpolation {raw!r}"
|
|
174
|
+
) from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _normalize_curve(raw: str | None) -> ValueCurve:
|
|
178
|
+
# FCPXML 1.14 declares ``smooth`` as the DTD default.
|
|
179
|
+
compact = (raw or "smooth").strip().lower()
|
|
180
|
+
if compact == "linear":
|
|
181
|
+
return "linear"
|
|
182
|
+
if compact == "smooth":
|
|
183
|
+
return "smooth"
|
|
184
|
+
raise AnimationValidationError(f"unsupported keyframe curve {raw!r}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_components(raw: str, *, count: int, field_name: str) -> tuple[float, ...]:
|
|
188
|
+
parts = raw.replace(",", " ").split()
|
|
189
|
+
if len(parts) != count:
|
|
190
|
+
raise AnimationValidationError(
|
|
191
|
+
f"{field_name} requires exactly {count} numeric component(s), got {raw!r}"
|
|
192
|
+
)
|
|
193
|
+
return tuple(
|
|
194
|
+
_finite_number(part, field_name=f"{field_name} component {index}")
|
|
195
|
+
for index, part in enumerate(parts)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class ScalarControlPoint:
|
|
201
|
+
"""One validated scalar keyframe in source-local time."""
|
|
202
|
+
|
|
203
|
+
time: Fraction
|
|
204
|
+
value: float
|
|
205
|
+
interpolation: Interpolation = "linear"
|
|
206
|
+
curve: ValueCurve = "smooth"
|
|
207
|
+
aux_value: str | None = None
|
|
208
|
+
|
|
209
|
+
def __post_init__(self) -> None:
|
|
210
|
+
object.__setattr__(
|
|
211
|
+
self, "time", _exact_time(self.time, field_name="control-point time")
|
|
212
|
+
)
|
|
213
|
+
object.__setattr__(
|
|
214
|
+
self, "value", _finite_number(self.value, field_name="scalar value")
|
|
215
|
+
)
|
|
216
|
+
object.__setattr__(
|
|
217
|
+
self, "interpolation", _normalize_interpolation(self.interpolation)
|
|
218
|
+
)
|
|
219
|
+
object.__setattr__(self, "curve", _normalize_curve(self.curve))
|
|
220
|
+
if self.aux_value is not None and not isinstance(self.aux_value, str):
|
|
221
|
+
raise AnimationValidationError("auxValue must be a string or None")
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def from_keyframe(cls, keyframe: Keyframe) -> "ScalarControlPoint":
|
|
225
|
+
"""Parse one parser-owned keyframe as a typed scalar point."""
|
|
226
|
+
|
|
227
|
+
if not isinstance(keyframe, Keyframe):
|
|
228
|
+
raise AnimationValidationError(
|
|
229
|
+
"scalar control point requires model.Keyframe"
|
|
230
|
+
)
|
|
231
|
+
value = _parse_components(
|
|
232
|
+
keyframe.value, count=1, field_name="scalar keyframe value"
|
|
233
|
+
)[0]
|
|
234
|
+
return cls(
|
|
235
|
+
time=keyframe.time,
|
|
236
|
+
value=value,
|
|
237
|
+
interpolation=_normalize_interpolation(keyframe.interp),
|
|
238
|
+
curve=_normalize_curve(keyframe.curve),
|
|
239
|
+
aux_value=keyframe.aux_value,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass(frozen=True)
|
|
244
|
+
class Vec2ControlPoint:
|
|
245
|
+
"""One validated two-component keyframe in source-local time."""
|
|
246
|
+
|
|
247
|
+
time: Fraction
|
|
248
|
+
value: Vec2
|
|
249
|
+
interpolation: Interpolation = "linear"
|
|
250
|
+
curve: ValueCurve = "smooth"
|
|
251
|
+
aux_value: str | None = None
|
|
252
|
+
|
|
253
|
+
def __post_init__(self) -> None:
|
|
254
|
+
object.__setattr__(
|
|
255
|
+
self, "time", _exact_time(self.time, field_name="control-point time")
|
|
256
|
+
)
|
|
257
|
+
if not isinstance(self.value, tuple) or len(self.value) != 2:
|
|
258
|
+
raise AnimationValidationError(
|
|
259
|
+
"vec2 value must contain exactly two components"
|
|
260
|
+
)
|
|
261
|
+
object.__setattr__(
|
|
262
|
+
self,
|
|
263
|
+
"value",
|
|
264
|
+
(
|
|
265
|
+
_finite_number(self.value[0], field_name="vec2 x"),
|
|
266
|
+
_finite_number(self.value[1], field_name="vec2 y"),
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
object.__setattr__(
|
|
270
|
+
self, "interpolation", _normalize_interpolation(self.interpolation)
|
|
271
|
+
)
|
|
272
|
+
object.__setattr__(self, "curve", _normalize_curve(self.curve))
|
|
273
|
+
if self.aux_value is not None and not isinstance(self.aux_value, str):
|
|
274
|
+
raise AnimationValidationError("auxValue must be a string or None")
|
|
275
|
+
|
|
276
|
+
@classmethod
|
|
277
|
+
def from_keyframe(cls, keyframe: Keyframe) -> "Vec2ControlPoint":
|
|
278
|
+
"""Parse one parser-owned keyframe as a typed two-component point."""
|
|
279
|
+
|
|
280
|
+
if not isinstance(keyframe, Keyframe):
|
|
281
|
+
raise AnimationValidationError("vec2 control point requires model.Keyframe")
|
|
282
|
+
values = _parse_components(
|
|
283
|
+
keyframe.value, count=2, field_name="vec2 keyframe value"
|
|
284
|
+
)
|
|
285
|
+
return cls(
|
|
286
|
+
time=keyframe.time,
|
|
287
|
+
value=(values[0], values[1]),
|
|
288
|
+
interpolation=_normalize_interpolation(keyframe.interp),
|
|
289
|
+
curve=_normalize_curve(keyframe.curve),
|
|
290
|
+
aux_value=keyframe.aux_value,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass(frozen=True)
|
|
295
|
+
class AnimationNotice:
|
|
296
|
+
"""A preserved animation fact that is not yet calibrated for execution."""
|
|
297
|
+
|
|
298
|
+
code: Literal["uncalibrated_aux_value"]
|
|
299
|
+
control_point_index: int
|
|
300
|
+
time: Fraction
|
|
301
|
+
detail: str
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass(frozen=True)
|
|
305
|
+
class MappedControlPoint:
|
|
306
|
+
"""One source keyframe occurrence on the output timeline.
|
|
307
|
+
|
|
308
|
+
A moving occurrence has equal start/end times. A freeze occurrence spans
|
|
309
|
+
an interval and deliberately cannot be flattened to an arbitrary point.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
source_index: int
|
|
313
|
+
source_time: Fraction
|
|
314
|
+
value: AnimationValue
|
|
315
|
+
interpolation: Interpolation
|
|
316
|
+
curve: ValueCurve
|
|
317
|
+
aux_value: str | None
|
|
318
|
+
timeline_start: Fraction
|
|
319
|
+
timeline_end: Fraction
|
|
320
|
+
includes_start: bool
|
|
321
|
+
includes_end: bool
|
|
322
|
+
segment_indices: tuple[int, ...]
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def is_interval(self) -> bool:
|
|
326
|
+
return self.timeline_end > self.timeline_start
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _validate_points(
|
|
330
|
+
points: Sequence[ScalarControlPoint] | Sequence[Vec2ControlPoint],
|
|
331
|
+
*,
|
|
332
|
+
expected_type: type[ScalarControlPoint] | type[Vec2ControlPoint],
|
|
333
|
+
) -> None:
|
|
334
|
+
if not points:
|
|
335
|
+
raise AnimationValidationError(
|
|
336
|
+
"animation track requires at least one control point"
|
|
337
|
+
)
|
|
338
|
+
previous: Fraction | None = None
|
|
339
|
+
for index, point in enumerate(points):
|
|
340
|
+
if not isinstance(point, expected_type):
|
|
341
|
+
raise AnimationValidationError(
|
|
342
|
+
f"control_points[{index}] must be {expected_type.__name__}"
|
|
343
|
+
)
|
|
344
|
+
if previous is not None and point.time == previous:
|
|
345
|
+
raise AnimationValidationError(f"duplicate control-point time {point.time}")
|
|
346
|
+
if previous is not None and point.time < previous:
|
|
347
|
+
raise AnimationValidationError(
|
|
348
|
+
"control-point times must be strictly increasing"
|
|
349
|
+
)
|
|
350
|
+
previous = point.time
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _notices(
|
|
354
|
+
points: Sequence[ScalarControlPoint] | Sequence[Vec2ControlPoint],
|
|
355
|
+
) -> tuple[AnimationNotice, ...]:
|
|
356
|
+
return tuple(
|
|
357
|
+
AnimationNotice(
|
|
358
|
+
code="uncalibrated_aux_value",
|
|
359
|
+
control_point_index=index,
|
|
360
|
+
time=point.time,
|
|
361
|
+
detail=f"auxValue {point.aux_value!r} is preserved but not applied",
|
|
362
|
+
)
|
|
363
|
+
for index, point in enumerate(points)
|
|
364
|
+
if point.aux_value is not None
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _eased_fraction(value: float, interpolation: Interpolation) -> float:
|
|
369
|
+
if interpolation == "linear":
|
|
370
|
+
return value
|
|
371
|
+
if interpolation == "ease":
|
|
372
|
+
return value * value * (3.0 - 2.0 * value)
|
|
373
|
+
if interpolation == "ease-in":
|
|
374
|
+
return value * value * value
|
|
375
|
+
if interpolation == "ease-out":
|
|
376
|
+
inverse = 1.0 - value
|
|
377
|
+
return 1.0 - inverse * inverse * inverse
|
|
378
|
+
raise AssertionError(f"unreachable interpolation {interpolation!r}")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def ken_burns_progress(
|
|
382
|
+
elapsed: Fraction,
|
|
383
|
+
duration: Fraction,
|
|
384
|
+
) -> float:
|
|
385
|
+
"""Return Final Cut's calibrated, endpoint-held Pan camera progress.
|
|
386
|
+
|
|
387
|
+
Main callers:
|
|
388
|
+
- Geometry snapshots for Crop mode ``pan``.
|
|
389
|
+
- Ken Burns oracle diagnostics.
|
|
390
|
+
|
|
391
|
+
Why this exists:
|
|
392
|
+
- The previous geometry snapshot used linear time while the FFmpeg path
|
|
393
|
+
constructed a separate inline counter. The shared piecewise-linear
|
|
394
|
+
calibration below follows the measured Final Cut marker trajectory.
|
|
395
|
+
Exact rational time is retained until the final normalized scalar is
|
|
396
|
+
evaluated.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
exact_elapsed = _exact_time(elapsed, field_name="Ken Burns elapsed time")
|
|
400
|
+
exact_duration = _exact_time(duration, field_name="Ken Burns duration")
|
|
401
|
+
if exact_duration <= 0:
|
|
402
|
+
raise AnimationValidationError("Ken Burns duration must be positive")
|
|
403
|
+
normalized = min(max(exact_elapsed / exact_duration, Fraction(0)), Fraction(1))
|
|
404
|
+
value = float(normalized)
|
|
405
|
+
for (left_time, left_value), (right_time, right_value) in zip(
|
|
406
|
+
KEN_BURNS_PROGRESS_POINTS[:-1],
|
|
407
|
+
KEN_BURNS_PROGRESS_POINTS[1:],
|
|
408
|
+
strict=True,
|
|
409
|
+
):
|
|
410
|
+
if value <= right_time:
|
|
411
|
+
fraction = (value - left_time) / (right_time - left_time)
|
|
412
|
+
return left_value + (right_value - left_value) * fraction
|
|
413
|
+
return 1.0
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def ken_burns_progress_expression(
|
|
417
|
+
*,
|
|
418
|
+
counter: str,
|
|
419
|
+
frame_count: int,
|
|
420
|
+
first_frame_index: int = 0,
|
|
421
|
+
) -> str:
|
|
422
|
+
"""Return the FFmpeg expression matching :func:`ken_burns_progress`.
|
|
423
|
+
|
|
424
|
+
``zoompan`` exposes the zero-based renderer-owned ``on`` counter. The
|
|
425
|
+
final visible frame is therefore ``frame_count - 1`` and must evaluate to
|
|
426
|
+
exactly one. XML text is never accepted as the counter expression.
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
if counter not in {"on", "N"}:
|
|
430
|
+
raise AnimationValidationError(
|
|
431
|
+
"Ken Burns FFmpeg counter must be the renderer-owned 'on' or 'N' variable"
|
|
432
|
+
)
|
|
433
|
+
if isinstance(frame_count, bool) or not isinstance(frame_count, int):
|
|
434
|
+
raise AnimationValidationError("Ken Burns frame_count must be an integer")
|
|
435
|
+
if frame_count < 2:
|
|
436
|
+
raise AnimationValidationError("Ken Burns frame_count must be at least two")
|
|
437
|
+
if first_frame_index not in {0, 1}:
|
|
438
|
+
raise AnimationValidationError(
|
|
439
|
+
"Ken Burns first_frame_index must be zero or one"
|
|
440
|
+
)
|
|
441
|
+
elapsed = counter if first_frame_index == 0 else f"({counter}-1)"
|
|
442
|
+
normalized = f"min(max({elapsed}/{frame_count - 1},0),1)"
|
|
443
|
+
|
|
444
|
+
def number(value: float) -> str:
|
|
445
|
+
return format(value, ".12g")
|
|
446
|
+
|
|
447
|
+
expression = "1"
|
|
448
|
+
for (left_time, left_value), (right_time, right_value) in reversed(
|
|
449
|
+
tuple(
|
|
450
|
+
zip(
|
|
451
|
+
KEN_BURNS_PROGRESS_POINTS[:-1],
|
|
452
|
+
KEN_BURNS_PROGRESS_POINTS[1:],
|
|
453
|
+
strict=True,
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
):
|
|
457
|
+
segment = (
|
|
458
|
+
f"({number(left_value)}+({number(right_value-left_value)})*"
|
|
459
|
+
f"((({normalized})-{number(left_time)})/"
|
|
460
|
+
f"{number(right_time-left_time)}))"
|
|
461
|
+
)
|
|
462
|
+
expression = (
|
|
463
|
+
f"if(lte(({normalized}),{number(right_time)}),"
|
|
464
|
+
f"{segment},{expression})"
|
|
465
|
+
)
|
|
466
|
+
return expression
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _monotone_slopes(
|
|
470
|
+
times: Sequence[Fraction], values: Sequence[float]
|
|
471
|
+
) -> tuple[float, ...]:
|
|
472
|
+
"""Return PCHIP/Fritsch-Carlson slopes without endpoint overshoot."""
|
|
473
|
+
|
|
474
|
+
count = len(times)
|
|
475
|
+
if count == 1:
|
|
476
|
+
return (0.0,)
|
|
477
|
+
widths = [float(times[index + 1] - times[index]) for index in range(count - 1)]
|
|
478
|
+
secants = [
|
|
479
|
+
(values[index + 1] - values[index]) / widths[index]
|
|
480
|
+
for index in range(count - 1)
|
|
481
|
+
]
|
|
482
|
+
if count == 2:
|
|
483
|
+
return (secants[0], secants[0])
|
|
484
|
+
|
|
485
|
+
slopes = [0.0] * count
|
|
486
|
+
for index in range(1, count - 1):
|
|
487
|
+
left = secants[index - 1]
|
|
488
|
+
right = secants[index]
|
|
489
|
+
if left == 0.0 or right == 0.0 or left * right <= 0.0:
|
|
490
|
+
slopes[index] = 0.0
|
|
491
|
+
continue
|
|
492
|
+
left_weight = 2.0 * widths[index] + widths[index - 1]
|
|
493
|
+
right_weight = widths[index] + 2.0 * widths[index - 1]
|
|
494
|
+
slopes[index] = (left_weight + right_weight) / (
|
|
495
|
+
left_weight / left + right_weight / right
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
slopes[0] = _endpoint_slope(widths[0], widths[1], secants[0], secants[1])
|
|
499
|
+
slopes[-1] = _endpoint_slope(widths[-1], widths[-2], secants[-1], secants[-2])
|
|
500
|
+
return tuple(slopes)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _endpoint_slope(
|
|
504
|
+
adjacent_width: float,
|
|
505
|
+
next_width: float,
|
|
506
|
+
adjacent_secant: float,
|
|
507
|
+
next_secant: float,
|
|
508
|
+
) -> float:
|
|
509
|
+
slope = (
|
|
510
|
+
(2.0 * adjacent_width + next_width) * adjacent_secant
|
|
511
|
+
- adjacent_width * next_secant
|
|
512
|
+
) / (adjacent_width + next_width)
|
|
513
|
+
if slope * adjacent_secant <= 0.0:
|
|
514
|
+
return 0.0
|
|
515
|
+
if adjacent_secant * next_secant < 0.0 and abs(slope) > abs(3.0 * adjacent_secant):
|
|
516
|
+
return 3.0 * adjacent_secant
|
|
517
|
+
return slope
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _interpolate_component(
|
|
521
|
+
*,
|
|
522
|
+
index: int,
|
|
523
|
+
fraction: float,
|
|
524
|
+
times: Sequence[Fraction],
|
|
525
|
+
values: Sequence[float],
|
|
526
|
+
curve: ValueCurve,
|
|
527
|
+
slopes: Sequence[float],
|
|
528
|
+
) -> float:
|
|
529
|
+
left = values[index]
|
|
530
|
+
right = values[index + 1]
|
|
531
|
+
if curve == "linear":
|
|
532
|
+
return left + (right - left) * fraction
|
|
533
|
+
|
|
534
|
+
width = float(times[index + 1] - times[index])
|
|
535
|
+
squared = fraction * fraction
|
|
536
|
+
cubed = squared * fraction
|
|
537
|
+
left_basis = 2.0 * cubed - 3.0 * squared + 1.0
|
|
538
|
+
left_slope_basis = cubed - 2.0 * squared + fraction
|
|
539
|
+
right_basis = -2.0 * cubed + 3.0 * squared
|
|
540
|
+
right_slope_basis = cubed - squared
|
|
541
|
+
result = (
|
|
542
|
+
left_basis * left
|
|
543
|
+
+ left_slope_basis * width * slopes[index]
|
|
544
|
+
+ right_basis * right
|
|
545
|
+
+ right_slope_basis * width * slopes[index + 1]
|
|
546
|
+
)
|
|
547
|
+
# Floating-point roundoff must not violate the monotonicity guarantee.
|
|
548
|
+
return min(max(result, min(left, right)), max(left, right))
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _segment_index(
|
|
552
|
+
points: Sequence[ScalarControlPoint] | Sequence[Vec2ControlPoint],
|
|
553
|
+
time: Fraction,
|
|
554
|
+
) -> int:
|
|
555
|
+
for index in range(len(points) - 1):
|
|
556
|
+
if points[index].time <= time <= points[index + 1].time:
|
|
557
|
+
return index
|
|
558
|
+
raise AssertionError("time inside track range did not resolve to a segment")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
@dataclass(frozen=True)
|
|
562
|
+
class AnimatedScalar:
|
|
563
|
+
"""A validated scalar animation evaluated in source-local time.
|
|
564
|
+
|
|
565
|
+
Main callers:
|
|
566
|
+
- Geometry, opacity, effect, and audio compilers adapt parser parameters
|
|
567
|
+
through ``from_parameter`` or ``from_keyframes``.
|
|
568
|
+
- ``TimelineAnimatedScalar`` evaluates this track through a ``RetimeMap``.
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
control_points: tuple[ScalarControlPoint, ...]
|
|
572
|
+
|
|
573
|
+
def __post_init__(self) -> None:
|
|
574
|
+
points = tuple(self.control_points)
|
|
575
|
+
object.__setattr__(self, "control_points", points)
|
|
576
|
+
_validate_points(points, expected_type=ScalarControlPoint)
|
|
577
|
+
|
|
578
|
+
@classmethod
|
|
579
|
+
def from_keyframes(cls, keyframes: Iterable[Keyframe]) -> "AnimatedScalar":
|
|
580
|
+
return cls(tuple(ScalarControlPoint.from_keyframe(item) for item in keyframes))
|
|
581
|
+
|
|
582
|
+
@classmethod
|
|
583
|
+
def from_parameter(cls, parameter: Parameter) -> "AnimatedScalar":
|
|
584
|
+
if not isinstance(parameter, Parameter):
|
|
585
|
+
raise AnimationValidationError("scalar animation requires model.Parameter")
|
|
586
|
+
if not parameter.keyframes:
|
|
587
|
+
raise AnimationValidationError(
|
|
588
|
+
"parameter has no keyframeAnimation control points"
|
|
589
|
+
)
|
|
590
|
+
return cls.from_keyframes(parameter.keyframes)
|
|
591
|
+
|
|
592
|
+
@property
|
|
593
|
+
def notices(self) -> tuple[AnimationNotice, ...]:
|
|
594
|
+
return _notices(self.control_points)
|
|
595
|
+
|
|
596
|
+
def value_at(self, source_time: Fraction) -> float:
|
|
597
|
+
exact_time = _exact_time(source_time, field_name="source animation time")
|
|
598
|
+
points = self.control_points
|
|
599
|
+
if exact_time <= points[0].time:
|
|
600
|
+
return points[0].value
|
|
601
|
+
if exact_time >= points[-1].time:
|
|
602
|
+
return points[-1].value
|
|
603
|
+
|
|
604
|
+
index = _segment_index(points, exact_time)
|
|
605
|
+
left = points[index]
|
|
606
|
+
right = points[index + 1]
|
|
607
|
+
raw_fraction = float((exact_time - left.time) / (right.time - left.time))
|
|
608
|
+
fraction = _eased_fraction(raw_fraction, right.interpolation)
|
|
609
|
+
times = tuple(point.time for point in points)
|
|
610
|
+
values = tuple(point.value for point in points)
|
|
611
|
+
slopes = _monotone_slopes(times, values)
|
|
612
|
+
return _interpolate_component(
|
|
613
|
+
index=index,
|
|
614
|
+
fraction=fraction,
|
|
615
|
+
times=times,
|
|
616
|
+
values=values,
|
|
617
|
+
curve=right.curve,
|
|
618
|
+
slopes=slopes,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@dataclass(frozen=True)
|
|
623
|
+
class AnimatedVec2:
|
|
624
|
+
"""A validated two-component animation evaluated in source-local time."""
|
|
625
|
+
|
|
626
|
+
control_points: tuple[Vec2ControlPoint, ...]
|
|
627
|
+
|
|
628
|
+
def __post_init__(self) -> None:
|
|
629
|
+
points = tuple(self.control_points)
|
|
630
|
+
object.__setattr__(self, "control_points", points)
|
|
631
|
+
_validate_points(points, expected_type=Vec2ControlPoint)
|
|
632
|
+
|
|
633
|
+
@classmethod
|
|
634
|
+
def from_keyframes(cls, keyframes: Iterable[Keyframe]) -> "AnimatedVec2":
|
|
635
|
+
return cls(tuple(Vec2ControlPoint.from_keyframe(item) for item in keyframes))
|
|
636
|
+
|
|
637
|
+
@classmethod
|
|
638
|
+
def from_parameter(cls, parameter: Parameter) -> "AnimatedVec2":
|
|
639
|
+
if not isinstance(parameter, Parameter):
|
|
640
|
+
raise AnimationValidationError("vec2 animation requires model.Parameter")
|
|
641
|
+
if not parameter.keyframes:
|
|
642
|
+
raise AnimationValidationError(
|
|
643
|
+
"parameter has no keyframeAnimation control points"
|
|
644
|
+
)
|
|
645
|
+
return cls.from_keyframes(parameter.keyframes)
|
|
646
|
+
|
|
647
|
+
@property
|
|
648
|
+
def notices(self) -> tuple[AnimationNotice, ...]:
|
|
649
|
+
return _notices(self.control_points)
|
|
650
|
+
|
|
651
|
+
def value_at(self, source_time: Fraction) -> Vec2:
|
|
652
|
+
exact_time = _exact_time(source_time, field_name="source animation time")
|
|
653
|
+
points = self.control_points
|
|
654
|
+
if exact_time <= points[0].time:
|
|
655
|
+
return points[0].value
|
|
656
|
+
if exact_time >= points[-1].time:
|
|
657
|
+
return points[-1].value
|
|
658
|
+
|
|
659
|
+
index = _segment_index(points, exact_time)
|
|
660
|
+
left = points[index]
|
|
661
|
+
right = points[index + 1]
|
|
662
|
+
raw_fraction = float((exact_time - left.time) / (right.time - left.time))
|
|
663
|
+
fraction = _eased_fraction(raw_fraction, right.interpolation)
|
|
664
|
+
times = tuple(point.time for point in points)
|
|
665
|
+
components = tuple(zip(*(point.value for point in points)))
|
|
666
|
+
result: list[float] = []
|
|
667
|
+
for values in components:
|
|
668
|
+
slopes = _monotone_slopes(times, values)
|
|
669
|
+
result.append(
|
|
670
|
+
_interpolate_component(
|
|
671
|
+
index=index,
|
|
672
|
+
fraction=fraction,
|
|
673
|
+
times=times,
|
|
674
|
+
values=values,
|
|
675
|
+
curve=right.curve,
|
|
676
|
+
slopes=slopes,
|
|
677
|
+
)
|
|
678
|
+
)
|
|
679
|
+
return result[0], result[1]
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _mapped_control_points(
|
|
683
|
+
control_points: Sequence[ScalarControlPoint] | Sequence[Vec2ControlPoint],
|
|
684
|
+
retime_map: RetimeMap,
|
|
685
|
+
) -> tuple[MappedControlPoint, ...]:
|
|
686
|
+
mapped: list[MappedControlPoint] = []
|
|
687
|
+
for source_index, point in enumerate(control_points):
|
|
688
|
+
for occurrence in retime_map.source_to_timeline_occurrences(point.time):
|
|
689
|
+
mapped.append(
|
|
690
|
+
MappedControlPoint(
|
|
691
|
+
source_index=source_index,
|
|
692
|
+
source_time=point.time,
|
|
693
|
+
value=point.value,
|
|
694
|
+
interpolation=point.interpolation,
|
|
695
|
+
curve=point.curve,
|
|
696
|
+
aux_value=point.aux_value,
|
|
697
|
+
timeline_start=occurrence.timeline_start,
|
|
698
|
+
timeline_end=occurrence.timeline_end,
|
|
699
|
+
includes_start=occurrence.includes_start,
|
|
700
|
+
includes_end=occurrence.includes_end,
|
|
701
|
+
segment_indices=occurrence.segment_indices,
|
|
702
|
+
)
|
|
703
|
+
)
|
|
704
|
+
return tuple(
|
|
705
|
+
sorted(
|
|
706
|
+
mapped,
|
|
707
|
+
key=lambda item: (
|
|
708
|
+
item.timeline_start,
|
|
709
|
+
item.timeline_end,
|
|
710
|
+
item.source_index,
|
|
711
|
+
),
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _validate_timeline_wrapper(
|
|
717
|
+
source_track: object, retime_map: object, expected_type: type[object]
|
|
718
|
+
) -> None:
|
|
719
|
+
if not isinstance(source_track, expected_type):
|
|
720
|
+
raise AnimationValidationError(f"source_track must be {expected_type.__name__}")
|
|
721
|
+
if not isinstance(retime_map, RetimeMap):
|
|
722
|
+
raise AnimationValidationError("retime_map must be RetimeMap")
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _materializable_segment(retime_map: RetimeMap) -> RetimeSegment:
|
|
726
|
+
if len(retime_map.segments) != 1:
|
|
727
|
+
raise UnsupportedAnimationMappingError(
|
|
728
|
+
"materializing timeline control points requires one continuous retime segment"
|
|
729
|
+
)
|
|
730
|
+
segment = retime_map.segments[0]
|
|
731
|
+
if segment.kind == "freeze":
|
|
732
|
+
raise UnsupportedAnimationMappingError(
|
|
733
|
+
"a freeze maps source keyframes to intervals, not unique timeline points"
|
|
734
|
+
)
|
|
735
|
+
if segment.kind == "reverse":
|
|
736
|
+
raise UnsupportedAnimationMappingError(
|
|
737
|
+
"reverse easing cannot be flattened without changing source-track semantics"
|
|
738
|
+
)
|
|
739
|
+
return segment
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@dataclass(frozen=True)
|
|
743
|
+
class TimelineAnimatedScalar:
|
|
744
|
+
"""Losslessly evaluate a scalar source track through an exact retime map."""
|
|
745
|
+
|
|
746
|
+
source_track: AnimatedScalar
|
|
747
|
+
retime_map: RetimeMap
|
|
748
|
+
|
|
749
|
+
def __post_init__(self) -> None:
|
|
750
|
+
_validate_timeline_wrapper(self.source_track, self.retime_map, AnimatedScalar)
|
|
751
|
+
|
|
752
|
+
@property
|
|
753
|
+
def control_point_occurrences(self) -> tuple[MappedControlPoint, ...]:
|
|
754
|
+
return _mapped_control_points(self.source_track.control_points, self.retime_map)
|
|
755
|
+
|
|
756
|
+
@property
|
|
757
|
+
def notices(self) -> tuple[AnimationNotice, ...]:
|
|
758
|
+
return self.source_track.notices
|
|
759
|
+
|
|
760
|
+
def value_at(self, timeline_time: Fraction) -> float:
|
|
761
|
+
source_time = self.retime_map.timeline_to_source(timeline_time)
|
|
762
|
+
return self.source_track.value_at(source_time)
|
|
763
|
+
|
|
764
|
+
def materialize_timeline_track(self) -> AnimatedScalar:
|
|
765
|
+
"""Flatten the only unambiguous subset into ordinary timeline points.
|
|
766
|
+
|
|
767
|
+
Main callers:
|
|
768
|
+
- FFmpeg expression builders that cannot evaluate ``RetimeMap`` first.
|
|
769
|
+
|
|
770
|
+
Why this exists:
|
|
771
|
+
- Forward one-segment retimes preserve point ordering and easing. More
|
|
772
|
+
complex maps remain available through ``value_at`` but must not be
|
|
773
|
+
rewritten into a misleading flat keyframe sequence.
|
|
774
|
+
"""
|
|
775
|
+
|
|
776
|
+
segment = _materializable_segment(self.retime_map)
|
|
777
|
+
points: list[ScalarControlPoint] = []
|
|
778
|
+
for point in self.source_track.control_points:
|
|
779
|
+
occurrences = self.retime_map.source_to_timeline_occurrences(point.time)
|
|
780
|
+
if not occurrences:
|
|
781
|
+
raise UnsupportedAnimationMappingError(
|
|
782
|
+
"materializing a partial source-track range requires synthesized boundary points"
|
|
783
|
+
)
|
|
784
|
+
if len(occurrences) != 1 or occurrences[0].is_interval:
|
|
785
|
+
raise UnsupportedAnimationMappingError(
|
|
786
|
+
"source control point does not map to one timeline coordinate"
|
|
787
|
+
)
|
|
788
|
+
points.append(
|
|
789
|
+
ScalarControlPoint(
|
|
790
|
+
time=occurrences[0].timeline_time,
|
|
791
|
+
value=point.value,
|
|
792
|
+
interpolation=point.interpolation,
|
|
793
|
+
curve=point.curve,
|
|
794
|
+
aux_value=point.aux_value,
|
|
795
|
+
)
|
|
796
|
+
)
|
|
797
|
+
if not points:
|
|
798
|
+
raise UnsupportedAnimationMappingError(
|
|
799
|
+
"retime segment does not contain any source control points"
|
|
800
|
+
)
|
|
801
|
+
# Retain the local name to make the forward-only precondition obvious.
|
|
802
|
+
assert segment.kind == "forward"
|
|
803
|
+
return AnimatedScalar(tuple(points))
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@dataclass(frozen=True)
|
|
807
|
+
class TimelineAnimatedVec2:
|
|
808
|
+
"""Losslessly evaluate a two-component source track through a retime map."""
|
|
809
|
+
|
|
810
|
+
source_track: AnimatedVec2
|
|
811
|
+
retime_map: RetimeMap
|
|
812
|
+
|
|
813
|
+
def __post_init__(self) -> None:
|
|
814
|
+
_validate_timeline_wrapper(self.source_track, self.retime_map, AnimatedVec2)
|
|
815
|
+
|
|
816
|
+
@property
|
|
817
|
+
def control_point_occurrences(self) -> tuple[MappedControlPoint, ...]:
|
|
818
|
+
return _mapped_control_points(self.source_track.control_points, self.retime_map)
|
|
819
|
+
|
|
820
|
+
@property
|
|
821
|
+
def notices(self) -> tuple[AnimationNotice, ...]:
|
|
822
|
+
return self.source_track.notices
|
|
823
|
+
|
|
824
|
+
def value_at(self, timeline_time: Fraction) -> Vec2:
|
|
825
|
+
source_time = self.retime_map.timeline_to_source(timeline_time)
|
|
826
|
+
return self.source_track.value_at(source_time)
|
|
827
|
+
|
|
828
|
+
def materialize_timeline_track(self) -> AnimatedVec2:
|
|
829
|
+
_materializable_segment(self.retime_map)
|
|
830
|
+
points: list[Vec2ControlPoint] = []
|
|
831
|
+
for point in self.source_track.control_points:
|
|
832
|
+
occurrences = self.retime_map.source_to_timeline_occurrences(point.time)
|
|
833
|
+
if not occurrences:
|
|
834
|
+
raise UnsupportedAnimationMappingError(
|
|
835
|
+
"materializing a partial source-track range requires synthesized boundary points"
|
|
836
|
+
)
|
|
837
|
+
if len(occurrences) != 1 or occurrences[0].is_interval:
|
|
838
|
+
raise UnsupportedAnimationMappingError(
|
|
839
|
+
"source control point does not map to one timeline coordinate"
|
|
840
|
+
)
|
|
841
|
+
points.append(
|
|
842
|
+
Vec2ControlPoint(
|
|
843
|
+
time=occurrences[0].timeline_time,
|
|
844
|
+
value=point.value,
|
|
845
|
+
interpolation=point.interpolation,
|
|
846
|
+
curve=point.curve,
|
|
847
|
+
aux_value=point.aux_value,
|
|
848
|
+
)
|
|
849
|
+
)
|
|
850
|
+
if not points:
|
|
851
|
+
raise UnsupportedAnimationMappingError(
|
|
852
|
+
"retime segment does not contain any source control points"
|
|
853
|
+
)
|
|
854
|
+
return AnimatedVec2(tuple(points))
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def map_scalar_animation(
|
|
858
|
+
source_track: AnimatedScalar, retime_map: RetimeMap
|
|
859
|
+
) -> TimelineAnimatedScalar:
|
|
860
|
+
"""Create the lossless source-to-timeline scalar evaluation wrapper."""
|
|
861
|
+
|
|
862
|
+
return TimelineAnimatedScalar(source_track=source_track, retime_map=retime_map)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def map_vec2_animation(
|
|
866
|
+
source_track: AnimatedVec2, retime_map: RetimeMap
|
|
867
|
+
) -> TimelineAnimatedVec2:
|
|
868
|
+
"""Create the lossless source-to-timeline vector evaluation wrapper."""
|
|
869
|
+
|
|
870
|
+
return TimelineAnimatedVec2(source_track=source_track, retime_map=retime_map)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
__all__ = [
|
|
874
|
+
"AnimatedScalar",
|
|
875
|
+
"AnimatedVec2",
|
|
876
|
+
"AnimationError",
|
|
877
|
+
"AnimationNotice",
|
|
878
|
+
"AnimationValidationError",
|
|
879
|
+
"KEN_BURNS_PROGRESS_CALIBRATION_ID",
|
|
880
|
+
"KEN_BURNS_PROGRESS_MAX_RESIDUAL",
|
|
881
|
+
"KEN_BURNS_PROGRESS_POINTS",
|
|
882
|
+
"MappedControlPoint",
|
|
883
|
+
"ScalarControlPoint",
|
|
884
|
+
"TimelineAnimatedScalar",
|
|
885
|
+
"TimelineAnimatedVec2",
|
|
886
|
+
"UnsupportedAnimationMappingError",
|
|
887
|
+
"Vec2",
|
|
888
|
+
"Vec2ControlPoint",
|
|
889
|
+
"ken_burns_progress",
|
|
890
|
+
"ken_burns_progress_expression",
|
|
891
|
+
"map_scalar_animation",
|
|
892
|
+
"map_vec2_animation",
|
|
893
|
+
]
|