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,851 @@
1
+ """E4 effect ports: the colour pipeline -- Color Adjustments and Color Board (owner: E4 batch).
2
+
3
+ Architecture map
4
+ ================
5
+
6
+ plan.build_tensor_plan -> effects.lower_effect -> _lower_color(effect, ctx)
7
+ color.color_effect_filters(effect) : the CPU emitter's EXACT filter strings
8
+ (same parameter lookup, clamps, ``.12g``
9
+ formatting the ffmpeg CLI would parse)
10
+ ctx.reference_effect_link : which pixel link the reference feeds the
11
+ stack through (``BridgeLink``, computed by
12
+ the plan via ``reference_effect_link``)
13
+ _parse_stage(filter_string, link) : one ffmpeg filter -> one frozen stage
14
+ (unknown filter / option -> loud reject)
15
+ ColorPipelinePayload(stages) : the port payload (hashable, no torch)
16
+
17
+ renderer._FrameComposer.placed -> effects.apply_effects -> _apply_color(payload, canvas)
18
+ color.premultiplied_to_code : premultiplied linear -> straight 0..255 code
19
+ round to the uint8 frame the reference sees (``format=rgba``)
20
+ stages in order:
21
+ YuvBridgeStage : swscale RGB -> yuva444p (8-bit, lossy; matrix / range / alpha
22
+ path per the negotiated ``BridgeLink``) -> ``eq`` per-plane
23
+ LUTs -> swscale yuva444p -> RGB
24
+ LutStage : per-channel 8-bit LUT (``colorlevels``, ``curves``)
25
+ ColorBalanceStage: ``colorbalance`` float32 three-zone math (``pl=0``)
26
+ color.code_to_premultiplied : straight code -> premultiplied linear
27
+
28
+ Reference filter chains (``src/bladeworks/core/color.py``; ffmpeg 8.0.1 auto-negotiates
29
+ the pixel formats, ``ffmpeg -v verbose`` shows the two ``auto_scale`` insertions):
30
+
31
+ Color Adjustments (always emits ``eq``):
32
+ RGB --auto_scale--> yuva444p eq=brightness:contrast:saturation:gamma
33
+ --auto_scale--> RGB [colorlevels=rimin=gimin=bimin=k] (black point > 0)
34
+ [colorbalance=rs:bs:rm:bm:rh:bh:gs:gm:gh] (warmth/tint)
35
+ Color Board (every filter optional):
36
+ [eq=saturation=s] (same yuva444p bridge) [curves=master='5 points'] [colorbalance=...]
37
+
38
+ Which bridge: the negotiated ``eq`` link (documented here so E1/E2 can reuse the pattern)
39
+ --------------------------------------------------------------------------------------
40
+ ``eq`` only accepts 8-bit YUV formats, so libavfilter inserts ``scale`` (libswscale) around
41
+ it. What that scale computes depends on three facts of the *reference graph*, all of which
42
+ were read off ``ffmpeg -v verbose`` on real reference graphs and are modelled by
43
+ ``BridgeLink`` = ``<pix_fmt>:<colorspace>:<range>``:
44
+
45
+ * ``pix_fmt`` of the RGB frame entering ``eq``: ``rgba`` after a bare ``format=rgba`` chain
46
+ (same-size clip, trim crop, ``conform none``, folded group effects) or ``gbrap`` after a
47
+ ``perspective`` stage (conform resample, camera crop, Ken Burns) -- ``perspective`` only
48
+ supports 8-bit planar formats. It decides the alpha path (``rgbaToA_c`` replicates bit 7:
49
+ A >= 128 gains one code; ``planar_rgb_to_a`` is exact).
50
+ * ``colorspace`` of the YUV link: libavfilter propagates the *source stream's* colorspace tag
51
+ through the RGB filters to the YUV link -- ``bt709`` for tagged camera media, unknown /
52
+ ``gbr`` (PNG rasters, titles) or ``bt470bg`` (JPEG) -> the ``SWS_CS_DEFAULT`` (601) table.
53
+ Others (bt2020nc, smpte240m, fcc, ycgco) are not ported and reject loudly.
54
+ * ``range`` of the YUV link: the source's range tag (``tv`` / unknown -> limited; ``pc`` for
55
+ JPEG / yuvj sources -> full), or ``pc`` whenever ``setparams=range=full`` (every
56
+ ``perspective`` stage) precedes the effects.
57
+
58
+ ``reference_effect_link(...)`` derives the link from plan facts; the plan passes it as
59
+ ``LowerContext.reference_effect_link``. All eight links were verified bit-exact against
60
+ ``ffmpeg`` on all 2**24 RGB triples and all 2**24 YUV triples (``probe_swscale`` run,
61
+ 2026-08-17; the test file re-checks each on a 1M-pixel plate). No dithering anywhere (8-bit
62
+ source: ``ff_sws_pb_64`` -> ``+64 >> 7``).
63
+
64
+ RGB -> YUV (``input.c`` ``rgb32ToY_c`` / ``rgb32ToUV_c`` = ``planar_rgb_to_y`` / ``_uv``,
65
+ ``hScale16To15`` (x2, FFMIN 32767), ``lumRangeToJpeg_c`` / ``chrRangeToJpeg_c`` for ``pc``,
66
+ ``yuv2plane1_8_c``):
67
+
68
+ RY..BV = rgb2yuv_table(colorspace) # fill_rgb2yuv_table (utils.c), 15-bit, ROUNDED_DIV
69
+ bt601: 8414 16519 3208 | -4865 -9528 14392 | 14392 -12061 -2332 (fixed constants)
70
+ bt709: 5983 20127 2032 | -3298 -11094 14392 | 14392 -13073 -1320
71
+ y14 = (RY*R + GY*G + BY*B + (32 << 14) + (1 << 8)) >> 9 # 15-bit sum -> 14-bit, offset 16
72
+ u14 = (RU*R + GU*G + BU*B + (256 << 14) + (1 << 8)) >> 9 # offset 128
73
+ v14 = (RV*R + GV*G + BV*B + (256 << 14) + (1 << 8)) >> 9
74
+ x15 = min(2*x14, 32767)
75
+ pc: x15 = min((x15*coeff + offset) >> 14, 32767) with (coeff, offset) = solve_range_convert
76
+ (n8.0.1: luma (19078, -39084288), chroma (18652, -38207488); n8.0 lacked the +2**13
77
+ rounding term and lands 0.4% of codes one lower)
78
+ X8 = clip((x15 + 64) >> 7)
79
+ A8' = rgba: min(A8 + (A8 >= 128), 255) ; gbrap: A8
80
+
81
+ YUV -> RGB (``output.c`` ``yuv2rgb_write_full`` for ``rgba``, ``yuv2gbrp_full_X_c`` for
82
+ ``gbrap`` -- identical arithmetic; ``ff_yuv2rgb_c_init_tables`` per (colorspace, range)):
83
+
84
+ (y_coeff, y_off>>9, v2r, v2g, u2g, u2b) = yuv2rgb_coefficients(colorspace, full_range)
85
+ bt601 tv: 9539 16 13075 -6660 -3209 16525 bt601 pc: 8192 0 11485 -5850 -2819 14516
86
+ bt709 tv: 9539 16 14686 -4366 -1747 17305 bt709 pc: 8192 0 12901 -3835 -1534 15201
87
+ Y = Y8 << 9 ; U = (U8 - 128) << 9 ; V = (V8 - 128) << 9
88
+ t = (Y - (y_off << 9)) * y_coeff + (1 << 21)
89
+ R = t + V*v2r ; G = t + V*v2g + U*u2g ; B = t + U*u2b (32-bit int arithmetic)
90
+ if (R|G|B) & 0xC0000000: each is av_clip_uintp2(x, 30) -> negative OR wrapped-past-2**31 -> 0
91
+ R8 = R >> 22 (etc.); A8 copied exactly.
92
+ In the >>9-reduced form used here: T = (Y8-y_off)*y_coeff + 4096 + chroma terms;
93
+ R8 = 0 if T < 0 or T >= 2**22 (the B plane really reaches the 2**31 wrap for Y8=255, U8=255
94
+ in the limited sets and swscale returns 0 there -- reproduced, not "fixed"), 255 if
95
+ T >= 2**21, else T >> 13.
96
+
97
+ Post-``eq`` precision: when the effect stack is followed by a spatial tail (static transform /
98
+ corner pin: ``format=rgba64le,lutrgb...``), libavfilter negotiates the post-``eq`` RGB link to
99
+ ``rgba64le`` and ``colorlevels`` / ``colorbalance`` / ``curves`` run at 16 bits until the
100
+ tail's ``perspective`` quantises to 8-bit ``gbrap``; the port always runs them at 8 bits (the
101
+ no-tail negotiation), which is within one code of that -- soft-match territory, recorded in
102
+ the E4 report as a ledger candidate.
103
+
104
+ ``eq`` (``vf_eq.c``, per plane, alpha plane copied): luma uses ``process_c`` when gamma == 1
105
+ (``c = int(contrast*4096)``, ``b = ((int)(100*brightness+100)*511)/200 - 128 - c/32`` in C
106
+ (truncating) division, controls float32-rounded like ``av_clipf`` -- ``eq_process_lut``,
107
+ ``pel = clip(((src*c) >> 12) + b)``) and the double LUT ``256.0 * pow(contrast*(v-0.5)+0.5+
108
+ brightness, 1/gamma)`` (truncated, 0 for v <= 0, 255 for v >= 1) otherwise; chroma uses
109
+ ``process_c`` with ``contrast = saturation`` and ``brightness = 0``, or is copied when
110
+ saturation == 1.0. All three are 8-bit LUTs, computed at plan time in Python integers /
111
+ doubles. The C x86 SIMD path (``psllw 4; pmulhw``) equals ``process_c`` bit for bit.
112
+
113
+ ``colorlevels`` (8-bit RGB, packed or planar): ``imin = lrint(k*255)``, ``coeff = float32(255/(255-imin))``,
114
+ ``dst = clip(trunc(float32(src - imin) * coeff))``; alpha coeff 1.0 -> copied.
115
+ ``colorbalance`` (8-bit RGB, float32, ``pl=0``): ``l = max(r,g,b) + min(r,g,b)`` on ``x/255``;
116
+ zone weights ``clipf((0.333 - l)*4 + 0.5) * 0.7`` (shadows), ``clipf((l-0.333)*4+0.5) *
117
+ clipf((1-l-0.333)*4+0.5) * 0.7`` (midtones), ``clipf((l+0.333-1)*4+0.5) * 0.7`` (highlights);
118
+ ``v = clipf(v + s + m + h)``; ``lrintf(v*255)`` (half-to-even); alpha copied. The
119
+ reference binary contracts ``x*a + 0.5f`` into FMA on arm64, so this stage is <= 1 code, not
120
+ bit-exact, and the goldens gate it at 1.
121
+ ``curves=master=...`` (8-bit RGB): natural cubic spline through the points in double
122
+ (``interpolate()``), ``clip((int)(y*255))`` per code, applied to R, G, B; alpha copied.
123
+
124
+ Precision contract: every stage is 8-bit-in / 8-bit-out exactly like the reference link
125
+ between filters (the reference's bridge loss IS the calibrated look and is reproduced, not
126
+ avoided); the input frame is the working canvas' straight code rounded to the nearest
127
+ integer (the uint8 ``format=rgba`` / ``gbrap`` frame the reference feeds ``eq``). Integer
128
+ stages run in int32 on the canvas device (MPS-safe: no int64, no float64).
129
+
130
+ Main callers:
131
+ - ``effects.lower_effect`` / ``effects.apply_effects`` through the registry
132
+ (``EFFECT_PORTS["color_adjustments"]``, ``EFFECT_PORTS["color_board"]``).
133
+
134
+ Why this exists:
135
+ Color Adjustments is the one calibrated colour handler in the corpus, and its look is
136
+ inseparable from the accidental 8-bit YUV bridge the CPU graph pays for ``eq``. A
137
+ "cleaner" float port would not soft-match the reference (the oracle in
138
+ ``evidence/color_adjustments_shared_plan_v3.json`` is +-1 code against that bridge), so
139
+ the port reproduces libswscale / libavfilter integer semantics exactly and keeps the whole
140
+ parameter lowering on the CPU emitter (``color.color_effect_filters``) so both backends can
141
+ never disagree about what the authored controls mean.
142
+ """
143
+
144
+ from __future__ import annotations
145
+
146
+ import math
147
+ from dataclasses import dataclass
148
+ from functools import lru_cache
149
+ from typing import Final, Mapping, Sequence
150
+
151
+ import numpy as np
152
+ import torch
153
+
154
+ from ..core.color import color_effect_filters
155
+ from ..core.model import ResolvedEffect
156
+ from .color import code_to_premultiplied, premultiplied_to_code
157
+ from .effects import ApplyContext, EffectPort, LowerContext, register
158
+ from .support import reject
159
+
160
+ # --------------------------------------------------------------------------- swscale model
161
+ #
162
+ # The pixel link libavfilter negotiates around ``eq`` (see the module doc "which bridge"):
163
+ # input pixel format (alpha path), the YUV colour matrix (the *source stream's* colorspace
164
+ # tag propagates to the YUV link) and the YUV range (source range tag, or ``pc`` after
165
+ # ``setparams=range=full``). Everything below is libswscale n8.0.1 integer arithmetic,
166
+ # verified bit-exact against ``ffmpeg`` on all 2**24 triples for every variant.
167
+
168
+ LINK_FORMATS: Final = ("rgba", "gbrap")
169
+ LINK_COLORSPACES: Final = ("bt601", "bt709") # bt601 = the SWS_CS_DEFAULT table (unknown / gbr / bt470bg / smpte170m)
170
+ LINK_RANGES: Final = ("tv", "pc")
171
+
172
+ # ff_yuv2rgb_coeffs (yuv2rgb.c): {crv, cbu, cgu, cgv} per colourspace; the 601 row is SWS_CS_DEFAULT.
173
+ _CSP_TABLES: Final[Mapping[str, tuple[int, int, int, int]]] = {
174
+ "bt601": (104597, 132201, 25675, 53279),
175
+ "bt709": (117489, 138438, 13975, 34925),
176
+ }
177
+ # ffprobe / libavfilter colourspace names -> the swscale table they select (sws_getCoefficients:
178
+ # RGB(0) / unspecified(2) / reserved(3) / bt470bg(5) / smpte170m(6) / >10 all use the default row).
179
+ LINK_COLORSPACE_ALIASES: Final[Mapping[str, str]] = {
180
+ "bt709": "bt709",
181
+ "bt601": "bt601", "bt470bg": "bt601", "smpte170m": "bt601", "unknown": "bt601",
182
+ "unspecified": "bt601", "reserved": "bt601", "gbr": "bt601", "rgb": "bt601",
183
+ }
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class BridgeLink:
188
+ """The negotiated ``eq`` link: ``<pix_fmt>:<colorspace>:<range>``, e.g. ``rgba:bt709:tv``."""
189
+
190
+ pix_fmt: str # "rgba" (rgbaToA_c alpha quirk) | "gbrap" (alpha exact)
191
+ colorspace: str # "bt601" | "bt709"
192
+ color_range: str # "tv" (limited, also unknown) | "pc" (full)
193
+
194
+ @staticmethod
195
+ def parse(text: str) -> "BridgeLink":
196
+ """``<pix_fmt>:<colorspace>:<range>`` -> link; ``ValueError`` names what is not portable."""
197
+
198
+ parts = text.split(":")
199
+ if len(parts) != 3:
200
+ raise ValueError(f"reference effect link {text!r} is not '<pix_fmt>:<colorspace>:<range>'")
201
+ pix_fmt, colorspace, color_range = parts
202
+ if pix_fmt not in LINK_FORMATS:
203
+ raise ValueError(f"reference effect link {text!r}: pixel format {pix_fmt!r} not in {LINK_FORMATS}")
204
+ if colorspace not in LINK_COLORSPACES:
205
+ raise ValueError(
206
+ f"reference effect link {text!r}: colourspace {colorspace!r} is not one of the ported eq "
207
+ f"bridge matrices {LINK_COLORSPACES} (aliases {sorted(set(LINK_COLORSPACE_ALIASES))})"
208
+ )
209
+ if color_range not in LINK_RANGES:
210
+ raise ValueError(f"reference effect link {text!r}: range {color_range!r} not in {LINK_RANGES}")
211
+ return BridgeLink(pix_fmt, colorspace, color_range)
212
+
213
+ @property
214
+ def full_range(self) -> bool:
215
+ return self.color_range == "pc"
216
+
217
+ def __str__(self) -> str:
218
+ return f"{self.pix_fmt}:{self.colorspace}:{self.color_range}"
219
+
220
+
221
+ def _c_div(a: int, b: int) -> int:
222
+ """C integer division (truncates toward zero)."""
223
+
224
+ q = abs(a) // abs(b)
225
+ return q if (a >= 0) == (b > 0) else -q
226
+
227
+
228
+ def _rounded_div(a: int, b: int) -> int:
229
+ """FFmpeg ``ROUNDED_DIV`` (b > 0): ``(a +- b/2) / b`` with C truncation."""
230
+
231
+ return _c_div(a + (b >> 1) if a >= 0 else a - (b >> 1), b)
232
+
233
+
234
+ def _round_to_int16(value: int) -> int:
235
+ """``roundToInt16`` (yuv2rgb.c) without the +-0x7FFF saturation (never reached here)."""
236
+
237
+ return (value + (1 << 15)) >> 16
238
+
239
+
240
+ @lru_cache(maxsize=None)
241
+ def rgb2yuv_table(colorspace: str) -> tuple[int, int, int, int, int, int, int, int, int]:
242
+ """``fill_rgb2yuv_table`` (utils.c): (RY, GY, BY, RU, GU, BU, RV, GV, BV), 15-bit fixed point.
243
+
244
+ ``dstRange`` is forced to 0 in that function (range conversion happens elsewhere), so the
245
+ table is always the limited-range one; the default (601) table is overwritten with the
246
+ fixed ``(int)(coef * 219/255 [Y] or 224/255 [UV] * 32768 + 0.5)`` constants.
247
+ """
248
+
249
+ table = _CSP_TABLES[colorspace]
250
+ vr, ub, ug, vg = table[0], table[1], -table[2], -table[3]
251
+ one = 65536
252
+ cy = one * 255 // 219
253
+ w = _rounded_div(one * one * ug, ub)
254
+ v = _rounded_div(one * one * vg, vr)
255
+ z = one * one - w - v
256
+ c_y, c_u, c_v = _rounded_div(cy * z, one), _rounded_div(ub * z, one), _rounded_div(vr * z, one)
257
+ s = 1 << 15
258
+ ry, gy, by = -_rounded_div(s * v, c_y), _rounded_div(s * one * one, c_y), -_rounded_div(s * w, c_y)
259
+ ru, gu, bu = _rounded_div(s * v, c_u), -_rounded_div(s * one * one, c_u), _rounded_div(s * (z + w), c_u)
260
+ rv, gv, bv = _rounded_div(s * (v + z), c_v), -_rounded_div(s * one * one, c_v), _rounded_div(s * w, c_v)
261
+ if table == _CSP_TABLES["bt601"]:
262
+ by, bv, bu = int(0.114 * 219 / 255 * s + 0.5), -int(0.081 * 224 / 255 * s + 0.5), int(0.500 * 224 / 255 * s + 0.5)
263
+ gy, gv, gu = int(0.587 * 219 / 255 * s + 0.5), -int(0.419 * 224 / 255 * s + 0.5), -int(0.331 * 224 / 255 * s + 0.5)
264
+ ry, rv, ru = int(0.299 * 219 / 255 * s + 0.5), int(0.500 * 224 / 255 * s + 0.5), -int(0.169 * 224 / 255 * s + 0.5)
265
+ return ry, gy, by, ru, gu, bu, rv, gv, bv
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class _Yuv2Rgb:
270
+ """``ff_yuv2rgb_c_init_tables`` (yuv2rgb.c) for one (colourspace, source range): the six int16
271
+ coefficients ``yuv2rgb_write_full`` / ``yuv2gbrp_full_X_c`` use (brightness 0, contrast 1,
272
+ saturation 1)."""
273
+
274
+ y_coeff: int
275
+ y_offset: int # already >> 9 (the port works on the accumulator >> 9)
276
+ v2r: int
277
+ v2g: int
278
+ u2g: int
279
+ u2b: int
280
+
281
+
282
+ @lru_cache(maxsize=None)
283
+ def yuv2rgb_coefficients(colorspace: str, full_range: bool) -> _Yuv2Rgb:
284
+ """``ff_yuv2rgb_c_init_tables`` for ``ff_yuv2rgb_coeffs[colorspace]`` and the YUV source range."""
285
+
286
+ crv, cbu, cgu, cgv = _CSP_TABLES[colorspace]
287
+ cgu, cgv = -cgu, -cgv
288
+ cy, oy = 1 << 16, 0
289
+ if full_range:
290
+ crv, cbu, cgu, cgv = (_c_div(v * 224, 255) for v in (crv, cbu, cgu, cgv))
291
+ else:
292
+ cy = (cy * 255) // 219
293
+ oy = 16 << 16
294
+ return _Yuv2Rgb(
295
+ y_coeff=_round_to_int16(cy << 13),
296
+ y_offset=_round_to_int16(oy << 9) >> 9,
297
+ v2r=_round_to_int16(crv << 13), v2g=_round_to_int16(cgv << 13),
298
+ u2g=_round_to_int16(cgu << 13), u2b=_round_to_int16(cbu << 13),
299
+ )
300
+
301
+
302
+ def _solve_range_convert(src_min: int, src_max: int, dst_min: int, dst_max: int) -> tuple[int, int]:
303
+ """``solve_range_convert`` (swscale.c, n8.0.1) for an 8-bit destination: 15-bit source,
304
+ ``src_shift`` 7, ``mult_shift`` 14; the ``+ (1 << 13)`` rounding term in the offset is
305
+ the n8.0.1 fix (n8.0 lacked it and lands 0.4% of codes one lower)."""
306
+
307
+ src_shift, mult_shift = 7, 14
308
+ total_shift = mult_shift + src_shift
309
+ coeff = -((-(((dst_max - dst_min) << total_shift) // (src_max - src_min))) >> src_shift)
310
+ offset = (dst_max << total_shift) - (src_max << src_shift) * coeff + (1 << (mult_shift - 1))
311
+ return coeff, offset
312
+
313
+
314
+ _LUM_TO_JPEG: Final = _solve_range_convert(16, 235, 0, 255) # (19078, -39084288)
315
+ _CHR_TO_JPEG: Final = _solve_range_convert(16, 240, 0, 255) # (18652, -38207488)
316
+
317
+
318
+ # --------------------------------------------------------------------------- payload types
319
+
320
+
321
+ @dataclass(frozen=True)
322
+ class YuvBridgeStage:
323
+ """RGB -> yuva444p -> ``eq`` -> RGB: the bridge variant plus three per-plane 8-bit LUTs."""
324
+
325
+ link: BridgeLink # the negotiated eq link (pix_fmt / colourspace / range)
326
+ y_lut: tuple[int, ...]
327
+ u_lut: tuple[int, ...]
328
+ v_lut: tuple[int, ...]
329
+
330
+
331
+ @dataclass(frozen=True)
332
+ class LutStage:
333
+ """Per-channel 8-bit LUT on straight RGB (``colorlevels``, ``curves``); alpha copied."""
334
+
335
+ r_lut: tuple[int, ...]
336
+ g_lut: tuple[int, ...]
337
+ b_lut: tuple[int, ...]
338
+
339
+
340
+ @dataclass(frozen=True)
341
+ class ColorBalanceStage:
342
+ """``colorbalance`` option values as float32-rounded floats (rs gs bs rm gm bm rh gh bh)."""
343
+
344
+ shadows: tuple[float, float, float]
345
+ midtones: tuple[float, float, float]
346
+ highlights: tuple[float, float, float]
347
+
348
+
349
+ Stage = YuvBridgeStage | LutStage | ColorBalanceStage
350
+
351
+
352
+ @dataclass(frozen=True)
353
+ class ColorPipelinePayload:
354
+ filters: tuple[str, ...] # the exact CPU filter strings (diagnostics)
355
+ stages: tuple[Stage, ...]
356
+
357
+
358
+ # --------------------------------------------------------------------------- lowering
359
+
360
+
361
+ def _split_options(handler_path: str, name: str, args: str) -> dict[str, str]:
362
+ """``k=v:k=v`` -> dict; quoted values (``master='...'``) unquoted. Positional -> reject."""
363
+
364
+ options: dict[str, str] = {}
365
+ for item in _split_top_level(args):
366
+ if not item:
367
+ continue
368
+ if "=" not in item:
369
+ raise reject("effect (unsupported parameters)", f"{handler_path}: {name} positional option {item!r}")
370
+ key, value = item.split("=", 1)
371
+ options[key.strip()] = value.strip().strip("'\"")
372
+ return options
373
+
374
+
375
+ def _split_top_level(args: str) -> list[str]:
376
+ """Split on ``:`` outside single quotes (``curves=master='0/0 0.2/0.21 ...'``)."""
377
+
378
+ parts: list[str] = []
379
+ current: list[str] = []
380
+ quoted = False
381
+ for char in args:
382
+ if char == "'":
383
+ quoted = not quoted
384
+ if char == ":" and not quoted:
385
+ parts.append("".join(current))
386
+ current = []
387
+ else:
388
+ current.append(char)
389
+ parts.append("".join(current))
390
+ return parts
391
+
392
+
393
+ def _option(options: dict[str, str], key: str, default: float) -> float:
394
+ return float(options.pop(key)) if key in options else default
395
+
396
+
397
+ def _reject_leftovers(handler_path: str, name: str, options: dict[str, str]) -> None:
398
+ if options:
399
+ raise reject(
400
+ "effect (unsupported parameters)",
401
+ f"{handler_path}: {name} option(s) {sorted(options)} are not ported",
402
+ )
403
+
404
+
405
+ def _clipf(value: float, low: float, high: float) -> float:
406
+ return max(low, min(high, value))
407
+
408
+
409
+ def _clipf32(value: float, low: float, high: float) -> float:
410
+ """``av_clipf`` semantics: the double is rounded to float32 (the parameter type) and clipped."""
411
+
412
+ return _clipf(float(np.float32(value)), low, high)
413
+
414
+
415
+ # ---- eq ------------------------------------------------------------------------------------
416
+
417
+
418
+ def eq_process_lut(contrast: float, brightness: float) -> tuple[int, ...]:
419
+ """``process_c`` (vf_eq.h) as a LUT: ``((src * c) >> 12) + b`` clipped.
420
+
421
+ ``vf_eq.c`` stores every control through ``av_clipf`` (a *float* function), so the LUT is
422
+ built from the float32-rounded values promoted back to double: ``brightness=-0.28`` makes
423
+ ``100*b + 100`` = 71.9999998... -> ``(int)`` 71 (Y-75), where a plain double gives 72 (Y-73).
424
+ Callers that already went through ``_clipf32`` are unaffected (rounding is idempotent);
425
+ direct callers (``fx_branched`` Callout constants) rely on it. Divisions use C truncation
426
+ (``c`` is negative for negative contrast).
427
+
428
+ Main callers: ``_eq_stage`` (E4 eq bridge), ``fx_branched`` (Callout field / chroma LUTs).
429
+ """
430
+
431
+ contrast = float(np.float32(contrast))
432
+ brightness = float(np.float32(brightness))
433
+ c = int(contrast * 256 * 16)
434
+ b = _c_div(int(100.0 * brightness + 100.0) * 511, 200) - 128 - _c_div(c, 32)
435
+ return tuple(min(255, max(0, ((src * c) >> 12) + b)) for src in range(256))
436
+
437
+
438
+ def _eq_gamma_lut(contrast: float, brightness: float, gamma: float, gamma_weight: float = 1.0) -> tuple[int, ...]:
439
+ """``create_lut`` (vf_eq.c): double math, ``(uint8_t)(256.0 * v)`` truncation."""
440
+
441
+ g = 1.0 / gamma
442
+ lw = 1.0 - gamma_weight
443
+ lut = []
444
+ for i in range(256):
445
+ v = i / 255.0
446
+ v = contrast * (v - 0.5) + 0.5 + brightness
447
+ if v <= 0.0:
448
+ lut.append(0)
449
+ continue
450
+ v = v * lw + math.pow(v, g) * gamma_weight
451
+ lut.append(255 if v >= 1.0 else int(256.0 * v))
452
+ return tuple(lut)
453
+
454
+
455
+ _IDENTITY_LUT: Final = tuple(range(256))
456
+
457
+
458
+ def _eq_stage(handler_path: str, options: dict[str, str], link: BridgeLink) -> YuvBridgeStage:
459
+ """``eq=...`` -> per-plane LUTs with vf_eq.c's ``check_values`` dispatch (option clamps included)."""
460
+
461
+ # ``av_clipf`` takes *float* arguments: every eq control is rounded to float32 on its way
462
+ # into the double fields (``brightness=-0.4`` really is -0.4000000059604645, which is what
463
+ # makes ``(int)(100*b + 100)`` = 59, not 60).
464
+ contrast = _clipf32(_option(options, "contrast", 1.0), -1000.0, 1000.0)
465
+ brightness = _clipf32(_option(options, "brightness", 0.0), -1.0, 1.0)
466
+ saturation = _clipf32(_option(options, "saturation", 1.0), 0.0, 3.0)
467
+ gamma = _clipf32(_option(options, "gamma", 1.0), 0.1, 10.0)
468
+ _reject_leftovers(handler_path, "eq", options) # gamma_r/g/b/weight/eval are never emitted
469
+ # Luma: param[0] = (contrast, brightness, gamma * gamma_g=1).
470
+ if contrast == 1.0 and brightness == 0.0 and gamma == 1.0:
471
+ y_lut = _IDENTITY_LUT
472
+ elif gamma == 1.0 and abs(contrast) < 7.9:
473
+ y_lut = eq_process_lut(contrast, brightness)
474
+ else:
475
+ y_lut = _eq_gamma_lut(contrast, brightness, gamma)
476
+ # Chroma: param[1..2] = (saturation, 0, sqrt(gamma_b/gamma_g) = 1.0).
477
+ if saturation == 1.0:
478
+ chroma_lut = _IDENTITY_LUT
479
+ elif abs(saturation) < 7.9:
480
+ chroma_lut = eq_process_lut(saturation, 0.0)
481
+ else: # unreachable (saturation is clipped to 3.0) but keeps the C dispatch complete
482
+ chroma_lut = _eq_gamma_lut(saturation, 0.0, 1.0)
483
+ return YuvBridgeStage(link=link, y_lut=y_lut, u_lut=chroma_lut, v_lut=chroma_lut)
484
+
485
+
486
+ # ---- colorlevels ---------------------------------------------------------------------------
487
+
488
+
489
+ def _colorlevels_lut(in_min: float, in_max: float, out_min: float, out_max: float) -> tuple[int, ...]:
490
+ """8-bit ``colorlevels`` channel LUT: ``clip((int)(float32(src - imin) * coeff + omin))``."""
491
+
492
+ imin = int(round(in_min * 255)) # lrint (half-even; the emitted values never sit on .5)
493
+ imax = int(round(in_max * 255))
494
+ omin = int(round(out_min * 255))
495
+ omax = int(round(out_max * 255))
496
+ if imin < 0 or imax < 0:
497
+ raise ValueError("colorlevels auto (negative) input levels are not emitted by the reference")
498
+ coeff = np.float32((omax - omin) / float(imax - imin))
499
+ values = np.float32(np.arange(256) - imin) * coeff + np.float32(omin)
500
+ return tuple(int(v) for v in np.clip(np.trunc(values.astype(np.float64)), 0, 255))
501
+
502
+
503
+ def _colorlevels_stage(handler_path: str, options: dict[str, str]) -> LutStage:
504
+ luts = []
505
+ for channel in "rgb":
506
+ luts.append(_colorlevels_lut(
507
+ _option(options, f"{channel}imin", 0.0), _option(options, f"{channel}imax", 1.0),
508
+ _option(options, f"{channel}omin", 0.0), _option(options, f"{channel}omax", 1.0),
509
+ ))
510
+ # Alpha levels default to identity (aimin=0 aimax=1 aomin=0 aomax=1); the emitter never sets them.
511
+ for key in ("aimin", "aimax", "aomin", "aomax", "preserve"):
512
+ if key in options:
513
+ raise reject("effect (unsupported parameters)", f"{handler_path}: colorlevels {key} is not ported")
514
+ _reject_leftovers(handler_path, "colorlevels", options)
515
+ return LutStage(*luts)
516
+
517
+
518
+ # ---- colorbalance --------------------------------------------------------------------------
519
+
520
+
521
+ def _colorbalance_stage(handler_path: str, options: dict[str, str]) -> ColorBalanceStage:
522
+ def zone(suffix: str) -> tuple[float, float, float]:
523
+ return tuple( # type: ignore[return-value]
524
+ float(np.float32(_clipf(_option(options, f"{channel}{suffix}", 0.0), -1.0, 1.0)))
525
+ for channel in "rgb"
526
+ )
527
+
528
+ shadows, midtones, highlights = zone("s"), zone("m"), zone("h")
529
+ if _option(options, "pl", 0.0) != 0.0:
530
+ raise reject("effect (unsupported parameters)", f"{handler_path}: colorbalance pl=1 (preserve lightness) is not ported")
531
+ _reject_leftovers(handler_path, "colorbalance", options)
532
+ return ColorBalanceStage(shadows=shadows, midtones=midtones, highlights=highlights)
533
+
534
+
535
+ # ---- curves --------------------------------------------------------------------------------
536
+
537
+
538
+ def _curves_lut(points: Sequence[tuple[float, float]]) -> tuple[int, ...]:
539
+ """``interpolate()`` (vf_curves.c, natural cubic spline, 8-bit): the master LUT."""
540
+
541
+ scale = 255
542
+ n = len(points)
543
+ if n == 0:
544
+ return _IDENTITY_LUT
545
+ if n == 1:
546
+ return tuple([min(255, max(0, int(points[0][1] * scale)))] * 256)
547
+ xs = [p[0] for p in points]
548
+ ys = [p[1] for p in points]
549
+ h = [xs[i + 1] - xs[i] for i in range(n - 1)]
550
+ r = [0.0] * n
551
+ for i in range(1, n - 1):
552
+ r[i] = 6 * ((ys[i + 1] - ys[i]) / h[i] - (ys[i] - ys[i - 1]) / h[i - 1])
553
+ bd = [0.0] * n
554
+ md = [0.0] * n
555
+ ad = [0.0] * n
556
+ md[0] = md[n - 1] = 1.0
557
+ for i in range(1, n - 1):
558
+ bd[i] = h[i - 1]
559
+ md[i] = 2 * (h[i - 1] + h[i])
560
+ ad[i] = h[i]
561
+ for i in range(1, n):
562
+ den = md[i] - bd[i] * ad[i - 1]
563
+ k = 1.0 / den if den else 1.0
564
+ ad[i] *= k
565
+ r[i] = (r[i] - bd[i] * r[i - 1]) * k
566
+ for i in range(n - 2, -1, -1):
567
+ r[i] = r[i] - ad[i] * r[i + 1]
568
+ lut = [0] * 256
569
+
570
+ def clip8(value: float) -> int:
571
+ return min(255, max(0, int(value))) # av_clip_uint8((int) v): C truncation
572
+
573
+ for i in range(int(xs[0] * scale)):
574
+ lut[i] = clip8(ys[0] * scale)
575
+ for i in range(n - 1):
576
+ yc, yn = ys[i], ys[i + 1]
577
+ a = yc
578
+ b = (yn - yc) / h[i] - h[i] * r[i] / 2.0 - h[i] * (r[i + 1] - r[i]) / 6.0
579
+ c = r[i] / 2.0
580
+ d = (r[i + 1] - r[i]) / (6.0 * h[i])
581
+ x_start = int(xs[i] * scale)
582
+ x_end = int(xs[i + 1] * scale)
583
+ for x in range(x_start, x_end + 1):
584
+ xx = (x - x_start) * 1.0 / scale
585
+ yy = a + b * xx + c * xx * xx + d * xx * xx * xx
586
+ lut[x] = clip8(yy * scale)
587
+ for i in range(int(xs[-1] * scale), 256):
588
+ lut[i] = clip8(ys[-1] * scale)
589
+ return tuple(lut)
590
+
591
+
592
+ def _curves_stage(handler_path: str, options: dict[str, str]) -> LutStage:
593
+ master = options.pop("master", options.pop("m", None))
594
+ if master is None:
595
+ raise reject("effect (unsupported parameters)", f"{handler_path}: curves without a master curve")
596
+ _reject_leftovers(handler_path, "curves", options) # r/g/b/all/preset/psfile/interp never emitted
597
+ points: list[tuple[float, float]] = []
598
+ for token in master.split():
599
+ x_text, y_text = token.split("/")
600
+ x, y = float(x_text), float(y_text)
601
+ if not (0.0 <= x <= 1.0 and 0.0 <= y <= 1.0):
602
+ raise reject("effect (unsupported parameters)", f"{handler_path}: curves point {token!r} outside [0, 1]")
603
+ if points and int(points[-1][0] * 255) >= int(x * 255):
604
+ raise reject("effect (unsupported parameters)", f"{handler_path}: curves points not strictly increasing ({token!r})")
605
+ points.append((x, y))
606
+ lut = _curves_lut(points)
607
+ return LutStage(lut, lut, lut) # component curves are identity, composed with master
608
+
609
+
610
+ _STAGE_PARSERS: Final = {
611
+ "colorlevels": _colorlevels_stage,
612
+ "colorbalance": _colorbalance_stage,
613
+ "curves": _curves_stage,
614
+ }
615
+
616
+
617
+ def _parse_stage(handler_path: str, filter_string: str, link: BridgeLink | None) -> Stage:
618
+ name, _, args = filter_string.partition("=")
619
+ if name == "eq":
620
+ if link is None:
621
+ raise reject(
622
+ "effect (unsupported parameters)",
623
+ f"{handler_path}: the plan did not resolve which pixel link the reference feeds "
624
+ "this effect stack (LowerContext.reference_effect_link is unset; see "
625
+ "fx_color.reference_effect_link)",
626
+ )
627
+ return _eq_stage(handler_path, _split_options(handler_path, name, args), link)
628
+ parser = _STAGE_PARSERS.get(name)
629
+ if parser is None:
630
+ raise reject("effect (unsupported parameters)", f"{handler_path}: reference filter {name!r} is not ported")
631
+ return parser(handler_path, _split_options(handler_path, name, args))
632
+
633
+
634
+ def reference_effect_link(
635
+ *,
636
+ source_color_space: str | None,
637
+ source_color_range: str | None,
638
+ source_width: int,
639
+ source_height: int,
640
+ canvas_width: int,
641
+ canvas_height: int,
642
+ conform: str,
643
+ crop_mode: str | None,
644
+ folded_group_effect: bool = False,
645
+ ) -> str:
646
+ """Which pixel link the reference graph feeds an effect stack through (``<fmt>:<csp>:<range>``).
647
+
648
+ Read from ``ffmpeg._video_chain`` / ``_geometry_stage_filters`` / ``GeometryPlan._conform_filters``
649
+ and verified with ``ffmpeg -v verbose`` on real reference graphs (see the module doc):
650
+
651
+ * pixel format + range: the effect stack follows the pre-effect geometry stages, which
652
+ contain a ``perspective`` (always preceded by ``setparams=range=full`` and negotiated in
653
+ 8-bit planar ``gbrap``) exactly when the conform resamples (Fit / Fill with a source size
654
+ different from the canvas), the crop is a camera crop, or the crop is an animated pan
655
+ (Ken Burns) -> ``gbrap`` / ``pc``. Otherwise the stack follows a bare ``format=rgba``
656
+ (+ ``crop`` / ``pad`` for trim / ``conform none``) -> ``rgba`` with the *source's* range
657
+ tag (``tv`` for tv / unknown, ``pc`` for full-range sources such as JPEG rasters). Static
658
+ transforms / corner pins live in the spatial *tail* (after effects). Folded *group*
659
+ effects run on the group surface after a bare ``format=rgba`` -> ``rgba`` / source range.
660
+ * colourspace: libavfilter propagates the source stream's colorspace tag to the YUV link
661
+ (``bt709`` for tagged camera media, unknown / ``gbr`` for PNG rasters -> the 601 default,
662
+ ``bt470bg`` for JPEG); anything outside the two ported matrices rejects loudly.
663
+
664
+ Main callers:
665
+ - ``plan._effect_specs`` (requested wiring: passes the result as
666
+ ``LowerContext.reference_effect_link``); tests.
667
+ """
668
+
669
+ # Unported matrices are carried through by name (e.g. ``rgba:bt2020nc:tv``) and reject only
670
+ # when a chain actually needs the bridge (``_lower_color``), so a bt2020 source without
671
+ # colour effects never trips the plan.
672
+ csp_name = (source_color_space or "unknown").lower()
673
+ colorspace = LINK_COLORSPACE_ALIASES.get(csp_name, csp_name)
674
+ resamples = (not folded_group_effect) and (
675
+ crop_mode in ("crop", "pan")
676
+ or (conform in ("fit", "fill") and (source_width, source_height) != (canvas_width, canvas_height))
677
+ )
678
+ if resamples:
679
+ return str(BridgeLink("gbrap", colorspace, "pc"))
680
+ range_name = (source_color_range or "tv").lower()
681
+ color_range = "pc" if range_name in ("pc", "jpeg", "full") else "tv"
682
+ return str(BridgeLink("rgba", colorspace, color_range))
683
+
684
+
685
+ def _lower_color(effect: ResolvedEffect, ctx: LowerContext) -> ColorPipelinePayload:
686
+ """Lower one Color Adjustments / Color Board effect from the CPU emitter's filter strings.
687
+
688
+ Everything about *what the authored controls mean* (key/name lookup, registry
689
+ ``ffmpeg_scale`` / clamps / defaults, ``.12g`` formatting) stays in
690
+ ``color.color_effect_filters`` -- the port only interprets the emitted ffmpeg filters, so
691
+ the two backends cannot drift apart on parameter semantics. Any emitted filter or option
692
+ the port does not implement is a loud ``effect (unsupported parameters)`` reject.
693
+
694
+ The ``eq`` bridge link comes from ``ctx.reference_effect_link`` (see
695
+ ``reference_effect_link``). TODO(E4 -> core): ``effects.LowerContext`` does not carry that
696
+ field yet; until ``plan._effect_specs`` passes it, any chain that needs the bridge rejects
697
+ loudly rather than guess (the links differ by whole tone-curve pivots and matrices, not by
698
+ a rounding code). The requested diff is in the E4 report.
699
+ """
700
+
701
+ handler_path = f"{ctx.clip_path}: {effect.name or effect.handler}"
702
+ filters = tuple(color_effect_filters(effect))
703
+ link_text = getattr(ctx, "reference_effect_link", None)
704
+ link: BridgeLink | None = None
705
+ if link_text is not None and any(item.startswith("eq=") for item in filters):
706
+ try:
707
+ link = BridgeLink.parse(link_text)
708
+ except ValueError as error:
709
+ raise reject("effect (unsupported parameters)", f"{handler_path}: {error}") from None
710
+ stages = tuple(_parse_stage(handler_path, item, link) for item in filters)
711
+ return ColorPipelinePayload(filters=filters, stages=stages)
712
+
713
+
714
+ # --------------------------------------------------------------------------- apply
715
+
716
+
717
+ @lru_cache(maxsize=256)
718
+ def _lut_tensor(lut: tuple[int, ...], device: str) -> torch.Tensor:
719
+ return torch.tensor(lut, dtype=torch.int32, device=device)
720
+
721
+
722
+ def _floor_shift(values: torch.Tensor, bits: int) -> torch.Tensor:
723
+ """Arithmetic ``>>`` on int32 tensors (floor division; MPS-safe)."""
724
+
725
+ return torch.div(values, 1 << bits, rounding_mode="floor")
726
+
727
+
728
+ def rgba8_to_yuva444p8(rgba: torch.Tensor, link: BridgeLink) -> torch.Tensor:
729
+ """swscale 8-bit RGB -> ``yuva444p`` on ``[4, H, W]`` int32 (RGBA plane order in; see the module doc).
730
+
731
+ ``rgb32ToY_c`` / ``planar_rgb_to_y`` (same arithmetic) with ``rgb2yuv_table(link.colorspace)``,
732
+ ``hScale16To15`` (x2, FFMIN 32767), then for a ``pc`` link ``lumRangeToJpeg_c`` /
733
+ ``chrRangeToJpeg_c`` on the 15-bit intermediate, then ``yuv2plane1_8_c`` (``+64 >> 7``, no
734
+ dither). Alpha: ``rgba`` follows ``rgbaToA_c`` (+1 for A >= 128, saturating at 255),
735
+ ``gbrap`` follows ``planar_rgb_to_a`` (exact). Bit-exact against ffmpeg 8.0.1 on every RGB
736
+ triple for all eight links.
737
+ """
738
+
739
+ ry, gy, by, ru, gu, bu, rv, gv, bv = rgb2yuv_table(link.colorspace)
740
+ r, g, b, a = rgba[0], rgba[1], rgba[2], rgba[3]
741
+
742
+ def plane(cr: int, cg: int, cb: int, offset: int, to_jpeg: tuple[int, int]) -> torch.Tensor:
743
+ x14 = _floor_shift(cr * r + cg * g + cb * b + (offset << 14) + (1 << 8), 9)
744
+ x15 = torch.clamp(x14 * 2, max=32767)
745
+ if link.full_range:
746
+ coeff, shift_offset = to_jpeg
747
+ x15 = torch.clamp(_floor_shift(x15 * coeff + shift_offset, 14), max=32767)
748
+ return torch.clamp(_floor_shift(x15 + 64, 7), 0, 255)
749
+
750
+ y = plane(ry, gy, by, 32, _LUM_TO_JPEG)
751
+ u = plane(ru, gu, bu, 256, _CHR_TO_JPEG)
752
+ v = plane(rv, gv, bv, 256, _CHR_TO_JPEG)
753
+ alpha = a if link.pix_fmt == "gbrap" else torch.clamp(a + (a >= 128).to(torch.int32), max=255)
754
+ return torch.stack((y, u, v, alpha))
755
+
756
+
757
+ def yuva444p8_to_rgba8(yuva: torch.Tensor, link: BridgeLink) -> torch.Tensor:
758
+ """swscale ``yuva444p`` -> 8-bit RGB on ``[4, H, W]`` int32 (RGBA plane order out).
759
+
760
+ ``yuv2rgb_write_full`` (``rgba``) / ``yuv2gbrp_full_X_c`` (``gbrap``) -- the same arithmetic --
761
+ with ``yuv2rgb_coefficients(link.colorspace, link.full_range)``. Bit-exact against ffmpeg
762
+ 8.0.1 on every YUV triple for all eight links, including the 32-bit wrap of the B
763
+ accumulator in the limited-range sets (Y8=255 with U8 near 255 -> 0); alpha copied.
764
+ """
765
+
766
+ k = yuv2rgb_coefficients(link.colorspace, link.full_range)
767
+ y, u, v, a = yuva[0], yuva[1], yuva[2], yuva[3]
768
+ t = (y - k.y_offset) * k.y_coeff + (1 << 21 >> 9)
769
+ du, dv = u - 128, v - 128
770
+
771
+ def channel(total: torch.Tensor) -> torch.Tensor:
772
+ # total is the C accumulator >> 9 (exact: every term is a multiple of 2**9).
773
+ wrapped_or_negative = (total < 0) | (total >= (1 << 22))
774
+ saturated = total >= (1 << 21)
775
+ return torch.where(wrapped_or_negative, torch.zeros_like(total),
776
+ torch.where(saturated, torch.full_like(total, 255), _floor_shift(total, 13)))
777
+
778
+ r = channel(t + dv * k.v2r)
779
+ g = channel(t + dv * k.v2g + du * k.u2g)
780
+ b = channel(t + du * k.u2b)
781
+ return torch.stack((r, g, b, a))
782
+
783
+
784
+ def _apply_lut(values: torch.Tensor, lut: tuple[int, ...]) -> torch.Tensor:
785
+ if lut is _IDENTITY_LUT:
786
+ return values
787
+ return _lut_tensor(lut, str(values.device))[values.long()]
788
+
789
+
790
+ def _apply_bridge(stage: YuvBridgeStage, rgba: torch.Tensor) -> torch.Tensor:
791
+ yuva = rgba8_to_yuva444p8(rgba, stage.link)
792
+ yuva = torch.stack((
793
+ _apply_lut(yuva[0], stage.y_lut), _apply_lut(yuva[1], stage.u_lut),
794
+ _apply_lut(yuva[2], stage.v_lut), yuva[3],
795
+ ))
796
+ return yuva444p8_to_rgba8(yuva, stage.link)
797
+
798
+
799
+ def _apply_luts(stage: LutStage, rgba: torch.Tensor) -> torch.Tensor:
800
+ return torch.stack((
801
+ _apply_lut(rgba[0], stage.r_lut), _apply_lut(rgba[1], stage.g_lut),
802
+ _apply_lut(rgba[2], stage.b_lut), rgba[3],
803
+ ))
804
+
805
+
806
+ def _apply_colorbalance(stage: ColorBalanceStage, rgba: torch.Tensor) -> torch.Tensor:
807
+ """``color_balance8`` (vf_colorbalance.c) in float32, ``pl=0``."""
808
+
809
+ x = rgba[:3].to(torch.float32) / 255.0
810
+ lightness = torch.amax(x, dim=0) + torch.amin(x, dim=0) # ``l`` in vf_colorbalance.c
811
+ a, b, scale = 4.0, 0.333, 0.7
812
+ w_s = torch.clamp((b - lightness) * a + 0.5, 0.0, 1.0) * scale
813
+ w_m = torch.clamp((lightness - b) * a + 0.5, 0.0, 1.0) * torch.clamp((1.0 - lightness - b) * a + 0.5, 0.0, 1.0) * scale
814
+ w_h = torch.clamp((lightness + b - 1) * a + 0.5, 0.0, 1.0) * scale
815
+ outputs = []
816
+ for index in range(3):
817
+ v = x[index]
818
+ v = v + stage.shadows[index] * w_s
819
+ v = v + stage.midtones[index] * w_m
820
+ v = v + stage.highlights[index] * w_h
821
+ v = torch.clamp(v, 0.0, 1.0)
822
+ outputs.append(torch.clamp(torch.round(v * 255.0), 0.0, 255.0).to(torch.int32))
823
+ return torch.stack((*outputs, rgba[3]))
824
+
825
+
826
+ def apply_stages(stages: Sequence[Stage], rgba8: torch.Tensor) -> torch.Tensor:
827
+ """Run the parsed reference stages on an int32 ``[4, H, W]`` straight-RGBA 0..255 frame."""
828
+
829
+ for stage in stages:
830
+ if isinstance(stage, YuvBridgeStage):
831
+ rgba8 = _apply_bridge(stage, rgba8)
832
+ elif isinstance(stage, LutStage):
833
+ rgba8 = _apply_luts(stage, rgba8)
834
+ elif isinstance(stage, ColorBalanceStage):
835
+ rgba8 = _apply_colorbalance(stage, rgba8)
836
+ else: # pragma: no cover - the payload is built by _parse_stage only
837
+ raise TypeError(f"unknown colour stage {stage!r}")
838
+ return rgba8
839
+
840
+
841
+ def _apply_color(payload: ColorPipelinePayload, canvas: torch.Tensor, ctx: ApplyContext) -> torch.Tensor:
842
+ if not payload.stages:
843
+ return canvas
844
+ code = premultiplied_to_code(canvas)
845
+ rgba8 = torch.clamp(torch.round(code), 0.0, 255.0).to(torch.int32)
846
+ rgba8 = apply_stages(payload.stages, rgba8)
847
+ return code_to_premultiplied(rgba8.to(canvas.dtype))
848
+
849
+
850
+ register(EffectPort(handler="color_adjustments", lower=_lower_color, apply=_apply_color))
851
+ register(EffectPort(handler="color_board", lower=_lower_color, apply=_apply_color))