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,817 @@
|
|
|
1
|
+
"""Compile typed FCPXML animation into bounded FFmpeg execution plans.
|
|
2
|
+
|
|
3
|
+
Architecture map
|
|
4
|
+
================
|
|
5
|
+
|
|
6
|
+
``TimelineAnimatedScalar`` / ``TimelineAnimatedVec2``
|
|
7
|
+
Exact source control points plus an exact piecewise ``RetimeMap``.
|
|
8
|
+
|
|
9
|
+
``AnimationClock``
|
|
10
|
+
Defines how an FFmpeg filter's time variable relates to the clip. A
|
|
11
|
+
clip-local clock starts at the beginning of a rendered pre-roll window;
|
|
12
|
+
an absolute clock uses sequence time directly.
|
|
13
|
+
|
|
14
|
+
``ClipTimeState``
|
|
15
|
+
One shared temporal snapshot for every geometry component. It records
|
|
16
|
+
clip-local time, retimed source time, segment ownership, and whether a
|
|
17
|
+
transition handle is holding an endpoint.
|
|
18
|
+
|
|
19
|
+
``compile_scalar_expression`` / ``compile_vec2_expressions``
|
|
20
|
+
Clamp pre/post-roll to endpoint holds, map every retime segment without
|
|
21
|
+
averaging, then reproduce the animation kernel as deterministic FFmpeg
|
|
22
|
+
expressions.
|
|
23
|
+
|
|
24
|
+
``sample_scalar_frames`` / ``sample_vec2_frames``
|
|
25
|
+
Produce an exact-time, per-frame command plan for filters such as
|
|
26
|
+
``perspective`` that cannot evaluate an expression for every option.
|
|
27
|
+
|
|
28
|
+
Important invariants
|
|
29
|
+
--------------------
|
|
30
|
+
|
|
31
|
+
* Every time entering this boundary is an exact ``Fraction``. Generated
|
|
32
|
+
expressions retain rational times as numerator/denominator operations.
|
|
33
|
+
* Reverse, freeze, variable-rate, discontinuous, and repeated source ranges
|
|
34
|
+
are represented segment by segment. There is no average-speed fallback.
|
|
35
|
+
* Times before or after the clip hold the first or last rendered value. This
|
|
36
|
+
makes transition pre-roll and post-roll deterministic.
|
|
37
|
+
* Expression size, control-point count, retime-segment count, and their
|
|
38
|
+
product are bounded. Oversized plans fail explicitly instead of handing an
|
|
39
|
+
unbounded expression to FFmpeg.
|
|
40
|
+
|
|
41
|
+
Central integration seam
|
|
42
|
+
------------------------
|
|
43
|
+
|
|
44
|
+
The central FFmpeg builder should consume the typed tracks already attached
|
|
45
|
+
to ``model.RenderTransformAnimation`` (and future opacity/audio records):
|
|
46
|
+
|
|
47
|
+
1. Use ``compile_*_expression`` when a filter option supports per-frame
|
|
48
|
+
FFmpeg expressions.
|
|
49
|
+
2. Use ``sample_*_frames`` plus a renderer-owned ``sendcmd`` file when a
|
|
50
|
+
filter option is command-only.
|
|
51
|
+
3. Surface ``AnimationExecutionError`` as a compatibility finding or compile
|
|
52
|
+
error. Never fall back to a static or endpoint-average value.
|
|
53
|
+
|
|
54
|
+
This module deliberately does not import or mutate the shared compiler or
|
|
55
|
+
FFmpeg builder. It is the isolated Wave 2 contract for that later integration.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
from dataclasses import dataclass
|
|
61
|
+
from fractions import Fraction
|
|
62
|
+
import math
|
|
63
|
+
import re
|
|
64
|
+
from typing import Literal, Sequence, TypeAlias
|
|
65
|
+
|
|
66
|
+
from .animation import (
|
|
67
|
+
ScalarControlPoint,
|
|
68
|
+
TimelineAnimatedScalar,
|
|
69
|
+
TimelineAnimatedVec2,
|
|
70
|
+
Vec2ControlPoint,
|
|
71
|
+
)
|
|
72
|
+
from .retime import RetimeMap, SegmentKind
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
TimeOrigin: TypeAlias = Literal["clip_local", "absolute"]
|
|
76
|
+
ClockPhase: TypeAlias = Literal[
|
|
77
|
+
"transition_pre_roll",
|
|
78
|
+
"clip",
|
|
79
|
+
"transition_post_roll",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AnimationExecutionError(ValueError):
|
|
84
|
+
"""Base error for an animation that cannot be executed faithfully."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AnimationExpressionLimitError(AnimationExecutionError):
|
|
88
|
+
"""A valid animation exceeds the explicitly configured FFmpeg bounds."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AnimationClockError(AnimationExecutionError):
|
|
92
|
+
"""The FFmpeg time origin or render window is ambiguous or invalid."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _exact_time(value: object, *, name: str) -> Fraction:
|
|
96
|
+
"""Require an exact rational time at the FFmpeg execution boundary."""
|
|
97
|
+
|
|
98
|
+
if isinstance(value, bool):
|
|
99
|
+
raise AnimationClockError(f"{name} must be an exact Fraction, not bool")
|
|
100
|
+
if isinstance(value, Fraction):
|
|
101
|
+
return value
|
|
102
|
+
if isinstance(value, int):
|
|
103
|
+
return Fraction(value)
|
|
104
|
+
if isinstance(value, float):
|
|
105
|
+
raise AnimationClockError(f"{name} must be an exact Fraction, not float")
|
|
106
|
+
raise AnimationClockError(
|
|
107
|
+
f"{name} must be an exact Fraction, got {type(value).__name__}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _positive_int(value: object, *, name: str) -> int:
|
|
112
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
113
|
+
raise AnimationExpressionLimitError(f"{name} must be a positive integer")
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class AnimationExpressionLimits:
|
|
119
|
+
"""Hard resource limits for generated FFmpeg expressions.
|
|
120
|
+
|
|
121
|
+
Main callers:
|
|
122
|
+
- Registry-owned renderer configuration.
|
|
123
|
+
|
|
124
|
+
Why this exists:
|
|
125
|
+
- FFmpeg's expression parser is recursive. Explicit limits prevent a
|
|
126
|
+
large untrusted project from creating excessive parser work or memory.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
max_control_points: int = 64
|
|
130
|
+
max_retime_segments: int = 64
|
|
131
|
+
max_segment_point_product: int = 1024
|
|
132
|
+
max_expression_chars: int = 65_536
|
|
133
|
+
|
|
134
|
+
def __post_init__(self) -> None:
|
|
135
|
+
for name in (
|
|
136
|
+
"max_control_points",
|
|
137
|
+
"max_retime_segments",
|
|
138
|
+
"max_segment_point_product",
|
|
139
|
+
"max_expression_chars",
|
|
140
|
+
):
|
|
141
|
+
object.__setattr__(
|
|
142
|
+
self,
|
|
143
|
+
name,
|
|
144
|
+
_positive_int(getattr(self, name), name=name),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class ClipTimeState:
|
|
150
|
+
"""One exact clock/retime state shared by a clip's geometry tracks.
|
|
151
|
+
|
|
152
|
+
``clip_time`` and ``track_time`` are endpoint-held during transition-only
|
|
153
|
+
pre/post-roll. ``source_time`` is evaluated through the same exact map as
|
|
154
|
+
video playback, including reverse and freeze segments.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
input_time: Fraction
|
|
158
|
+
unclamped_clip_time: Fraction
|
|
159
|
+
clip_time: Fraction
|
|
160
|
+
track_time: Fraction
|
|
161
|
+
source_time: Fraction
|
|
162
|
+
phase: ClockPhase
|
|
163
|
+
retime_segment_index: int
|
|
164
|
+
retime_segment_kind: SegmentKind
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def is_endpoint_hold(self) -> bool:
|
|
168
|
+
return self.phase != "clip"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class AnimationClock:
|
|
173
|
+
"""Map an FFmpeg filter time variable onto one clip's retime domain.
|
|
174
|
+
|
|
175
|
+
``clip_local`` means FFmpeg time zero is the start of the rendered window.
|
|
176
|
+
With two seconds of pre-roll, the clip itself therefore begins at FFmpeg
|
|
177
|
+
time two. ``absolute`` means the FFmpeg variable is sequence time and
|
|
178
|
+
``absolute_clip_start`` identifies the clip's true start.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
origin: TimeOrigin = "clip_local"
|
|
182
|
+
absolute_clip_start: Fraction | None = None
|
|
183
|
+
pre_roll: Fraction = Fraction(0)
|
|
184
|
+
post_roll: Fraction = Fraction(0)
|
|
185
|
+
|
|
186
|
+
def __post_init__(self) -> None:
|
|
187
|
+
if self.origin not in {"clip_local", "absolute"}:
|
|
188
|
+
raise AnimationClockError(
|
|
189
|
+
f"origin must be 'clip_local' or 'absolute', got {self.origin!r}"
|
|
190
|
+
)
|
|
191
|
+
pre_roll = _exact_time(self.pre_roll, name="pre_roll")
|
|
192
|
+
post_roll = _exact_time(self.post_roll, name="post_roll")
|
|
193
|
+
if pre_roll < 0 or post_roll < 0:
|
|
194
|
+
raise AnimationClockError("pre_roll and post_roll must be non-negative")
|
|
195
|
+
object.__setattr__(self, "pre_roll", pre_roll)
|
|
196
|
+
object.__setattr__(self, "post_roll", post_roll)
|
|
197
|
+
|
|
198
|
+
absolute_start = self.absolute_clip_start
|
|
199
|
+
if self.origin == "absolute":
|
|
200
|
+
if absolute_start is None:
|
|
201
|
+
raise AnimationClockError(
|
|
202
|
+
"absolute origin requires absolute_clip_start"
|
|
203
|
+
)
|
|
204
|
+
object.__setattr__(
|
|
205
|
+
self,
|
|
206
|
+
"absolute_clip_start",
|
|
207
|
+
_exact_time(absolute_start, name="absolute_clip_start"),
|
|
208
|
+
)
|
|
209
|
+
elif absolute_start is not None:
|
|
210
|
+
raise AnimationClockError(
|
|
211
|
+
"clip_local origin must not provide absolute_clip_start"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def clip_start_in_input_time(self) -> Fraction:
|
|
216
|
+
"""Return the FFmpeg time at which the actual clip begins."""
|
|
217
|
+
|
|
218
|
+
if self.origin == "clip_local":
|
|
219
|
+
return self.pre_roll
|
|
220
|
+
assert self.absolute_clip_start is not None
|
|
221
|
+
return self.absolute_clip_start
|
|
222
|
+
|
|
223
|
+
def input_window(self, clip_duration: Fraction) -> tuple[Fraction, Fraction]:
|
|
224
|
+
"""Return the exact inclusive/exclusive render-window coordinates."""
|
|
225
|
+
|
|
226
|
+
duration = _exact_time(clip_duration, name="clip_duration")
|
|
227
|
+
if duration <= 0:
|
|
228
|
+
raise AnimationClockError("clip_duration must be positive")
|
|
229
|
+
if self.origin == "clip_local":
|
|
230
|
+
return Fraction(0), self.pre_roll + duration + self.post_roll
|
|
231
|
+
assert self.absolute_clip_start is not None
|
|
232
|
+
return (
|
|
233
|
+
self.absolute_clip_start - self.pre_roll,
|
|
234
|
+
self.absolute_clip_start + duration + self.post_roll,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def clip_time(self, input_time: Fraction, clip_duration: Fraction) -> Fraction:
|
|
238
|
+
"""Convert one exact FFmpeg time to bounded clip-local time."""
|
|
239
|
+
|
|
240
|
+
exact_input = _exact_time(input_time, name="input_time")
|
|
241
|
+
duration = _exact_time(clip_duration, name="clip_duration")
|
|
242
|
+
local = exact_input - self.clip_start_in_input_time
|
|
243
|
+
return min(max(local, Fraction(0)), duration)
|
|
244
|
+
|
|
245
|
+
def state_at(
|
|
246
|
+
self,
|
|
247
|
+
input_time: Fraction,
|
|
248
|
+
retime_map: RetimeMap,
|
|
249
|
+
) -> ClipTimeState:
|
|
250
|
+
"""Resolve one shared source-time state, including transition handles.
|
|
251
|
+
|
|
252
|
+
Main callers:
|
|
253
|
+
- Expression-plan reference evaluators and per-frame command plans.
|
|
254
|
+
- Geometry integration for transform, corner pin, opacity, and Pan.
|
|
255
|
+
|
|
256
|
+
Why this exists:
|
|
257
|
+
- Each component previously combined FFmpeg time, pre-roll, and retime
|
|
258
|
+
offsets independently. Small differences made geometry drift during
|
|
259
|
+
reverse/freeze playback and overlapping transitions.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
if not isinstance(retime_map, RetimeMap):
|
|
263
|
+
raise AnimationClockError("retime_map must be a RetimeMap")
|
|
264
|
+
exact_input = _exact_time(input_time, name="input_time")
|
|
265
|
+
duration = retime_map.timeline_duration
|
|
266
|
+
unclamped = exact_input - self.clip_start_in_input_time
|
|
267
|
+
if unclamped < 0:
|
|
268
|
+
phase: ClockPhase = "transition_pre_roll"
|
|
269
|
+
elif unclamped > duration:
|
|
270
|
+
phase = "transition_post_roll"
|
|
271
|
+
else:
|
|
272
|
+
phase = "clip"
|
|
273
|
+
clip_time = min(max(unclamped, Fraction(0)), duration)
|
|
274
|
+
track_time = retime_map.timeline_start + clip_time
|
|
275
|
+
sample = retime_map.sample(track_time)
|
|
276
|
+
return ClipTimeState(
|
|
277
|
+
input_time=exact_input,
|
|
278
|
+
unclamped_clip_time=unclamped,
|
|
279
|
+
clip_time=clip_time,
|
|
280
|
+
track_time=track_time,
|
|
281
|
+
source_time=sample.source_time,
|
|
282
|
+
phase=phase,
|
|
283
|
+
retime_segment_index=sample.segment_index,
|
|
284
|
+
retime_segment_kind=sample.segment_kind,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass(frozen=True)
|
|
289
|
+
class ScalarExpressionPlan:
|
|
290
|
+
"""One scalar FFmpeg expression plus its exact reference evaluator."""
|
|
291
|
+
|
|
292
|
+
expression: str
|
|
293
|
+
track: TimelineAnimatedScalar
|
|
294
|
+
clock: AnimationClock
|
|
295
|
+
time_variable: str
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def clip_duration(self) -> Fraction:
|
|
299
|
+
return self.track.retime_map.timeline_duration
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def input_window_start(self) -> Fraction:
|
|
303
|
+
return self.clock.input_window(self.clip_duration)[0]
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def input_window_end(self) -> Fraction:
|
|
307
|
+
return self.clock.input_window(self.clip_duration)[1]
|
|
308
|
+
|
|
309
|
+
def value_at_input_time(self, input_time: Fraction) -> float:
|
|
310
|
+
"""Evaluate the same clock and endpoint holds as the expression."""
|
|
311
|
+
|
|
312
|
+
state = self.clock.state_at(input_time, self.track.retime_map)
|
|
313
|
+
return self.track.source_track.value_at(state.source_time)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass(frozen=True)
|
|
317
|
+
class Vec2ExpressionPlan:
|
|
318
|
+
"""Two FFmpeg expressions sharing one exact time/retime contract."""
|
|
319
|
+
|
|
320
|
+
x_expression: str
|
|
321
|
+
y_expression: str
|
|
322
|
+
track: TimelineAnimatedVec2
|
|
323
|
+
clock: AnimationClock
|
|
324
|
+
time_variable: str
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def clip_duration(self) -> Fraction:
|
|
328
|
+
return self.track.retime_map.timeline_duration
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def input_window_start(self) -> Fraction:
|
|
332
|
+
return self.clock.input_window(self.clip_duration)[0]
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def input_window_end(self) -> Fraction:
|
|
336
|
+
return self.clock.input_window(self.clip_duration)[1]
|
|
337
|
+
|
|
338
|
+
def value_at_input_time(self, input_time: Fraction) -> tuple[float, float]:
|
|
339
|
+
state = self.clock.state_at(input_time, self.track.retime_map)
|
|
340
|
+
return self.track.source_track.value_at(state.source_time)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass(frozen=True)
|
|
344
|
+
class ScalarFrameCommand:
|
|
345
|
+
"""One exact per-frame scalar command for a command-only filter."""
|
|
346
|
+
|
|
347
|
+
frame_index: int
|
|
348
|
+
input_time: Fraction
|
|
349
|
+
clip_time: Fraction
|
|
350
|
+
track_time: Fraction
|
|
351
|
+
value: float
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass(frozen=True)
|
|
355
|
+
class Vec2FrameCommand:
|
|
356
|
+
"""One exact per-frame two-component command for a command-only filter."""
|
|
357
|
+
|
|
358
|
+
frame_index: int
|
|
359
|
+
input_time: Fraction
|
|
360
|
+
clip_time: Fraction
|
|
361
|
+
track_time: Fraction
|
|
362
|
+
value: tuple[float, float]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _fraction_expression(value: Fraction) -> str:
|
|
366
|
+
if value.denominator == 1:
|
|
367
|
+
return str(value.numerator)
|
|
368
|
+
return f"({value.numerator}/{value.denominator})"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _float_expression(value: float) -> str:
|
|
372
|
+
if not math.isfinite(value):
|
|
373
|
+
raise AnimationExecutionError("animation expression value must be finite")
|
|
374
|
+
if abs(value) < 5e-16:
|
|
375
|
+
value = 0.0
|
|
376
|
+
return format(value, ".17g")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _validate_time_variable(value: object) -> str:
|
|
380
|
+
if value in {"t", "T"}:
|
|
381
|
+
return str(value)
|
|
382
|
+
if isinstance(value, str) and re.fullmatch(
|
|
383
|
+
r"\(on-1\)\*[1-9][0-9]*/[1-9][0-9]*",
|
|
384
|
+
value,
|
|
385
|
+
):
|
|
386
|
+
# ``perspective=eval=frame`` exposes only its output frame number
|
|
387
|
+
# (``on``), not the ordinary video-filter time variable. FFmpeg defines
|
|
388
|
+
# that counter as one-based, so subtract one before multiplying by the
|
|
389
|
+
# exact frame duration. Accepting only this closed renderer-owned form
|
|
390
|
+
# keeps XML/user text out of FFmpeg expressions.
|
|
391
|
+
return f"({value})"
|
|
392
|
+
else:
|
|
393
|
+
raise AnimationExecutionError(
|
|
394
|
+
"time_variable must be 't' or 'T', or the renderer-owned exact "
|
|
395
|
+
"perspective clock '(on-1)*N/D'"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _check_track_bounds(
|
|
400
|
+
*,
|
|
401
|
+
point_count: int,
|
|
402
|
+
segment_count: int,
|
|
403
|
+
limits: AnimationExpressionLimits,
|
|
404
|
+
) -> None:
|
|
405
|
+
if point_count > limits.max_control_points:
|
|
406
|
+
raise AnimationExpressionLimitError(
|
|
407
|
+
f"animation has {point_count} control points; limit is "
|
|
408
|
+
f"{limits.max_control_points}"
|
|
409
|
+
)
|
|
410
|
+
if segment_count > limits.max_retime_segments:
|
|
411
|
+
raise AnimationExpressionLimitError(
|
|
412
|
+
f"animation has {segment_count} retime segments; limit is "
|
|
413
|
+
f"{limits.max_retime_segments}"
|
|
414
|
+
)
|
|
415
|
+
product = max(point_count - 1, 1) * segment_count
|
|
416
|
+
if product > limits.max_segment_point_product:
|
|
417
|
+
raise AnimationExpressionLimitError(
|
|
418
|
+
f"animation expression complexity is {product}; limit is "
|
|
419
|
+
f"{limits.max_segment_point_product}"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _clip_time_expression(
|
|
424
|
+
*, time_variable: str, clock: AnimationClock, duration: Fraction
|
|
425
|
+
) -> str:
|
|
426
|
+
start = _fraction_expression(clock.clip_start_in_input_time)
|
|
427
|
+
return (
|
|
428
|
+
f"clip(({time_variable})-({start}),0,{_fraction_expression(duration)})"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _track_time_expression(
|
|
433
|
+
*, time_variable: str, clock: AnimationClock, timeline_start: Fraction,
|
|
434
|
+
duration: Fraction
|
|
435
|
+
) -> str:
|
|
436
|
+
local = _clip_time_expression(
|
|
437
|
+
time_variable=time_variable,
|
|
438
|
+
clock=clock,
|
|
439
|
+
duration=duration,
|
|
440
|
+
)
|
|
441
|
+
return f"({_fraction_expression(timeline_start)}+({local}))"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _source_time_expression(
|
|
445
|
+
track: TimelineAnimatedScalar | TimelineAnimatedVec2,
|
|
446
|
+
*,
|
|
447
|
+
time_variable: str,
|
|
448
|
+
clock: AnimationClock,
|
|
449
|
+
) -> str:
|
|
450
|
+
"""Map bounded FFmpeg time through every exact retime segment."""
|
|
451
|
+
|
|
452
|
+
retime_map = track.retime_map
|
|
453
|
+
timeline_time = _track_time_expression(
|
|
454
|
+
time_variable=time_variable,
|
|
455
|
+
clock=clock,
|
|
456
|
+
timeline_start=retime_map.timeline_start,
|
|
457
|
+
duration=retime_map.timeline_duration,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def mapped(segment_index: int) -> str:
|
|
461
|
+
segment = retime_map.segments[segment_index]
|
|
462
|
+
return (
|
|
463
|
+
f"({_fraction_expression(segment.source_start)}+"
|
|
464
|
+
f"(({timeline_time})-{_fraction_expression(segment.timeline_start)})*"
|
|
465
|
+
f"{_fraction_expression(segment.rate)})"
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# The final segment owns the map's final endpoint. Every prior segment is
|
|
469
|
+
# half-open, exactly matching RetimeMap.map_timeline.
|
|
470
|
+
expression = mapped(len(retime_map.segments) - 1)
|
|
471
|
+
for index in reversed(range(len(retime_map.segments) - 1)):
|
|
472
|
+
boundary = _fraction_expression(retime_map.segments[index].timeline_end)
|
|
473
|
+
expression = (
|
|
474
|
+
f"if(lt(({timeline_time}),{boundary}),{mapped(index)},{expression})"
|
|
475
|
+
)
|
|
476
|
+
return expression
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _monotone_slopes(
|
|
480
|
+
points: Sequence[ScalarControlPoint] | Sequence[Vec2ControlPoint],
|
|
481
|
+
values: Sequence[float],
|
|
482
|
+
) -> tuple[float, ...]:
|
|
483
|
+
"""Mirror ``animation._monotone_slopes`` for expression coefficients.
|
|
484
|
+
|
|
485
|
+
Why this exists:
|
|
486
|
+
- The animation kernel intentionally keeps its interpolation helpers
|
|
487
|
+
private. This execution module freezes identical coefficients without
|
|
488
|
+
exposing an additional central API during isolated development.
|
|
489
|
+
"""
|
|
490
|
+
|
|
491
|
+
count = len(points)
|
|
492
|
+
if count == 1:
|
|
493
|
+
return (0.0,)
|
|
494
|
+
widths = [
|
|
495
|
+
float(points[index + 1].time - points[index].time)
|
|
496
|
+
for index in range(count - 1)
|
|
497
|
+
]
|
|
498
|
+
secants = [
|
|
499
|
+
(values[index + 1] - values[index]) / widths[index]
|
|
500
|
+
for index in range(count - 1)
|
|
501
|
+
]
|
|
502
|
+
if count == 2:
|
|
503
|
+
return (secants[0], secants[0])
|
|
504
|
+
|
|
505
|
+
slopes = [0.0] * count
|
|
506
|
+
for index in range(1, count - 1):
|
|
507
|
+
left = secants[index - 1]
|
|
508
|
+
right = secants[index]
|
|
509
|
+
if left == 0.0 or right == 0.0 or left * right <= 0.0:
|
|
510
|
+
slopes[index] = 0.0
|
|
511
|
+
continue
|
|
512
|
+
left_weight = 2.0 * widths[index] + widths[index - 1]
|
|
513
|
+
right_weight = widths[index] + 2.0 * widths[index - 1]
|
|
514
|
+
slopes[index] = (left_weight + right_weight) / (
|
|
515
|
+
left_weight / left + right_weight / right
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
def endpoint(
|
|
519
|
+
adjacent_width: float,
|
|
520
|
+
next_width: float,
|
|
521
|
+
adjacent_secant: float,
|
|
522
|
+
next_secant: float,
|
|
523
|
+
) -> float:
|
|
524
|
+
slope = (
|
|
525
|
+
(2.0 * adjacent_width + next_width) * adjacent_secant
|
|
526
|
+
- adjacent_width * next_secant
|
|
527
|
+
) / (adjacent_width + next_width)
|
|
528
|
+
if slope * adjacent_secant <= 0.0:
|
|
529
|
+
return 0.0
|
|
530
|
+
if (
|
|
531
|
+
adjacent_secant * next_secant < 0.0
|
|
532
|
+
and abs(slope) > abs(3.0 * adjacent_secant)
|
|
533
|
+
):
|
|
534
|
+
return 3.0 * adjacent_secant
|
|
535
|
+
return slope
|
|
536
|
+
|
|
537
|
+
slopes[0] = endpoint(widths[0], widths[1], secants[0], secants[1])
|
|
538
|
+
slopes[-1] = endpoint(widths[-1], widths[-2], secants[-1], secants[-2])
|
|
539
|
+
return tuple(slopes)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _easing_expression(raw: str, interpolation: str) -> str:
|
|
543
|
+
if interpolation == "linear":
|
|
544
|
+
return raw
|
|
545
|
+
if interpolation == "ease":
|
|
546
|
+
return f"({raw})*({raw})*(3-2*({raw}))"
|
|
547
|
+
if interpolation == "ease-in":
|
|
548
|
+
return f"({raw})*({raw})*({raw})"
|
|
549
|
+
if interpolation == "ease-out":
|
|
550
|
+
return f"1-(1-({raw}))*(1-({raw}))*(1-({raw}))"
|
|
551
|
+
raise AnimationExecutionError(
|
|
552
|
+
f"unsupported animation interpolation {interpolation!r}"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _component_expression(
|
|
557
|
+
*,
|
|
558
|
+
points: Sequence[ScalarControlPoint] | Sequence[Vec2ControlPoint],
|
|
559
|
+
values: Sequence[float],
|
|
560
|
+
source_time: str,
|
|
561
|
+
) -> str:
|
|
562
|
+
if len(points) == 1:
|
|
563
|
+
return _float_expression(values[0])
|
|
564
|
+
|
|
565
|
+
slopes = _monotone_slopes(points, values)
|
|
566
|
+
segments: list[str] = []
|
|
567
|
+
for index, (left, right) in enumerate(zip(points, points[1:])):
|
|
568
|
+
raw = (
|
|
569
|
+
f"(({source_time})-{_fraction_expression(left.time)})/"
|
|
570
|
+
f"{_fraction_expression(right.time-left.time)}"
|
|
571
|
+
)
|
|
572
|
+
eased = _easing_expression(raw, right.interpolation)
|
|
573
|
+
if right.curve == "linear":
|
|
574
|
+
value = (
|
|
575
|
+
f"({_float_expression(values[index])}+"
|
|
576
|
+
f"({_float_expression(values[index+1]-values[index])})*({eased}))"
|
|
577
|
+
)
|
|
578
|
+
elif right.curve == "smooth":
|
|
579
|
+
squared = f"({eased})*({eased})"
|
|
580
|
+
cubed = f"({squared})*({eased})"
|
|
581
|
+
width = float(right.time - left.time)
|
|
582
|
+
value = (
|
|
583
|
+
f"((2*({cubed})-3*({squared})+1)*"
|
|
584
|
+
f"{_float_expression(values[index])}+"
|
|
585
|
+
f"(({cubed})-2*({squared})+({eased}))*"
|
|
586
|
+
f"{_float_expression(width*slopes[index])}+"
|
|
587
|
+
f"(-2*({cubed})+3*({squared}))*"
|
|
588
|
+
f"{_float_expression(values[index+1])}+"
|
|
589
|
+
f"(({cubed})-({squared}))*"
|
|
590
|
+
f"{_float_expression(width*slopes[index+1])})"
|
|
591
|
+
)
|
|
592
|
+
low = _float_expression(min(values[index], values[index + 1]))
|
|
593
|
+
high = _float_expression(max(values[index], values[index + 1]))
|
|
594
|
+
value = f"clip({value},{low},{high})"
|
|
595
|
+
else:
|
|
596
|
+
raise AnimationExecutionError(
|
|
597
|
+
f"unsupported animation value curve {right.curve!r}"
|
|
598
|
+
)
|
|
599
|
+
segments.append(value)
|
|
600
|
+
|
|
601
|
+
# Explicit endpoint holds are based on source time, after the timeline has
|
|
602
|
+
# already been clamped for transition pre/post-roll.
|
|
603
|
+
expression = _float_expression(values[-1])
|
|
604
|
+
for index in reversed(range(len(segments))):
|
|
605
|
+
expression = (
|
|
606
|
+
f"if(lte(({source_time}),{_fraction_expression(points[index+1].time)}),"
|
|
607
|
+
f"{segments[index]},{expression})"
|
|
608
|
+
)
|
|
609
|
+
return (
|
|
610
|
+
f"if(lte(({source_time}),{_fraction_expression(points[0].time)}),"
|
|
611
|
+
f"{_float_expression(values[0])},{expression})"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _enforce_expression_size(
|
|
616
|
+
expression: str,
|
|
617
|
+
*,
|
|
618
|
+
limits: AnimationExpressionLimits,
|
|
619
|
+
component: str,
|
|
620
|
+
) -> None:
|
|
621
|
+
if len(expression) > limits.max_expression_chars:
|
|
622
|
+
raise AnimationExpressionLimitError(
|
|
623
|
+
f"{component} expression has {len(expression)} characters; limit is "
|
|
624
|
+
f"{limits.max_expression_chars}"
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def compile_scalar_expression(
|
|
629
|
+
track: TimelineAnimatedScalar,
|
|
630
|
+
*,
|
|
631
|
+
clock: AnimationClock = AnimationClock(),
|
|
632
|
+
time_variable: str = "t",
|
|
633
|
+
limits: AnimationExpressionLimits = AnimationExpressionLimits(),
|
|
634
|
+
) -> ScalarExpressionPlan:
|
|
635
|
+
"""Compile a scalar track without flattening its retime map.
|
|
636
|
+
|
|
637
|
+
Main callers:
|
|
638
|
+
- Geometry, opacity, and audio filter builders during central integration.
|
|
639
|
+
"""
|
|
640
|
+
|
|
641
|
+
if not isinstance(track, TimelineAnimatedScalar):
|
|
642
|
+
raise AnimationExecutionError("track must be TimelineAnimatedScalar")
|
|
643
|
+
if not isinstance(clock, AnimationClock):
|
|
644
|
+
raise AnimationClockError("clock must be AnimationClock")
|
|
645
|
+
if not isinstance(limits, AnimationExpressionLimits):
|
|
646
|
+
raise AnimationExpressionLimitError(
|
|
647
|
+
"limits must be AnimationExpressionLimits"
|
|
648
|
+
)
|
|
649
|
+
variable = _validate_time_variable(time_variable)
|
|
650
|
+
points = track.source_track.control_points
|
|
651
|
+
_check_track_bounds(
|
|
652
|
+
point_count=len(points),
|
|
653
|
+
segment_count=len(track.retime_map.segments),
|
|
654
|
+
limits=limits,
|
|
655
|
+
)
|
|
656
|
+
source_time = _source_time_expression(
|
|
657
|
+
track,
|
|
658
|
+
time_variable=variable,
|
|
659
|
+
clock=clock,
|
|
660
|
+
)
|
|
661
|
+
expression = _component_expression(
|
|
662
|
+
points=points,
|
|
663
|
+
values=tuple(point.value for point in points),
|
|
664
|
+
source_time=source_time,
|
|
665
|
+
)
|
|
666
|
+
_enforce_expression_size(expression, limits=limits, component="scalar")
|
|
667
|
+
return ScalarExpressionPlan(expression, track, clock, variable)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def compile_vec2_expressions(
|
|
671
|
+
track: TimelineAnimatedVec2,
|
|
672
|
+
*,
|
|
673
|
+
clock: AnimationClock = AnimationClock(),
|
|
674
|
+
time_variable: str = "t",
|
|
675
|
+
limits: AnimationExpressionLimits = AnimationExpressionLimits(),
|
|
676
|
+
) -> Vec2ExpressionPlan:
|
|
677
|
+
"""Compile each vector component against one shared exact retime map."""
|
|
678
|
+
|
|
679
|
+
if not isinstance(track, TimelineAnimatedVec2):
|
|
680
|
+
raise AnimationExecutionError("track must be TimelineAnimatedVec2")
|
|
681
|
+
if not isinstance(clock, AnimationClock):
|
|
682
|
+
raise AnimationClockError("clock must be AnimationClock")
|
|
683
|
+
if not isinstance(limits, AnimationExpressionLimits):
|
|
684
|
+
raise AnimationExpressionLimitError(
|
|
685
|
+
"limits must be AnimationExpressionLimits"
|
|
686
|
+
)
|
|
687
|
+
variable = _validate_time_variable(time_variable)
|
|
688
|
+
points = track.source_track.control_points
|
|
689
|
+
_check_track_bounds(
|
|
690
|
+
point_count=len(points),
|
|
691
|
+
segment_count=len(track.retime_map.segments),
|
|
692
|
+
limits=limits,
|
|
693
|
+
)
|
|
694
|
+
source_time = _source_time_expression(
|
|
695
|
+
track,
|
|
696
|
+
time_variable=variable,
|
|
697
|
+
clock=clock,
|
|
698
|
+
)
|
|
699
|
+
x_expression = _component_expression(
|
|
700
|
+
points=points,
|
|
701
|
+
values=tuple(point.value[0] for point in points),
|
|
702
|
+
source_time=source_time,
|
|
703
|
+
)
|
|
704
|
+
y_expression = _component_expression(
|
|
705
|
+
points=points,
|
|
706
|
+
values=tuple(point.value[1] for point in points),
|
|
707
|
+
source_time=source_time,
|
|
708
|
+
)
|
|
709
|
+
_enforce_expression_size(x_expression, limits=limits, component="vec2 x")
|
|
710
|
+
_enforce_expression_size(y_expression, limits=limits, component="vec2 y")
|
|
711
|
+
return Vec2ExpressionPlan(x_expression, y_expression, track, clock, variable)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _frame_times(
|
|
715
|
+
*,
|
|
716
|
+
window_start: Fraction,
|
|
717
|
+
frame_rate: Fraction,
|
|
718
|
+
frame_count: int,
|
|
719
|
+
first_frame: int,
|
|
720
|
+
) -> tuple[tuple[int, Fraction], ...]:
|
|
721
|
+
rate = _exact_time(frame_rate, name="frame_rate")
|
|
722
|
+
if rate <= 0:
|
|
723
|
+
raise AnimationClockError("frame_rate must be positive")
|
|
724
|
+
count = _positive_int(frame_count, name="frame_count")
|
|
725
|
+
if isinstance(first_frame, bool) or not isinstance(first_frame, int):
|
|
726
|
+
raise AnimationClockError("first_frame must be an integer")
|
|
727
|
+
if first_frame < 0:
|
|
728
|
+
raise AnimationClockError("first_frame must be non-negative")
|
|
729
|
+
return tuple(
|
|
730
|
+
(
|
|
731
|
+
frame_index,
|
|
732
|
+
window_start + Fraction(frame_index, 1) / rate,
|
|
733
|
+
)
|
|
734
|
+
for frame_index in range(first_frame, first_frame + count)
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def sample_scalar_frames(
|
|
739
|
+
plan: ScalarExpressionPlan,
|
|
740
|
+
*,
|
|
741
|
+
frame_rate: Fraction,
|
|
742
|
+
frame_count: int,
|
|
743
|
+
first_frame: int = 0,
|
|
744
|
+
) -> tuple[ScalarFrameCommand, ...]:
|
|
745
|
+
"""Build deterministic scalar commands at exact output-frame times."""
|
|
746
|
+
|
|
747
|
+
if not isinstance(plan, ScalarExpressionPlan):
|
|
748
|
+
raise AnimationExecutionError("plan must be ScalarExpressionPlan")
|
|
749
|
+
commands: list[ScalarFrameCommand] = []
|
|
750
|
+
for frame_index, input_time in _frame_times(
|
|
751
|
+
window_start=plan.input_window_start,
|
|
752
|
+
frame_rate=frame_rate,
|
|
753
|
+
frame_count=frame_count,
|
|
754
|
+
first_frame=first_frame,
|
|
755
|
+
):
|
|
756
|
+
state = plan.clock.state_at(input_time, plan.track.retime_map)
|
|
757
|
+
commands.append(
|
|
758
|
+
ScalarFrameCommand(
|
|
759
|
+
frame_index=frame_index,
|
|
760
|
+
input_time=input_time,
|
|
761
|
+
clip_time=state.clip_time,
|
|
762
|
+
track_time=state.track_time,
|
|
763
|
+
value=plan.track.source_track.value_at(state.source_time),
|
|
764
|
+
)
|
|
765
|
+
)
|
|
766
|
+
return tuple(commands)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def sample_vec2_frames(
|
|
770
|
+
plan: Vec2ExpressionPlan,
|
|
771
|
+
*,
|
|
772
|
+
frame_rate: Fraction,
|
|
773
|
+
frame_count: int,
|
|
774
|
+
first_frame: int = 0,
|
|
775
|
+
) -> tuple[Vec2FrameCommand, ...]:
|
|
776
|
+
"""Build deterministic vector commands at exact output-frame times."""
|
|
777
|
+
|
|
778
|
+
if not isinstance(plan, Vec2ExpressionPlan):
|
|
779
|
+
raise AnimationExecutionError("plan must be Vec2ExpressionPlan")
|
|
780
|
+
commands: list[Vec2FrameCommand] = []
|
|
781
|
+
for frame_index, input_time in _frame_times(
|
|
782
|
+
window_start=plan.input_window_start,
|
|
783
|
+
frame_rate=frame_rate,
|
|
784
|
+
frame_count=frame_count,
|
|
785
|
+
first_frame=first_frame,
|
|
786
|
+
):
|
|
787
|
+
state = plan.clock.state_at(input_time, plan.track.retime_map)
|
|
788
|
+
commands.append(
|
|
789
|
+
Vec2FrameCommand(
|
|
790
|
+
frame_index=frame_index,
|
|
791
|
+
input_time=input_time,
|
|
792
|
+
clip_time=state.clip_time,
|
|
793
|
+
track_time=state.track_time,
|
|
794
|
+
value=plan.track.source_track.value_at(state.source_time),
|
|
795
|
+
)
|
|
796
|
+
)
|
|
797
|
+
return tuple(commands)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
__all__ = [
|
|
801
|
+
"AnimationClock",
|
|
802
|
+
"AnimationClockError",
|
|
803
|
+
"AnimationExecutionError",
|
|
804
|
+
"AnimationExpressionLimitError",
|
|
805
|
+
"AnimationExpressionLimits",
|
|
806
|
+
"ClipTimeState",
|
|
807
|
+
"ClockPhase",
|
|
808
|
+
"ScalarExpressionPlan",
|
|
809
|
+
"ScalarFrameCommand",
|
|
810
|
+
"TimeOrigin",
|
|
811
|
+
"Vec2ExpressionPlan",
|
|
812
|
+
"Vec2FrameCommand",
|
|
813
|
+
"compile_scalar_expression",
|
|
814
|
+
"compile_vec2_expressions",
|
|
815
|
+
"sample_scalar_frames",
|
|
816
|
+
"sample_vec2_frames",
|
|
817
|
+
]
|