bladeworks 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bladeworks/__init__.py +56 -0
- bladeworks/__main__.py +4 -0
- bladeworks/cli.py +728 -0
- bladeworks/core/__init__.py +34 -0
- bladeworks/core/animation.py +893 -0
- bladeworks/core/animation_execution.py +817 -0
- bladeworks/core/audio_enhancements.py +673 -0
- bladeworks/core/audio_execution.py +1848 -0
- bladeworks/core/audio_ir.py +1971 -0
- bladeworks/core/basic_effects.py +168 -0
- bladeworks/core/bindings.py +196 -0
- bladeworks/core/capabilities.py +257 -0
- bladeworks/core/cohort_effects.py +702 -0
- bladeworks/core/color.py +324 -0
- bladeworks/core/compiler.py +3142 -0
- bladeworks/core/composition_diagnostics.py +629 -0
- bladeworks/core/composition_ir.py +3154 -0
- bladeworks/core/composition_planning.py +708 -0
- bladeworks/core/composition_shadow.py +687 -0
- bladeworks/core/compositor.py +1186 -0
- bladeworks/core/effect_parameter_semantics.py +165 -0
- bladeworks/core/effect_parameters.py +193 -0
- bladeworks/core/errors.py +21 -0
- bladeworks/core/filter_text.py +48 -0
- bladeworks/core/geometry.py +1711 -0
- bladeworks/core/group_surface.py +664 -0
- bladeworks/core/keyer.py +280 -0
- bladeworks/core/masks.py +453 -0
- bladeworks/core/missing_media.py +130 -0
- bladeworks/core/model.py +985 -0
- bladeworks/core/multicam_execution.py +496 -0
- bladeworks/core/output_color.py +52 -0
- bladeworks/core/parser.py +1257 -0
- bladeworks/core/pixel_domains.py +433 -0
- bladeworks/core/render_profile.py +311 -0
- bladeworks/core/render_sources.py +475 -0
- bladeworks/core/report.py +118 -0
- bladeworks/core/retime.py +967 -0
- bladeworks/core/retime_execution.py +1515 -0
- bladeworks/core/spatial_intrinsics.py +1749 -0
- bladeworks/core/story_containers.py +524 -0
- bladeworks/core/story_ir.py +1042 -0
- bladeworks/core/text.py +1032 -0
- bladeworks/core/text_templates.py +934 -0
- bladeworks/data/FCPXML_RENDER_CAPABILITIES.yaml +1797 -0
- bladeworks/effects/__init__.py +1 -0
- bladeworks/effects/contract.py +162 -0
- bladeworks/examples/__init__.py +80 -0
- bladeworks/examples/color.fcpxmld/Info.fcpxml +17 -0
- bladeworks/examples/color.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Info.fcpxml +13 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/crop_or_transition.fcpxmld/Media/b.mp4 +0 -0
- bladeworks/examples/manual_qa/generate_2h_qa_fixtures.py +237 -0
- bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Info.fcpxml +28 -0
- bladeworks/examples/manual_qa/missing_media_lab.fcpxmld/Media/night_signal.mp4 +0 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Info.fcpxml +44 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/city_grid.mp4 +0 -0
- bladeworks/examples/manual_qa/portrait_social.fcpxmld/Media/golden_hour.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Info.fcpxml +113 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/city_grid.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/golden_hour.mp4 +0 -0
- bladeworks/examples/manual_qa/studio_showcase.fcpxmld/Media/night_signal.mp4 +0 -0
- bladeworks/examples/single_clip.fcpxmld/Info.fcpxml +9 -0
- bladeworks/examples/single_clip.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Info.fcpxml +13 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/examples/spine_and_lane.fcpxmld/Media/b.mp4 +0 -0
- bladeworks/examples/transform.fcpxmld/Info.fcpxml +11 -0
- bladeworks/examples/transform.fcpxmld/Media/a.mp4 +0 -0
- bladeworks/executor.py +293 -0
- bladeworks/preview/__init__.py +11 -0
- bladeworks/preview/application.py +84 -0
- bladeworks/preview/audio.py +104 -0
- bladeworks/preview/capabilities.py +457 -0
- bladeworks/preview/contracts.py +157 -0
- bladeworks/preview/events.py +55 -0
- bladeworks/preview/export.py +57 -0
- bladeworks/preview/history.py +197 -0
- bladeworks/preview/main.py +19 -0
- bladeworks/preview/media.py +23 -0
- bladeworks/preview/media_library.py +381 -0
- bladeworks/preview/media_reporting.py +55 -0
- bladeworks/preview/media_routes.py +155 -0
- bladeworks/preview/media_visuals.py +225 -0
- bladeworks/preview/producer.py +211 -0
- bladeworks/preview/provider.py +74 -0
- bladeworks/preview/rawframe.py +218 -0
- bladeworks/preview/render_jobs.py +311 -0
- bladeworks/preview/routes.py +375 -0
- bladeworks/preview/runner.py +349 -0
- bladeworks/preview/security.py +61 -0
- bladeworks/preview/service.py +234 -0
- bladeworks/preview/session.py +457 -0
- bladeworks/preview/source.py +603 -0
- bladeworks/preview/source_routes.py +151 -0
- bladeworks/preview/studio.py +77 -0
- bladeworks/preview/webrtc.py +295 -0
- bladeworks/spatial_luts/manifest.json +17 -0
- bladeworks/spatial_luts/rec2020_hlg_to_rec709_sdr_v1.cube +4917 -0
- bladeworks/spatial_luts/rec2020_pq_to_rec709_sdr_v1.cube +4917 -0
- bladeworks/studio_static/__init__.py +1 -0
- bladeworks/studio_static/app.js +6519 -0
- bladeworks/studio_static/build-id.js +1 -0
- bladeworks/studio_static/capability-ui.js +68 -0
- bladeworks/studio_static/clip-state.js +335 -0
- bladeworks/studio_static/fcpxml.js +3313 -0
- bladeworks/studio_static/fixtures.js +393 -0
- bladeworks/studio_static/history.js +50 -0
- bladeworks/studio_static/index.html +24 -0
- bladeworks/studio_static/magnetic-timeline.js +1194 -0
- bladeworks/studio_static/mask-ui.js +189 -0
- bladeworks/studio_static/media-visuals.js +224 -0
- bladeworks/studio_static/mock-capabilities.js +409 -0
- bladeworks/studio_static/retime-ui.js +151 -0
- bladeworks/studio_static/runtime.js +1652 -0
- bladeworks/studio_static/scope-ui.js +150 -0
- bladeworks/studio_static/styles.css +1558 -0
- bladeworks/studio_static/templates/catalog.js +77 -0
- bladeworks/studio_static/templates/icons.js +84 -0
- bladeworks/studio_static/templates/inspector.js +553 -0
- bladeworks/studio_static/templates/library.js +61 -0
- bladeworks/studio_static/templates/media.js +185 -0
- bladeworks/studio_static/templates/shared.js +43 -0
- bladeworks/studio_static/templates/shell.js +94 -0
- bladeworks/studio_static/templates/timeline.js +377 -0
- bladeworks/studio_static/templates/topbar.js +83 -0
- bladeworks/studio_static/templates/types.js +7 -0
- bladeworks/studio_static/templates/viewer.js +174 -0
- bladeworks/studio_static/templates.js +31 -0
- bladeworks/studio_static/types.js +18 -0
- bladeworks/studio_static/ui.js +97 -0
- bladeworks/studio_static/viewer-geometry.js +55 -0
- bladeworks/tensor/__init__.py +131 -0
- bladeworks/tensor/audio_delivery.py +280 -0
- bladeworks/tensor/audio_pyav.py +360 -0
- bladeworks/tensor/blend.py +217 -0
- bladeworks/tensor/color.py +53 -0
- bladeworks/tensor/composite.py +33 -0
- bladeworks/tensor/decode.py +1309 -0
- bladeworks/tensor/decode_policy.py +363 -0
- bladeworks/tensor/effects.py +322 -0
- bladeworks/tensor/encode.py +614 -0
- bladeworks/tensor/errors.py +32 -0
- bladeworks/tensor/expr.py +775 -0
- bladeworks/tensor/fx_basic.py +946 -0
- bladeworks/tensor/fx_branched.py +640 -0
- bladeworks/tensor/fx_cohort.py +404 -0
- bladeworks/tensor/fx_color.py +851 -0
- bladeworks/tensor/fx_keyer.py +140 -0
- bladeworks/tensor/fx_mask.py +352 -0
- bladeworks/tensor/fx_warp.py +337 -0
- bladeworks/tensor/hdr.py +122 -0
- bladeworks/tensor/pipeline.py +401 -0
- bladeworks/tensor/plan.py +2231 -0
- bladeworks/tensor/renderer.py +1199 -0
- bladeworks/tensor/requirements.txt +28 -0
- bladeworks/tensor/resolution.py +246 -0
- bladeworks/tensor/sampler.py +598 -0
- bladeworks/tensor/support.py +169 -0
- bladeworks/tensor/swscale_fixedpoint.py +181 -0
- bladeworks/tensor/tr_equirect.py +420 -0
- bladeworks/tensor/tr_handlers.py +443 -0
- bladeworks/tensor/tr_phase5.py +697 -0
- bladeworks/tensor/transitions.py +424 -0
- bladeworks/transitions/__init__.py +10 -0
- bladeworks/transitions/carryovers.py +299 -0
- bladeworks/transitions/cohort.py +981 -0
- bladeworks/transitions/contract.py +490 -0
- bladeworks/transitions/equirectangular.py +915 -0
- bladeworks/transitions/light_deco.py +141 -0
- bladeworks/transitions/panel_motion.py +495 -0
- bladeworks/transitions/stock.py +270 -0
- bladeworks-0.1.0.dist-info/METADATA +66 -0
- bladeworks-0.1.0.dist-info/RECORD +178 -0
- bladeworks-0.1.0.dist-info/WHEEL +5 -0
- bladeworks-0.1.0.dist-info/entry_points.txt +2 -0
- bladeworks-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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))
|