Skip to content

Commit 85a2255

Browse files
gyngclaude
andcommitted
New artistic filters: LUT, Orton, Sumi-e, Halation, Paper Texture + 6 GL ports
New filters: - LUT: 44 iconic colour-grade presets (ACES, Reinhard, Uncharted 2, Teal & Orange, Bleach Bypass, Cross Process, Kodachrome, Technicolor, Faded Film, Matrix Green, Amber Noir, Cold Winter; film stocks CineStill 800T, Velvia, Portra, Tri-X, Kodak Gold, Fuji Pro 400H, Ilford HP5+, Ektachrome, Agfa Vista; movie looks Blade Runner, Fury Road, Deakins, Saving Private Ryan, Breaking Bad, Joker, Inception, Drive, Stranger Things, etc.). Exposure stops -5..+5; strength 0..1.5 with overshoot. - Orton: dreamy screen-blended gaussian blur glow with saturation + contrast. - Sumi-e: Japanese ink-wash — quantized tonal washes + Sobel brush strokes on paper, configurable ink/paper colours and grain. - Halation: tinted highlight bleed with separable gaussian + screen composite (CineStill-style pink red fringe, configurable colour). - Paper Texture: procedural paper / canvas / linen / cardboard / parchment overlays with multiply / overlay / soft-light blend modes. GL ports (all pixel-identical to JS reference where deterministic): - Bokeh: remove wasmOk + paletteIdentity hard gates, apply palette pass post-readout; matches clahe pattern. Fixes WebGL2 fast path firing. - Isometric Extrude: invert the painter's algorithm in-shader — per pixel, the largest depth d whose source is above threshold wins. - Binarize: per-channel threshold + sRGB/linear mode. - Duotone, Chromatic Posterize, Color Pop, Solarize: straightforward per-pixel map ports. Solarize threshold biased by +0.5/255 to match JS strict-greater integer comparison under float32 texture sampling. Five bundled film-stock preset packs: CineStill 800T, Kodak Portra, Kodak Tri-X B&W, Fuji Velvia Landscape, Blade Runner 2049 — each chains the LUT with halation / grain / vignette as appropriate. UI fixes: - ENUM select values now render left-aligned (text-align-last: left). - Library browser no longer snaps to the right on first drag: the wrapper mounts lazily so useDraggable's init effect ran against a null ref; expose ensureInitializedPosition and call it in a useLayoutEffect when the browser opens. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5047bba commit 85a2255

28 files changed

Lines changed: 2956 additions & 34 deletions

src/components/App/useDraggable.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,5 +229,5 @@ export default function useDraggable(
229229
return () => window.removeEventListener("resize", handleResize);
230230
}, [ensureInitializedPosition, ref]);
231231

232-
return { onMouseDown, onMouseMove, didDrag };
232+
return { onMouseDown, onMouseMove, didDrag, ensureInitializedPosition };
233233
}

src/components/ChainList/index.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useRef, useCallback, useEffect } from "react";
1+
import { useState, useRef, useCallback, useEffect, useLayoutEffect } from "react";
22
import { useFilter } from "context/useFilter";
33
import useDraggable from "components/App/useDraggable";
44
import { filterList, noop } from "filters";
@@ -85,6 +85,14 @@ const ChainList = ({
8585
const dragCounter = useRef(0);
8686
const libraryDragRef = useRef<HTMLDivElement | null>(null);
8787
const libraryDrag = useDraggable(libraryDragRef, { defaultPosition: { x: 560, y: 90 } });
88+
const ensureLibraryDragInit = libraryDrag.ensureInitializedPosition;
89+
// The library-browser wrapper mounts lazily, so useDraggable's own
90+
// init effect (which runs before ref.current is attached) never applies
91+
// the initial transform. Without this nudge the first drag computes its
92+
// offset against (0,0) and snaps the window to the right of the cursor.
93+
useLayoutEffect(() => {
94+
if (showLibraryBrowser) ensureLibraryDragInit();
95+
}, [showLibraryBrowser, ensureLibraryDragInit]);
8896
const hoverOpenTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
8997
const hoverCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
9098
const randomCycleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);

src/components/ChainList/presets.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,15 @@ export const CHAIN_PRESETS: ChainPreset[] = [
381381
{ name: "Noir", desc: "High-contrast black and white with grain and vignette", filters: [f("Grayscale"), f("Levels"), f("Sharpen"), f("Vignette"), f("Film grain")], category: "Photo" },
382382
{ name: "Polaroid", desc: "Instant film look with faded edges and subtle grain", filters: [f("Polaroid"), f("Vignette"), f("Film grain")], category: "Photo" },
383383
{ name: "Vintage Photo", desc: "Warm sepia toning with chemical grain and light bleed", filters: [f("Sepia"), f("Film grain"), f("Vignette"), f("Light leak")], category: "Photo" },
384+
385+
// Top LUT-based packs — one halation showcase, two film stocks, one
386+
// saturated landscape, one cinematic look. The full LUT list is still
387+
// available as a single-filter option inside the LUT filter itself.
388+
{ name: "CineStill 800T", desc: "Tungsten-balanced CineStill with signature pink halation around highlights", filters: [f("LUT", { preset: "CINESTILL_800T" }), f("Halation"), f("Film grain")], category: "Photo" },
389+
{ name: "Kodak Portra", desc: "Portra 400 skin-tone friendly — muted saturation, warm lift, subtle grain", filters: [f("LUT", { preset: "PORTRA" }), f("Film grain")], category: "Photo" },
390+
{ name: "Kodak Tri-X B&W", desc: "Punchy Kodak Tri-X B&W film with grain and vignette", filters: [f("LUT", { preset: "TRIX_BW" }), f("Film grain"), f("Vignette")], category: "Photo" },
391+
{ name: "Fuji Velvia Landscape", desc: "Ultra-saturated Fuji Velvia slide-film landscape grade with sharpening", filters: [f("LUT", { preset: "VELVIA" }), f("Sharpen")], category: "Photo" },
392+
{ name: "Blade Runner 2049", desc: "Amber noir with bloom and halation — Deakins' Blade Runner 2049 palette", filters: [f("LUT", { preset: "AMBER_NOIR" }), f("Bloom"), f("Halation")], category: "Photo" },
384393
];
385394

386395
export const PRESET_CATEGORIES = [...new Set(CHAIN_PRESETS.map((preset) => preset.category))];

src/components/controls/styles.module.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@
7171
padding: 5px;
7272
background: var(--window-bg);
7373
color: var(--window-text);
74+
text-align: left;
75+
text-align-last: left;
7476
}
7577

7678
.enum optgroup {

src/filters/binarize.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { RANGE, PALETTE } from "constants/controlTypes";
22
import * as palettes from "palettes";
3-
import { cloneCanvas, fillBufferPixel, getBufferIndex, rgba, srgbBufToLinearFloat, linearFloatToSrgbBuf, srgbPaletteGetColor, linearPaletteGetColor } from "utils";
3+
import { cloneCanvas, fillBufferPixel, getBufferIndex, rgba, srgbBufToLinearFloat, linearFloatToSrgbBuf, srgbPaletteGetColor, linearPaletteGetColor, logFilterBackend } from "utils";
44
import { defineFilter, type FilterOptionValues } from "filters/types";
5+
import { applyPalettePassToCanvas, paletteIsIdentity } from "palettes/backend";
6+
import { binarizeGLAvailable, renderBinarizeGL } from "./binarizeGL";
57

68
export const optionTypes = {
79
thresholdR: { type: RANGE, range: [0, 255], step: 0.5, default: 127.5, desc: "Red channel threshold for black/white split" },
@@ -21,6 +23,7 @@ export const defaults = {
2123

2224
type BinarizeOptions = FilterOptionValues & typeof defaults & {
2325
_linearize?: boolean;
26+
_webglAcceleration?: boolean;
2427
};
2528

2629
const binarize = (
@@ -31,6 +34,24 @@ const binarize = (
3134
val > threshold ? 255 : 0;
3235

3336
const { thresholdR, thresholdG, thresholdB, thresholdA, palette } = options;
37+
const W = input.width, H = input.height;
38+
39+
// GL fast path — per-channel threshold on GPU, palette pass on CPU.
40+
// Identity palette is the default, so it's a no-op most of the time.
41+
if (options._webglAcceleration !== false && binarizeGLAvailable()) {
42+
const rendered = renderBinarizeGL(
43+
input, W, H, thresholdR, thresholdG, thresholdB, thresholdA, !!options._linearize,
44+
);
45+
if (rendered) {
46+
const identity = paletteIsIdentity(palette);
47+
const out = identity ? rendered : applyPalettePassToCanvas(rendered, W, H, palette);
48+
if (out) {
49+
logFilterBackend("Binarize", "WebGL2", `thresholds=${thresholdR}/${thresholdG}/${thresholdB}/${thresholdA}${identity ? "" : "+palettePass"}${options._linearize ? " linear" : ""}`);
50+
return out;
51+
}
52+
}
53+
}
54+
3455
const output = cloneCanvas(input, false);
3556

3657
const inputCtx = input.getContext("2d");
@@ -40,7 +61,7 @@ const binarize = (
4061
return input;
4162
}
4263

43-
const buf = inputCtx.getImageData(0, 0, input.width, input.height).data;
64+
const buf = inputCtx.getImageData(0, 0, W, H).data;
4465

4566
if (options._linearize) {
4667
const floatBuf = srgbBufToLinearFloat(buf);

src/filters/binarizeGL.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {
2+
drawPass, ensureTexture, getGLCtx, getQuadVAO, glAvailable,
3+
linkProgram, readoutToCanvas, resizeGLCanvas, uploadSourceTexture,
4+
type Program,
5+
} from "gl";
6+
7+
// Per-channel threshold → 0 or 1. Alpha is not sRGB-encoded, so when
8+
// u_linearize=1 only RGB get transformed into linear before comparison.
9+
// Matches the JS path: `val > threshold` in the chosen colour space.
10+
const BINARIZE_FS = `#version 300 es
11+
precision highp float;
12+
in vec2 v_uv;
13+
out vec4 fragColor;
14+
15+
uniform sampler2D u_source;
16+
uniform vec4 u_threshold; // R, G, B, A thresholds in 0..1
17+
uniform int u_linearize; // 1 = linearize RGB before threshold
18+
19+
float srgbToLinear(float c) {
20+
return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4);
21+
}
22+
23+
void main() {
24+
vec4 c = texture(u_source, v_uv);
25+
if (u_linearize == 1) {
26+
c.r = srgbToLinear(c.r);
27+
c.g = srgbToLinear(c.g);
28+
c.b = srgbToLinear(c.b);
29+
}
30+
fragColor = vec4(
31+
c.r > u_threshold.r ? 1.0 : 0.0,
32+
c.g > u_threshold.g ? 1.0 : 0.0,
33+
c.b > u_threshold.b ? 1.0 : 0.0,
34+
c.a > u_threshold.a ? 1.0 : 0.0
35+
);
36+
}
37+
`;
38+
39+
type Cache = { prog: Program };
40+
let _cache: Cache | null = null;
41+
const initCache = (gl: WebGL2RenderingContext): Cache => {
42+
if (_cache) return _cache;
43+
_cache = {
44+
prog: linkProgram(gl, BINARIZE_FS, ["u_source", "u_threshold", "u_linearize"] as const),
45+
};
46+
return _cache;
47+
};
48+
49+
export const binarizeGLAvailable = (): boolean => glAvailable();
50+
51+
export const renderBinarizeGL = (
52+
source: HTMLCanvasElement | OffscreenCanvas,
53+
width: number, height: number,
54+
thresholdR: number, thresholdG: number, thresholdB: number, thresholdA: number,
55+
linearize: boolean,
56+
): HTMLCanvasElement | OffscreenCanvas | null => {
57+
const ctx = getGLCtx();
58+
if (!ctx) return null;
59+
const { gl, canvas } = ctx;
60+
const cache = initCache(gl);
61+
const vao = getQuadVAO(gl);
62+
63+
resizeGLCanvas(canvas, width, height);
64+
const sourceTex = ensureTexture(gl, "binarize:source", width, height);
65+
uploadSourceTexture(gl, sourceTex, source);
66+
67+
drawPass(gl, null, width, height, cache.prog, () => {
68+
gl.activeTexture(gl.TEXTURE0);
69+
gl.bindTexture(gl.TEXTURE_2D, sourceTex.tex);
70+
gl.uniform1i(cache.prog.uniforms.u_source, 0);
71+
gl.uniform4f(
72+
cache.prog.uniforms.u_threshold,
73+
thresholdR / 255, thresholdG / 255, thresholdB / 255, thresholdA / 255,
74+
);
75+
gl.uniform1i(cache.prog.uniforms.u_linearize, linearize ? 1 : 0);
76+
}, vao);
77+
78+
return readoutToCanvas(canvas, width, height);
79+
};

src/filters/bokeh.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
wasmBokehBuffer,
1010
} from "utils";
1111
import { defineFilter } from "filters/types";
12-
import { applyPaletteToBuffer, paletteIsIdentity as isIdentityPalette } from "palettes/backend";
12+
import { applyPaletteToBuffer, applyPalettePassToCanvas, paletteIsIdentity as isIdentityPalette } from "palettes/backend";
1313
import { bokehGLAvailable, renderBokehGL } from "./bokehGL";
1414

1515
const SHAPE = { CIRCLE: "CIRCLE", HEXAGON: "HEXAGON", TRIANGLE: "TRIANGLE", PENTAGON: "PENTAGON", OCTAGON: "OCTAGON", STAR: "STAR" };
@@ -68,17 +68,18 @@ const bokeh = (input: any, options: BokehOptions = defaults) => {
6868
const paletteIdentity = isIdentityPalette(palette);
6969
const shapeId = SHAPE_TO_ID[shape] ?? 0;
7070

71-
// GL fast path
71+
// GL fast path — bokeh render on GPU, palette pass on CPU after readout.
7272
if (
73-
paletteIdentity
74-
&& wasmOk
75-
&& options._webglAcceleration !== false
73+
options._webglAcceleration !== false
7674
&& bokehGLAvailable()
7775
) {
7876
const rendered = renderBokehGL(input, W, H, radius, threshold, intensity, shapeId, localDetect, softness, edgeFringe, rotation, catsEye, edgeRing, bubble);
7977
if (rendered) {
80-
logFilterBackend("Bokeh", "WebGL2", `gpu radius=${radius} shape=${shape}`);
81-
return rendered;
78+
const out = paletteIdentity ? rendered : applyPalettePassToCanvas(rendered, W, H, palette, wasmOk);
79+
if (out) {
80+
logFilterBackend("Bokeh", "WebGL2", `gpu radius=${radius} shape=${shape}${paletteIdentity ? "" : "+palettePass"}`);
81+
return out;
82+
}
8283
}
8384
}
8485

src/filters/chromaticPosterize.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import {
66
fillBufferPixel,
77
getBufferIndex,
88
rgba,
9-
paletteGetColor
9+
paletteGetColor,
10+
logFilterBackend,
1011
} from "utils";
12+
import { applyPalettePassToCanvas, paletteIsIdentity } from "palettes/backend";
13+
import { chromaticPosterizeGLAvailable, renderChromaticPosterizeGL } from "./chromaticPosterizeGL";
1114

1215
export const optionTypes = {
1316
levelsR: { type: RANGE, range: [2, 32], step: 1, default: 4, desc: "Quantization levels for red channel" },
@@ -23,16 +26,29 @@ export const defaults = {
2326
palette: { ...optionTypes.palette.default, options: { levels: 256 } }
2427
};
2528

26-
const chromaticPosterize = (input: any, options = defaults) => {
29+
type ChromaticPosterizeOptions = typeof defaults & { _webglAcceleration?: boolean };
30+
31+
const chromaticPosterize = (input: any, options: ChromaticPosterizeOptions = defaults) => {
2732
const { levelsR, levelsG, levelsB, palette } = options;
33+
const W = input.width, H = input.height;
34+
35+
if (options._webglAcceleration !== false && chromaticPosterizeGLAvailable()) {
36+
const rendered = renderChromaticPosterizeGL(input, W, H, levelsR, levelsG, levelsB);
37+
if (rendered) {
38+
const identity = paletteIsIdentity(palette);
39+
const out = identity ? rendered : applyPalettePassToCanvas(rendered, W, H, palette);
40+
if (out) {
41+
logFilterBackend("Chromatic Posterize", "WebGL2", `R=${levelsR} G=${levelsG} B=${levelsB}${identity ? "" : "+palettePass"}`);
42+
return out;
43+
}
44+
}
45+
}
2846

2947
const output = cloneCanvas(input, false);
3048
const inputCtx = input.getContext("2d");
3149
const outputCtx = output.getContext("2d");
3250
if (!inputCtx || !outputCtx) return input;
3351

34-
const W = input.width;
35-
const H = input.height;
3652
const buf = inputCtx.getImageData(0, 0, W, H).data;
3753
const outBuf = new Uint8ClampedArray(buf.length);
3854

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
drawPass, ensureTexture, getGLCtx, getQuadVAO, glAvailable,
3+
linkProgram, readoutToCanvas, resizeGLCanvas, uploadSourceTexture,
4+
type Program,
5+
} from "gl";
6+
7+
// Matches the JS LUT: for each channel, step = 255/(levels-1), output is
8+
// round(round(byte/step)*step)/255. We compute in float but keep the same
9+
// rounding semantics.
10+
const FS = `#version 300 es
11+
precision highp float;
12+
in vec2 v_uv;
13+
out vec4 fragColor;
14+
uniform sampler2D u_source;
15+
uniform vec3 u_levels; // R, G, B quantization levels
16+
17+
float quantize(float v, float levels) {
18+
float step255 = 255.0 / (levels - 1.0);
19+
float byte = v * 255.0;
20+
float q = floor(byte / step255 + 0.5) * step255;
21+
return floor(q + 0.5) / 255.0;
22+
}
23+
24+
void main() {
25+
vec4 c = texture(u_source, v_uv);
26+
fragColor = vec4(
27+
quantize(c.r, u_levels.x),
28+
quantize(c.g, u_levels.y),
29+
quantize(c.b, u_levels.z),
30+
c.a
31+
);
32+
}
33+
`;
34+
35+
type Cache = { prog: Program };
36+
let _cache: Cache | null = null;
37+
const initCache = (gl: WebGL2RenderingContext): Cache => {
38+
if (_cache) return _cache;
39+
_cache = { prog: linkProgram(gl, FS, ["u_source", "u_levels"] as const) };
40+
return _cache;
41+
};
42+
43+
export const chromaticPosterizeGLAvailable = (): boolean => glAvailable();
44+
45+
export const renderChromaticPosterizeGL = (
46+
source: HTMLCanvasElement | OffscreenCanvas,
47+
width: number, height: number,
48+
levelsR: number, levelsG: number, levelsB: number,
49+
): HTMLCanvasElement | OffscreenCanvas | null => {
50+
const ctx = getGLCtx();
51+
if (!ctx) return null;
52+
const { gl, canvas } = ctx;
53+
const cache = initCache(gl);
54+
const vao = getQuadVAO(gl);
55+
resizeGLCanvas(canvas, width, height);
56+
const sourceTex = ensureTexture(gl, "chromaticPosterize:source", width, height);
57+
uploadSourceTexture(gl, sourceTex, source);
58+
drawPass(gl, null, width, height, cache.prog, () => {
59+
gl.activeTexture(gl.TEXTURE0);
60+
gl.bindTexture(gl.TEXTURE_2D, sourceTex.tex);
61+
gl.uniform1i(cache.prog.uniforms.u_source, 0);
62+
gl.uniform3f(cache.prog.uniforms.u_levels, levelsR, levelsG, levelsB);
63+
}, vao);
64+
return readoutToCanvas(canvas, width, height);
65+
};

src/filters/colorPop.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { RANGE } from "constants/controlTypes";
2-
import { cloneCanvas, getBufferIndex } from "utils";
2+
import { cloneCanvas, getBufferIndex, logFilterBackend } from "utils";
33
import { defineFilter } from "filters/types";
4+
import { colorPopGLAvailable, renderColorPopGL } from "./colorPopGL";
45

56
const smoothstep = (edge0: number, edge1: number, x: number) => {
67
if (edge0 === edge1) return x < edge0 ? 0 : 1;
@@ -22,15 +23,25 @@ export const defaults = {
2223
softness: optionTypes.softness.default
2324
};
2425

25-
const colorPop = (input: any, options = defaults) => {
26+
type ColorPopOptions = typeof defaults & { _webglAcceleration?: boolean };
27+
28+
const colorPop = (input: any, options: ColorPopOptions = defaults) => {
2629
const { targetHue, hueWidth, desaturateOthers, softness } = options;
30+
const W = input.width, H = input.height;
31+
32+
if (options._webglAcceleration !== false && colorPopGLAvailable()) {
33+
const rendered = renderColorPopGL(input, W, H, targetHue, hueWidth, desaturateOthers, softness);
34+
if (rendered) {
35+
logFilterBackend("Color Pop", "WebGL2", `hue=${targetHue} w=${hueWidth}`);
36+
return rendered;
37+
}
38+
}
39+
2740
const output = cloneCanvas(input, false);
2841
const inputCtx = input.getContext("2d");
2942
const outputCtx = output.getContext("2d");
3043
if (!inputCtx || !outputCtx) return input;
3144

32-
const W = input.width;
33-
const H = input.height;
3445
const buf = inputCtx.getImageData(0, 0, W, H).data;
3546
const outBuf = new Uint8ClampedArray(buf.length);
3647
const softEdge = hueWidth + softness * 90;

0 commit comments

Comments
 (0)