-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathTransform.js
More file actions
9516 lines (8676 loc) · 449 KB
/
Copy pathTransform.js
File metadata and controls
9516 lines (8676 loc) · 449 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*************************************************************************
* @license
*
* Copyright © 2019, 2026 Glenn Wilton
* O2 Creative Limited
* www.o2creative.co.nz
* support@o2creative.co.nz
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
*/
'use strict';
var Profile = require('./Profile');
var convert = require('./convert');
var defs = require('./def');
var wasmLifecycle = require('./kernels/wasmLifecycle');
var _pool = require('./pool.js');
var eIntent = defs.eIntent;
var eProfileType = defs.eProfileType;
var eColourType = defs.eColourType;
var illuminant = defs.illuminant;
var encoding = defs.encoding;
var encodingStr = defs.encodingStr;
/**
* ============================================================================
* Transform — the colour-conversion engine
* ============================================================================
*
* A Transform takes 2+ Profiles and an intent, builds a pipeline of stages
* between them, and (optionally) bakes that pipeline into a CLUT for very
* fast image conversion.
*
* The class deliberately exposes TWO PARALLEL EXECUTION PATHS with
* different design priorities. Picking the wrong one will give you 30x
* worse throughput or 30x worse accuracy. Read this section first.
*
* ----------------------------------------------------------------------------
* USAGE GUIDE — pick the right entry point for your workload
* ----------------------------------------------------------------------------
*
* 1. SINGLE COLOURS (colour pickers, ΔE, swatch soft-proof, analysis)
* Accuracy-first path. ~µs per call. Allocations & per-stage dispatch
* are fine here. Custom stages and pipelineDebug are only meaningful
* on this path.
*
* new Transform({ dataFormat: 'object' })
* .create(srcProfile, dstProfile, eIntent.relative);
* var lab = transform.transform(color.RGB(255, 0, 0));
*
*
* 2. MANY COLOURS / IMAGE DATA
* `transform.array(...)` is the batch entry. Native units; the
* container matches `dataFormat`:
*
* int8 Uint8ClampedArray (0–255)
* int16 Uint16Array (0–65535)
* device Array of floats (0–1)
* object Array of colour objects ({R,G,B} / {C,M,Y,K} / …)
* objectFloat Array of float colour objects ({Rf,Gf,Bf} / …)
*
* `transformArray` is the same call plus an optional `outputFormat`
* applied afterwards via `Transform.reformat`. After either,
* `transform.lastUsedKernel` is the kernel `name`, or `'pipeline'` /
* `'cache'`.
*
* new Transform({ buildLut: true, dataFormat: 'int8', BPC: true })
* .create('*sRGB', cmykProfile, eIntent.perceptual);
* var out = transform.array(uint8Pixels, true, true);
*
* `transformArrayViaLUT` is the loud cousin: same work, but throws
* 'No LUT loaded' instead of falling through to the per-pixel walk.
* Use it when a missing table must be a hard error.
*
* Image kernels omit bounds-checks. For `int8` / `int16` the input
* must be a well-formed typed buffer, length === pixelCount ×
* channelsPerPixel. Out-of-range values are undefined behaviour
* (garbage out, no exception). Object batches are an array of
* colour objects; identity clones them, a colour conversion walks
* the pipeline per element.
*
*
* ANTI-PATTERN — do not do this:
*
* for (let i = 0; i < pixelCount; i++) {
* out[i] = transform.transform(pixels[i]); // ← DON'T
* }
*
* That bypasses the LUT, allocates ~6 Arrays per pixel, and dispatches
* every stage via .call(this, ...). On a 4 MP image you will be ~30x
* slower than `array()` on a LUT and you will GC-thrash the host.
*
* ----------------------------------------------------------------------------
* DATAFORMAT OPTIONS (constructor `dataFormat`)
* ----------------------------------------------------------------------------
*
* 'object' Structured input/output, integer ranges. Accuracy path.
* RGB: {type: eColourType.RGB, R:0..255, G:0..255, B:0..255}
* Lab: {type: eColourType.Lab, L:0..100, a:-128..127, b:-128..127}
* CMYK: {type: eColourType.CMYK, C:0..100, M:0..100, Y:0..100, K:0..100}
* Compatible with the helpers in convert.js. Best for
* analysis and human-readable output.
*
* 'objectFloat' Same shape but float ranges 0.0–1.0.
* RGB: {type, Rf, Gf, Bf}
* Lab: {type, L:0..100, a:-128..127, b:-128..127} (unchanged)
* CMYK: {type, Cf, Mf, Yf, Kf}
*
* 'int8' Flat 8-bit integer array, 0–255 per channel. Image path.
* With `buildLut: true`, `array()` hands the batch to
* the kernel (`enableForArrays`).
*
* 'int16' Flat 16-bit integer array, 0–65535 per channel.
*
* 'device' Flat array of n-channel floats, 0.0–1.0 per channel.
* CMYK 25%,0,100%,50% → [0.25, 0.0, 1.0, 0.5]
* RGB 255,0,25 → [1.0, 0.0, 0.098...]
* Used internally; suitable when caller wants raw device
* values without the input/output conversion stages.
*
* ----------------------------------------------------------------------------
* CUSTOM STAGES (3rd argument of create() / 2nd of createMultiStage())
* ----------------------------------------------------------------------------
*
* An array of stage objects to be inserted into the pipeline at named
* pipeline locations. When the Transform is built with `buildLut: true`,
* custom stages are baked INTO the LUT — so they cost zero per pixel at
* runtime. This is the recommended way to apply per-image effects (grey
* conversion, saturation tweaks, ink limiting, etc.) without sacrificing
* the speed of the LUT path.
*
* {
* description: 'name of stage',
* stageData: { ... arbitrary state passed to stageFn ... },
* stageFn: function(input, stageData, stage) { return output; },
* location: one of:
* 'beforeInput2Device'
* 'beforeDevice2PCS'
* 'afterDevice2PCS'
* 'PCS'
* 'beforePCS2Device'
* 'afterPCS2Device'
* 'afterDevice2Output'
*
* For multi-stage profile chains, the same custom
* stage is inserted at EACH boundary by default. To
* target a specific boundary, append (n) where n is
* the 0-based stage index, e.g. 'PCS(0)', 'PCS(1)'.
* }
*
* See README "Insert a custom stage to convert to grey" for a worked
* example.
*
* ----------------------------------------------------------------------------
* PIPELINE NOTES (internal)
* ----------------------------------------------------------------------------
*
* - Pipeline construction runs ONCE per create() call — speed of build is
* irrelevant. Pipeline EXECUTION is per-pixel — speed is critical.
*
* - The pipeline optimiser (this.optimise === true) collapses adjacent
* stages with matching encodings (e.g. PCSv2→PCSv2 conversions become
* no-ops) and can drop entire stages.
*
* - Stages are stored as { inputEncoding, funct, outputEncoding,
* stageData, stageName, debugFormat } — see _Stage typedef in def.js.
*
* - Idea for future: emit each stage body as a string, then construct one
* monolithic Function() that runs the whole pipeline inline per pixel.
* Bigger gain than micro-optimising individual stages.
*
* ----------------------------------------------------------------------------
* CONSTRUCTOR OPTIONS
* ----------------------------------------------------------------------------
*
* @param {object} options
*
* @param {boolean} [options.buildLut=false]
* Precompute and store the CLUT. Required for the fast image path
* (see USAGE GUIDE #3). Slight accuracy loss vs. running the full
* pipeline because of LUT quantisation, but typically invisible to
* the eye and 20–30x faster on image data.
* (Note: legacy spelling `builtLut` is also accepted.)
*
* @param {string} [options.lutGamutMode='none']
* Baked gamut check during LUT build. Zero cost at transform time.
* - `'none'` — no gamut check (default).
* - `'color'` — hard replace above `lutGamutLimit` with
* `lutGamutColor`.
* - `'map'` — write scaled ΔE into every output channel
* (0 = in-gamut, 1.0 = `lutGamutMapScale` ΔE).
* For analysis — output is raw ΔE data.
* - `'colorMap'` — blend original colour → `lutGamutColor`
* proportional to ΔE / `lutGamutMapScale`.
* Visual heat-map overlay on the image.
*
* @param {boolean} [options.bakeLutGamut=false]
* Legacy shorthand. `true` is equivalent to `lutGamutMode:'color'`.
* `lutGamutMode` takes precedence when both are set.
*
* @param {number} [options.lutGamutLimit=5]
* ΔE76 threshold for `'color'` mode. Grid points whose ΔE76
* exceeds this value are replaced with `lutGamutColor`.
* Ignored in `'map'` mode (map is continuous, not thresholded).
*
* @param {number} [options.lutGamutMapScale=25.5]
* ΔE that maps to 1.0 in `'map'` mode. In int8 output a channel
* value of 255 = this many ΔE units. Default 25.5 gives 0.1 ΔE
* resolution per int8 LSB.
*
* @param {object} [options.lutGamutColor={L:0, a:127, b:127}]
* Lab colour used for out-of-gamut replacement cells in `'color'`
* mode. Converted to the output device space once at LUT-build
* time. Default is a vivid pink/magenta.
*
* @param {function} [options.gamutDeFn=convert.deltaE1976]
* Colour-difference function `(labA, labB) => number` used by the
* gamut check. Swap in `convert.deltaE2000`, `convert.deltaCMC`,
* or a custom function.
*
* @param {number} [options.lutGridPoints3D=33]
* Grid points per axis for 3D LUTs. 17 / 33 / 65 are typical. Above
* 65 you hit memory cost without measurable accuracy gain.
*
* @param {number} [options.lutGridPoints4D=17]
* Grid points per axis for 4D (CMYK) LUTs. 11 / 17 / 33 typical.
* 4D grows as N^4 in memory — be cautious above 33.
*
* @param {string} [options.interpolation3D='tetrahedral']
* @param {string} [options.interpolation4D='tetrahedral']
* 'trilinear' or 'tetrahedral' for the live pipeline interpolation.
* Tetrahedral is BOTH faster AND more accurate for device→device LUTs;
* stay on tetrahedral unless you have a measured reason not to. For
* PCS→device 3-channel input, addStageLUT() automatically switches to
* trilinear (matches LittleCMS / Photoshop / SampleICC behaviour).
*
* @param {string} [options.LUTinterpolation3D]
* @param {string} [options.LUTinterpolation4D]
* Same as above but applied to the LUT-substituted pipeline (i.e.
* after buildLut). Defaults to interpolation3D / interpolation4D.
*
* @param {boolean} [options.interpolationFast=true]
* Use the unrolled per-channel-count interpolators (3Ch / 4Ch / NCh).
* Set false to force the generic *_3or4Ch reference variants — only
* useful for diagnosing accuracy issues.
*
* @param {string} [options.dataFormat='object']
* 'object' | 'objectFloat' | 'int8' | 'int16' | 'device' — see
* "DATAFORMAT OPTIONS" above.
*
* @param {boolean} [options.useFloats]
* DEPRECATED. Use dataFormat: 'objectFloat' instead.
*
* @param {boolean} [options.labAdaptation=false]
* If true, object-based Lab inputs are chromatically adapted to D50
* before entering the pipeline (e.g. LabD65 input → LabD50 internal).
*
* @param {boolean} [options.labInputAdaptation=true]
* If false, suppresses Lab→Lab whitepoint adaptation on input.
*
* @param {boolean} [options.displayChromaticAdaptation=false]
* Apply chromatic adaptation across the PCS when source/destination
* profiles have different whitepoints. For abstract Lab profiles.
*
* @param {boolean} [options.pipelineDebug=false]
* Capture per-stage values into this.pipelineHistory and
* this.debugHistory. Adds overhead — only enable for diagnostics.
* Only meaningful on the accuracy path (transform()).
*
* @param {boolean} [options.optimise=true]
* Run the pipeline optimiser to remove redundant conversions.
*
* @param {boolean} [options.roundOutput=true]
* Round numeric output to `precision` decimal places. Set false to
* keep raw floats (e.g. 243.20100198... for sub-integer accuracy).
*
* @param {number} [options.precision=0]
* Decimal places to round to when roundOutput=true.
*
* @param {number} [options.precession=0]
* @deprecated Long-standing typo of `precision`. Still accepted for
* backwards compatibility — `options.precision` and `options.precession`
* are interchangeable, and both `this.precision` and `this.precession`
* are populated for read. New code should use `precision`.
*
* @param {boolean|boolean[]} [options.BPC=false]
* Black Point Compensation. Pass a boolean to enable for ALL stages,
* or an array of booleans to control per-stage independently. The
* array indexes by STAGE number (0,1,2,…), NOT by chain index.
*
* @param {boolean} [options.clipRGBinPipeline=false]
* Clip RGB values to 0..1 inside the pipeline (useful when going
* through extreme abstract profiles).
*
* @param {('auto'|'float'|'int'|'int16'|'int-wasm-scalar'|'int-wasm-simd'|'int16-wasm-scalar'|'int16-wasm-simd')} [options.lutMode='auto']
* LUT-based image hot-path kernel selector. Only meaningful when
* `dataFormat: 'int8'` or `'int16'` AND `buildLut: true`. Non-LUT
* (accuracy) paths are unaffected — they always run the float
* code.
*
* 'int16' is the u16 sibling of 'int' — same i32 ALU, same u16
* CLUT (built once via buildIntLut), but with Uint16Array I/O
* instead of Uint8ClampedArray. Output uses the canonical
* [0, 65280] → [0, 65535] bit-trick `v + (v >>> 8)` that is
* bit-exact for our cell scale (255 × 257 = 65535). Pairs
* naturally with `dataFormat: 'int16'` — see the 'auto'
* resolution rules below.
*
* Modes (each one falls through to the previous if its kernel
* can't service the LUT shape or the host can't run it):
*
* - 'auto' (default, v1.2+) — the engine picks the fastest
* applicable kernel at construction
* time based on `dataFormat` and
* `buildLut`:
* • `dataFormat: 'int8'` + `buildLut:
* true` → resolves to 'int-wasm-simd'
* (with the SIMD → scalar → int
* demotion chain running at
* `create()` time for hosts that
* lack WASM or SIMD).
* • any other configuration →
* resolves to 'float'. (lutMode
* is ignored for non-int8
* dataFormats anyway, so the
* resolved value matches what
* actually runs.)
* Inspect `xform.lutMode` after
* construction to see the resolved
* value — it reflects what will run.
*
* - 'float' — the original floating-point kernels.
* Pin this explicitly when you want
* bit-stable f64 LUT interp regardless
* of release.
*
* - 'int' — integer-math kernels reading a u16
* mirror LUT with Q0.8 fractional weights
* and Math.imul. Typical 1.10–1.15×
* speedup vs float on real ICC profiles,
* with accuracy ≤2 LSB vs float (well
* under perceptual threshold for u8 image
* data). Uses 4× less LUT memory
* (Uint16Array instead of Float64Array).
*
* - 'int-wasm-scalar' — (v1.2) same integer math as 'int' but
* executed by a hand-written WebAssembly
* kernel. Bit-exact against 'int' across
* millions of verified pixels (6-config
* matrix in `bench/wasm_poc/`). ~1.40×
* over 'int' on x64 for 3D tetrahedral
* workloads; see docs/deepdive/Performance.md
* "WASM scalar — measured" section.
*
* If WebAssembly is unavailable in the
* host (very rare today — sandboxed
* environments, ancient runtimes) this
* silently demotes to 'int' at
* `create()` time. 4D kernels are not
* yet ported; 4D workloads through this
* mode run the 'int' JS kernel.
*
* - 'int-wasm-simd' — (v1.2) channel-parallel WebAssembly
* SIMD kernel for 3D tetrahedral LUTs.
* Bit-exact against both the 'int' and
* 'int-wasm-scalar' paths across the
* same 6-config matrix. ~3.0-3.5× over
* 'int' (2.0-2.5× over 'int-wasm-scalar')
* on x64 for 3D RGB→RGB / RGB→CMYK; see
* docs/deepdive/Performance.md "WASM SIMD —
* channel-parallel" section.
*
* Supports cMax ∈ {3, 4} only — other
* channel counts fall through to the
* scalar WASM kernel, then to 'int'. 4D
* kernels are not ported. On hosts that
* lack WebAssembly SIMD support this
* silently demotes to 'int-wasm-scalar'
* at `create()` time; demotes further
* to 'int' if WebAssembly itself is
* unavailable.
*
* - 'int16-wasm-scalar' — (v1.3, Q0.13) sibling of
* 'int-wasm-scalar' for u16 I/O workloads.
* Reads u16 input, writes u16 output (no
* u8/u16 re-quantisation at the I/O
* boundary). Q0.13 fixed-point fractional
* weights (settled on Q0.13 after a brief
* internal Q0.12 iteration during v1.3
* development). Bit-exact against the JS
* 'int16' kernel across the 6-config matrix
* (`bench/wasm_poc/tetra3d_int16_run.js`).
* 1.07–1.38× over 'int16' on x64 for 3D
* tetrahedral; 1.96–2.53× over lcms-wasm
* u16; see docs/deepdive/Performance.md
* "WASM int16 — measured" section.
*
* 3D + 4D both ship (4D uses two-rounding
* K-LERP for i32 safety). On hosts that
* lack WebAssembly this demotes to 'int16'
* at `create()` time.
*
* - 'int16-wasm-simd' — (v1.3, Q0.13) channel-parallel
* WebAssembly SIMD kernel for u16 I/O.
* Bit-exact against both 'int16' and
* 'int16-wasm-scalar' across the same
* 6-config matrix. Mirrors the
* 'int-wasm-simd' u8 design — vectorises
* the four output channels in i32x4
* lanes — but uses Q0.13 weights and
* i16x8.narrow_i32x4_u for the u16
* output store.
*
* Supports cMax ∈ {3, 4} only — other
* channel counts fall through to the
* scalar u16 WASM kernel, then to JS
* 'int16'. The 4D SIMD u16 kernel keeps
* the K0 intermediate in a v128 local
* register and skips the scratch-memory
* round-trip the scalar 4D u16 kernel
* needs. On hosts that lack WebAssembly
* SIMD support this silently demotes to
* 'int16-wasm-scalar' at `create()` time;
* demotes further to 'int16' if
* WebAssembly itself is unavailable.
*
* The 'auto' heuristic today is just "int8 + LUT → best WASM
* kernel" or "int16 + LUT → best WASM int16 kernel". Future releases may add per-Transform microbenchmarks
* (int JS can beat scalar WASM on older / weaker CPUs in some
* workloads) or host-capability heuristics. The public API is
* stable either way — 'auto' always means "pick the fastest
* applicable kernel for this Transform on this host".
*
* The integer kernels are NOT recommended for color-measurement
* workflows that compare transformed pixel values to reference
* targets — pin `lutMode: 'float'` (or `buildLut: false` for the
* f64 pipeline) for that. See `bench/fastLUT_real_world.js` for
* accuracy/speed numbers on real profiles.
*
* @param {Object} [options.wasmCache]
* Optional shared cache bag for the compiled `WebAssembly.Module`.
* Used when `lutMode` is `'int-wasm-scalar'` or `'int-wasm-simd'`.
* Each Transform still gets its own `WebAssembly.Instance` (its
* own linear memory); sharing the compiled module just avoids
* redundant compile work. Scalar and SIMD modules live under
* different private keys on the bag, so you can use a single
* cache for a mix of Transforms. Example:
*
* const wasmCache = {};
* const t1 = new Transform({ lutMode: 'int-wasm-simd', wasmCache });
* const t2 = new Transform({ lutMode: 'int-wasm-scalar', wasmCache });
*
* @param {boolean} [options.verbose=false]
* @param {boolean} [options.verboseTiming=false]
* Log pipeline construction info / build timings to console.
*
* @constructor
*/
class Transform{
// Kernel module descriptors, indexed by INPUT CHANNEL COUNT, 1..15 —
// the full ICC range (FCLR is 15 channels, see Profile.js). Dense, so
// setKernel() is one array index with no key string to build, and any
// single dimension can be replaced without disturbing its neighbours.
//
// Slots 5 and 6 are Kernel5D / Kernel6D (int8 WASM scalar). Slots
// 7..15 hold the SAME KernelND descriptor. A later tuned kernel can
// still replace one slot without forking the rest.
//
// Registered once via Transform.registerKernel(); instantiated per
// Transform in setKernel() via Object.create(descriptor).
// See docs/deepdive/KernelContract.md.
static kernels = [];
// Highest input channel count a kernel can be registered for. ICC tops
// out at FCLR = 15.
static MAX_KERNEL_DIMENSIONS = 15;
// Set by Transform.compatibility(); null means current defaults.
static _compatDefaults = null;
/**
* Pin construction defaults to an earlier release's behaviour.
*
* Transform.compatibility('1.5'); // 1.5.0 output
* Transform.compatibility(null); // back to current
* Transform.compatibility(); // returns the active pin
*
* WHY A NAMED SNAPSHOT RATHER THAN A SETTINGS BAG. An upgrade should
* not require finding and setting each changed default by hand — that
* is a research task with a silent failure mode. A version is one call,
* it documents itself, and it cannot drift: the list below IS the
* changelog of defaults that move output.
*
* NOT AN ENVIRONMENT VARIABLE, deliberately. This changes PIXELS, and
* `process.env` does not exist in a browser — a setting that worked in
* Node and silently did nothing in the browser would be worse than no
* setting. (Pool sizing is environment-configurable, because that is a
* Node-only subsystem and cannot change a pixel; see src/pool.js.)
*
* CALL IT BEFORE CONSTRUCTING ANYTHING. Defaults are read at
* construction, so a Transform built earlier keeps what it was built
* with. That is the one sharp edge, and it is why this is a single call
* at startup rather than a mutable configuration object.
*
* @param {string|null} [version] '1.5' | null | omitted to read
* @returns {string|null} the version now pinned
*/
/**
* Start the worker pool now, and report whether it worked.
*
* STATIC, NOT PER-INSTANCE, because the pool is process-wide: workers
* are shared across every Transform, which is what makes the
* per-worker transform cache worth having. `t.enablePool()` would
* imply two Transforms get two pools.
*
* await Transform.enablePool(); // Node
* await Transform.enablePool({cores: 4});
* await Transform.enablePool({ // browser
* workerUrl: '/path/to/jsColorEngineWorker.js'
* });
*
* Everything else falls back to sequential silently on failure, which
* is right — multicore is an optimisation, never a capability. This is
* for the caller who deliberately wants parallelism and would rather
* find out at startup than ship something that quietly runs on one
* thread. It also warms the pool, so the first batch is not the one
* paying for spawning.
*
* Rejects with the reason. A browser needs the worker bundle URL —
* `workerUrl` or `globalThis.JSCE_WORKER_URL`.
*
* @param {object} [options] pool options, e.g. {cores, maxThreads, workerUrl}
* @returns {Promise<{workers:number, host:string}>}
*/
static enablePool(options){
var o = Transform._normalisePoolOptions(options);
var restart = o.restart === true;
var cancelQueue = o.cancelQueue === true;
delete o.restart; delete o.cancelQueue;
// ALREADY ENABLED IS A NO-OP, not an error. Two modules that both
// want a pool should both be able to say so; making the second
// caller throw would mean every caller has to know whether it is
// first, which is the kind of coupling a process-wide resource
// should absorb rather than export.
if(Transform._poolDefault && !restart){
// Different options, though, are worth a word: silently
// ignoring them means someone believes they reconfigured the
// pool and did not.
if(Transform._poolOptionsDiffer(o, Transform._poolDefault)
&& !Transform._warnedPoolReconfig){
Transform._warnedPoolReconfig = true;
console.warn('jsColorEngine: enablePool() called again with different ' +
'options — ignored, the pool is already running. Use ' +
'enablePool({restart: true, ...}) to reconfigure it, or ' +
'restartPool(...).');
}
return Promise.resolve(Transform._poolInfo
? Object.assign({alreadyEnabled: true}, Transform._poolInfo)
: {alreadyEnabled: true});
}
var settle = Promise.resolve();
if(restart && Transform._poolDefault){
// RECONFIGURING MEANS REPLACING THE WORKERS, and workers hold
// fragments. Draining first is the safe default: in-flight
// images finish and their callbacks fire. cancelQueue trades
// that for immediacy — the work stops and every affected image
// fires its callback with a cancelled result, so nothing is
// left waiting either way.
if(cancelQueue){ try { _pool.cancelAll(); } catch(e){ /* nothing queued */ } }
settle = _pool.onQueueFree();
}
return settle.then(function(){
// Tear down BEFORE starting: pools are keyed by worker count,
// so enabling 6 after 2 without this leaves both alive and the
// process holding 8 workers for a pool of 6.
Transform.disablePool();
return _pool.enable(o);
}).then(function(info){
// Enabling is also SWITCHING ON. A caller who starts a pool
// means their batches to use it; making them repeat
// `multicore: true` at every call site is a papercut that
// produces exactly one bug — the call that forgot.
Transform._poolDefault = o;
Transform._poolInfo = info;
return info;
});
}
/**
* Reconfigure a running pool. Sugar for `enablePool({restart: true})`,
* and the honest name for what a test wants between cases.
*
* await Transform.restartPool({workers: 4});
* await Transform.restartPool({workers: 4, cancelQueue: true});
*
* Waits for in-flight work to finish unless `cancelQueue` is set.
*/
static restartPool(options){
var o = {};
for(var k in (options || {})) o[k] = options[k];
o.restart = true;
return Transform.enablePool(o);
}
/** Do two normalised option sets ask for a different pool? */
static _poolOptionsDiffer(a, b){
var keys = {};
for(var i in a) keys[i] = true;
for(var j in b) keys[j] = true;
for(var k in keys){ if(a[k] !== b[k]) return true; }
return false;
}
/**
* Tear the pool down and stop defaulting batches to it.
* Anything explicitly asking for `multicore` still gets its own pool.
*/
static disablePool(){
Transform._poolDefault = null;
Transform._poolInfo = null;
Transform._warnedPoolReconfig = false;
_pool.destroyAll();
}
/** Per-worker fragment / MPx/s split — see pool.workerStats(). */
static workerStats(){ return _pool.workerStats(); }
/** Resident LUT copies across the pool, one line per pool. */
static poolMemory(){ return _pool.memorySummary(); }
/**
* Accept the words people actually reach for.
*
* The pool's own vocabulary is `cores` / `maxThreads` / `minThreads`,
* which is accurate — but they are workers, and callers write
* `workers` / `maxWorkers`. Same spirit as buildLut/builtLut and
* matrixShaper/wasmMatrixShaper: one concept, more than one spelling,
* resolved in one place rather than checked for in several.
*/
static _normalisePoolOptions(options){
var o = {};
for(var k in (options || {})) o[k] = options[k];
if(o.workers !== undefined && o.cores === undefined) o.cores = o.workers;
if(o.maxWorkers !== undefined && o.maxThreads === undefined) o.maxThreads = o.maxWorkers;
if(o.minWorkers !== undefined && o.minThreads === undefined) o.minThreads = o.minWorkers;
delete o.workers; delete o.maxWorkers; delete o.minWorkers;
if(o.url !== undefined && o.workerUrl === undefined) o.workerUrl = o.url;
return o;
}
static compatibility(version){
if(version === undefined) return Transform._compatVersion || null;
if(version === null || version === 'latest' || version === false){
Transform._compatDefaults = null;
Transform._compatVersion = null;
return null;
}
var key = String(version).split('.').slice(0, 2).join('.');
var known = Transform.COMPAT_DEFAULTS[key];
if(!known){
throw new Error('Transform.compatibility: unknown version "' + version +
'". Known: ' + Object.keys(Transform.COMPAT_DEFAULTS).join(', ') +
', or null for current defaults.');
}
Transform._compatDefaults = known;
Transform._compatVersion = key;
return key;
}
constructor(options){
options = options || {};
// COMPATIBILITY DEFAULTS, if a version was pinned. Applied UNDER
// the caller's options, never over them: an explicit setting always
// wins, so pinning changes what you get by default and nothing you
// asked for. See Transform.compatibility().
if(Transform._compatDefaults){
var _merged = {};
for(var _c in Transform._compatDefaults) _merged[_c] = Transform._compatDefaults[_c];
for(var _o in options) if(options[_o] !== undefined) _merged[_o] = options[_o];
options = _merged;
}
this.kernel = null;
// What array() actually ran last: the kernel's `name`, or
// 'pipeline' / 'cache'. Null until the first batch. Tests
// assert on this; ViaLUT throws before array() so it stays
// whatever it was.
this.lastUsedKernel = null;
// Cache the raw constructor options so behaviours applied via t.use()
// can read plugin-specific values (e.g. totalInk) without the caller
// having to repeat them at use() time.
this._originalOptions = options;
// Namespaced plugin store — each plugin/behaviour should write its state
// here rather than directly onto the Transform instance, preventing
// collisions between plugins and with future Transform properties.
// transform.plugin['ink-limit'].totalInk = 260
// transform.plugin['ink-limit'].initialised = true (author's guard)
this.plugin = Object.create(null);
// Accept both spellings: `builtLut` (original) and `buildLut` (the name
// used throughout the JSDoc and in newer docs). They mean the same thing —
// "precompute and store a LUT for the fast image path". Internally we
// normalise to `this.builtLut` to keep all downstream code untouched.
this.builtLut = (options.builtLut === true) || (options.buildLut === true);
// `builtLut` doubles as intent ("build one") and state ("have
// one") — setLut() sets it true on a Transform that never asked.
// clear() has to restore the INTENT, so the constructor's answer
// is kept separately rather than inferred from the current value.
this._buildLutRequested = this.builtLut;
// THE WASM IMPLEMENTATION of the matrix-shaper maths. Named for
// the WASM part because the pipeline is ALREADY a matrix shaper —
// the optimiser folds an RGB->RGB pair into
// stage_Gamma_Inverse -> stage_matrix_rgb -> stage_Gamma, in JS
// float, and that is the exact reference everything here is
// measured against. It is simply slow: ~8 MPx/s against ~229 for
// the same arithmetic in WASM SIMD. Nothing about the maths
// changes; only who executes it.
//
// Three modes,
// because there are three genuinely different answers:
//
// 'auto' (default) use it where there is no LUT to displace.
// Nothing the caller asked for changes.
// 'prefer' ALSO replace a CLUT that was asked for.
// 331 MPx/s against 123 on a photo, and
// within 1 LSB
// of the exact pipeline where the CLUT can be
// 25 LSB out. Opt-in because a LUT is also an
// object callers export, clone and inspect.
// false never. The honest reference for comparing
// against, and a way out if a host misbehaves.
//
// Note "prefer" rather than "force": the kernel declines for a
// list of ordinary reasons — identity pairs, LUT-based RGB
// profiles, per-channel TRCs, a dataFormat other than int8 or
// int16 — and a mode named force would either have to throw on
// all of them or quietly not force. A host without WASM SIMD is
// NOT one of those reasons: it gets the scalar build, which is
// bit-identical and merely slower.
// `wasmMatrixShaper` is the name; `matrixShaper` and
// `preferMatrixShaperOverLUT` are accepted spellings, in the same
// spirit as buildLut/builtLut above.
var _ms = options.wasmMatrixShaper;
if(_ms === undefined) _ms = options.matrixShaper;
if(options.preferMatrixShaperOverLUT === true) _ms = 'prefer';
if(_ms === true) _ms = 'prefer';
if(_ms === false || _ms === 'off' || _ms === 'none') _ms = 'off';
if(_ms !== 'prefer' && _ms !== 'off') _ms = 'auto';
this.wasmMatrixShaper = _ms;
// PER-TRANSFORM MULTICORE DEFAULT. _multicoreHandoff() has always
// read `this.multicore` as the fallback when a call passes no
// `multicore` option — but nothing ever set it, so the fallback was
// permanently undefined and `new Transform({multicore: true})`
// silently did nothing. Wired here so the option a caller passes to
// the constructor means what it looks like it means.
this.multicore = (options.multicore === undefined) ? false : options.multicore;
// Derived flags so the decisions downstream read plainly.
this.preferMatrixShaperOverLUT = (_ms === 'prefer');
// Gamut mode: 'none', 'color', 'map'. bakeLutGamut:true is legacy for 'color'.
if (options.lutGamutMode && options.lutGamutMode !== 'none') {
this.lutGamutMode = options.lutGamutMode;
} else {
this.lutGamutMode = (options.bakeLutGamut === true) ? 'color' : 'none';
}
this.lutGamutLimit = options.lutGamutLimit || 5;
this.lutGamutMapScale = options.lutGamutMapScale || 25.5;
// default is cmsLab(0, 127, 127) which is a bright pink that stands out in most gamuts
this.lutGamutColor = options.lutGamutColor || this.Lab(0, 127, 127);
this.gamutDeFn = options.gamutDeFn || convert.deltaE1976;
// TODO: accept options.lutGamutColorMap as an array of device colours
// for multi-stop heatmaps (e.g. white → yellow → red → black).
// gamutCheck would then pick the stop pair based on the scaled ΔE.
this.gamutTransforms = {};
this.gamutColorDevice = [];
this.gamutWhiteDevice = [];
this.lutGridPoints3D = (isNaN(Number(options.lutGridPoints3D))) ? 33 : Number(options.lutGridPoints3D);
this.lutGridPoints4D = (isNaN(Number(options.lutGridPoints4D))) ? 17 : Number(options.lutGridPoints4D);
// LUT image-hot-path kernel selector. See JSDoc above for full
// semantics. v1.2+ ships 'auto' (default), 'float', 'int',
// 'int-wasm-scalar', 'int-wasm-simd'. Unknown values fall back
// to 'auto' so a typo or forward-written code can never crash a
// production transform — verbose mode warns when this happens.
var rawLutMode = (options.lutMode === undefined) ? 'auto' : ('' + options.lutMode);
switch(rawLutMode){
case 'float':
case 'int':
case 'int16':
case 'int-wasm-scalar':
case 'int-wasm-simd':
case 'int16-wasm-scalar':
case 'int16-wasm-simd':
this.lutMode = rawLutMode;
this.lutModeRequested = rawLutMode;
break;
case 'auto':
// 'auto' is a heuristic: pick the fastest kernel that's
// applicable to this Transform's (dataFormat, buildLut)
// combination. int8 + buildLut=true gets the full WASM
// SIMD hot path (with SIMD → scalar → int demotion at
// create() time for older hosts). int16 + buildLut=true
// resolves to the WASM int16 scalar kernel (with int16
// JS demotion when WASM is unavailable — see roadmap
// v1.3.x for the int16 SIMD ceiling lift). Everything
// else runs the float kernel — which is what the engine
// would have used anyway, because lutMode is ignored for
// non-LUT dataFormats. Resolving the mode here makes
// xform.lutMode self-documenting: it always reflects
// the kernel that will actually run.
if(options.dataFormat === 'int8' && this.builtLut){
this.lutMode = 'int-wasm-simd';
} else if(options.dataFormat === 'int16' && this.builtLut){
this.lutMode = 'int16-wasm-simd';
} else {
this.lutMode = 'float';
}
this.lutModeRequested = 'auto';
break;
default:
if(Transform._plugins[rawLutMode]){
// Plugin-registered mode — accepted as-is. The kernel
// resolves the plugin's run in its own init().
this.lutMode = rawLutMode;
this.lutModeRequested = rawLutMode;
// initialise — per-instance, runs here in the constructor.
// Gets (transform, rawOpts): validate options, store state on
// transform.plugin[name], add hooks — anything per-instance.
// Constructor runs once per instance so no double-up possible.
var _pEntry = Transform._plugins[rawLutMode];
if(_pEntry && _pEntry.initialise){
_pEntry.initialise(this, options);
}
} else {
if(options.verbose === true){
console.warn('Unknown lutMode "' + rawLutMode + '" — falling back to "auto". Valid values: auto, float, int, int16, int-wasm-scalar, int-wasm-simd, int16-wasm-scalar, int16-wasm-simd.');
}
// Forward-compat: unknown modes resolve as if user had
// said 'auto' — gets them the best-available kernel
// without crashing, better than the previous behaviour
// (silent demote to slowest) for someone writing against
// a future version that adds a new mode.
if(options.dataFormat === 'int8' && this.builtLut){
this.lutMode = 'int-wasm-simd';
} else if(options.dataFormat === 'int16' && this.builtLut){
this.lutMode = 'int16-wasm-simd';
} else {
this.lutMode = 'float';
}
this.lutModeRequested = 'auto';
}
}
// WASM kernel state. Populated at create() time when lutMode is
// 'int-wasm-scalar' or 'int-wasm-simd' and the host supports
// WebAssembly. Null means "no WASM available or no WASM kernel
// eligible for this LUT shape"; the dispatcher falls back to the
// JS 'int' kernel on null.
//
// All four u8 states can be present simultaneously when lutMode=
// 'int-wasm-simd':
// - wasmTetra3DSimd : 3D SIMD, cMax ∈ {3, 4}
// - wasmTetra3D : 3D scalar, every cMax (fallthrough)
// - wasmTetra4DSimd : 4D SIMD, cMax ∈ {3, 4}
// - wasmTetra4D : 4D scalar, every cMax (fallthrough)
// If the SIMD module fails to compile (host lacks SIMD), lutMode
// is demoted to 'int-wasm-scalar' and both wasmTetra*Simd are
// left null; the two scalar states stay loaded.
//
// For lutMode='int16-wasm-scalar' (v1.3) the 3D u16 + 4D u16
// scalar states are loaded; both share one wasmCache bag and
// distinct module keys. The int16 SIMD ceiling lift is tracked
// under roadmap v1.3.x.
//
// The optional shared-module cache is taken here so multiple
// Transforms created from the same bag share compile work. Each
// Transform still has its own linear-memory instance.
this.wasmCache = options.wasmCache || null;
// Kernel-scoped options, keyed by kernel name, passed through to
// kernels untouched:
//
// new Transform({ kernelOptions: { kernel3D: { f32: true } } })
//
// Transform never validates or interprets these — it does not know
// what any of them mean, and a typo is the kernel's to catch,
// because the kernel owns the schema. See _kernelOpts() and
// docs/deepdive/KernelContract.md.
this.kernelOptions = options.kernelOptions || null;
this._wasmShrinkRatio = options.wasmShrinkRatio || 0;
this._wasmMaxMemory = options.wasmMaxMemory !== undefined
? options.wasmMaxMemory : 128 * 1024 * 1024;
// WASM STATE LIVES ON THE KERNEL (v1.6 phase 4c —
// docs/deepdive/KernelContract.md). The eight wasmTetra* slots that
// used to be declared here are initialised by setKernel() on the
// kernel instance, because the kernel is what uses them. Reading
// `transform.wasmTetra3D` still works — see the forwarding
// accessors at the bottom of this file — but the state is the
// kernel's, which is what lets a kernel eventually load only its
// own dimension's modules.
// NO DISPATCH STATE LIVES HERE. It used to: _lutKernelBig,
// _lutKernelSmall and _lutKernelThreshold were fields on this
// Transform, and a resolver method kept them current. They belong
// to the kernel -- which variant runs, and how big a batch has to
// be to be worth a WASM call, are the kernel's own business and
// nothing out here needs to know a choice was made.
//
// The two booleans below are Transform's, and are only a cache of
// its own lutMode string so the hot path stops re-comparing it.
this._expectsU16 = false; // cached: lutMode is int16 family
this._isIntegerMode = false; // cached: lutMode is any integer family
// REMOVED IN v1.6: transformArrayFn / bindTransformArrayFn.
//
// A closure bound at create() so transformArray() could skip a
// layer of routing. The Roadmap recorded the measurement that
// killed it -- "no faster for images and slower for tiny batches"
// -- and it shipped defaulted to off. Once the kernels owned
// dispatch, its LUT branch was a wrapper that called
// kernel.array(), so it could not be faster than the thing it
// called. transformArray() now reaches the kernel directly.
//
// bindTransformArrayFn is still accepted and ignored, so option
// objects written against v1.5 keep working.
// LUT build hooks — run per grid cell during createNDDeviceLUT,
// zero per-pixel cost. Each array holds functions chained in
// order; addLutInputHook / addLutOutputHook manage ordering.
this._lutInputHooks = [];
this._lutOutputHooks = [];
if (typeof options.lutInputHook === 'function') {
this._lutInputHooks.push(options.lutInputHook);
}
if (typeof options.lutOutputHook === 'function') {
this._lutOutputHooks.push(options.lutOutputHook);
}
this.interpolation3D = options.interpolation3D ? options.interpolation3D.toLowerCase() : 'tetrahedral';
this.interpolation4D = options.interpolation4D ? options.interpolation4D.toLowerCase() : 'tetrahedral';
this.interpolationFast = options.interpolationFast !== false;
this.LUTinterpolation3D = options.LUTinterpolation3D ? options.LUTinterpolation3D.toLowerCase() : this.interpolation3D;
this.LUTinterpolation4D = options.LUTinterpolation4D ? options.LUTinterpolation4D.toLowerCase() : this.interpolation4D;
this.labAdaptation = options.labAdaptation === true;
this.displayChromaticAdaptation = options.displayChromaticAdaptation === true;
this.labInputAdaptation = options.labInputAdaptation !== false;
this.dataFormat = options.dataFormat || 'object'; // object, objectFloat, int8, int16, device
if(!options.dataFormat){
// Obsolete, use dataFormat instead
if(options.useFloats){
console.log('useFloats is obsolete, use dataFormat instead')
this.dataFormat = 'objectFloat';
}
}
var convertInputOutput = true;
switch(this.dataFormat){
case 'object':
convertInputOutput = true
break;
case 'objectFloat':
convertInputOutput = true
this.useFloats = true; // backwards compatibility
break;
case 'int8':
case 'int16':
convertInputOutput = true;
break;
case 'device':
convertInputOutput = false;
break;
default: