fablecut 0.0.0-stage → 1.12.0
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.
- package/CLAUDE.md +1198 -0
- package/LICENSE +21 -0
- package/README.md +571 -2
- package/analyze.js +289 -0
- package/app.js +13339 -0
- package/audio-fx.js +241 -0
- package/cli.js +31 -0
- package/color.js +1043 -0
- package/denoise.js +120 -0
- package/ducking.js +127 -0
- package/edit-ops.js +962 -0
- package/encode-profiles.js +302 -0
- package/encoding-profiles.json +68 -0
- package/export-jobs.js +163 -0
- package/favicon.svg +14 -0
- package/fx-worklet.js +162 -0
- package/icons/favicon-32.png +0 -0
- package/import-url.js +293 -0
- package/index.html +420 -0
- package/library/README.md +15 -0
- package/library/elements/.gitkeep +0 -0
- package/library/elements/bokeh-drift.svg +19 -0
- package/library/elements/light-leak-cool.svg +16 -0
- package/library/elements/light-leak-warm.svg +22 -0
- package/library/elements/vhs-scanlines.svg +18 -0
- package/library/elements/vignette-frame.svg +14 -0
- package/library/fonts/.gitkeep +0 -0
- package/library/fonts/Abril Fatface.woff2 +0 -0
- package/library/fonts/Anton.woff2 +0 -0
- package/library/fonts/Archivo Black.woff2 +0 -0
- package/library/fonts/Bangers.woff2 +0 -0
- package/library/fonts/Barlow.woff2 +0 -0
- package/library/fonts/Bebas Neue.woff2 +0 -0
- package/library/fonts/Bungee.woff2 +0 -0
- package/library/fonts/Caveat.woff2 +0 -0
- package/library/fonts/DM Sans.woff2 +0 -0
- package/library/fonts/Inter.woff2 +0 -0
- package/library/fonts/LICENSES.md +31 -0
- package/library/fonts/Lato.woff2 +0 -0
- package/library/fonts/Montserrat.woff2 +0 -0
- package/library/fonts/Oswald.woff2 +0 -0
- package/library/fonts/Pacifico.woff2 +0 -0
- package/library/fonts/Playfair Display.woff2 +0 -0
- package/library/fonts/Poppins.woff2 +0 -0
- package/library/fonts/Righteous.woff2 +0 -0
- package/library/fonts/Roboto.woff2 +0 -0
- package/library/fonts/Rubik.woff2 +0 -0
- package/library/fonts/Teko.woff2 +0 -0
- package/library/sfx/README.md +18 -0
- package/library/svg/ai-star.svg +17 -0
- package/library/svg/arrow-bounce.svg +18 -0
- package/library/svg/arrow-scribble.svg +13 -0
- package/library/svg/burst-lines.svg +30 -0
- package/library/svg/checkmark-success.svg +19 -0
- package/library/svg/confetti-burst.svg +23 -0
- package/library/svg/countdown-ring.svg +92 -0
- package/library/svg/equalizer.svg +18 -0
- package/library/svg/glow-orbs.svg +29 -0
- package/library/svg/heart-pop.svg +28 -0
- package/library/svg/loading-dots.svg +16 -0
- package/library/svg/location-pin.svg +25 -0
- package/library/svg/lower-third.svg +17 -0
- package/library/svg/new-badge.svg +18 -0
- package/library/svg/progress-bar.svg +16 -0
- package/library/svg/radial-pulse.svg +19 -0
- package/library/svg/rating-stars.svg +25 -0
- package/library/svg/rec-indicator.svg +41 -0
- package/library/svg/sparkles.svg +17 -0
- package/library/svg/spawning-hearts.svg +39 -0
- package/library/svg/speech-bubble.svg +23 -0
- package/library/svg/subscribe-bell.svg +29 -0
- package/library/svg/subscribe-pill.svg +22 -0
- package/library/svg/swipe-up.svg +46 -0
- package/library/svg/underline-swoosh.svg +15 -0
- package/loudness.js +135 -0
- package/mcp-server.js +1421 -0
- package/meter-worklet.js +229 -0
- package/package.json +45 -4
- package/paths.js +106 -0
- package/ruler-worker.js +120 -0
- package/server.js +775 -0
- package/style.css +3563 -0
package/color.js
ADDED
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
/* FableCut color — the grade's math, its validation, the scopes and the
|
|
2
|
+
frame statistics an agent reads. Zero dependencies. Loaded by the editor as
|
|
3
|
+
a plain script (global `FableCutColor`) and required by the MCP server and
|
|
4
|
+
the tests, so the editor's Color page, an agent's `setGrade` op and the
|
|
5
|
+
numbers in `fablecut_scopes` all agree. The GPU path (GRADE_FRAG, run by
|
|
6
|
+
app.js) mirrors gradePixel() step for step; the CPU path is the fallback
|
|
7
|
+
when WebGL2 is missing, and the reference the tests check.
|
|
8
|
+
|
|
9
|
+
A grade lives on a clip (or adjustment layer) as `props.grade`, sparse —
|
|
10
|
+
only the keys that differ from neutral:
|
|
11
|
+
{ exposure: 0.3, temp: -12, lift: [0, 0, 0.02, -0.03], contrast: 1.15 }
|
|
12
|
+
Wheels (lift / gamma / gain / offset) are [r, g, b, master]; every other
|
|
13
|
+
key is a number. `on: false` bypasses the grade without losing it.
|
|
14
|
+
|
|
15
|
+
Order, on display (sRGB-encoded) values 0…1:
|
|
16
|
+
1 exposure + white balance, in linear light
|
|
17
|
+
2 offset x + o
|
|
18
|
+
3 lift x + l·(1 − x) raises / lowers blacks, white stays put
|
|
19
|
+
4 gain x · (1 + g)
|
|
20
|
+
5 gamma x ^ (1 / 2^γ) γ > 0 brightens mids
|
|
21
|
+
6 contrast pivot · (x / pivot)^c — linear in log, so blacks never clip
|
|
22
|
+
7 tone blacks · shadows · midtones · highlights · whites (luma bands)
|
|
23
|
+
8 rolloff soft knee into white (highSoft) and black (lowSoft)
|
|
24
|
+
9 saturation around Rec.709 luma
|
|
25
|
+
10 curves luma curve (brightness only) then the R, G, B curves
|
|
26
|
+
11 hue curves hue vs hue · hue vs saturation · hue vs luma · sat vs luma
|
|
27
|
+
Steps 1–6 are per channel, so the CPU path bakes them into a table; the
|
|
28
|
+
curves are baked into lookup tables that the shader reads the same way.
|
|
29
|
+
|
|
30
|
+
Curves are point lists, interpolated with a monotone cubic (no overshoot):
|
|
31
|
+
curves: { y?, r?, g?, b? } each [[x, y], …] on 0…1 — (0,0) and (1,1)
|
|
32
|
+
are added when missing, so [[0.25, 0.2]] is a gentle shadow dip
|
|
33
|
+
hueHue: [[hue°, shift°], …] shift −180…180, neutral 0
|
|
34
|
+
hueSat: [[hue°, factor], …] factor 0…2, neutral 1
|
|
35
|
+
hueLuma: [[hue°, offset], …] offset −0.5…0.5, neutral 0
|
|
36
|
+
satLuma: [[saturation, offset], …] saturation 0…1, offset −0.5…0.5
|
|
37
|
+
Hue curves wrap around 360°. A hue curve with ONE point is a band: that
|
|
38
|
+
value at the hue, easing back to neutral 40° either side.
|
|
39
|
+
|
|
40
|
+
Layers (secondaries) run after all of that, in order. Each is a grade of
|
|
41
|
+
its own (any keys above) mixed into the picture by a matte:
|
|
42
|
+
layers: [{ name?, on?, qualifier?, mask?, …grade keys }]
|
|
43
|
+
qualifier: { hue?: [centre°, width°, soft°], sat?: [lo, hi, soft],
|
|
44
|
+
luma?: [lo, hi, soft], invert? } — measured on the layer's input
|
|
45
|
+
mask: { shape: ellipse | rect | poly, x, y (centre, 0…1 of the picture),
|
|
46
|
+
w, h (size, 0…1), rotation°, feather (0…0.5 of the height),
|
|
47
|
+
invert?, points?: [[dx, dy], …] (poly, relative to x, y),
|
|
48
|
+
keys?: [{ t, ease?, x?, y?, w?, h?, rotation?, feather? }] }
|
|
49
|
+
matte = qualifier × mask (each 1 when absent); out = mix(in, graded, matte). */
|
|
50
|
+
(function (root, factory) {
|
|
51
|
+
const api = factory();
|
|
52
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
53
|
+
else root.FableCutColor = api;
|
|
54
|
+
})(typeof self !== "undefined" ? self : this, function () {
|
|
55
|
+
"use strict";
|
|
56
|
+
|
|
57
|
+
const WHEELS = ["lift", "gamma", "gain", "offset"];
|
|
58
|
+
// [default, min, max, step, label] — wheel entries describe each component.
|
|
59
|
+
const R = (def, min, max, step, label) => ({ def, min, max, step, label });
|
|
60
|
+
const GRADE_PARAMS = {
|
|
61
|
+
exposure: R(0, -5, 5, 0.01, "Exposure"),
|
|
62
|
+
temp: R(0, -100, 100, 1, "Temp"),
|
|
63
|
+
tint: R(0, -100, 100, 1, "Tint"),
|
|
64
|
+
lift: R(0, -1, 1, 0.005, "Lift"),
|
|
65
|
+
gamma: R(0, -1, 1, 0.005, "Gamma"),
|
|
66
|
+
gain: R(0, -1, 1, 0.005, "Gain"),
|
|
67
|
+
offset: R(0, -1, 1, 0.005, "Offset"),
|
|
68
|
+
contrast: R(1, 0, 3, 0.01, "Contrast"),
|
|
69
|
+
pivot: R(0.435, 0.05, 0.95, 0.005, "Pivot"),
|
|
70
|
+
blacks: R(0, -100, 100, 1, "Blacks"),
|
|
71
|
+
shadows: R(0, -100, 100, 1, "Shadows"),
|
|
72
|
+
midtones: R(0, -100, 100, 1, "Midtones"),
|
|
73
|
+
highlights: R(0, -100, 100, 1, "Highlights"),
|
|
74
|
+
whites: R(0, -100, 100, 1, "Whites"),
|
|
75
|
+
lowSoft: R(0, 0, 100, 1, "Low rolloff"),
|
|
76
|
+
highSoft: R(0, 0, 100, 1, "High rolloff"),
|
|
77
|
+
saturation: R(100, 0, 200, 1, "Saturation"),
|
|
78
|
+
};
|
|
79
|
+
const GRADE_KEYS = Object.keys(GRADE_PARAMS);
|
|
80
|
+
const CURVE_CHANNELS = ["y", "r", "g", "b"];
|
|
81
|
+
const HUE_CURVES = {
|
|
82
|
+
hueHue: { label: "Hue vs Hue", xMax: 360, min: -180, max: 180, neutral: 0, periodic: true },
|
|
83
|
+
hueSat: { label: "Hue vs Sat", xMax: 360, min: 0, max: 2, neutral: 1, periodic: true },
|
|
84
|
+
hueLuma: { label: "Hue vs Luma", xMax: 360, min: -0.5, max: 0.5, neutral: 0, periodic: true },
|
|
85
|
+
satLuma: { label: "Sat vs Luma", xMax: 1, min: -0.5, max: 0.5, neutral: 0, periodic: false },
|
|
86
|
+
};
|
|
87
|
+
const HUE_KEYS = Object.keys(HUE_CURVES);
|
|
88
|
+
const MAX_POINTS = 24;
|
|
89
|
+
const BAND = 40; // a one-point hue curve eases back to neutral this far either side
|
|
90
|
+
const CURVE_N = 1024; // luma / RGB lookup-table size
|
|
91
|
+
const HUE_N = 360; // hue-curve lookup-table size (also sat 0…1 for satLuma)
|
|
92
|
+
const MAX_LAYERS = 8;
|
|
93
|
+
const MAX_POLY = 16;
|
|
94
|
+
const MASK_SHAPES = ["ellipse", "rect", "poly"];
|
|
95
|
+
const MASK_KEYED = ["x", "y", "w", "h", "rotation", "feather"];
|
|
96
|
+
const KEY_RANGE = { x: [-1, 2], y: [-1, 2], w: [0.001, 4], h: [0.001, 4], rotation: [-720, 720], feather: [0, 0.5] };
|
|
97
|
+
const WB_STOPS = 1 / 100; // temp / tint ±100 → ±1 stop per channel
|
|
98
|
+
const TONE_K = 0.5; // tone slider ±100 → ±0.5 luma at the band's peak
|
|
99
|
+
|
|
100
|
+
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
|
101
|
+
const round = (v, step) => Math.round(v / step) * step;
|
|
102
|
+
|
|
103
|
+
/** Validate a grade. Returns the sparse form (neutral keys dropped), or null
|
|
104
|
+
* when nothing is left. strict: throw on unknown keys / bad values instead
|
|
105
|
+
* of dropping them (agent input). */
|
|
106
|
+
function normalizeGrade(g, strict = false, nested = false) {
|
|
107
|
+
if (g == null) return null;
|
|
108
|
+
if (typeof g !== "object" || Array.isArray(g)) {
|
|
109
|
+
if (strict) throw new Error("grade must be an object like {exposure: 0.5, lift: [0, 0, 0.05, 0]}");
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const [k, v] of Object.entries(g)) {
|
|
114
|
+
if (k === "on") { if (v === false) out.on = false; continue; }
|
|
115
|
+
if (k === "layers" && !nested) {
|
|
116
|
+
const ls = normalizeLayers(v, strict);
|
|
117
|
+
if (ls) out.layers = ls;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (k === "curves") {
|
|
121
|
+
const c = normalizeCurves(v, strict);
|
|
122
|
+
if (c) out.curves = c;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (HUE_CURVES[k]) {
|
|
126
|
+
const pts = normalizeHueCurve(k, v, strict);
|
|
127
|
+
if (pts) out[k] = pts;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const def = GRADE_PARAMS[k];
|
|
131
|
+
if (!def) {
|
|
132
|
+
if (strict) throw new Error(`unknown grade key ${JSON.stringify(k)} (known: on, ${GRADE_KEYS.join(", ")}, curves, ${HUE_KEYS.join(", ")}${nested ? "" : ", layers"})`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (WHEELS.includes(k)) {
|
|
136
|
+
if (!Array.isArray(v) || v.length !== 4 || v.some((n) => typeof n !== "number" || !Number.isFinite(n))) {
|
|
137
|
+
if (strict) throw new Error(`grade.${k} must be [r, g, b, master], four numbers ${def.min}…${def.max}`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const w = v.map((n) => +clamp(n, def.min, def.max).toFixed(4));
|
|
141
|
+
if (w.some((n) => n !== 0)) out[k] = w;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
145
|
+
if (strict) throw new Error(`grade.${k} must be a number ${def.min}…${def.max}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const n = +clamp(v, def.min, def.max).toFixed(4);
|
|
149
|
+
if (n !== def.def) out[k] = n;
|
|
150
|
+
}
|
|
151
|
+
const keys = Object.keys(out);
|
|
152
|
+
if (!keys.length || (keys.length === 1 && out.on === false)) return null;
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/** [[x, v], …] → sorted, clamped, de-duplicated points (or throws / null). */
|
|
156
|
+
function normalizePoints(name, v, xMax, min, max, strict) {
|
|
157
|
+
const bad = (msg) => { if (strict) throw new Error(`grade.${name} ${msg}`); return null; };
|
|
158
|
+
if (!Array.isArray(v)) return bad(`must be a list of [x, value] points`);
|
|
159
|
+
if (v.length > MAX_POINTS) return bad(`has ${v.length} points (at most ${MAX_POINTS})`);
|
|
160
|
+
const byX = new Map();
|
|
161
|
+
for (const p of v) {
|
|
162
|
+
if (!Array.isArray(p) || p.length !== 2 || p.some((n) => typeof n !== "number" || !Number.isFinite(n)))
|
|
163
|
+
return bad(`points must be [x, value] number pairs (x 0…${xMax}, value ${min}…${max})`);
|
|
164
|
+
const x = xMax === 360 ? ((p[0] % 360) + 360) % 360 : clamp(p[0], 0, xMax);
|
|
165
|
+
byX.set(+x.toFixed(4), +clamp(p[1], min, max).toFixed(4));
|
|
166
|
+
}
|
|
167
|
+
return [...byX.entries()].sort((a, b) => a[0] - b[0]);
|
|
168
|
+
}
|
|
169
|
+
function normalizeCurves(v, strict) {
|
|
170
|
+
if (v == null) return null;
|
|
171
|
+
if (typeof v !== "object" || Array.isArray(v)) {
|
|
172
|
+
if (strict) throw new Error("grade.curves must be {y?, r?, g?, b?}, each a list of [x, y] points on 0…1");
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const out = {};
|
|
176
|
+
for (const [ch, pts] of Object.entries(v)) {
|
|
177
|
+
if (!CURVE_CHANNELS.includes(ch)) {
|
|
178
|
+
if (strict) throw new Error(`grade.curves.${ch}: unknown curve (y, r, g, b)`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (pts == null) continue;
|
|
182
|
+
let p = normalizePoints("curves." + ch, pts, 1, 0, 1, strict);
|
|
183
|
+
if (!p) continue;
|
|
184
|
+
if (!p.length || p[0][0] > 0) p.unshift([0, 0]);
|
|
185
|
+
if (p[p.length - 1][0] < 1) p.push([1, 1]);
|
|
186
|
+
if (p.every(([x, y]) => Math.abs(x - y) < 1e-4)) continue; // identity
|
|
187
|
+
out[ch] = p;
|
|
188
|
+
}
|
|
189
|
+
return Object.keys(out).length ? out : null;
|
|
190
|
+
}
|
|
191
|
+
function normalizeHueCurve(k, v, strict) {
|
|
192
|
+
const d = HUE_CURVES[k];
|
|
193
|
+
if (v == null) return null;
|
|
194
|
+
let p = normalizePoints(k, v, d.xMax, d.min, d.max, strict);
|
|
195
|
+
if (!p || !p.length) return null;
|
|
196
|
+
if (d.periodic && p.length === 1) {
|
|
197
|
+
const [h, val] = p[0];
|
|
198
|
+
p = [[((h - BAND) + 360) % 360, d.neutral], [h, val], [(h + BAND) % 360, d.neutral]].sort((a, b) => a[0] - b[0]);
|
|
199
|
+
}
|
|
200
|
+
if (!d.periodic && p[0][0] > 0) p.unshift([0, d.neutral]);
|
|
201
|
+
if (p.every(([, y]) => Math.abs(y - d.neutral) < 1e-4)) return null;
|
|
202
|
+
return p;
|
|
203
|
+
}
|
|
204
|
+
/** Every key filled in (wheels as fresh arrays). */
|
|
205
|
+
function fullGrade(g) {
|
|
206
|
+
const out = { on: !(g && g.on === false) };
|
|
207
|
+
for (const k of GRADE_KEYS) {
|
|
208
|
+
const v = g && g[k];
|
|
209
|
+
out[k] = WHEELS.includes(k) ? (Array.isArray(v) ? v.slice(0, 4) : [0, 0, 0, 0]) : (typeof v === "number" ? v : GRADE_PARAMS[k].def);
|
|
210
|
+
}
|
|
211
|
+
out.curves = {};
|
|
212
|
+
for (const ch of CURVE_CHANNELS) {
|
|
213
|
+
const p = g && g.curves && g.curves[ch];
|
|
214
|
+
out.curves[ch] = Array.isArray(p) && p.length ? p.map((q) => q.slice(0, 2)) : null;
|
|
215
|
+
}
|
|
216
|
+
for (const k of HUE_KEYS) {
|
|
217
|
+
const p = g && g[k];
|
|
218
|
+
out[k] = Array.isArray(p) && p.length ? p.map((q) => q.slice(0, 2)) : null;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
/** True when the grade changes nothing (absent, bypassed or all neutral). */
|
|
223
|
+
function isNeutral(g) {
|
|
224
|
+
if (!g || g.on === false) return true;
|
|
225
|
+
for (const k of GRADE_KEYS) {
|
|
226
|
+
const v = g[k];
|
|
227
|
+
if (v == null) continue;
|
|
228
|
+
if (WHEELS.includes(k)) { if (Array.isArray(v) && (v[0] || v[1] || v[2] || v[3])) return false; }
|
|
229
|
+
else if (v !== GRADE_PARAMS[k].def) return false;
|
|
230
|
+
}
|
|
231
|
+
if (g.curves && CURVE_CHANNELS.some((ch) => Array.isArray(g.curves[ch]) && g.curves[ch].length)) return false;
|
|
232
|
+
if (HUE_KEYS.some((k) => Array.isArray(g[k]) && g[k].length)) return false;
|
|
233
|
+
if (Array.isArray(g.layers) && g.layers.some((l) => l && l.on !== false && !isNeutral(layerGrade(l)))) return false;
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
/** The grade without its curves (what the curve pickers sample). */
|
|
237
|
+
function withoutCurves(g) {
|
|
238
|
+
if (!g) return g;
|
|
239
|
+
const out = { ...g };
|
|
240
|
+
delete out.curves;
|
|
241
|
+
for (const k of HUE_KEYS) delete out[k];
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
/** Merge a change into a grade (null on a key resets it) → sparse. */
|
|
245
|
+
function mergeGrade(g, set, strict = false) {
|
|
246
|
+
const next = { ...(g || {}) };
|
|
247
|
+
for (const [k, v] of Object.entries(set || {})) {
|
|
248
|
+
if (v === null) delete next[k];
|
|
249
|
+
else if (k === "curves" && v && typeof v === "object" && !Array.isArray(v)) {
|
|
250
|
+
const cur = { ...(next.curves || {}) }; // curves merge per channel
|
|
251
|
+
for (const [ch, pts] of Object.entries(v)) { if (pts === null) delete cur[ch]; else cur[ch] = pts; }
|
|
252
|
+
next.curves = cur;
|
|
253
|
+
}
|
|
254
|
+
else next[k] = v;
|
|
255
|
+
}
|
|
256
|
+
return normalizeGrade(next, strict);
|
|
257
|
+
}
|
|
258
|
+
/** Merge a change into one layer: grade keys as mergeGrade, qualifier and
|
|
259
|
+
* mask key by key (null removes); returns the normalized layer. */
|
|
260
|
+
function mergeLayer(l, set, strict = false) {
|
|
261
|
+
const next = { ...(l || {}) };
|
|
262
|
+
for (const [k, v] of Object.entries(set || {})) {
|
|
263
|
+
if (v === null) { delete next[k]; continue; }
|
|
264
|
+
if ((k === "qualifier" || k === "mask") && typeof v === "object" && !Array.isArray(v)) {
|
|
265
|
+
const cur = { ...(next[k] || {}) };
|
|
266
|
+
for (const [kk, vv] of Object.entries(v)) { if (vv === null) delete cur[kk]; else cur[kk] = vv; }
|
|
267
|
+
next[k] = cur;
|
|
268
|
+
} else if (k === "curves" && v && typeof v === "object" && !Array.isArray(v)) {
|
|
269
|
+
const cur = { ...(next.curves || {}) };
|
|
270
|
+
for (const [ch, pts] of Object.entries(v)) { if (pts === null) delete cur[ch]; else cur[ch] = pts; }
|
|
271
|
+
next.curves = cur;
|
|
272
|
+
} else next[k] = v;
|
|
273
|
+
}
|
|
274
|
+
return normalizeLayer(next, strict, 0) || {};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* ── White balance ── */
|
|
278
|
+
/** Linear-light channel gains for temp / tint, normalized so a neutral
|
|
279
|
+
* grey keeps its Rec.709 luminance. temp > 0 warms, tint > 0 → magenta. */
|
|
280
|
+
function wbGains(temp, tint) {
|
|
281
|
+
const a = (+temp || 0) * WB_STOPS, c = (+tint || 0) * WB_STOPS;
|
|
282
|
+
const r = Math.pow(2, a), g = Math.pow(2, -c), b = Math.pow(2, -a);
|
|
283
|
+
const n = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
284
|
+
return [r / n, g / n, b / n];
|
|
285
|
+
}
|
|
286
|
+
/** temp / tint that turn this sampled colour (sRGB 0…1) neutral. */
|
|
287
|
+
function solveWhiteBalance(rgb) {
|
|
288
|
+
const lr = Math.log2(Math.max(1e-4, srgbToLinear(rgb[0])));
|
|
289
|
+
const lg = Math.log2(Math.max(1e-4, srgbToLinear(rgb[1])));
|
|
290
|
+
const lb = Math.log2(Math.max(1e-4, srgbToLinear(rgb[2])));
|
|
291
|
+
const a = (lb - lr) / 2; // r·2^a = b·2^-a
|
|
292
|
+
const c = lg - (lr + a); // g·2^-c = that
|
|
293
|
+
return {
|
|
294
|
+
temp: Math.round(clamp(a / WB_STOPS, -100, 100)),
|
|
295
|
+
tint: Math.round(clamp(c / WB_STOPS, -100, 100)),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* ── Transfer ── */
|
|
300
|
+
function srgbToLinear(v) {
|
|
301
|
+
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
302
|
+
}
|
|
303
|
+
function linearToSrgb(v) {
|
|
304
|
+
return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* ── Wheels ↔ colour offsets ──
|
|
308
|
+
A wheel's puck sits at (Cb, Cr) of its r/g/b offset — the same plane as
|
|
309
|
+
the vectorscope, so pushing a wheel toward red moves the trace toward the
|
|
310
|
+
red target. The r/g/b it writes has zero Rec.709 luma (pure colour); the
|
|
311
|
+
master component carries brightness. */
|
|
312
|
+
function wheelToRgb(cb, cr) {
|
|
313
|
+
return [
|
|
314
|
+
+(1.5748 * cr).toFixed(4),
|
|
315
|
+
+(-0.18733 * cb - 0.46812 * cr).toFixed(4),
|
|
316
|
+
+(1.8556 * cb).toFixed(4),
|
|
317
|
+
];
|
|
318
|
+
}
|
|
319
|
+
function rgbToWheel(r, g, b) {
|
|
320
|
+
return [ycbcr(r, g, b)[1], ycbcr(r, g, b)[2]];
|
|
321
|
+
}
|
|
322
|
+
/** Rec.709 Y, Cb, Cr (Cb/Cr ±0.5). */
|
|
323
|
+
function ycbcr(r, g, b) {
|
|
324
|
+
return [
|
|
325
|
+
0.2126 * r + 0.7152 * g + 0.0722 * b,
|
|
326
|
+
-0.114572 * r - 0.385428 * g + 0.5 * b,
|
|
327
|
+
0.5 * r - 0.454153 * g - 0.045847 * b,
|
|
328
|
+
];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* ── Curves: monotone cubic interpolation (Fritsch–Carlson) ── */
|
|
332
|
+
function monotone(pts) {
|
|
333
|
+
const n = pts.length, xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
|
|
334
|
+
if (n === 1) return () => ys[0];
|
|
335
|
+
const d = [], m = new Array(n);
|
|
336
|
+
for (let i = 0; i < n - 1; i++) d.push((ys[i + 1] - ys[i]) / Math.max(1e-9, xs[i + 1] - xs[i]));
|
|
337
|
+
m[0] = d[0]; m[n - 1] = d[n - 2];
|
|
338
|
+
for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) / 2;
|
|
339
|
+
for (let i = 0; i < n - 1; i++) {
|
|
340
|
+
if (d[i] === 0) { m[i] = 0; m[i + 1] = 0; continue; }
|
|
341
|
+
const a = m[i] / d[i], b = m[i + 1] / d[i], s2 = a * a + b * b;
|
|
342
|
+
if (s2 > 9) { const t = 3 / Math.sqrt(s2); m[i] = t * a * d[i]; m[i + 1] = t * b * d[i]; }
|
|
343
|
+
}
|
|
344
|
+
return (x) => {
|
|
345
|
+
if (x <= xs[0]) return ys[0];
|
|
346
|
+
if (x >= xs[n - 1]) return ys[n - 1];
|
|
347
|
+
let i = 0;
|
|
348
|
+
while (i < n - 2 && x > xs[i + 1]) i++;
|
|
349
|
+
const h = xs[i + 1] - xs[i], t = (x - xs[i]) / h, t2 = t * t, t3 = t2 * t;
|
|
350
|
+
return (2 * t3 - 3 * t2 + 1) * ys[i] + (t3 - 2 * t2 + t) * h * m[i] + (-2 * t3 + 3 * t2) * ys[i + 1] + (t3 - t2) * h * m[i + 1];
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/** Evaluate a stored curve (as normalized) at x. */
|
|
354
|
+
function curveFn(pts, periodic) {
|
|
355
|
+
if (!periodic) return monotone(pts);
|
|
356
|
+
const ext = [...pts.map(([x, y]) => [x - 360, y]), ...pts, ...pts.map(([x, y]) => [x + 360, y])];
|
|
357
|
+
const f = monotone(ext);
|
|
358
|
+
return (x) => f(((x % 360) + 360) % 360);
|
|
359
|
+
}
|
|
360
|
+
const lutCache = new Map();
|
|
361
|
+
function cached(key, build) {
|
|
362
|
+
let v = lutCache.get(key);
|
|
363
|
+
if (!v) {
|
|
364
|
+
v = build();
|
|
365
|
+
if (lutCache.size > 48) lutCache.delete(lutCache.keys().next().value);
|
|
366
|
+
lutCache.set(key, v);
|
|
367
|
+
}
|
|
368
|
+
return v;
|
|
369
|
+
}
|
|
370
|
+
/** RGBA float table, CURVE_N wide: R G B curves in .rgb, the luma curve in .a. */
|
|
371
|
+
function curveLut(curves) {
|
|
372
|
+
if (!curves || !CURVE_CHANNELS.some((ch) => curves[ch])) return null;
|
|
373
|
+
return cached("c" + JSON.stringify(curves), () => {
|
|
374
|
+
const t = new Float32Array(CURVE_N * 4);
|
|
375
|
+
const f = CURVE_CHANNELS.map((ch) => (curves[ch] ? monotone(curves[ch]) : (x) => x));
|
|
376
|
+
for (let i = 0; i < CURVE_N; i++) {
|
|
377
|
+
const x = i / (CURVE_N - 1);
|
|
378
|
+
t[i * 4] = clamp(f[1](x), 0, 1); t[i * 4 + 1] = clamp(f[2](x), 0, 1);
|
|
379
|
+
t[i * 4 + 2] = clamp(f[3](x), 0, 1); t[i * 4 + 3] = clamp(f[0](x), 0, 1);
|
|
380
|
+
}
|
|
381
|
+
return t;
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
/** RGBA float table, HUE_N wide: hue shift (°), sat factor, hue→luma (by hue)
|
|
385
|
+
* and sat→luma (by saturation 0…1) in .r .g .b .a. */
|
|
386
|
+
function hueLut(g) {
|
|
387
|
+
if (!HUE_KEYS.some((k) => g[k])) return null;
|
|
388
|
+
return cached("h" + JSON.stringify(HUE_KEYS.map((k) => g[k] || 0)), () => {
|
|
389
|
+
const t = new Float32Array(HUE_N * 4);
|
|
390
|
+
const f = HUE_KEYS.map((k) => {
|
|
391
|
+
const d = HUE_CURVES[k];
|
|
392
|
+
return g[k] ? curveFn(g[k], d.periodic) : () => d.neutral;
|
|
393
|
+
});
|
|
394
|
+
for (let i = 0; i < HUE_N; i++) {
|
|
395
|
+
const h = i * 360 / HUE_N, sv = i / (HUE_N - 1);
|
|
396
|
+
for (let k = 0; k < 3; k++) t[i * 4 + k] = clamp(f[k](h), HUE_CURVES[HUE_KEYS[k]].min, HUE_CURVES[HUE_KEYS[k]].max);
|
|
397
|
+
t[i * 4 + 3] = clamp(f[3](sv), -0.5, 0.5);
|
|
398
|
+
}
|
|
399
|
+
return t;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
/** Linear read of a baked table, like the shader's two texelFetch taps. */
|
|
403
|
+
function lutAt(t, n, x, ch) {
|
|
404
|
+
const f = clamp(x, 0, 1) * (n - 1), i = Math.floor(f), j = Math.min(i + 1, n - 1), u = f - i;
|
|
405
|
+
return t[i * 4 + ch] + (t[j * 4 + ch] - t[i * 4 + ch]) * u;
|
|
406
|
+
}
|
|
407
|
+
function hueAt(t, h, ch) { // h in 0…1, wraps
|
|
408
|
+
const f = (((h % 1) + 1) % 1) * HUE_N, i = Math.floor(f) % HUE_N, j = (i + 1) % HUE_N, u = f - Math.floor(f);
|
|
409
|
+
return t[i * 4 + ch] + (t[j * 4 + ch] - t[i * 4 + ch]) * u;
|
|
410
|
+
}
|
|
411
|
+
function rgbToHsv(r, g, b) {
|
|
412
|
+
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
|
|
413
|
+
let h = 0;
|
|
414
|
+
if (d > 1e-9) {
|
|
415
|
+
h = mx === r ? (g - b) / d : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
416
|
+
h /= 6; if (h < 0) h += 1;
|
|
417
|
+
}
|
|
418
|
+
return [h, mx > 1e-9 ? d / mx : 0, mx];
|
|
419
|
+
}
|
|
420
|
+
function hsvToRgb(h, s, v) {
|
|
421
|
+
const k = (n) => { const q = (n + h * 6) % 6; return v - v * s * Math.max(0, Math.min(q, 4 - q, 1)); };
|
|
422
|
+
return [k(5), k(3), k(1)];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* ── Layers (secondaries): qualifier × mask mixes a grade in ── */
|
|
426
|
+
const LAYER_META = ["name", "on", "qualifier", "mask"];
|
|
427
|
+
/** The grade part of a layer (no name / qualifier / mask). */
|
|
428
|
+
function layerGrade(l) {
|
|
429
|
+
if (!l) return null;
|
|
430
|
+
const g = {};
|
|
431
|
+
for (const [k, v] of Object.entries(l)) if (!LAYER_META.includes(k)) g[k] = v;
|
|
432
|
+
return g;
|
|
433
|
+
}
|
|
434
|
+
function normalizeLayers(v, strict) {
|
|
435
|
+
if (v == null) return null;
|
|
436
|
+
if (!Array.isArray(v)) { if (strict) throw new Error("grade.layers must be a list of layers"); return null; }
|
|
437
|
+
if (v.length > MAX_LAYERS) { if (strict) throw new Error(`grade.layers has ${v.length} layers (at most ${MAX_LAYERS})`); v = v.slice(0, MAX_LAYERS); }
|
|
438
|
+
const out = v.map((l, i) => normalizeLayer(l, strict, i)).filter(Boolean);
|
|
439
|
+
return out.length ? out : null;
|
|
440
|
+
}
|
|
441
|
+
function normalizeLayer(l, strict, i) {
|
|
442
|
+
const where = `grade.layers[${i}]`;
|
|
443
|
+
if (l == null || typeof l !== "object" || Array.isArray(l)) {
|
|
444
|
+
if (strict) throw new Error(`${where} must be an object like {qualifier:{hue:[25, 40, 20]}, saturation: 90}`);
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
if ("layers" in l && strict) throw new Error(`${where}: layers do not nest`);
|
|
448
|
+
let gpart;
|
|
449
|
+
try { gpart = normalizeGrade(layerGrade(l), strict, true); }
|
|
450
|
+
catch (err) { throw new Error(`${where}: ${err.message.replace(/^grade\./, "")}`); }
|
|
451
|
+
const out = {};
|
|
452
|
+
if (typeof l.name === "string" && l.name.trim()) out.name = l.name.trim().slice(0, 40);
|
|
453
|
+
else if (l.name != null && strict) throw new Error(`${where}.name must be a string`);
|
|
454
|
+
if (l.on === false) out.on = false;
|
|
455
|
+
const q = normalizeQualifier(l.qualifier, strict, where);
|
|
456
|
+
if (q) out.qualifier = q;
|
|
457
|
+
const m = normalizeMask(l.mask, strict, where);
|
|
458
|
+
if (m) out.mask = m;
|
|
459
|
+
if (gpart) { delete gpart.on; Object.assign(out, gpart); }
|
|
460
|
+
return Object.keys(out).length ? out : null;
|
|
461
|
+
}
|
|
462
|
+
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
463
|
+
function normalizeQualifier(q, strict, where) {
|
|
464
|
+
if (q == null) return null;
|
|
465
|
+
const bad = (msg) => { if (strict) throw new Error(`${where}.qualifier${msg}`); return null; };
|
|
466
|
+
if (typeof q !== "object" || Array.isArray(q)) return bad(" must be {hue?, sat?, luma?, invert?}");
|
|
467
|
+
const out = {};
|
|
468
|
+
for (const [k, v] of Object.entries(q)) {
|
|
469
|
+
if (k === "invert") { if (v === true) out.invert = true; continue; }
|
|
470
|
+
if (k !== "hue" && k !== "sat" && k !== "luma") { bad(`.${k}: unknown key (hue, sat, luma, invert)`); continue; }
|
|
471
|
+
if (v == null) continue;
|
|
472
|
+
if (!Array.isArray(v) || v.length < 2 || v.length > 3 || !v.every(isNum)) {
|
|
473
|
+
bad(k === "hue" ? ".hue must be [centre°, width°, soft°?]" : `.${k} must be [low, high, soft?] on 0…1`);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (k === "hue") {
|
|
477
|
+
out.hue = [+(((v[0] % 360) + 360) % 360).toFixed(2), +clamp(v[1], 0, 360).toFixed(2), +clamp(v[2] ?? 10, 0, 180).toFixed(2)];
|
|
478
|
+
} else {
|
|
479
|
+
let lo = clamp(v[0], 0, 1), hi = clamp(v[1], 0, 1);
|
|
480
|
+
if (lo > hi) [lo, hi] = [hi, lo];
|
|
481
|
+
out[k] = [+lo.toFixed(4), +hi.toFixed(4), +clamp(v[2] ?? 0.05, 0, 0.5).toFixed(4)];
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return out.hue || out.sat || out.luma ? out : null;
|
|
485
|
+
}
|
|
486
|
+
function normalizeMask(m, strict, where) {
|
|
487
|
+
if (m == null) return null;
|
|
488
|
+
const bad = (msg) => { if (strict) throw new Error(`${where}.mask${msg}`); return null; };
|
|
489
|
+
if (typeof m !== "object" || Array.isArray(m)) return bad(" must be {shape, x, y, w, h, rotation?, feather?, invert?}");
|
|
490
|
+
const shape = m.shape == null ? "ellipse" : m.shape;
|
|
491
|
+
if (!MASK_SHAPES.includes(shape)) return bad(`.shape must be ${MASK_SHAPES.join(" | ")}`);
|
|
492
|
+
for (const k of Object.keys(m)) if (![...MASK_KEYED, "shape", "invert", "points", "keys"].includes(k)) bad(`.${k}: unknown key`);
|
|
493
|
+
const f = (k, def) => {
|
|
494
|
+
const v = m[k];
|
|
495
|
+
if (v == null) return def;
|
|
496
|
+
if (!isNum(v)) { bad(`.${k} must be a number`); return def; }
|
|
497
|
+
return v;
|
|
498
|
+
};
|
|
499
|
+
const out = {
|
|
500
|
+
shape,
|
|
501
|
+
x: +clamp(f("x", 0.5), -1, 2).toFixed(4), y: +clamp(f("y", 0.5), -1, 2).toFixed(4),
|
|
502
|
+
w: +clamp(f("w", 0.4), 0.001, 4).toFixed(4), h: +clamp(f("h", 0.4), 0.001, 4).toFixed(4),
|
|
503
|
+
rotation: +((((f("rotation", 0) + 180) % 360) + 360) % 360 - 180).toFixed(2),
|
|
504
|
+
feather: +clamp(f("feather", 0.05), 0, 0.5).toFixed(4),
|
|
505
|
+
};
|
|
506
|
+
if (m.invert === true) out.invert = true;
|
|
507
|
+
if (shape === "poly") {
|
|
508
|
+
const pts = m.points;
|
|
509
|
+
if (!Array.isArray(pts) || pts.length < 3 || pts.length > MAX_POLY || pts.some((p) => !Array.isArray(p) || p.length !== 2 || !p.every(isNum)))
|
|
510
|
+
return bad(`.points must be 3…${MAX_POLY} [dx, dy] pairs around x, y (fractions of the picture)`);
|
|
511
|
+
out.points = pts.map(([a, b]) => [+clamp(a, -2, 2).toFixed(4), +clamp(b, -2, 2).toFixed(4)]);
|
|
512
|
+
}
|
|
513
|
+
if (m.keys != null) {
|
|
514
|
+
if (!Array.isArray(m.keys)) return bad(".keys must be a list of {t, x?, y?, w?, h?, rotation?, feather?}");
|
|
515
|
+
const keys = [];
|
|
516
|
+
for (const kf of m.keys) {
|
|
517
|
+
if (!kf || typeof kf !== "object" || !isNum(kf.t)) { bad(".keys entries need a time t (seconds from the clip's start)"); continue; }
|
|
518
|
+
const k = { t: +Math.max(0, kf.t).toFixed(4) };
|
|
519
|
+
for (const p of MASK_KEYED) if (isNum(kf[p])) k[p] = +clamp(kf[p], ...KEY_RANGE[p]).toFixed(4);
|
|
520
|
+
if (kf.ease && kf.ease !== "ease-in-out") {
|
|
521
|
+
if (!EASES[kf.ease]) { bad(`.keys ease must be ${Object.keys(EASES).join(" | ")}`); continue; }
|
|
522
|
+
k.ease = kf.ease;
|
|
523
|
+
}
|
|
524
|
+
if (Object.keys(k).some((p) => MASK_KEYED.includes(p))) keys.push(k);
|
|
525
|
+
}
|
|
526
|
+
keys.sort((a, b) => a.t - b.t);
|
|
527
|
+
const dedup = keys.filter((k, i) => i === keys.length - 1 || keys[i + 1].t !== k.t);
|
|
528
|
+
if (dedup.length) out.keys = dedup;
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
const EASES = {
|
|
533
|
+
linear: (u) => u,
|
|
534
|
+
"ease-in": (u) => u * u,
|
|
535
|
+
"ease-out": (u) => 1 - (1 - u) * (1 - u),
|
|
536
|
+
"ease-in-out": (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2),
|
|
537
|
+
};
|
|
538
|
+
/** A mask's shape at clip-local time t (keys resolved, keys removed). */
|
|
539
|
+
function maskAt(m, t) {
|
|
540
|
+
if (!m || !m.keys || !m.keys.length) return m;
|
|
541
|
+
const out = { ...m };
|
|
542
|
+
delete out.keys;
|
|
543
|
+
for (const p of MASK_KEYED) {
|
|
544
|
+
const ks = m.keys.filter((k) => k[p] != null);
|
|
545
|
+
if (!ks.length) continue;
|
|
546
|
+
if (t <= ks[0].t) { out[p] = ks[0][p]; continue; }
|
|
547
|
+
if (t >= ks[ks.length - 1].t) { out[p] = ks[ks.length - 1][p]; continue; }
|
|
548
|
+
let i = 0;
|
|
549
|
+
while (t > ks[i + 1].t) i++;
|
|
550
|
+
const a = ks[i], b = ks[i + 1], u = (EASES[b.ease || "ease-in-out"] || EASES.linear)((t - a.t) / Math.max(1e-9, b.t - a.t));
|
|
551
|
+
out[p] = a[p] + (b[p] - a[p]) * u;
|
|
552
|
+
}
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
/** The grade at clip-local time t: animated masks resolved (same object when nothing moves). */
|
|
556
|
+
function gradeAt(g, t) {
|
|
557
|
+
if (!g || !Array.isArray(g.layers) || !g.layers.some((l) => l.mask && l.mask.keys)) return g;
|
|
558
|
+
return { ...g, layers: g.layers.map((l) => (l.mask && l.mask.keys ? { ...l, mask: maskAt(l.mask, t) } : l)) };
|
|
559
|
+
}
|
|
560
|
+
const smooth = (a, b, x) => { const u = clamp((x - a) / (b - a), 0, 1); return u * u * (3 - 2 * u); };
|
|
561
|
+
function range(v, q) {
|
|
562
|
+
const sf = Math.max(1e-4, q[2]);
|
|
563
|
+
return smooth(q[0] - sf, q[0], v) * (1 - smooth(q[1], q[1] + sf, v));
|
|
564
|
+
}
|
|
565
|
+
/** Qualifier matte (0…1) for an sRGB colour. Greys carry no hue, so a hue
|
|
566
|
+
* range fades out below 2–10 % saturation. */
|
|
567
|
+
function qualAlpha(q, r, g, b) {
|
|
568
|
+
if (!q) return 1;
|
|
569
|
+
let a = 1;
|
|
570
|
+
if (q.hue || q.sat) {
|
|
571
|
+
const hsv = rgbToHsv(clamp(r, 0, 1), clamp(g, 0, 1), clamp(b, 0, 1));
|
|
572
|
+
if (q.hue) {
|
|
573
|
+
const c = q.hue[0] / 360, hw = q.hue[1] / 720, sf = Math.max(1e-4, q.hue[2] / 360);
|
|
574
|
+
const d = Math.abs((((hsv[0] - c + 0.5) % 1) + 1) % 1 - 0.5);
|
|
575
|
+
a *= (1 - smooth(hw, hw + sf, d)) * smooth(0.02, 0.1, hsv[1]);
|
|
576
|
+
}
|
|
577
|
+
if (q.sat) a *= range(hsv[1], q.sat);
|
|
578
|
+
}
|
|
579
|
+
if (q.luma) a *= range(clamp(0.2126 * r + 0.7152 * g + 0.0722 * b, 0, 1), q.luma);
|
|
580
|
+
return q.invert ? 1 - a : a;
|
|
581
|
+
}
|
|
582
|
+
/** Mask matte (0…1) at picture position (u, v), 0…1 with y down; aspect = width / height. */
|
|
583
|
+
function maskAlpha(m, u, v, aspect) {
|
|
584
|
+
if (!m) return 1;
|
|
585
|
+
const d = maskDist(m, u, v, aspect), f = Math.max(0.002, m.feather);
|
|
586
|
+
const a = 1 - smooth(-f / 2, f / 2, d);
|
|
587
|
+
return m.invert ? 1 - a : a;
|
|
588
|
+
}
|
|
589
|
+
/** Signed distance (in picture heights) from (u, v) to the mask's edge — negative inside. */
|
|
590
|
+
function maskDist(m, u, v, aspect) {
|
|
591
|
+
const th = m.rotation * Math.PI / 180, cs = Math.cos(th), sn = Math.sin(th);
|
|
592
|
+
const dx = (u - m.x) * aspect, dy = v - m.y;
|
|
593
|
+
const px = dx * cs + dy * sn, py = -dx * sn + dy * cs;
|
|
594
|
+
const rx = m.w / 2 * aspect, ry = m.h / 2;
|
|
595
|
+
if (m.shape === "rect") {
|
|
596
|
+
const qx = Math.abs(px) - rx, qy = Math.abs(py) - ry;
|
|
597
|
+
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0);
|
|
598
|
+
}
|
|
599
|
+
if (m.shape === "poly") return polyDist(m.points.map(([a, b]) => [a * aspect, b]), px, py);
|
|
600
|
+
const k0 = Math.hypot(px / rx, py / ry), k1 = Math.hypot(px / (rx * rx), py / (ry * ry));
|
|
601
|
+
return k0 < 1e-6 ? -Math.min(rx, ry) : k0 * (k0 - 1) / k1;
|
|
602
|
+
}
|
|
603
|
+
function polyDist(pts, px, py) {
|
|
604
|
+
let d = (px - pts[0][0]) ** 2 + (py - pts[0][1]) ** 2, s = 1;
|
|
605
|
+
for (let i = 0, j = pts.length - 1; i < pts.length; j = i, i++) {
|
|
606
|
+
const ex = pts[j][0] - pts[i][0], ey = pts[j][1] - pts[i][1], wx = px - pts[i][0], wy = py - pts[i][1];
|
|
607
|
+
const k = clamp((wx * ex + wy * ey) / Math.max(1e-12, ex * ex + ey * ey), 0, 1);
|
|
608
|
+
d = Math.min(d, (wx - ex * k) ** 2 + (wy - ey * k) ** 2);
|
|
609
|
+
const c1 = py >= pts[i][1], c2 = py < pts[j][1], c3 = ex * wy > ey * wx;
|
|
610
|
+
if ((c1 && c2 && c3) || (!c1 && !c2 && !c3)) s = -s;
|
|
611
|
+
}
|
|
612
|
+
return s * Math.sqrt(d);
|
|
613
|
+
}
|
|
614
|
+
/** The layers that render (on, and changing something), prepared; plus
|
|
615
|
+
* `matte` = a layer index whose matte replaces the picture. */
|
|
616
|
+
function prepareLayers(g, matte = -1) {
|
|
617
|
+
const out = [], n = g && Array.isArray(g.layers) ? normalizeGrade({ layers: g.layers }) : null; // fills mask defaults
|
|
618
|
+
(n ? n.layers : []).forEach((l, i) => {
|
|
619
|
+
const show = i === matte;
|
|
620
|
+
if (!show && (l.on === false || isNeutral(layerGrade(l)))) return;
|
|
621
|
+
out.push({ index: i, P: prepareGrade(layerGrade(l)), qualifier: l.qualifier || null, mask: l.mask || null, matte: show });
|
|
622
|
+
});
|
|
623
|
+
const m = out.findIndex((L) => L.matte);
|
|
624
|
+
return m >= 0 ? out.slice(0, m + 1) : out;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/* ── The grade itself ── */
|
|
628
|
+
/** Pre-compute the numbers the shader and the CPU path use. */
|
|
629
|
+
function prepareGrade(g) {
|
|
630
|
+
const f = fullGrade(g && g.on === false ? g : normalizeGrade(g));
|
|
631
|
+
const wb = wbGains(f.temp, f.tint), ex = Math.pow(2, f.exposure);
|
|
632
|
+
const per = (w, c) => w[c] + w[3];
|
|
633
|
+
const P = {
|
|
634
|
+
on: f.on,
|
|
635
|
+
mul: [0, 1, 2].map((c) => wb[c] * ex),
|
|
636
|
+
off: [0, 1, 2].map((c) => per(f.offset, c)),
|
|
637
|
+
lift: [0, 1, 2].map((c) => per(f.lift, c)),
|
|
638
|
+
gain: [0, 1, 2].map((c) => 1 + per(f.gain, c)),
|
|
639
|
+
gam: [0, 1, 2].map((c) => 1 / Math.pow(2, per(f.gamma, c))),
|
|
640
|
+
contrast: f.contrast, pivot: f.pivot,
|
|
641
|
+
tone: [f.blacks, f.shadows, f.midtones, f.highlights, f.whites].map((v) => (v / 100) * TONE_K),
|
|
642
|
+
lowSoft: f.lowSoft / 100, highSoft: f.highSoft / 100,
|
|
643
|
+
sat: f.saturation / 100,
|
|
644
|
+
};
|
|
645
|
+
P.curveLut = curveLut(f.curves);
|
|
646
|
+
P.hueLut = hueLut(f);
|
|
647
|
+
P.linear = P.mul.some((m) => Math.abs(m - 1) > 1e-6);
|
|
648
|
+
P.toned = P.tone.some((v) => v !== 0);
|
|
649
|
+
return P;
|
|
650
|
+
}
|
|
651
|
+
/** Steps 1–6: one channel. */
|
|
652
|
+
function channelCurve(v, P, c) {
|
|
653
|
+
if (P.linear) v = linearToSrgb(Math.max(0, srgbToLinear(v) * P.mul[c]));
|
|
654
|
+
v += P.off[c];
|
|
655
|
+
v += P.lift[c] * (1 - v);
|
|
656
|
+
v *= P.gain[c];
|
|
657
|
+
v = Math.pow(Math.max(0, v), P.gam[c]);
|
|
658
|
+
if (P.contrast !== 1) v = P.pivot * Math.pow(Math.max(0, v) / P.pivot, P.contrast);
|
|
659
|
+
return v;
|
|
660
|
+
}
|
|
661
|
+
function softHigh(v, s) {
|
|
662
|
+
if (s <= 0) return v;
|
|
663
|
+
const k = 1 - 0.6 * s;
|
|
664
|
+
return v > k ? k + (1 - k) * Math.tanh((v - k) / (1 - k)) : v;
|
|
665
|
+
}
|
|
666
|
+
function softLow(v, s) {
|
|
667
|
+
if (s <= 0) return v;
|
|
668
|
+
const k = 0.25 * s;
|
|
669
|
+
return v < k ? k - k * Math.tanh((k - v) / k) : v;
|
|
670
|
+
}
|
|
671
|
+
/** Steps 7–9 on one pixel already through the channel curves. */
|
|
672
|
+
function finishPixel(r, g, b, P, out) {
|
|
673
|
+
if (P.toned) {
|
|
674
|
+
const y = clamp(0.2126 * r + 0.7152 * g + 0.0722 * b, 0, 1), u = 1 - y;
|
|
675
|
+
const d = P.tone[0] * u * u * u * u + P.tone[1] * 4 * y * u * u * u + P.tone[2] * 6 * y * y * u * u +
|
|
676
|
+
P.tone[3] * 4 * y * y * y * u + P.tone[4] * y * y * y * y;
|
|
677
|
+
r += d; g += d; b += d;
|
|
678
|
+
}
|
|
679
|
+
r = softLow(softHigh(r, P.highSoft), P.lowSoft);
|
|
680
|
+
g = softLow(softHigh(g, P.highSoft), P.lowSoft);
|
|
681
|
+
b = softLow(softHigh(b, P.highSoft), P.lowSoft);
|
|
682
|
+
if (P.sat !== 1) {
|
|
683
|
+
const y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
684
|
+
r = y + (r - y) * P.sat; g = y + (g - y) * P.sat; b = y + (b - y) * P.sat;
|
|
685
|
+
}
|
|
686
|
+
if (P.curveLut) {
|
|
687
|
+
const t = P.curveLut;
|
|
688
|
+
r = clamp(r, 0, 1); g = clamp(g, 0, 1); b = clamp(b, 0, 1);
|
|
689
|
+
const y = 0.2126 * r + 0.7152 * g + 0.0722 * b, dy = lutAt(t, CURVE_N, y, 3) - y;
|
|
690
|
+
r = lutAt(t, CURVE_N, r + dy, 0); g = lutAt(t, CURVE_N, g + dy, 1); b = lutAt(t, CURVE_N, b + dy, 2);
|
|
691
|
+
}
|
|
692
|
+
if (P.hueLut) {
|
|
693
|
+
const t = P.hueLut;
|
|
694
|
+
const [h, s0, v] = rgbToHsv(clamp(r, 0, 1), clamp(g, 0, 1), clamp(b, 0, 1));
|
|
695
|
+
const h2 = h + hueAt(t, h, 0) / 360, s2 = clamp(s0 * hueAt(t, h, 1), 0, 1);
|
|
696
|
+
const dl = hueAt(t, h, 2) * s0 + lutAt(t, HUE_N, s0, 3);
|
|
697
|
+
[r, g, b] = hsvToRgb(((h2 % 1) + 1) % 1, s2, v);
|
|
698
|
+
r += dl; g += dl; b += dl;
|
|
699
|
+
}
|
|
700
|
+
out[0] = clamp(r, 0, 1); out[1] = clamp(g, 0, 1); out[2] = clamp(b, 0, 1);
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
/** Grade one sRGB colour (0…1) — the reference the shader matches. */
|
|
704
|
+
/** Grade one sRGB colour. Layers see it at picture position (u, v). */
|
|
705
|
+
function gradePixel(rgb, g, u = 0.5, v = 0.5, aspect = 16 / 9) {
|
|
706
|
+
const P = g && g.mul ? g : prepareGrade(g);
|
|
707
|
+
if (!P.on) return rgb.slice(0, 3);
|
|
708
|
+
const out = finishPixel(channelCurve(rgb[0], P, 0), channelCurve(rgb[1], P, 1), channelCurve(rgb[2], P, 2), P, [0, 0, 0]);
|
|
709
|
+
return g && g.mul ? out : applyLayers(out, prepareLayers(g), u, v, aspect);
|
|
710
|
+
}
|
|
711
|
+
/** Mix each layer's grade in by its matte (in place); a matte layer shows its matte. */
|
|
712
|
+
function applyLayers(px, Ls, u, v, aspect) {
|
|
713
|
+
for (const L of Ls) {
|
|
714
|
+
const a = qualAlpha(L.qualifier, px[0], px[1], px[2]) * maskAlpha(L.mask, u, v, aspect);
|
|
715
|
+
if (L.matte) { px[0] = px[1] = px[2] = a; break; }
|
|
716
|
+
if (a < 1e-5) continue;
|
|
717
|
+
const q = finishPixel(channelCurve(px[0], L.P, 0), channelCurve(px[1], L.P, 1), channelCurve(px[2], L.P, 2), L.P, [0, 0, 0]);
|
|
718
|
+
for (let c = 0; c < 3; c++) px[c] += (q[c] - px[c]) * a;
|
|
719
|
+
}
|
|
720
|
+
return px;
|
|
721
|
+
}
|
|
722
|
+
/** Grade RGBA bytes in place (the CPU fallback). w x h place the layers'
|
|
723
|
+
* masks; `matte` = a layer index whose matte replaces the picture. */
|
|
724
|
+
function gradeImageData(data, g, w = 1, h = 1, matte = -1) {
|
|
725
|
+
const P = prepareGrade(g);
|
|
726
|
+
if (!P.on) return data;
|
|
727
|
+
const lut = [new Float32Array(256), new Float32Array(256), new Float32Array(256)];
|
|
728
|
+
for (let c = 0; c < 3; c++) for (let i = 0; i < 256; i++) lut[c][i] = channelCurve(i / 255, P, c);
|
|
729
|
+
const Ls = prepareLayers(g, matte), aspect = w / h;
|
|
730
|
+
const px = [0, 0, 0];
|
|
731
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
732
|
+
finishPixel(lut[0][data[i]], lut[1][data[i + 1]], lut[2][data[i + 2]], P, px);
|
|
733
|
+
if (Ls.length) { const k = i >> 2; applyLayers(px, Ls, ((k % w) + 0.5) / w, (Math.floor(k / w) + 0.5) / h, aspect); }
|
|
734
|
+
data[i] = Math.round(px[0] * 255); data[i + 1] = Math.round(px[1] * 255); data[i + 2] = Math.round(px[2] * 255);
|
|
735
|
+
}
|
|
736
|
+
return data;
|
|
737
|
+
}
|
|
738
|
+
/** Uniform values for GRADE_FRAG (flat arrays, ready for gl.uniform*). */
|
|
739
|
+
function gradeUniforms(g) { return passUniforms(prepareGrade(g)); }
|
|
740
|
+
/** The GPU passes for a grade: the primary, then one per rendering layer
|
|
741
|
+
* (qualifier and mask uniforms included). `matte` as in gradeImageData. */
|
|
742
|
+
function gradePasses(g, matte = -1) {
|
|
743
|
+
const passes = [passUniforms(prepareGrade(g))];
|
|
744
|
+
for (const L of prepareLayers(g, matte)) {
|
|
745
|
+
const u = passUniforms(L.P), q = L.qualifier, m = L.mask;
|
|
746
|
+
u.layer = L.index;
|
|
747
|
+
u.uMatte = L.matte ? 1 : 0;
|
|
748
|
+
u.uQOn = q ? 1 : 0;
|
|
749
|
+
u.uQUse = q ? [q.hue ? 1 : 0, q.sat ? 1 : 0, q.luma ? 1 : 0] : [0, 0, 0];
|
|
750
|
+
u.uQHue = q && q.hue ? [q.hue[0] / 360, q.hue[1] / 720, Math.max(1e-4, q.hue[2] / 360)] : [0, 0, 1];
|
|
751
|
+
u.uQSat = q && q.sat ? [q.sat[0], q.sat[1], Math.max(1e-4, q.sat[2])] : [0, 1, 1];
|
|
752
|
+
u.uQLuma = q && q.luma ? [q.luma[0], q.luma[1], Math.max(1e-4, q.luma[2])] : [0, 1, 1];
|
|
753
|
+
u.uQInv = q && q.invert ? 1 : 0;
|
|
754
|
+
u.uMShape = m ? MASK_SHAPES.indexOf(m.shape) + 1 : 0;
|
|
755
|
+
u.uMC = m ? [m.x, m.y] : [0.5, 0.5];
|
|
756
|
+
u.uMW = m ? [m.w, m.h] : [1, 1];
|
|
757
|
+
u.uMRot = m ? m.rotation * Math.PI / 180 : 0;
|
|
758
|
+
u.uMFeather = m ? Math.max(0.002, m.feather) : 0.002;
|
|
759
|
+
u.uMInv = m && m.invert ? 1 : 0;
|
|
760
|
+
const pts = new Float32Array(MAX_POLY * 2);
|
|
761
|
+
if (m && m.points) m.points.forEach(([a, b], i) => { pts[i * 2] = a; pts[i * 2 + 1] = b; });
|
|
762
|
+
u.uMPts = pts;
|
|
763
|
+
u.uMN = m && m.points ? m.points.length : 0;
|
|
764
|
+
passes.push(u);
|
|
765
|
+
}
|
|
766
|
+
return passes;
|
|
767
|
+
}
|
|
768
|
+
function passUniforms(P) {
|
|
769
|
+
return {
|
|
770
|
+
uMul: P.mul, uLinear: P.linear ? 1 : 0, uOff: P.off, uLift: P.lift, uGain: P.gain, uGam: P.gam,
|
|
771
|
+
uContrast: P.contrast, uPivot: P.pivot, uTone: P.tone, uSoft: [P.lowSoft, P.highSoft], uSat: P.sat,
|
|
772
|
+
curveLut: P.curveLut, hueLut: P.hueLut, CURVE_N, HUE_N,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const GRADE_VERT = `#version 300 es
|
|
777
|
+
in vec2 aPos;
|
|
778
|
+
uniform vec4 uRect; // source crop: x, y, w, h in texture coords (y down)
|
|
779
|
+
out vec2 vUv;
|
|
780
|
+
out vec2 vPos; // 0…1 over the output picture, y down (masks live here)
|
|
781
|
+
void main() {
|
|
782
|
+
vec2 t = aPos * 0.5 + 0.5;
|
|
783
|
+
vUv = vec2(uRect.x + t.x * uRect.z, uRect.y + (1.0 - t.y) * uRect.w);
|
|
784
|
+
vPos = vec2(t.x, 1.0 - t.y);
|
|
785
|
+
gl_Position = vec4(aPos, 0.0, 1.0);
|
|
786
|
+
}`;
|
|
787
|
+
const GRADE_FRAG = `#version 300 es
|
|
788
|
+
precision highp float;
|
|
789
|
+
in vec2 vUv;
|
|
790
|
+
uniform sampler2D uTex;
|
|
791
|
+
uniform vec3 uMul; uniform int uLinear;
|
|
792
|
+
uniform vec3 uOff, uLift, uGain, uGam;
|
|
793
|
+
uniform float uContrast, uPivot, uSat;
|
|
794
|
+
uniform float uTone[5];
|
|
795
|
+
uniform vec2 uSoft;
|
|
796
|
+
uniform sampler2D uCurveLut; // ${CURVE_N}×1 RGBA32F: r, g, b curves + luma curve in .a
|
|
797
|
+
uniform sampler2D uHueLut; // ${HUE_N}×1 RGBA32F: hue shift°, sat factor, hue→luma, sat→luma
|
|
798
|
+
uniform int uCurves, uHueCurves;
|
|
799
|
+
uniform int uLayer, uMatte, uPremul; // layer pass: mix by its matte · show the matte · premultiply the output
|
|
800
|
+
uniform int uQOn, uQInv;
|
|
801
|
+
uniform ivec3 uQUse; // hue, sat, luma ranges in use
|
|
802
|
+
uniform vec3 uQHue, uQSat, uQLuma; // hue: centre, half width, soft (turns) · sat / luma: low, high, soft
|
|
803
|
+
uniform int uMShape, uMInv, uMN; // 0 none · 1 ellipse · 2 rect · 3 poly
|
|
804
|
+
uniform vec2 uMC, uMW;
|
|
805
|
+
uniform float uMRot, uMFeather, uAspect;
|
|
806
|
+
uniform vec2 uMPts[${MAX_POLY}];
|
|
807
|
+
in vec2 vPos;
|
|
808
|
+
out vec4 outColor;
|
|
809
|
+
vec4 lutAt(sampler2D t, int n, float x) {
|
|
810
|
+
float f = clamp(x, 0.0, 1.0) * float(n - 1);
|
|
811
|
+
int i = int(floor(f));
|
|
812
|
+
return mix(texelFetch(t, ivec2(i, 0), 0), texelFetch(t, ivec2(min(i + 1, n - 1), 0), 0), f - float(i));
|
|
813
|
+
}
|
|
814
|
+
vec4 hueAt(float h) {
|
|
815
|
+
float f = fract(h) * float(${HUE_N});
|
|
816
|
+
int i = int(floor(f)) % ${HUE_N};
|
|
817
|
+
return mix(texelFetch(uHueLut, ivec2(i, 0), 0), texelFetch(uHueLut, ivec2((i + 1) % ${HUE_N}, 0), 0), fract(f));
|
|
818
|
+
}
|
|
819
|
+
vec3 rgbToHsv(vec3 c) {
|
|
820
|
+
float mx = max(c.r, max(c.g, c.b)), mn = min(c.r, min(c.g, c.b)), d = mx - mn, h = 0.0;
|
|
821
|
+
if (d > 1e-9) {
|
|
822
|
+
h = mx == c.r ? (c.g - c.b) / d : mx == c.g ? (c.b - c.r) / d + 2.0 : (c.r - c.g) / d + 4.0;
|
|
823
|
+
h /= 6.0; if (h < 0.0) h += 1.0;
|
|
824
|
+
}
|
|
825
|
+
return vec3(h, mx > 1e-9 ? d / mx : 0.0, mx);
|
|
826
|
+
}
|
|
827
|
+
vec3 hsvToRgb(vec3 c) {
|
|
828
|
+
vec3 q = mod(vec3(5.0, 3.0, 1.0) + c.x * 6.0, 6.0);
|
|
829
|
+
return c.z - c.z * c.y * max(vec3(0.0), min(min(q, 4.0 - q), vec3(1.0)));
|
|
830
|
+
}
|
|
831
|
+
vec3 toLin(vec3 v) { return mix(v / 12.92, pow((v + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), v)); }
|
|
832
|
+
vec3 toSrgb(vec3 v) { return mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), v)); }
|
|
833
|
+
float softHigh(float v, float s) {
|
|
834
|
+
if (s <= 0.0) return v;
|
|
835
|
+
float k = 1.0 - 0.6 * s;
|
|
836
|
+
return v > k ? k + (1.0 - k) * tanh((v - k) / (1.0 - k)) : v;
|
|
837
|
+
}
|
|
838
|
+
float softLow(float v, float s) {
|
|
839
|
+
if (s <= 0.0) return v;
|
|
840
|
+
float k = 0.25 * s;
|
|
841
|
+
return v < k ? k - k * tanh((k - v) / k) : v;
|
|
842
|
+
}
|
|
843
|
+
float range3(float v, vec3 q) { return smoothstep(q.x - q.z, q.x, v) * (1.0 - smoothstep(q.y, q.y + q.z, v)); }
|
|
844
|
+
float qualify(vec3 c) {
|
|
845
|
+
if (uQOn == 0) return 1.0;
|
|
846
|
+
vec3 hsv = rgbToHsv(clamp(c, 0.0, 1.0));
|
|
847
|
+
float a = 1.0;
|
|
848
|
+
if (uQUse.x == 1) {
|
|
849
|
+
float d = abs(fract(hsv.x - uQHue.x + 0.5) - 0.5);
|
|
850
|
+
a *= (1.0 - smoothstep(uQHue.y, uQHue.y + uQHue.z, d)) * smoothstep(0.02, 0.1, hsv.y);
|
|
851
|
+
}
|
|
852
|
+
if (uQUse.y == 1) a *= range3(hsv.y, uQSat);
|
|
853
|
+
if (uQUse.z == 1) a *= range3(clamp(dot(c, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0), uQLuma);
|
|
854
|
+
return uQInv == 1 ? 1.0 - a : a;
|
|
855
|
+
}
|
|
856
|
+
float maskDist(vec2 uv) {
|
|
857
|
+
float cs = cos(uMRot), sn = sin(uMRot);
|
|
858
|
+
vec2 d = vec2((uv.x - uMC.x) * uAspect, uv.y - uMC.y);
|
|
859
|
+
vec2 p = vec2(d.x * cs + d.y * sn, -d.x * sn + d.y * cs);
|
|
860
|
+
vec2 r = vec2(uMW.x * 0.5 * uAspect, uMW.y * 0.5);
|
|
861
|
+
if (uMShape == 2) {
|
|
862
|
+
vec2 q = abs(p) - r;
|
|
863
|
+
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
|
|
864
|
+
}
|
|
865
|
+
if (uMShape == 3) {
|
|
866
|
+
vec2 v0 = uMPts[0] * vec2(uAspect, 1.0);
|
|
867
|
+
float dd = dot(p - v0, p - v0), s = 1.0;
|
|
868
|
+
for (int i = 0; i < ${MAX_POLY}; i++) {
|
|
869
|
+
if (i >= uMN) break;
|
|
870
|
+
int j = i == 0 ? uMN - 1 : i - 1;
|
|
871
|
+
vec2 vi = uMPts[i] * vec2(uAspect, 1.0), vj = uMPts[j] * vec2(uAspect, 1.0);
|
|
872
|
+
vec2 e = vj - vi, w = p - vi;
|
|
873
|
+
vec2 b = w - e * clamp(dot(w, e) / max(dot(e, e), 1e-12), 0.0, 1.0);
|
|
874
|
+
dd = min(dd, dot(b, b));
|
|
875
|
+
bvec3 c = bvec3(p.y >= vi.y, p.y < vj.y, e.x * w.y > e.y * w.x);
|
|
876
|
+
if (all(c) || all(not(c))) s = -s;
|
|
877
|
+
}
|
|
878
|
+
return s * sqrt(dd);
|
|
879
|
+
}
|
|
880
|
+
float k0 = length(p / r), k1 = length(p / (r * r));
|
|
881
|
+
return k0 < 1e-6 ? -min(r.x, r.y) : k0 * (k0 - 1.0) / k1;
|
|
882
|
+
}
|
|
883
|
+
float maskAlpha(vec2 uv) {
|
|
884
|
+
if (uMShape == 0) return 1.0;
|
|
885
|
+
float a = 1.0 - smoothstep(-uMFeather * 0.5, uMFeather * 0.5, maskDist(uv));
|
|
886
|
+
return uMInv == 1 ? 1.0 - a : a;
|
|
887
|
+
}
|
|
888
|
+
vec3 grade(vec3 v) {
|
|
889
|
+
if (uLinear == 1) v = toSrgb(max(vec3(0.0), toLin(v) * uMul));
|
|
890
|
+
v += uOff;
|
|
891
|
+
v += uLift * (1.0 - v);
|
|
892
|
+
v *= uGain;
|
|
893
|
+
v = pow(max(v, vec3(0.0)), uGam);
|
|
894
|
+
if (uContrast != 1.0) v = uPivot * pow(max(v, vec3(0.0)) / uPivot, vec3(uContrast));
|
|
895
|
+
float y = clamp(dot(v, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0), u = 1.0 - y;
|
|
896
|
+
v += uTone[0] * u * u * u * u + uTone[1] * 4.0 * y * u * u * u + uTone[2] * 6.0 * y * y * u * u +
|
|
897
|
+
uTone[3] * 4.0 * y * y * y * u + uTone[4] * y * y * y * y;
|
|
898
|
+
v = vec3(softLow(softHigh(v.r, uSoft.y), uSoft.x), softLow(softHigh(v.g, uSoft.y), uSoft.x), softLow(softHigh(v.b, uSoft.y), uSoft.x));
|
|
899
|
+
float l = dot(v, vec3(0.2126, 0.7152, 0.0722));
|
|
900
|
+
v = l + (v - l) * uSat;
|
|
901
|
+
if (uCurves == 1) {
|
|
902
|
+
v = clamp(v, 0.0, 1.0);
|
|
903
|
+
float y = dot(v, vec3(0.2126, 0.7152, 0.0722));
|
|
904
|
+
float dy = lutAt(uCurveLut, ${CURVE_N}, y).a - y;
|
|
905
|
+
v = vec3(lutAt(uCurveLut, ${CURVE_N}, v.r + dy).r, lutAt(uCurveLut, ${CURVE_N}, v.g + dy).g, lutAt(uCurveLut, ${CURVE_N}, v.b + dy).b);
|
|
906
|
+
}
|
|
907
|
+
if (uHueCurves == 1) {
|
|
908
|
+
vec3 hsv = rgbToHsv(clamp(v, 0.0, 1.0));
|
|
909
|
+
vec4 hc = hueAt(hsv.x);
|
|
910
|
+
float dl = hc.b * hsv.y + lutAt(uHueLut, ${HUE_N}, hsv.y).a;
|
|
911
|
+
v = hsvToRgb(vec3(fract(hsv.x + hc.r / 360.0), clamp(hsv.y * hc.g, 0.0, 1.0), hsv.z)) + dl;
|
|
912
|
+
}
|
|
913
|
+
return clamp(v, 0.0, 1.0);
|
|
914
|
+
}
|
|
915
|
+
void main() {
|
|
916
|
+
vec4 src = texture(uTex, vUv);
|
|
917
|
+
vec3 v = grade(src.rgb);
|
|
918
|
+
if (uLayer == 1) {
|
|
919
|
+
float a = qualify(src.rgb) * maskAlpha(vPos);
|
|
920
|
+
v = uMatte == 1 ? vec3(a) : mix(src.rgb, v, a);
|
|
921
|
+
}
|
|
922
|
+
outColor = uPremul == 1 ? vec4(v * src.a, src.a) : vec4(v, src.a); // the canvas wants premultiplied
|
|
923
|
+
}`;
|
|
924
|
+
|
|
925
|
+
/* ── Scopes ──
|
|
926
|
+
All read RGBA bytes (a downscaled copy of the program monitor). */
|
|
927
|
+
/** kinds: any of "histogram", "waveform", "parade", "vectorscope".
|
|
928
|
+
* cols: waveform columns (default the image width). */
|
|
929
|
+
function computeScopes(data, w, h, kinds, cols = w) {
|
|
930
|
+
const want = new Set(kinds);
|
|
931
|
+
const out = {};
|
|
932
|
+
const hist = want.has("histogram") ? { r: new Uint32Array(256), g: new Uint32Array(256), b: new Uint32Array(256), y: new Uint32Array(256) } : null;
|
|
933
|
+
const wave = want.has("waveform") ? new Uint32Array(cols * 256) : null;
|
|
934
|
+
const par = want.has("parade") ? [new Uint32Array(cols * 256), new Uint32Array(cols * 256), new Uint32Array(cols * 256)] : null;
|
|
935
|
+
const vec = want.has("vectorscope") ? new Uint32Array(256 * 256) : null;
|
|
936
|
+
const colOf = new Uint16Array(w);
|
|
937
|
+
for (let x = 0; x < w; x++) colOf[x] = Math.min(cols - 1, Math.floor(x * cols / w));
|
|
938
|
+
for (let yy = 0; yy < h; yy++) {
|
|
939
|
+
let i = yy * w * 4;
|
|
940
|
+
for (let x = 0; x < w; x++, i += 4) {
|
|
941
|
+
if (data[i + 3] === 0) continue;
|
|
942
|
+
const r = data[i], g = data[i + 1], b = data[i + 2];
|
|
943
|
+
const Y = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
|
|
944
|
+
if (hist) { hist.r[r]++; hist.g[g]++; hist.b[b]++; hist.y[Y]++; }
|
|
945
|
+
const col = colOf[x];
|
|
946
|
+
if (wave) wave[col * 256 + Y]++;
|
|
947
|
+
if (par) { par[0][col * 256 + r]++; par[1][col * 256 + g]++; par[2][col * 256 + b]++; }
|
|
948
|
+
if (vec) {
|
|
949
|
+
const cb = -0.114572 * r - 0.385428 * g + 0.5 * b; // ±127.5
|
|
950
|
+
const cr = 0.5 * r - 0.454153 * g - 0.045847 * b;
|
|
951
|
+
const vx = clamp(Math.round(128 + cb), 0, 255), vy = clamp(Math.round(128 - cr), 0, 255);
|
|
952
|
+
vec[vy * 256 + vx]++;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (hist) out.histogram = hist;
|
|
957
|
+
if (wave) out.waveform = { cols, data: wave };
|
|
958
|
+
if (par) out.parade = { cols, data: par };
|
|
959
|
+
if (vec) out.vectorscope = vec;
|
|
960
|
+
return out;
|
|
961
|
+
}
|
|
962
|
+
/** Where the 75 % colour bars land on a 256² vectorscope (x, y, label). */
|
|
963
|
+
function vectorTargets(level = 0.75) {
|
|
964
|
+
const bars = { R: [1, 0, 0], Mg: [1, 0, 1], B: [0, 0, 1], Cy: [0, 1, 1], G: [0, 1, 0], Yl: [1, 1, 0] };
|
|
965
|
+
return Object.entries(bars).map(([label, c]) => {
|
|
966
|
+
const [, cb, cr] = ycbcr(c[0] * level, c[1] * level, c[2] * level);
|
|
967
|
+
return { label, x: 128 + cb * 255, y: 128 - cr * 255 };
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
const SKIN_ANGLE = 123; // degrees from +Cb toward +Cr — the "I" / skin-tone line
|
|
971
|
+
|
|
972
|
+
const HUE_NAMES = [["red", 0], ["orange", 30], ["yellow", 60], ["green", 120], ["cyan", 180], ["blue", 240], ["magenta", 300]];
|
|
973
|
+
/** Plain-number summary of a frame for agents: levels, clipping, cast. */
|
|
974
|
+
function scopeStats(data, w, h) {
|
|
975
|
+
const hy = new Uint32Array(256);
|
|
976
|
+
let n = 0, sr = 0, sg = 0, sb = 0, sat = 0, mcb = 0, mcr = 0, mn = 0;
|
|
977
|
+
for (let i = 0; i < w * h * 4; i += 4) {
|
|
978
|
+
if (data[i + 3] === 0) continue;
|
|
979
|
+
const r = data[i], g = data[i + 1], b = data[i + 2];
|
|
980
|
+
const [Y, cb, cr] = ycbcr(r / 255, g / 255, b / 255);
|
|
981
|
+
hy[clamp(Math.round(Y * 255), 0, 255)]++;
|
|
982
|
+
n++; sr += r; sg += g; sb += b; sat += Math.hypot(cb, cr);
|
|
983
|
+
if (Y > 0.15 && Y < 0.85) { mcb += cb; mcr += cr; mn++; }
|
|
984
|
+
}
|
|
985
|
+
if (!n) return null;
|
|
986
|
+
const pct = (q) => { let acc = 0; for (let i = 0; i < 256; i++) { acc += hy[i]; if (acc >= q * n) return i / 255; } return 1; };
|
|
987
|
+
let sum = 0, lo = -1, hi = 0;
|
|
988
|
+
for (let i = 0; i < 256; i++) { sum += i * hy[i]; if (hy[i] && lo < 0) lo = i; if (hy[i]) hi = i; }
|
|
989
|
+
const r3 = (v) => Math.round(v * 1000) / 1000;
|
|
990
|
+
const cb = mn ? mcb / mn : 0, cr = mn ? mcr / mn : 0;
|
|
991
|
+
// hue of the cast, measured like an HSV hue (red 0°, green 120°, blue 240°)
|
|
992
|
+
const rgb = [cr * 1.5748, -0.18733 * cb - 0.46812 * cr, 1.8556 * cb];
|
|
993
|
+
const hue = rgbHue(rgb);
|
|
994
|
+
const strength = Math.hypot(cb, cr);
|
|
995
|
+
const name = HUE_NAMES.reduce((best, [nm, a]) => {
|
|
996
|
+
const d = Math.min(Math.abs(hue - a), 360 - Math.abs(hue - a));
|
|
997
|
+
return d < best.d ? { nm, d } : best;
|
|
998
|
+
}, { nm: "", d: 999 }).nm;
|
|
999
|
+
return {
|
|
1000
|
+
pixels: n,
|
|
1001
|
+
luma: { min: r3(lo / 255), p1: r3(pct(0.01)), median: r3(pct(0.5)), mean: r3(sum / n / 255), p99: r3(pct(0.99)), max: r3(hi / 255) },
|
|
1002
|
+
clipped: { blackPct: r3(100 * hy[0] / n), whitePct: r3(100 * hy[255] / n) },
|
|
1003
|
+
rgbMean: [r3(sr / n / 255), r3(sg / n / 255), r3(sb / n / 255)],
|
|
1004
|
+
saturation: r3(sat / n),
|
|
1005
|
+
cast: strength < 0.01 ? { tone: "neutral", strength: r3(strength) } : { tone: name, hue: Math.round(hue), strength: r3(strength) },
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
function rgbHue([r, g, b]) {
|
|
1009
|
+
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
|
|
1010
|
+
if (d <= 0) return 0;
|
|
1011
|
+
let h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
1012
|
+
h *= 60;
|
|
1013
|
+
return h < 0 ? h + 360 : h;
|
|
1014
|
+
}
|
|
1015
|
+
/** Short text for a grade, e.g. "exposure+0.3 temp-12 lift(0,0,0.02,-0.03)". */
|
|
1016
|
+
function summarizeLayer(l, i) {
|
|
1017
|
+
const by = [];
|
|
1018
|
+
if (l.qualifier) by.push("qualifier(" + ["hue", "sat", "luma"].filter((k) => l.qualifier[k]).map((k) => `${k} ${l.qualifier[k].join("/")}`).join(", ") + (l.qualifier.invert ? ", inverted" : "") + ")");
|
|
1019
|
+
if (l.mask) by.push(`${l.mask.invert ? "outside " : ""}${l.mask.shape}@${+l.mask.x.toFixed(2)},${+l.mask.y.toFixed(2)}${l.mask.keys ? ` ${l.mask.keys.length} keys` : ""}`);
|
|
1020
|
+
const g = normalizeGrade(layerGrade(l), false, true);
|
|
1021
|
+
return `${l.name ? JSON.stringify(l.name) : "#" + (i + 1)}${l.on === false ? " (off)" : ""}: ${by.join(" ") || "whole frame"} → ${g ? summarizeGrade(g) : "neutral"}`;
|
|
1022
|
+
}
|
|
1023
|
+
function summarizeGrade(g) {
|
|
1024
|
+
const s = normalizeGrade(g);
|
|
1025
|
+
if (!s) return "neutral";
|
|
1026
|
+
return Object.entries(s).map(([k, v]) => k === "on" ? "(bypassed)"
|
|
1027
|
+
: k === "layers" ? `layers[${v.map((l, i) => summarizeLayer(l, i)).join(" | ")}]`
|
|
1028
|
+
: k === "curves" ? `curves(${Object.keys(v).join(",")})`
|
|
1029
|
+
: HUE_CURVES[k] ? `${k}(${v.map((p) => p.join(":")).join(" ")})`
|
|
1030
|
+
: Array.isArray(v) ? `${k}(${v.join(",")})` : `${k}${v >= 0 && k !== "contrast" && k !== "pivot" && k !== "saturation" ? "+" : ""}${v}`).join(" ");
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return {
|
|
1034
|
+
GRADE_PARAMS, GRADE_KEYS, WHEELS, GRADE_VERT, GRADE_FRAG, SKIN_ANGLE,
|
|
1035
|
+
CURVE_CHANNELS, HUE_CURVES, HUE_KEYS, CURVE_N, HUE_N, BAND,
|
|
1036
|
+
normalizeGrade, fullGrade, isNeutral, mergeGrade, summarizeGrade, withoutCurves,
|
|
1037
|
+
MAX_LAYERS, MAX_POLY, MASK_SHAPES, MASK_KEYED, mergeLayer, layerGrade, maskAt, gradeAt, qualAlpha, maskAlpha, maskDist, gradePasses,
|
|
1038
|
+
monotone, curveFn, rgbToHsv, hsvToRgb,
|
|
1039
|
+
wbGains, solveWhiteBalance, srgbToLinear, linearToSrgb, wheelToRgb, rgbToWheel, ycbcr,
|
|
1040
|
+
prepareGrade, gradePixel, gradeImageData, gradeUniforms,
|
|
1041
|
+
computeScopes, vectorTargets, scopeStats, round,
|
|
1042
|
+
};
|
|
1043
|
+
});
|