@radialapps/memories-vips-editor 0.1.1 → 0.1.2
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/package.json +2 -1
- package/src/index.ts +11 -0
- package/src/math.ts +41 -0
- package/src/pipeline.ts +490 -0
- package/src/shaders.ts +59 -0
- package/src/transforms/blur.ts +51 -0
- package/src/transforms/brightnessContrast.ts +78 -0
- package/src/transforms/crop.ts +124 -0
- package/src/transforms/exposure.ts +59 -0
- package/src/transforms/grayscale.ts +42 -0
- package/src/transforms/hueSaturation.ts +86 -0
- package/src/transforms/index.ts +49 -0
- package/src/transforms/invert.ts +41 -0
- package/src/transforms/rotate.ts +211 -0
- package/src/transforms/sepia.ts +63 -0
- package/src/transforms/sharpen.ts +86 -0
- package/src/transforms/state.ts +29 -0
- package/src/transforms/transform.ts +101 -0
- package/src/transforms/vibrance.ts +61 -0
- package/src/transforms/vignette.ts +78 -0
- package/src/types.ts +58 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@radialapps/memories-vips-editor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Headless WebGL preview library for vips-static edits. Renders an ordered op list on a caller-provided canvas; serializes the same list as JSON for the vips-preview CLI (convert/thumbnail ... \"ops\": [...]).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
+
"src",
|
|
17
18
|
"README.md"
|
|
18
19
|
],
|
|
19
20
|
"sideEffects": false,
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// index.ts — minimal public surface: the pipeline, the registry, state
|
|
2
|
+
// helpers, and the types needed to consume them (including writing a
|
|
3
|
+
// custom Transform). Internal helpers (lookup, isNumInRange,
|
|
4
|
+
// setFloatUniform, math utils, per-transform types) live in their own
|
|
5
|
+
// modules — import those directly.
|
|
6
|
+
export { EditPipeline } from './pipeline.js';
|
|
7
|
+
export { TRANSFORMS, validateEdit, normalizeEdit } from './transforms/index.js';
|
|
8
|
+
export { validateState, stateToJSON, stateFromJSON } from './transforms/state.js';
|
|
9
|
+
export type { Transform, ParamControl, RenderContext, Target } from './transforms/transform.js';
|
|
10
|
+
export { emptyState, EDIT_STATE_VERSION } from './types.js';
|
|
11
|
+
export type { Edit, EditName, EditState } from './types.js';
|
package/src/math.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// math.ts — CPU reference for every edit's pixel math.
|
|
2
|
+
//
|
|
3
|
+
// These functions are the single source of truth shared (by port, not by
|
|
4
|
+
// import) with the GLSL in shaders.ts and the libvips code in src/edit.h:
|
|
5
|
+
// preview (WebGL) == math.ts == VIPS applier
|
|
6
|
+
// all operate on sRGB-encoded 0..1 floats and apply in array order.
|
|
7
|
+
//
|
|
8
|
+
// glfx.js inspiration: brightnessContrast / hueSaturation / sepia / vibrance /
|
|
9
|
+
// vignette shaders are direct ports of evanw/glfx.js adjust filters (see
|
|
10
|
+
// shaders.ts); the matrix derivations below match those shaders term for term
|
|
11
|
+
// so a VIPS `recomb`/`linear` does exactly what the canvas showed.
|
|
12
|
+
|
|
13
|
+
export function clampf(lo: number, v: number, hi: number): number {
|
|
14
|
+
return Math.min(hi, Math.max(lo, v));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Exposure gain for a stop shift. */
|
|
18
|
+
export function exposureMult(stops: number): number {
|
|
19
|
+
return Math.pow(2, clampf(-5, stops, 5));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 1D gaussian kernel for a blur radius (sigma = radius/2). Preview + docs.
|
|
23
|
+
*
|
|
24
|
+
* Single source of truth, ported term-for-term to src/transforms/gaussian.h:
|
|
25
|
+
* both sides convolve these exact weights (separable, clamp edges), so
|
|
26
|
+
* preview and VIPS agree to ±1 LSB at every radius.
|
|
27
|
+
*/
|
|
28
|
+
export function gaussianKernel(radius: number): number[] {
|
|
29
|
+
const r = clampf(0, radius, 100);
|
|
30
|
+
if (r <= 0) return [1];
|
|
31
|
+
const sigma = r / 2;
|
|
32
|
+
const half = Math.min(15, Math.max(1, Math.ceil(sigma * 2)));
|
|
33
|
+
const w: number[] = [];
|
|
34
|
+
let sum = 0;
|
|
35
|
+
for (let i = -half; i <= half; i++) {
|
|
36
|
+
const v = Math.exp(-0.5 * (i / sigma) * (i / sigma));
|
|
37
|
+
w.push(v);
|
|
38
|
+
sum += v;
|
|
39
|
+
}
|
|
40
|
+
return w.map((v) => v / sum);
|
|
41
|
+
}
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// pipeline.ts — headless WebGL preview for an ordered edit list.
|
|
2
|
+
//
|
|
3
|
+
// Headless means: the caller owns the <canvas> — layout and DOM — while the
|
|
4
|
+
// pipeline owns its backing store size: every render resizes
|
|
5
|
+
// `canvas.width/height` to the rendered output area (source size when no
|
|
6
|
+
// geometric edit applies) and presents the result fullscreen 1:1, so the
|
|
7
|
+
// image always fills the canvas exactly — never letterboxed, never cropped.
|
|
8
|
+
// Each edit runs through its Transform (transforms/) — single shader passes
|
|
9
|
+
// or multi-pass sequences (blur/sharpen) ping-ponging between two RGBA
|
|
10
|
+
// textures, the same structure as glfx.js Canvas.
|
|
11
|
+
//
|
|
12
|
+
// Serialization (toJSON) emits the canonical {version, edits} state; the same
|
|
13
|
+
// array (normalized) is what vips-preview accepts as "edits".
|
|
14
|
+
import { BLIT_FRAG, COPY_FRAG, GAUSS_FRAG, VERT } from './shaders.js';
|
|
15
|
+
import { TRANSFORMS, normalizeEdit } from './transforms/index.js';
|
|
16
|
+
import type { RenderContext, Target, UniformSetter, ExtraTex } from './transforms/transform.js';
|
|
17
|
+
import { emptyState, type Edit, type EditState } from './types.js';
|
|
18
|
+
|
|
19
|
+
export class EditPipeline {
|
|
20
|
+
private gl: WebGLRenderingContext;
|
|
21
|
+
private canvas: HTMLCanvasElement;
|
|
22
|
+
private programs = new Map<string, WebGLProgram>();
|
|
23
|
+
// Fullscreen quad (-1..1 triangle strip) shared by every draw call.
|
|
24
|
+
private quad: WebGLBuffer | null = null;
|
|
25
|
+
// src holds the uploaded image; ping/pong are the working pair that
|
|
26
|
+
// edits bounce between; extra is scratch (sharpen keeps its unblurred
|
|
27
|
+
// original there while blurring the working copy in place).
|
|
28
|
+
private src: Target | null = null;
|
|
29
|
+
private ping: Target | null = null;
|
|
30
|
+
private pong: Target | null = null;
|
|
31
|
+
private extra: Target | null = null;
|
|
32
|
+
private imgW = 0;
|
|
33
|
+
private imgH = 0;
|
|
34
|
+
private outW = 0;
|
|
35
|
+
private outH = 0;
|
|
36
|
+
/** Working/native resolution ratio backing RenderContext.resolutionScale. */
|
|
37
|
+
private resScale = 1;
|
|
38
|
+
private state: EditState = emptyState();
|
|
39
|
+
private floatOK = false;
|
|
40
|
+
private highp = false;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Take over the canvas' WebGL context. preserveDrawingBuffer stays true
|
|
44
|
+
* so the demo can read pixels back; premultipliedAlpha stays false
|
|
45
|
+
* so alpha survives the passes unmodified. The canvas backing store is
|
|
46
|
+
* resized to the rendered output on every render — the caller owns layout
|
|
47
|
+
* and CSS only. Throws when WebGL is missing.
|
|
48
|
+
*/
|
|
49
|
+
public constructor(canvas: HTMLCanvasElement) {
|
|
50
|
+
this.canvas = canvas;
|
|
51
|
+
const gl = canvas.getContext('webgl', {
|
|
52
|
+
preserveDrawingBuffer: true,
|
|
53
|
+
premultipliedAlpha: false,
|
|
54
|
+
antialias: false,
|
|
55
|
+
depth: false,
|
|
56
|
+
stencil: false,
|
|
57
|
+
});
|
|
58
|
+
if (!gl) throw new Error('WebGL is not available on this canvas');
|
|
59
|
+
this.gl = gl;
|
|
60
|
+
this.floatOK = this.probeFloat();
|
|
61
|
+
// Rotate's manual-bilinear shader wants highp for pixel-scale math;
|
|
62
|
+
// fall back to mediump (same body) where the GPU lacks it.
|
|
63
|
+
try {
|
|
64
|
+
const fmt = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT);
|
|
65
|
+
this.highp = !!fmt && fmt.precision > 0;
|
|
66
|
+
} catch {
|
|
67
|
+
this.highp = false;
|
|
68
|
+
}
|
|
69
|
+
const buf = gl.createBuffer();
|
|
70
|
+
if (!buf) throw new Error('WebGL: createBuffer failed');
|
|
71
|
+
this.quad = buf;
|
|
72
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
73
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Output image size (source size when no geometric edit applies). */
|
|
77
|
+
public get width(): number {
|
|
78
|
+
return this.outW;
|
|
79
|
+
}
|
|
80
|
+
public get height(): number {
|
|
81
|
+
return this.outH;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Ordered edit list (live reference — mutate via setEdits/addEdit/...). */
|
|
85
|
+
public getEdits(): Edit[] {
|
|
86
|
+
return this.state.edits;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Replace the whole edit list (normalizing each edit) and re-render. */
|
|
90
|
+
public setEdits(edits: Edit[]): void {
|
|
91
|
+
this.state = { version: 1, edits: edits.map(normalizeEdit) };
|
|
92
|
+
this.render();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Append one edit (normalized) and re-render. */
|
|
96
|
+
public addEdit(edit: Edit): void {
|
|
97
|
+
this.state.edits.push(normalizeEdit(edit));
|
|
98
|
+
this.render();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Drop the edit at `index` and re-render. */
|
|
102
|
+
public removeEdit(index: number): void {
|
|
103
|
+
this.state.edits.splice(index, 1);
|
|
104
|
+
this.render();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Reorder one edit and re-render. */
|
|
108
|
+
public moveEdit(from: number, to: number): void {
|
|
109
|
+
const [edit] = this.state.edits.splice(from, 1);
|
|
110
|
+
if (edit !== undefined) this.state.edits.splice(to, 0, edit);
|
|
111
|
+
this.render();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Drop all edits (back to the bare source image) and re-render. */
|
|
115
|
+
public clearEdits(): void {
|
|
116
|
+
this.state.edits = [];
|
|
117
|
+
this.render();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Canonical JSON state for the VIPS side. */
|
|
121
|
+
public toJSON(): string {
|
|
122
|
+
return JSON.stringify({ version: 1 as const, edits: this.state.edits.map(normalizeEdit) });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Longest side of the working image. Camera JPEGs (12-50MP) would need
|
|
127
|
+
* hundreds of MB as float ping-pong targets and exceed
|
|
128
|
+
* MAX_TEXTURE_SIZE on weak GPUs — both failure modes render black with
|
|
129
|
+
* no exception. The preview is resolution-capped (reported via
|
|
130
|
+
* width/height); the VIPS side always renders full-res from the JSON.
|
|
131
|
+
*/
|
|
132
|
+
public static readonly MAX_DIM = 2048;
|
|
133
|
+
|
|
134
|
+
/** Resolve the pixel size of any TexImageSource upload. */
|
|
135
|
+
private sourceDims(source: TexImageSource): { w: number; h: number } {
|
|
136
|
+
const anySrc = source as unknown as Record<string, unknown>;
|
|
137
|
+
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
138
|
+
// HTMLImageElement (natural size, unaffected by CSS/attributes).
|
|
139
|
+
const nw = num(anySrc['naturalWidth']);
|
|
140
|
+
const nh = num(anySrc['naturalHeight']);
|
|
141
|
+
if (nw > 0 && nh > 0) return { w: nw, h: nh };
|
|
142
|
+
// HTMLVideoElement.
|
|
143
|
+
const vw = num(anySrc['videoWidth']);
|
|
144
|
+
const vh = num(anySrc['videoHeight']);
|
|
145
|
+
if (vw > 0 && vh > 0) return { w: vw, h: vh };
|
|
146
|
+
// HTMLCanvasElement / ImageBitmap / OffscreenCanvas.
|
|
147
|
+
return { w: num(anySrc['width']), h: num(anySrc['height']) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Turn a sticky GL error flag into a thrown message naming the stage. */
|
|
151
|
+
private checkGLError(stage: string): void {
|
|
152
|
+
const gl = this.gl;
|
|
153
|
+
const err = gl.getError();
|
|
154
|
+
if (err !== gl.NO_ERROR) {
|
|
155
|
+
throw new Error(`WebGL error ${err} during ${stage} (image may be too large for this GPU)`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Upload a new source image, (re)allocating all targets to it.
|
|
161
|
+
* Oversized sources are downscaled through a 2D canvas first (see
|
|
162
|
+
* MAX_DIM); old GL targets are deleted. Ends with a full render(),
|
|
163
|
+
* which resizes the canvas backing store to the output area.
|
|
164
|
+
* Throws with a message instead of rendering black on failure.
|
|
165
|
+
*/
|
|
166
|
+
public setImage(source: TexImageSource): void {
|
|
167
|
+
const gl = this.gl;
|
|
168
|
+
const dims = this.sourceDims(source);
|
|
169
|
+
if (!dims.w || !dims.h) throw new Error('setImage: source has no width/height');
|
|
170
|
+
// Downscale oversized sources through a 2D canvas before upload.
|
|
171
|
+
let upload: TexImageSource = source;
|
|
172
|
+
let w = dims.w;
|
|
173
|
+
let h = dims.h;
|
|
174
|
+
const longest = Math.max(w, h);
|
|
175
|
+
if (longest > EditPipeline.MAX_DIM) {
|
|
176
|
+
const scale = EditPipeline.MAX_DIM / longest;
|
|
177
|
+
w = Math.max(1, Math.round(w * scale));
|
|
178
|
+
h = Math.max(1, Math.round(h * scale));
|
|
179
|
+
const scratch = document.createElement('canvas');
|
|
180
|
+
scratch.width = w;
|
|
181
|
+
scratch.height = h;
|
|
182
|
+
const g = scratch.getContext('2d');
|
|
183
|
+
if (!g) throw new Error('setImage: 2D context unavailable for downscale');
|
|
184
|
+
g.imageSmoothingQuality = 'high';
|
|
185
|
+
g.drawImage(source as CanvasImageSource, 0, 0, w, h);
|
|
186
|
+
upload = scratch;
|
|
187
|
+
}
|
|
188
|
+
this.imgW = w;
|
|
189
|
+
this.imgH = h;
|
|
190
|
+
this.outW = w;
|
|
191
|
+
this.outH = h;
|
|
192
|
+
this.resScale = dims.w > 0 ? w / dims.w : 1;
|
|
193
|
+
for (const t of [this.src, this.ping, this.pong, this.extra]) {
|
|
194
|
+
if (t) {
|
|
195
|
+
gl.deleteTexture(t.tex);
|
|
196
|
+
gl.deleteFramebuffer(t.fb);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const type = this.floatOK ? gl.FLOAT : gl.UNSIGNED_BYTE;
|
|
200
|
+
this.src = this.makeTarget(w, h, gl.UNSIGNED_BYTE);
|
|
201
|
+
this.ping = this.makeTarget(w, h, type);
|
|
202
|
+
this.pong = this.makeTarget(w, h, type);
|
|
203
|
+
this.extra = this.makeTarget(w, h, type);
|
|
204
|
+
this.checkGLError('target allocation');
|
|
205
|
+
gl.bindTexture(gl.TEXTURE_2D, this.src.tex);
|
|
206
|
+
// NOTE: no UNPACK_FLIP_Y here — like glfx.js, the source is uploaded
|
|
207
|
+
// unflipped and the final blit flips Y once (double flip = upside down).
|
|
208
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0);
|
|
209
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, upload);
|
|
210
|
+
this.checkGLError('texture upload');
|
|
211
|
+
// Framebuffer completeness is the other silent-black cause.
|
|
212
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, this.ping.fb);
|
|
213
|
+
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
|
|
214
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
215
|
+
throw new Error('WebGL: ping framebuffer incomplete at this size');
|
|
216
|
+
}
|
|
217
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
218
|
+
this.render();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Re-run all passes: copy source -> ping, then apply each edit in order,
|
|
223
|
+
* ping-ponging between the two working targets (`cur` holds the latest
|
|
224
|
+
* result, `nxt` is the scratch target; multi-pass edits may bounce
|
|
225
|
+
* between them internally and return either side). Geometric edits
|
|
226
|
+
* (crop/rotate) change dimensions: `nxt` is resized to the edit's
|
|
227
|
+
* outputDims before apply, `extra` is kept at the input size (sharpen
|
|
228
|
+
* scratch). Presents by resizing the canvas to the rendered area and
|
|
229
|
+
* blitting fullscreen 1:1. No-op when no image is loaded yet.
|
|
230
|
+
*/
|
|
231
|
+
public render(): void {
|
|
232
|
+
if (!this.src || !this.ping || !this.pong || !this.extra || this.imgW === 0) return;
|
|
233
|
+
this.ensureSize('ping', this.imgW, this.imgH);
|
|
234
|
+
// Start from the source image.
|
|
235
|
+
this.blit(this.src, this.ping);
|
|
236
|
+
let cur: Target = this.ping;
|
|
237
|
+
let nxt: Target = this.pong;
|
|
238
|
+
let curW = this.imgW;
|
|
239
|
+
let curH = this.imgH;
|
|
240
|
+
const ctx = this.renderContext();
|
|
241
|
+
for (const edit of this.state.edits) {
|
|
242
|
+
const t = TRANSFORMS[edit.edit];
|
|
243
|
+
if (!t) continue; // validated on set; skip defensively
|
|
244
|
+
const params = normalizeEdit(edit) as never;
|
|
245
|
+
const od = t.outputDims?.(curW, curH, params) ?? { w: curW, h: curH };
|
|
246
|
+
nxt = this.ensureSize(nxt === this.ping ? 'ping' : 'pong', od.w, od.h);
|
|
247
|
+
ctx.extra = this.ensureSize('extra', curW, curH);
|
|
248
|
+
ctx.width = curW;
|
|
249
|
+
ctx.height = curH;
|
|
250
|
+
ctx.outWidth = od.w;
|
|
251
|
+
ctx.outHeight = od.h;
|
|
252
|
+
cur = t.apply(ctx, cur, nxt, params);
|
|
253
|
+
if (cur === this.ping || cur === this.pong) {
|
|
254
|
+
if (cur === nxt) {
|
|
255
|
+
curW = od.w;
|
|
256
|
+
curH = od.h;
|
|
257
|
+
}
|
|
258
|
+
nxt = cur === this.ping ? this.pong : this.ping;
|
|
259
|
+
}
|
|
260
|
+
// Multi-pass edits return `from` (same size) or `to`; anything else
|
|
261
|
+
// (never today) keeps the current dims.
|
|
262
|
+
}
|
|
263
|
+
this.outW = curW;
|
|
264
|
+
this.outH = curH;
|
|
265
|
+
this.toCanvas(cur);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* RenderContext facade for Transform.apply(): exposes the pass/blit/gauss
|
|
270
|
+
* drawing primitives plus the scratch `extra` target, without handing
|
|
271
|
+
* out the raw WebGL context.
|
|
272
|
+
*/
|
|
273
|
+
private renderContext(): RenderContext {
|
|
274
|
+
const extra = this.extra;
|
|
275
|
+
if (!extra) throw new Error('render: no targets');
|
|
276
|
+
return {
|
|
277
|
+
width: this.imgW,
|
|
278
|
+
height: this.imgH,
|
|
279
|
+
outWidth: this.imgW,
|
|
280
|
+
outHeight: this.imgH,
|
|
281
|
+
highp: this.highp,
|
|
282
|
+
resolutionScale: this.resScale,
|
|
283
|
+
extra,
|
|
284
|
+
pass: (frag, from, to, setUniforms, extraTex) =>
|
|
285
|
+
this.pass(frag, from, to, setUniforms, extraTex),
|
|
286
|
+
blit: (from, to) => this.blit(from, to),
|
|
287
|
+
gauss: (from, to, horizontal, kernel) => this.gauss(from, to, horizontal, kernel),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --- internals ---
|
|
292
|
+
|
|
293
|
+
/** Resize a working target when geometry changed (returns live target). */
|
|
294
|
+
private ensureSize(which: 'ping' | 'pong' | 'extra', w: number, h: number): Target {
|
|
295
|
+
const t = which === 'ping' ? this.ping : which === 'pong' ? this.pong : this.extra;
|
|
296
|
+
if (!t) throw new Error('render: no targets');
|
|
297
|
+
if (t.w === w && t.h === h) return t;
|
|
298
|
+
const gl = this.gl;
|
|
299
|
+
gl.deleteTexture(t.tex);
|
|
300
|
+
gl.deleteFramebuffer(t.fb);
|
|
301
|
+
const fresh = this.makeTarget(w, h, this.floatOK ? gl.FLOAT : gl.UNSIGNED_BYTE);
|
|
302
|
+
if (which === 'ping') this.ping = fresh;
|
|
303
|
+
else if (which === 'pong') this.pong = fresh;
|
|
304
|
+
else this.extra = fresh;
|
|
305
|
+
this.checkGLError('target resize');
|
|
306
|
+
return fresh;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Decide float vs 8-bit ping-pong targets. Float needs both the
|
|
311
|
+
* renderable-float extension and float-linear filtering (every pass
|
|
312
|
+
* samples with LINEAR); either missing falls back to UNSIGNED_BYTE.
|
|
313
|
+
* Also verifies float renderability with a throwaway 4x4 target.
|
|
314
|
+
*/
|
|
315
|
+
private probeFloat(): boolean {
|
|
316
|
+
const gl = this.gl;
|
|
317
|
+
// glfx.js requires both: renderable float targets AND float-linear
|
|
318
|
+
// filtering (all passes sample with LINEAR). Without the latter the
|
|
319
|
+
// float textures are incomplete and every pass outputs black.
|
|
320
|
+
if (!gl.getExtension('OES_texture_float')) return false;
|
|
321
|
+
if (!gl.getExtension('OES_texture_float_linear')) return false;
|
|
322
|
+
const tex = gl.createTexture();
|
|
323
|
+
const fb = gl.createFramebuffer();
|
|
324
|
+
if (!tex || !fb) return false;
|
|
325
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
326
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 4, 4, 0, gl.RGBA, gl.FLOAT, null);
|
|
327
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
|
|
328
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
|
|
329
|
+
const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
|
|
330
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
331
|
+
gl.deleteTexture(tex);
|
|
332
|
+
gl.deleteFramebuffer(fb);
|
|
333
|
+
return ok;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Allocate one offscreen RGBA render target (texture + framebuffer) with
|
|
338
|
+
* linear filtering and clamp-to-edge (NPOT-safe: no mipmaps, no repeat).
|
|
339
|
+
*/
|
|
340
|
+
private makeTarget(w: number, h: number, type: number): Target {
|
|
341
|
+
const gl = this.gl;
|
|
342
|
+
const tex = gl.createTexture();
|
|
343
|
+
const fb = gl.createFramebuffer();
|
|
344
|
+
if (!tex || !fb) throw new Error('WebGL: target allocation failed');
|
|
345
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
346
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
347
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
348
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
349
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
350
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, type, null);
|
|
351
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
|
|
352
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
|
|
353
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
354
|
+
return { tex, fb, w, h };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Compile one shader stage, throwing the info log on failure (a bad
|
|
359
|
+
* shader would otherwise render black with no indication why).
|
|
360
|
+
*/
|
|
361
|
+
private compile(type: number, src: string): WebGLShader {
|
|
362
|
+
const gl = this.gl;
|
|
363
|
+
const sh = gl.createShader(type);
|
|
364
|
+
if (!sh) throw new Error('WebGL: createShader failed');
|
|
365
|
+
gl.shaderSource(sh, src);
|
|
366
|
+
gl.compileShader(sh);
|
|
367
|
+
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
|
368
|
+
const log = gl.getShaderInfoLog(sh) ?? 'unknown';
|
|
369
|
+
gl.deleteShader(sh);
|
|
370
|
+
throw new Error(`WebGL shader compile failed: ${log}`);
|
|
371
|
+
}
|
|
372
|
+
return sh;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Compile+link the quad vertex shader with `frag`, cached by source. */
|
|
376
|
+
private program(frag: string): WebGLProgram {
|
|
377
|
+
let p = this.programs.get(frag);
|
|
378
|
+
if (p) return p;
|
|
379
|
+
const gl = this.gl;
|
|
380
|
+
const vs = this.compile(gl.VERTEX_SHADER, VERT);
|
|
381
|
+
const fs = this.compile(gl.FRAGMENT_SHADER, frag);
|
|
382
|
+
p = gl.createProgram();
|
|
383
|
+
if (!p) throw new Error('WebGL: createProgram failed');
|
|
384
|
+
gl.attachShader(p, vs);
|
|
385
|
+
gl.attachShader(p, fs);
|
|
386
|
+
gl.linkProgram(p);
|
|
387
|
+
gl.deleteShader(vs);
|
|
388
|
+
gl.deleteShader(fs);
|
|
389
|
+
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
|
|
390
|
+
throw new Error(`WebGL link failed: ${gl.getProgramInfoLog(p) ?? 'unknown'}`);
|
|
391
|
+
}
|
|
392
|
+
this.programs.set(frag, p);
|
|
393
|
+
return p;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Draw one fullscreen pass: render `from` through `frag` into `to`.
|
|
398
|
+
* `from` is bound to unit 0 (`texture` uniform) unless the shader uses
|
|
399
|
+
* other sampler names; `extraTex` optionally binds a second texture
|
|
400
|
+
* (used by the sharpen mix pass). `setUniforms` fills the rest.
|
|
401
|
+
*/
|
|
402
|
+
public pass(
|
|
403
|
+
frag: string,
|
|
404
|
+
from: Target,
|
|
405
|
+
to: Target,
|
|
406
|
+
setUniforms?: UniformSetter,
|
|
407
|
+
extraTex?: ExtraTex,
|
|
408
|
+
): void {
|
|
409
|
+
const gl = this.gl;
|
|
410
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, to.fb);
|
|
411
|
+
gl.viewport(0, 0, to.w, to.h);
|
|
412
|
+
const prog = this.program(frag);
|
|
413
|
+
gl.useProgram(prog);
|
|
414
|
+
const loc = gl.getAttribLocation(prog, 'aPos');
|
|
415
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
|
|
416
|
+
gl.enableVertexAttribArray(loc);
|
|
417
|
+
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
|
418
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
419
|
+
gl.bindTexture(gl.TEXTURE_2D, from.tex);
|
|
420
|
+
const uTex = gl.getUniformLocation(prog, 'texture');
|
|
421
|
+
if (uTex) gl.uniform1i(uTex, 0);
|
|
422
|
+
if (extraTex) {
|
|
423
|
+
gl.activeTexture(gl.TEXTURE0 + extraTex.unit);
|
|
424
|
+
gl.bindTexture(gl.TEXTURE_2D, extraTex.tex);
|
|
425
|
+
const u = gl.getUniformLocation(prog, extraTex.name);
|
|
426
|
+
if (u) gl.uniform1i(u, extraTex.unit);
|
|
427
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
428
|
+
}
|
|
429
|
+
setUniforms?.(gl, prog);
|
|
430
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
431
|
+
gl.disableVertexAttribArray(loc);
|
|
432
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Copy one target to another with the identity shader. */
|
|
436
|
+
public blit(from: Target, to: Target): void {
|
|
437
|
+
this.pass(COPY_FRAG, from, to);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* One axis of the separable gaussian: `horizontal` picks the sample
|
|
442
|
+
* direction (offsets are in UV units, hence the 1/width division).
|
|
443
|
+
* The shader loops over a fixed 31-tap array; `kernel.length` tells it
|
|
444
|
+
* how many center taps carry real weight, the rest are zero-padded.
|
|
445
|
+
*/
|
|
446
|
+
public gauss(from: Target, to: Target, horizontal: boolean, kernel: number[]): void {
|
|
447
|
+
const padded = kernel.slice();
|
|
448
|
+
while (padded.length < 31) padded.push(0);
|
|
449
|
+
this.pass(GAUSS_FRAG, from, to, (gl, p) => {
|
|
450
|
+
const uDir = gl.getUniformLocation(p, 'dir');
|
|
451
|
+
if (uDir) {
|
|
452
|
+
gl.uniform2f(uDir, horizontal ? 1 / from.w : 0, horizontal ? 0 : 1 / from.h);
|
|
453
|
+
}
|
|
454
|
+
const uK = gl.getUniformLocation(p, 'kernel');
|
|
455
|
+
if (uK) gl.uniform1fv(uK, padded);
|
|
456
|
+
const uC = gl.getUniformLocation(p, 'count');
|
|
457
|
+
if (uC) gl.uniform1i(uC, kernel.length);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Present the final result: resize the canvas backing store to the
|
|
463
|
+
* rendered output area (a no-op assignment when it already matches —
|
|
464
|
+
* setting width/height clears, so this only fires when geometry changed)
|
|
465
|
+
* and blit fullscreen 1:1. (The BLIT_FRAG flip still compensates the
|
|
466
|
+
* unflipped upload.)
|
|
467
|
+
*/
|
|
468
|
+
private toCanvas(from: Target): void {
|
|
469
|
+
const gl = this.gl;
|
|
470
|
+
if (this.outW <= 0 || this.outH <= 0) return;
|
|
471
|
+
if (this.canvas.width !== this.outW || this.canvas.height !== this.outH) {
|
|
472
|
+
this.canvas.width = this.outW;
|
|
473
|
+
this.canvas.height = this.outH;
|
|
474
|
+
}
|
|
475
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
476
|
+
gl.viewport(0, 0, this.outW, this.outH);
|
|
477
|
+
const prog = this.program(BLIT_FRAG);
|
|
478
|
+
gl.useProgram(prog);
|
|
479
|
+
const loc = gl.getAttribLocation(prog, 'aPos');
|
|
480
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
|
|
481
|
+
gl.enableVertexAttribArray(loc);
|
|
482
|
+
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
|
483
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
484
|
+
gl.bindTexture(gl.TEXTURE_2D, from.tex);
|
|
485
|
+
const uTex = gl.getUniformLocation(prog, 'texture');
|
|
486
|
+
if (uTex) gl.uniform1i(uTex, 0);
|
|
487
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
488
|
+
gl.disableVertexAttribArray(loc);
|
|
489
|
+
}
|
|
490
|
+
}
|
package/src/shaders.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// shaders.ts — fragment shaders for the preview pipeline.
|
|
2
|
+
//
|
|
3
|
+
// brightnessContrast / hueSaturation / sepia / vibrance / vignette are ports
|
|
4
|
+
// of evanw/glfx.js src/filters/adjust/*.js (same uniforms, same formulas, same
|
|
5
|
+
// clamp ranges). exposure / invert / grayscale / gaussian blur / sharpen-mix
|
|
6
|
+
// are trivial shaders in the same style. The VIPS applier (src/edit.h)
|
|
7
|
+
// implements these exact formulas, so the canvas preview is WYSIWYG.
|
|
8
|
+
|
|
9
|
+
export const VERT = `
|
|
10
|
+
attribute vec2 aPos;
|
|
11
|
+
varying vec2 texCoord;
|
|
12
|
+
void main() {
|
|
13
|
+
texCoord = vec2(aPos.x * 0.5 + 0.5, aPos.y * 0.5 + 0.5);
|
|
14
|
+
gl_Position = vec4(aPos, 0.0, 1.0);
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const PRELUDE = `
|
|
19
|
+
precision mediump float;
|
|
20
|
+
uniform sampler2D texture;
|
|
21
|
+
varying vec2 texCoord;
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
export const COPY_FRAG =
|
|
25
|
+
PRELUDE +
|
|
26
|
+
`
|
|
27
|
+
void main() {
|
|
28
|
+
gl_FragColor = texture2D(texture, texCoord);
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
/** Final blit to the canvas (un-flip the FLIP_Y upload). */
|
|
33
|
+
export const BLIT_FRAG =
|
|
34
|
+
PRELUDE +
|
|
35
|
+
`
|
|
36
|
+
void main() {
|
|
37
|
+
gl_FragColor = texture2D(texture, vec2(texCoord.x, 1.0 - texCoord.y));
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
/** Separable gaussian, one axis per draw (dir = (1/w,0) or (0,1/h)). */
|
|
42
|
+
export const GAUSS_FRAG =
|
|
43
|
+
PRELUDE +
|
|
44
|
+
`
|
|
45
|
+
uniform vec2 dir;
|
|
46
|
+
uniform float kernel[31];
|
|
47
|
+
uniform int count;
|
|
48
|
+
void main() {
|
|
49
|
+
vec4 sum = vec4(0.0);
|
|
50
|
+
for (int i = 0; i < 31; i++) {
|
|
51
|
+
if (i >= count) break;
|
|
52
|
+
vec2 off = dir * float(i - (count - 1) / 2);
|
|
53
|
+
sum += texture2D(texture, texCoord + off) * kernel[i];
|
|
54
|
+
}
|
|
55
|
+
gl_FragColor = sum;
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
/** glfx unsharp-mix: mix(blurred, original, 1 + strength). */
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// blur.ts — separable gaussian with the shared preview kernel
|
|
2
|
+
// (see math.ts gaussianKernel; VIPS side: src/transforms/gaussian.h).
|
|
3
|
+
import { clampf, gaussianKernel } from '../math.js';
|
|
4
|
+
import {
|
|
5
|
+
isNumInRange,
|
|
6
|
+
type ParamControl,
|
|
7
|
+
type RenderContext,
|
|
8
|
+
type Target,
|
|
9
|
+
type Transform,
|
|
10
|
+
} from './transform.js';
|
|
11
|
+
|
|
12
|
+
export interface BlurEdit {
|
|
13
|
+
edit: 'blur';
|
|
14
|
+
/** Gaussian radius in output pixels, 0..100. 0 = no-op. Default 0. */
|
|
15
|
+
radius?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Full = Required<BlurEdit>;
|
|
19
|
+
|
|
20
|
+
export class BlurTransform implements Transform<'blur', BlurEdit> {
|
|
21
|
+
public readonly edit = 'blur' as const;
|
|
22
|
+
public controls: ParamControl[] = [
|
|
23
|
+
{ key: 'radius', label: 'radius px', min: 0, max: 30, step: 0.5, def: 0 },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
public defaults(): Full {
|
|
27
|
+
return { edit: 'blur', radius: 0 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public validate(params: BlurEdit): string[] {
|
|
31
|
+
if (params.radius !== undefined && !isNumInRange(params.radius, 0, 100))
|
|
32
|
+
return ['radius must be a number in [0,100]'];
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public normalize(params: BlurEdit): Full {
|
|
37
|
+
return { edit: 'blur', radius: clampf(0, params.radius ?? 0, 100) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public apply(ctx: RenderContext, from: Target, to: Target, params: Full): Target {
|
|
41
|
+
const radius = params.radius * ctx.resolutionScale;
|
|
42
|
+
if (radius <= 0) {
|
|
43
|
+
ctx.blit(from, to);
|
|
44
|
+
return to;
|
|
45
|
+
}
|
|
46
|
+
const kernel = gaussianKernel(radius);
|
|
47
|
+
ctx.gauss(from, to, true, kernel);
|
|
48
|
+
ctx.gauss(to, from, false, kernel);
|
|
49
|
+
return from;
|
|
50
|
+
}
|
|
51
|
+
}
|