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,420 @@
1
+ """F2 transition port: the eight 360° equirectangular transitions (handler ``equirectangular``).
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ plan.build_tensor_plan -> transitions.lower_transition (handler "equirectangular")
7
+ -> _lower_equirectangular(item, LowerContext)
8
+ transitions.equirectangular.build_equirectangular_transition_plan(xfade_id,
9
+ parameter_values) -- the SAME registry resolution the CPU builder
10
+ runs (``ffmpeg._build_stock_transition_groups``),
11
+ parameter-aware (direction / speed menu /
12
+ soften edges / border / slices / spacing)
13
+ prefilter None + admitted id -> kind "xfade_custom" (transitions.XfadePayload:
14
+ the wrap-aware ``mod``-longitude expression is
15
+ evaluated by tensor/expr.py exactly like the
16
+ flat registry expressions)
17
+ prefilter equirectangular_gaussian
18
+ -> kind "equirect_gaussian_blur" (this module:
19
+ the stock ``gblur`` graph, see below)
20
+ prefilter equirectangular_bloom
21
+ -> loud reject (see below)
22
+
23
+ Six ids are pure ``xfade=custom`` expressions in the reference and lower to ``xfade_custom``:
24
+ ``equirect_circle_wipe``, ``equirect_divide``, ``equirect_push``, ``equirect_reveal_wipe``,
25
+ ``equirect_slide``, ``equirect_wipe`` (``ADMITTED_EQUIRECT_IDS``; goldens in
26
+ ``experimental_tests/core/test_tensor_transition_golden.py`` -- default parameter values plus
27
+ direction / speed+soften / border / slices variants, float64 CPU bit-exact -- and an
28
+ end-to-end SSIM render in ``test_tensor_transition_golden_f1f2.py``).
29
+
30
+ The two blur variants are NOT expressions in the reference: ``equirect_gaussian_blur`` and
31
+ ``equirect_bloom_default`` resolve to ``prefilter`` in {``equirectangular_gaussian``,
32
+ ``equirectangular_bloom``} and the CPU builder runs ``ffmpeg._build_equirectangular_blur_group``
33
+ (the plan's ``expression`` field for those ids is the dead five-tap expression the builder
34
+ never emits, so evaluating it would be a silent approximation).
35
+
36
+ 360° Gaussian Blur (ported, kind ``equirect_gaussian_blur``) -- per side, in the 8-bit
37
+ straight ``gbrap`` code domain of the reference sides (``color.premultiplied_to_code``):
38
+
39
+ clean = the side
40
+ strip = [side | side | side] (``split`` + ``hstack=inputs=3``)
41
+ strong = gblur(strip, sigma, sigmaV, steps=3)[centre crop]
42
+ mild = gblur(strip, 0.28 sigma, 0.28 sigmaV, steps=2)[centre crop]
43
+ shoulder = trunc(clean*(1-sw) + mild*sw) (``blend=all_expr``, ``sw = min(1, 2*activity)``)
44
+ side_out = trunc(shoulder*(1-activity) + strong*activity)
45
+ activity = transitions.equirectangular.equirectangular_blur_activity("gaussian_skew", duration) at T = k*tb
46
+
47
+ then the two blurred sides go through the small ownership ``xfade=custom`` expression
48
+ (``gaussian_lead``, ``ffmpeg._build_equirectangular_blur_group``) evaluated by
49
+ ``expr.xfade_custom_rgba`` like every other xfade. ``gblur`` is FFmpeg's Alvarez-Mazorra
50
+ recursive Gaussian (``vf_gblur.c``): per axis ``steps`` passes of a causal then anti-causal
51
+ first-order IIR ``y[n] = x[n] + nu*y[n-1]`` whose boundary scale ``1/(1-nu)`` is exactly an
52
+ infinite replicate pad, ``postscale = (nu/lambda)^steps`` per axis, float32 buffers, clip to
53
+ [0, 255], ``lrintf`` (round half even). Here each IIR pass is the same recursion evaluated
54
+ in blocks (lower-Toeplitz matmul + carry, ``_iir_pass_blocked``) on the triple strip -- so the
55
+ longitude seam wraps exactly like the reference's ``hstack`` and latitude clamps at the poles. The envelope,
56
+ sigma texts (``ffmpeg._number`` -> float32 AVOption) and the ownership / activity expression
57
+ strings are taken from ``ffmpeg.py`` itself, never
58
+ re-typed, so the port cannot drift from the emitted graph. Golden:
59
+ ``test_tensor_transition_golden_f1f2.py`` (gblur vs the ``ffmpeg`` CLI on plates, the whole
60
+ side graph vs the ``ffmpeg`` CLI, and the e2e SSIM render vs the CPU reference).
61
+
62
+ 360° Bloom stays a loud reject (``support.reject("transition (other)", ...)``): its side graph
63
+ adds ``colorlevels`` + ``eq`` (a YUV round trip through swscale) + a ``curves`` natural
64
+ spline luminance mask + ``alphamerge`` + two alpha ``fade`` ramps + two ``overlay`` passes;
65
+ six stock filters with their own conversions and rounding, each needing its own golden.
66
+
67
+ Contract: see ``tensor/transitions.py`` (``Lowered`` / ``XfadePayload`` / apply ports).
68
+
69
+ Main callers:
70
+ - ``transitions.lower_transition`` (via ``register_handler``); ``transitions.apply_transition``
71
+ (via ``register`` of the ``EquirectGaussian`` transition).
72
+ - ``experimental_tests/core/test_tensor_transition_golden.py`` (``resolve_equirectangular_expression``),
73
+ ``test_tensor_transition_golden_f1f2.py`` (``gblur_gbrap``, ``gaussian_blur_side``).
74
+
75
+ Why this exists: the 360° ids are a separate registry (``transitions.equirectangular``) with
76
+ its own parameter contract and its own plan type; keeping the lowering here (one owner per
77
+ file) leaves ``tensor/transitions.py``'s xfade path untouched while sharing its apply kernel.
78
+ """
79
+
80
+ from __future__ import annotations
81
+
82
+ import math
83
+ from dataclasses import dataclass
84
+ from fractions import Fraction
85
+ from typing import Any, Final, Mapping, Optional
86
+
87
+ import numpy as np
88
+ import torch
89
+
90
+ from ..core.filter_text import format_number as _number, format_seconds as _seconds
91
+ from ..transitions.equirectangular import equirectangular_blur_activity
92
+ from ..core.model import RenderTransition
93
+ from ..transitions.equirectangular import build_equirectangular_transition_plan
94
+ from .color import code_to_premultiplied, premultiplied_to_code
95
+ from .errors import TensorRenderUnsupported
96
+ from .expr import Environment, evaluate, parse, quantize_uint8, xfade_custom_rgba, xfade_progress
97
+ from .support import reject
98
+ from .transitions import (
99
+ ApplyContext,
100
+ LowerContext,
101
+ Lowered,
102
+ Transition,
103
+ XfadePayload,
104
+ register,
105
+ register_handler,
106
+ )
107
+
108
+ # 360° ids the plan admits (append-only; every entry has a golden -- see module doc).
109
+ ADMITTED_EQUIRECT_IDS: Final[tuple[str, ...]] = (
110
+ "equirect_circle_wipe",
111
+ "equirect_divide",
112
+ "equirect_push",
113
+ "equirect_reveal_wipe",
114
+ "equirect_slide",
115
+ "equirect_wipe",
116
+ "equirect_gaussian_blur",
117
+ )
118
+
119
+ # The reference's ``ffmpeg._build_equirectangular_blur_group`` shape for the Gaussian id
120
+ # (asserted at lowering so a registry change is loud, not silently re-interpreted).
121
+ _GAUSSIAN_ACTIVITY_PROFILE: Final = "gaussian_skew"
122
+ _GAUSSIAN_OWNERSHIP_PROFILE: Final = "gaussian_lead"
123
+ _STRONG_STEPS: Final = 3
124
+ _MILD_STEPS: Final = 2
125
+ _MILD_SIGMA_SCALE: Final = 0.28
126
+
127
+
128
+ def resolve_equirectangular_expression(xfade_id: str, parameter_values: Optional[Mapping[str, Any]] = None) -> str:
129
+ """The reference's resolved 360° ``xfade=custom`` expression for an admitted expression id, loudly.
130
+
131
+ Runs the very registry resolution the CPU builder runs
132
+ (``build_equirectangular_transition_plan(xfade_id, parameter_values)``); refuses ids
133
+ outside ``ADMITTED_EQUIRECT_IDS`` and plans that carry a blur prefilter (their
134
+ ``expression`` is dead text, see module doc -- the Gaussian id lowers to its own kind).
135
+ """
136
+
137
+ if xfade_id not in ADMITTED_EQUIRECT_IDS:
138
+ raise TensorRenderUnsupported(
139
+ f"360° transition {xfade_id!r} is not ported to the tensor renderer "
140
+ f"(admitted: {', '.join(ADMITTED_EQUIRECT_IDS)})"
141
+ )
142
+ plan = build_equirectangular_transition_plan(xfade_id, dict(parameter_values or {}))
143
+ if plan.mode != "custom" or plan.prefilter is not None:
144
+ raise TensorRenderUnsupported(
145
+ f"360° transition {xfade_id!r} resolves to mode={plan.mode!r} prefilter={plan.prefilter!r}, "
146
+ "not a pure custom expression (the Gaussian blur is its own kind, see tr_equirect)"
147
+ )
148
+ return plan.expression
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # 360° Gaussian Blur: the stock gblur side graph
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class EquirectGaussianPayload:
158
+ xfade_id: str
159
+ horizontal_strength: float # plan.strength: sigma = max(1e-4, min(W, H) * strength)
160
+ vertical_strength: float # plan.spread
161
+ duration_text: str # ffmpeg._seconds(owned window) as the graph writes it
162
+ ownership_expression: str # the small ownership xfade text (evaluated by expr)
163
+
164
+
165
+ def gaussian_ownership_expression() -> str:
166
+ """The ``gaussian_lead`` ownership ``xfade=custom`` text of ``ffmpeg._build_equirectangular_blur_group``.
167
+
168
+ Copied character-for-character from the builder (it is an inline literal there);
169
+ ``test_tensor_transition_golden_f1f2.py`` asserts the emitted filter script carries it.
170
+ """
171
+
172
+ q = "(1-P)"
173
+ normalized = f"clip((({q})-0.02)/0.825,0,1)"
174
+ weight = f"(({normalized})*({normalized})*(3-2*({normalized})))"
175
+ return f"if(gte(P,1),A,if(lte(P,0),B,A*(1-({weight}))+B*({weight})))"
176
+
177
+
178
+ def gblur_params(sigma: float, steps: int) -> tuple[float, float]:
179
+ """``vf_gblur.c`` ``set_params``: ``(nu, postscale)`` for one axis, in the C precisions.
180
+
181
+ ``sigma`` is the value the AVOption parsed (float32 of the ``_number`` text); ``lambda``
182
+ and ``dnu`` are doubles, ``nu`` is stored as float32, ``postscale = pow(dnu/lambda, steps)``.
183
+ """
184
+
185
+ sigma32 = np.float32(sigma)
186
+ lam = float(sigma32 * sigma32) / (2.0 * steps) # C: float * float, then promoted
187
+ dnu = (1.0 + 2.0 * lam - math.sqrt(1.0 + 4.0 * lam)) / (2.0 * lam)
188
+ postscale = float(np.float32(math.pow(dnu / lam, steps)))
189
+ nu = float(np.float32(dnu))
190
+ if not math.isfinite(postscale) or postscale == 0.0:
191
+ postscale = 1.0
192
+ if not math.isfinite(nu):
193
+ nu = 0.0
194
+ return nu, postscale
195
+
196
+
197
+ _IIR_BLOCK: Final = 128
198
+
199
+
200
+ def _iir_pass_blocked(rows: torch.Tensor, nu: float, *, causal: bool) -> torch.Tensor:
201
+ """One first-order IIR pass ``y[n] = x[n] + nu*y[n-1]`` along the last axis with the C boundary
202
+ (``ptr[0] *= 1/(1-nu)``, i.e. ``y[-1] = x[0]/(1-nu)``), evaluated in blocks of ``_IIR_BLOCK``:
203
+
204
+ y_block = x_block @ T + carry * nu^(1..B) T[j, i] = nu^(i-j) for i >= j (lower Toeplitz)
205
+ carry = y_block[-1]
206
+
207
+ -- the exact recursion, one matmul + one outer product per block instead of ``L`` scalar
208
+ steps (fast on MPS and CPU) and no unfolded convolution buffers (``conv1d`` on the CPU
209
+ unfolds ``rows x taps x L`` floats: gigabytes at 1080p). ``causal=False`` mirrors the axis
210
+ (``ptr[W-1] *= bscale; ptr[x-1] += nu*ptr[x]``).
211
+ """
212
+
213
+ if not causal:
214
+ return _iir_pass_blocked(rows.flip(-1), nu, causal=True).flip(-1)
215
+ length = rows.shape[-1]
216
+ block = min(_IIR_BLOCK, length)
217
+ device, dtype = rows.device, rows.dtype
218
+ powers = torch.pow(torch.full((block,), nu, device=device, dtype=dtype), torch.arange(1, block + 1, device=device, dtype=dtype))
219
+ index = torch.arange(block, device=device)
220
+ exponent = (index.view(1, block) - index.view(block, 1)).to(dtype) # i - j
221
+ toeplitz = torch.where(exponent >= 0, torch.pow(torch.full_like(exponent, nu), exponent.clamp(min=0)), torch.zeros_like(exponent))
222
+ carry = rows[:, :1] * float(np.float32(1.0 / (1.0 - nu))) # y[-1] = x[0] * bscale
223
+ pieces = []
224
+ for start in range(0, length, block):
225
+ chunk = rows[:, start:start + block]
226
+ width = chunk.shape[-1]
227
+ y = chunk @ toeplitz[:width, :width] + carry * powers[:width].view(1, width)
228
+ carry = y[:, -1:]
229
+ pieces.append(y)
230
+ return torch.cat(pieces, dim=-1)
231
+
232
+
233
+ def _iir_pass_recursion(rows: torch.Tensor, nu: float, *, causal: bool) -> torch.Tensor:
234
+ """The C recursion itself (``horiz_slice_c``), vectorized across rows, sequential along the
235
+ axis: ``ptr[0] *= bscale; ptr[x] += nu*ptr[x-1]`` (and mirrored). Reference implementation
236
+ for the goldens (same float32 evaluation order as ffmpeg); ``L`` vector ops per pass."""
237
+
238
+ out = rows.clone()
239
+ length = out.shape[-1]
240
+ bscale = float(np.float32(1.0 / (1.0 - nu)))
241
+ columns = out.unbind(-1) # views into ``out``; in-place updates keep the recursion
242
+ if causal:
243
+ columns[0].mul_(bscale)
244
+ for x in range(1, length):
245
+ columns[x].add_(columns[x - 1], alpha=nu)
246
+ else:
247
+ columns[length - 1].mul_(bscale)
248
+ for x in range(length - 1, 0, -1):
249
+ columns[x - 1].add_(columns[x], alpha=nu)
250
+ return out
251
+
252
+
253
+ def _iir_axis(planes: torch.Tensor, nu: float, steps: int, method: str = "blocked") -> torch.Tensor:
254
+ """``steps`` passes of causal + anti-causal IIR along the last axis (``horiz_slice_c``).
255
+
256
+ ``method``: ``"blocked"`` (the runtime, any device) or ``"recursion"`` (the C loop, goldens);
257
+ both are goldened against ``ffmpeg gblur`` in ``test_tensor_transition_golden_f1f2.py``.
258
+ """
259
+
260
+ if nu <= 0.0:
261
+ return planes
262
+ one_pass = {"blocked": _iir_pass_blocked, "recursion": _iir_pass_recursion}[method]
263
+ rows = planes.reshape(-1, planes.shape[-1])
264
+ for _ in range(steps):
265
+ rows = one_pass(rows, nu, causal=True)
266
+ rows = one_pass(rows, nu, causal=False)
267
+ return rows.reshape(planes.shape)
268
+
269
+
270
+ def gblur_gbrap(
271
+ code: torch.Tensor, *, sigma: float, sigma_v: float, steps: int,
272
+ crop_x: Optional[tuple[int, int]] = None, method: str = "blocked",
273
+ ) -> torch.Tensor:
274
+ """``gblur=sigma=..:sigmaV=..:steps=..`` on an 8-bit code frame ``[planes, H, W]`` (all planes).
275
+
276
+ Input values are code values (any float dtype); output is the C result: float32 IIR passes
277
+ horizontally then vertically, ``* postscale_h * postscale_v``, clip to [0, 255], ``lrintf``.
278
+ ``sigma`` / ``sigma_v`` are the AVOption values (callers pass the ``_number`` text's float).
279
+ ``crop_x = (x0, x1)`` returns columns ``x0:x1`` (the reference's centre ``crop`` after the
280
+ triple-strip blur); columns are independent in the vertical pass, so it is applied first.
281
+ ``method`` selects the IIR implementation (see ``_iir_axis``).
282
+ """
283
+
284
+ nu_h, post_h = gblur_params(sigma, steps)
285
+ nu_v, post_v = gblur_params(sigma_v, steps)
286
+ work = code.to(torch.float32)
287
+ work = _iir_axis(work, nu_h, steps, method)
288
+ if crop_x is not None:
289
+ work = work[..., crop_x[0]:crop_x[1]]
290
+ work = _iir_axis(work.transpose(-1, -2).contiguous(), nu_v, steps, method).transpose(-1, -2)
291
+ work = (work * float(np.float32(post_h) * np.float32(post_v))).clamp(0.0, 255.0)
292
+ return torch.round(work).to(code.dtype)
293
+
294
+
295
+ def _sigma_options(width: int, height: int, strength: float) -> tuple[float, float]:
296
+ """The strong and mild sigmas the graph writes for one axis, as the ``gblur`` AVOption reads them.
297
+
298
+ ``ffmpeg._equirectangular_blur_side``: ``sigma = max(0.0001, min(W, H) * strength)`` (double),
299
+ strong text ``_number(sigma)``, mild text ``_number(sigma * 0.28)``; AVOption floats are float32.
300
+ """
301
+
302
+ sigma = max(0.0001, min(width, height) * strength)
303
+ return float(np.float32(float(_number(sigma)))), float(np.float32(float(_number(sigma * _MILD_SIGMA_SCALE))))
304
+
305
+
306
+ def _scalar(text: str, **variables: float) -> float:
307
+ value = evaluate(parse(text), Environment(variables=dict(variables), samplers={}, device=torch.device("cpu"), dtype=torch.float64))
308
+ if isinstance(value, torch.Tensor):
309
+ raise AssertionError(f"expected a scalar envelope, got a tensor for {text!r}")
310
+ return float(value)
311
+
312
+
313
+ def gaussian_blur_side(
314
+ code: torch.Tensor,
315
+ *,
316
+ width: int,
317
+ height: int,
318
+ horizontal_strength: float,
319
+ vertical_strength: float,
320
+ activity: float,
321
+ ) -> torch.Tensor:
322
+ """One side of ``ffmpeg._equirectangular_blur_side`` (Gaussian branch) on an 8-bit code frame.
323
+
324
+ ``activity`` is the envelope value at this frame (``transitions.equirectangular.equirectangular_blur_activity``
325
+ at ``T``); the two ``blend=all_expr`` stages truncate like the C ``uint8`` store.
326
+ """
327
+
328
+ strong_h, mild_h = _sigma_options(width, height, horizontal_strength)
329
+ strong_v, mild_v = _sigma_options(width, height, vertical_strength)
330
+ strip = torch.cat((code, code, code), dim=-1)
331
+ centre = (width, 2 * width)
332
+ strong = gblur_gbrap(strip, sigma=strong_h, sigma_v=strong_v, steps=_STRONG_STEPS, crop_x=centre)
333
+ mild = gblur_gbrap(strip, sigma=mild_h, sigma_v=mild_v, steps=_MILD_STEPS, crop_x=centre)
334
+ shoulder_weight = min(1.0, 2.0 * activity)
335
+ shoulder = quantize_uint8(code * (1.0 - shoulder_weight) + mild * shoulder_weight)
336
+ return quantize_uint8(shoulder * (1.0 - activity) + strong * activity)
337
+
338
+
339
+ def blur_activity(duration_text: str, *, frame_index: int, frame_duration: Fraction) -> float:
340
+ """``transitions.equirectangular.equirectangular_blur_activity("gaussian_skew", duration)`` at the k-th frame:
341
+ ``T = pts * av_q2d(tb)`` with ``pts = k`` and ``tb`` the frame duration (``settb=expr=1/fps``)."""
342
+
343
+ time_seconds = frame_index * (frame_duration.numerator / frame_duration.denominator)
344
+ return _scalar(equirectangular_blur_activity(_GAUSSIAN_ACTIVITY_PROFILE, duration_text), T=time_seconds)
345
+
346
+
347
+ class EquirectGaussian(Transition):
348
+ kind = "equirect_gaussian_blur"
349
+
350
+ def apply(self, payload: EquirectGaussianPayload, a: torch.Tensor, b: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
351
+ progress = xfade_progress(ctx.frame_index, ctx.frame_count)
352
+ if progress >= 1.0:
353
+ return a
354
+ activity = blur_activity(payload.duration_text, frame_index=ctx.frame_index, frame_duration=ctx.frame_duration)
355
+ sides = []
356
+ for side in (a, b):
357
+ sides.append(gaussian_blur_side(
358
+ premultiplied_to_code(side), width=ctx.width, height=ctx.height,
359
+ horizontal_strength=payload.horizontal_strength, vertical_strength=payload.vertical_strength,
360
+ activity=activity,
361
+ ))
362
+ out_code = xfade_custom_rgba(parse(payload.ownership_expression), sides[0], sides[1], progress=progress)
363
+ return code_to_premultiplied(out_code)
364
+
365
+
366
+ register(EquirectGaussian())
367
+
368
+
369
+ # ---------------------------------------------------------------------------
370
+ # lowering
371
+ # ---------------------------------------------------------------------------
372
+
373
+
374
+ def _lower_equirectangular(item: RenderTransition, ctx: LowerContext) -> Lowered:
375
+ if item.xfade_id is None:
376
+ raise reject("transition (other)", f"{item.path}: equirectangular transition without a registry id")
377
+ plan = build_equirectangular_transition_plan(item.xfade_id, dict(item.parameter_values or {}))
378
+ if plan.prefilter == "equirectangular_bloom":
379
+ raise reject(
380
+ "transition (other)",
381
+ f"{item.path}: transition {item.name!r} xfade_id={item.xfade_id!r} resolves to the stock "
382
+ "bloom graph (ffmpeg._build_equirectangular_blur_group: hstack-wrapped gblur + colorlevels + eq "
383
+ "+ curves luminance mask + alphamerge + alpha fades + overlays), not an expression; F2 bloom port pending",
384
+ )
385
+ if plan.prefilter == "equirectangular_gaussian":
386
+ if item.xfade_id not in ADMITTED_EQUIRECT_IDS:
387
+ raise reject("transition (other)", f"{item.path}: {item.xfade_id!r} gaussian graph is not admitted")
388
+ if (plan.activity_profile, plan.ownership_profile) != (_GAUSSIAN_ACTIVITY_PROFILE, _GAUSSIAN_OWNERSHIP_PROFILE):
389
+ raise reject(
390
+ "transition (other)",
391
+ f"{item.path}: {item.xfade_id!r} resolves to profiles ({plan.activity_profile!r}, "
392
+ f"{plan.ownership_profile!r}); the tensor port covers ({_GAUSSIAN_ACTIVITY_PROFILE!r}, "
393
+ f"{_GAUSSIAN_OWNERSHIP_PROFILE!r}) only",
394
+ )
395
+ return Lowered(
396
+ kind="equirect_gaussian_blur",
397
+ xfade_id=item.xfade_id,
398
+ payload=EquirectGaussianPayload(
399
+ xfade_id=item.xfade_id,
400
+ horizontal_strength=plan.strength,
401
+ vertical_strength=plan.spread,
402
+ duration_text=_seconds(ctx.frame_count * ctx.frame_duration),
403
+ ownership_expression=gaussian_ownership_expression(),
404
+ ),
405
+ )
406
+ if plan.prefilter is not None:
407
+ raise reject(
408
+ "transition (other)",
409
+ f"{item.path}: transition {item.name!r} xfade_id={item.xfade_id!r} resolves to prefilter={plan.prefilter!r}",
410
+ )
411
+ if item.xfade_id not in ADMITTED_EQUIRECT_IDS:
412
+ raise reject(
413
+ "transition (other)",
414
+ f"{item.path}: transition {item.name!r} xfade_id={item.xfade_id!r} is a 360° custom "
415
+ "expression but is not in tr_equirect.ADMITTED_EQUIRECT_IDS (needs its golden)",
416
+ )
417
+ return Lowered(kind="xfade_custom", xfade_id=item.xfade_id, payload=XfadePayload(item.xfade_id, plan.expression))
418
+
419
+
420
+ register_handler("equirectangular", _lower_equirectangular)