@photivra/engine 2.8.1 → 2.10.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 (91) hide show
  1. package/CHANGELOG.md +144 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +8 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +2 -0
  6. package/dist/capture/export-jpeg.d.ts +19 -0
  7. package/dist/capture/export-jpeg.js +190 -108
  8. package/dist/capture/export-jpeg.js.map +1 -1
  9. package/dist/capture/native-environment-lean.js +106 -12
  10. package/dist/capture/native-environment-lean.js.map +1 -1
  11. package/dist/capture/native-environment-tabulated.d.ts +37 -0
  12. package/dist/capture/native-environment-tabulated.js +42 -6
  13. package/dist/capture/native-environment-tabulated.js.map +1 -1
  14. package/dist/core/portable-math.d.ts +13 -0
  15. package/dist/core/portable-math.js +43 -0
  16. package/dist/core/portable-math.js.map +1 -1
  17. package/dist/core/version.d.ts +1 -1
  18. package/dist/core/version.js +1 -1
  19. package/dist/core/version.js.map +1 -1
  20. package/dist/facade/channel-response.d.ts +15 -0
  21. package/dist/facade/channel-response.js +33 -0
  22. package/dist/facade/channel-response.js.map +1 -0
  23. package/dist/facade/diffraction-provider.js +50 -15
  24. package/dist/facade/diffraction-provider.js.map +1 -1
  25. package/dist/facade/display-view.d.ts +75 -0
  26. package/dist/facade/display-view.js +471 -109
  27. package/dist/facade/display-view.js.map +1 -1
  28. package/dist/facade/expected-signal-setup.js +2 -1
  29. package/dist/facade/expected-signal-setup.js.map +1 -1
  30. package/dist/facade/expected-signal.d.ts +36 -4
  31. package/dist/facade/expected-signal.js +331 -43
  32. package/dist/facade/expected-signal.js.map +1 -1
  33. package/dist/facade/scene-radiance.d.ts +4 -1
  34. package/dist/facade/scene-radiance.js +4 -4
  35. package/dist/facade/scene-radiance.js.map +1 -1
  36. package/dist/facade/simulate-capture.d.ts +77 -5
  37. package/dist/facade/simulate-capture.js +232 -40
  38. package/dist/facade/simulate-capture.js.map +1 -1
  39. package/dist/facade/simulate-display-view.d.ts +25 -2
  40. package/dist/facade/simulate-display-view.js +55 -21
  41. package/dist/facade/simulate-display-view.js.map +1 -1
  42. package/dist/gpu/image-kernel-execution.d.ts +5 -0
  43. package/dist/gpu/image-kernel-execution.js +2 -2
  44. package/dist/gpu/image-kernel-execution.js.map +1 -1
  45. package/dist/index.d.ts +5 -4
  46. package/dist/index.js +3 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/optics/diffraction-psf-profile.d.ts +19 -17
  49. package/dist/optics/diffraction-psf-profile.js +49 -18
  50. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  51. package/dist/optics/irradiance-convolution.d.ts +11 -0
  52. package/dist/optics/irradiance-convolution.js +64 -24
  53. package/dist/optics/irradiance-convolution.js.map +1 -1
  54. package/dist/optics/scene-to-sensor-irradiance.d.ts +13 -0
  55. package/dist/optics/scene-to-sensor-irradiance.js +58 -24
  56. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  57. package/dist/optics/sensor-aperture-rays.js +2 -1
  58. package/dist/optics/sensor-aperture-rays.js.map +1 -1
  59. package/dist/optics/sensor-environment-query.js +4 -3
  60. package/dist/optics/sensor-environment-query.js.map +1 -1
  61. package/dist/optics/site-diffraction-kernel.d.ts +16 -0
  62. package/dist/optics/site-diffraction-kernel.js +60 -8
  63. package/dist/optics/site-diffraction-kernel.js.map +1 -1
  64. package/dist/output/print-jpeg.d.ts +28 -1
  65. package/dist/output/print-jpeg.js +79 -7
  66. package/dist/output/print-jpeg.js.map +1 -1
  67. package/dist/processing/demosaic.js +114 -37
  68. package/dist/processing/demosaic.js.map +1 -1
  69. package/dist/schema/scene-radiance.d.ts +8 -0
  70. package/dist/schema/scene-radiance.js +28 -17
  71. package/dist/schema/scene-radiance.js.map +1 -1
  72. package/dist/sensor/environment-photo-signal.d.ts +21 -0
  73. package/dist/sensor/environment-photo-signal.js +68 -2
  74. package/dist/sensor/environment-photo-signal.js.map +1 -1
  75. package/dist/sensor/stochastic-seed.d.ts +12 -0
  76. package/dist/sensor/stochastic-seed.js +25 -0
  77. package/dist/sensor/stochastic-seed.js.map +1 -1
  78. package/docs/API_REFERENCE.md +23 -1
  79. package/docs/QUICK_START.md +77 -8
  80. package/docs/USAGE.md +30 -0
  81. package/docs/api/capture-export-jpeg.md +10 -0
  82. package/docs/api/core-version.md +1 -1
  83. package/docs/api/exports.json +77 -2
  84. package/docs/api/facade-display-view.md +50 -0
  85. package/docs/api/facade-scene-radiance.md +2 -1
  86. package/docs/api/facade-simulate-capture.md +88 -8
  87. package/docs/api/facade-simulate-display-view.md +23 -1
  88. package/docs/api/gpu-image-kernel-execution.md +5 -0
  89. package/docs/api/optics-diffraction-psf-profile.md +1 -1
  90. package/docs/api/output-print-jpeg.md +41 -0
  91. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,149 @@
1
1
  # Changelog
2
2
 
3
- ## 2.8.1 — unreleased (prepared October 25, 2026)
3
+ ## 2.10.0 — unreleased (prepared October 27, 2026)
4
+
5
+ A minor release: one exact-or-fast flag for every slow option (no option has a fast method yet, so every result is still exact), export and import of the display view's AHD correction states so workers can share them, and faster display-view prepares and parts and faster Quality captures. Samples, RAW codes and files are the same as 2.9.0; captures and display views gain the `diagnostics.fast` report.
6
+ - **New:** `fast`, `SIMULATE_CAPTURE_FAST_OPTIONS` and `diagnostics.fast` (#395, stage 2). `serializeDisplayViewCorrectionStates`, `bindDisplayViewCorrectionStates` and three `PreparedDisplayView` methods share the AHD correction states between workers (#447); no speed-up is claimed until the app measures it.
7
+ - **Faster prepare** (Node, one thread): `prepareDisplayView` after an aperture change takes 54–67 ms instead of 378–430 ms in Quality (#443), and after an ISO, white-balance or shutter change 9–16 ms instead of 37–45 ms at 6 µm f/11 (#446).
8
+ - **Faster parts and captures** (Node, one thread, each against the build before it): Quality display-view part medians −8 to −14 % (#441), −3 to −11 % (#445) and −3 to −8 % (#442); Speed parts −4 to −7 % (#442) and up to −9 % whole-view CPU (#444); the AHD demosaic stage −40 % (#448); the app's Quality 96 × 64 detail tile median 128 → 115 ms (#449, first part) and 116 → 95 ms (#449, second part), then 102 → 90 ms with the verification sites' chain re-validating less (#453). Browser wall time is not measured.
9
+ - **Compatibility:** eight new root exports (1,742); new optional `fast` fields and an `output.demosaic` record form; `lens.diffraction`'s `aperture` key is deprecated in favour of `kind`. Query points use a portable `hypot3`, unchanged on V8 (#452).
10
+
11
+ ### Added
12
+
13
+ - **One exact-or-fast flag for every slow option (#395, stage 2): `fast`, `SIMULATE_CAPTURE_FAST_OPTIONS` and `diagnostics.fast`.** No option has a fast method yet, so every result is the same as before.
14
+ - **The flag:** `fast?: boolean` on the `simulateCapture` request (and so on `simulateDisplayView` and `prepareDisplayView`, which take the same request). `false` or omitted is exact, the engine reference; `true` asks each listed option for its fast method, a measured, bounded approximation. An option's record `fast` overrides the top-level flag for that option only.
15
+ - **The options:** `SIMULATE_CAPTURE_FAST_OPTIONS` lists `lens.diffraction`, `sensor.antiAliasing`, `sensor.pixelAperture.box` and `output.demosaic.ahd`. Each takes a record with `fast`: `lens.diffraction` `{ kind: "circular-aperture", kernelRadiusSamples?, fast? }`, `output.demosaic` (new record form) `{ kind: "bilinear" | "ahd", fast? }`, and the existing `sensor.antiAliasing` and `sensor.pixelAperture` records gain `fast?`. An option without a fast method accepts the flag, runs exact and reports `false`.
16
+ - **The report:** captures and display views carry `diagnostics.fast = { requested, applied, assurance }`: whether `fast: true` was given anywhere, each listed option that ran (true where its fast method ran) and `engine-reference` or `approximation-with-a-priori-bound`. The WebGPU image-kernel execution record gains `fast` (`false` for `cpu-reference`, `true` for `webgpu-f32`).
17
+ - **Same output:** every request gives the same samples, RAW and files as before; a new test (`test/fast-flag-convention.test.ts`) checks each listed option with the flag on, off and overridden, and enforces the convention documented in `docs/API_STYLE.md`, "Exact and fast modes".
18
+ - New root exports: `SIMULATE_CAPTURE_FAST_OPTIONS` and the types `SimulateCaptureFastOption`, `SimulateCaptureFastReport` and `SimulateCaptureDemosaicRecord`.
19
+
20
+ - **Display-view benchmark and validation on a device-pixel request (#454).** Measurement and documentation only; no result changes.
21
+ - **Benchmark:** `node scripts/benchmark-display-view.mjs --app` times a 2028 × 1352 view of a 24 MP, 6 µm frame with the 2 × 2 box, a caller material scene and a stand-in label, prepared once and rendered in 96 × 96 parts centre first, in Speed and Quality at f/8 and f/11. It prints per-part medians and maxima, µs per display pixel, the estimation method, AHD-corrected pixels, the load average, the Node version and the commit; with `DIST_B` it runs a second build interleaved. Measured on 2.9.0 (Node 24.21.0, one thread, 4-vCPU Xeon): Speed about 0.09 s per part, Quality about 0.18 s, prepare 0.2 s and 0.5 s. The 2.6.0 whole-view table is now labelled as the 2.6.0 record.
22
+ - **Validation set F:** `node scripts/validate-display-view.mjs --set F` (outside the default run) checks the view at device-pixel widths on the full-frame (2028 wide) and medium-format (4000 wide) sensors. Speed passes every limit. Quality misses two, now recorded in `docs/DISPLAY_VIEW.md` and `docs/validation/display-view-2026-10-26/`: the predicted noise is 1.13–1.20 of the reference's (the known limit, #379), and at the Siemens star's centre the view's AHD result differs from the capture's by 5.1 codes RMS, at most 34 (full frame).
23
+ - **Test:** parts of a prepared device-pixel view of a caller material scene with the box and the Quality options equal separate `simulateDisplayView` calls, by SHA-256 (`test/display-view-prepared.test.ts`).
24
+
25
+ - **Display view: export, import, collect and compute of the AHD low-signal correction states, so workers can split and share them (#447).** No result changes.
26
+ - **Why:** each correction state costs 27–30 ms (2048 drawn neighbourhoods through the AHD steps; Node, one thread, measured for #447), and the states are kept per JavaScript realm. Each Web Worker of an app computed the same states again, and each worker's first part waited for all of them: 897 states for one 48 × 48 part at the centre of the app's Quality view at 6 µm ISO 400.
27
+ - **The API:** `serializeDisplayViewCorrectionStates()` returns one `{ key, gridKeys, values }` entry per cached setup (an `Int32Array` of grid keys and a `Float64Array` of three values per state, 28 bytes per state, both transferable). `bindDisplayViewCorrectionStates(entries)` adds the states a setup does not hold yet and returns the count added. It checks every entry first (a non-empty key, grid keys in [0, 97³), 3 × that many finite values) and fails with an `InvalidConfigurationError`, leaving the cache unchanged. The cache's bounds apply as in a render (4 setups, 65 536 states each), so import only keys a prepared view reported. `PreparedDisplayView` gains `correctionKey()` (null without AHD), `collectCorrectionStates(view)` (the grid keys of every state a render of the view reads, from a render pass that records them instead of computing them; it returns no image and leaves the cache untouched) and `computeCorrectionStates(gridKeys, { signal })` (computes the given states as a render does, stores them and returns them; it yields and can be aborted like `render`). Documented in `docs/DISPLAY_VIEW.md`, "AHD at low signal", with a two-worker example in `docs/USAGE.md`.
28
+ - **Same output:** renders are unchanged. The state computation moved, unchanged, from the render into a shared function used by the render and `computeCorrectionStates`. Samples, expected values, noise, every diagnostic (including `sceneLookups` and `ahdCorrectedPixelCount`) and the assumptions are identical (SHA-256) to the previous build on 12 renders: the issue's comparison set (the app's Quality request at ISO 3200 at a frame corner and in the chart area, at 1.4 µm in 16 bits at the centre, at 6 µm ISO 400 on the coarse 192 × 128 view at the centre and the bottom-right corner, and at 6 µm ISO 200 and ISO 400 at the centre in 8 and 16 bits; spectral patches with mirror borders at 2 µm ISO 1600; a radiance-function scene at 1.4 µm ISO 3200; dead leaves with the Reinhard tone curve at 4 µm ISO 6400 in 16 bits). Each of the 12 also has the same hash when its states are collected, computed in two halves in two cleared caches (one half in reversed order), exchanged by export, `structuredClone` and import in reversed order, and then rendered; the collect pass returned exactly the states of the cold render (0 to 914 per render). The existing hash locks pass unchanged. New tests in `test/display-view.test.ts` render a region after importing a neighbour's states in reversed order and after merging two realms' sets, and compare it with a cold render. They also check that the collect pass returns exactly the states a cold render computes, that two realms hold the same bits for common states, and that the import rejects malformed states and leaves the cache unchanged.
29
+ - **Measured** (the app's Quality request, one 48 × 48 part at the view centre, a fresh process per run, against the previous build a58cbe8, three rounds alternating the builds; Node 24.21.0, one thread, 4-vCPU Xeon, load average 0.6–1.0): the per-state cost (cold minus warm render time over the states) is unchanged, 25.10–25.30 → 25.07–25.29 ms at 6 µm ISO 400 (764 states) and 27.4–30.9 → 27.4–29.2 ms at ISO 200 (32 states). The collect pass took 209–217, 126–132 and 76–77 ms in three successive calls, against 129–139, 100–105 and 94–98 ms for a warm render of the same part. `structuredClone` of the exported set (764 states, 21,392 bytes) took 10–11 µs at the median and 15–16 µs at the 90th percentile of 200 clones; serializing it took 0.19–0.21 ms. No speed-up is claimed: any gain in the app depends on its workers splitting and exchanging the states (Photivra/apps#113), which has to be measured in the browser.
30
+ - **Exports:** `serializeDisplayViewCorrectionStates` (the export), `bindDisplayViewCorrectionStates` (the import) and the types `DisplayViewCorrectionStateSet` and `DisplayViewCorrectionStateKeys`; root exports grow from 1,738 to 1,742. The two functions take the `serialize` and `bind` verb families of `docs/API_STYLE.md` (no new verb family).
31
+
32
+ ### Faster, same output
33
+
34
+ - **Display view, exact path with diffraction (device-pixel views such as the app's Quality parts): each native site's value, material and readout moments are computed once per render (#441).**
35
+ - **What changed:** a real pixel's demosaic reads 27 native sites (AHD) or 7 (bilinear), and each site was read through the expected signal and clipped to its readout moments again for every real pixel that reads it, about 25 times per site on the app's Quality request. A per-render ring of native rows over the part's columns now keeps each site's expected electrons, chart material and moments after its first read; a new internal `markReads` tells the expected signal's window which of its sites a real pixel read from the ring, so `diagnostics.sceneLookups` is the count reading them would give. Also: no jitter stream on the exact path (it never draws one), the site moments read the slot arrays instead of taking boxed arguments, a record's unwritten fields are zeroed one by one instead of filling the whole record, and two-argument maxima replace a four-argument `Math.max`. The ring is used only where the expected signal fills its windows (chart materials with diffraction); coarse (stratified) views, views without diffraction (Speed, where the ring measured 2–4 % slower on the `--app` request) and radiance scenes with diffraction keep the per-read path and allocate no ring. Memory: 7 arrays of about 44 bytes per ring entry; for a 96 × 96 part at 3 native pixels per display pixel about 16 native rows × 300 columns, about 0.2 MB per render, and never more than 2^18 entries (about 11.5 MB; above that the ring is off).
36
+ - **Same bytes:** samples, expected values, noise, every diagnostic (including `sceneLookups` and `siteEvaluations`) and the assumptions are identical (SHA-256) to the previous build on 224 renders: the `--app` request of `scripts/benchmark-display-view.mjs` in Speed and Quality at f/8 and f/11 (centre, corners, edges and the label area), 16-bit output, coarse stratified views (K = 4), mirror borders, ISO 3200 and a 1.4 µm sensor with the AHD correction, dead leaves with the Reinhard tone curve and a radiance-function scene, rendered in sequence, separately and concurrently. The existing hash locks pass unchanged. A new test (`test/display-view-site-ring.test.ts`) checks overlapping and adjacent parts of one prepared view in both orders and concurrently against separate calls and the whole view, with mirror borders and the AHD correction, and that `markReads` gives a filled and a direct window the lookup count and later reads of `site`.
37
+ - **Measured** (Node 24.21.0, one thread, 4-vCPU Xeon, load average 1.0–1.7, `DIST_B=… node scripts/benchmark-display-view.mjs --app`, 30 of 294 parts, 3 repeats per build, two separate runs, builds alternated): Quality part medians 169–194 → 148–164 ms at f/8 and 177–187 → 154–164 ms at f/11 (−8 to −14 %); Speed, where the ring is not used, within ±3 %. Browser wall time is not measured here.
38
+ - **Display view prepare and captures: diffraction kernels are kept by value, so an ISO, white-balance or shutter change reuses them (#446).**
39
+ - **What changed:** the per-node site diffraction kernels (`prepareDisplayView`, the capture's tabulated RAW path and the object-space diffraction convolution) were cached by the identity of the PSF record, which the facade builds anew for every request, so every prepare resolved them again. They are now kept by value: the key is the whole `createDiffractionLimitedPsfProfile` input (aperture, focal length, f-number, focus, spectral nodes, pitch, kernel radius, field extent, profile id), the PSF record's other fields and the query's wavelength, pitch and radius. The last four optics settings are kept (least recently used out first; about 2.7 MB with 20 spectral nodes at the 32-site radius); each caller still gets its own copy. An aperture, focal-length, focus, pitch or radius change resolves the kernels again.
40
+ - **Same bytes:** a kept kernel is the same pure function of the same inputs; a new test (`test/site-diffraction-kernel-cache.test.ts`) checks that kept and freshly resolved kernels are equal element by element (`Object.is`) for pitches 1.0–8.4 µm and f/1.4–f/22, that a change of any input misses, that writing to a returned kernel changes nothing later, and that prepares after an ISO, white-balance or shutter change and captures after a prepare render the bytes of a cold run. Samples, RAW codes, diagnostics without timings and assumptions are identical (SHA-256) to the previous build on 55 cases (229 digests, including the whole result without timings): the `--app` request of `scripts/benchmark-display-view.mjs` in Quality through ISO, white-balance, shutter, f-number and focus changes, the 1.4 µm sensor at f/8, f/22 at 6 µm, captures on the tabulated path and on the object-space diffraction path.
41
+ - **Measured** (Node 24.21.0, one thread, 4-vCPU Xeon, load average about 1.2, the app-request shape in Quality, five rounds in one process alternating the two builds; ranges): `prepareDisplayView` after an ISO change 37–45 → 9–16 ms at 6 µm f/11, 76–84 → 10–12 ms at 6 µm f/22 and 241–276 → 15–19 ms at 1.4 µm f/11; after a white-balance change 38–41 → 9–11 ms, 77–88 → 10–12 ms and 241–279 → 16–17 ms; after a shutter change 37–41 → 9–13 ms, 76–121 → 10–12 ms and 234–305 → 15–19 ms. Browser wall time is not measured here.
42
+
43
+ - **Display view and tabulated capture: the scene lookup count of a filled window is computed once per group shape (#445).**
44
+ - **What changed:** `diagnostics.sceneLookups` (and the capture's tabulated `providerEvaluationCount`) counts, for each filled window (chart materials with diffraction), the lattice points the per-pixel lazy caches would have looked up, a box dilation of the window's computed sites per lattice group. The count is a function of those sites and each group's rectangle and cache sizes only, so a group shaped like an earlier one now takes that group's count (on the app's Quality request all four groups have one shape), in both the bit-mask and the general branch; the OR of the site rows that reach a cache row comes from prefix and suffix ORs of the row masks, computed once per window; and a window whose every site was computed, with no site read outside it (the capture path's windows), counts every point of its caches without dilating. No image value is computed by this count.
45
+ - **Same bytes and the same count:** samples, every diagnostic (`sceneLookups` compared explicitly) and the assumptions are identical (SHA-256) to the previous build on the `--app` request of `scripts/benchmark-display-view.mjs` in Speed and Quality at f/8 and f/11 (centre, corners, edges and the label area), coarse stratified views (K = 4), mirror borders, ISO 3200, a 1.4 µm sensor, dead leaves and a radiance-function scene. A new test (`test/expected-signal-lookup-count.test.ts`) compares the open and closed windows' counts with a copy of the previous algorithm and with a brute-force count of the definition over seeded random window sizes on both sides of the 31-site bit-mask limit, windows at the raster edges, kernel radii, group shapes (four-spot, two-spot, different and random offsets), partial, banded and fully read windows, reads outside the window, repeated reads and `markReads`. The existing hash locks pass unchanged.
46
+ - **Measured** (`DIST_B=… node scripts/benchmark-display-view.mjs --app`): Quality part medians 149–152 → 132–144 ms at f/8 and 155–158 → 138–156 ms at f/11 (−3 to −11 %; Node 24.21.0, one thread, 4-vCPU Xeon, load average 1.3, 30 of 294 parts, 3 repeats per build, builds alternated, against #441); Speed has no filled windows and is unchanged (84–91 → 85–90 ms). Browser wall time is not measured here.
47
+ - **Display view render loop: typed and flat per-render tables, so V8 keeps its optimized code (#442).**
48
+ - **What changed:** in `renderDisplayView` the readout tables (`k`, `dark`, `c0`, `readStd`) and the homogeneity white (`labWhite`) are built with `Float64Array.from` instead of `.map`; the per-phase candidate tables are read from flat arrays of typed arrays (`phase * 2 + candidate`, and `* 3 + channel` for the slots and weights) instead of nested arrays; and a site's window offset is read from two per-render `Int32Array`s (`(slot % side) - reach`, `((slot / side) | 0) - reach`, computed once) instead of a modulo and a division per site read. The number of candidates (1 bilinear, 2 AHD) is now checked to be the same for every phase. Keyed loads on the earlier tables met several array shapes, and V8 discarded the whole function's optimized code on them (`wrong map` deoptimizations) several times per view: with `node --trace-deopt --trace-opt`, one run each against #441, 15 → 2 over 36 parts of the app's recorded Quality request (completed TurboFan compiles of the function 14 → 6), 9 → 2 over 24 Quality Siemens-star parts and 16 → 4 over 24 Speed ones. The `exit from OSR'd inner loop` deoptimizations vary from run to run and are not claimed. Memory: two `Int32Array`s of at most 49 entries and four small arrays of references per render.
49
+ - **Same bytes:** the tables hold the same doubles and integers and every operation runs on the same values in the same order. Samples, expected values, noise, every diagnostic (including `sceneLookups` and `siteEvaluations`) and the assumptions are identical (SHA-256) to the previous build on the 224-render set of #441 (the `--app` request of `scripts/benchmark-display-view.mjs` in Speed and Quality at f/8 and f/11 at the centre, corners, edges and label area, prepared parts in sequence and concurrently; 16-bit; coarse stratified views, K = 4; mirror borders; ISO 3200 and a 1.4 µm sensor with the AHD correction; the Reinhard tone curve; a radiance-function scene). The existing hash locks pass unchanged. A new test (`test/display-view-render-tables.test.ts`) checks the offset tables against the modulo and division and that the flat tables hold the nested tables' own arrays.
50
+ - **Measured** (Node 24.21.0, one thread, 4-vCPU Xeon, load average 1.3, 30 of 294 parts, 3 repeats per build, builds alternated, `DIST_B=… node scripts/benchmark-display-view.mjs --app`, against #441): Quality part medians 148–156 → 143 ms at f/8 and 154–155 → 149–151 ms at f/11 (−3 to −8 %); Speed 84–87 → 79–81 ms at f/8 and 83–91 → 80–84 ms at f/11 (−4 to −7 %). Browser wall time is not measured here.
51
+
52
+ - **Display view without diffraction (the Speed path): one-material samples are replayed there too (#444).**
53
+ - **What changed:** the display view replays the recorded terms of a sample whose demosaic sites all read one chart material (the one-material memo), but the expected signal reported the material only on the diffraction path, so every Speed sample was computed in full. Without diffraction a site now records whether all its query points (four with the 2 × 2 box) read one material, and the signal's direct ring and window cache keep that material beside the site's value, so the memo applies to chart materials (presets and caller material scenes) on the Speed path. Radiance scenes are not tracked. A sample whose sites read two materials cannot be replayed, so from the first site of a second material on it takes the site statistics at once instead of in a second pass, which limits the extra work on parts with no one-material samples (the Siemens-star centre).
54
+ - **Same bytes:** a one-material site sums the same operands in the same order as every other such site of its channel, so a replayed sample adds exactly the terms it would have computed; a site's statistics depend on that site only, so taking them earlier changes no bit. Samples, every diagnostic (including `sceneLookups` and `siteEvaluations`) and the assumptions are identical (SHA-256) to the previous build on 120 regions of 28 requests: the `--app` request of `scripts/benchmark-display-view.mjs` in Speed and Quality at f/8 and f/11 (star centre, label area, step wedge and square patches, line grid, corners, edges and the 24 × 24 region of #440), 16-bit output, ISO 3200, a 1.4 µm sensor, coarse stratified views at K = 4 and 16, mirror borders, four-spot anti-aliasing and AHD without diffraction at ISO 100 and 3200, the dead-leaves and Siemens-star presets, and radiance-, reflectance- and material-function scenes; expected values and noise too on 7 of them, and RAW codes and images of 6 captures. A new test (`test/display-view-speed-material.test.ts`) checks the reported material on ring misses, ring hits and window-cache hits against the query points, that one-material sites of a channel hold one value, and that radiance scenes are not tracked, and locks five Speed renders (caller material scene with mirror borders, a corner part at 16 bits, the stratified estimator, AHD at ISO 3200 and a radiance scene) to hashes recorded before the change. The existing hash locks pass unchanged.
55
+ - **Measured** (Node 24.21.0, one thread, 4-vCPU Xeon, load average 0.8–1.6, `DIST_B=… node scripts/benchmark-display-view.mjs --app`, 30 of 294 parts, four repeats per build, builds alternated, against #445): Speed whole-view CPU, extrapolated from the sampled parts, 31.2–32.1 → 29.1–32.0 s at f/8 and 31.0–31.8 → 28.8–30.1 s at f/11 (up to −9 %); part medians 82–87 → 73–84 ms and 83–89 → 76–89 ms; the fastest parts (one material) 7.9–8.5 → 6.7–6.9 µs per display pixel. **Slower:** the Siemens-star centre part, whose samples read two materials and are never replayed, still pays the tracking: alone in a process (`PARTS=1`, six repeats, including JIT warm-up) 230–342 → 246–341 ms at f/8 and 202–262 → 234–271 ms at f/11; without the guard that takes such a sample's statistics inline it was 260–335 ms at f/8. Quality already tracked the material and is unchanged (part medians 133–147 → 131–135 ms at f/8, 136–142 → 135–141 ms at f/11). Browser wall time is not measured here.
56
+
57
+ - **Display view prepare: the channel response runs the sensor chain once per channel, and a chart, illuminant or illuminance change keeps it (#443).**
58
+ - **What changed:** the channel response (electrons per unit spectral radiance at each spectral node, used by `prepareDisplayView`, `simulateIdealReference` and the capture's tabulated RAW path) ran the facade's environment chain once per spectral node and channel plus once per channel to find the nodes: 63 runs on a 20 nm request. It now runs it once per channel, with unit radiance at every node, and reads each node's electrons from that node's own rate term times the exposure time. The chain is planned as engine-owned, photo-only data, as the capture path already does. The cached response is keyed without the scene bindings (scene, illumination and material profiles), which are checked but never enter its numbers, so a chart, illuminant or illuminance change reuses it; an aperture, shutter, focus or sensor change still recomputes it. ISO and white-balance changes already reused it.
59
+ - **Same bytes:** the per-node sums are compensated sums of per-node terms, so the earlier one-node runs returned exactly that node's term. Samples, RAW codes, diagnostics (including `sceneLookups`) and assumptions are identical (SHA-256) to the previous build on 84 cases: the `--app` request of `scripts/benchmark-display-view.mjs` in Speed and Quality at f/8 and f/11 in five regions each, a sequence of prepares with aperture, shutter, chart, illuminant, illuminance, ISO and white-balance changes in Speed and Quality, captured regions with diffraction, a 1.4 µm capture, a DNG, two ideal references and a radiance-function scene. A new test (`test/channel-response.test.ts`) compares each node's value with the earlier one-node runs (`Object.is`) over tiers, pitches, filters, box sizes, apertures, shutters, spectral steps, illuminants, scene kinds and exposure modes, and checks that a chart, illuminant, illuminance or scene change keeps the entry with the maps and bytes of a cold run. The existing hash locks pass unchanged.
60
+ - **Measured** (Node 24.21.0, one thread, 4-vCPU Xeon 2.80 GHz, load average 1.3; the app-request shape of `scripts/benchmark-display-view.mjs --app`, five rounds in one process alternating the two builds, each round a new f-number and shutter): `prepareDisplayView` after an aperture change 378–430 ms → 54–67 ms in Quality and 110–142 ms → 15–19 ms in Speed; after a shutter change 379–420 → 55–69 ms and 112–118 → 18–20 ms; after a chart change 379–435 → 34–39 ms and 108–115 → 6–7 ms; after an illuminant change 370–385 → 35–47 ms and 108–120 → 6–7 ms; after an illuminance change 373–405 → 28–37 ms and 112–124 → 7–11 ms. Each worker of an app prepares once per settings change, so this is time before its first part. Browser wall time is not measured here.
61
+ - **AHD demosaic: an interior fast path and no per-pixel allocations (#448).**
62
+ - **What changed:** `createAhdDemosaicTask` read every neighbour through the reflect-101 border mapping and created two closures and four small arrays per pixel. Sites whose neighbourhood lies inside the plane (two sites from the edge for the directional greens, one for the colour differences, homogeneity maps and 3 × 3 sums) now read their neighbours by direct index offsets, where the mapping returns its argument anyway; border sites keep the mapping. The homogeneity loop keeps its 16 differences in locals instead of nested arrays, and the colour matrix entries are read once. Every floating-point expression keeps its operands, order and grouping.
63
+ - **Same bytes:** a new test (`test/processing-ahd-exactness.test.ts`) compares `data` and `direction` bit for bit with a reference copy of the previous algorithm on seeded mosaics of every size from 3 × 3 to 7 × 7 and larger odd sizes, all four CFA phases, and windows of one frame at even and odd offsets including every border, and locks SHA-256 hashes recorded before the change. RAW codes, samples and PNG bytes are identical (SHA-256) for Quality captures (diffraction, four-spot anti-aliasing, AHD) of a 1024² region of a 24 MP frame, the four frame corners with margin and mirror borders, odd offsets, 16-bit output, ISO 3200, a 1.4 µm sensor and whole small frames, and for display views with AHD, which do not run this task.
64
+ - **Measured** in Node, one thread, before and after interleaved: the demosaic stage (`diagnostics.timingsMs.demosaic`) of a 1024² Quality region of a 24 MP frame 594–678 ms → 350–361 ms (−40 %, median 601 → 358 ms; five rounds, fresh processes, the two builds alternated, the second capture of each process). Run directly on the demosaic task (nine rounds, one process): a 1028² plane 450–505 → 272–331 ms (median −38 %), a 100 × 68 plane, the 96 × 64 detail tile with its margin, 3.0 → 1.7 ms (median −43 %). Inside a whole 96 × 64 tile capture the demosaic stage's own timing (11–21 ms) varied more than that and showed no measurable change. Display views, the preview and the Speed preset (bilinear demosaic) do not run this task.
65
+ - **Captures with diffraction keep the diffraction profile across calls, and the tabulated RAW loop does less work per site (#449, first part).**
66
+ - **What changed:** `simulateCapture` built the diffraction profile (`createDiffractionLimitedPsfProfile`) on every call, so every region part and detail tile of one request built the same one again. The profiles are now kept across calls by the content of their input (aperture, focal length, f-number, focus, the spectral nodes, pitch, kernel radius, field extent and profile ID), the last 8, as the channel responses already are. Each call gets its own copy of the kept record, as mutable as before, so a caller's change to `request.diffraction.profile` never reaches another call. Metering reads the CIE 1931 ȳ values of its 81 nodes from a table built once instead of evaluating the colour-matching functions for each of the 256 zones. In the tabulated RAW loop, a schedule whose opening and closing boundaries are both simultaneous (the global shutter) reads the exposure window once for the region instead of once per site; rolling-shutter schedules still read it per site. The sensor stage of each verified CFA phase reads its readout constants once, keeps the dark Poisson draw's threshold e^(−mean) while the dark mean repeats, does the ADC arithmetic inline when there is no integral non-linearity (with one it calls `adcTransfer` as before) and reuses one output record; it is checked against the full site computation at the verification sites, as before.
67
+ - **Same bytes:** RAW codes, saturation flags, black levels, saturation codes, samples, PNG and DNG bytes, diagnostics without timings and assumptions are identical (SHA-256) to the previous build, on the first and a repeated call in one process, for the app's Quality and Speed requests: the 96 × 64 detail tile, the 192 × 128 detail box, a 1024² region of a 24 MP frame, the frame corners with margin and mirror borders and odd offsets, 16-bit, PNG and DNG output, ISO 3200, a 1.4 µm sensor, whole small frames, a rolling shutter, a radiance-function scene, two captures awaited together, and three display-view parts. The existing hash locks pass unchanged. New tests: `test/capture-set-up-caches.test.ts` (the profile is built once for the parts of a request, any changed input misses and matches a cold run, the cache keeps 8, a caller's change to the returned record does not reach the next call, concurrent captures match sequential ones, the metered luminance keeps its bits, and the exposure window is read once for a global shutter and per site for a rolling one, with the same codes for a still scene) and `test/native-site-readout-flat.test.ts` (every site equals `calculateNativeRawSite` with fixed-pattern noise, digital gain, integral non-linearity, extra read-noise components, saturation and dark means of 0, 8 and 80 e⁻).
68
+ - **Measured** (Node 24.21.0, one thread, 4-vCPU Xeon, load average about 1.3, five rounds alternating the builds, a fresh process per scenario, warm calls): the app's Quality 96 × 64 detail tile median 128 → 115 ms (range 94–170 → 95–145 ms; `timingsMs.raw` median 96 → 92 ms); the 192 × 128 box and a 1024² Quality region within ±3 % (medians 177 → 172 ms and 2520 → 2468 ms). In a CPU profile the profile cache saves 1.5–2 ms and the colour-matching table 3.5–4.5 ms per call. With #446's kernel cache the larger saving the ticket measured on the PSF set-up is already in main.
69
+ - **Captures with diffraction keep the expected signal's chart tables across calls (#449, second part).**
70
+ - **What changed:** the tabulated RAW path built the expected signal's tables (the materials, the lattice groups of the query offsets, the per-group, per-channel combined diffraction kernels over every spectral node, and each material's whole-kernel sum) for every `simulateCapture` call, because they were kept per input object and every call builds a new one; every region part and detail tile of one request built the same tables again. For chart materials and caller material scenes the tables are now also kept by their content: every number they are built from (the spectral nodes, the channel responses, the query offsets and weights, the kernel radius and taps, each material's reflectance or radiance and the white radiance), compared bit for bit, the last 4. Each call still gets its own window state (each lattice group's window cache and its position and size, the bands, the site rings and the lookup bookkeeping), and the lattice material rings, which depend on the scene's lookup function, stay per input, so two captures or a capture and a display view awaited together share only the tables, which are never written after they are built. Radiance scenes are not kept by content. Single `simulateDisplayView` calls of one request share the tables the same way; a prepared view already kept them for its renders.
71
+ - **Same bytes:** RAW codes, saturation flags, black levels, saturation codes, samples, PNG and DNG bytes, diagnostics without timings (including `providerEvaluationCount` and `sceneLookups`) and assumptions are identical (SHA-256) to the previous build, on the first and a repeated call in one process, for the app's Quality and Speed requests with a printed label: the 96 × 64 detail tile, the 192 × 128 detail box, a 1024² region of a 24 MP frame, the frame corners with margin and mirror borders and odd offsets, 16-bit, PNG and DNG output, ISO 3200, a 1.4 µm sensor, whole small frames, a rolling shutter, a radiance-function scene, two captures and a display-view part awaited together, and display-view parts of the `--app` request of `scripts/benchmark-display-view.mjs` in Speed and Quality at f/8 and f/11. New tests (`test/expected-signal-table-cache.test.ts`): region parts of one request build the tables once and match a cold run; a changed f-number, pixel pitch, spectral step or material list misses and matches a cold run bit for bit, as does a change of any one number of the tables' input (including the sign of a zero tap); equal inputs read with their windows interleaved give the values and lookup counts of fresh tables; two captures and a display view awaited together give the bytes of sequential runs; the cache keeps 4, least recently used out first.
72
+ - **Measured** (Node 24.21.0, one thread, 4-vCPU Xeon, load average 0.5–1.3, five rounds alternating the builds, a fresh process per build and request, six warm calls after a cold one, against #444): the app's Quality 96 × 64 detail tile 92–174 → 74–135 ms per call, median 116 → 95 ms (`timingsMs.raw` median 94 → 77 ms); the 192 × 128 detail box 136–195 → 125–181 ms, median 160 → 153 ms (`timingsMs.raw` median 124 → 114 ms). The first call per settings still builds the tables (cold calls 301–326 → 306–317 ms and 370–377 → 362–373 ms). The display view's `--app` rows (prepared views, five repeats) are unchanged: part medians 68–73 ms in Speed and 124–135 ms in Quality with both builds. Browser wall time is not measured here.
73
+ - **Captures of chart and material scenes: the tabulated RAW path's four verification sites skip re-validating engine-built records, and the diffraction convolution fills its lattice from one material lookup per point (#453).**
74
+ - **What changed:** every tabulated `simulateCapture` call runs one site of each CFA phase through the environment chain to verify the tabulated electrons and to reproduce the readout. That chain re-validated, at each of its 656 queries per site (the app's Quality request), records the engine had built and already validated: the full provider-result parse, the scene-binding profile checks and the optical bridge's profile, focal length, f-number, focus and working f-number checks, plus a result envelope per query and one per site. It now runs through an internal engine-owned execution (`executeEngineOwnedEnvironmentPhotoSignal`): the first query runs every check as before, later queries keep every per-query check (result values, request and result identities, wavelength basis, transmission, filters, field throughput, finite irradiance) and skip only those re-validations; the photo signal is formed by the same code from the same values. With diffraction, the verification's object-space convolution filled each lattice point once per spectral node through `radianceAt`; for chart presets and caller material scenes, where `radianceAt(u, v, λ)` is `materialRadiance(chartMaterialAt(u, v), λ)`, it now looks the material up once per point and takes each node's value from one `materialRadiance` call per material present, and the shifts × nodes convolutions of a band share one validation of their geometry (each still checks its own samples and kernel). Radiance and reflectance function scenes keep the per-point fill. The per-frame (rather than per-part) verification the ticket also discusses is not part of this change.
75
+ - **Same bytes:** RAW codes, saturation flags, black levels, saturation codes, samples, PNG bytes, diagnostics without timings (including `providerEvaluationCount` and `sceneLookups`) and assumptions are identical (SHA-256) to the previous build, on the first and a repeated call in one process, for the app's Quality and Speed requests with a printed label: the 96 × 64 detail tile, the 192 × 128 detail box, a 1024² region of a 24 MP frame, the frame corners with margin and mirror borders and odd offsets, 16-bit, PNG and DNG output, ISO 3200, a 1.4 µm sensor, whole small frames, a rolling shutter, a radiance-function scene, two captures awaited together, and three display-view parts. The existing hash locks pass unchanged. New tests (`test/tabulated-verification-chain.test.ts`): each tabulated call runs the chain at exactly four sites, and their photo signals equal the full executor's bit for bit (Speed, Quality, a spectral material scene with a chart size, spectral patches with a rolling shutter in a region, ISO 3200 at 1.4 µm); a tabulated signal off by 10⁻⁶ relative at one CFA phase is rejected and the capture gives the environment path's bytes; `radianceAt` equals `materialRadiance(chartMaterialAt)` bit for bit over a lattice at six wavelengths for every chart preset (with spectral materials where the preset takes them), with and without a fixed-size surround, and for material scenes; the material fill gives the per-point fill's convolved values bit for bit; the shared-geometry convolutions equal `calculateIrradianceConvolution` and reject bad arrays with its messages.
76
+ - **Measured** (Node 24.21.0, one thread, 4-vCPU Xeon, load average 0.4–1.6, five rounds alternating the builds, a fresh process per build and request, one cold and six warm calls, against #449's table cache): the verification span (the four sites' chain, timed with two trace points in a copy of each build, 25 warm calls per process) 32.8–73.5 → 22.7–55.8 ms, median 43.2 → 27.0 ms, on the app's Quality 96 × 64 detail tile and 35.0–67.8 → 23.5–47.7 ms, median 39.3 → 25.4 ms, on the 192 × 128 detail box. Per call: the tile 84–170 → 75–121 ms, median 102 → 90 ms (`timingsMs.raw` median 79 → 67 ms); the box 127–172 → 110–159 ms, median 158 → 137 ms (`timingsMs.raw` 114 → 99 ms); the Speed tile median 32.6 → 29.0 ms. The 1024² Quality and Speed parts are unchanged (medians 2295 → 2296 ms and 990 → 993 ms): the verification is a fixed cost per call. Browser wall time is not measured here.
77
+
78
+ ### Changed
79
+
80
+ - **The capture's query points have the same bits in every JavaScript engine (#452).** Its three-argument norms (the environment ray, its unit-direction checks, pupil-sampled aperture rays and the display view's copy of the query point) use a new internal `hypot3`: V8's own three-argument `Math.hypot` sequence written as correctly rounded operations. On V8 (Node, Chromium) every capture, RAW code, file and display view is unchanged; on JavaScriptCore and SpiderMonkey the query points move to V8's bits where their `Math.hypot` differs. This is a portability change, not a speed-up: no end-to-end gain was measured. The display view still takes other native `Math` inputs from the capture (`docs/DISPLAY_VIEW.md`, "Same bytes in every engine").
81
+ - **Tests:** `test/portable-hypot3.test.ts` locks fixed bits that hold in every engine, including the non-finite rules, and on V8 compares `hypot3` with `Math.hypot` on 10^6 seeded triples across magnitude regimes (0 differences).
82
+
83
+ ### Deprecated
84
+
85
+ - **`lens.diffraction`'s `aperture` key (#395): use `kind`.** `{ aperture: "circular-aperture" }` is still accepted, with the same result, until 3.0.0; giving both `kind` and `aperture` is an error.
86
+
87
+ ## 2.9.0 — published October 26, 2026
88
+
89
+ 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.
90
+ - **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:
91
+ - a 24 MP DNG takes 23.8 s instead of 79.1 s (Speed) and 46.2 s instead of 158.0 s (Quality);
92
+ - a 24 MP JPEG takes about 20 s instead of 32–35 s (Speed) and about 40 s instead of 53–59 s (Quality).
93
+ - **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.
94
+ - **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.
95
+ - **Compatibility:** five new root exports (1,734); no option, default or result field changes. `createDisplayViewExpectedSignal`, which is internal, takes an optional span hint.
96
+
97
+ ### Added
98
+
99
+ - **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.
100
+ - **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.
101
+ - **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.
102
+ - The whole-image JPEG writer and the strips share one block coder, and the existing byte-identity tests pass.
103
+ - New root exports: `createPrintJpegStrip`, `composePrintJpegStrips`, and the types `PrintJpegStripInput`, `PrintJpegStrip` and `JpegStripBits`.
104
+
105
+ - **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.
106
+ - **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.
107
+ - **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.
108
+ - New root exports: `serializeSimulatedCaptureDngFromParts` and the type `SimulateCaptureDngPart`.
109
+
110
+ ### Faster, same output
111
+
112
+ - **Captures with AHD demosaic: development about 3× cheaper around the demosaic (#420).**
113
+ - **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).
114
+ - **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.
115
+ - **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).
116
+
117
+ - **Captures of chart and material scenes: the sensor stage allocates less per site (#418).**
118
+ - **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.
119
+ - **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.
120
+ - **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.
121
+
122
+ - **Display view parts: request tables kept across renders of a prepared view; bands sized to the part.**
123
+ - **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.
124
+ - **Band width:** a filled band is built as wide as the part's windows span, not the sensor's full width.
125
+ - **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.
126
+ - **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.
127
+
128
+ - **Display view refinement of chart scenes: taller lattice bands and a cheaper `sceneLookups` count.**
129
+ - **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.
130
+ - **`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.
131
+ - **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.
132
+
133
+ - **Display view of chart and caller material scenes: lattice materials kept across refinement windows; caller scene points no longer frozen.**
134
+ - **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.
135
+ - **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.
136
+ - **Same values:** all display-view hash locks and tests pass.
137
+ - **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.
138
+
139
+ ### Faster, values within rounding
140
+
141
+ - **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.
142
+ - **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.
143
+ - **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.
144
+ - **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.
145
+
146
+ ## 2.8.1 — published October 25, 2026
4
147
 
5
148
  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
149
  - **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.10.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.10.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_10_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.10.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.10.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.10.0` (release candidate prepared October 27, 2026; publication is separate)
367
+ - Engine API contract: `2.10.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.10.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.9.0**, published on October 26, 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
 
package/THIRD_PARTY.md CHANGED
@@ -23,6 +23,8 @@ Node built-in modules are used only by development tooling and the repository-lo
23
23
 
24
24
  `src/sensor/stochastic-seed.ts` independently implements the MurmurHash3_x86_32 algorithm and its `fmix32` finalizer (Austin Appleby). The upstream `MurmurHash3.cpp` header states the algorithm "is placed in the public domain" and disclaims copyright. The SMHasher test suite that contains it is a separate work: its README says "SMHasher is released under the MIT license". No SMHasher or MurmurHash3 source file is vendored or copied. `test/sensor-raw-noise-independence.test.ts` reimplements, in its own code, SMHasher's verification procedure (keys of 0–255 bytes hashed with seed 256 − i, then the hash of those hashes) and compares the result with the published MurmurHash3_x86_32 verification value 0xB0F57EE3, a published fact. Test vectors earlier copied from a Stack Overflow answer (CC BY-SA 3.0) were removed.
25
25
 
26
+ `hypot3` in `src/core/portable-math.ts` independently implements the operation sequence of V8's three-argument `Math.hypot` (`MathHypot` and `FastMathHypot` in V8's `src/builtins/math.tq`; V8 is distributed under a BSD-style licence): a maximum, a normalisation and a Kahan-compensated sum, written as plain JavaScript arithmetic so the bits match V8's. No V8 source file is vendored or copied; the sequence is a method, and its equality with `Math.hypot` is checked by `test/portable-hypot3.test.ts`.
27
+
26
28
  `src/optics/longitudinal-chromatic-aberration.ts` uses three physical constants: the Fraunhofer d (helium, 587.5618 nm), F (hydrogen, 486.1327 nm) and C (hydrogen, 656.2725 nm) spectral-line wavelengths used to define the Abbe number (as tabulated in SCHOTT Technical Information TIE-29, *Refractive Index and Dispersion*, 2023, free; also the reference wavelengths of ISO 7944). They are factual measured constants of nature, not a copied dataset or table; no glass catalogue data is included and refractive index and Abbe number are caller inputs. The 2.0 projection, distortion, Zernike/Seidel, vignetting, polarizer and haze models are independent implementations of published equations cited in `docs/PHYSICS_FOUNDATION.md` and `docs/PSF_FOUNDATION.md`; they contain no third-party code or numeric tables.
27
29
 
28
30
  This inventory does not replace a transitive dependency/SBOM review before a release. New runtime dependencies, scientific datasets, model weights, or calibration material require explicit provenance and license review.
@@ -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
  };