@photivra/engine 2.8.1 → 2.9.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 (45) hide show
  1. package/CHANGELOG.md +60 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +8 -6
  4. package/SECURITY.md +1 -1
  5. package/dist/capture/export-jpeg.d.ts +19 -0
  6. package/dist/capture/export-jpeg.js +190 -108
  7. package/dist/capture/export-jpeg.js.map +1 -1
  8. package/dist/capture/native-environment-lean.js +19 -9
  9. package/dist/capture/native-environment-lean.js.map +1 -1
  10. package/dist/core/version.d.ts +1 -1
  11. package/dist/core/version.js +1 -1
  12. package/dist/core/version.js.map +1 -1
  13. package/dist/facade/expected-signal.d.ts +4 -1
  14. package/dist/facade/expected-signal.js +151 -31
  15. package/dist/facade/expected-signal.js.map +1 -1
  16. package/dist/facade/scene-radiance.d.ts +4 -1
  17. package/dist/facade/scene-radiance.js +4 -4
  18. package/dist/facade/scene-radiance.js.map +1 -1
  19. package/dist/facade/simulate-capture.d.ts +26 -1
  20. package/dist/facade/simulate-capture.js +102 -9
  21. package/dist/facade/simulate-capture.js.map +1 -1
  22. package/dist/facade/simulate-display-view.js +2 -1
  23. package/dist/facade/simulate-display-view.js.map +1 -1
  24. package/dist/index.d.ts +4 -3
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/optics/diffraction-psf-profile.d.ts +19 -17
  28. package/dist/optics/diffraction-psf-profile.js +49 -18
  29. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  30. package/dist/output/print-jpeg.d.ts +28 -1
  31. package/dist/output/print-jpeg.js +79 -7
  32. package/dist/output/print-jpeg.js.map +1 -1
  33. package/dist/sensor/stochastic-seed.d.ts +12 -0
  34. package/dist/sensor/stochastic-seed.js +25 -0
  35. package/dist/sensor/stochastic-seed.js.map +1 -1
  36. package/docs/API_REFERENCE.md +8 -1
  37. package/docs/QUICK_START.md +59 -0
  38. package/docs/api/capture-export-jpeg.md +10 -0
  39. package/docs/api/core-version.md +1 -1
  40. package/docs/api/exports.json +37 -2
  41. package/docs/api/facade-scene-radiance.md +2 -1
  42. package/docs/api/facade-simulate-capture.md +28 -0
  43. package/docs/api/optics-diffraction-psf-profile.md +1 -1
  44. package/docs/api/output-print-jpeg.md +41 -0
  45. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,65 @@
1
1
  # Changelog
2
2
 
3
- ## 2.8.1 — unreleased (prepared October 25, 2026)
3
+ ## 2.9.0 — unreleased (prepared October 25, 2026)
4
+
5
+ A minor release: new functions for writing a whole-frame DNG from region parts and a Print JPEG in strips, and a faster display view and Quality preview. Results are the same as 2.8.1, except display views and binned frames whose diffraction cells have β ≥ 32, which change in the last bits.
6
+ - **New:** `serializeSimulatedCaptureDngFromParts` joins region parts' RAW into a DNG with the bytes of one whole-frame run. `createPrintJpegStrip` and `composePrintJpegStrips` write a 1:1 Print JPEG's rows on separate workers and join them into the bytes of `createPrintJpegTask`. In the Photivra app, on four browser workers:
7
+ - a 24 MP DNG takes 23.8 s instead of 79.1 s (Speed) and 46.2 s instead of 158.0 s (Quality);
8
+ - a 24 MP JPEG takes about 20 s instead of 32–35 s (Speed) and about 40 s instead of 53–59 s (Quality).
9
+ - **Faster preview:** in the app's Quality preview (Chromium, four workers), the quick frame takes 0.7 s instead of 4.0 s, and the fully sharp preview 65 s instead of 170 s.
10
+ - **Faster captures:** a 24 MP chart capture takes 16.4 s instead of 18.5 s, and with AHD 28.5 s instead of 33.6 s (Node, one thread), with the same bytes.
11
+ - **Compatibility:** five new root exports (1,734); no option, default or result field changes. `createDisplayViewExpectedSignal`, which is internal, takes an optional span hint.
12
+
13
+ ### Added
14
+
15
+ - **A Print JPEG written in strips: `createPrintJpegStrip` and `composePrintJpegStrips`.** At 1:1, a Print JPEG's rows can be coded on separate workers and joined into the file `createPrintJpegTask` writes for the same plan and pixels, with the same bytes. Only each strip's first block depends on the strip before it, through its DC differences; the join codes that block and copies the rest of the strip's bits.
16
+ - **Same bytes:** tests compare the joined file with the task's file for several splits, crops, quantization steps and a density. The Photivra app's 24 MP JPEG from strips equals the engine's whole-frame JPEG in Speed and Quality.
17
+ - **Measured:** 24 MP, Node, one thread: the whole task 8.1 s; four strips 1.9 s each; the join 1.1 s for a 64 MB file. In the app (Chromium, four workers), writing the JPEG takes about 3 s instead of 9–12 s. A full-size Speed JPEG export takes 19.7–21.0 s instead of 32–35 s, and Quality 39.9–40.2 s instead of 53–59 s.
18
+ - The whole-image JPEG writer and the strips share one block coder, and the existing byte-identity tests pass.
19
+ - New root exports: `createPrintJpegStrip`, `composePrintJpegStrips`, and the types `PrintJpegStripInput`, `PrintJpegStrip` and `JpegStripBits`.
20
+
21
+ - **A whole-frame DNG from region parts (#391): `serializeSimulatedCaptureDngFromParts(options, parts)`.** A large DNG's RAW can now render in parallel: render region parts of the request (any format), pass each part's `raw` and `region`, and the function joins their native sites and writes the DNG exactly as `simulateCapture` writes it, with the same bytes. Parts must cover the native raster and agree where they overlap; a part from another request is refused. Parts take a fixed white balance and no `output.dng`; auto white balance and the capture time apply to the DNG options and are measured over the joined frame.
22
+ - **Same bytes:** tests compare the joined DNG with the whole-frame DNG for bilinear and AHD, diffraction with the four-spot filter and the 2 × 2 box, auto white balance with a capture time, a reflectance-function scene and the environment path; on the Photivra app's 24 MP request both presets gave the same bytes.
23
+ - **Measured** (24 MP, 24 parts of about 1024 pixels, Node, one thread): parts 58.1 s of CPU against 50.6 s for one whole render (Quality options 119.3 s against 113.6 s), join 2.6 s. On four workers the wall time is roughly a quarter of the whole render's.
24
+ - New root exports: `serializeSimulatedCaptureDngFromParts` and the type `SimulateCaptureDngPart`.
25
+
26
+ ### Faster, same output
27
+
28
+ - **Captures with AHD demosaic: development about 3× cheaper around the demosaic (#420).**
29
+ - **What changed:** after the AHD demosaic, each output pixel looked up its native site through the full raster-point transform (validation and two new objects per pixel) and scanned a 7 × 7 square of saturation flags. The loop now steps along each output row from two transformed points, as the bilinear path already does, and finds saturated sites from a row-dilated mask: one column of 7 reads instead of 49, and none when no site is saturated. The demosaic itself is unchanged (capture AHD stays exact, #399).
30
+ - **Same bytes:** samples, RAW and the highlight counts are identical (SHA-256) on a 24 MP Siemens star frame and on 2048² regions of Siemens star and dead leaves at seven exposures, from no clipped pixel through 144,548 and 3.6 million to all of them. A new test checks the saturated-pixel count against a 7 × 7 brute-force count from the RAW on frames where none, some or all pixels clip; reducing the reach by one site fails it.
31
+ - **Measured** in Node, one thread, the 2.8.0 record's request (24 MP full frame, 6 µm, 50 mm f/8, 2 × 2 box, ISO 100) with `output.demosaic: "ahd"`, before and after interleaved: development of a 2048² Siemens star region 3.39–3.57 s → 2.32–2.35 s at 1/125 s (three runs); the whole frame 33.6 s → 28.5 s, development 18.0 s → 12.4 s with the demosaic at 10.1–10.3 s (one run).
32
+
33
+ - **Captures of chart and material scenes: the sensor stage allocates less per site (#418).**
34
+ - **What changed:** the tabulated RAW path's sensor stage reseeds its charge and read-noise streams in place for each site instead of creating two new streams, sums the read-noise components in one loop instead of a mapped array, and returns each site's record without a second copy. The seeds, every draw and every addition are the same, in the same order.
35
+ - **Same bytes:** RAW codes and samples are identical (SHA-256) on a 24 MP Siemens star and a dead-leaves frame; the bit-identity hash locks and the tabulated-path tests pass. A new test checks that a reseeded stream draws what a new one does for 137 seeds, including the fallback-state seed.
36
+ - **Measured** in Node, one thread, the 2.8.0 record's request (24 MP full frame, 6 µm, 50 mm f/8, 2 × 2 box, ISO 100), built `dist`, before and after interleaved on the same host: RAW stage of a 2048 × 2048 centre region 3.21–3.24 s → 2.74–2.78 s (Siemens star) and 3.27–3.36 s → 2.80–2.89 s (dead leaves), three runs each; the whole frame 18.5 s → 16.4 s (RAW 15.5 s → 13.3 s), one run.
37
+
38
+ - **Display view parts: request tables kept across renders of a prepared view; bands sized to the part.**
39
+ - **Kept tables:** a prepared view's renders share its expected-signal tables (lattice groups, combined kernels, each material's uniform sum) and its lattice material rings, instead of rebuilding them per render. They depend only on the request, and a ring entry only on its lattice point, so concurrent renders may share them.
40
+ - **Band width:** a filled band is built as wide as the part's windows span, not the sensor's full width.
41
+ - **Same values:** a hash of 24 of the Photivra app's Quality and Speed parts (samples, diagnostics and assumptions) is unchanged; concurrent renders of a Quality chart view equal separate calls.
42
+ - **Measured:** a 1 × 1 render of the app's Quality view took 37 ms, of which 21 ms was tables and 10 ms the full-width band. The app's 48 × 48 Quality parts take 94–112 µs per display pixel instead of 115–130 µs (Node, one thread). In the app's Chromium preview (four workers), Quality becomes fully sharp in 65 s instead of 85 s, and in 71 s instead of 97 s after an f-number change; Speed is unchanged.
43
+
44
+ - **Display view refinement of chart scenes: taller lattice bands and a cheaper `sceneLookups` count.**
45
+ - **Taller bands:** a filled window's band of lattice materials and change counts is built twice as tall (when it uses the lattice ring), so the next display rows' windows reuse its columns instead of rebuilding a full-width band per display row. A lattice point's material, and the number of material changes inside a rectangle, do not depend on the band's origin, so every value is the same.
46
+ - **`sceneLookups` count:** the lookups a window's lazy cache would have made are counted from a bit mask per window row (a union of dilations is the dilation of the union) instead of marking and box-filtering the whole window per lattice group. Same count: checked against the previous method on every window of the measured band; the diagnostics are part of the display-view hash locks.
47
+ - **Measured** in Node, one thread, the Photivra app's 24 MP request with its maker-label chart: one Quality refinement band (2028 × 32 of a 2028 × 1352 view) 6.1 s → 4.1 s, with the same result hash; Speed unchanged within run-to-run variation.
48
+
49
+ - **Display view of chart and caller material scenes: lattice materials kept across refinement windows; caller scene points no longer frozen.**
50
+ - **Lattice rows kept:** at device-pixel sizes each display pixel's window spans its footprint (about 3 real pixels) plus the demosaic reach and kernel radius, so successive display rows re-read most lattice points. Each lattice group now keeps up to 128 rows of looked-up material indices (Int16 where they fit, about 3 MB for four groups of a 6000-site row) across the filled windows of a render; a material is a function of its lattice point alone, so the values are the same.
51
+ - **Points not frozen:** caller `materialAt` and `evaluate` callbacks receive a new plain point object per call, which the engine does not read afterwards. `Object.freeze` on it took about 40 % of a chart lookup (268 → 160 ns in a micro-benchmark). The point was never documented as frozen; mutating it affects nothing.
52
+ - **Same values:** all display-view hash locks and tests pass.
53
+ - **Measured** in Node, one thread, the Photivra app's 24 MP request with its maker-label chart (with the app's own label-mask speed-ups): Quality 96 × 64 coarse frame at 4 × 4 samples 42.9 s → 30.2 s; one Quality refinement band (2028 × 32 of a 2028 × 1352 view) 10.1 s → 6.3 s.
54
+
55
+ ### Faster, values within rounding
56
+
57
+ - **Diffraction kernels of large cells in real space from β = 32 (was 192).** The frequency-domain cell integration costs grow as β² (β = cell side × cut-off frequency); whole-frame and binned views of the app's Quality preset sit at β ≈ 60–190, where it took seconds per new f-number.
58
+ - **What changed:** the real-space encircled-energy integration of 2.5 now runs from β = 32. Below x = 300 it evaluates J₀² + J₁² by Miller's backward recurrence, normalized by J₀ + 2ΣJ₂ₖ = 1 (DLMF §3.6(iii), 10.12.4; free); from 300 by the Hankel expansion as before.
59
+ - **Values:** at β = 32–191 (radius 1–8) the real-space cells agree with the frequency-domain cells within 5.5e-13 (cell energies sum to 1) and change by under 1e-17 when the panel counts double; J₀² + J₁² matches Bessel's integral within 1e-11 relative at x = 0.5–12 346. Every native pitch up to 10 µm (β < 19) keeps the frequency-domain cells, so captures at sensor pitch are unchanged; binned and whole-frame views at β ≥ 32 change in the last bits. No capture hash lock changed. `quadrature.method` reports the real-space method for those wavelengths.
60
+ - **Measured** in Node, one thread, the app's 48-pixel Quality quick frame of a 24 MP full-frame sensor on a fresh process: 3.99 s → 0.62 s (the profile's cell integration was 3.4 s). Over the nine f-stops of the 50 mm lens, fresh each: 14.0 s → 4.4 s in all, slowest stop 3.65 s → 0.90 s. Per cell: radius 4, β = 160: 2.4 s → 12 ms; radius 8, β = 191: 19.5 s → 81 ms.
61
+
62
+ ## 2.8.1 — published October 25, 2026
4
63
 
5
64
  A patch release: the same results as 2.8.0, faster. It fixes a 2.8.0 slowdown before a chart or material capture's first RAW tile and writes JPEGs about 3× faster.
6
65
  - **Captures:** a 768-pixel Quality capture takes 5.6 s instead of 23.4 s on 2.8.0 (796 s on 2.7.0), and the slowest first tile measured takes 2.2 s instead of 24 s (Node, one thread).
package/CITATION.cff CHANGED
@@ -9,7 +9,7 @@ abstract: "An open TypeScript engine for the science and mathematics of photogra
9
9
  repository-code: "https://github.com/photivra/photivra"
10
10
  url: "https://photivra.com"
11
11
  license: "Apache-2.0"
12
- version: "2.8.1"
12
+ version: "2.9.0"
13
13
  keywords:
14
14
  - photography
15
15
  - camera optics
package/README.md CHANGED
@@ -32,10 +32,10 @@ The source is licensed under Apache-2.0.
32
32
 
33
33
  ## Quick start
34
34
 
35
- Install the published ESM package below. The [2.8.1 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_8_1.md) and [changelog](https://github.com/Photivra/photivra/blob/main/CHANGELOG.md) describe the current release:
35
+ Install the published ESM package below. The [2.9.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_9_0.md) and [changelog](https://github.com/Photivra/photivra/blob/main/CHANGELOG.md) describe the current release:
36
36
 
37
37
  ```sh
38
- npm install @photivra/engine@2.8.1
38
+ npm install @photivra/engine@2.9.0
39
39
  ```
40
40
 
41
41
  Simulate a photograph of a test chart with a generic camera, then read the developed sRGB pixels:
@@ -75,7 +75,7 @@ console.log(horizontal.provenance);
75
75
 
76
76
  Start with the [Developer Guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md). Then use the [Usage Guide](https://github.com/Photivra/photivra/blob/main/docs/USAGE.md) and the [API reference](https://github.com/Photivra/photivra/blob/main/docs/API_REFERENCE.md), which is generated from the package exports.
77
77
 
78
- Repository `main` may describe newer, unreleased work. For the stable contract, use the docs bundled in the published package, or the exact `v2.8.1` tag.
78
+ Repository `main` may describe newer, unreleased work. For the stable contract, use the docs bundled in the published package, or the exact `v2.9.0` tag.
79
79
 
80
80
  ## Documentation
81
81
 
@@ -363,13 +363,13 @@ The application can then use those results, while the photography calculations t
363
363
 
364
364
  ## Status
365
365
 
366
- - Repository package version: `2.8.1` (release candidate prepared October 25, 2026; publication is separate)
367
- - Engine API contract: `2.8.1`
366
+ - Repository package version: `2.9.0` (release candidate prepared October 25, 2026; publication is separate)
367
+ - Engine API contract: `2.9.0`
368
368
  - Versioned generic tier reference assets: [GENERIC_TIER_PRESETS.md](https://github.com/Photivra/photivra/blob/main/docs/GENERIC_TIER_PRESETS.md).
369
369
  - Composed POC simulation API contract: `1.0.0`
370
370
  - Stability: 1.0 root-package compatibility policy; bounded scientific models and independent POC contract
371
371
 
372
- From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.8.1; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
372
+ From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.9.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
373
373
 
374
374
  A breaking change to the root contracts now needs a new package major version. Additive and corrective changes follow [compatibility and migration](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_1_3_0.md). A 1.0 package is not a claim of calibrated physical accuracy.
375
375
 
@@ -505,3 +505,5 @@ Source code in this repository is licensed under Apache-2.0 unless a file clearl
505
505
  This product includes DNG technology under license by Adobe.
506
506
 
507
507
  The Apache license does not grant trademark rights to the Photivra name, logos or branding, except as allowed by applicable law and the license itself.
508
+
509
+ Outside contributions are merged only under the project's contributor licence agreement; see [Licensing of contributions](https://github.com/Photivra/photivra/blob/main/CONTRIBUTING.md#licensing-of-contributions).
package/SECURITY.md CHANGED
@@ -4,7 +4,7 @@ Please do not open a public issue that contains vulnerability details.
4
4
 
5
5
  ## Supported versions
6
6
 
7
- The published 1.0.0 release started the 1.x root-package compatibility line, and 2.0.0 started the 2.x line. The current release is **2.8.0**, published on October 25, 2026 (see [CHANGELOG.md](CHANGELOG.md)).
7
+ The published 1.0.0 release started the 1.x root-package compatibility line, and 2.0.0 started the 2.x line. The current release is **2.8.1**, published on October 25, 2026 (see [CHANGELOG.md](CHANGELOG.md)).
8
8
 
9
9
  Security fixes target the current maintained development and release line. Do not assume that older 1.x releases, snapshots or pre-1.0 releases will receive fixes.
10
10
 
@@ -32,6 +32,24 @@ export interface ExportJpegInput {
32
32
  }
33
33
  /** Encodes a baseline JPEG with a directly computed DCT. Uses no third-party encoder source, default quantization tables or Huffman tables. */
34
34
  export declare function encodeExportJpeg(input: ExportJpegInput): Uint8Array;
35
+ /**
36
+ * The entropy-coded bits of consecutive blocks of one image, coded apart from the blocks before them (`encodeJpegStrip`):
37
+ * the first block's quantized coefficients (3 × 64, natural order), whose DC differences need the block before; the other
38
+ * blocks' bits, unstuffed, most-significant first; and the last block's DC values, which the next strip's first block needs.
39
+ */
40
+ export interface JpegStripBits {
41
+ first: Float64Array;
42
+ bits: Uint8Array;
43
+ bitLength: number;
44
+ blockCount: number;
45
+ lastDc: Float64Array;
46
+ }
47
+ /**
48
+ * Codes `blockCount` consecutive blocks of an image (`block(i, out)` writes block i's 192 RGB samples, 0–255) apart from the
49
+ * blocks before them, for writing an image's strips on separate workers; `createExportJpegEncoder`'s `appendStrip` joins
50
+ * them into the bytes the whole image's `writeRgbBlock` calls would have written.
51
+ */
52
+ export declare function encodeJpegStrip(quantizationStep: number, blockCount: number, block: (index: number, out: Uint8Array) => void): JpegStripBits;
35
53
  /**
36
54
  * Internal size-limited JPEG writer. The reference export and the scalable export use exactly the same
37
55
  * DCT and entropy-coding equations.
@@ -47,6 +65,7 @@ export declare function createExportJpegEncoder(input: Omit<ExportJpegInput, "sa
47
65
  densityDotsPerInch?: number;
48
66
  }): {
49
67
  writeRgbBlock(samples: readonly number[]): void;
68
+ appendStrip(strip: JpegStripBits): void;
50
69
  finish(): Uint8Array;
51
70
  dispose(): void;
52
71
  };
@@ -52,6 +52,175 @@ export function encodeExportJpeg(input) {
52
52
  }
53
53
  return encoder.finish();
54
54
  }
55
+ /**
56
+ * The DCT, quantization and entropy coding of one 8 × 8 RGB block, shared by the whole-image writer and strips so both
57
+ * make the same bits.
58
+ *
59
+ * The 8×8 DCT, computed directly, with orthonormal DC factors and one chosen quantization step.
60
+ *
61
+ * This is a deliberately simple 4:4:4 reference encoder. Lossy quantization means exact RGB pixels are
62
+ * not preserved. Decoding the JPEG and comparing is an interoperability check, not an exact scientific
63
+ * check of pixel values.
64
+ *
65
+ * Each coefficient is the direct 64-term sum Σ_y Σ_x (f(x,y)·C(u,x))·C(v,y), summed over y, then x.
66
+ * The row products f(x,y)·C(u,x) are computed once per u and reused for all eight v. This reuses the
67
+ * identical IEEE-754 products and keeps the exact order of multiplication and summation. A factored
68
+ * row/column DCT would reorder the sum, so it is deliberately not used.
69
+ *
70
+ * C(u,x) comes from the fixed table `JPEG_DCT_COSINES` (correctly rounded constants), not `Math.cos`,
71
+ * so the coefficients do not depend on the JavaScript engine's maths library. Each quantized coefficient
72
+ * is rounded half away from zero (`roundJpegCoefficient`).
73
+ */
74
+ function createJpegBlockCoder(q, emit) {
75
+ const amplitude = (value, max) => {
76
+ const size = value === 0 ? 0 : Math.floor(Math.log2(Math.abs(value))) + 1;
77
+ if (size > max)
78
+ throw new InvalidConfigurationError("JPEG coefficient exceeds baseline entropy range.");
79
+ return size;
80
+ };
81
+ const coefficient = (value, size) => { if (size)
82
+ emit(value >= 0 ? value : value + (1 << size) - 1, size); };
83
+ const zigzag = new Int32Array(64);
84
+ for (let diagonal = 0, i = 0; diagonal <= 14; diagonal++) {
85
+ const points = [];
86
+ for (let y = 0; y < 8; y++) {
87
+ const x = diagonal - y;
88
+ if (x >= 0 && x < 8)
89
+ points.push(y * 8 + x);
90
+ }
91
+ for (const point of diagonal % 2 === 0 ? points.reverse() : points)
92
+ zigzag[i++] = point;
93
+ }
94
+ // Huffman symbol index of each AC (run, size) pair; equals the index of the symbol in the DHT segment's AC list.
95
+ const ac = [0, 240];
96
+ for (let run = 0; run < 16; run++)
97
+ for (let size = 1; size <= 10; size++)
98
+ ac.push(run * 16 + size);
99
+ const acIndex = new Int16Array(256).fill(-1);
100
+ for (let i = ac.length - 1; i >= 0; i--)
101
+ acIndex[ac[i]] = i;
102
+ const cosine = JPEG_DCT_COSINES;
103
+ const planes = [new Float64Array(64), new Float64Array(64), new Float64Array(64)];
104
+ const blueScale = 2 * (1 - .114), redScale = 2 * (1 - .299);
105
+ return {
106
+ transform(samples, out) {
107
+ const [luma, blueDifference, redDifference] = planes;
108
+ for (let i = 0; i < 64; i++) {
109
+ // JPEG pads only partial 8x8 blocks (MCUs, minimum coded units). The declared image and crop size never change.
110
+ const p = i * 3;
111
+ const red = samples[p], green = samples[p + 1], blue = samples[p + 2], luminance = .299 * red + .587 * green + .114 * blue;
112
+ luma[i] = luminance - 128;
113
+ blueDifference[i] = (blue - luminance) / blueScale;
114
+ redDifference[i] = (red - luminance) / redScale;
115
+ }
116
+ for (let channel = 0; channel < 3; channel++) {
117
+ const block = planes[channel], o = channel * 64;
118
+ for (let u = 0; u < 8; u++) {
119
+ const uOffset = u * 8;
120
+ const uFactor = u === 0 ? Math.SQRT1_2 : 1;
121
+ // The eight v sums in one pass over (y, x): each sum still adds the same products f(x,y)·C(u,x)·C(v,y) in the same
122
+ // order (y, then x), and each row product is the same IEEE product, formed once per (x, y) instead of stored.
123
+ let s0 = 0, s1 = 0, s2 = 0, s3 = 0, s4 = 0, s5 = 0, s6 = 0, s7 = 0;
124
+ for (let y = 0; y < 8; y++) {
125
+ const c0 = cosine[y], c1 = cosine[8 + y], c2 = cosine[16 + y], c3 = cosine[24 + y], c4 = cosine[32 + y], c5 = cosine[40 + y], c6 = cosine[48 + y], c7 = cosine[56 + y];
126
+ for (let x = 0; x < 8; x++) {
127
+ const r = block[y * 8 + x] * cosine[uOffset + x];
128
+ s0 += r * c0;
129
+ s1 += r * c1;
130
+ s2 += r * c2;
131
+ s3 += r * c3;
132
+ s4 += r * c4;
133
+ s5 += r * c5;
134
+ s6 += r * c6;
135
+ s7 += r * c7;
136
+ }
137
+ }
138
+ // Scaled as before, left to right: sum·0.25·(u factor)·(v factor)/q.
139
+ out[o + u] = roundJpegCoefficient(s0 * .25 * uFactor * Math.SQRT1_2 / q);
140
+ out[o + 8 + u] = roundJpegCoefficient(s1 * .25 * uFactor * 1 / q);
141
+ out[o + 16 + u] = roundJpegCoefficient(s2 * .25 * uFactor * 1 / q);
142
+ out[o + 24 + u] = roundJpegCoefficient(s3 * .25 * uFactor * 1 / q);
143
+ out[o + 32 + u] = roundJpegCoefficient(s4 * .25 * uFactor * 1 / q);
144
+ out[o + 40 + u] = roundJpegCoefficient(s5 * .25 * uFactor * 1 / q);
145
+ out[o + 48 + u] = roundJpegCoefficient(s6 * .25 * uFactor * 1 / q);
146
+ out[o + 56 + u] = roundJpegCoefficient(s7 * .25 * uFactor * 1 / q);
147
+ }
148
+ }
149
+ },
150
+ code(coefficients, previous) {
151
+ for (let channel = 0; channel < 3; channel++) {
152
+ const o = channel * 64, difference = coefficients[o] - previous[channel], size = amplitude(difference, 11);
153
+ emit(size, 4);
154
+ coefficient(difference, size);
155
+ previous[channel] = coefficients[o];
156
+ let zeroes = 0;
157
+ for (let i = 1; i < 64; i++) {
158
+ const value = coefficients[o + zigzag[i]];
159
+ if (value === 0) {
160
+ zeroes++;
161
+ continue;
162
+ }
163
+ while (zeroes >= 16) {
164
+ emit(1, 8);
165
+ zeroes -= 16;
166
+ }
167
+ const length = amplitude(value, 10), symbol = zeroes * 16 + length;
168
+ emit(acIndex[symbol], 8);
169
+ coefficient(value, length);
170
+ zeroes = 0;
171
+ }
172
+ if (zeroes)
173
+ emit(0, 8);
174
+ }
175
+ }
176
+ };
177
+ }
178
+ /**
179
+ * Codes `blockCount` consecutive blocks of an image (`block(i, out)` writes block i's 192 RGB samples, 0–255) apart from the
180
+ * blocks before them, for writing an image's strips on separate workers; `createExportJpegEncoder`'s `appendStrip` joins
181
+ * them into the bytes the whole image's `writeRgbBlock` calls would have written.
182
+ */
183
+ export function encodeJpegStrip(quantizationStep, blockCount, block) {
184
+ if (!Number.isSafeInteger(quantizationStep) || quantizationStep < 1 || quantizationStep > 255 || !Number.isSafeInteger(blockCount) || blockCount < 1)
185
+ throw new InvalidConfigurationError("Invalid JPEG strip plan.");
186
+ let storage = new Uint8Array(65_536), length = 0, accumulator = 0, bits = 0, bitLength = 0;
187
+ // Unstuffed bits, most-significant first.
188
+ const emit = (value, count) => {
189
+ accumulator = (accumulator << count) | (value & ((1 << count) - 1));
190
+ bits += count;
191
+ bitLength += count;
192
+ while (bits >= 8) {
193
+ bits -= 8;
194
+ if (length === storage.length) {
195
+ const grown = new Uint8Array(storage.length * 2);
196
+ grown.set(storage);
197
+ storage = grown;
198
+ }
199
+ storage[length++] = (accumulator >>> bits) & 255;
200
+ }
201
+ accumulator &= (1 << bits) - 1;
202
+ };
203
+ const coder = createJpegBlockCoder(quantizationStep, emit), samples = new Uint8Array(192), first = new Float64Array(192), coefficients = new Float64Array(192), lastDc = new Float64Array(3);
204
+ block(0, samples);
205
+ coder.transform(samples, first);
206
+ lastDc[0] = first[0];
207
+ lastDc[1] = first[64];
208
+ lastDc[2] = first[128];
209
+ for (let i = 1; i < blockCount; i++) {
210
+ block(i, samples);
211
+ coder.transform(samples, coefficients);
212
+ coder.code(coefficients, lastDc);
213
+ }
214
+ if (bits) {
215
+ if (length === storage.length) {
216
+ const grown = new Uint8Array(storage.length + 1);
217
+ grown.set(storage);
218
+ storage = grown;
219
+ }
220
+ storage[length++] = (accumulator << (8 - bits)) & 255;
221
+ }
222
+ return { first, bits: storage.slice(0, length), bitLength, blockCount, lastDc };
223
+ }
55
224
  /**
56
225
  * Largest byte budget the internal writer accepts: the Print JPEG budget ceiling at the largest opt-in source
57
226
  * (256 000 000 bytes per 24 000 000 pixels at 2^27 pixels). Each caller enforces its own smaller budget.
@@ -132,125 +301,38 @@ export function createExportJpegEncoder(input) {
132
301
  }
133
302
  accumulator &= (1 << bits) - 1;
134
303
  };
135
- const amplitude = (value, max) => {
136
- const size = value === 0 ? 0 : Math.floor(Math.log2(Math.abs(value))) + 1;
137
- if (size > max)
138
- throw new InvalidConfigurationError("JPEG coefficient exceeds baseline entropy range.");
139
- return size;
140
- };
141
- const coefficient = (value, size) => { if (size)
142
- emit(value >= 0 ? value : value + (1 << size) - 1, size); };
143
- const zigzag = new Int32Array(64);
144
- for (let diagonal = 0, i = 0; diagonal <= 14; diagonal++) {
145
- const points = [];
146
- for (let y = 0; y < 8; y++) {
147
- const x = diagonal - y;
148
- if (x >= 0 && x < 8)
149
- points.push(y * 8 + x);
150
- }
151
- for (const point of diagonal % 2 === 0 ? points.reverse() : points)
152
- zigzag[i++] = point;
153
- }
154
- // Huffman symbol index of each AC (run, size) pair; equals ac.indexOf(symbol) for every emitted symbol.
155
- const acIndex = new Int16Array(256).fill(-1);
156
- for (let i = ac.length - 1; i >= 0; i--)
157
- acIndex[ac[i]] = i;
158
- /**
159
- * The 8×8 DCT, computed directly, with orthonormal DC factors and one chosen quantization step.
160
- *
161
- * This is a deliberately simple 4:4:4 reference encoder. Lossy quantization means exact RGB pixels are
162
- * not preserved. Decoding the JPEG and comparing is an interoperability check, not an exact scientific
163
- * check of pixel values.
164
- *
165
- * Each coefficient is the direct 64-term sum Σ_y Σ_x (f(x,y)·C(u,x))·C(v,y), summed over y, then x.
166
- * The row products f(x,y)·C(u,x) are computed once per u and reused for all eight v. This reuses the
167
- * identical IEEE-754 products and keeps the exact order of multiplication and summation. A factored
168
- * row/column DCT would reorder the sum, so it is deliberately not used.
169
- *
170
- * C(u,x) comes from the fixed table `JPEG_DCT_COSINES` (correctly rounded constants), not `Math.cos`,
171
- * so the coefficients do not depend on the JavaScript engine's maths library. Each quantized coefficient
172
- * is rounded half away from zero (`roundJpegCoefficient`).
173
- */
174
- const cosine = JPEG_DCT_COSINES;
304
+ const coder = createJpegBlockCoder(q, emit), coefficients = new Float64Array(192);
175
305
  const previous = [0, 0, 0];
176
- const planes = [new Float64Array(64), new Float64Array(64), new Float64Array(64)];
177
- const transformed = new Float64Array(64);
178
- const blueScale = 2 * (1 - .114), redScale = 2 * (1 - .299);
306
+ const totalBlocks = Math.ceil(width / 8) * Math.ceil(height / 8);
179
307
  return {
180
308
  writeRgbBlock(samples) {
181
- let valid = !disposed && blocks < Math.ceil(width / 8) * Math.ceil(height / 8) && samples.length === 192;
309
+ let valid = !disposed && blocks < totalBlocks && samples.length === 192;
182
310
  for (let i = 0; valid && i < 192; i++) {
183
311
  const v = samples[i];
184
312
  valid = Number.isInteger(v) && v >= 0 && v <= 255;
185
313
  }
186
314
  if (!valid)
187
315
  throw new InvalidConfigurationError("JPEG requires complete bounded RGB coding blocks.");
188
- const [luma, blueDifference, redDifference] = planes;
189
- for (let i = 0; i < 64; i++) {
190
- // JPEG pads only partial 8x8 blocks (MCUs, minimum coded units). The declared image and crop size never change.
191
- const p = i * 3;
192
- const red = samples[p], green = samples[p + 1], blue = samples[p + 2], luminance = .299 * red + .587 * green + .114 * blue;
193
- luma[i] = luminance - 128;
194
- blueDifference[i] = (blue - luminance) / blueScale;
195
- redDifference[i] = (red - luminance) / redScale;
196
- }
197
- for (let channel = 0; channel < 3; channel++) {
198
- const block = planes[channel];
199
- for (let u = 0; u < 8; u++) {
200
- const uOffset = u * 8;
201
- const uFactor = u === 0 ? Math.SQRT1_2 : 1;
202
- // The eight v sums in one pass over (y, x): each sum still adds the same products f(x,y)·C(u,x)·C(v,y) in the same
203
- // order (y, then x), and each row product is the same IEEE product, formed once per (x, y) instead of stored.
204
- let s0 = 0, s1 = 0, s2 = 0, s3 = 0, s4 = 0, s5 = 0, s6 = 0, s7 = 0;
205
- for (let y = 0; y < 8; y++) {
206
- const c0 = cosine[y], c1 = cosine[8 + y], c2 = cosine[16 + y], c3 = cosine[24 + y], c4 = cosine[32 + y], c5 = cosine[40 + y], c6 = cosine[48 + y], c7 = cosine[56 + y];
207
- for (let x = 0; x < 8; x++) {
208
- const r = block[y * 8 + x] * cosine[uOffset + x];
209
- s0 += r * c0;
210
- s1 += r * c1;
211
- s2 += r * c2;
212
- s3 += r * c3;
213
- s4 += r * c4;
214
- s5 += r * c5;
215
- s6 += r * c6;
216
- s7 += r * c7;
217
- }
218
- }
219
- // Scaled as before, left to right: sum·0.25·(u factor)·(v factor)/q.
220
- transformed[u] = roundJpegCoefficient(s0 * .25 * uFactor * Math.SQRT1_2 / q);
221
- transformed[8 + u] = roundJpegCoefficient(s1 * .25 * uFactor * 1 / q);
222
- transformed[16 + u] = roundJpegCoefficient(s2 * .25 * uFactor * 1 / q);
223
- transformed[24 + u] = roundJpegCoefficient(s3 * .25 * uFactor * 1 / q);
224
- transformed[32 + u] = roundJpegCoefficient(s4 * .25 * uFactor * 1 / q);
225
- transformed[40 + u] = roundJpegCoefficient(s5 * .25 * uFactor * 1 / q);
226
- transformed[48 + u] = roundJpegCoefficient(s6 * .25 * uFactor * 1 / q);
227
- transformed[56 + u] = roundJpegCoefficient(s7 * .25 * uFactor * 1 / q);
228
- }
229
- const difference = transformed[0] - previous[channel], size = amplitude(difference, 11);
230
- emit(size, 4);
231
- coefficient(difference, size);
232
- previous[channel] = transformed[0];
233
- let zeroes = 0;
234
- for (let i = 1; i < 64; i++) {
235
- const value = transformed[zigzag[i]];
236
- if (value === 0) {
237
- zeroes++;
238
- continue;
239
- }
240
- while (zeroes >= 16) {
241
- emit(1, 8);
242
- zeroes -= 16;
243
- }
244
- const length = amplitude(value, 10), symbol = zeroes * 16 + length;
245
- emit(acIndex[symbol], 8);
246
- coefficient(value, length);
247
- zeroes = 0;
248
- }
249
- if (zeroes)
250
- emit(0, 8);
251
- }
316
+ coder.transform(samples, coefficients);
317
+ coder.code(coefficients, previous);
252
318
  blocks++;
253
319
  },
320
+ appendStrip(strip) {
321
+ if (disposed || blocks + strip.blockCount > totalBlocks || strip.first.length !== 192 || strip.lastDc.length !== 3 || strip.bits.length * 8 < strip.bitLength)
322
+ throw new InvalidConfigurationError("JPEG strip does not continue this encoding.");
323
+ // The strip's first block, coded against the DC values before it; then its other blocks' bits, which only depend on
324
+ // values inside the strip, as written (each byte's 0xFF stuffing happens here, as in writeRgbBlock).
325
+ coder.code(strip.first, previous);
326
+ const whole = strip.bitLength >> 3;
327
+ for (let i = 0; i < whole; i++)
328
+ emit(strip.bits[i], 8);
329
+ if (strip.bitLength & 7)
330
+ emit(strip.bits[whole] >> (8 - (strip.bitLength & 7)), strip.bitLength & 7);
331
+ previous[0] = strip.lastDc[0];
332
+ previous[1] = strip.lastDc[1];
333
+ previous[2] = strip.lastDc[2];
334
+ blocks += strip.blockCount;
335
+ },
254
336
  finish() {
255
337
  if (disposed || blocks !== Math.ceil(width / 8) * Math.ceil(height / 8))
256
338
  throw new InvalidConfigurationError("JPEG cannot finish incomplete or disposed encoding.");