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,946 @@
1
+ """E1 effect ports: simple + LUT + blur / sharpen / vignette + Color Curves no-ops + Color Wheels.
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ effects.lower_effect(effect, ctx) (plan time)
7
+ -> _lower_<handler>(effect, ctx) -> frozen payload (parameters read exactly like the
8
+ CPU emitter for the same handler)
9
+ effects.apply_effects(canvas, specs, frame) (per frame, premultiplied LINEAR RGBA in/out)
10
+ -> _apply_<handler>(payload, canvas, ctx)
11
+ 1. ``_code8(canvas)`` premultiplied linear -> straight 0..255 code, rounded to
12
+ integers: the ``format=rgba`` 8-bit link the reference filter
13
+ reads (``ffmpeg._video_chain`` puts ``format=rgba`` in
14
+ ``initial_filters`` before ``_ordered_effect_filters``)
15
+ 2. the filter's own arithmetic, mirrored from the FFmpeg n8.0 libavfilter source,
16
+ in the pixel format libavfilter negotiates for it (see the table below); the
17
+ two auto-inserted swscale conversions the reference pays for filters that do
18
+ not take ``rgba`` are mirrored too (``_rgba_to_yuva444p`` /
19
+ ``_yuva444p_to_rgba``: limited-range fixed point, BT.709 for 709-tagged
20
+ sources / ITU601 for untagged ones -- the link colourspace libavfilter
21
+ negotiates -- bit-exact against ffmpeg; ``rgba -> gbrap`` and ``rgba ->
22
+ rgb24`` are exact byte shuffles)
23
+ 3. ``_from_code8(...)`` the C store (truncation / lrint per filter) back to
24
+ premultiplied linear
25
+
26
+ handler reference filter string (``ffmpeg._effect_filters``) negotiated pix_fmt
27
+ --------------------------- --------------------------------------------------------------- ------------------
28
+ negative lutrgb=r/g/b='pow(clip(1-pow(clip(val/maxval,0,1),2.2),0,1),0.45)*maxval' rgba
29
+ threshold geq (two-band luma smoothstep, calibrated) gbrap
30
+ colorize colorchannelmixer=rr=0.69:rg=0.27:rb=0.20:gr=0.087:gg=0.47:gb=0.08:br=0:bg=0:bb=0.51 rgba
31
+ tint colorize=hue=245.54457:saturation=0.324662:lightness=0.270485:mix=1,eq=brightness=-0.15 yuva444p (YUV bridge)
32
+ flipped hflip rgba
33
+ mirror geq (right half reflected across the centre seam) gbrap
34
+ add_noise noise=c0s=6:c1s=6:c2s=6:c3s=0:c0_seed=424242:c1_seed=424243:c2_seed=424244 gbrap
35
+ pixellate_default pixelize=width=4:height=4:mode=avg:planes=0x7 gbrap
36
+ gaussian gblur=sigma=<amount*boost*ffmpeg_sigma> gbrap (all 4 planes)
37
+ sharpen unsharp=5:5:<amount*ffmpeg_scale>:5:5:0 yuva444p (YUV bridge, Y only)
38
+ vignette vignette=angle=<clamp(strength*scale/size)>:eval=frame rgb24 (ALPHA DROPPED -> opaque)
39
+ cohort_color_curves curves=master='0/0 1/1' identity (verified by golden)
40
+ cohort_hue_saturation_curves huesaturation=colors=r+y+m:saturation=0:strength=0:lightness=0 identity (verified by golden)
41
+ color_wheels [hue=h=<deg>] [colorbalance=rs..bh] yuva444p bridge (hue) then gbrap / rgba
42
+
43
+ Invariants
44
+ ----------
45
+ * Parameters: ``effects.effect_scalar`` (authored key, else calibration default) clamped to the
46
+ registry ``minimum`` / ``maximum`` exactly like ``ffmpeg._effect_scalar``; option strings go
47
+ through ``ffmpeg._number`` so the port parses the very float the CPU filter string carries
48
+ (``AV_OPT_TYPE_FLOAT`` options are float32, expression options are doubles).
49
+ * Anything a port cannot honour raises ``support.reject("effect (unsupported parameters)")``
50
+ at plan time; nothing is approximated silently. The BASIC handlers accept only the
51
+ no-parameter default (``basic_effects.unsupported_basic_effect_reason`` already omits
52
+ parameterised instances before they reach the IR; the ports re-check).
53
+ * Each port round-trips through the RGBA link on its own. When the reference chains two
54
+ YUV-only filters back to back (Tint then Sharpen, Color Wheels' ``hue`` then a Sharpen ...)
55
+ libavfilter keeps the yuva444p link *between* them (no gbrap round trip), so such chains
56
+ differ from these independent ports by up to a few tens of codes at the effect stage
57
+ (measured: Tint -> Sharpen 1.0, max 28 / mean 0.17 code, delivered SSIM 0.9957); the fix is
58
+ a core change (ports declare their native link, ``apply_effects`` fuses adjacent YUV ports),
59
+ requested in the E1 report -- not approximated here.
60
+ * Reference departures that are *reproduced* here (ledger candidates, see the report):
61
+ Vignette drops alpha (libavfilter negotiates ``rgb24``), so the clip becomes opaque after it;
62
+ the yuva444p bridge nudges alpha 128..254 up by one (``rgbaToA_c``'s ``a<<6 | a>>2``).
63
+ * Measured on the goldens (arm64 ffmpeg 8.0.1): CPU float64 is bit-exact for every port --
64
+ including gblur (float32 recurrence with fmadd in the reference, emulated step by step) and
65
+ colorbalance (float32 math), whose gates stay at <= 1 code because x86 builds take SIMD paths
66
+ with a different summation order; the default device (float32) is within one code.
67
+
68
+ Main callers:
69
+ - ``tensor/effects.py`` imports this module for its ``register`` side effects; the registry
70
+ dispatches ``lower`` / ``apply``.
71
+
72
+ Why this exists:
73
+ The E1 batch is the "simple" effects, but each one is a real libavfilter kernel with its own
74
+ pixel format, fixed-point rounding and store rule; keeping them in one file (one owner) with
75
+ the negotiated-format table above is what lets the goldens prove them one by one against
76
+ the ``ffmpeg`` CLI on the same bytes.
77
+ """
78
+
79
+ from __future__ import annotations
80
+
81
+ import hashlib
82
+ import math
83
+ from dataclasses import dataclass
84
+ from typing import Mapping, Optional
85
+
86
+ import numpy as np
87
+ import torch
88
+
89
+ from ..core.filter_text import format_number as cpu_number
90
+ from ..core.model import ResolvedEffect
91
+ from .color import code_to_premultiplied, premultiplied_to_code
92
+ from .effects import ApplyContext, EffectPort, LowerContext, effect_scalar, register
93
+ from .expr import geq_rgba, parse
94
+ from .support import reject
95
+ from .fx_color import LINK_COLORSPACE_ALIASES, BridgeLink, rgba8_to_yuva444p8, yuva444p8_to_rgba8
96
+
97
+
98
+ # --------------------------------------------------------------------------- shared helpers
99
+
100
+
101
+ def _calibration(effect: ResolvedEffect, key: str, name: str, default: float) -> float:
102
+ """``ffmpeg._calibration``: registry calibration value ``name`` under parameter ``key``."""
103
+
104
+ raw = effect.calibration.get(key, {}) if isinstance(effect.calibration, Mapping) else {}
105
+ if not isinstance(raw, Mapping):
106
+ return default
107
+ try:
108
+ return float(raw.get(name, default))
109
+ except (TypeError, ValueError):
110
+ return default
111
+
112
+
113
+ def _cpu_scalar(effect: ResolvedEffect, key: str, default: float) -> float:
114
+ """``ffmpeg._effect_scalar``: authored value (else default) clamped to the registry min/max."""
115
+
116
+ value = effect_scalar(effect, key, default)
117
+ raw = effect.calibration.get(key, {}) if isinstance(effect.calibration, Mapping) else {}
118
+ if not isinstance(raw, Mapping):
119
+ return value
120
+ minimum = float(raw.get("minimum", value))
121
+ maximum = float(raw.get("maximum", value))
122
+ return max(minimum, min(maximum, value))
123
+
124
+
125
+ def _option_float32(value: float) -> float:
126
+ """An ``AV_OPT_TYPE_FLOAT`` option as ffmpeg stores it: the CPU string re-parsed, then cast to float."""
127
+
128
+ return float(np.float32(float(cpu_number(value))))
129
+
130
+
131
+ def _require_no_params(effect: ResolvedEffect) -> None:
132
+ if effect.params:
133
+ names = sorted({p.name or p.key or "?" for p in effect.params})
134
+ raise reject(
135
+ "effect (unsupported parameters)",
136
+ f"{effect.path}: {effect.name or effect.handler} default-only port received controls {names}",
137
+ )
138
+
139
+
140
+ def _code8(canvas: torch.Tensor) -> torch.Tensor:
141
+ """Premultiplied linear -> straight RGBA code values rounded to the 8-bit link (float tensor)."""
142
+
143
+ return premultiplied_to_code(canvas).round().clamp(0.0, 255.0)
144
+
145
+
146
+ def _from_code8(code: torch.Tensor) -> torch.Tensor:
147
+ return code_to_premultiplied(code)
148
+
149
+
150
+ def _lut_apply(code_plane: torch.Tensor, lut: torch.Tensor) -> torch.Tensor:
151
+ """``lut[code]`` for an integer-valued float plane (LUT already on the plane's device/dtype)."""
152
+
153
+ return lut[code_plane.long()]
154
+
155
+
156
+ def _floor_div(x: torch.Tensor, divisor: int) -> torch.Tensor:
157
+ """C ``>>`` on signed ints (arithmetic shift == floor division by a power of two)."""
158
+
159
+ return torch.div(x, divisor, rounding_mode="floor")
160
+
161
+
162
+ # --------------------------------------------------------------------------- YUV bridge (swscale)
163
+ # libavfilter auto-inserts ``scale`` (swscale, csp/range negotiated per link) around filters that
164
+ # only take YUV: rgba -> yuva444p before, yuva444p -> gbrap after. The negotiated colourspace of
165
+ # that yuva444p link follows the *source's* tags: a BT.709-tagged source gives ``csp:bt709
166
+ # range:tv`` (Yunah-class footage), an untagged source gives ``csp:unknown`` = swscale's default
167
+ # ITU601 (verified with ``-v debug``: ``auto_scale_N ... -> fmt:yuva444p csp:bt709 range:tv``).
168
+ # Fixed-point transcription of swscale n8.0 for the 1:1 case, verified bit-exact against ffmpeg
169
+ # on all 256 ramps + a random plate for both matrices (test_tensor_fx_basic.py):
170
+ # input.c bgr32ToY_c/UV_c (RGB2YUV_SHIFT 15; the ITU601 table is special-cased in
171
+ # utils.c fill_rgb2yuv_table, BT.709 comes from its generic ROUNDED_DIV derivation)
172
+ # -> 14-bit ((S + off<<15 + 256) >> 9), hScale16To15 (RGB sh=13, 1:1 tap 1<<14) -> *2,
173
+ # yuv2plane1_8_c: (v + 64) >> 7 (no dither: 8-bit source)
174
+ # alpha rgbaToA_c: a<<6 | a>>2 (14-bit) through the same path -> a + 1 for 128 <= a < 255
175
+ # output.c yuv2gbrp_full_X_c with hScale8To15 (Y8<<7), 12-bit vertical tap 4096,
176
+ # yuv2rgb.c ff_yuv2rgb_c_init_tables (cy 76309, oy 16<<16, per-matrix crv/cbu/cgu/cgv)
177
+
178
+
179
+ # --------------------------------------------------------------------------- yuva444p bridge (shared)
180
+ #
181
+ # YUV-native filters (``colorize`` / ``eq`` for Tint, ``unsharp`` for Sharpen, ``hue`` for Color
182
+ # Wheels) run on the link libavfilter negotiates from the graph -- ``<pix_fmt>:<colorspace>:<range>``,
183
+ # see ``fx_color.reference_effect_link`` (source tags + whether a ``perspective`` stage precedes the
184
+ # effects) -- so the bridge is E4's swscale-exact ``rgba8_to_yuva444p8`` / ``yuva444p8_to_rgba8``
185
+ # (bit-exact on every triple for all eight links). Payloads carry the link text.
186
+
187
+
188
+ def _bridge_link(effect: ResolvedEffect, ctx: LowerContext) -> str:
189
+ """The negotiated bridge link for this layer, validated (rejects unported matrices / links)."""
190
+
191
+ text = ctx.reference_effect_link
192
+ if text is None:
193
+ # No plan-side negotiation (direct port tests): a bare ``format=rgba`` link on the
194
+ # source's tags, the way ``reference_effect_link`` resolves a non-resampling layer.
195
+ csp = LINK_COLORSPACE_ALIASES.get(str(ctx.source_colorspace).lower(), str(ctx.source_colorspace).lower())
196
+ rng = "pc" if str(ctx.source_color_range).lower() in ("pc", "jpeg", "full") else "tv"
197
+ text = f"rgba:{csp}:{rng}"
198
+ try:
199
+ return str(BridgeLink.parse(text))
200
+ except ValueError as error:
201
+ raise reject(
202
+ "effect (unsupported parameters)",
203
+ f"{effect.path}: {effect.name or effect.handler} on a {text} link: {error}",
204
+ ) from error
205
+
206
+
207
+ def _rgba_to_yuva444p(code: torch.Tensor, link: str) -> torch.Tensor:
208
+ """Straight 0..255 integer-valued RGBA code [4,H,W] -> int64 YUVA planes [4,H,W] on ``link``."""
209
+
210
+ if ":" not in link: # bare colourspace (unit goldens): the limited-range rgba link
211
+ link = f"rgba:{link}:tv"
212
+ return rgba8_to_yuva444p8(code.to(torch.int32), BridgeLink.parse(link)).long()
213
+
214
+
215
+ def _yuva444p_to_rgba(yuva: torch.Tensor, dtype: torch.dtype, link: str) -> torch.Tensor:
216
+ """int64 YUVA planes [4,H,W] -> straight 0..255 RGBA code [4,H,W] on ``link``."""
217
+
218
+ if ":" not in link:
219
+ link = f"rgba:{link}:tv"
220
+ return yuva444p8_to_rgba8(yuva.to(torch.int32), BridgeLink.parse(link)).to(dtype)
221
+
222
+
223
+ # --------------------------------------------------------------------------- Negative (lutrgb)
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class NegativePayload:
228
+ pass
229
+
230
+
231
+ _LUTS: dict[tuple, torch.Tensor] = {}
232
+
233
+
234
+ def _negative_lut(device: torch.device, dtype: torch.dtype) -> torch.Tensor:
235
+ """``vf_lut.c``: ``lut[val] = av_clip((int)expr, 0, 255)`` with the expression folded in double."""
236
+
237
+ key = ("negative", str(device), dtype)
238
+ lut = _LUTS.get(key)
239
+ if lut is None:
240
+ values = []
241
+ for val in range(256):
242
+ inner = min(max(val / 255.0, 0.0), 1.0) ** 2.2
243
+ curve = min(max(1.0 - inner, 0.0), 1.0) ** 0.45 * 255.0
244
+ values.append(float(min(max(int(curve), 0), 255)))
245
+ lut = torch.tensor(values, device=device, dtype=dtype)
246
+ _LUTS[key] = lut
247
+ return lut
248
+
249
+
250
+ def _lower_negative(effect: ResolvedEffect, ctx: LowerContext) -> NegativePayload:
251
+ _require_no_params(effect)
252
+ return NegativePayload()
253
+
254
+
255
+ def _apply_negative(payload: NegativePayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
256
+ code = _code8(canvas)
257
+ lut = _negative_lut(canvas.device, canvas.dtype)
258
+ rgb = _lut_apply(code[:3], lut)
259
+ return _from_code8(torch.cat((rgb, code[3:4]), dim=0))
260
+
261
+
262
+ register(EffectPort(handler="negative", lower=_lower_negative, apply=_apply_negative))
263
+
264
+
265
+ # --------------------------------------------------------------------------- Threshold / Mirror (geq)
266
+ # The exact ``geq`` strings of ``basic_effects._threshold_filter`` / ``_mirror_filter``,
267
+ # evaluated by ``expr.geq_rgba`` (vf_geq.c on gbrap: bilinear samplers, uint8 truncation).
268
+
269
+ _THRESHOLD_LUMA = "0.2126*r(X,Y)+0.7152*g(X,Y)+0.0722*b(X,Y)"
270
+ _THRESHOLD_PROGRESS = f"clip((({_THRESHOLD_LUMA})-70.125)/63.75,0,1)"
271
+ _THRESHOLD_SMOOTH = f"pow({_THRESHOLD_PROGRESS},2)*(3-2*{_THRESHOLD_PROGRESS})"
272
+ THRESHOLD_GEQ: Mapping[str, str] = {
273
+ "r": f"r(X,Y)*0.375+255*0.625*({_THRESHOLD_SMOOTH})",
274
+ "g": f"g(X,Y)*0.375+255*0.625*({_THRESHOLD_SMOOTH})",
275
+ "b": f"b(X,Y)*0.375+255*0.625*({_THRESHOLD_SMOOTH})",
276
+ "a": "alpha(X,Y)",
277
+ }
278
+ _MIRROR_X = "min(W-1,W/2+abs(X-W/2))"
279
+ MIRROR_GEQ: Mapping[str, str] = {
280
+ "r": f"r({_MIRROR_X},Y)",
281
+ "g": f"g({_MIRROR_X},Y)",
282
+ "b": f"b({_MIRROR_X},Y)",
283
+ "a": f"alpha({_MIRROR_X},Y)",
284
+ }
285
+
286
+
287
+ @dataclass(frozen=True)
288
+ class GeqPayload:
289
+ kind: str # "threshold" | "mirror"
290
+
291
+
292
+ def _lower_geq(kind: str):
293
+ def lower(effect: ResolvedEffect, ctx: LowerContext) -> GeqPayload:
294
+ _require_no_params(effect)
295
+ return GeqPayload(kind=kind)
296
+
297
+ return lower
298
+
299
+
300
+ def _apply_geq(payload: GeqPayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
301
+ strings = THRESHOLD_GEQ if payload.kind == "threshold" else MIRROR_GEQ
302
+ expressions = {key: parse(text) for key, text in strings.items()}
303
+ out = geq_rgba(expressions, _code8(canvas), frame_number=ctx.frame, time_seconds=ctx.seconds)
304
+ return _from_code8(out)
305
+
306
+
307
+ register(EffectPort(handler="threshold", lower=_lower_geq("threshold"), apply=_apply_geq))
308
+ register(EffectPort(handler="mirror", lower=_lower_geq("mirror"), apply=_apply_geq))
309
+
310
+
311
+ # --------------------------------------------------------------------------- Colorize (colorchannelmixer)
312
+ # ``vf_colorchannelmixer.c``: per-channel LUTs ``lrint(i * coeff)`` summed, ``av_clip_uintp2(.., 8)``;
313
+ # alpha is ``lut[A][A][a] = lrint(a * 1.0)`` = identity (ra/ga/ba/aa defaults).
314
+
315
+ _COLORIZE_MATRIX = ((0.69, 0.27, 0.20), (0.087, 0.47, 0.08), (0.0, 0.0, 0.51))
316
+
317
+
318
+ @dataclass(frozen=True)
319
+ class ColorizePayload:
320
+ pass
321
+
322
+
323
+ def _colorize_luts(device: torch.device, dtype: torch.dtype) -> torch.Tensor:
324
+ key = ("colorize", str(device), dtype)
325
+ luts = _LUTS.get(key)
326
+ if luts is None:
327
+ table = [[[float(round(i * coeff)) for i in range(256)] for coeff in row] for row in _COLORIZE_MATRIX]
328
+ luts = torch.tensor(table, device=device, dtype=dtype) # [out 3, in 3, 256]
329
+ _LUTS[key] = luts
330
+ return luts
331
+
332
+
333
+ def _lower_colorize(effect: ResolvedEffect, ctx: LowerContext) -> ColorizePayload:
334
+ _require_no_params(effect)
335
+ return ColorizePayload()
336
+
337
+
338
+ def _apply_colorize(payload: ColorizePayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
339
+ code = _code8(canvas)
340
+ luts = _colorize_luts(canvas.device, canvas.dtype)
341
+ idx = code[:3].long()
342
+ outs = []
343
+ for out_channel in range(3):
344
+ acc = luts[out_channel][0][idx[0]] + luts[out_channel][1][idx[1]] + luts[out_channel][2][idx[2]]
345
+ outs.append(acc.clamp(0.0, 255.0))
346
+ return _from_code8(torch.cat((torch.stack(outs), code[3:4]), dim=0))
347
+
348
+
349
+ register(EffectPort(handler="colorize", lower=_lower_colorize, apply=_apply_colorize))
350
+
351
+
352
+ # --------------------------------------------------------------------------- Tint (colorize + eq, 601 bridge)
353
+ # ``vf_colorize.c`` with mix=1 keeps Y and writes constant U/V from hsl2rgb (float32) ->
354
+ # rgb2yuv (double, truncated to int); ``vf_eq.c`` process_c with contrast 1 / brightness -0.15
355
+ # adds a constant to Y (chroma + alpha copied).
356
+
357
+
358
+ def _hue2rgb(p: np.float32, q: np.float32, t: np.float32) -> np.float32:
359
+ one, six = np.float32(1.0), np.float32(6.0)
360
+ if t < 0:
361
+ t = np.float32(t + one)
362
+ if t > 1:
363
+ t = np.float32(t - one)
364
+ if t < np.float32(1.0 / 6.0):
365
+ return np.float32(p + np.float32(np.float32(np.float32(q - p) * six) * t))
366
+ if t < np.float32(0.5):
367
+ return q
368
+ if t < np.float32(2.0 / 3.0):
369
+ return np.float32(p + np.float32(np.float32(np.float32(q - p) * np.float32(np.float32(2.0 / 3.0) - t)) * six))
370
+ return p
371
+
372
+
373
+ def _colorize_yuv_constants(hue: float, saturation: float, lightness: float) -> tuple[int, int, int]:
374
+ """``vf_colorize.c`` hsl2rgb (float32) + rgb2yuv (double, ``(int)`` store) for depth 8."""
375
+
376
+ h = np.float32(np.float32(hue) / np.float32(360.0))
377
+ s = np.float32(saturation)
378
+ light = np.float32(lightness)
379
+ if s == 0:
380
+ r = g = b = light
381
+ else:
382
+ q = np.float32(light * np.float32(np.float32(1.0) + s)) if light < np.float32(0.5) else np.float32(np.float32(light + s) - np.float32(light * s))
383
+ p = np.float32(np.float32(2.0) * light - q)
384
+ r = _hue2rgb(p, q, np.float32(h + np.float32(1.0 / 3.0)))
385
+ g = _hue2rgb(p, q, h)
386
+ b = _hue2rgb(p, q, np.float32(h - np.float32(1.0 / 3.0)))
387
+ r, g, b = float(r), float(g), float(b)
388
+ y = int(((0.21260 * 219.0 / 255.0) * r + (0.71520 * 219.0 / 255.0) * g + (0.07220 * 219.0 / 255.0) * b) * 255)
389
+ u = int((-(0.11457 * 224.0 / 255.0) * r - (0.38543 * 224.0 / 255.0) * g + (0.50000 * 224.0 / 255.0) * b + 0.5) * 255)
390
+ v = int(((0.50000 * 224.0 / 255.0) * r - (0.45415 * 224.0 / 255.0) * g - (0.04585 * 224.0 / 255.0) * b + 0.5) * 255)
391
+ return y, u, v
392
+
393
+
394
+ def _eq_brightness_offset(brightness: float) -> int:
395
+ """``vf_eq.h`` process_c constant for contrast 1: ``((int)(100*b + 100) * 511) / 200 - 128 - 4096/32``."""
396
+
397
+ brightness = float(np.float32(brightness)) # set_brightness: av_clipf(...) returns float
398
+ contrast = int(1.0 * 256 * 16)
399
+ return (int(100.0 * brightness + 100.0) * 511) // 200 - 128 - contrast // 32
400
+
401
+
402
+ @dataclass(frozen=True)
403
+ class TintPayload:
404
+ u: int
405
+ v: int
406
+ y_offset: int
407
+ link: str # negotiated yuva444p bridge link (fx_color.BridgeLink text)
408
+
409
+
410
+ def _lower_tint(effect: ResolvedEffect, ctx: LowerContext) -> TintPayload:
411
+ _require_no_params(effect)
412
+ _, u, v = _colorize_yuv_constants(_option_float32(245.54457), _option_float32(0.324662), _option_float32(0.270485))
413
+ return TintPayload(u=u, v=v, y_offset=_eq_brightness_offset(-0.15), link=_bridge_link(effect, ctx))
414
+
415
+
416
+ def _apply_tint(payload: TintPayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
417
+ yuva = _rgba_to_yuva444p(_code8(canvas), payload.link)
418
+ y = (yuva[0] + payload.y_offset).clamp(0, 255)
419
+ u = torch.full_like(y, payload.u)
420
+ v = torch.full_like(y, payload.v)
421
+ return _from_code8(_yuva444p_to_rgba(torch.stack((y, u, v, yuva[3])), canvas.dtype, payload.link))
422
+
423
+
424
+ register(EffectPort(handler="tint", lower=_lower_tint, apply=_apply_tint))
425
+
426
+
427
+ # --------------------------------------------------------------------------- Flipped (hflip)
428
+
429
+
430
+ @dataclass(frozen=True)
431
+ class FlippedPayload:
432
+ pass
433
+
434
+
435
+ def _lower_flipped(effect: ResolvedEffect, ctx: LowerContext) -> FlippedPayload:
436
+ _require_no_params(effect)
437
+ return FlippedPayload()
438
+
439
+
440
+ def _apply_flipped(payload: FlippedPayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
441
+ # A byte-exact horizontal flip on rgba; premultiplied linear flips identically, and the
442
+ # 8-bit link is a no-op for a permutation, so no code round trip is needed.
443
+ return canvas.flip(-1)
444
+
445
+
446
+ register(EffectPort(handler="flipped", lower=_lower_flipped, apply=_apply_flipped))
447
+
448
+
449
+ # --------------------------------------------------------------------------- Add Noise (noise, gbrap)
450
+ # ``vf_noise.c`` on gbrap (rgba -> gbrap is an exact shuffle): per plane an ``AVLFG`` seeded
451
+ # ``123457 + comp*31415`` (comp = gbrap plane index: 0 G, 1 B, 2 R) fills a 5120-entry int8
452
+ # gaussian table, then 3*4096 draws (prev_shift) and 4096 line shifts (first frame, non-temporal
453
+ # so every frame reuses them); row y adds ``noise[rand_shift[y & 4095] + x]``. Alpha untouched.
454
+ # NOTE the seed: ``vf_noise.c`` init() overwrites every ``param[i].seed`` with ``all_seed`` when
455
+ # it is set, else with the constant 123457 -- the ``c0_seed=424242:c1_seed=..:c2_seed=..`` the CPU
456
+ # string carries never reach the generator (verified by the golden; ffmpeg n8.0 behaviour).
457
+
458
+ _MAX_NOISE, _MAX_SHIFT = 5120, 1024
459
+ _MAX_RES = _MAX_NOISE - _MAX_SHIFT
460
+ _NOISE_BASE_SEED = 123457
461
+ _NOISE_PLANES = ("c0", "c1", "c2") # gbrap planes G, B, R
462
+ _NOISE_STRENGTH = 6
463
+ _MASK32 = 0xFFFFFFFF
464
+
465
+
466
+ class _AVLFG:
467
+ """``libavutil/lfg.c``: 64-word lagged Fibonacci generator seeded through chained MD5 blocks."""
468
+
469
+ def __init__(self, seed: int) -> None:
470
+ self.state = [0] * 64
471
+ tmp = bytearray(16)
472
+ for i in range(8, 64, 4):
473
+ tmp[0:4] = (seed & _MASK32).to_bytes(4, "little")
474
+ tmp[4] = i
475
+ digest = hashlib.md5(bytes(tmp)).digest()
476
+ tmp[:] = digest
477
+ for k in range(4):
478
+ self.state[i + k] = int.from_bytes(digest[4 * k: 4 * k + 4], "little")
479
+ self.index = 0
480
+
481
+ def get(self) -> int:
482
+ a = (self.state[(self.index - 24) & 63] + self.state[(self.index - 55) & 63]) & _MASK32
483
+ self.state[self.index & 63] = a
484
+ self.index += 1
485
+ return a
486
+
487
+
488
+ def _noise_plane_tables(seed: int, strength: int) -> tuple[np.ndarray, np.ndarray]:
489
+ """(noise table int8[5120], rand_shift int32[4096]) for one plane, exactly ``init_noise`` + frame 0."""
490
+
491
+ lfg = _AVLFG(seed)
492
+ noise = np.zeros(_MAX_NOISE, dtype=np.int64)
493
+ uint_max_f = float(np.float32(0xFFFFFFFF)) # (float)UINT_MAX == 4294967296.0f
494
+ for i in range(_MAX_NOISE):
495
+ while True:
496
+ x1 = 2.0 * lfg.get() / uint_max_f - 1.0
497
+ x2 = 2.0 * lfg.get() / uint_max_f - 1.0
498
+ w = x1 * x1 + x2 * x2
499
+ if w < 1.0:
500
+ break
501
+ w = math.sqrt((-2.0 * math.log(w)) / w)
502
+ y1 = x1 * w
503
+ y1 *= strength / math.sqrt(3.0)
504
+ y1 = float(np.float32(min(max(y1, -128.0), 127.0))) # av_clipf returns float
505
+ noise[i] = int(y1)
506
+ lfg.get() # RAND_N(6) (pattern bookkeeping only; the draw still advances the state)
507
+ for _ in range(_MAX_RES * 3):
508
+ lfg.get() # prev_shift init
509
+ rand_shift = np.array([lfg.get() & (_MAX_SHIFT - 1) for _ in range(_MAX_RES)], dtype=np.int64)
510
+ return noise, rand_shift
511
+
512
+
513
+ _NOISE_CACHE: dict[tuple, torch.Tensor] = {}
514
+
515
+
516
+ def _noise_field(height: int, width: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
517
+ """[3, H, W] additive noise for the R, G, B code planes (cached; the pattern is static per frame)."""
518
+
519
+ key = (height, width, str(device), dtype)
520
+ field = _NOISE_CACHE.get(key)
521
+ if field is None:
522
+ if width > _MAX_RES:
523
+ raise ValueError(f"noise port: width {width} exceeds the single-chunk MAX_RES {_MAX_RES}")
524
+ planes = {}
525
+ for comp, option in enumerate(_NOISE_PLANES):
526
+ table, shifts = _noise_plane_tables(_NOISE_BASE_SEED + comp * 31415, _NOISE_STRENGTH)
527
+ rows = shifts[np.arange(height) & (_MAX_RES - 1)]
528
+ planes[option] = table[rows[:, None] + np.arange(width)[None, :]]
529
+ # gbrap plane order: c0 = G, c1 = B, c2 = R -> stack as R, G, B
530
+ stacked = np.stack((planes["c2"], planes["c0"], planes["c1"])).astype(np.float64)
531
+ field = torch.from_numpy(stacked).to(device=device, dtype=dtype)
532
+ _NOISE_CACHE[key] = field
533
+ return field
534
+
535
+
536
+ @dataclass(frozen=True)
537
+ class NoisePayload:
538
+ pass
539
+
540
+
541
+ def _lower_noise(effect: ResolvedEffect, ctx: LowerContext) -> NoisePayload:
542
+ _require_no_params(effect)
543
+ if ctx.width > _MAX_RES:
544
+ raise reject("effect (unsupported parameters)", f"{effect.path}: Add Noise canvas width {ctx.width} > {_MAX_RES}")
545
+ return NoisePayload()
546
+
547
+
548
+ def _apply_noise(payload: NoisePayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
549
+ code = _code8(canvas)
550
+ _, height, width = code.shape
551
+ rgb = (code[:3] + _noise_field(height, width, canvas.device, canvas.dtype)).clamp(0.0, 255.0)
552
+ return _from_code8(torch.cat((rgb, code[3:4]), dim=0))
553
+
554
+
555
+ register(EffectPort(handler="add_noise", lower=_lower_noise, apply=_apply_noise))
556
+
557
+
558
+ # --------------------------------------------------------------------------- Pixellate (pixelize avg 4x4)
559
+ # ``vf_pixelize.c`` on gbrap, planes 0x7 (G, B, R; alpha copied): every 4x4 block (edge blocks
560
+ # clipped to the plane) is filled with ``sum / (w*h)`` in integer arithmetic (floor).
561
+
562
+ _PIXELIZE_BLOCK = 4
563
+
564
+
565
+ @dataclass(frozen=True)
566
+ class PixellatePayload:
567
+ pass
568
+
569
+
570
+ def _lower_pixellate(effect: ResolvedEffect, ctx: LowerContext) -> PixellatePayload:
571
+ _require_no_params(effect)
572
+ return PixellatePayload()
573
+
574
+
575
+ def _block_average_floor(planes: torch.Tensor, block: int) -> torch.Tensor:
576
+ """Integer block mean (floor) with clipped edge blocks, expanded back to the plane size."""
577
+
578
+ _, height, width = planes.shape
579
+ ones = torch.ones((1, height, width), device=planes.device, dtype=planes.dtype)
580
+ sums = torch.nn.functional.avg_pool2d(planes.unsqueeze(0), block, stride=block, ceil_mode=True, count_include_pad=False, divisor_override=1)
581
+ counts = torch.nn.functional.avg_pool2d(ones.unsqueeze(0), block, stride=block, ceil_mode=True, count_include_pad=False, divisor_override=1)
582
+ fill = torch.div(sums.round(), counts.round(), rounding_mode="floor")
583
+ expanded = fill.repeat_interleave(block, dim=2).repeat_interleave(block, dim=3)
584
+ return expanded[0, :, :height, :width]
585
+
586
+
587
+ def _apply_pixellate(payload: PixellatePayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
588
+ code = _code8(canvas)
589
+ rgb = _block_average_floor(code[:3], _PIXELIZE_BLOCK)
590
+ return _from_code8(torch.cat((rgb, code[3:4]), dim=0))
591
+
592
+
593
+ register(EffectPort(handler="pixellate_default", lower=_lower_pixellate, apply=_apply_pixellate))
594
+
595
+
596
+ # --------------------------------------------------------------------------- Gaussian (gblur, gbrap, all planes)
597
+ # ``vf_gblur.c`` (Alvarez-Mazorra recursive filter, ``steps=1``): float32 buffer, per row
598
+ # ``p[0]*=bscale; p[x] += nu*p[x-1] (right); p[W-1]*=bscale; p[x-1] += nu*p[x] (left)``, the
599
+ # same down/up per column, ``*= postscale*postscaleV``, clip, ``lrintf``. Parameters from
600
+ # ``set_params`` (double, from the float sigma). Emulated step by step in float32 (each
601
+ # ``+=`` rounded to float32 like the reference's fmadd) so CPU float64 stays within one code.
602
+
603
+
604
+ @dataclass(frozen=True)
605
+ class GaussianPayload:
606
+ sigma: float # the float32 option value ffmpeg parses from the CPU string
607
+ nu: float
608
+ boundaryscale: float
609
+ postscale: float # postscale * postscaleV (float32 product)
610
+
611
+
612
+ def _gblur_params(sigma32: float, steps: int = 1) -> tuple[float, float, float]:
613
+ """``vf_gblur.c`` set_params: (nu, boundaryscale, postscale) as the float32 fields ffmpeg keeps."""
614
+
615
+ sigma = np.float32(sigma32)
616
+ lam = float(np.float32(sigma * sigma)) / (2.0 * steps) # float*float in single, then double division
617
+ if lam == 0.0:
618
+ return 0.0, 1.0, 1.0 # dnu = NaN -> !isnormal -> nu 0, scales 1 (identity)
619
+ dnu = (1.0 + 2.0 * lam - math.sqrt(1.0 + 4.0 * lam)) / (2.0 * lam)
620
+ postscale = float(np.float32(math.pow(dnu / lam, steps)))
621
+ boundaryscale = float(np.float32(1.0 / (1.0 - dnu)))
622
+ nu = float(np.float32(dnu))
623
+ if not (math.isfinite(postscale) and postscale != 0.0):
624
+ postscale = 1.0
625
+ if not (math.isfinite(boundaryscale) and boundaryscale != 0.0):
626
+ boundaryscale = 1.0
627
+ if not (math.isfinite(nu) and nu != 0.0):
628
+ nu = 0.0
629
+ return nu, boundaryscale, postscale
630
+
631
+
632
+ def _lower_gaussian(effect: ResolvedEffect, ctx: LowerContext) -> GaussianPayload:
633
+ amount = _cpu_scalar(effect, "9999/986883370/100/986883376/2/100", 0.0)
634
+ boost = _cpu_scalar(effect, "9999/986883370/100/986884620/2/100", 1.0)
635
+ sigma_scale = _calibration(effect, "9999/986883370/100/986883376/2/100", "ffmpeg_sigma", 20.0)
636
+ sigma = _option_float32(max(0.0, amount * boost * sigma_scale))
637
+ nu, boundaryscale, postscale = _gblur_params(sigma)
638
+ return GaussianPayload(sigma=sigma, nu=nu, boundaryscale=boundaryscale, postscale=float(np.float32(np.float32(postscale) * np.float32(postscale))))
639
+
640
+
641
+ def _f32_step(value: torch.Tensor) -> torch.Tensor:
642
+ """Round a float64 tensor to float32 precision (float32 tensors pass through)."""
643
+
644
+ return value.float().to(value.dtype) if value.dtype == torch.float64 else value
645
+
646
+
647
+ def _gblur_axis(buffer: torch.Tensor, nu: float, bscale: float, axis: int) -> torch.Tensor:
648
+ """One causal + anti-causal recursive pass along ``axis`` (1 = rows/vertical, 2 = columns/horizontal)."""
649
+
650
+ work = buffer.movedim(axis, -1).contiguous() # [..., L] (a copy; the loop writes into it)
651
+ length = work.shape[-1]
652
+ lines = work.view(-1, length)
653
+ lines[:, 0] = _f32_step(lines[:, 0] * bscale)
654
+ for x in range(1, length):
655
+ lines[:, x] = _f32_step(lines[:, x] + nu * lines[:, x - 1])
656
+ lines[:, length - 1] = _f32_step(lines[:, length - 1] * bscale)
657
+ for x in range(length - 1, 0, -1):
658
+ lines[:, x - 1] = _f32_step(lines[:, x - 1] + nu * lines[:, x])
659
+ return lines.view(work.shape).movedim(-1, axis)
660
+
661
+
662
+ def gblur_planes(code_planes: torch.Tensor, payload: GaussianPayload) -> torch.Tensor:
663
+ """``vf_gblur.c`` filter_frame on integer-valued 8-bit planes ``[P, H, W]`` -> ``lrintf`` result."""
664
+
665
+ if payload.sigma <= 0.0:
666
+ return code_planes
667
+ buffer = _f32_step(code_planes.clone())
668
+ buffer = _gblur_axis(buffer, payload.nu, payload.boundaryscale, axis=2) # horizontal (rows)
669
+ buffer = _gblur_axis(buffer, payload.nu, payload.boundaryscale, axis=1) # vertical (columns)
670
+ buffer = _f32_step(buffer * payload.postscale).clamp(0.0, 255.0)
671
+ return torch.round(buffer) # lrintf: half to even == torch.round
672
+
673
+
674
+ def _apply_gaussian(payload: GaussianPayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
675
+ return _from_code8(gblur_planes(_code8(canvas), payload))
676
+
677
+
678
+ register(EffectPort(handler="gaussian", lower=_lower_gaussian, apply=_apply_gaussian))
679
+
680
+
681
+ # --------------------------------------------------------------------------- Sharpen (unsharp luma, 601 bridge)
682
+ # ``vf_unsharp.c`` ``unsharp=5:5:A:5:5:0`` on yuva444p: only plane 0 (Y) is filtered (chroma
683
+ # and alpha amounts are 0 -> plane copy). The sr/sc accumulator cascade is the separable
684
+ # binomial [1 4 6 4 1] (sum 256 = 1 << scalebits) with edge replication;
685
+ # res = y + (((y - ((blur + 128) >> 8)) * amount) >> 16), amount = (int)(A * 65536.0)
686
+
687
+
688
+ @dataclass(frozen=True)
689
+ class SharpenPayload:
690
+ amount: int # ``fp->amount`` (16.16 fixed point, truncated like the C double->int store)
691
+ link: str # negotiated yuva444p bridge link (fx_color.BridgeLink text)
692
+
693
+
694
+ def _lower_sharpen(effect: ResolvedEffect, ctx: LowerContext) -> SharpenPayload:
695
+ amount = _cpu_scalar(effect, "9999/986883553/100/986883554/2/100", 0.0)
696
+ scale = _calibration(effect, "9999/986883553/100/986883554/2/100", "ffmpeg_scale", 2.0)
697
+ option = _option_float32(max(0.0, amount * scale))
698
+ return SharpenPayload(amount=int(option * 65536.0), link=_bridge_link(effect, ctx))
699
+
700
+
701
+ _BINOMIAL5 = (1.0, 4.0, 6.0, 4.0, 1.0)
702
+
703
+
704
+ def unsharp_luma(y8: torch.Tensor, amount: int) -> torch.Tensor:
705
+ """``vf_unsharp.c`` slice on one int64 8-bit plane ``[H, W]``."""
706
+
707
+ if amount == 0:
708
+ return y8
709
+ kernel_1d = torch.tensor(_BINOMIAL5, device=y8.device, dtype=torch.float32)
710
+ kernel = (kernel_1d[:, None] * kernel_1d[None, :]).view(1, 1, 5, 5)
711
+ padded = torch.nn.functional.pad(y8.to(torch.float32).view(1, 1, *y8.shape), (2, 2, 2, 2), mode="replicate")
712
+ # integer sums <= 255 * 256 are exact in float32
713
+ blur = torch.nn.functional.conv2d(padded, kernel).view(y8.shape).round().long()
714
+ blurred = _floor_div(blur + 128, 256)
715
+ res = y8 + _floor_div((y8 - blurred) * amount, 1 << 16)
716
+ return res.clamp(0, 255)
717
+
718
+
719
+ def _apply_sharpen(payload: SharpenPayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
720
+ yuva = _rgba_to_yuva444p(_code8(canvas), payload.link)
721
+ y = unsharp_luma(yuva[0], payload.amount)
722
+ return _from_code8(_yuva444p_to_rgba(torch.stack((y, yuva[1], yuva[2], yuva[3])), canvas.dtype, payload.link))
723
+
724
+
725
+ register(EffectPort(handler="sharpen", lower=_lower_sharpen, apply=_apply_sharpen))
726
+
727
+
728
+ # --------------------------------------------------------------------------- Vignette (rgb24: alpha dropped)
729
+ # ``vf_vignette.c`` negotiates rgb24 (no alpha format in its list), so the reference clip is
730
+ # opaque afterwards (rgb24 -> gbrap fills alpha with 255). Per pixel ``f = fmap[y][x]``
731
+ # (float32 of ``cos(angle*dnorm)^4``, 0 outside the ellipse), each channel
732
+ # ``(uint8_t)(int)(src*f + dv)`` where ``dv`` is the default dither: an LCG
733
+ # ``s = s*1664525 + 1013904223`` (uint32, seeded 0, one draw per channel sample, never reset)
734
+ # so frame n consumes samples [3WHn, 3WH(n+1)). ``eval=frame`` re-evaluates the constant
735
+ # angle each frame (identical map).
736
+
737
+ _LCG_A, _LCG_C = 1664525, 1013904223
738
+
739
+
740
+ @dataclass(frozen=True)
741
+ class VignettePayload:
742
+ angle: float # float32 of clip(angle, 0, pi/2) (av_clipf returns float)
743
+
744
+
745
+ def _lower_vignette(effect: ResolvedEffect, ctx: LowerContext) -> VignettePayload:
746
+ strength = _cpu_scalar(effect, "9999/987213582/3001385021/1/200/202", 0.65)
747
+ size = _cpu_scalar(effect, "9999/987213582/3001385021/3/987213589/1", 1.5)
748
+ angle_scale = _calibration(effect, "9999/987213582/3001385021/1/200/202", "ffmpeg_angle_scale", math.pi / 4)
749
+ angle = max(0.01, min(math.pi / 2, strength * angle_scale / max(size, 0.25)))
750
+ parsed = float(cpu_number(angle)) # expression option: parsed as a double
751
+ clipped = min(max(np.float32(parsed), np.float32(0.0)), np.float32(math.pi / 2))
752
+ return VignettePayload(angle=float(clipped))
753
+
754
+
755
+ _VIGNETTE_CACHE: dict[tuple, torch.Tensor] = {}
756
+
757
+
758
+ def vignette_fmap(height: int, width: int, angle: float, device: torch.device) -> torch.Tensor:
759
+ """``get_natural_factor`` over the frame as the float32 map ffmpeg keeps (returned as float32)."""
760
+
761
+ key = (height, width, angle, str(device))
762
+ fmap = _VIGNETTE_CACHE.get(key)
763
+ if fmap is None:
764
+ x0, y0 = width / 2.0, height / 2.0
765
+ dmax = math.hypot(width / 2.0, height / 2.0)
766
+ xs = np.trunc((np.arange(width) - x0) * 1.0).astype(np.float64) # (int)((x - x0) * xscale)
767
+ ys = np.trunc((np.arange(height) - y0) * 1.0).astype(np.float64)
768
+ dnorm = np.hypot(xs[None, :], ys[:, None]) / dmax
769
+ c = np.cos(angle * dnorm)
770
+ factor = np.where(dnorm > 1.0, 0.0, (c * c) * (c * c)).astype(np.float32)
771
+ fmap = torch.from_numpy(factor).to(device)
772
+ _VIGNETTE_CACHE[key] = fmap
773
+ return fmap
774
+
775
+
776
+ def _lcg_affine_power(steps: int) -> tuple[int, int]:
777
+ """(A, C) with ``state_{k+steps} = A*state_k + C (mod 2^32)`` for the vignette LCG."""
778
+
779
+ a, c = 1, 0
780
+ base_a, base_c = _LCG_A, _LCG_C
781
+ while steps:
782
+ if steps & 1:
783
+ a, c = (base_a * a) & _MASK32, (base_a * c + base_c) & _MASK32
784
+ base_a, base_c = (base_a * base_a) & _MASK32, (base_a * base_c + base_c) & _MASK32
785
+ steps >>= 1
786
+ return a, c
787
+
788
+
789
+ def _lcg_block(count: int, device: torch.device) -> torch.Tensor:
790
+ """States 0..count-1 of the LCG from seed 0 as int64 (cached per count/device)."""
791
+
792
+ key = ("lcg", count, str(device))
793
+ block = _VIGNETTE_CACHE.get(key)
794
+ if block is None:
795
+ states = np.zeros(1, dtype=np.uint64)
796
+ length = 1
797
+ while length < count:
798
+ a, c = _lcg_affine_power(length)
799
+ nxt = (np.uint64(a) * states + np.uint64(c)) & np.uint64(_MASK32)
800
+ states = np.concatenate((states, nxt))
801
+ length *= 2
802
+ block = torch.from_numpy(states[:count].astype(np.int64)).to(device)
803
+ _VIGNETTE_CACHE[key] = block
804
+ return block
805
+
806
+
807
+ def _mulmod32(a: int, states: torch.Tensor) -> torch.Tensor:
808
+ """``(a * states) mod 2^32`` on int64 tensors without overflowing (a, states < 2^32)."""
809
+
810
+ lo, hi = a & 0xFFFF, a >> 16
811
+ return torch.remainder(lo * states + torch.remainder(hi * states, 1 << 16) * (1 << 16), 1 << 32)
812
+
813
+
814
+ def vignette_dither(frame: int, height: int, width: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
815
+ """The dither values ``dv`` for layer-local frame ``frame`` as ``[3, H, W]`` (R, G, B sample order)."""
816
+
817
+ count = 3 * height * width
818
+ a, c = _lcg_affine_power(frame * count)
819
+ states = torch.remainder(_mulmod32(a, _lcg_block(count, device)) + c, 1 << 32)
820
+ return (states.to(dtype) / float(1 << 32)).view(height, width, 3).permute(2, 0, 1)
821
+
822
+
823
+ def vignette_rgb(code_rgb: torch.Tensor, angle: float, frame: int) -> torch.Tensor:
824
+ """``vf_vignette.c`` RGB path on integer-valued code planes ``[3, H, W]``."""
825
+
826
+ _, height, width = code_rgb.shape
827
+ fmap = vignette_fmap(height, width, angle, code_rgb.device)
828
+ scaled = (code_rgb.float() * fmap).to(code_rgb.dtype) # ``srcp[0] * f`` is a float32 product
829
+ dither = vignette_dither(frame, height, width, code_rgb.device, code_rgb.dtype)
830
+ return (scaled + dither).trunc().clamp(0.0, 255.0) # av_clip_uint8((int)double)
831
+
832
+
833
+ def _apply_vignette(payload: VignettePayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
834
+ code = _code8(canvas)
835
+ rgb = vignette_rgb(code[:3], payload.angle, ctx.frame)
836
+ opaque = torch.full_like(code[3:4], 255.0) # rgb24 -> gbrap: alpha becomes 255
837
+ return _from_code8(torch.cat((rgb, opaque), dim=0))
838
+
839
+
840
+ register(EffectPort(handler="vignette", lower=_lower_vignette, apply=_apply_vignette))
841
+
842
+
843
+ # --------------------------------------------------------------------------- Color Curves / Hue-Sat Curves (no-ops)
844
+ # ``cohort_effects.cohort_effect_filters`` emits ``curves=master='0/0 1/1'`` and
845
+ # ``huesaturation=colors=r+y+m:saturation=0:strength=0:lightness=0``; both are byte-exact
846
+ # identities on rgba (golden in test_tensor_fx_basic.py), so the ports pass the canvas through.
847
+
848
+
849
+ @dataclass(frozen=True)
850
+ class IdentityPayload:
851
+ handler: str
852
+
853
+
854
+ def _lower_identity(effect: ResolvedEffect, ctx: LowerContext) -> IdentityPayload:
855
+ if effect.data:
856
+ raise reject("effect (unsupported parameters)", f"{effect.path}: {effect.name or effect.handler} carries opaque filter data")
857
+ return IdentityPayload(handler=effect.handler or "")
858
+
859
+
860
+ def _apply_identity(payload: IdentityPayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
861
+ return canvas
862
+
863
+
864
+ register(EffectPort(handler="cohort_color_curves", lower=_lower_identity, apply=_apply_identity))
865
+ register(EffectPort(handler="cohort_hue_saturation_curves", lower=_lower_identity, apply=_apply_identity))
866
+
867
+
868
+ # --------------------------------------------------------------------------- Color Wheels (hue + colorbalance)
869
+ # ``color._color_wheels_filters``: ``hue=h=<deg>`` when hue != 0 (vf_hue.c: 16.16 rotation LUT on
870
+ # U/V, Y and alpha copied, through the yuva444p bridge) and ``colorbalance=rs=..:bh=..`` when the
871
+ # temperature / tint deltas are non-zero (vf_colorbalance.c float32 math on gbrap or rgba;
872
+ # preserve_lightness 0, alpha copied).
873
+
874
+
875
+ @dataclass(frozen=True)
876
+ class ColorWheelsPayload:
877
+ hue_deg: Optional[float] # None when the hue stage is not emitted
878
+ red: Optional[float] # colorbalance rs=rm=rh (float32 option) or None when not emitted
879
+ green: Optional[float]
880
+ blue: Optional[float]
881
+ link: str # negotiated yuva444p bridge link for the hue stage
882
+
883
+
884
+ def _lower_color_wheels(effect: ResolvedEffect, ctx: LowerContext) -> ColorWheelsPayload:
885
+ temperature = _cpu_scalar(effect, "8890", 5000.0)
886
+ tint = _cpu_scalar(effect, "8891", 0.0)
887
+ hue = _cpu_scalar(effect, "8892", 0.0)
888
+ temperature_delta = (temperature - 5000.0) * _calibration(effect, "8890", "ffmpeg_scale", 0.00006)
889
+ tint_delta = tint * _calibration(effect, "8891", "ffmpeg_scale", 0.006)
890
+ hue_deg = float(cpu_number(hue)) if abs(hue) > 1e-6 else None
891
+ if abs(temperature_delta) > 1e-6 or abs(tint_delta) > 1e-6:
892
+ red = _option_float32(max(-1.0, min(1.0, temperature_delta)))
893
+ green = _option_float32(max(-1.0, min(1.0, tint_delta)))
894
+ blue = _option_float32(max(-1.0, min(1.0, -temperature_delta)))
895
+ else:
896
+ red = green = blue = None
897
+ return ColorWheelsPayload(hue_deg=hue_deg, red=red, green=green, blue=blue, link=_bridge_link(effect, ctx))
898
+
899
+
900
+ def hue_rotate_uv(u8: torch.Tensor, v8: torch.Tensor, hue_deg: float) -> tuple[torch.Tensor, torch.Tensor]:
901
+ """``vf_hue.c`` create_chrominance_lut / apply_lut on int64 U/V planes (saturation 1)."""
902
+
903
+ hue = hue_deg * math.pi / 180
904
+ s = int(round(math.sin(hue) * (1 << 16) * 1.0)) # lrint (half-even; the argument is never a tie)
905
+ c = int(round(math.cos(hue) * (1 << 16) * 1.0))
906
+ u = u8 - 128
907
+ v = v8 - 128
908
+ new_u = _floor_div((c * u) - (s * v) + (1 << 15) + (128 << 16), 1 << 16).clamp(0, 255)
909
+ new_v = _floor_div((s * u) + (c * v) + (1 << 15) + (128 << 16), 1 << 16).clamp(0, 255)
910
+ return new_u, new_v
911
+
912
+
913
+ def colorbalance_rgb(code_rgb: torch.Tensor, red: float, green: float, blue: float) -> torch.Tensor:
914
+ """``vf_colorbalance.c`` (pl=0) with equal shadows/midtones/highlights per channel, float32 math."""
915
+
916
+ rgb = code_rgb.float() / np.float32(255.0)
917
+ light = rgb.amax(dim=0) + rgb.amin(dim=0) # ``l = FFMAX3 + FFMIN3``
918
+ a, b, scale = np.float32(4.0), np.float32(0.333), np.float32(0.7)
919
+ shadow_w = ((b - light) * a + np.float32(0.5)).clamp(0.0, 1.0) * scale
920
+ mid_w = ((light - b) * a + np.float32(0.5)).clamp(0.0, 1.0) * ((np.float32(1.0) - light - b) * a + np.float32(0.5)).clamp(0.0, 1.0) * scale
921
+ high_w = ((light + b - np.float32(1.0)) * a + np.float32(0.5)).clamp(0.0, 1.0) * scale
922
+ outs = []
923
+ for channel, amount in enumerate((red, green, blue)):
924
+ v = rgb[channel]
925
+ v = v + np.float32(amount) * shadow_w
926
+ v = v + np.float32(amount) * mid_w
927
+ v = v + np.float32(amount) * high_w
928
+ outs.append((v.clamp(0.0, 1.0) * np.float32(255.0)).round().clamp(0.0, 255.0)) # lrintf
929
+ return torch.stack(outs).to(code_rgb.dtype)
930
+
931
+
932
+ def _apply_color_wheels(payload: ColorWheelsPayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
933
+ if payload.hue_deg is None and payload.red is None:
934
+ return canvas
935
+ code = _code8(canvas)
936
+ if payload.hue_deg is not None:
937
+ yuva = _rgba_to_yuva444p(code, payload.link)
938
+ u, v = hue_rotate_uv(yuva[1], yuva[2], payload.hue_deg)
939
+ code = _yuva444p_to_rgba(torch.stack((yuva[0], u, v, yuva[3])), canvas.dtype, payload.link)
940
+ if payload.red is not None:
941
+ assert payload.green is not None and payload.blue is not None
942
+ code = torch.cat((colorbalance_rgb(code[:3], payload.red, payload.green, payload.blue), code[3:4]), dim=0)
943
+ return _from_code8(code)
944
+
945
+
946
+ register(EffectPort(handler="color_wheels", lower=_lower_color_wheels, apply=_apply_color_wheels))