@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
@@ -0,0 +1,78 @@
1
+ // brightnessContrast.ts — glfx.js brightness/contrast port.
2
+ import { clampf } from '../math.js';
3
+ import {
4
+ isNumInRange,
5
+ setFloatUniform,
6
+ type ParamControl,
7
+ type RenderContext,
8
+ type Target,
9
+ type Transform,
10
+ } from './transform.js';
11
+
12
+ export interface BrightnessContrastEdit {
13
+ edit: 'brightnessContrast';
14
+ /** Additive brightness, -1 (solid black) .. 1 (solid white). Default 0. */
15
+ brightness?: number;
16
+ /** Contrast, -1 (solid gray) .. 1 (max). Default 0. */
17
+ contrast?: number;
18
+ }
19
+
20
+ type Full = Required<BrightnessContrastEdit>;
21
+
22
+ const FRAG = `
23
+ precision mediump float;
24
+ uniform sampler2D texture;
25
+ uniform float brightness;
26
+ uniform float contrast;
27
+ varying vec2 texCoord;
28
+ void main() {
29
+ vec4 color = texture2D(texture, texCoord);
30
+ color.rgb += brightness;
31
+ if (contrast > 0.0) {
32
+ color.rgb = (color.rgb - 0.5) / (1.0 - contrast) + 0.5;
33
+ } else {
34
+ color.rgb = (color.rgb - 0.5) * (1.0 + contrast) + 0.5;
35
+ }
36
+ gl_FragColor = color;
37
+ }
38
+ `;
39
+
40
+ export class BrightnessContrastTransform implements Transform<
41
+ 'brightnessContrast',
42
+ BrightnessContrastEdit
43
+ > {
44
+ public readonly edit = 'brightnessContrast' as const;
45
+ public controls: ParamControl[] = [
46
+ { key: 'brightness', label: 'brightness', min: -1, max: 1, step: 0.01, def: 0 },
47
+ { key: 'contrast', label: 'contrast', min: -1, max: 1, step: 0.01, def: 0 },
48
+ ];
49
+
50
+ public defaults(): Full {
51
+ return { edit: 'brightnessContrast', brightness: 0, contrast: 0 };
52
+ }
53
+
54
+ public validate(params: BrightnessContrastEdit): string[] {
55
+ const errs: string[] = [];
56
+ if (params.brightness !== undefined && !isNumInRange(params.brightness, -1, 1))
57
+ errs.push('brightness must be a number in [-1,1]');
58
+ if (params.contrast !== undefined && !isNumInRange(params.contrast, -1, 1))
59
+ errs.push('contrast must be a number in [-1,1]');
60
+ return errs;
61
+ }
62
+
63
+ public normalize(params: BrightnessContrastEdit): Full {
64
+ return {
65
+ edit: 'brightnessContrast',
66
+ brightness: clampf(-1, params.brightness ?? 0, 1),
67
+ contrast: clampf(-1, params.contrast ?? 0, 1),
68
+ };
69
+ }
70
+
71
+ public apply(ctx: RenderContext, from: Target, to: Target, params: Full): Target {
72
+ ctx.pass(FRAG, from, to, (gl, p) => {
73
+ setFloatUniform(gl, p, 'brightness', params.brightness);
74
+ setFloatUniform(gl, p, 'contrast', params.contrast);
75
+ });
76
+ return to;
77
+ }
78
+ }
@@ -0,0 +1,115 @@
1
+ // crop.ts — relative-rect crop (geometric, resolution-independent).
2
+ //
3
+ // Params are fractions of the current image size (origin top-left), so the
4
+ // same JSON means the same framing at any resolution. The pixel rect uses
5
+ // the exact VIPS formula (src/transforms/crop.h): round + clamp to
6
+ // [1px, remainder]; outputDims() shares it so the pipeline pre-sizes `to`.
7
+ import { clampf } from '../math.js';
8
+ import {
9
+ isNumInRange,
10
+ type ParamControl,
11
+ type RenderContext,
12
+ type Target,
13
+ type Transform,
14
+ } from './transform.js';
15
+
16
+ export interface CropEdit {
17
+ edit: 'crop';
18
+ /** Left edge, fraction of width, 0..1. Default 0. */
19
+ x?: number;
20
+ /** Top edge, fraction of height, 0..1. Default 0. */
21
+ y?: number;
22
+ /** Width, fraction of width, >0..1. Default 1. */
23
+ width?: number;
24
+ /** Height, fraction of height, >0..1. Default 1. */
25
+ height?: number;
26
+ }
27
+
28
+ type Full = Required<CropEdit>;
29
+
30
+ const MIN_EXTENT = 1e-4;
31
+
32
+ /** Pixel rect for the given input size; identical to src/transforms/crop.h. */
33
+ export function cropRect(
34
+ inW: number,
35
+ inH: number,
36
+ params: Full,
37
+ ): { left: number; top: number; w: number; h: number } {
38
+ const left = Math.min(inW - 1, Math.max(0, Math.round(params.x * inW)));
39
+ const top = Math.min(inH - 1, Math.max(0, Math.round(params.y * inH)));
40
+ const w = Math.min(inW - left, Math.max(1, Math.round(params.width * inW)));
41
+ const h = Math.min(inH - top, Math.max(1, Math.round(params.height * inH)));
42
+ return { left, top, w, h };
43
+ }
44
+
45
+ const FRAG = `
46
+ precision mediump float;
47
+ uniform sampler2D texture;
48
+ uniform vec2 offset;
49
+ uniform vec2 scale;
50
+ varying vec2 texCoord;
51
+ void main() {
52
+ gl_FragColor = texture2D(texture, offset + texCoord * scale);
53
+ }
54
+ `;
55
+
56
+ export class CropTransform implements Transform<'crop', CropEdit> {
57
+ public readonly edit = 'crop' as const;
58
+ public controls: ParamControl[] = [
59
+ { key: 'x', label: 'x', min: 0, max: 1, step: 0.01, def: 0 },
60
+ { key: 'y', label: 'y', min: 0, max: 1, step: 0.01, def: 0 },
61
+ { key: 'width', label: 'width', min: 0, max: 1, step: 0.01, def: 1 },
62
+ { key: 'height', label: 'height', min: 0, max: 1, step: 0.01, def: 1 },
63
+ ];
64
+
65
+ public defaults(): Full {
66
+ return { edit: 'crop', x: 0, y: 0, width: 1, height: 1 };
67
+ }
68
+
69
+ public validate(params: CropEdit): string[] {
70
+ const errs: string[] = [];
71
+ if (params.x !== undefined && !isNumInRange(params.x, 0, 1))
72
+ errs.push('x must be a number in [0,1]');
73
+ if (params.y !== undefined && !isNumInRange(params.y, 0, 1))
74
+ errs.push('y must be a number in [0,1]');
75
+ if (params.width !== undefined && !isNumInRange(params.width, 0, 1))
76
+ errs.push('width must be a number in [0,1]');
77
+ if (params.height !== undefined && !isNumInRange(params.height, 0, 1))
78
+ errs.push('height must be a number in [0,1]');
79
+ if (errs.length > 0) return errs;
80
+ const x = params.x ?? 0;
81
+ const y = params.y ?? 0;
82
+ const w = params.width ?? 1;
83
+ const h = params.height ?? 1;
84
+ if (!(w > 0) || !(h > 0)) errs.push('width/height must be > 0');
85
+ const eps = 1e-9;
86
+ if (x + w > 1 + eps || y + h > 1 + eps)
87
+ errs.push('crop region x+width/y+height must not exceed 1');
88
+ return errs;
89
+ }
90
+
91
+ public normalize(params: CropEdit): Full {
92
+ // Keep at least MIN_EXTENT of frame: the renderer rounds that to 1px.
93
+ const x = clampf(0, params.x ?? 0, 1 - MIN_EXTENT);
94
+ const y = clampf(0, params.y ?? 0, 1 - MIN_EXTENT);
95
+ const width = clampf(MIN_EXTENT, params.width ?? 1, 1 - x);
96
+ const height = clampf(MIN_EXTENT, params.height ?? 1, 1 - y);
97
+ return { edit: 'crop', x, y, width, height };
98
+ }
99
+
100
+ public outputDims(inW: number, inH: number, params: Full): { w: number; h: number } {
101
+ const r = cropRect(inW, inH, params);
102
+ return { w: r.w, h: r.h };
103
+ }
104
+
105
+ public apply(ctx: RenderContext, from: Target, to: Target, params: Full): Target {
106
+ const r = cropRect(ctx.width, ctx.height, params);
107
+ ctx.pass(FRAG, from, to, (gl, p) => {
108
+ const uOff = gl.getUniformLocation(p, 'offset');
109
+ if (uOff) gl.uniform2f(uOff, r.left / ctx.width, r.top / ctx.height);
110
+ const uScale = gl.getUniformLocation(p, 'scale');
111
+ if (uScale) gl.uniform2f(uScale, r.w / ctx.width, r.h / ctx.height);
112
+ });
113
+ return to;
114
+ }
115
+ }
@@ -0,0 +1,59 @@
1
+ // exposure.ts — stop-based gain (2^stops).
2
+ import { clampf, exposureMult } from '../math.js';
3
+ import {
4
+ isNumInRange,
5
+ setFloatUniform,
6
+ type ParamControl,
7
+ type RenderContext,
8
+ type Target,
9
+ type Transform,
10
+ } from './transform.js';
11
+
12
+ export interface ExposureEdit {
13
+ edit: 'exposure';
14
+ /** Exposure shift in stops, -5..5. Pixel gain is 2^stops. Default 0. */
15
+ stops?: number;
16
+ }
17
+
18
+ type Full = Required<ExposureEdit>;
19
+
20
+ const FRAG = `
21
+ precision mediump float;
22
+ uniform sampler2D texture;
23
+ uniform float mult;
24
+ varying vec2 texCoord;
25
+ void main() {
26
+ vec4 color = texture2D(texture, texCoord);
27
+ color.rgb *= mult;
28
+ gl_FragColor = color;
29
+ }
30
+ `;
31
+
32
+ export class ExposureTransform implements Transform<'exposure', ExposureEdit> {
33
+ public readonly edit = 'exposure' as const;
34
+ public controls: ParamControl[] = [
35
+ { key: 'stops', label: 'stops', min: -5, max: 5, step: 0.05, def: 0 },
36
+ ];
37
+
38
+ public defaults(): Full {
39
+ return { edit: 'exposure', stops: 0 };
40
+ }
41
+
42
+ public validate(params: ExposureEdit): string[] {
43
+ if (params.stops !== undefined && !isNumInRange(params.stops, -5, 5))
44
+ return ['stops must be a number in [-5,5]'];
45
+ return [];
46
+ }
47
+
48
+ public normalize(params: ExposureEdit): Full {
49
+ return { edit: 'exposure', stops: clampf(-5, params.stops ?? 0, 5) };
50
+ }
51
+
52
+ public apply(ctx: RenderContext, from: Target, to: Target, params: Full): Target {
53
+ const mult = exposureMult(params.stops);
54
+ ctx.pass(FRAG, from, to, (gl, p) => {
55
+ setFloatUniform(gl, p, 'mult', mult);
56
+ });
57
+ return to;
58
+ }
59
+ }
@@ -0,0 +1,42 @@
1
+ // grayscale.ts — channel-average desaturation (== saturation -1).
2
+ import type { ParamControl, RenderContext, Target, Transform } from './transform.js';
3
+
4
+ export interface GrayscaleEdit {
5
+ edit: 'grayscale';
6
+ }
7
+
8
+ type Full = Required<GrayscaleEdit>;
9
+
10
+ const FRAG = `
11
+ precision mediump float;
12
+ uniform sampler2D texture;
13
+ varying vec2 texCoord;
14
+ void main() {
15
+ vec4 color = texture2D(texture, texCoord);
16
+ float average = (color.r + color.g + color.b) / 3.0;
17
+ color.rgb = vec3(average);
18
+ gl_FragColor = color;
19
+ }
20
+ `;
21
+
22
+ export class GrayscaleTransform implements Transform<'grayscale', GrayscaleEdit> {
23
+ public readonly edit = 'grayscale' as const;
24
+ public controls: ParamControl[] = [];
25
+
26
+ public defaults(): Full {
27
+ return { edit: 'grayscale' };
28
+ }
29
+
30
+ public validate(_params: GrayscaleEdit): string[] {
31
+ return [];
32
+ }
33
+
34
+ public normalize(_params: GrayscaleEdit): Full {
35
+ return { edit: 'grayscale' };
36
+ }
37
+
38
+ public apply(ctx: RenderContext, from: Target, to: Target, _params: Full): Target {
39
+ ctx.pass(FRAG, from, to);
40
+ return to;
41
+ }
42
+ }
@@ -0,0 +1,86 @@
1
+ // hueSaturation.ts — glfx.js hue/saturation port.
2
+ import { clampf } from '../math.js';
3
+ import {
4
+ isNumInRange,
5
+ setFloatUniform,
6
+ type ParamControl,
7
+ type RenderContext,
8
+ type Target,
9
+ type Transform,
10
+ } from './transform.js';
11
+
12
+ export interface HueSaturationEdit {
13
+ edit: 'hueSaturation';
14
+ /** Hue rotation, -1 (-180deg) .. 1 (+180deg). Default 0. */
15
+ hue?: number;
16
+ /** Saturation, -1 (gray) .. 1 (max). Default 0. */
17
+ saturation?: number;
18
+ }
19
+
20
+ type Full = Required<HueSaturationEdit>;
21
+
22
+ const FRAG = `
23
+ precision mediump float;
24
+ uniform sampler2D texture;
25
+ uniform float hue;
26
+ uniform float saturation;
27
+ varying vec2 texCoord;
28
+ void main() {
29
+ vec4 color = texture2D(texture, texCoord);
30
+
31
+ float angle = hue * 3.14159265;
32
+ float s = sin(angle), c = cos(angle);
33
+ vec3 weights = (vec3(2.0 * c, -sqrt(3.0) * s - c, sqrt(3.0) * s - c) + 1.0) / 3.0;
34
+ color.rgb = vec3(
35
+ dot(color.rgb, weights.xyz),
36
+ dot(color.rgb, weights.zxy),
37
+ dot(color.rgb, weights.yzx)
38
+ );
39
+
40
+ float average = (color.r + color.g + color.b) / 3.0;
41
+ if (saturation > 0.0) {
42
+ color.rgb += (average - color.rgb) * (1.0 - 1.0 / (1.001 - saturation));
43
+ } else {
44
+ color.rgb += (average - color.rgb) * (-saturation);
45
+ }
46
+
47
+ gl_FragColor = color;
48
+ }
49
+ `;
50
+
51
+ export class HueSaturationTransform implements Transform<'hueSaturation', HueSaturationEdit> {
52
+ public readonly edit = 'hueSaturation' as const;
53
+ public controls: ParamControl[] = [
54
+ { key: 'hue', label: 'hue', min: -1, max: 1, step: 0.01, def: 0 },
55
+ { key: 'saturation', label: 'saturation', min: -1, max: 1, step: 0.01, def: 0 },
56
+ ];
57
+
58
+ public defaults(): Full {
59
+ return { edit: 'hueSaturation', hue: 0, saturation: 0 };
60
+ }
61
+
62
+ public validate(params: HueSaturationEdit): string[] {
63
+ const errs: string[] = [];
64
+ if (params.hue !== undefined && !isNumInRange(params.hue, -1, 1))
65
+ errs.push('hue must be a number in [-1,1]');
66
+ if (params.saturation !== undefined && !isNumInRange(params.saturation, -1, 1))
67
+ errs.push('saturation must be a number in [-1,1]');
68
+ return errs;
69
+ }
70
+
71
+ public normalize(params: HueSaturationEdit): Full {
72
+ return {
73
+ edit: 'hueSaturation',
74
+ hue: clampf(-1, params.hue ?? 0, 1),
75
+ saturation: clampf(-1, params.saturation ?? 0, 1),
76
+ };
77
+ }
78
+
79
+ public apply(ctx: RenderContext, from: Target, to: Target, params: Full): Target {
80
+ ctx.pass(FRAG, from, to, (gl, p) => {
81
+ setFloatUniform(gl, p, 'hue', params.hue);
82
+ setFloatUniform(gl, p, 'saturation', params.saturation);
83
+ });
84
+ return to;
85
+ }
86
+ }
@@ -0,0 +1,49 @@
1
+ // transforms/index.ts — registry: name -> Transform instance.
2
+ //
3
+ // Adding a transform: write its file (params interface + Transform class),
4
+ // import it here, add one line to TRANSFORMS. The pipeline, validation,
5
+ // serialization, and demo pick it up with no other changes.
6
+ import type { Edit, EditName } from '../types.js';
7
+ import { lookup, type Transform } from './transform.js';
8
+ import { BlurTransform } from './blur.js';
9
+ import { BrightnessContrastTransform } from './brightnessContrast.js';
10
+ import { CropTransform } from './crop.js';
11
+ import { ExposureTransform } from './exposure.js';
12
+ import { GrayscaleTransform } from './grayscale.js';
13
+ import { HueSaturationTransform } from './hueSaturation.js';
14
+ import { InvertTransform } from './invert.js';
15
+ import { RotateTransform } from './rotate.js';
16
+ import { SepiaTransform } from './sepia.js';
17
+ import { SharpenTransform } from './sharpen.js';
18
+ import { VibranceTransform } from './vibrance.js';
19
+ import { VignetteTransform } from './vignette.js';
20
+
21
+ export const TRANSFORMS: Record<EditName, Transform<string, object>> = {
22
+ brightnessContrast: new BrightnessContrastTransform(),
23
+ exposure: new ExposureTransform(),
24
+ hueSaturation: new HueSaturationTransform(),
25
+ sepia: new SepiaTransform(),
26
+ vibrance: new VibranceTransform(),
27
+ blur: new BlurTransform(),
28
+ sharpen: new SharpenTransform(),
29
+ vignette: new VignetteTransform(),
30
+ invert: new InvertTransform(),
31
+ grayscale: new GrayscaleTransform(),
32
+ crop: new CropTransform(),
33
+ rotate: new RotateTransform(),
34
+ };
35
+
36
+ /** Human-readable problems; empty means the edit is VIPS-appliable. */
37
+ export function validateEdit(edit: Edit): string[] {
38
+ if (typeof edit !== 'object' || edit === null) return ['edit must be an object'];
39
+ const t = lookup(TRANSFORMS, edit.edit);
40
+ if (!t) return [`unknown edit ${String(edit.edit)}`];
41
+ return t.validate(edit);
42
+ }
43
+
44
+ /** Fill defaults + clamp, so serialized JSON always passes validateEdit. */
45
+ export function normalizeEdit(edit: Edit): Edit {
46
+ const t = lookup(TRANSFORMS, edit.edit);
47
+ if (!t) throw new Error(`unknown edit ${String(edit.edit)}`);
48
+ return t.normalize(edit) as Edit;
49
+ }
@@ -0,0 +1,41 @@
1
+ // invert.ts — per-pixel inversion.
2
+ import type { ParamControl, RenderContext, Target, Transform } from './transform.js';
3
+
4
+ export interface InvertEdit {
5
+ edit: 'invert';
6
+ }
7
+
8
+ type Full = Required<InvertEdit>;
9
+
10
+ const FRAG = `
11
+ precision mediump float;
12
+ uniform sampler2D texture;
13
+ varying vec2 texCoord;
14
+ void main() {
15
+ vec4 color = texture2D(texture, texCoord);
16
+ color.rgb = 1.0 - color.rgb;
17
+ gl_FragColor = color;
18
+ }
19
+ `;
20
+
21
+ export class InvertTransform implements Transform<'invert', InvertEdit> {
22
+ public readonly edit = 'invert' as const;
23
+ public controls: ParamControl[] = [];
24
+
25
+ public defaults(): Full {
26
+ return { edit: 'invert' };
27
+ }
28
+
29
+ public validate(_params: InvertEdit): string[] {
30
+ return [];
31
+ }
32
+
33
+ public normalize(_params: InvertEdit): Full {
34
+ return { edit: 'invert' };
35
+ }
36
+
37
+ public apply(ctx: RenderContext, from: Target, to: Target, _params: Full): Target {
38
+ ctx.pass(FRAG, from, to);
39
+ return to;
40
+ }
41
+ }
@@ -0,0 +1,211 @@
1
+ // rotate.ts — clockwise rotation, exact 90s + arbitrary angles.
2
+ //
3
+ // angle is clockwise degrees in [-180,180] (matches vips_rotate() and
4
+ // src/transforms/rotate.h). Multiples of 90 use the exact axis swap (no
5
+ // resample, no band change); other angles expand the canvas to the VIPS
6
+ // bounding box and sample with bilinear over a transparent background —
7
+ // the VIPS side likewise appends an alpha band for non-90 angles, so
8
+ // preview and VIPS agree on dims and corners.
9
+ import { clampf } from '../math.js';
10
+ import {
11
+ isNumInRange,
12
+ setFloatUniform,
13
+ type ParamControl,
14
+ type RenderContext,
15
+ type Target,
16
+ type Transform,
17
+ } from './transform.js';
18
+
19
+ export interface RotateEdit {
20
+ edit: 'rotate';
21
+ /** Clockwise degrees, -180..180. Default 0. */
22
+ angle?: number;
23
+ }
24
+
25
+ type Full = Required<RotateEdit>;
26
+
27
+ /** Normalize like src/transforms/rotate.h: fmod + wrap to (-180,180]. */
28
+ export function normAngle(angle: number): number {
29
+ let a = angle % 360;
30
+ if (a <= -180) a += 360;
31
+ else if (a > 180) a -= 360;
32
+ return a;
33
+ }
34
+
35
+ /** Quarter turns when snapped to a multiple of 90, else null. */
36
+ export function snapQuarters(angle: number): number | null {
37
+ const a = normAngle(angle);
38
+ if (a === 0) return 0;
39
+ const q = Math.round(a / 90);
40
+ if (Math.abs(a - q * 90) < 1e-9) return q;
41
+ return null;
42
+ }
43
+
44
+ /** Snapped cos/sin for the shader (exact 0/±1 at multiples of 90). */
45
+ export function cosSin(angle: number): { c: number; s: number } {
46
+ const q = snapQuarters(angle);
47
+ if (q !== null) {
48
+ const qq = ((q % 4) + 4) % 4;
49
+ return [
50
+ { c: 1, s: 0 },
51
+ { c: 0, s: 1 },
52
+ { c: -1, s: 0 },
53
+ { c: 0, s: -1 },
54
+ ][qq] as { c: number; s: number };
55
+ }
56
+ const rad = (normAngle(angle) * Math.PI) / 180;
57
+ return { c: Math.cos(rad), s: Math.sin(rad) };
58
+ }
59
+
60
+ /** Bounding box; identical to the VIPS rotate output for all probed sizes. */
61
+ export function rotateDims(inW: number, inH: number, params: Full): { w: number; h: number } {
62
+ const q = snapQuarters(params.angle);
63
+ if (q !== null) return ((q % 2) + 2) % 2 === 0 ? { w: inW, h: inH } : { w: inH, h: inW };
64
+ const { c, s } = cosSin(params.angle);
65
+ const ac = Math.abs(c);
66
+ const as = Math.abs(s);
67
+ return { w: Math.round(inW * ac + inH * as), h: Math.round(inW * as + inH * ac) };
68
+ }
69
+
70
+ /**
71
+ * Output origin (top-left of the output in transform space): round() of the
72
+ * forward-mapped bounding-box min, exactly like vips_affine's oarea for
73
+ * vips_rotate (integer texel lattice, texel (i,j) at (i,j); output texel
74
+ * (x,y) samples the input at Minv*(x+ox, y+oy)).
75
+ */
76
+ export function rotateOrigin(inW: number, inH: number, params: Full): { ox: number; oy: number } {
77
+ const { c, s } = cosSin(params.angle);
78
+ const xs = [0, c * inW, -s * inH, c * inW - s * inH];
79
+ const ys = [0, s * inW, c * inH, s * inW + c * inH];
80
+ return { ox: Math.round(Math.min(...xs)), oy: Math.round(Math.min(...ys)) };
81
+ }
82
+
83
+ function frag(precision: string): string {
84
+ return `
85
+ precision ${precision} float;
86
+ uniform sampler2D texture;
87
+ uniform float cosA;
88
+ uniform float sinA;
89
+ uniform vec2 inSize;
90
+ uniform vec2 outSize;
91
+ uniform vec2 outOrigin;
92
+ varying vec2 texCoord;
93
+
94
+ // One bilinear tap on the integer texel lattice (texel (i,j) at (i,j));
95
+ // outside [0,W-1]x[0,H-1] reads the transparent background, exactly like
96
+ // the vips_affine embed behind vips_rotate. The 1e-4 epsilon keeps
97
+ // near-integer coordinates (float error) on their texel centers.
98
+ vec4 tap(vec2 lattice) {
99
+ if (lattice.x < -0.0001 || lattice.x > inSize.x - 1.0 + 0.0001 ||
100
+ lattice.y < -0.0001 || lattice.y > inSize.y - 1.0 + 0.0001) {
101
+ return vec4(0.0);
102
+ }
103
+ return texture2D(texture, (lattice + 0.5) / inSize);
104
+ }
105
+
106
+ void main() {
107
+ // Output texel (x,y) <=> lattice point (x+ox, y+oy), backward-mapped
108
+ // with the inverse rotation (y-down, clockwise-positive).
109
+ vec2 lat = vec2(texCoord.x * outSize.x - 0.5 + outOrigin.x,
110
+ texCoord.y * outSize.y - 0.5 + outOrigin.y);
111
+ vec2 inp = vec2(cosA * lat.x + sinA * lat.y,
112
+ -sinA * lat.x + cosA * lat.y);
113
+ vec2 p0 = floor(inp);
114
+ vec2 f = inp - p0;
115
+ float w00 = (1.0 - f.x) * (1.0 - f.y);
116
+ float w10 = f.x * (1.0 - f.y);
117
+ float w01 = (1.0 - f.x) * f.y;
118
+ float w11 = f.x * f.y;
119
+ vec4 t00 = tap(p0);
120
+ vec4 t10 = tap(p0 + vec2(1.0, 0.0));
121
+ vec4 t01 = tap(p0 + vec2(0.0, 1.0));
122
+ vec4 t11 = tap(p0 + vec2(1.0, 1.0));
123
+ // vips_affine premultiplies alpha before resampling and unpremultiplies
124
+ // after (float path: factor 0 when |alpha| < 0.01, else rgb /= alpha).
125
+ vec3 prgb = t00.rgb * (t00.a * w00) + t10.rgb * (t10.a * w10) +
126
+ t01.rgb * (t01.a * w01) + t11.rgb * (t11.a * w11);
127
+ float pa = t00.a * w00 + t10.a * w10 + t01.a * w01 + t11.a * w11;
128
+ if (abs(pa) < 0.01) {
129
+ gl_FragColor = vec4(0.0, 0.0, 0.0, pa);
130
+ } else {
131
+ gl_FragColor = vec4(prgb / pa, pa);
132
+ }
133
+ }
134
+ `;
135
+ }
136
+
137
+ const FRAG_HIGH = frag('highp');
138
+ const FRAG_MED = frag('mediump');
139
+
140
+ // Exact multiples of 90 go through vips_rot() on the VIPS side (pixel
141
+ // shuffle, no resampling, no band change) — the preview mirrors that with
142
+ // a pure UV swizzle (O(1) math, exact under any precision). Quarter q is
143
+ // clockwise: out(x,y) = in(y,H-1-x) / in(W-1-x,H-1-y) / in(W-1-y,x).
144
+ const FRAG_QUARTER = `
145
+ precision mediump float;
146
+ uniform sampler2D texture;
147
+ uniform int quarter;
148
+ varying vec2 texCoord;
149
+ void main() {
150
+ vec2 uv = texCoord;
151
+ if (quarter == 1) {
152
+ uv = vec2(texCoord.y, 1.0 - texCoord.x);
153
+ } else if (quarter == 2) {
154
+ uv = vec2(1.0 - texCoord.x, 1.0 - texCoord.y);
155
+ } else if (quarter == 3) {
156
+ uv = vec2(1.0 - texCoord.y, texCoord.x);
157
+ }
158
+ gl_FragColor = texture2D(texture, uv);
159
+ }
160
+ `;
161
+
162
+ export class RotateTransform implements Transform<'rotate', RotateEdit> {
163
+ public readonly edit = 'rotate' as const;
164
+ public controls: ParamControl[] = [
165
+ { key: 'angle', label: 'angle °', min: -180, max: 180, step: 1, def: 0 },
166
+ ];
167
+
168
+ public defaults(): Full {
169
+ return { edit: 'rotate', angle: 0 };
170
+ }
171
+
172
+ public validate(params: RotateEdit): string[] {
173
+ if (params.angle !== undefined && !isNumInRange(params.angle, -180, 180))
174
+ return ['angle must be a number in [-180,180]'];
175
+ return [];
176
+ }
177
+
178
+ public normalize(params: RotateEdit): Full {
179
+ return { edit: 'rotate', angle: clampf(-180, params.angle ?? 0, 180) };
180
+ }
181
+
182
+ public outputDims(inW: number, inH: number, params: Full): { w: number; h: number } {
183
+ return rotateDims(inW, inH, params);
184
+ }
185
+
186
+ public apply(ctx: RenderContext, from: Target, to: Target, params: Full): Target {
187
+ const q = snapQuarters(params.angle);
188
+ if (q !== null) {
189
+ const quarter = ((q % 4) + 4) % 4;
190
+ ctx.pass(FRAG_QUARTER, from, to, (gl, p) => {
191
+ const uQ = gl.getUniformLocation(p, 'quarter');
192
+ if (uQ) gl.uniform1i(uQ, quarter);
193
+ });
194
+ return to;
195
+ }
196
+ const { c, s } = cosSin(params.angle);
197
+ const o = rotateOrigin(ctx.width, ctx.height, params);
198
+ const shader = ctx.highp ? FRAG_HIGH : FRAG_MED;
199
+ ctx.pass(shader, from, to, (gl, p) => {
200
+ setFloatUniform(gl, p, 'cosA', c);
201
+ setFloatUniform(gl, p, 'sinA', s);
202
+ const uIn = gl.getUniformLocation(p, 'inSize');
203
+ if (uIn) gl.uniform2f(uIn, ctx.width, ctx.height);
204
+ const uOut = gl.getUniformLocation(p, 'outSize');
205
+ if (uOut) gl.uniform2f(uOut, ctx.outWidth, ctx.outHeight);
206
+ const uOrigin = gl.getUniformLocation(p, 'outOrigin');
207
+ if (uOrigin) gl.uniform2f(uOrigin, o.ox, o.oy);
208
+ });
209
+ return to;
210
+ }
211
+ }