@radialapps/memories-vips-editor 0.1.2 → 0.1.5

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 (70) hide show
  1. package/README.md +23 -15
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +4 -3
  5. package/dist/index.js.map +1 -1
  6. package/dist/math.d.ts +25 -0
  7. package/dist/math.d.ts.map +1 -1
  8. package/dist/math.js +38 -4
  9. package/dist/math.js.map +1 -1
  10. package/dist/pipeline.d.ts +9 -0
  11. package/dist/pipeline.d.ts.map +1 -1
  12. package/dist/pipeline.js +35 -23
  13. package/dist/pipeline.js.map +1 -1
  14. package/dist/shaders.d.ts +2 -3
  15. package/dist/shaders.d.ts.map +1 -1
  16. package/dist/shaders.js +9 -12
  17. package/dist/shaders.js.map +1 -1
  18. package/dist/smallPreview.d.ts +31 -0
  19. package/dist/smallPreview.d.ts.map +1 -0
  20. package/dist/smallPreview.js +118 -0
  21. package/dist/smallPreview.js.map +1 -0
  22. package/dist/transforms/crop.d.ts.map +1 -1
  23. package/dist/transforms/crop.js +7 -21
  24. package/dist/transforms/crop.js.map +1 -1
  25. package/dist/transforms/highlights.d.ts +17 -0
  26. package/dist/transforms/highlights.d.ts.map +1 -0
  27. package/dist/transforms/highlights.js +49 -0
  28. package/dist/transforms/highlights.js.map +1 -0
  29. package/dist/transforms/index.d.ts.map +1 -1
  30. package/dist/transforms/index.js +8 -2
  31. package/dist/transforms/index.js.map +1 -1
  32. package/dist/transforms/sepia.d.ts.map +1 -1
  33. package/dist/transforms/sepia.js +5 -4
  34. package/dist/transforms/sepia.js.map +1 -1
  35. package/dist/transforms/shadows.d.ts +17 -0
  36. package/dist/transforms/shadows.d.ts.map +1 -0
  37. package/dist/transforms/shadows.js +49 -0
  38. package/dist/transforms/shadows.js.map +1 -0
  39. package/dist/transforms/sharpen.js +1 -1
  40. package/dist/transforms/state.d.ts +1 -1
  41. package/dist/transforms/state.d.ts.map +1 -1
  42. package/dist/transforms/state.js +9 -9
  43. package/dist/transforms/state.js.map +1 -1
  44. package/dist/transforms/transform.d.ts +2 -2
  45. package/dist/transforms/transform.d.ts.map +1 -1
  46. package/dist/transforms/transform.js +4 -1
  47. package/dist/transforms/transform.js.map +1 -1
  48. package/dist/transforms/warmth.d.ts +17 -0
  49. package/dist/transforms/warmth.d.ts.map +1 -0
  50. package/dist/transforms/warmth.js +46 -0
  51. package/dist/transforms/warmth.js.map +1 -0
  52. package/dist/types.d.ts +5 -2
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/types.js.map +1 -1
  55. package/package.json +2 -2
  56. package/src/index.ts +4 -3
  57. package/src/math.ts +43 -4
  58. package/src/pipeline.ts +46 -22
  59. package/src/shaders.ts +9 -13
  60. package/src/smallPreview.ts +120 -0
  61. package/src/transforms/crop.ts +8 -17
  62. package/src/transforms/highlights.ts +67 -0
  63. package/src/transforms/index.ts +10 -4
  64. package/src/transforms/sepia.ts +5 -4
  65. package/src/transforms/shadows.ts +67 -0
  66. package/src/transforms/sharpen.ts +1 -1
  67. package/src/transforms/state.ts +7 -10
  68. package/src/transforms/transform.ts +8 -5
  69. package/src/transforms/warmth.ts +64 -0
  70. package/src/types.ts +9 -0
@@ -0,0 +1,46 @@
1
+ // warmth.ts — white-balance-style temperature shift (red/blue gains).
2
+ //
3
+ // r *= 1 + 0.3*amount, b *= 1 - 0.3*amount, g unchanged: +1 is warm
4
+ // (amber), -1 is cool (blue). Blacks stay black (multiplicative, like a
5
+ // white-balance gain). Same factors in src/transforms/warmth.h and
6
+ // math.ts warmthGains, so the canvas preview is WYSIWYG.
7
+ import { clampf } from '../math.js';
8
+ import { isNumInRange, setFloatUniform, } from './transform.js';
9
+ const FRAG = `
10
+ precision mediump float;
11
+ uniform sampler2D texture;
12
+ uniform float amount;
13
+ varying vec2 texCoord;
14
+ void main() {
15
+ vec4 color = texture2D(texture, texCoord);
16
+ color.r *= 1.0 + amount * 0.3;
17
+ color.b *= 1.0 - amount * 0.3;
18
+ gl_FragColor = color;
19
+ }
20
+ `;
21
+ export class WarmthTransform {
22
+ constructor() {
23
+ this.edit = 'warmth';
24
+ this.controls = [
25
+ { key: 'amount', label: 'amount', min: -1, max: 1, step: 0.01, def: 0 },
26
+ ];
27
+ }
28
+ defaults() {
29
+ return { edit: 'warmth', amount: 0 };
30
+ }
31
+ validate(params) {
32
+ if (params.amount !== undefined && !isNumInRange(params.amount, -1, 1))
33
+ return ['amount must be a number in [-1,1]'];
34
+ return [];
35
+ }
36
+ normalize(params) {
37
+ return { edit: 'warmth', amount: clampf(-1, params.amount ?? 0, 1) };
38
+ }
39
+ apply(ctx, from, to, params) {
40
+ ctx.pass(FRAG, from, to, (gl, p) => {
41
+ setFloatUniform(gl, p, 'amount', params.amount);
42
+ });
43
+ return to;
44
+ }
45
+ }
46
+ //# sourceMappingURL=warmth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"warmth.js","sourceRoot":"","sources":["../../src/transforms/warmth.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,oEAAoE;AACpE,wEAAwE;AACxE,mEAAmE;AACnE,yDAAyD;AACzD,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AACpC,OAAO,EACL,YAAY,EACZ,eAAe,GAKhB,MAAM,gBAAgB,CAAC;AAUxB,MAAM,IAAI,GAAG;;;;;;;;;;;CAWZ,CAAC;AAEF,MAAM,OAAO,eAAe;IAA5B;QACkB,SAAI,GAAG,QAAiB,CAAC;QAClC,aAAQ,GAAmB;YAChC,EAAE,GAAG,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,EAAE;SACxE,CAAC;IAsBJ,CAAC;IApBQ,QAAQ;QACb,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;IACvC,CAAC;IAEM,QAAQ,CAAC,MAAkB;QAChC,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC;YACpE,OAAO,CAAC,mCAAmC,CAAC,CAAC;QAC/C,OAAO,EAAE,CAAC;IACZ,CAAC;IAEM,SAAS,CAAC,MAAkB;QACjC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACvE,CAAC;IAEM,KAAK,CAAC,GAAkB,EAAE,IAAY,EAAE,EAAU,EAAE,MAAY;QACrE,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE;YACjC,eAAe,CAAC,EAAE,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;QAClD,CAAC,CAAC,CAAC;QACH,OAAO,EAAE,CAAC;IACZ,CAAC;CACF"}
package/dist/types.d.ts CHANGED
@@ -3,15 +3,18 @@ import type { BrightnessContrastEdit } from './transforms/brightnessContrast.js'
3
3
  import type { CropEdit } from './transforms/crop.js';
4
4
  import type { ExposureEdit } from './transforms/exposure.js';
5
5
  import type { GrayscaleEdit } from './transforms/grayscale.js';
6
+ import type { HighlightsEdit } from './transforms/highlights.js';
6
7
  import type { HueSaturationEdit } from './transforms/hueSaturation.js';
7
8
  import type { InvertEdit } from './transforms/invert.js';
8
9
  import type { RotateEdit } from './transforms/rotate.js';
9
10
  import type { SepiaEdit } from './transforms/sepia.js';
11
+ import type { ShadowsEdit } from './transforms/shadows.js';
10
12
  import type { SharpenEdit } from './transforms/sharpen.js';
11
13
  import type { VibranceEdit } from './transforms/vibrance.js';
12
14
  import type { VignetteEdit } from './transforms/vignette.js';
13
- export type EditName = 'brightnessContrast' | 'exposure' | 'hueSaturation' | 'sepia' | 'vibrance' | 'blur' | 'sharpen' | 'vignette' | 'invert' | 'grayscale' | 'crop' | 'rotate';
14
- export type Edit = BrightnessContrastEdit | ExposureEdit | HueSaturationEdit | SepiaEdit | VibranceEdit | BlurEdit | SharpenEdit | VignetteEdit | InvertEdit | GrayscaleEdit | CropEdit | RotateEdit;
15
+ import type { WarmthEdit } from './transforms/warmth.js';
16
+ export type EditName = 'brightnessContrast' | 'exposure' | 'hueSaturation' | 'sepia' | 'vibrance' | 'blur' | 'sharpen' | 'vignette' | 'shadows' | 'highlights' | 'warmth' | 'invert' | 'grayscale' | 'crop' | 'rotate';
17
+ export type Edit = BrightnessContrastEdit | ExposureEdit | HueSaturationEdit | SepiaEdit | VibranceEdit | BlurEdit | SharpenEdit | VignetteEdit | ShadowsEdit | HighlightsEdit | WarmthEdit | InvertEdit | GrayscaleEdit | CropEdit | RotateEdit;
15
18
  export declare const EDIT_STATE_VERSION: 1;
16
19
  export interface EditState {
17
20
  version: typeof EDIT_STATE_VERSION;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,oCAAoC,CAAC;AACjF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAE7D,MAAM,MAAM,QAAQ,GAChB,oBAAoB,GACpB,UAAU,GACV,eAAe,GACf,OAAO,GACP,UAAU,GACV,MAAM,GACN,SAAS,GACT,UAAU,GACV,QAAQ,GACR,WAAW,GACX,MAAM,GACN,QAAQ,CAAC;AAEb,MAAM,MAAM,IAAI,GACZ,sBAAsB,GACtB,YAAY,GACZ,iBAAiB,GACjB,SAAS,GACT,YAAY,GACZ,QAAQ,GACR,WAAW,GACX,YAAY,GACZ,UAAU,GACV,aAAa,GACb,QAAQ,GACR,UAAU,CAAC;AAEf,eAAO,MAAM,kBAAkB,EAAG,CAAU,CAAC;AAE7C,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,OAAO,kBAAkB,CAAC;IACnC,KAAK,EAAE,IAAI,EAAE,CAAC;CACf;AAED,wBAAgB,UAAU,IAAI,SAAS,CAEtC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,oCAAoC,CAAC;AACjF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAEzD,MAAM,MAAM,QAAQ,GAChB,oBAAoB,GACpB,UAAU,GACV,eAAe,GACf,OAAO,GACP,UAAU,GACV,MAAM,GACN,SAAS,GACT,UAAU,GACV,SAAS,GACT,YAAY,GACZ,QAAQ,GACR,QAAQ,GACR,WAAW,GACX,MAAM,GACN,QAAQ,CAAC;AAEb,MAAM,MAAM,IAAI,GACZ,sBAAsB,GACtB,YAAY,GACZ,iBAAiB,GACjB,SAAS,GACT,YAAY,GACZ,QAAQ,GACR,WAAW,GACX,YAAY,GACZ,WAAW,GACX,cAAc,GACd,UAAU,GACV,UAAU,GACV,aAAa,GACb,QAAQ,GACR,UAAU,CAAC;AAEf,eAAO,MAAM,kBAAkB,EAAG,CAAU,CAAC;AAE7C,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,OAAO,kBAAkB,CAAC;IACnC,KAAK,EAAE,IAAI,EAAE,CAAC;CACf;AAED,wBAAgB,UAAU,IAAI,SAAS,CAEtC"}
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAgDA,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAU,CAAC;AAO7C,MAAM,UAAU,UAAU;IACxB,OAAO,EAAE,OAAO,EAAE,kBAAkB,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;AACpD,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAyDA,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAU,CAAC;AAO7C,MAAM,UAAU,UAAU;IACxB,OAAO,EAAE,OAAO,EAAE,kBAAkB,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;AACpD,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@radialapps/memories-vips-editor",
3
- "version": "0.1.2",
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.5",
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",
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
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,
1
+ // index.ts — minimal public surface: the pipeline, small previews, the
2
+ // registry, state helpers, and the types needed to consume them (including
3
+ // writing a custom Transform). Internal helpers (lookup, isNumInRange,
4
4
  // setFloatUniform, math utils, per-transform types) live in their own
5
5
  // modules — import those directly.
6
6
  export { EditPipeline } from './pipeline.js';
7
+ export { SmallPreview } from './smallPreview.js';
7
8
  export { TRANSFORMS, validateEdit, normalizeEdit } from './transforms/index.js';
8
9
  export { validateState, stateToJSON, stateFromJSON } from './transforms/state.js';
9
10
  export type { Transform, ParamControl, RenderContext, Target } from './transforms/transform.js';
package/src/math.ts CHANGED
@@ -1,14 +1,13 @@
1
1
  // math.ts — CPU reference for every edit's pixel math.
2
2
  //
3
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:
4
+ // import) with the GLSL in transforms/ and the libvips code in src/transforms/:
5
5
  // preview (WebGL) == math.ts == VIPS applier
6
6
  // all operate on sRGB-encoded 0..1 floats and apply in array order.
7
7
  //
8
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.
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.
12
11
 
13
12
  export function clampf(lo: number, v: number, hi: number): number {
14
13
  return Math.min(hi, Math.max(lo, v));
@@ -19,6 +18,46 @@ export function exposureMult(stops: number): number {
19
18
  return Math.pow(2, clampf(-5, stops, 5));
20
19
  }
21
20
 
21
+ /** Shadows weight for a clamped 0..1 luma: (1-l)^2 (dark-localized). */
22
+ export function shadowsWeight(luma01: number): number {
23
+ const l = clampf(0, luma01, 1);
24
+ return (1 - l) * (1 - l);
25
+ }
26
+
27
+ /** Highlights weight for a clamped 0..1 luma: l^2 (bright-localized). */
28
+ export function highlightsWeight(luma01: number): number {
29
+ const l = clampf(0, luma01, 1);
30
+ return l * l;
31
+ }
32
+
33
+ /** Gray-pixel output of the shadows edit: v + (a*0.5)*(1-v)^2.
34
+ *
35
+ * The 0.5 keeps the curve monotonic increasing over the whole amount range
36
+ * (d/dv = 1-2k(1-v) >= 0 for k <= 0.5): without it black would lift past
37
+ * mid-gray at high amounts. Reference for the GLSL in shadows.ts and the
38
+ * loop in src/transforms/shadows.h.
39
+ */
40
+ export function applyShadowsGray(v: number, amount: number): number {
41
+ return v + clampf(-1, amount, 1) * 0.5 * shadowsWeight(v);
42
+ }
43
+
44
+ /** Gray-pixel output of the highlights edit: v + (a*0.5)*v^2.
45
+ *
46
+ * The 0.5 keeps the curve monotonic increasing over the whole amount range
47
+ * (d/dv = 1+2kv >= 0 for k >= -0.5): without it white would recover below
48
+ * near-white at high negative amounts. Reference for the GLSL in
49
+ * highlights.ts and the loop in src/transforms/highlights.h.
50
+ */
51
+ export function applyHighlightsGray(v: number, amount: number): number {
52
+ return v + clampf(-1, amount, 1) * 0.5 * highlightsWeight(v);
53
+ }
54
+
55
+ /** Red/blue white-balance gains for a warmth amount in [-1,1]. */
56
+ export function warmthGains(amount: number): { r: number; b: number } {
57
+ const a = clampf(-1, amount, 1);
58
+ return { r: 1 + a * 0.3, b: 1 - a * 0.3 };
59
+ }
60
+
22
61
  /** 1D gaussian kernel for a blur radius (sigma = radius/2). Preview + docs.
23
62
  *
24
63
  * Single source of truth, ported term-for-term to src/transforms/gaussian.h:
package/src/pipeline.ts CHANGED
@@ -13,8 +13,9 @@
13
13
  // array (normalized) is what vips-preview accepts as "edits".
14
14
  import { BLIT_FRAG, COPY_FRAG, GAUSS_FRAG, VERT } from './shaders.js';
15
15
  import { TRANSFORMS, normalizeEdit } from './transforms/index.js';
16
+ import { stateToJSON } from './transforms/state.js';
16
17
  import type { RenderContext, Target, UniformSetter, ExtraTex } from './transforms/transform.js';
17
- import { emptyState, type Edit, type EditState } from './types.js';
18
+ import { EDIT_STATE_VERSION, emptyState, type Edit, type EditState } from './types.js';
18
19
 
19
20
  export class EditPipeline {
20
21
  private gl: WebGLRenderingContext;
@@ -88,7 +89,7 @@ export class EditPipeline {
88
89
 
89
90
  /** Replace the whole edit list (normalizing each edit) and re-render. */
90
91
  public setEdits(edits: Edit[]): void {
91
- this.state = { version: 1, edits: edits.map(normalizeEdit) };
92
+ this.state = { version: EDIT_STATE_VERSION, edits: edits.map(normalizeEdit) };
92
93
  this.render();
93
94
  }
94
95
 
@@ -119,7 +120,7 @@ export class EditPipeline {
119
120
 
120
121
  /** Canonical JSON state for the VIPS side. */
121
122
  public toJSON(): string {
122
- return JSON.stringify({ version: 1 as const, edits: this.state.edits.map(normalizeEdit) });
123
+ return stateToJSON(this.state);
123
124
  }
124
125
 
125
126
  /**
@@ -182,14 +183,14 @@ export class EditPipeline {
182
183
  const g = scratch.getContext('2d');
183
184
  if (!g) throw new Error('setImage: 2D context unavailable for downscale');
184
185
  g.imageSmoothingQuality = 'high';
185
- g.drawImage(source as CanvasImageSource, 0, 0, w, h);
186
+ g.drawImage(EditPipeline.toDrawableSource(source), 0, 0, w, h);
186
187
  upload = scratch;
187
188
  }
188
189
  this.imgW = w;
189
190
  this.imgH = h;
190
191
  this.outW = w;
191
192
  this.outH = h;
192
- this.resScale = dims.w > 0 ? w / dims.w : 1;
193
+ this.resScale = w / dims.w;
193
194
  for (const t of [this.src, this.ping, this.pong, this.extra]) {
194
195
  if (t) {
195
196
  gl.deleteTexture(t.tex);
@@ -405,10 +406,23 @@ export class EditPipeline {
405
406
  to: Target,
406
407
  setUniforms?: UniformSetter,
407
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,
408
422
  ): void {
409
423
  const gl = this.gl;
410
- gl.bindFramebuffer(gl.FRAMEBUFFER, to.fb);
411
- gl.viewport(0, 0, to.w, to.h);
424
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
425
+ gl.viewport(0, 0, w, h);
412
426
  const prog = this.program(frag);
413
427
  gl.useProgram(prog);
414
428
  const loc = gl.getAttribLocation(prog, 'aPos');
@@ -466,25 +480,35 @@ export class EditPipeline {
466
480
  * unflipped upload.)
467
481
  */
468
482
  private toCanvas(from: Target): void {
469
- const gl = this.gl;
470
483
  if (this.outW <= 0 || this.outH <= 0) return;
471
484
  if (this.canvas.width !== this.outW || this.canvas.height !== this.outH) {
472
485
  this.canvas.width = this.outW;
473
486
  this.canvas.height = this.outH;
474
487
  }
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);
488
+ this.draw(BLIT_FRAG, from, null, this.outW, this.outH);
489
+ }
490
+
491
+ /**
492
+ * drawImage() rejects ImageData — a valid TexImageSource that texImage2D
493
+ * accepts — so stage ImageData through a 2D canvas first. Everything else
494
+ * passes through untouched. Duck-types ({width,height,data}) instead of
495
+ * instanceof so cross-realm ImageData works too.
496
+ */
497
+ public static toDrawableSource(source: TexImageSource): CanvasImageSource {
498
+ const o = source as unknown as { data?: unknown; width?: unknown; height?: unknown };
499
+ if (
500
+ typeof o.width !== 'number' ||
501
+ typeof o.height !== 'number' ||
502
+ !(o.data instanceof Uint8ClampedArray)
503
+ ) {
504
+ return source as unknown as CanvasImageSource;
505
+ }
506
+ const staged = document.createElement('canvas');
507
+ staged.width = o.width;
508
+ staged.height = o.height;
509
+ const g = staged.getContext('2d');
510
+ if (!g) throw new Error('toDrawableSource: 2D context unavailable for ImageData upload');
511
+ g.putImageData(source as ImageData, 0, 0);
512
+ return staged;
489
513
  }
490
514
  }
package/src/shaders.ts CHANGED
@@ -1,10 +1,8 @@
1
- // shaders.ts — fragment shaders for the preview pipeline.
1
+ // shaders.ts — shared GLSL for the preview pipeline (quad, copy, blit, gaussian).
2
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.
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.
8
6
 
9
7
  export const VERT = `
10
8
  attribute vec2 aPos;
@@ -29,7 +27,7 @@ void main() {
29
27
  }
30
28
  `;
31
29
 
32
- /** Final blit to the canvas (un-flip the FLIP_Y upload). */
30
+ /** Final blit to the canvas: flips Y once, compensating the unflipped upload. */
33
31
  export const BLIT_FRAG =
34
32
  PRELUDE +
35
33
  `
@@ -38,7 +36,7 @@ void main() {
38
36
  }
39
37
  `;
40
38
 
41
- /** Separable gaussian, one axis per draw (dir = (1/w,0) or (0,1/h)). */
39
+ /** Separable gaussian, one axis per draw (dir = (1/w,0) or (0,1/h)). RGB only: alpha stays. */
42
40
  export const GAUSS_FRAG =
43
41
  PRELUDE +
44
42
  `
@@ -46,14 +44,12 @@ uniform vec2 dir;
46
44
  uniform float kernel[31];
47
45
  uniform int count;
48
46
  void main() {
49
- vec4 sum = vec4(0.0);
47
+ vec3 sum = vec3(0.0);
50
48
  for (int i = 0; i < 31; i++) {
51
49
  if (i >= count) break;
52
50
  vec2 off = dir * float(i - (count - 1) / 2);
53
- sum += texture2D(texture, texCoord + off) * kernel[i];
51
+ sum += texture2D(texture, texCoord + off).rgb * kernel[i];
54
52
  }
55
- gl_FragColor = sum;
53
+ gl_FragColor = vec4(sum, texture2D(texture, texCoord).a);
56
54
  }
57
55
  `;
58
-
59
- /** glfx unsharp-mix: mix(blurred, original, 1 + strength). */
@@ -0,0 +1,120 @@
1
+ // smallPreview.ts — cheap small previews for an edit list.
2
+ //
3
+ // A SmallPreview owns one hidden WebGL canvas + EditPipeline plus a
4
+ // downscaled 2D copy of the source (longest side <= maxDim, default 512).
5
+ // setSource() downscales and uploads once; each render() then only re-runs
6
+ // the edit passes and snapshots the result into a fresh caller-owned 2D
7
+ // canvas — no re-upload, no shader recompile, no disturbance of any main
8
+ // preview canvas. Returning a snapshot (not the live canvas) keeps repeated
9
+ // renders safe to hold (e.g. a filmstrip of edit variants).
10
+ //
11
+ // Pixel radii (blur/sharpen) are in preview pixels here, so large radii look
12
+ // relatively stronger than at full res; everything else matches the main
13
+ // preview. Each instance holds one WebGL context: call dispose() when done
14
+ // (browsers cap live contexts at ~16 per page).
15
+ import { EditPipeline } from './pipeline.js';
16
+ import type { Edit } from './types.js';
17
+
18
+ export class SmallPreview {
19
+ /** Default longest side of the downscaled working copy. */
20
+ public static readonly MAX_DIM = 512;
21
+
22
+ private pipe: EditPipeline | null = null;
23
+ private view: HTMLCanvasElement | null = null;
24
+ private hasSource = false;
25
+ private readonly maxDim: number;
26
+
27
+ public constructor(maxDim: number = SmallPreview.MAX_DIM) {
28
+ if (!Number.isFinite(maxDim) || maxDim < 1)
29
+ throw new Error('SmallPreview: maxDim must be a number >= 1');
30
+ this.maxDim = Math.floor(maxDim);
31
+ }
32
+
33
+ /** Last render output size (downscaled source size when no geometric edit applies). */
34
+ public get width(): number {
35
+ return this.pipe?.width ?? 0;
36
+ }
37
+ public get height(): number {
38
+ return this.pipe?.height ?? 0;
39
+ }
40
+
41
+ /**
42
+ * Downscale `source` to <= maxDim (aspect-fit, never upscaled) through a
43
+ * 2D canvas and upload it once to the hidden pipeline. Replaces any
44
+ * previous source. Throws when WebGL/2D is unavailable.
45
+ */
46
+ public setSource(source: TexImageSource): void {
47
+ const dims = this.sourceDims(source);
48
+ if (!dims.w || !dims.h) throw new Error('setSource: source has no width/height');
49
+ // drawImage() rejects ImageData: stage it through a 2D canvas first.
50
+ const drawable = EditPipeline.toDrawableSource(source);
51
+ const longest = Math.max(dims.w, dims.h);
52
+ const scale = longest > this.maxDim ? this.maxDim / longest : 1;
53
+ const w = Math.max(1, Math.round(dims.w * scale));
54
+ const h = Math.max(1, Math.round(dims.h * scale));
55
+ const base = document.createElement('canvas');
56
+ base.width = w;
57
+ base.height = h;
58
+ const g = base.getContext('2d');
59
+ if (!g) throw new Error('setSource: 2D context unavailable for downscale');
60
+ g.imageSmoothingQuality = 'high';
61
+ g.drawImage(drawable, 0, 0, w, h);
62
+ if (!this.pipe || !this.view) {
63
+ const view = document.createElement('canvas');
64
+ this.view = view;
65
+ this.pipe = new EditPipeline(view);
66
+ }
67
+ this.pipe.setImage(base);
68
+ this.hasSource = true;
69
+ }
70
+
71
+ /**
72
+ * Render `edits` over the downscaled source and return the result as a
73
+ * fresh caller-owned 2D canvas (sized to the rendered output area).
74
+ * The hidden pipeline keeps its compiled programs between calls, so
75
+ * repeated renders only pay for the edit passes plus a small copy.
76
+ */
77
+ public render(edits: Edit[]): HTMLCanvasElement {
78
+ const pipe = this.pipe;
79
+ const view = this.view;
80
+ if (!pipe || !view || !this.hasSource)
81
+ throw new Error('render: no source (call setSource first)');
82
+ pipe.setEdits(edits);
83
+ const out = document.createElement('canvas');
84
+ out.width = pipe.width;
85
+ out.height = pipe.height;
86
+ const g = out.getContext('2d');
87
+ if (!g) throw new Error('render: 2D context unavailable for snapshot');
88
+ // preserveDrawingBuffer (set by EditPipeline) keeps this reliable.
89
+ g.drawImage(view, 0, 0);
90
+ return out;
91
+ }
92
+
93
+ /** Release the hidden WebGL context; render() throws until setSource again. */
94
+ public dispose(): void {
95
+ const view = this.view;
96
+ this.pipe = null;
97
+ this.view = null;
98
+ this.hasSource = false;
99
+ if (!view) return;
100
+ try {
101
+ // getContext('webgl') returns the existing context for this canvas.
102
+ view.getContext('webgl')?.getExtension('WEBGL_lose_context')?.loseContext();
103
+ } catch {
104
+ // Context already gone — nothing to release.
105
+ }
106
+ }
107
+
108
+ /** Pixel size of any TexImageSource upload (mirrors EditPipeline). */
109
+ private sourceDims(source: TexImageSource): { w: number; h: number } {
110
+ const anySrc = source as unknown as Record<string, unknown>;
111
+ const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
112
+ const nw = num(anySrc['naturalWidth']);
113
+ const nh = num(anySrc['naturalHeight']);
114
+ if (nw > 0 && nh > 0) return { w: nw, h: nh };
115
+ const vw = num(anySrc['videoWidth']);
116
+ const vh = num(anySrc['videoHeight']);
117
+ if (vw > 0 && vh > 0) return { w: vw, h: vh };
118
+ return { w: num(anySrc['width']), h: num(anySrc['height']) };
119
+ }
120
+ }
@@ -27,6 +27,8 @@ export interface CropEdit {
27
27
 
28
28
  type Full = Required<CropEdit>;
29
29
 
30
+ const MIN_EXTENT = 1e-4;
31
+
30
32
  /** Pixel rect for the given input size; identical to src/transforms/crop.h. */
31
33
  export function cropRect(
32
34
  inW: number,
@@ -87,23 +89,12 @@ export class CropTransform implements Transform<'crop', CropEdit> {
87
89
  }
88
90
 
89
91
  public normalize(params: CropEdit): Full {
90
- let x = clampf(0, params.x ?? 0, 1);
91
- let y = clampf(0, params.y ?? 0, 1);
92
- let w = clampf(0, params.width ?? 1, 1);
93
- let h = clampf(0, params.height ?? 1, 1);
94
- if (!(w > 0)) w = Math.min(1, Math.max(1e-4, 1 - x));
95
- else w = Math.min(w, 1 - x);
96
- if (!(h > 0)) h = Math.min(1, Math.max(1e-4, 1 - y));
97
- else h = Math.min(h, 1 - y);
98
- if (!(w > 0)) {
99
- w = 1e-4;
100
- x = 1 - w;
101
- }
102
- if (!(h > 0)) {
103
- h = 1e-4;
104
- y = 1 - h;
105
- }
106
- return { edit: 'crop', x, y, width: w, height: h };
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 };
107
98
  }
108
99
 
109
100
  public outputDims(inW: number, inH: number, params: Full): { w: number; h: number } {
@@ -0,0 +1,67 @@
1
+ // highlights.ts — boost/recover the bright end, weighted by brightness.
2
+ //
3
+ // weight w = clamp(avg,0,1)^2 (0 in blacks, 1 in whites, quadratic falloff),
4
+ // then rgb += (amount * 0.5) * w. The 0.5 keeps the gray curve monotonic over
5
+ // the whole slider range (a full-strength recovery would push white below
6
+ // near-white). Same formula in src/transforms/highlights.h and
7
+ // math.ts applyHighlights, so the canvas preview is WYSIWYG.
8
+ import { clampf } from '../math.js';
9
+ import {
10
+ isNumInRange,
11
+ setFloatUniform,
12
+ type ParamControl,
13
+ type RenderContext,
14
+ type Target,
15
+ type Transform,
16
+ } from './transform.js';
17
+
18
+ export interface HighlightsEdit {
19
+ edit: 'highlights';
20
+ /** -1 (recover highlights) .. 1 (boost highlights). Default 0. */
21
+ amount?: number;
22
+ }
23
+
24
+ type Full = Required<HighlightsEdit>;
25
+
26
+ const FRAG = `
27
+ precision mediump float;
28
+ uniform sampler2D texture;
29
+ uniform float amount;
30
+ varying vec2 texCoord;
31
+ void main() {
32
+ vec4 color = texture2D(texture, texCoord);
33
+ float avg = (color.r + color.g + color.b) / 3.0;
34
+ float l = clamp(avg, 0.0, 1.0);
35
+ float w = l * l;
36
+ color.rgb += (amount * 0.5) * w;
37
+ gl_FragColor = color;
38
+ }
39
+ `;
40
+
41
+ export class HighlightsTransform implements Transform<'highlights', HighlightsEdit> {
42
+ public readonly edit = 'highlights' as const;
43
+ public controls: ParamControl[] = [
44
+ { key: 'amount', label: 'amount', min: -1, max: 1, step: 0.01, def: 0 },
45
+ ];
46
+
47
+ public defaults(): Full {
48
+ return { edit: 'highlights', amount: 0 };
49
+ }
50
+
51
+ public validate(params: HighlightsEdit): string[] {
52
+ if (params.amount !== undefined && !isNumInRange(params.amount, -1, 1))
53
+ return ['amount must be a number in [-1,1]'];
54
+ return [];
55
+ }
56
+
57
+ public normalize(params: HighlightsEdit): Full {
58
+ return { edit: 'highlights', amount: clampf(-1, params.amount ?? 0, 1) };
59
+ }
60
+
61
+ public apply(ctx: RenderContext, from: Target, to: Target, params: Full): Target {
62
+ ctx.pass(FRAG, from, to, (gl, p) => {
63
+ setFloatUniform(gl, p, 'amount', params.amount);
64
+ });
65
+ return to;
66
+ }
67
+ }
@@ -10,13 +10,16 @@ import { BrightnessContrastTransform } from './brightnessContrast.js';
10
10
  import { CropTransform } from './crop.js';
11
11
  import { ExposureTransform } from './exposure.js';
12
12
  import { GrayscaleTransform } from './grayscale.js';
13
+ import { HighlightsTransform } from './highlights.js';
13
14
  import { HueSaturationTransform } from './hueSaturation.js';
14
15
  import { InvertTransform } from './invert.js';
15
16
  import { RotateTransform } from './rotate.js';
16
17
  import { SepiaTransform } from './sepia.js';
18
+ import { ShadowsTransform } from './shadows.js';
17
19
  import { SharpenTransform } from './sharpen.js';
18
20
  import { VibranceTransform } from './vibrance.js';
19
21
  import { VignetteTransform } from './vignette.js';
22
+ import { WarmthTransform } from './warmth.js';
20
23
 
21
24
  export const TRANSFORMS: Record<EditName, Transform<string, object>> = {
22
25
  brightnessContrast: new BrightnessContrastTransform(),
@@ -27,6 +30,9 @@ export const TRANSFORMS: Record<EditName, Transform<string, object>> = {
27
30
  blur: new BlurTransform(),
28
31
  sharpen: new SharpenTransform(),
29
32
  vignette: new VignetteTransform(),
33
+ shadows: new ShadowsTransform(),
34
+ highlights: new HighlightsTransform(),
35
+ warmth: new WarmthTransform(),
30
36
  invert: new InvertTransform(),
31
37
  grayscale: new GrayscaleTransform(),
32
38
  crop: new CropTransform(),
@@ -36,14 +42,14 @@ export const TRANSFORMS: Record<EditName, Transform<string, object>> = {
36
42
  /** Human-readable problems; empty means the edit is VIPS-appliable. */
37
43
  export function validateEdit(edit: Edit): string[] {
38
44
  if (typeof edit !== 'object' || edit === null) return ['edit must be an object'];
39
- const t = lookup(TRANSFORMS, (edit as { edit?: unknown }).edit as string);
40
- if (!t) return [`unknown edit ${(edit as { edit?: unknown }).edit as string}`];
45
+ const t = lookup(TRANSFORMS, edit.edit);
46
+ if (!t) return [`unknown edit ${String(edit.edit)}`];
41
47
  return t.validate(edit);
42
48
  }
43
49
 
44
50
  /** Fill defaults + clamp, so serialized JSON always passes validateEdit. */
45
51
  export function normalizeEdit(edit: Edit): Edit {
46
- const t = lookup(TRANSFORMS, (edit as { edit?: unknown }).edit as string);
47
- if (!t) throw new Error(`unknown edit ${(edit as { edit?: unknown }).edit as string}`);
52
+ const t = lookup(TRANSFORMS, edit.edit);
53
+ if (!t) throw new Error(`unknown edit ${String(edit.edit)}`);
48
54
  return t.normalize(edit) as Edit;
49
55
  }
@@ -1,4 +1,5 @@
1
- // sepia.ts — glfx.js sepia port.
1
+ // sepia.ts — glfx.js sepia port, minus its per-channel min(1.0): like the VIPS
2
+ // side, values stay unclipped until the end so later edits see the same input.
2
3
  import { clampf } from '../math.js';
3
4
  import {
4
5
  isNumInRange,
@@ -27,9 +28,9 @@ void main() {
27
28
  float r = color.r;
28
29
  float g = color.g;
29
30
  float b = color.b;
30
- color.r = min(1.0, (r * (1.0 - (0.607 * amount))) + (g * (0.769 * amount)) + (b * (0.189 * amount)));
31
- color.g = min(1.0, (r * 0.349 * amount) + (g * (1.0 - (0.314 * amount))) + (b * 0.168 * amount));
32
- color.b = min(1.0, (r * 0.272 * amount) + (g * 0.534 * amount) + (b * (1.0 - (0.869 * amount))));
31
+ color.r = (r * (1.0 - (0.607 * amount))) + (g * (0.769 * amount)) + (b * (0.189 * amount));
32
+ color.g = (r * 0.349 * amount) + (g * (1.0 - (0.314 * amount))) + (b * 0.168 * amount);
33
+ color.b = (r * 0.272 * amount) + (g * 0.534 * amount) + (b * (1.0 - (0.869 * amount)));
33
34
  gl_FragColor = color;
34
35
  }
35
36
  `;