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,775 @@
|
|
|
1
|
+
"""FFmpeg expression parser + torch evaluator (plan task F0).
|
|
2
|
+
|
|
3
|
+
Architecture map
|
|
4
|
+
================
|
|
5
|
+
|
|
6
|
+
expression text (registry string, e.g. ``light_deco.BLOOM_EXPRESSION``)
|
|
7
|
+
-> parse() : libavutil/eval.c grammar -> frozen ``Expr`` tree (cached per text)
|
|
8
|
+
-> evaluate() : tree walk over an ``Environment`` (scalars, pixel grids, samplers)
|
|
9
|
+
* scalar-only sub-trees fold in Python doubles (ffmpeg precision)
|
|
10
|
+
* pixel-dependent sub-trees run as float32 tensor math on device
|
|
11
|
+
-> xfade_custom_rgba(): the ``vf_xfade.c`` custom-transition loop (all four planes at once
|
|
12
|
+
as a GBRA stack with ``PLANE`` = [4,1,1], nearest ``a0..a3/b0..b3``
|
|
13
|
+
samplers, uint8 store)
|
|
14
|
+
-> geq_rgba() : the ``vf_geq.c`` loop (per plane, bilinear ``r/g/b/alpha/p``
|
|
15
|
+
samplers, ``N``/``T`` frame variables, uint8 store)
|
|
16
|
+
|
|
17
|
+
Semantics matched against FFmpeg n8.0 sources (``libavutil/eval.c``, ``libavfilter/vf_xfade.c``,
|
|
18
|
+
``libavfilter/vf_geq.c``); the goldens in ``experimental_tests/core/test_tensor_expr.py`` and
|
|
19
|
+
``test_tensor_transition_golden.py`` run the same strings through the ``ffmpeg`` CLI.
|
|
20
|
+
|
|
21
|
+
Grammar (eval.c, whitespace stripped first)
|
|
22
|
+
-------------------------------------------
|
|
23
|
+
expr := subexpr (';' subexpr)* -- ';' evaluates both, returns the last
|
|
24
|
+
subexpr := term (('+' term) | term_starting_with_'-')* -- ``a-b`` is ``a + (-b)``
|
|
25
|
+
term := factor (('*'|'/') factor)*
|
|
26
|
+
factor := [sign] primary ('^' [sign] primary)* -- '^' is pow, left-assoc; ``-x^2 = -(x^2)``
|
|
27
|
+
primary := number | name | name '(' expr (',' expr){0,2} ')' | '(' expr ')'
|
|
28
|
+
number := decimal/exponent/hex per ``av_strtod`` (SI/dB postfixes are rejected loudly)
|
|
29
|
+
|
|
30
|
+
Every comparison returns 1.0/0.0, ``if(c,a)`` returns 0 when ``c`` is false, ``mod`` is the
|
|
31
|
+
*floored* modulo ``d - floor(d/d2)*d2`` (not fmod), ``clip`` returns NaN when ``min > max``,
|
|
32
|
+
``between`` is inclusive, division by zero follows C (``x/0 -> +-inf``, ``0/0 -> NaN``),
|
|
33
|
+
``round`` is half-away-from-zero, ``*`` doubles as boolean AND. Unsupported eval.c
|
|
34
|
+
features (``st/ld/while/taylor/root/random/randomi/print/time/bitand/bitor/gcd``) raise
|
|
35
|
+
``ExpressionError`` at parse time -- no registry expression uses them (grep 2026-08-17).
|
|
36
|
+
|
|
37
|
+
Value / dtype policy
|
|
38
|
+
--------------------
|
|
39
|
+
``evaluate`` returns a Python ``float`` for sub-trees that depend on scalars only
|
|
40
|
+
(``P, W, H, PLANE, N, T, t, PI`` ...) and a tensor once ``X, Y, A, B`` or a sampler enters.
|
|
41
|
+
Scalar folding happens in float64 exactly like ffmpeg (numpy/libm), which removes most float32
|
|
42
|
+
drift from the time envelopes (``sin(PI*(1-P))``, ``pow(...)``); pixel math runs in the input
|
|
43
|
+
tensors' dtype (float32 on MPS -- float64 is unavailable there). Measured on the goldens:
|
|
44
|
+
float32 pixel math changes ~1e-4 of the stored uint8 values by +-1 (truncation-boundary flips);
|
|
45
|
+
float64 on CPU is bit-exact -- its transcendentals (``sin/cos/tan/asin/acos/atan/exp/log/
|
|
46
|
+
sinh/cosh/tanh/pow/atan2/hypot``) route through numpy = libm (``_libm_unary`` / ``_libm_binary``)
|
|
47
|
+
because torch's vectorized float64 kernels differ from the C library in the last ulp for 5-15%
|
|
48
|
+
of arguments, and knife-edge registry expressions are decided there (Clock's ``0.001`` solid
|
|
49
|
+
edge, the 360° Circle Wipe ties). ``if`` with a tensor condition evaluates both branches and
|
|
50
|
+
selects with ``torch.where`` (the untaken branch's NaN/inf never propagates); ``if`` with a
|
|
51
|
+
scalar condition is lazy (only the taken branch is evaluated), which is what makes the
|
|
52
|
+
registry endpoint guards ``if(gte(P,1),A,...)`` free.
|
|
53
|
+
|
|
54
|
+
uint8 store: C ``dst[x] = (uint8_t)double`` truncates toward zero and is undefined out of
|
|
55
|
+
range. ``quantize_uint8`` truncates, maps NaN to 0 and clamps to [0, 255] -- registry
|
|
56
|
+
expressions bound themselves with ``min(255, ...)`` so the clamp is only a safety net; a
|
|
57
|
+
golden mismatch (not a silent wrap) is what an unbounded expression produces here.
|
|
58
|
+
|
|
59
|
+
Main callers:
|
|
60
|
+
- ``tensor/transitions.py`` (``xfade_custom`` -> ``xfade_custom_rgba``).
|
|
61
|
+
- Effect batches (E1/E2, ``geq_rgba``) once ``geq`` effects are lowered.
|
|
62
|
+
- ``experimental_tests/core/test_tensor_expr.py``, ``test_tensor_transition_golden.py``.
|
|
63
|
+
|
|
64
|
+
Why this exists:
|
|
65
|
+
35 of the 52 approved transitions and ~10 effects are one fixed ffmpeg expression over
|
|
66
|
+
``A,B,X,Y,W,H,P,PLANE`` (``plan_research/fx_inventory.md`` §0). One evaluator ports them
|
|
67
|
+
mechanically from the registry strings themselves, so a port can never drift from the CPU
|
|
68
|
+
reference's expression text; F1/F2 batches append ids, they do not extend this parser.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
from __future__ import annotations
|
|
72
|
+
|
|
73
|
+
import math
|
|
74
|
+
import re
|
|
75
|
+
from dataclasses import dataclass
|
|
76
|
+
from functools import lru_cache
|
|
77
|
+
from typing import Callable, Mapping, Sequence, Union
|
|
78
|
+
|
|
79
|
+
import numpy as np
|
|
80
|
+
import torch
|
|
81
|
+
|
|
82
|
+
Value = Union[float, torch.Tensor]
|
|
83
|
+
Sampler = Callable[[Value, Value], torch.Tensor]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ExpressionError(ValueError):
|
|
87
|
+
"""The expression text is outside the supported eval.c grammar (raised at parse time)."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --------------------------------------------------------------------------- tree
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Number:
|
|
95
|
+
value: float
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Name:
|
|
100
|
+
name: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class Call:
|
|
105
|
+
name: str
|
|
106
|
+
args: tuple["Node", ...]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class Binary:
|
|
111
|
+
op: str # '+', '*', '/', '^', ';'
|
|
112
|
+
left: "Node"
|
|
113
|
+
right: "Node"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class Negate:
|
|
118
|
+
operand: "Node"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
Node = Union[Number, Name, Call, Binary, Negate]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class Expr:
|
|
126
|
+
"""A parsed expression: the tree plus the names it references."""
|
|
127
|
+
|
|
128
|
+
text: str
|
|
129
|
+
root: Node
|
|
130
|
+
names: frozenset[str]
|
|
131
|
+
functions: frozenset[str]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# builtin eval.c constants (looked up after the environment's own names, like ffmpeg)
|
|
135
|
+
CONSTANTS: Mapping[str, float] = {"PI": math.pi, "E": math.e, "PHI": (1.0 + 5.0 ** 0.5) / 2.0}
|
|
136
|
+
|
|
137
|
+
# function -> allowed argument counts
|
|
138
|
+
_ARITY: Mapping[str, tuple[int, ...]] = {
|
|
139
|
+
# eval.c one-argument libm functions
|
|
140
|
+
"sinh": (1,), "cosh": (1,), "tanh": (1,), "sin": (1,), "cos": (1,), "tan": (1,),
|
|
141
|
+
"atan": (1,), "asin": (1,), "acos": (1,), "exp": (1,), "log": (1,), "abs": (1,),
|
|
142
|
+
# eval.c typed operators
|
|
143
|
+
"squish": (1,), "gauss": (1,), "isnan": (1,), "isinf": (1,), "floor": (1,), "ceil": (1,),
|
|
144
|
+
"trunc": (1,), "round": (1,), "sgn": (1,), "sqrt": (1,), "not": (1,),
|
|
145
|
+
"mod": (2,), "max": (2,), "min": (2,), "eq": (2,), "gte": (2,), "gt": (2,), "lte": (2,),
|
|
146
|
+
"lt": (2,), "pow": (2,), "hypot": (2,), "atan2": (2,),
|
|
147
|
+
"if": (2, 3), "ifnot": (2, 3), "between": (3,), "clip": (3,), "lerp": (3,),
|
|
148
|
+
}
|
|
149
|
+
_UNSUPPORTED = frozenset(
|
|
150
|
+
{"st", "ld", "while", "taylor", "root", "random", "randomi", "print", "time",
|
|
151
|
+
"bitand", "bitor", "gcd"}
|
|
152
|
+
)
|
|
153
|
+
# two-argument sampler functions supplied by the environment (vf_xfade / vf_geq)
|
|
154
|
+
SAMPLER_NAMES = frozenset(
|
|
155
|
+
{"a0", "a1", "a2", "a3", "b0", "b1", "b2", "b3", "r", "g", "b", "alpha", "p"}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
_NUMBER = re.compile(r"(?:0[xX][0-9a-fA-F]+|(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)")
|
|
159
|
+
_IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _Parser:
|
|
163
|
+
"""Recursive descent over eval.c's grammar (one instance per parse call)."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, text: str) -> None:
|
|
166
|
+
self.text = text
|
|
167
|
+
self.pos = 0
|
|
168
|
+
self.names: set[str] = set()
|
|
169
|
+
self.functions: set[str] = set()
|
|
170
|
+
|
|
171
|
+
# -- helpers ------------------------------------------------------------
|
|
172
|
+
def peek(self) -> str:
|
|
173
|
+
return self.text[self.pos] if self.pos < len(self.text) else ""
|
|
174
|
+
|
|
175
|
+
def fail(self, message: str) -> ExpressionError:
|
|
176
|
+
return ExpressionError(f"{message} at offset {self.pos} in {self.text!r}")
|
|
177
|
+
|
|
178
|
+
# -- grammar ------------------------------------------------------------
|
|
179
|
+
def parse_expr(self) -> Node:
|
|
180
|
+
node = self.parse_subexpr()
|
|
181
|
+
while self.peek() == ";":
|
|
182
|
+
self.pos += 1
|
|
183
|
+
node = Binary(";", node, self.parse_subexpr())
|
|
184
|
+
return node
|
|
185
|
+
|
|
186
|
+
def parse_subexpr(self) -> Node:
|
|
187
|
+
node = self.parse_term()
|
|
188
|
+
while self.peek() in ("+", "-"):
|
|
189
|
+
if self.peek() == "+":
|
|
190
|
+
self.pos += 1
|
|
191
|
+
# '-' stays: the next factor consumes it as its sign (a-b == a+(-b)).
|
|
192
|
+
node = Binary("+", node, self.parse_term())
|
|
193
|
+
return node
|
|
194
|
+
|
|
195
|
+
def parse_term(self) -> Node:
|
|
196
|
+
node = self.parse_factor()
|
|
197
|
+
while self.peek() in ("*", "/"):
|
|
198
|
+
op = self.peek()
|
|
199
|
+
self.pos += 1
|
|
200
|
+
node = Binary(op, node, self.parse_factor())
|
|
201
|
+
return node
|
|
202
|
+
|
|
203
|
+
def parse_sign(self) -> int:
|
|
204
|
+
char = self.peek()
|
|
205
|
+
if char == "+":
|
|
206
|
+
self.pos += 1
|
|
207
|
+
return 1
|
|
208
|
+
if char == "-":
|
|
209
|
+
self.pos += 1
|
|
210
|
+
return -1
|
|
211
|
+
return 1
|
|
212
|
+
|
|
213
|
+
def parse_factor(self) -> Node:
|
|
214
|
+
sign = self.parse_sign()
|
|
215
|
+
node = self.parse_primary()
|
|
216
|
+
while self.peek() == "^":
|
|
217
|
+
self.pos += 1
|
|
218
|
+
sign2 = self.parse_sign()
|
|
219
|
+
exponent = self.parse_primary()
|
|
220
|
+
if sign2 < 0:
|
|
221
|
+
exponent = Negate(exponent)
|
|
222
|
+
node = Binary("^", node, exponent)
|
|
223
|
+
return Negate(node) if sign < 0 else node
|
|
224
|
+
|
|
225
|
+
def parse_primary(self) -> Node:
|
|
226
|
+
if self.pos >= len(self.text):
|
|
227
|
+
raise self.fail("unexpected end of expression")
|
|
228
|
+
match = _NUMBER.match(self.text, self.pos)
|
|
229
|
+
if match:
|
|
230
|
+
self.pos = match.end()
|
|
231
|
+
if self.peek() and (self.peek().isalnum() or self.peek() == "_"):
|
|
232
|
+
raise self.fail(f"number {match.group(0)!r} followed by an unsupported postfix")
|
|
233
|
+
literal = match.group(0)
|
|
234
|
+
value = float(int(literal, 16)) if literal[:2].lower() == "0x" else float(literal)
|
|
235
|
+
return Number(value)
|
|
236
|
+
if self.peek() == "(":
|
|
237
|
+
self.pos += 1
|
|
238
|
+
node = self.parse_expr()
|
|
239
|
+
if self.peek() != ")":
|
|
240
|
+
raise self.fail("missing ')'")
|
|
241
|
+
self.pos += 1
|
|
242
|
+
return node
|
|
243
|
+
match = _IDENT.match(self.text, self.pos)
|
|
244
|
+
if not match:
|
|
245
|
+
raise self.fail(f"unexpected character {self.peek()!r}")
|
|
246
|
+
name = match.group(0)
|
|
247
|
+
self.pos = match.end()
|
|
248
|
+
if self.peek() != "(":
|
|
249
|
+
self.names.add(name)
|
|
250
|
+
return Name(name)
|
|
251
|
+
# function call: 1..3 comma-separated expressions
|
|
252
|
+
self.pos += 1
|
|
253
|
+
args = [self.parse_expr()]
|
|
254
|
+
while self.peek() == ",":
|
|
255
|
+
self.pos += 1
|
|
256
|
+
args.append(self.parse_expr())
|
|
257
|
+
if self.peek() != ")":
|
|
258
|
+
raise self.fail(f"missing ')' after arguments of {name}")
|
|
259
|
+
self.pos += 1
|
|
260
|
+
if name in _UNSUPPORTED:
|
|
261
|
+
raise self.fail(f"eval.c function {name!r} is not supported by the tensor evaluator")
|
|
262
|
+
if name in SAMPLER_NAMES:
|
|
263
|
+
if len(args) != 2:
|
|
264
|
+
raise self.fail(f"sampler {name} takes (x, y), got {len(args)} arguments")
|
|
265
|
+
elif name in _ARITY:
|
|
266
|
+
if len(args) not in _ARITY[name]:
|
|
267
|
+
raise self.fail(f"{name} takes {_ARITY[name]} arguments, got {len(args)}")
|
|
268
|
+
else:
|
|
269
|
+
raise self.fail(f"unknown function {name!r}")
|
|
270
|
+
self.functions.add(name)
|
|
271
|
+
return Call(name, tuple(args))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@lru_cache(maxsize=256)
|
|
275
|
+
def parse(text: str) -> Expr:
|
|
276
|
+
"""Parse ``text`` (cached) into an ``Expr``; raises ``ExpressionError`` on any unsupported syntax."""
|
|
277
|
+
|
|
278
|
+
stripped = "".join(ch for ch in text if not ch.isspace())
|
|
279
|
+
if not stripped:
|
|
280
|
+
raise ExpressionError("empty expression")
|
|
281
|
+
parser = _Parser(stripped)
|
|
282
|
+
root = parser.parse_expr()
|
|
283
|
+
if parser.pos != len(stripped):
|
|
284
|
+
raise parser.fail("trailing characters")
|
|
285
|
+
return Expr(text=text, root=root, names=frozenset(parser.names), functions=frozenset(parser.functions))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# --------------------------------------------------------------------------- environment
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class Environment:
|
|
293
|
+
"""What an expression can see: named scalars/planes, sampler functions, and the tensor factory.
|
|
294
|
+
|
|
295
|
+
``variables`` map names to floats or tensors (``X``/``Y`` grids, ``A``/``B`` planes);
|
|
296
|
+
``samplers`` map ``a0..a3/b0..b3/r/g/b/alpha/p`` to ``f(x, y) -> Tensor``. ``device``
|
|
297
|
+
and ``dtype`` materialize scalars when a tensor is required (``if`` on a tensor condition).
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
variables: Mapping[str, Value]
|
|
301
|
+
samplers: Mapping[str, Sampler]
|
|
302
|
+
device: torch.device
|
|
303
|
+
dtype: torch.dtype
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _is_tensor(value: Value) -> bool:
|
|
307
|
+
return isinstance(value, torch.Tensor)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _tensor(value: Value, env: Environment) -> torch.Tensor:
|
|
311
|
+
if isinstance(value, torch.Tensor):
|
|
312
|
+
return value
|
|
313
|
+
return torch.tensor(float(value), device=env.device, dtype=env.dtype)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _bool(flag: Union[bool, torch.Tensor], env: Environment) -> Value:
|
|
317
|
+
if isinstance(flag, torch.Tensor):
|
|
318
|
+
return flag.to(env.dtype)
|
|
319
|
+
return 1.0 if flag else 0.0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _scalar_div(a: float, b: float) -> float:
|
|
323
|
+
if b == 0.0:
|
|
324
|
+
return float(a * math.inf) if a == a else math.nan # C: d * INFINITY (0*inf = NaN)
|
|
325
|
+
return a / b
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _div(a: Value, b: Value) -> Value:
|
|
329
|
+
if not _is_tensor(a) and not _is_tensor(b):
|
|
330
|
+
return _scalar_div(a, b)
|
|
331
|
+
return a / b # torch: x/0 -> +-inf, 0/0 -> NaN, like C
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _np1(fn: Callable[[np.ndarray], np.ndarray], x: float) -> float:
|
|
335
|
+
with np.errstate(all="ignore"):
|
|
336
|
+
return float(fn(np.float64(x)))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _np2(fn: Callable[[np.ndarray, np.ndarray], np.ndarray], x: float, y: float) -> float:
|
|
340
|
+
with np.errstate(all="ignore"):
|
|
341
|
+
return float(fn(np.float64(x), np.float64(y)))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _cmp(op: str, a: Value, b: Value, env: Environment) -> Value:
|
|
345
|
+
if not _is_tensor(a) and not _is_tensor(b):
|
|
346
|
+
return _bool({"lt": a < b, "lte": a <= b, "gt": a > b, "gte": a >= b, "eq": a == b}[op], env)
|
|
347
|
+
ta, tb = _tensor(a, env), _tensor(b, env)
|
|
348
|
+
return _bool({"lt": ta < tb, "lte": ta <= tb, "gt": ta > tb, "gte": ta >= tb, "eq": ta == tb}[op], env)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _select(cond: Value, then_value: Value, else_value: Value, env: Environment) -> Value:
|
|
352
|
+
if not _is_tensor(cond):
|
|
353
|
+
return then_value if bool(cond) else else_value # NaN is truthy, as in C
|
|
354
|
+
return torch.where(cond != 0, _tensor(then_value, env), _tensor(else_value, env))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
_UNARY_SCALAR: Mapping[str, Callable[[float], float]] = {
|
|
358
|
+
"sinh": lambda x: _np1(np.sinh, x), "cosh": lambda x: _np1(np.cosh, x),
|
|
359
|
+
"tanh": lambda x: _np1(np.tanh, x), "sin": lambda x: _np1(np.sin, x),
|
|
360
|
+
"cos": lambda x: _np1(np.cos, x), "tan": lambda x: _np1(np.tan, x),
|
|
361
|
+
"atan": lambda x: _np1(np.arctan, x), "asin": lambda x: _np1(np.arcsin, x),
|
|
362
|
+
"acos": lambda x: _np1(np.arccos, x), "exp": lambda x: _np1(np.exp, x),
|
|
363
|
+
"log": lambda x: _np1(np.log, x), "abs": lambda x: abs(x),
|
|
364
|
+
"sqrt": lambda x: _np1(np.sqrt, x), "floor": lambda x: _np1(np.floor, x),
|
|
365
|
+
"ceil": lambda x: _np1(np.ceil, x), "trunc": lambda x: _np1(np.trunc, x),
|
|
366
|
+
"round": lambda x: _np1(lambda v: np.sign(v) * np.floor(np.abs(v) + 0.5), x),
|
|
367
|
+
"squish": lambda x: _np1(lambda v: 1.0 / (1.0 + np.exp(4.0 * v)), x),
|
|
368
|
+
"gauss": lambda x: _np1(lambda v: np.exp(-v * v / 2.0) / np.sqrt(2.0 * np.pi), x),
|
|
369
|
+
"sgn": lambda x: float((x > 0) - (x < 0)),
|
|
370
|
+
"not": lambda x: 1.0 if x == 0 else 0.0,
|
|
371
|
+
"isnan": lambda x: 1.0 if x != x else 0.0,
|
|
372
|
+
"isinf": lambda x: 1.0 if x in (math.inf, -math.inf) else 0.0,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _libm_unary(
|
|
377
|
+
torch_fn: Callable[[torch.Tensor], torch.Tensor],
|
|
378
|
+
numpy_fn: Callable[[np.ndarray], np.ndarray],
|
|
379
|
+
) -> Callable[[torch.Tensor], torch.Tensor]:
|
|
380
|
+
"""A tensor transcendental that is libm-faithful on CPU float64 (see ``_libm_binary``)."""
|
|
381
|
+
|
|
382
|
+
def apply(value: torch.Tensor) -> torch.Tensor:
|
|
383
|
+
if value.dtype == torch.float64 and value.device.type == "cpu":
|
|
384
|
+
with np.errstate(all="ignore"):
|
|
385
|
+
return torch.from_numpy(numpy_fn(value.numpy()))
|
|
386
|
+
return torch_fn(value)
|
|
387
|
+
|
|
388
|
+
return apply
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _libm_binary(
|
|
392
|
+
torch_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor],
|
|
393
|
+
numpy_fn: Callable[[np.ndarray, np.ndarray], np.ndarray],
|
|
394
|
+
) -> Callable[[torch.Tensor, torch.Tensor], torch.Tensor]:
|
|
395
|
+
"""A two-argument tensor transcendental that is libm-faithful on CPU float64.
|
|
396
|
+
|
|
397
|
+
Why this exists: torch's CPU float64 ``sin``/``cos``/``exp``/``atan2``/``pow``/... are
|
|
398
|
+
vectorized (SLEEF-style) and differ from the C library in the last ulp for ~5-15% of
|
|
399
|
+
arguments; numpy's float64 loops call libm and are bit-identical to ``math.*`` (measured
|
|
400
|
+
on macOS arm64). ffmpeg's ``eval.c`` calls libm, so the CPU float64 path -- the goldens'
|
|
401
|
+
semantics proof and the runtime for float64-only expressions -- routes through numpy.
|
|
402
|
+
Knife-edge registry expressions (Clock's ``0.001`` solid edge, the 360° Circle Wipe's
|
|
403
|
+
great-circle ``lte`` on exact pixel ties) are decided by that last ulp. Float32 / MPS
|
|
404
|
+
evaluation is untouched.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
def apply(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
|
|
408
|
+
if a.dtype == torch.float64 and b.dtype == torch.float64 and a.device.type == "cpu" and b.device.type == "cpu":
|
|
409
|
+
with np.errstate(all="ignore"):
|
|
410
|
+
return torch.from_numpy(numpy_fn(a.numpy(), b.numpy()))
|
|
411
|
+
return torch_fn(a, b)
|
|
412
|
+
|
|
413
|
+
return apply
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
_TENSOR_POW = _libm_binary(torch.pow, np.power)
|
|
417
|
+
_TENSOR_ATAN2 = _libm_binary(torch.atan2, np.arctan2)
|
|
418
|
+
_TENSOR_HYPOT = _libm_binary(torch.hypot, np.hypot)
|
|
419
|
+
|
|
420
|
+
_UNARY_TENSOR: Mapping[str, Callable[[torch.Tensor], torch.Tensor]] = {
|
|
421
|
+
"sinh": _libm_unary(torch.sinh, np.sinh), "cosh": _libm_unary(torch.cosh, np.cosh),
|
|
422
|
+
"tanh": _libm_unary(torch.tanh, np.tanh), "sin": _libm_unary(torch.sin, np.sin),
|
|
423
|
+
"cos": _libm_unary(torch.cos, np.cos), "tan": _libm_unary(torch.tan, np.tan),
|
|
424
|
+
"atan": _libm_unary(torch.atan, np.arctan), "asin": _libm_unary(torch.asin, np.arcsin),
|
|
425
|
+
"acos": _libm_unary(torch.acos, np.arccos), "exp": _libm_unary(torch.exp, np.exp),
|
|
426
|
+
"log": _libm_unary(torch.log, np.log), "abs": torch.abs,
|
|
427
|
+
"sqrt": torch.sqrt, "floor": torch.floor, "ceil": torch.ceil, "trunc": torch.trunc,
|
|
428
|
+
"round": lambda v: torch.sign(v) * torch.floor(torch.abs(v) + 0.5), # C round(), not banker's
|
|
429
|
+
"squish": lambda v: 1.0 / (1.0 + torch.exp(4.0 * v)),
|
|
430
|
+
"gauss": lambda v: torch.exp(-v * v / 2.0) / math.sqrt(2.0 * math.pi),
|
|
431
|
+
"sgn": lambda v: torch.sign(v),
|
|
432
|
+
"not": lambda v: (v == 0).to(v.dtype),
|
|
433
|
+
"isnan": lambda v: torch.isnan(v).to(v.dtype),
|
|
434
|
+
"isinf": lambda v: torch.isinf(v).to(v.dtype),
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _pow(a: Value, b: Value, env: Environment) -> Value:
|
|
439
|
+
if not _is_tensor(a) and not _is_tensor(b):
|
|
440
|
+
return _np2(np.power, a, b)
|
|
441
|
+
return _TENSOR_POW(_tensor(a, env), _tensor(b, env))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _call(name: str, args: Sequence[Value], env: Environment) -> Value:
|
|
445
|
+
if name in _UNARY_SCALAR:
|
|
446
|
+
(x,) = args
|
|
447
|
+
return _UNARY_SCALAR[name](x) if not _is_tensor(x) else _UNARY_TENSOR[name](x)
|
|
448
|
+
if name in ("lt", "lte", "gt", "gte", "eq"):
|
|
449
|
+
return _cmp(name, args[0], args[1], env)
|
|
450
|
+
if name == "pow":
|
|
451
|
+
return _pow(args[0], args[1], env)
|
|
452
|
+
if name in ("max", "min"):
|
|
453
|
+
a, b = args
|
|
454
|
+
# eval.c: d > d2 ? d : d2 (and d < d2 ? d : d2); NaN falls through to d2.
|
|
455
|
+
if not _is_tensor(a) and not _is_tensor(b):
|
|
456
|
+
return a if (a > b if name == "max" else a < b) else b
|
|
457
|
+
ta, tb = _tensor(a, env), _tensor(b, env)
|
|
458
|
+
return torch.where(ta > tb if name == "max" else ta < tb, ta, tb)
|
|
459
|
+
if name == "mod":
|
|
460
|
+
a, b = args
|
|
461
|
+
quotient = _div(a, b)
|
|
462
|
+
floored = _UNARY_SCALAR["floor"](quotient) if not _is_tensor(quotient) else torch.floor(quotient)
|
|
463
|
+
return a - floored * b
|
|
464
|
+
if name == "hypot":
|
|
465
|
+
a, b = args
|
|
466
|
+
if not _is_tensor(a) and not _is_tensor(b):
|
|
467
|
+
return _np2(np.hypot, a, b)
|
|
468
|
+
return _TENSOR_HYPOT(_tensor(a, env), _tensor(b, env))
|
|
469
|
+
if name == "atan2":
|
|
470
|
+
a, b = args
|
|
471
|
+
if not _is_tensor(a) and not _is_tensor(b):
|
|
472
|
+
return _np2(np.arctan2, a, b)
|
|
473
|
+
return _TENSOR_ATAN2(_tensor(a, env), _tensor(b, env))
|
|
474
|
+
if name == "between":
|
|
475
|
+
x, lo, hi = args
|
|
476
|
+
if not any(_is_tensor(v) for v in args):
|
|
477
|
+
return _bool(x >= lo and x <= hi, env)
|
|
478
|
+
tx = _tensor(x, env)
|
|
479
|
+
return _bool((tx >= _tensor(lo, env)) & (tx <= _tensor(hi, env)), env)
|
|
480
|
+
if name == "clip":
|
|
481
|
+
x, lo, hi = args
|
|
482
|
+
if not any(_is_tensor(v) for v in args):
|
|
483
|
+
if x != x or lo != lo or hi != hi or lo > hi:
|
|
484
|
+
return math.nan
|
|
485
|
+
return lo if x < lo else hi if x > hi else x
|
|
486
|
+
tx = _tensor(x, env)
|
|
487
|
+
clipped = torch.minimum(torch.maximum(tx, _tensor(lo, env)), _tensor(hi, env))
|
|
488
|
+
# av_clipd leaves NaN x as NaN (maximum/minimum propagate NaN too); min > max -> NaN.
|
|
489
|
+
if _is_tensor(lo) or _is_tensor(hi):
|
|
490
|
+
bad = _tensor(lo, env) > _tensor(hi, env)
|
|
491
|
+
return torch.where(bad, torch.full_like(clipped, math.nan), clipped)
|
|
492
|
+
if lo != lo or hi != hi or lo > hi:
|
|
493
|
+
return torch.full_like(clipped, math.nan)
|
|
494
|
+
return clipped
|
|
495
|
+
if name == "lerp":
|
|
496
|
+
v0, v1, f = args
|
|
497
|
+
return v0 + (v1 - v0) * f
|
|
498
|
+
raise AssertionError(f"unhandled function {name}") # parse() validated the name
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _evaluate(node: Node, env: Environment) -> Value:
|
|
502
|
+
if isinstance(node, Number):
|
|
503
|
+
return node.value
|
|
504
|
+
if isinstance(node, Name):
|
|
505
|
+
if node.name in env.variables:
|
|
506
|
+
return env.variables[node.name]
|
|
507
|
+
if node.name in CONSTANTS:
|
|
508
|
+
return CONSTANTS[node.name]
|
|
509
|
+
raise ExpressionError(f"unknown name {node.name!r} (environment has {sorted(env.variables)})")
|
|
510
|
+
if isinstance(node, Negate):
|
|
511
|
+
return -_evaluate(node.operand, env)
|
|
512
|
+
if isinstance(node, Binary):
|
|
513
|
+
left = _evaluate(node.left, env)
|
|
514
|
+
if node.op == ";":
|
|
515
|
+
return _evaluate(node.right, env)
|
|
516
|
+
right = _evaluate(node.right, env)
|
|
517
|
+
if node.op == "+":
|
|
518
|
+
return left + right
|
|
519
|
+
if node.op == "*":
|
|
520
|
+
return left * right
|
|
521
|
+
if node.op == "/":
|
|
522
|
+
return _div(left, right)
|
|
523
|
+
if node.op == "^":
|
|
524
|
+
return _pow(left, right, env)
|
|
525
|
+
raise AssertionError(node.op)
|
|
526
|
+
# Call
|
|
527
|
+
name = node.name
|
|
528
|
+
if name in ("if", "ifnot"):
|
|
529
|
+
cond = _evaluate(node.args[0], env)
|
|
530
|
+
truthy = (name == "if")
|
|
531
|
+
if not _is_tensor(cond):
|
|
532
|
+
taken = bool(cond) == truthy
|
|
533
|
+
if taken:
|
|
534
|
+
return _evaluate(node.args[1], env)
|
|
535
|
+
return _evaluate(node.args[2], env) if len(node.args) == 3 else 0.0
|
|
536
|
+
then_value = _evaluate(node.args[1], env)
|
|
537
|
+
else_value = _evaluate(node.args[2], env) if len(node.args) == 3 else 0.0
|
|
538
|
+
if not truthy:
|
|
539
|
+
then_value, else_value = else_value, then_value
|
|
540
|
+
return _select(cond, then_value, else_value, env)
|
|
541
|
+
if name in SAMPLER_NAMES:
|
|
542
|
+
sampler = env.samplers.get(name)
|
|
543
|
+
if sampler is None:
|
|
544
|
+
raise ExpressionError(f"sampler {name!r} is not available in this environment")
|
|
545
|
+
return sampler(_evaluate(node.args[0], env), _evaluate(node.args[1], env))
|
|
546
|
+
return _call(name, [_evaluate(arg, env) for arg in node.args], env)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def evaluate(expr: Expr, env: Environment) -> Value:
|
|
550
|
+
"""Evaluate ``expr`` in ``env``: a float when nothing pixel-dependent is touched, else a tensor."""
|
|
551
|
+
|
|
552
|
+
return _evaluate(expr.root, env)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def evaluate_image(expr: Expr, env: Environment, shape: tuple[int, ...]) -> torch.Tensor:
|
|
556
|
+
"""``evaluate`` broadcast to a full tensor of ``shape`` (``[H, W]`` or ``[planes, H, W]``) in ``env.dtype``."""
|
|
557
|
+
|
|
558
|
+
value = evaluate(expr, env)
|
|
559
|
+
if _is_tensor(value):
|
|
560
|
+
return value.to(env.dtype).expand(shape) if value.shape != torch.Size(shape) else value.to(env.dtype)
|
|
561
|
+
return torch.full(shape, float(value), device=env.device, dtype=env.dtype)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# --------------------------------------------------------------------------- grids + samplers
|
|
565
|
+
|
|
566
|
+
_GRIDS: dict[tuple[int, int, str, torch.dtype], tuple[torch.Tensor, torch.Tensor]] = {}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def pixel_grid(height: int, width: int, *, device: torch.device, dtype: torch.dtype) -> tuple[torch.Tensor, torch.Tensor]:
|
|
570
|
+
"""``(X, Y)`` integer pixel-index grids as ``[H, W]`` tensors (cached per size/device/dtype)."""
|
|
571
|
+
|
|
572
|
+
key = (height, width, str(device), dtype)
|
|
573
|
+
grids = _GRIDS.get(key)
|
|
574
|
+
if grids is None:
|
|
575
|
+
ys = torch.arange(height, device=device, dtype=dtype)
|
|
576
|
+
xs = torch.arange(width, device=device, dtype=dtype)
|
|
577
|
+
grid_y, grid_x = torch.meshgrid(ys, xs, indexing="ij")
|
|
578
|
+
grids = (grid_x.contiguous(), grid_y.contiguous())
|
|
579
|
+
_GRIDS[key] = grids
|
|
580
|
+
return grids
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _index_pair(x: Value, y: Value, plane: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
584
|
+
"""Sampler coordinates as tensors of the plane's dtype/device (scalars become 0-d tensors)."""
|
|
585
|
+
|
|
586
|
+
tx = x if _is_tensor(x) else torch.tensor(float(x), device=plane.device, dtype=plane.dtype)
|
|
587
|
+
ty = y if _is_tensor(y) else torch.tensor(float(y), device=plane.device, dtype=plane.dtype)
|
|
588
|
+
return tx.to(plane.dtype), ty.to(plane.dtype)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def nearest_sampler(plane: torch.Tensor) -> Sampler:
|
|
592
|
+
"""``vf_xfade.c`` ``getpix``: ``src[(int)av_clipd(x,0,w-1) + (int)av_clipd(y,0,h-1)*linesize]``."""
|
|
593
|
+
|
|
594
|
+
height, width = plane.shape
|
|
595
|
+
flat = plane.reshape(-1)
|
|
596
|
+
|
|
597
|
+
def sample(x: Value, y: Value) -> torch.Tensor:
|
|
598
|
+
tx, ty = _index_pair(x, y, plane)
|
|
599
|
+
xi = tx.clamp(0, width - 1).trunc().long()
|
|
600
|
+
yi = ty.clamp(0, height - 1).trunc().long()
|
|
601
|
+
xi, yi = torch.broadcast_tensors(xi, yi)
|
|
602
|
+
return flat[yi * width + xi]
|
|
603
|
+
|
|
604
|
+
return sample
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def bilinear_sampler(plane: torch.Tensor) -> Sampler:
|
|
608
|
+
"""``vf_geq.c`` ``getpix`` (default ``interpolation=bilinear``): clamp to ``[0, w-1]``,
|
|
609
|
+
``xi = (int)x``, ``xn = min(xi+1, w-1)``, weights ``x - xi``."""
|
|
610
|
+
|
|
611
|
+
height, width = plane.shape
|
|
612
|
+
flat = plane.reshape(-1)
|
|
613
|
+
|
|
614
|
+
def sample(x: Value, y: Value) -> torch.Tensor:
|
|
615
|
+
tx, ty = _index_pair(x, y, plane)
|
|
616
|
+
cx = tx.clamp(0, width - 1)
|
|
617
|
+
cy = ty.clamp(0, height - 1)
|
|
618
|
+
xi = cx.trunc()
|
|
619
|
+
yi = cy.trunc()
|
|
620
|
+
fx = cx - xi
|
|
621
|
+
fy = cy - yi
|
|
622
|
+
xi_l = xi.long()
|
|
623
|
+
yi_l = yi.long()
|
|
624
|
+
xn = torch.clamp(xi_l + 1, max=width - 1)
|
|
625
|
+
yn = torch.clamp(yi_l + 1, max=height - 1)
|
|
626
|
+
xi_l, yi_l, xn, yn, fx, fy = torch.broadcast_tensors(xi_l, yi_l, xn, yn, fx, fy)
|
|
627
|
+
top = (1 - fx) * flat[yi_l * width + xi_l] + fx * flat[yi_l * width + xn]
|
|
628
|
+
bottom = (1 - fx) * flat[yn * width + xi_l] + fx * flat[yn * width + xn]
|
|
629
|
+
return (1 - fy) * top + fy * bottom
|
|
630
|
+
|
|
631
|
+
return sample
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# --------------------------------------------------------------------------- filter loops
|
|
635
|
+
|
|
636
|
+
# gbrap plane order: index in the ffmpeg plane loop -> RGBA channel index
|
|
637
|
+
GBRA_TO_RGBA: tuple[int, int, int, int] = (2, 0, 1, 3) # gbra[p] is rgba[GBRA_TO_RGBA[p]]
|
|
638
|
+
RGBA_TO_GBRA: tuple[int, int, int, int] = (1, 2, 0, 3) # rgba[c] is gbra[RGBA_TO_GBRA[c]]
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def rgba_to_gbra(planes: torch.Tensor) -> torch.Tensor:
|
|
642
|
+
return planes[list(RGBA_TO_GBRA)]
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def gbra_to_rgba(planes: torch.Tensor) -> torch.Tensor:
|
|
646
|
+
return planes[list(GBRA_TO_RGBA)]
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def quantize_uint8(values: torch.Tensor) -> torch.Tensor:
|
|
650
|
+
"""The C ``(uint8_t)double`` store: truncate toward zero; NaN -> 0; clamp to [0, 255] (see module doc).
|
|
651
|
+
|
|
652
|
+
No rounding slack, deliberately: a 2**-11 slack before the float32 truncation was tried
|
|
653
|
+
to rescue exact-integer results (``w*A + (1-w)*A``) and made agreement *worse* (Deco 0.02%
|
|
654
|
+
-> 1.5% of values off by one) because ffmpeg's own doubles land just below integers
|
|
655
|
+
systematically (``(1-w)*A + w*158`` with ``w = 0.30000000000000004`` -> 157.99999999999997
|
|
656
|
+
-> 157); plain truncation of the float32 result is the closest match to that reference.
|
|
657
|
+
"""
|
|
658
|
+
|
|
659
|
+
return torch.nan_to_num(values, nan=0.0, posinf=255.0, neginf=0.0).trunc().clamp(0.0, 255.0)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def xfade_progress(frame_index: int, frame_count: int) -> float:
|
|
663
|
+
"""``vf_xfade.c`` ``P`` for the k-th of F transition frames with ``offset=0``, ``duration=F/fps``.
|
|
664
|
+
|
|
665
|
+
ffmpeg computes ``av_clipf(1.f - (float)(pts - start_pts) / duration_pts, 0, 1)`` in
|
|
666
|
+
*float32* and hands the value to the double evaluator; reproducing the float32 rounding
|
|
667
|
+
keeps knife-edge comparisons (``lt(1-P, 0.40)`` at ``k/F = 0.6``) on ffmpeg's side. With a
|
|
668
|
+
frame-exact timebase ``pts - start_pts = k`` and ``duration_pts = F``.
|
|
669
|
+
"""
|
|
670
|
+
|
|
671
|
+
if not 0 <= frame_index < frame_count:
|
|
672
|
+
raise ValueError(f"frame_index {frame_index} outside [0, {frame_count})")
|
|
673
|
+
ratio = np.float32(np.float32(frame_index) / np.float32(frame_count))
|
|
674
|
+
progress = np.float32(np.float32(1.0) - ratio)
|
|
675
|
+
return float(min(np.float32(1.0), max(np.float32(0.0), progress)))
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def xfade_custom_rgba(
|
|
679
|
+
expr: Expr,
|
|
680
|
+
a_rgba: torch.Tensor,
|
|
681
|
+
b_rgba: torch.Tensor,
|
|
682
|
+
*,
|
|
683
|
+
progress: float,
|
|
684
|
+
) -> torch.Tensor:
|
|
685
|
+
"""Run ``expr`` the way ``xfade=transition=custom`` does on two ``gbrap`` frames.
|
|
686
|
+
|
|
687
|
+
Inputs and output are RGBA ``[4, H, W]`` code values (0..255, any float dtype); ffmpeg's
|
|
688
|
+
loop runs the expression per plane in **GBRA** order with ``PLANE`` = 0..3, ``A``/``B`` the
|
|
689
|
+
current plane, ``X``/``Y`` pixel indices, ``W``/``H`` the frame size, ``P`` = ``progress``,
|
|
690
|
+
and the nearest-neighbour samplers ``a0..a3``/``b0..b3`` over the (GBRA-indexed) planes.
|
|
691
|
+
Here the four planes are evaluated in one broadcast pass (``A``/``B`` = GBRA stacks,
|
|
692
|
+
``PLANE`` = ``[4, 1, 1]``) -- same values, one traversal. Every plane -- alpha included --
|
|
693
|
+
goes through the expression, and the result is stored with the uint8 truncation
|
|
694
|
+
(``quantize_uint8``), exactly like the 8-bit ffmpeg path.
|
|
695
|
+
|
|
696
|
+
Main callers:
|
|
697
|
+
- ``tensor/transitions.py::xfade_custom``.
|
|
698
|
+
- ``experimental_tests/core/test_tensor_transition_golden.py``.
|
|
699
|
+
"""
|
|
700
|
+
|
|
701
|
+
if a_rgba.shape != b_rgba.shape or a_rgba.dim() != 3 or a_rgba.shape[0] != 4:
|
|
702
|
+
raise ValueError(f"expected two [4, H, W] RGBA tensors, got {tuple(a_rgba.shape)} and {tuple(b_rgba.shape)}")
|
|
703
|
+
_, height, width = a_rgba.shape
|
|
704
|
+
device, dtype = a_rgba.device, a_rgba.dtype
|
|
705
|
+
a_gbra = rgba_to_gbra(a_rgba)
|
|
706
|
+
b_gbra = rgba_to_gbra(b_rgba)
|
|
707
|
+
grid_x, grid_y = pixel_grid(height, width, device=device, dtype=dtype)
|
|
708
|
+
samplers: dict[str, Sampler] = {}
|
|
709
|
+
for index in range(4):
|
|
710
|
+
samplers[f"a{index}"] = nearest_sampler(a_gbra[index])
|
|
711
|
+
samplers[f"b{index}"] = nearest_sampler(b_gbra[index])
|
|
712
|
+
# All four planes in one evaluation: ``A``/``B`` are the [4, H, W] GBRA stacks and ``PLANE``
|
|
713
|
+
# a [4, 1, 1] tensor, so plane-independent sub-trees (the X/Y fields of Lens Flare) are
|
|
714
|
+
# computed once and ``if(eq(PLANE,0), ...)`` colour literals select per plane by broadcast.
|
|
715
|
+
plane_index = torch.arange(4, device=device, dtype=dtype).view(4, 1, 1)
|
|
716
|
+
env = Environment(
|
|
717
|
+
variables={
|
|
718
|
+
"X": grid_x, "Y": grid_y, "W": float(width), "H": float(height),
|
|
719
|
+
"A": a_gbra, "B": b_gbra, "PLANE": plane_index, "P": float(progress),
|
|
720
|
+
},
|
|
721
|
+
samplers=samplers,
|
|
722
|
+
device=device,
|
|
723
|
+
dtype=dtype,
|
|
724
|
+
)
|
|
725
|
+
out = quantize_uint8(evaluate_image(expr, env, (4, height, width)))
|
|
726
|
+
return gbra_to_rgba(out)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def geq_rgba(
|
|
730
|
+
expressions: Mapping[str, Expr],
|
|
731
|
+
source_rgba: torch.Tensor,
|
|
732
|
+
*,
|
|
733
|
+
frame_number: int,
|
|
734
|
+
time_seconds: float,
|
|
735
|
+
) -> torch.Tensor:
|
|
736
|
+
"""Run per-channel ``geq`` expressions (``r``, ``g``, ``b``, ``a`` keys) on an RGBA code frame.
|
|
737
|
+
|
|
738
|
+
Mirrors ``vf_geq.c`` on ``gbrap``: samplers ``r/g/b/alpha`` read the named plane with the
|
|
739
|
+
default bilinear ``getpix`` (clamped), ``p`` reads the plane being written; variables
|
|
740
|
+
``X, Y, W, H, N, T`` (``SW``/``SH`` are 1 for full-resolution planes); uint8 store. A missing
|
|
741
|
+
channel key raises -- no implicit ``lum`` inheritance, callers name all four.
|
|
742
|
+
|
|
743
|
+
Main callers:
|
|
744
|
+
- effect batches (E1/E2) once ``geq`` effects are lowered; ``test_tensor_expr.py`` goldens.
|
|
745
|
+
"""
|
|
746
|
+
|
|
747
|
+
missing = [key for key in ("r", "g", "b", "a") if key not in expressions]
|
|
748
|
+
if missing:
|
|
749
|
+
raise ValueError(f"geq needs expressions for r, g, b and a; missing {missing}")
|
|
750
|
+
if source_rgba.dim() != 3 or source_rgba.shape[0] != 4:
|
|
751
|
+
raise ValueError(f"expected one [4, H, W] RGBA tensor, got {tuple(source_rgba.shape)}")
|
|
752
|
+
_, height, width = source_rgba.shape
|
|
753
|
+
device, dtype = source_rgba.device, source_rgba.dtype
|
|
754
|
+
grid_x, grid_y = pixel_grid(height, width, device=device, dtype=dtype)
|
|
755
|
+
channel_samplers = {
|
|
756
|
+
"r": bilinear_sampler(source_rgba[0]),
|
|
757
|
+
"g": bilinear_sampler(source_rgba[1]),
|
|
758
|
+
"b": bilinear_sampler(source_rgba[2]),
|
|
759
|
+
"alpha": bilinear_sampler(source_rgba[3]),
|
|
760
|
+
}
|
|
761
|
+
out = torch.empty_like(source_rgba)
|
|
762
|
+
for channel_index, key in enumerate(("r", "g", "b", "a")):
|
|
763
|
+
samplers = dict(channel_samplers)
|
|
764
|
+
samplers["p"] = channel_samplers["alpha" if key == "a" else key]
|
|
765
|
+
env = Environment(
|
|
766
|
+
variables={
|
|
767
|
+
"X": grid_x, "Y": grid_y, "W": float(width), "H": float(height),
|
|
768
|
+
"N": float(frame_number), "T": float(time_seconds), "SW": 1.0, "SH": 1.0,
|
|
769
|
+
},
|
|
770
|
+
samplers=samplers,
|
|
771
|
+
device=device,
|
|
772
|
+
dtype=dtype,
|
|
773
|
+
)
|
|
774
|
+
out[channel_index] = quantize_uint8(evaluate_image(expressions[key], env, (height, width)))
|
|
775
|
+
return out
|