@radialapps/memories-vips-editor 0.1.1 → 0.1.3

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