bladeworks 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. bladeworks/__init__.py +56 -0
  2. bladeworks/__main__.py +4 -0
  3. bladeworks/cli.py +728 -0
  4. bladeworks/core/__init__.py +34 -0
  5. bladeworks/core/animation.py +893 -0
  6. bladeworks/core/animation_execution.py +817 -0
  7. bladeworks/core/audio_enhancements.py +673 -0
  8. bladeworks/core/audio_execution.py +1848 -0
  9. bladeworks/core/audio_ir.py +1971 -0
  10. bladeworks/core/basic_effects.py +168 -0
  11. bladeworks/core/bindings.py +196 -0
  12. bladeworks/core/capabilities.py +257 -0
  13. bladeworks/core/cohort_effects.py +702 -0
  14. bladeworks/core/color.py +324 -0
  15. bladeworks/core/compiler.py +3142 -0
  16. bladeworks/core/composition_diagnostics.py +629 -0
  17. bladeworks/core/composition_ir.py +3154 -0
  18. bladeworks/core/composition_planning.py +708 -0
  19. bladeworks/core/composition_shadow.py +687 -0
  20. bladeworks/core/compositor.py +1186 -0
  21. bladeworks/core/effect_parameter_semantics.py +165 -0
  22. bladeworks/core/effect_parameters.py +193 -0
  23. bladeworks/core/errors.py +21 -0
  24. bladeworks/core/filter_text.py +48 -0
  25. bladeworks/core/geometry.py +1711 -0
  26. bladeworks/core/group_surface.py +664 -0
  27. bladeworks/core/keyer.py +280 -0
  28. bladeworks/core/masks.py +453 -0
  29. bladeworks/core/missing_media.py +130 -0
  30. bladeworks/core/model.py +985 -0
  31. bladeworks/core/multicam_execution.py +496 -0
  32. bladeworks/core/output_color.py +52 -0
  33. bladeworks/core/parser.py +1257 -0
  34. bladeworks/core/pixel_domains.py +433 -0
  35. bladeworks/core/render_profile.py +311 -0
  36. bladeworks/core/render_sources.py +475 -0
  37. bladeworks/core/report.py +118 -0
  38. bladeworks/core/retime.py +967 -0
  39. bladeworks/core/retime_execution.py +1515 -0
  40. bladeworks/core/spatial_intrinsics.py +1749 -0
  41. bladeworks/core/story_containers.py +524 -0
  42. bladeworks/core/story_ir.py +1042 -0
  43. bladeworks/core/text.py +1032 -0
  44. bladeworks/core/text_templates.py +934 -0
  45. bladeworks/data/FCPXML_RENDER_CAPABILITIES.yaml +1797 -0
  46. bladeworks/effects/__init__.py +1 -0
  47. bladeworks/effects/contract.py +162 -0
  48. bladeworks/examples/__init__.py +80 -0
  49. bladeworks/examples/color.fcpxmld/Info.fcpxml +17 -0
  50. bladeworks/examples/color.fcpxmld/Media/a.mp4 +0 -0
  51. bladeworks/examples/crop_or_transition.fcpxmld/Info.fcpxml +13 -0
  52. bladeworks/examples/crop_or_transition.fcpxmld/Media/a.mp4 +0 -0
  53. bladeworks/examples/crop_or_transition.fcpxmld/Media/b.mp4 +0 -0
  54. bladeworks/examples/manual_qa/generate_2h_qa_fixtures.py +237 -0
  55. bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Info.fcpxml +28 -0
  56. bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Media/night_signal.mp4 +0 -0
  57. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Info.fcpxml +44 -0
  58. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/city_grid.mp4 +0 -0
  59. bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/golden_hour.mp4 +0 -0
  60. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Info.fcpxml +113 -0
  61. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/city_grid.mp4 +0 -0
  62. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/golden_hour.mp4 +0 -0
  63. bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/night_signal.mp4 +0 -0
  64. bladeworks/examples/single_clip.fcpxmld/Info.fcpxml +9 -0
  65. bladeworks/examples/single_clip.fcpxmld/Media/a.mp4 +0 -0
  66. bladeworks/examples/spine_and_lane.fcpxmld/Info.fcpxml +13 -0
  67. bladeworks/examples/spine_and_lane.fcpxmld/Media/a.mp4 +0 -0
  68. bladeworks/examples/spine_and_lane.fcpxmld/Media/b.mp4 +0 -0
  69. bladeworks/examples/transform.fcpxmld/Info.fcpxml +11 -0
  70. bladeworks/examples/transform.fcpxmld/Media/a.mp4 +0 -0
  71. bladeworks/executor.py +293 -0
  72. bladeworks/preview/__init__.py +11 -0
  73. bladeworks/preview/application.py +84 -0
  74. bladeworks/preview/audio.py +104 -0
  75. bladeworks/preview/capabilities.py +457 -0
  76. bladeworks/preview/contracts.py +157 -0
  77. bladeworks/preview/events.py +55 -0
  78. bladeworks/preview/export.py +57 -0
  79. bladeworks/preview/history.py +197 -0
  80. bladeworks/preview/main.py +19 -0
  81. bladeworks/preview/media.py +23 -0
  82. bladeworks/preview/media_library.py +381 -0
  83. bladeworks/preview/media_reporting.py +55 -0
  84. bladeworks/preview/media_routes.py +155 -0
  85. bladeworks/preview/media_visuals.py +225 -0
  86. bladeworks/preview/producer.py +211 -0
  87. bladeworks/preview/provider.py +74 -0
  88. bladeworks/preview/rawframe.py +218 -0
  89. bladeworks/preview/render_jobs.py +311 -0
  90. bladeworks/preview/routes.py +375 -0
  91. bladeworks/preview/runner.py +349 -0
  92. bladeworks/preview/security.py +61 -0
  93. bladeworks/preview/service.py +234 -0
  94. bladeworks/preview/session.py +457 -0
  95. bladeworks/preview/source.py +603 -0
  96. bladeworks/preview/source_routes.py +151 -0
  97. bladeworks/preview/studio.py +77 -0
  98. bladeworks/preview/webrtc.py +295 -0
  99. bladeworks/spatial_luts/manifest.json +17 -0
  100. bladeworks/spatial_luts/rec2020_hlg_to_rec709_sdr_v1.cube +4917 -0
  101. bladeworks/spatial_luts/rec2020_pq_to_rec709_sdr_v1.cube +4917 -0
  102. bladeworks/studio_static/__init__.py +1 -0
  103. bladeworks/studio_static/app.js +6519 -0
  104. bladeworks/studio_static/build-id.js +1 -0
  105. bladeworks/studio_static/capability-ui.js +68 -0
  106. bladeworks/studio_static/clip-state.js +335 -0
  107. bladeworks/studio_static/fcpxml.js +3313 -0
  108. bladeworks/studio_static/fixtures.js +393 -0
  109. bladeworks/studio_static/history.js +50 -0
  110. bladeworks/studio_static/index.html +24 -0
  111. bladeworks/studio_static/magnetic-timeline.js +1194 -0
  112. bladeworks/studio_static/mask-ui.js +189 -0
  113. bladeworks/studio_static/media-visuals.js +224 -0
  114. bladeworks/studio_static/mock-capabilities.js +409 -0
  115. bladeworks/studio_static/retime-ui.js +151 -0
  116. bladeworks/studio_static/runtime.js +1652 -0
  117. bladeworks/studio_static/scope-ui.js +150 -0
  118. bladeworks/studio_static/styles.css +1558 -0
  119. bladeworks/studio_static/templates/catalog.js +77 -0
  120. bladeworks/studio_static/templates/icons.js +84 -0
  121. bladeworks/studio_static/templates/inspector.js +553 -0
  122. bladeworks/studio_static/templates/library.js +61 -0
  123. bladeworks/studio_static/templates/media.js +185 -0
  124. bladeworks/studio_static/templates/shared.js +43 -0
  125. bladeworks/studio_static/templates/shell.js +94 -0
  126. bladeworks/studio_static/templates/timeline.js +377 -0
  127. bladeworks/studio_static/templates/topbar.js +83 -0
  128. bladeworks/studio_static/templates/types.js +7 -0
  129. bladeworks/studio_static/templates/viewer.js +174 -0
  130. bladeworks/studio_static/templates.js +31 -0
  131. bladeworks/studio_static/types.js +18 -0
  132. bladeworks/studio_static/ui.js +97 -0
  133. bladeworks/studio_static/viewer-geometry.js +55 -0
  134. bladeworks/tensor/__init__.py +131 -0
  135. bladeworks/tensor/audio_delivery.py +280 -0
  136. bladeworks/tensor/audio_pyav.py +360 -0
  137. bladeworks/tensor/blend.py +217 -0
  138. bladeworks/tensor/color.py +53 -0
  139. bladeworks/tensor/composite.py +33 -0
  140. bladeworks/tensor/decode.py +1309 -0
  141. bladeworks/tensor/decode_policy.py +363 -0
  142. bladeworks/tensor/effects.py +322 -0
  143. bladeworks/tensor/encode.py +614 -0
  144. bladeworks/tensor/errors.py +32 -0
  145. bladeworks/tensor/expr.py +775 -0
  146. bladeworks/tensor/fx_basic.py +946 -0
  147. bladeworks/tensor/fx_branched.py +640 -0
  148. bladeworks/tensor/fx_cohort.py +404 -0
  149. bladeworks/tensor/fx_color.py +851 -0
  150. bladeworks/tensor/fx_keyer.py +140 -0
  151. bladeworks/tensor/fx_mask.py +352 -0
  152. bladeworks/tensor/fx_warp.py +337 -0
  153. bladeworks/tensor/hdr.py +122 -0
  154. bladeworks/tensor/pipeline.py +401 -0
  155. bladeworks/tensor/plan.py +2231 -0
  156. bladeworks/tensor/renderer.py +1199 -0
  157. bladeworks/tensor/requirements.txt +28 -0
  158. bladeworks/tensor/resolution.py +246 -0
  159. bladeworks/tensor/sampler.py +598 -0
  160. bladeworks/tensor/support.py +169 -0
  161. bladeworks/tensor/swscale_fixedpoint.py +181 -0
  162. bladeworks/tensor/tr_equirect.py +420 -0
  163. bladeworks/tensor/tr_handlers.py +443 -0
  164. bladeworks/tensor/tr_phase5.py +697 -0
  165. bladeworks/tensor/transitions.py +424 -0
  166. bladeworks/transitions/__init__.py +10 -0
  167. bladeworks/transitions/carryovers.py +299 -0
  168. bladeworks/transitions/cohort.py +981 -0
  169. bladeworks/transitions/contract.py +490 -0
  170. bladeworks/transitions/equirectangular.py +915 -0
  171. bladeworks/transitions/light_deco.py +141 -0
  172. bladeworks/transitions/panel_motion.py +495 -0
  173. bladeworks/transitions/stock.py +270 -0
  174. bladeworks-0.1.0.dist-info/METADATA +66 -0
  175. bladeworks-0.1.0.dist-info/RECORD +178 -0
  176. bladeworks-0.1.0.dist-info/WHEEL +5 -0
  177. bladeworks-0.1.0.dist-info/entry_points.txt +2 -0
  178. bladeworks-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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