@flighthq/adjustments 0.1.0

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 (95) hide show
  1. package/dist/brightnessContrastAdjustment.d.ts +3 -0
  2. package/dist/brightnessContrastAdjustment.d.ts.map +1 -0
  3. package/dist/brightnessContrastAdjustment.js +22 -0
  4. package/dist/brightnessContrastAdjustment.js.map +1 -0
  5. package/dist/channelMixerAdjustment.d.ts +3 -0
  6. package/dist/channelMixerAdjustment.d.ts.map +1 -0
  7. package/dist/channelMixerAdjustment.js +16 -0
  8. package/dist/channelMixerAdjustment.js.map +1 -0
  9. package/dist/colorAdjustmentResolution.d.ts +7 -0
  10. package/dist/colorAdjustmentResolution.d.ts.map +1 -0
  11. package/dist/colorAdjustmentResolution.js +59 -0
  12. package/dist/colorAdjustmentResolution.js.map +1 -0
  13. package/dist/colorBlindSimulationAdjustment.d.ts +3 -0
  14. package/dist/colorBlindSimulationAdjustment.d.ts.map +1 -0
  15. package/dist/colorBlindSimulationAdjustment.js +33 -0
  16. package/dist/colorBlindSimulationAdjustment.js.map +1 -0
  17. package/dist/colorGradeAdjustment.d.ts +3 -0
  18. package/dist/colorGradeAdjustment.d.ts.map +1 -0
  19. package/dist/colorGradeAdjustment.js +49 -0
  20. package/dist/colorGradeAdjustment.js.map +1 -0
  21. package/dist/colorLut.d.ts +29 -0
  22. package/dist/colorLut.d.ts.map +1 -0
  23. package/dist/colorLut.js +87 -0
  24. package/dist/colorLut.js.map +1 -0
  25. package/dist/colorLutAdjustment.d.ts +8 -0
  26. package/dist/colorLutAdjustment.d.ts.map +1 -0
  27. package/dist/colorLutAdjustment.js +30 -0
  28. package/dist/colorLutAdjustment.js.map +1 -0
  29. package/dist/colorLutCache.d.ts +6 -0
  30. package/dist/colorLutCache.d.ts.map +1 -0
  31. package/dist/colorLutCache.js +43 -0
  32. package/dist/colorLutCache.js.map +1 -0
  33. package/dist/colorMatrixAdjustment.d.ts +8 -0
  34. package/dist/colorMatrixAdjustment.d.ts.map +1 -0
  35. package/dist/colorMatrixAdjustment.js +14 -0
  36. package/dist/colorMatrixAdjustment.js.map +1 -0
  37. package/dist/colorMatrixMath.d.ts +160 -0
  38. package/dist/colorMatrixMath.d.ts.map +1 -0
  39. package/dist/colorMatrixMath.js +398 -0
  40. package/dist/colorMatrixMath.js.map +1 -0
  41. package/dist/colorTransformAdjustment.d.ts +3 -0
  42. package/dist/colorTransformAdjustment.d.ts.map +1 -0
  43. package/dist/colorTransformAdjustment.js +16 -0
  44. package/dist/colorTransformAdjustment.js.map +1 -0
  45. package/dist/exposureAdjustment.d.ts +3 -0
  46. package/dist/exposureAdjustment.d.ts.map +1 -0
  47. package/dist/exposureAdjustment.js +18 -0
  48. package/dist/exposureAdjustment.js.map +1 -0
  49. package/dist/grayscaleAdjustment.d.ts +3 -0
  50. package/dist/grayscaleAdjustment.d.ts.map +1 -0
  51. package/dist/grayscaleAdjustment.js +20 -0
  52. package/dist/grayscaleAdjustment.js.map +1 -0
  53. package/dist/hueSaturationAdjustment.d.ts +3 -0
  54. package/dist/hueSaturationAdjustment.d.ts.map +1 -0
  55. package/dist/hueSaturationAdjustment.js +62 -0
  56. package/dist/hueSaturationAdjustment.js.map +1 -0
  57. package/dist/index.d.ts +19 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +19 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/invertAdjustment.d.ts +3 -0
  62. package/dist/invertAdjustment.d.ts.map +1 -0
  63. package/dist/invertAdjustment.js +17 -0
  64. package/dist/invertAdjustment.js.map +1 -0
  65. package/dist/liftGammaGainAdjustment.d.ts +3 -0
  66. package/dist/liftGammaGainAdjustment.d.ts.map +1 -0
  67. package/dist/liftGammaGainAdjustment.js +29 -0
  68. package/dist/liftGammaGainAdjustment.js.map +1 -0
  69. package/dist/lookupTableGradeAdjustment.d.ts +3 -0
  70. package/dist/lookupTableGradeAdjustment.d.ts.map +1 -0
  71. package/dist/lookupTableGradeAdjustment.js +24 -0
  72. package/dist/lookupTableGradeAdjustment.js.map +1 -0
  73. package/dist/sepiaAdjustment.d.ts +3 -0
  74. package/dist/sepiaAdjustment.d.ts.map +1 -0
  75. package/dist/sepiaAdjustment.js +15 -0
  76. package/dist/sepiaAdjustment.js.map +1 -0
  77. package/package.json +37 -0
  78. package/src/brightnessContrastAdjustment.test.ts +23 -0
  79. package/src/channelMixerAdjustment.test.ts +24 -0
  80. package/src/colorAdjustmentResolution.test.ts +77 -0
  81. package/src/colorBlindSimulationAdjustment.test.ts +27 -0
  82. package/src/colorGradeAdjustment.test.ts +36 -0
  83. package/src/colorLut.test.ts +62 -0
  84. package/src/colorLutAdjustment.test.ts +37 -0
  85. package/src/colorLutCache.test.ts +61 -0
  86. package/src/colorMatrixAdjustment.test.ts +24 -0
  87. package/src/colorMatrixMath.test.ts +431 -0
  88. package/src/colorTransformAdjustment.test.ts +40 -0
  89. package/src/exposureAdjustment.test.ts +22 -0
  90. package/src/grayscaleAdjustment.test.ts +23 -0
  91. package/src/hueSaturationAdjustment.test.ts +33 -0
  92. package/src/invertAdjustment.test.ts +24 -0
  93. package/src/liftGammaGainAdjustment.test.ts +31 -0
  94. package/src/lookupTableGradeAdjustment.test.ts +49 -0
  95. package/src/sepiaAdjustment.test.ts +18 -0
@@ -0,0 +1,61 @@
1
+ import { createColorGradeAdjustment } from './colorGradeAdjustment';
2
+ import { bakeColorLut } from './colorLut';
3
+ import { getAdjustmentColorTransform } from './colorLutAdjustment';
4
+ import { bakeColorLutForRun, createColorLutCache } from './colorLutCache';
5
+ import { createHueSaturationAdjustment } from './hueSaturationAdjustment';
6
+
7
+ describe('bakeColorLutForRun', () => {
8
+ it('reuses the same ColorLut reference for a content-identical run rebuilt with fresh objects', () => {
9
+ const cache = createColorLutCache();
10
+ // The run is rebuilt each frame with fresh descriptors and fresh transform closures — reference
11
+ // identity differs, but the content signature is equal, so the bake is reused by identity.
12
+ const first = bakeColorLutForRun(cache, [createHueSaturationAdjustment({ hue: 30, saturation: 0.5 })], 8);
13
+ const second = bakeColorLutForRun(cache, [createHueSaturationAdjustment({ hue: 30, saturation: 0.5 })], 8);
14
+ expect(second).toBe(first);
15
+ expect(cache.lut).toBe(first);
16
+ expect(cache.signature).not.toBeNull();
17
+ });
18
+
19
+ it('re-bakes into a new LUT when an op param changes', () => {
20
+ const cache = createColorLutCache();
21
+ const first = bakeColorLutForRun(cache, [createHueSaturationAdjustment({ saturation: 0.4 })], 8);
22
+ const changed = bakeColorLutForRun(cache, [createHueSaturationAdjustment({ saturation: 0.8 })], 8);
23
+ expect(changed).not.toBe(first);
24
+ expect(changed.samples).not.toEqual(first.samples);
25
+ });
26
+
27
+ it('re-keys when the LUT size changes', () => {
28
+ const cache = createColorLutCache();
29
+ const small = bakeColorLutForRun(cache, [createHueSaturationAdjustment({ saturation: 0.4 })], 8);
30
+ const large = bakeColorLutForRun(cache, [createHueSaturationAdjustment({ saturation: 0.4 })], 16);
31
+ expect(large).not.toBe(small);
32
+ expect(large.size).toBe(16);
33
+ });
34
+
35
+ it('bakes byte-for-byte the same samples as the uncached bakeColorLut', () => {
36
+ const cache = createColorLutCache();
37
+ const run = [createHueSaturationAdjustment({ hue: 45 }), createColorGradeAdjustment({ contrast: 1.2 })];
38
+ const cached = bakeColorLutForRun(cache, run, 8);
39
+ const uncached = bakeColorLut(
40
+ run.map((op) => getAdjustmentColorTransform(op)!),
41
+ 8,
42
+ );
43
+ expect(cached.size).toBe(uncached.size);
44
+ expect(cached.samples).toEqual(uncached.samples);
45
+ });
46
+
47
+ it('bakes an empty run into an identity LUT and caches it', () => {
48
+ const cache = createColorLutCache();
49
+ const lut = bakeColorLutForRun(cache, [], 4);
50
+ expect(lut.size).toBe(4);
51
+ expect(bakeColorLutForRun(cache, [], 4)).toBe(lut);
52
+ });
53
+ });
54
+
55
+ describe('createColorLutCache', () => {
56
+ it('starts empty', () => {
57
+ const cache = createColorLutCache();
58
+ expect(cache.signature).toBeNull();
59
+ expect(cache.lut).toBeNull();
60
+ });
61
+ });
@@ -0,0 +1,24 @@
1
+ import { getAdjustmentColorMatrix, isColorMatrixAdjustment } from './colorMatrixAdjustment';
2
+ import { createInvertAdjustment } from './invertAdjustment';
3
+
4
+ describe('getAdjustmentColorMatrix', () => {
5
+ it('returns the matrix for a matrix-tier adjustment', () => {
6
+ const adjustment = createInvertAdjustment();
7
+ expect(getAdjustmentColorMatrix(adjustment)).toBe(adjustment.colorMatrix);
8
+ });
9
+
10
+ it('returns null for a spatial/composite effect (no colorMatrix)', () => {
11
+ expect(getAdjustmentColorMatrix({ kind: 'BlurEffect' })).toBeNull();
12
+ });
13
+
14
+ it('returns null for a malformed colorMatrix', () => {
15
+ expect(getAdjustmentColorMatrix({ kind: 'acme.Bad', colorMatrix: [1, 2, 3] } as never)).toBeNull();
16
+ });
17
+ });
18
+
19
+ describe('isColorMatrixAdjustment', () => {
20
+ it('is true for a matrix-tier adjustment and false for an effect', () => {
21
+ expect(isColorMatrixAdjustment(createInvertAdjustment())).toBe(true);
22
+ expect(isColorMatrixAdjustment({ kind: 'BlurEffect' })).toBe(false);
23
+ });
24
+ });
@@ -0,0 +1,431 @@
1
+ import {
2
+ applyColorMatrixToColor,
3
+ COLOR_MATRIX_LENGTH,
4
+ concatColorMatrix,
5
+ createBrightnessColorMatrix,
6
+ createChannelMixerColorMatrix,
7
+ createColorBalanceColorMatrix,
8
+ createColorMatrixFromTint,
9
+ createContrastColorMatrix,
10
+ createDesaturateColorMatrix,
11
+ createGrayscaleColorMatrix,
12
+ createHueRotateColorMatrix,
13
+ createIdentityColorMatrix,
14
+ createInvertColorMatrix,
15
+ createLevelsColorMatrix,
16
+ createOpacityColorMatrix,
17
+ createPolaroidColorMatrix,
18
+ createSaturationColorMatrix,
19
+ createSepiaColorMatrix,
20
+ createTechnicolorColorMatrix,
21
+ createVintageColorMatrix,
22
+ createWhiteBalanceColorMatrix,
23
+ fuseColorMatrices,
24
+ multiplyColorMatrix,
25
+ } from './colorMatrixMath';
26
+
27
+ const WHITE = 0xffffffff;
28
+ const BLACK = 0x000000ff;
29
+ const RED = 0xff0000ff;
30
+ const GREEN = 0x00ff00ff;
31
+
32
+ describe('applyColorMatrixToColor', () => {
33
+ it('identity matrix leaves color unchanged', () => {
34
+ const identity = createIdentityColorMatrix();
35
+ expect(applyColorMatrixToColor(identity, RED)).toBe(RED);
36
+ expect(applyColorMatrixToColor(identity, GREEN)).toBe(GREEN);
37
+ expect(applyColorMatrixToColor(identity, WHITE)).toBe(WHITE);
38
+ });
39
+
40
+ it('invert matrix inverts RGB channels', () => {
41
+ const inv = createInvertColorMatrix();
42
+ // 0xff0000ff → 0x00ffffff
43
+ expect(applyColorMatrixToColor(inv, 0xff0000ff)).toBe(0x00ffffff);
44
+ });
45
+
46
+ it('grayscale matrix produces equal R/G/B', () => {
47
+ const gray = createGrayscaleColorMatrix();
48
+ const result = applyColorMatrixToColor(gray, RED);
49
+ const r = (result >>> 24) & 0xff;
50
+ const g = (result >>> 16) & 0xff;
51
+ const b = (result >>> 8) & 0xff;
52
+ expect(r).toBe(g);
53
+ expect(g).toBe(b);
54
+ });
55
+ });
56
+
57
+ describe('COLOR_MATRIX_LENGTH', () => {
58
+ it('is 20', () => {
59
+ expect(COLOR_MATRIX_LENGTH).toBe(20);
60
+ });
61
+ });
62
+
63
+ describe('concatColorMatrix', () => {
64
+ it('applying identity leaves matrix unchanged', () => {
65
+ const m = createGrayscaleColorMatrix();
66
+ const original = m.slice();
67
+ concatColorMatrix(m, createIdentityColorMatrix());
68
+ expect(m).toEqual(original);
69
+ });
70
+
71
+ it('is alias-safe when target === source', () => {
72
+ const m = createIdentityColorMatrix();
73
+ const original = m.slice();
74
+ concatColorMatrix(m, m);
75
+ // identity ∘ identity = identity
76
+ expect(m).toEqual(original);
77
+ });
78
+ });
79
+
80
+ describe('createBrightnessColorMatrix', () => {
81
+ it('returns a 20-element array', () => {
82
+ expect(createBrightnessColorMatrix(50)).toHaveLength(COLOR_MATRIX_LENGTH);
83
+ });
84
+
85
+ it('brightens black to grey', () => {
86
+ const m = createBrightnessColorMatrix(128);
87
+ const result = applyColorMatrixToColor(m, BLACK);
88
+ const r = (result >>> 24) & 0xff;
89
+ expect(r).toBe(128);
90
+ });
91
+ });
92
+
93
+ describe('createChannelMixerColorMatrix', () => {
94
+ it('returns a 20-element array', () => {
95
+ expect(createChannelMixerColorMatrix([1, 0, 0], [0, 1, 0], [0, 0, 1])).toHaveLength(COLOR_MATRIX_LENGTH);
96
+ });
97
+
98
+ it('identity mix leaves color unchanged', () => {
99
+ const m = createChannelMixerColorMatrix([1, 0, 0], [0, 1, 0], [0, 0, 1]);
100
+ const identity = createIdentityColorMatrix();
101
+ for (let i = 0; i < 20; i++) {
102
+ expect(m[i]).toBeCloseTo(identity[i]);
103
+ }
104
+ });
105
+
106
+ it('swap R and G channels', () => {
107
+ const m = createChannelMixerColorMatrix([0, 1, 0], [1, 0, 0], [0, 0, 1]);
108
+ // Apply to 0xff0000ff (pure red) — should output pure green
109
+ const result = applyColorMatrixToColor(m, 0xff0000ff);
110
+ const r = (result >>> 24) & 0xff;
111
+ const g = (result >>> 16) & 0xff;
112
+ expect(r).toBe(0);
113
+ expect(g).toBe(255);
114
+ });
115
+ });
116
+
117
+ describe('createColorBalanceColorMatrix', () => {
118
+ it('returns a 20-element array', () => {
119
+ expect(createColorBalanceColorMatrix([0, 0, 0], [0, 0, 0], [0, 0, 0])).toHaveLength(COLOR_MATRIX_LENGTH);
120
+ });
121
+
122
+ it('all-zero offsets is identity', () => {
123
+ const m = createColorBalanceColorMatrix([0, 0, 0], [0, 0, 0], [0, 0, 0]);
124
+ const identity = createIdentityColorMatrix();
125
+ for (let i = 0; i < 20; i++) {
126
+ expect(m[i]).toBeCloseTo(identity[i]);
127
+ }
128
+ });
129
+
130
+ it('positive red midtone raises red channel offset', () => {
131
+ const m = createColorBalanceColorMatrix([0, 0, 0], [100, 0, 0], [0, 0, 0]);
132
+ // Red offset should be positive (midtone weight 0.5 * 255 = 127.5)
133
+ expect(m[4]).toBeCloseTo(127.5, 0);
134
+ expect(m[9]).toBeCloseTo(0, 5); // green unaffected
135
+ expect(m[14]).toBeCloseTo(0, 5); // blue unaffected
136
+ });
137
+ });
138
+
139
+ describe('createColorMatrixFromTint', () => {
140
+ it('returns a 20-element array', () => {
141
+ expect(createColorMatrixFromTint(0xff0000ff, 0.5)).toHaveLength(COLOR_MATRIX_LENGTH);
142
+ });
143
+
144
+ it('amount=0 is identity', () => {
145
+ const m = createColorMatrixFromTint(0xff0000ff, 0);
146
+ const identity = createIdentityColorMatrix();
147
+ for (let i = 0; i < 20; i++) {
148
+ expect(m[i]).toBeCloseTo(identity[i]);
149
+ }
150
+ });
151
+ });
152
+
153
+ describe('createContrastColorMatrix', () => {
154
+ it('returns a 20-element array', () => {
155
+ expect(createContrastColorMatrix(1.5)).toHaveLength(COLOR_MATRIX_LENGTH);
156
+ });
157
+
158
+ it('amount=1 is identity', () => {
159
+ const m = createContrastColorMatrix(1);
160
+ const identity = createIdentityColorMatrix();
161
+ for (let i = 0; i < 20; i++) {
162
+ expect(m[i]).toBeCloseTo(identity[i]);
163
+ }
164
+ });
165
+ });
166
+
167
+ describe('createDesaturateColorMatrix', () => {
168
+ it('amount=1 produces a grayscale matrix', () => {
169
+ const desaturate = createDesaturateColorMatrix(1);
170
+ const grayscale = createGrayscaleColorMatrix();
171
+ for (let i = 0; i < 20; i++) {
172
+ expect(desaturate[i]).toBeCloseTo(grayscale[i]);
173
+ }
174
+ });
175
+
176
+ it('amount=0 is identity', () => {
177
+ const m = createDesaturateColorMatrix(0);
178
+ const identity = createIdentityColorMatrix();
179
+ for (let i = 0; i < 20; i++) {
180
+ expect(m[i]).toBeCloseTo(identity[i]);
181
+ }
182
+ });
183
+ });
184
+
185
+ describe('createGrayscaleColorMatrix', () => {
186
+ it('returns a 20-element array', () => {
187
+ expect(createGrayscaleColorMatrix()).toHaveLength(COLOR_MATRIX_LENGTH);
188
+ });
189
+
190
+ it('luma row sums to 1', () => {
191
+ const m = createGrayscaleColorMatrix();
192
+ expect(m[0] + m[1] + m[2]).toBeCloseTo(1);
193
+ });
194
+ });
195
+
196
+ describe('createHueRotateColorMatrix', () => {
197
+ it('returns a 20-element array', () => {
198
+ expect(createHueRotateColorMatrix(90)).toHaveLength(COLOR_MATRIX_LENGTH);
199
+ });
200
+
201
+ it('0 degrees is close to identity', () => {
202
+ const m = createHueRotateColorMatrix(0);
203
+ const identity = createIdentityColorMatrix();
204
+ for (let i = 0; i < 20; i++) {
205
+ expect(m[i]).toBeCloseTo(identity[i]);
206
+ }
207
+ });
208
+
209
+ it('360 degrees is close to identity', () => {
210
+ const m = createHueRotateColorMatrix(360);
211
+ const identity = createIdentityColorMatrix();
212
+ for (let i = 0; i < 20; i++) {
213
+ expect(m[i]).toBeCloseTo(identity[i]);
214
+ }
215
+ });
216
+ });
217
+
218
+ describe('createIdentityColorMatrix', () => {
219
+ it('returns a 20-element array', () => {
220
+ expect(createIdentityColorMatrix()).toHaveLength(COLOR_MATRIX_LENGTH);
221
+ });
222
+
223
+ it('leaves red unchanged', () => {
224
+ expect(applyColorMatrixToColor(createIdentityColorMatrix(), RED)).toBe(RED);
225
+ });
226
+ });
227
+
228
+ describe('createInvertColorMatrix', () => {
229
+ it('returns a 20-element array', () => {
230
+ expect(createInvertColorMatrix()).toHaveLength(COLOR_MATRIX_LENGTH);
231
+ });
232
+
233
+ it('inverts white to black (RGB only, alpha unchanged)', () => {
234
+ const result = applyColorMatrixToColor(createInvertColorMatrix(), 0xffffff00);
235
+ expect((result >>> 24) & 0xff).toBe(0);
236
+ expect((result >>> 16) & 0xff).toBe(0);
237
+ expect((result >>> 8) & 0xff).toBe(0);
238
+ });
239
+ });
240
+
241
+ describe('createLevelsColorMatrix', () => {
242
+ it('returns a 20-element array', () => {
243
+ expect(createLevelsColorMatrix(0, 255, 0, 255)).toHaveLength(COLOR_MATRIX_LENGTH);
244
+ });
245
+
246
+ it('full range with gamma=1 is identity', () => {
247
+ const m = createLevelsColorMatrix(0, 255, 0, 255, 1);
248
+ const identity = createIdentityColorMatrix();
249
+ for (let i = 0; i < 20; i++) {
250
+ expect(m[i]).toBeCloseTo(identity[i]);
251
+ }
252
+ });
253
+
254
+ it('clips blacks: inBlack=128 shifts scale', () => {
255
+ const m = createLevelsColorMatrix(128, 255, 0, 255);
256
+ // scale = 255 / (255-128) ≈ 2.008; offset = 0 - 128 * 2.008 ≈ -257
257
+ expect(m[0]).toBeGreaterThan(1); // amplified
258
+ });
259
+ });
260
+
261
+ describe('createOpacityColorMatrix', () => {
262
+ it('returns a 20-element array', () => {
263
+ expect(createOpacityColorMatrix(0.5)).toHaveLength(COLOR_MATRIX_LENGTH);
264
+ });
265
+
266
+ it('scales alpha channel', () => {
267
+ const m = createOpacityColorMatrix(0.5);
268
+ // white fully opaque (alpha=255) → alpha should be ~128
269
+ const result = applyColorMatrixToColor(m, WHITE);
270
+ const a = result & 0xff;
271
+ expect(a).toBeCloseTo(128, 0);
272
+ });
273
+ });
274
+
275
+ describe('createPolaroidColorMatrix', () => {
276
+ it('returns a 20-element array', () => {
277
+ expect(createPolaroidColorMatrix()).toHaveLength(COLOR_MATRIX_LENGTH);
278
+ });
279
+ });
280
+
281
+ describe('createSaturationColorMatrix', () => {
282
+ it('returns a 20-element array', () => {
283
+ expect(createSaturationColorMatrix(0.5)).toHaveLength(COLOR_MATRIX_LENGTH);
284
+ });
285
+
286
+ it('saturation=1 is identity', () => {
287
+ const m = createSaturationColorMatrix(1);
288
+ const identity = createIdentityColorMatrix();
289
+ for (let i = 0; i < 20; i++) {
290
+ expect(m[i]).toBeCloseTo(identity[i]);
291
+ }
292
+ });
293
+
294
+ it('saturation=0 matches grayscale matrix', () => {
295
+ const sat0 = createSaturationColorMatrix(0);
296
+ const gray = createGrayscaleColorMatrix();
297
+ for (let i = 0; i < 20; i++) {
298
+ expect(sat0[i]).toBeCloseTo(gray[i]);
299
+ }
300
+ });
301
+ });
302
+
303
+ describe('createSepiaColorMatrix', () => {
304
+ it('returns a 20-element array', () => {
305
+ expect(createSepiaColorMatrix()).toHaveLength(COLOR_MATRIX_LENGTH);
306
+ });
307
+ });
308
+
309
+ describe('createTechnicolorColorMatrix', () => {
310
+ it('returns a 20-element array', () => {
311
+ expect(createTechnicolorColorMatrix()).toHaveLength(COLOR_MATRIX_LENGTH);
312
+ });
313
+ });
314
+
315
+ describe('createVintageColorMatrix', () => {
316
+ it('returns a 20-element array', () => {
317
+ expect(createVintageColorMatrix()).toHaveLength(COLOR_MATRIX_LENGTH);
318
+ });
319
+ });
320
+
321
+ describe('createWhiteBalanceColorMatrix', () => {
322
+ it('returns a 20-element array', () => {
323
+ expect(createWhiteBalanceColorMatrix(0, 0)).toHaveLength(COLOR_MATRIX_LENGTH);
324
+ });
325
+
326
+ it('neutral (0, 0) is identity', () => {
327
+ const m = createWhiteBalanceColorMatrix(0, 0);
328
+ const identity = createIdentityColorMatrix();
329
+ for (let i = 0; i < 20; i++) {
330
+ expect(m[i]).toBeCloseTo(identity[i]);
331
+ }
332
+ });
333
+
334
+ it('warm temperature (positive) increases red gain and decreases blue gain', () => {
335
+ const m = createWhiteBalanceColorMatrix(50, 0);
336
+ // m[0] = R diagonal (index 0), m[12] = B diagonal (index 2*5+2)
337
+ expect(m[0]).toBeGreaterThan(1); // R gain > 1
338
+ expect(m[12]).toBeLessThan(1); // B gain < 1
339
+ });
340
+
341
+ it('cool temperature (negative) decreases red gain and increases blue gain', () => {
342
+ const m = createWhiteBalanceColorMatrix(-50, 0);
343
+ // m[0] = R diagonal (index 0), m[12] = B diagonal (index 2*5+2)
344
+ expect(m[0]).toBeLessThan(1); // R gain < 1
345
+ expect(m[12]).toBeGreaterThan(1); // B gain > 1
346
+ });
347
+
348
+ it('positive tint increases green gain', () => {
349
+ const m = createWhiteBalanceColorMatrix(0, 100);
350
+ // m[6] = G diagonal (index 1*5+1)
351
+ expect(m[6]).toBeGreaterThan(1); // G gain > 1
352
+ });
353
+ });
354
+
355
+ describe('fuseColorMatrices', () => {
356
+ it('empty list yields the identity', () => {
357
+ expect(fuseColorMatrices([])).toEqual(createIdentityColorMatrix());
358
+ });
359
+
360
+ it('single matrix is returned as an independent copy', () => {
361
+ const invert = createInvertColorMatrix();
362
+ const fused = fuseColorMatrices([invert]);
363
+ expect(fused).toEqual(invert);
364
+ expect(fused).not.toBe(invert);
365
+ });
366
+
367
+ it('fuses a run of three in application order (index 0 first)', () => {
368
+ const a = createInvertColorMatrix();
369
+ const b = createGrayscaleColorMatrix();
370
+ const c = createContrastColorMatrix(1.5);
371
+ // Applying `a` then `b` then `c` = c × b × a as a single composed matrix (no per-stage rounding).
372
+ const expected = multiplyColorMatrix(c, multiplyColorMatrix(b, a));
373
+ const fused = fuseColorMatrices([a, b, c]);
374
+ for (let i = 0; i < 20; i++) expect(fused[i]).toBeCloseTo(expected[i], 10);
375
+ });
376
+
377
+ it('order matters (composition is not commutative)', () => {
378
+ const invert = createInvertColorMatrix();
379
+ const sepia = createSepiaColorMatrix();
380
+ const forward = fuseColorMatrices([invert, sepia]);
381
+ const reversed = fuseColorMatrices([sepia, invert]);
382
+ expect(applyColorMatrixToColor(forward, RED)).not.toBe(applyColorMatrixToColor(reversed, RED));
383
+ });
384
+
385
+ it('does not mutate its inputs', () => {
386
+ const a = createInvertColorMatrix();
387
+ const b = createSepiaColorMatrix();
388
+ const aCopy = a.slice();
389
+ const bCopy = b.slice();
390
+ fuseColorMatrices([a, b]);
391
+ expect(a).toEqual(aCopy);
392
+ expect(b).toEqual(bCopy);
393
+ });
394
+ });
395
+
396
+ describe('multiplyColorMatrix', () => {
397
+ it('identity ∘ identity = identity', () => {
398
+ const identity = createIdentityColorMatrix();
399
+ const result = multiplyColorMatrix(identity, identity);
400
+ for (let i = 0; i < 20; i++) {
401
+ expect(result[i]).toBeCloseTo(identity[i]);
402
+ }
403
+ });
404
+
405
+ it('writes to out when provided', () => {
406
+ const identity = createIdentityColorMatrix();
407
+ const out = new Array(20).fill(0);
408
+ const returned = multiplyColorMatrix(identity, identity, out);
409
+ expect(returned).toBe(out);
410
+ });
411
+
412
+ it('is alias-safe when out === a', () => {
413
+ const a = createGrayscaleColorMatrix();
414
+ const b = createIdentityColorMatrix();
415
+ const expected = multiplyColorMatrix(a, b);
416
+ multiplyColorMatrix(a, b, a);
417
+ for (let i = 0; i < 20; i++) {
418
+ expect(a[i]).toBeCloseTo(expected[i]);
419
+ }
420
+ });
421
+
422
+ it('is alias-safe when out === b', () => {
423
+ const a = createIdentityColorMatrix();
424
+ const b = createGrayscaleColorMatrix();
425
+ const expected = multiplyColorMatrix(a, b);
426
+ multiplyColorMatrix(a, b, b);
427
+ for (let i = 0; i < 20; i++) {
428
+ expect(b[i]).toBeCloseTo(expected[i]);
429
+ }
430
+ });
431
+ });
@@ -0,0 +1,40 @@
1
+ import type { ColorTransform } from '@flighthq/types';
2
+
3
+ import { applyColorMatrixToColor } from './colorMatrixMath';
4
+ import { createColorTransformAdjustment } from './colorTransformAdjustment';
5
+
6
+ function makeColorTransform(fields: Partial<ColorTransform>): ColorTransform {
7
+ return {
8
+ redMultiplier: 1,
9
+ greenMultiplier: 1,
10
+ blueMultiplier: 1,
11
+ alphaMultiplier: 1,
12
+ redOffset: 0,
13
+ greenOffset: 0,
14
+ blueOffset: 0,
15
+ alphaOffset: 0,
16
+ ...fields,
17
+ } as ColorTransform;
18
+ }
19
+
20
+ describe('createColorTransformAdjustment', () => {
21
+ it('carries the ColorTransform payload and the ColorTransformAdjustment kind', () => {
22
+ const colorTransform = makeColorTransform({ redMultiplier: 0.5 });
23
+ const adjustment = createColorTransformAdjustment(colorTransform);
24
+ expect(adjustment.kind).toBe('ColorTransformAdjustment');
25
+ expect(adjustment.colorTransform).toBe(colorTransform);
26
+ });
27
+
28
+ it('bakes a diagonal-affine 4×5 matrix so white tints to red', () => {
29
+ const adjustment = createColorTransformAdjustment(
30
+ makeColorTransform({ redMultiplier: 1, greenMultiplier: 0, blueMultiplier: 0 }),
31
+ );
32
+ expect(applyColorMatrixToColor(adjustment.colorMatrix as number[], 0xffffffff)).toBe(0xff0000ff);
33
+ });
34
+
35
+ it('bakes offsets in the 0–255 column', () => {
36
+ const adjustment = createColorTransformAdjustment(makeColorTransform({ redOffset: 40 }));
37
+ // Red channel 0x10 (16) + 40 = 56 = 0x38; other channels unchanged.
38
+ expect(applyColorMatrixToColor(adjustment.colorMatrix as number[], 0x102030ff)).toBe(0x382030ff);
39
+ });
40
+ });
@@ -0,0 +1,22 @@
1
+ import { applyColorMatrixToColor } from './colorMatrixMath';
2
+ import { createExposureAdjustment } from './exposureAdjustment';
3
+
4
+ describe('createExposureAdjustment', () => {
5
+ it('defaults to the identity (exposure 0)', () => {
6
+ const adjustment = createExposureAdjustment();
7
+ expect(adjustment.kind).toBe('ExposureAdjustment');
8
+ expect(applyColorMatrixToColor(adjustment.colorMatrix as number[], 0x204060ff)).toBe(0x204060ff);
9
+ });
10
+
11
+ it('scales RGB by 2^exposure, preserving alpha', () => {
12
+ const adjustment = createExposureAdjustment({ exposure: 1 });
13
+ // Each channel doubles; alpha is untouched.
14
+ expect(applyColorMatrixToColor(adjustment.colorMatrix as number[], 0x204060ff)).toBe(0x4080c0ff);
15
+ });
16
+
17
+ it('clamps to SDR [0,1] for the default pipeline', () => {
18
+ const adjustment = createExposureAdjustment({ exposure: 1 });
19
+ // 0x80 doubled is 256 → clamps to 255.
20
+ expect(applyColorMatrixToColor(adjustment.colorMatrix as number[], 0x808080ff)).toBe(0xffffffff);
21
+ });
22
+ });
@@ -0,0 +1,23 @@
1
+ import { applyColorMatrixToColor } from './colorMatrixMath';
2
+ import { createGrayscaleAdjustment } from './grayscaleAdjustment';
3
+
4
+ describe('createGrayscaleAdjustment', () => {
5
+ it('defaults to full BT.709 luma desaturation', () => {
6
+ const adjustment = createGrayscaleAdjustment();
7
+ expect(adjustment.kind).toBe('GrayscaleAdjustment');
8
+ // Pure red → luma 0.2126·255 ≈ 54, equal across channels; alpha preserved.
9
+ const out = applyColorMatrixToColor(adjustment.colorMatrix as number[], 0xff0000ff);
10
+ const r = (out >>> 24) & 0xff;
11
+ const g = (out >>> 16) & 0xff;
12
+ const b = (out >>> 8) & 0xff;
13
+ expect(r).toBe(g);
14
+ expect(g).toBe(b);
15
+ expect(r).toBe(Math.round(0.2126 * 255));
16
+ expect(out & 0xff).toBe(0xff);
17
+ });
18
+
19
+ it('intensity 0 leaves color unchanged', () => {
20
+ const adjustment = createGrayscaleAdjustment({ intensity: 0 });
21
+ expect(applyColorMatrixToColor(adjustment.colorMatrix as number[], 0x2040a0ff)).toBe(0x2040a0ff);
22
+ });
23
+ });
@@ -0,0 +1,33 @@
1
+ import { createHueSaturationAdjustment } from './hueSaturationAdjustment';
2
+
3
+ describe('createHueSaturationAdjustment', () => {
4
+ it('defaults to the identity and carries a fusable transform', () => {
5
+ const adjustment = createHueSaturationAdjustment();
6
+ expect(adjustment.kind).toBe('HueSaturationAdjustment');
7
+ expect(typeof adjustment.transform).toBe('function');
8
+ const out: [number, number, number] = [0, 0, 0];
9
+ adjustment.transform(out, 0.2, 0.4, 0.6);
10
+ expect(out[0]).toBeCloseTo(0.2, 4);
11
+ expect(out[1]).toBeCloseTo(0.4, 4);
12
+ expect(out[2]).toBeCloseTo(0.6, 4);
13
+ });
14
+
15
+ it('fully desaturates to luma-preserving grey at saturation 0', () => {
16
+ const adjustment = createHueSaturationAdjustment({ saturation: 0 });
17
+ const out: [number, number, number] = [0, 0, 0];
18
+ adjustment.transform(out, 1, 0, 0);
19
+ // HSL saturation 0 collapses to lightness grey (mid of max/min = 0.5).
20
+ expect(out[0]).toBeCloseTo(0.5, 4);
21
+ expect(out[0]).toBeCloseTo(out[1], 4);
22
+ expect(out[1]).toBeCloseTo(out[2], 4);
23
+ });
24
+
25
+ it('rotates hue by 120° mapping red toward green', () => {
26
+ const adjustment = createHueSaturationAdjustment({ hue: 120 });
27
+ const out: [number, number, number] = [0, 0, 0];
28
+ adjustment.transform(out, 1, 0, 0);
29
+ expect(out[1]).toBeCloseTo(1, 4);
30
+ expect(out[0]).toBeCloseTo(0, 4);
31
+ expect(out[2]).toBeCloseTo(0, 4);
32
+ });
33
+ });
@@ -0,0 +1,24 @@
1
+ import { applyColorMatrixToColor } from './colorMatrixMath';
2
+ import { createInvertAdjustment } from './invertAdjustment';
3
+
4
+ describe('createInvertAdjustment', () => {
5
+ it('defaults to a full invert and carries the fusable kind', () => {
6
+ const adjustment = createInvertAdjustment();
7
+ expect(adjustment.kind).toBe('InvertAdjustment');
8
+ expect(adjustment.colorMatrix).toHaveLength(20);
9
+ // Opaque mid-grey inverts to itself; pure red inverts to cyan. Alpha is preserved.
10
+ expect(applyColorMatrixToColor(adjustment.colorMatrix as number[], 0x808080ff)).toBe(0x7f7f7fff);
11
+ expect(applyColorMatrixToColor(adjustment.colorMatrix as number[], 0xff0000ff)).toBe(0x00ffffff);
12
+ });
13
+
14
+ it('intensity 0 is the identity', () => {
15
+ const adjustment = createInvertAdjustment({ intensity: 0 });
16
+ expect(applyColorMatrixToColor(adjustment.colorMatrix as number[], 0x123456ff)).toBe(0x123456ff);
17
+ });
18
+
19
+ it('intensity 0.5 collapses toward mid-grey', () => {
20
+ const adjustment = createInvertAdjustment({ intensity: 0.5 });
21
+ // scale 0, offset 127.5 → every channel rounds to 128.
22
+ expect(applyColorMatrixToColor(adjustment.colorMatrix as number[], 0x00ffccff)).toBe(0x808080ff);
23
+ });
24
+ });