@photivra/engine 2.20.0 → 2.22.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 (275) hide show
  1. package/CHANGELOG.md +295 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +7 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +18 -0
  6. package/dist/capture/c2pa-embed.d.ts +116 -0
  7. package/dist/capture/c2pa-embed.js +493 -0
  8. package/dist/capture/c2pa-embed.js.map +1 -0
  9. package/dist/capture/dng-import.d.ts +73 -8
  10. package/dist/capture/dng-import.js +315 -80
  11. package/dist/capture/dng-import.js.map +1 -1
  12. package/dist/capture/dng-lens-correction-opcodes.d.ts +2 -2
  13. package/dist/capture/dng-lens-correction-opcodes.js +2 -2
  14. package/dist/capture/dng-lens-correction-opcodes.js.map +1 -1
  15. package/dist/capture/dng-opcodes.d.ts +96 -0
  16. package/dist/capture/dng-opcodes.js +391 -0
  17. package/dist/capture/dng-opcodes.js.map +1 -0
  18. package/dist/capture/dng-profile-maps.d.ts +99 -0
  19. package/dist/capture/dng-profile-maps.js +381 -0
  20. package/dist/capture/dng-profile-maps.js.map +1 -0
  21. package/dist/capture/dng-stage-image.d.ts +23 -0
  22. package/dist/capture/dng-stage-image.js +65 -0
  23. package/dist/capture/dng-stage-image.js.map +1 -0
  24. package/dist/capture/exposure-build-up-stage.d.ts +16 -0
  25. package/dist/capture/exposure-build-up-stage.js +4 -0
  26. package/dist/capture/exposure-build-up-stage.js.map +1 -0
  27. package/dist/capture/jpeg-xl-tiles.d.ts +22 -0
  28. package/dist/capture/jpeg-xl-tiles.js +151 -0
  29. package/dist/capture/jpeg-xl-tiles.js.map +1 -0
  30. package/dist/capture/lossy-jpeg.d.ts +17 -0
  31. package/dist/capture/lossy-jpeg.js +369 -0
  32. package/dist/capture/lossy-jpeg.js.map +1 -0
  33. package/dist/capture/native-environment-lean.d.ts +2 -1
  34. package/dist/capture/native-environment-lean.js +41 -73
  35. package/dist/capture/native-environment-lean.js.map +1 -1
  36. package/dist/capture/native-environment-tabulated.js +4 -3
  37. package/dist/capture/native-environment-tabulated.js.map +1 -1
  38. package/dist/capture/native-raw-development.d.ts +2 -1
  39. package/dist/capture/native-raw-development.js.map +1 -1
  40. package/dist/capture/native-raw.d.ts +3 -2
  41. package/dist/capture/native-raw.js +5 -4
  42. package/dist/capture/native-raw.js.map +1 -1
  43. package/dist/capture/sensor-raw-producer.d.ts +4 -1
  44. package/dist/capture/sensor-raw-producer.js +12 -3
  45. package/dist/capture/sensor-raw-producer.js.map +1 -1
  46. package/dist/color/neighbor-highlight-reconstruction.d.ts +66 -0
  47. package/dist/color/neighbor-highlight-reconstruction.js +234 -0
  48. package/dist/color/neighbor-highlight-reconstruction.js.map +1 -0
  49. package/dist/color/sensor-color-development.d.ts +5 -0
  50. package/dist/color/sensor-color-development.js +7 -1
  51. package/dist/color/sensor-color-development.js.map +1 -1
  52. package/dist/color/spectral-spikiness.d.ts +11 -0
  53. package/dist/color/spectral-spikiness.js +23 -0
  54. package/dist/color/spectral-spikiness.js.map +1 -0
  55. package/dist/core/execution-error.d.ts +21 -0
  56. package/dist/core/execution-error.js +12 -0
  57. package/dist/core/execution-error.js.map +1 -0
  58. package/dist/core/version.d.ts +1 -1
  59. package/dist/core/version.js +1 -1
  60. package/dist/core/version.js.map +1 -1
  61. package/dist/equipment/generic-lens-catalogue.d.ts +3 -2
  62. package/dist/equipment/generic-lens-catalogue.js +18 -5
  63. package/dist/equipment/generic-lens-catalogue.js.map +1 -1
  64. package/dist/equipment/generic-tier-assets.d.ts +32 -0
  65. package/dist/equipment/generic-tier-assets.js +16 -11
  66. package/dist/equipment/generic-tier-assets.js.map +1 -1
  67. package/dist/equipment/sensor-profile-body.d.ts +78 -0
  68. package/dist/equipment/sensor-profile-body.js +77 -0
  69. package/dist/equipment/sensor-profile-body.js.map +1 -0
  70. package/dist/equipment/sensor-profiles.d.ts +8 -0
  71. package/dist/equipment/sensor-profiles.js +82 -0
  72. package/dist/equipment/sensor-profiles.js.map +1 -0
  73. package/dist/facade/catalogue-lens-signal.d.ts +28 -3
  74. package/dist/facade/catalogue-lens-signal.js +83 -78
  75. package/dist/facade/catalogue-lens-signal.js.map +1 -1
  76. package/dist/facade/chart-radiance-batch.js +2 -1
  77. package/dist/facade/chart-radiance-batch.js.map +1 -1
  78. package/dist/facade/defocus-image-space-provider.d.ts +7 -0
  79. package/dist/facade/defocus-image-space-provider.js +64 -76
  80. package/dist/facade/defocus-image-space-provider.js.map +1 -1
  81. package/dist/facade/diffraction-provider.d.ts +1 -1
  82. package/dist/facade/diffraction-provider.js +4 -4
  83. package/dist/facade/diffraction-provider.js.map +1 -1
  84. package/dist/facade/display-view-gpu-session.js +39 -5
  85. package/dist/facade/display-view-gpu-session.js.map +1 -1
  86. package/dist/facade/display-view-gpu.d.ts +19 -6
  87. package/dist/facade/display-view-gpu.js.map +1 -1
  88. package/dist/facade/display-view-lens-signal.js +25 -23
  89. package/dist/facade/display-view-lens-signal.js.map +1 -1
  90. package/dist/facade/display-view-normal.d.ts +15 -0
  91. package/dist/facade/display-view-normal.js +44 -0
  92. package/dist/facade/display-view-normal.js.map +1 -0
  93. package/dist/facade/display-view-rendering.d.ts +97 -0
  94. package/dist/facade/display-view-rendering.js +598 -0
  95. package/dist/facade/display-view-rendering.js.map +1 -0
  96. package/dist/facade/display-view.d.ts +37 -65
  97. package/dist/facade/display-view.js +152 -499
  98. package/dist/facade/display-view.js.map +1 -1
  99. package/dist/facade/expected-signal-setup.d.ts +3 -1
  100. package/dist/facade/expected-signal-setup.js +3 -3
  101. package/dist/facade/expected-signal-setup.js.map +1 -1
  102. package/dist/facade/expected-signal.d.ts +47 -14
  103. package/dist/facade/expected-signal.js +117 -112
  104. package/dist/facade/expected-signal.js.map +1 -1
  105. package/dist/facade/exposure-build-up.d.ts +70 -0
  106. package/dist/facade/exposure-build-up.js +164 -0
  107. package/dist/facade/exposure-build-up.js.map +1 -0
  108. package/dist/facade/fast-methods.d.ts +1 -1
  109. package/dist/facade/fast-methods.js +46 -9
  110. package/dist/facade/fast-methods.js.map +1 -1
  111. package/dist/facade/generic-lens-capture.d.ts +3 -3
  112. package/dist/facade/generic-lens-capture.js +3 -3
  113. package/dist/facade/generic-lens-capture.js.map +1 -1
  114. package/dist/facade/image-plane-enable.d.ts +7 -0
  115. package/dist/facade/image-plane-enable.js +17 -0
  116. package/dist/facade/image-plane-enable.js.map +1 -0
  117. package/dist/facade/image-plane-lattice-data.d.ts +6 -0
  118. package/dist/facade/image-plane-lattice-data.js +10 -0
  119. package/dist/facade/image-plane-lattice-data.js.map +1 -0
  120. package/dist/facade/image-plane-support.d.ts +33 -0
  121. package/dist/facade/image-plane-support.js +11 -0
  122. package/dist/facade/image-plane-support.js.map +1 -0
  123. package/dist/facade/image-plane.d.ts +36 -0
  124. package/dist/facade/image-plane.js +384 -0
  125. package/dist/facade/image-plane.js.map +1 -0
  126. package/dist/facade/mixed-illumination.d.ts +108 -0
  127. package/dist/facade/mixed-illumination.js +174 -0
  128. package/dist/facade/mixed-illumination.js.map +1 -0
  129. package/dist/facade/on-demand-kits.d.ts +14 -0
  130. package/dist/facade/on-demand-kits.js +36 -0
  131. package/dist/facade/on-demand-kits.js.map +1 -0
  132. package/dist/facade/opt-in-kit.d.ts +9 -0
  133. package/dist/facade/opt-in-kit.js +11 -0
  134. package/dist/facade/opt-in-kit.js.map +1 -0
  135. package/dist/facade/quality-kit.d.ts +21 -0
  136. package/dist/facade/quality-kit.js +23 -0
  137. package/dist/facade/quality-kit.js.map +1 -0
  138. package/dist/facade/quality-paths.d.ts +22 -0
  139. package/dist/facade/quality-paths.js +44 -0
  140. package/dist/facade/quality-paths.js.map +1 -0
  141. package/dist/facade/representative-light-sources.d.ts +72 -0
  142. package/dist/facade/representative-light-sources.js +124 -0
  143. package/dist/facade/representative-light-sources.js.map +1 -0
  144. package/dist/facade/scene-radiance.d.ts +145 -4
  145. package/dist/facade/scene-radiance.js +50 -13
  146. package/dist/facade/scene-radiance.js.map +1 -1
  147. package/dist/facade/simulate-capture.d.ts +68 -20
  148. package/dist/facade/simulate-capture.js +399 -146
  149. package/dist/facade/simulate-capture.js.map +1 -1
  150. package/dist/facade/simulate-display-view.d.ts +10 -12
  151. package/dist/facade/simulate-display-view.js +70 -53
  152. package/dist/facade/simulate-display-view.js.map +1 -1
  153. package/dist/gpu/chart-composite-gpu.d.ts +13 -1
  154. package/dist/gpu/chart-composite-gpu.js +48 -9
  155. package/dist/gpu/chart-composite-gpu.js.map +1 -1
  156. package/dist/gpu/chart-radiance-gpu.js +4 -30
  157. package/dist/gpu/chart-radiance-gpu.js.map +1 -1
  158. package/dist/gpu/display-view-composite-material-module.d.ts +10 -4
  159. package/dist/gpu/display-view-composite-material-module.js +39 -13
  160. package/dist/gpu/display-view-composite-material-module.js.map +1 -1
  161. package/dist/gpu/display-view-gpu-renderer.d.ts +43 -0
  162. package/dist/gpu/display-view-gpu-renderer.js +134 -0
  163. package/dist/gpu/display-view-gpu-renderer.js.map +1 -0
  164. package/dist/gpu/display-view-image-material-module.d.ts +87 -0
  165. package/dist/gpu/display-view-image-material-module.js +361 -0
  166. package/dist/gpu/display-view-image-material-module.js.map +1 -0
  167. package/dist/gpu/display-view-image-quality.d.ts +87 -0
  168. package/dist/gpu/display-view-image-quality.js +625 -0
  169. package/dist/gpu/display-view-image-quality.js.map +1 -0
  170. package/dist/gpu/display-view-quality-gpu.d.ts +80 -65
  171. package/dist/gpu/display-view-quality-gpu.js +187 -445
  172. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  173. package/dist/gpu/display-view-speed-gpu.d.ts +67 -19
  174. package/dist/gpu/display-view-speed-gpu.js +181 -105
  175. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  176. package/dist/gpu/image-kernel-accelerator.js +3 -0
  177. package/dist/gpu/image-kernel-accelerator.js.map +1 -1
  178. package/dist/gpu/image-kernel-execution.d.ts +2 -13
  179. package/dist/gpu/image-kernel-execution.js +2 -10
  180. package/dist/gpu/image-kernel-execution.js.map +1 -1
  181. package/dist/gpu/wgsl-display-view-coupled-stub.d.ts +8 -0
  182. package/dist/gpu/wgsl-display-view-coupled-stub.js +21 -0
  183. package/dist/gpu/wgsl-display-view-coupled-stub.js.map +1 -0
  184. package/dist/gpu/wgsl-display-view-coupled.d.ts +88 -0
  185. package/dist/gpu/wgsl-display-view-coupled.js +353 -0
  186. package/dist/gpu/wgsl-display-view-coupled.js.map +1 -0
  187. package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +33 -24
  188. package/dist/gpu/wgsl-display-view-quality-kernel.js +166 -146
  189. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
  190. package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +19 -44
  191. package/dist/gpu/wgsl-display-view-speed-kernel.js +116 -18
  192. package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
  193. package/dist/gpu/wgsl-exact-sum.d.ts +20 -0
  194. package/dist/gpu/wgsl-exact-sum.js +42 -0
  195. package/dist/gpu/wgsl-exact-sum.js.map +1 -0
  196. package/dist/index.d.ts +22 -6
  197. package/dist/index.js +16 -2
  198. package/dist/index.js.map +1 -1
  199. package/dist/motion/subject-motion.d.ts +66 -0
  200. package/dist/motion/subject-motion.js +52 -0
  201. package/dist/motion/subject-motion.js.map +1 -0
  202. package/dist/optics/generic-lens-lateral-color.d.ts +83 -0
  203. package/dist/optics/generic-lens-lateral-color.js +77 -0
  204. package/dist/optics/generic-lens-lateral-color.js.map +1 -0
  205. package/dist/optics/generic-lens-psf.js +54 -46
  206. package/dist/optics/generic-lens-psf.js.map +1 -1
  207. package/dist/optics/lens-psf-profile.d.ts +7 -0
  208. package/dist/optics/lens-psf-profile.js +2 -1
  209. package/dist/optics/lens-psf-profile.js.map +1 -1
  210. package/dist/output/camera-channel-response.d.ts +50 -0
  211. package/dist/output/camera-channel-response.js +69 -0
  212. package/dist/output/camera-channel-response.js.map +1 -0
  213. package/dist/output/developed-lens-corrections.d.ts +19 -5
  214. package/dist/output/developed-lens-corrections.js +28 -9
  215. package/dist/output/developed-lens-corrections.js.map +1 -1
  216. package/dist/output/generic-lens-corrections.d.ts +41 -5
  217. package/dist/output/generic-lens-corrections.js +52 -8
  218. package/dist/output/generic-lens-corrections.js.map +1 -1
  219. package/dist/output/native-capture-sdr.js +5 -2
  220. package/dist/output/native-capture-sdr.js.map +1 -1
  221. package/dist/output/print-plan.d.ts +41 -5
  222. package/dist/output/print-plan.js +59 -14
  223. package/dist/output/print-plan.js.map +1 -1
  224. package/dist/output/sdr-rendering.d.ts +18 -1
  225. package/dist/output/sdr-rendering.js +39 -2
  226. package/dist/output/sdr-rendering.js.map +1 -1
  227. package/dist/sensor/binomial-sample.d.ts +3 -0
  228. package/dist/sensor/binomial-sample.js +86 -0
  229. package/dist/sensor/binomial-sample.js.map +1 -0
  230. package/dist/sensor/native-sensor-report.d.ts +10 -0
  231. package/dist/sensor/native-sensor-report.js +16 -0
  232. package/dist/sensor/native-sensor-report.js.map +1 -0
  233. package/dist/sensor/sensor-characteristics.d.ts +91 -13
  234. package/dist/sensor/sensor-characteristics.js +30 -15
  235. package/dist/sensor/sensor-characteristics.js.map +1 -1
  236. package/docs/AGENT_GUIDE.md +1 -0
  237. package/docs/API_REFERENCE.md +214 -4
  238. package/docs/QUICK_START.md +162 -8
  239. package/docs/USAGE.md +195 -22
  240. package/docs/api/capture-c2pa-embed.md +177 -0
  241. package/docs/api/capture-dng-import.md +86 -10
  242. package/docs/api/capture-dng-opcodes.md +93 -0
  243. package/docs/api/capture-dng-profile-maps.md +141 -0
  244. package/docs/api/capture-dng-stage-image.md +43 -0
  245. package/docs/api/capture-jpeg-xl-tiles.md +33 -0
  246. package/docs/api/capture-lossy-jpeg.md +41 -0
  247. package/docs/api/capture-native-raw-development.md +2 -1
  248. package/docs/api/core-execution-error.md +43 -0
  249. package/docs/api/core-version.md +1 -1
  250. package/docs/api/equipment-generic-lens-catalogue.md +10 -5
  251. package/docs/api/equipment-sensor-profile-body.md +64 -0
  252. package/docs/api/equipment-sensor-profiles.md +86 -0
  253. package/docs/api/exports.json +460 -5
  254. package/docs/api/facade-display-view-gpu.md +5 -2
  255. package/docs/api/facade-display-view.md +11 -15
  256. package/docs/api/facade-exposure-build-up.md +105 -0
  257. package/docs/api/facade-fast-methods.md +47 -10
  258. package/docs/api/facade-image-plane-enable.md +16 -0
  259. package/docs/api/facade-mixed-illumination.md +67 -0
  260. package/docs/api/facade-quality-paths.md +24 -0
  261. package/docs/api/facade-representative-light-sources.md +131 -0
  262. package/docs/api/facade-scene-radiance.md +54 -3
  263. package/docs/api/facade-simulate-capture.md +48 -11
  264. package/docs/api/facade-simulate-display-view.md +10 -12
  265. package/docs/api/gpu-image-kernel-execution.md +0 -36
  266. package/docs/api/motion-subject-motion.md +23 -0
  267. package/docs/api/optics-generic-lens-lateral-color.md +79 -0
  268. package/docs/api/output-camera-channel-response.md +55 -0
  269. package/docs/api/output-developed-lens-corrections.md +10 -1
  270. package/docs/api/output-generic-lens-corrections.md +40 -4
  271. package/docs/api/output-print-plan.md +35 -1
  272. package/docs/api/output-sdr-rendering.md +6 -1
  273. package/docs/api/sensor-sensor-characteristics.md +54 -2
  274. package/llms.txt +3 -0
  275. package/package.json +6 -5
package/CHANGELOG.md CHANGED
@@ -1,6 +1,300 @@
1
1
  # Changelog
2
2
 
3
- ## 2.20.0 — unreleased (prepared November 9, 2026)
3
+ ## 2.22.0 — unreleased (prepared November 11, 2026)
4
+
5
+ A minor release: phone DNG import with JPEG XL and lossy JPEG raw data, the DNG stage image and opcodes (#598, #600) and the channel-response helper (#591); native-photosite sensor figures (#595), read noise per photosite on the tiers (#599), mixed lighting over an image-plane print (#597), sample counts that follow the light and the output (#590) and the catalogue's fast grid (#603); the display view's luminance tone curves, highlight reconstruction and desaturation on the CPU and GPU (#593, #601, #602); the removal of the display view's AHD low-signal correction; Content Credentials writers (#165, engine half); and the full-quality and opt-in code loaded on demand (#594). See the [2.22.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_22_0.md).
6
+
7
+ - **New:** `importDngRawImage`, `parseLossyJpeg`, `createJxlOxideDngDecoder`, `parseDngOpcodeList`, `applyDngOpcodes`, `developDngStageImage`, `parseDngProfileGainTableMap`, `parseDngRgbTables`, `applyDngProfileGainTableMap`, `applyDngRgbTables` (#598, #600); `calculateCameraChannelResponse` (#591); `result.sensor.native` (#595); `output.gamutHandling: "desaturate-to-luminance"` (#601) and `output.highlightMode: "reconstruct-neighbors"` (#602); the luminance tone curves and `highlightMode: "reconstruct"` in the display view (#593); `bindC2paManifestStore`, `calculateC2paDataHash`, `setC2paManifestStore`, `parseC2paManifestStore` (#165); `prepareQualityPaths()` (#594).
8
+ - **Changed (see "Default changes"):** the tiers' read noise is per photosite (#599); the default spectral step under a spiky light on exact requests (#590); the catalogue's fast grid (#603); mixed lighting over a print is rendered (#597); the display view's AHD has no low-signal correction; two `raw.expectedSignal` method names; the GPU Quality self-check runs at the first Quality render; the full-quality and opt-in code loads on demand (#594).
9
+ - **Root exports:** 50 new, 1,931 → 1,981 (runtime exports 654 → 672): 29 from #598 and #600 (11 runtime), 3 from #591 (1 runtime), 2 from #595 (types), 15 from #165 (6 runtime), 1 from #594 (runtime). Nothing is removed; the AHD correction-state API is deprecated (no-ops until 3.0.0).
10
+ - **Why minor:** new root exports and options; every default change below keeps the old behavior one option away or is a model correction or a removal the owner decided, with its deprecated API kept.
11
+
12
+ ### Default changes (migration)
13
+
14
+ | What changed | Before (2.21.0) | Now (2.22.0) | Keep the old behavior |
15
+ | --- | --- | --- | --- |
16
+ | Generic tiers' (and caller presets') read noise above 6 µm, and `sensor.readNoise` on a tier (#599; owner decision 2026-11-11) | once per rendered site, whatever the pitch; `sensor.readNoise` was the site's value | the read noise of one physical photosite (as a datasheet gives it, as for a sensor profile, #554): a site that sums N = pitch²/36 µm² photosites takes it ×√N; `native.readNoiseElectrons` reports it as given; at 6 µm and below unchanged | set `sensor.readNoise` to the 2.21.0 site value divided by √N = pitch / 6 µm |
17
+ | Default spectral step under a spiky light (#590) | 20 nm | 5 nm on exact requests (DNG, JPEG, PNG, the ideal reference, `fast: false`) when R(20) = 20²·κ/24 > 0.01: the four #557 representative sources (fluorescent 0.84, LEDs 0.017–0.038); fast requests keep 20 nm with the assumption `sensor.spectralSpikiness` and `diagnostics.fast.applied` reporting it; Planck, CIE A and other smooth lights unchanged | `sensor.spectralStepNanometers: 20` |
18
+ | Catalogue field grid on fast requests (#603) | at least 10 cells per field interval | at least 4 (2.20.0's grid) on fast requests (on-screen sRGB captures), except the measured lenses of `CATALOGUE_FAST_GRID_FULL_ON_SCREEN` (the PV 15 2.8 Fisheye Pro), which keep 10; exact requests keep 10 | `fast: false` with `lens.catalogue: { kind, fast: true }` |
19
+ | Mixed lighting over an image-plane print (#597) | refused | rendered: every site integrates the lit radiance per node on the CPU; the GPU display view lights each lattice point in the print's Quality passes (Speed requests too) | — (a refusal lifted) |
20
+ | Display view with `output.demosaic: "ahd"` (owner decision 2026-11-11) | a low-signal correction from precomputed values on a grid of the three channel signal levels | no correction on any path; `view.demosaic` labels the remaining bias an approximation with measured numbers (largest: about 4 red codes at 1.4 µm, ISO 6400) | no option (the correction-state API is kept as checked no-ops until 3.0.0) |
21
+ | `raw.expectedSignal` method names (labels only) | `tabulated-expected-electrons`, `tabulated-expected-electrons-catalogue-psf-field-grid` | `per-site-integrated-electrons`, `per-site-integrated-electrons-catalogue-psf-field-grid` (what the paths have done since 2.21.0); samples and RAW codes unchanged | match the new names |
22
+ | GPU Quality self-check (#594; owner decision 2026-11-10) | before a view's first GPU render | before its first render that takes the Quality passes (a Speed-only view lists only the Speed case) | no option (diagnostics timing; results unchanged) |
23
+ | Full-quality and opt-in code (#594) | in a bundle's first load | loaded on demand, once, by the first request that uses it (a bundler that splits dynamic imports puts it in its own chunks); a request whose code cannot load fails with `ImageKernelExecutionError` reason `quality-code-unavailable` (the GPU falls back to the CPU) | `await prepareQualityPaths()` at start-up; results are unchanged either way |
24
+
25
+ ### Integration (this release)
26
+
27
+ - **Parts:** six part branches merged `--no-ff` (docs/RELEASE_2_22_0.md, "Scope"); every part's behavior is kept. The AHD correction's removal and #593's coupled rendering met in the display view's CPU and GPU code: the coupled steps keep their constants after the aux table, and the pixel pass's bindings are the five without the correction's state table, bitmap and threshold.
28
+ - **Cross-part fixes (tests and inputs, no result change):** the DNG import helpers reject malformed input with domain errors (no raw `TypeError`); `import`, `apply` and `develop` are documented verb families (docs/API_STYLE.md); a bound DNG's import names the C2PA store tag as present and not applied, now checked; the GPU renderer's `supports()` keeps refusing unknown tone curves and accepts the two luminance curves; hash locks re-recorded only where two parts changed the same output, each checked against the part that changed its bytes. #597's image-plane light shader declared a local named `common`, a WGSL reserved word that Chrome 153 refuses on hardware adapters; renamed (results unchanged), and a test now rejects reserved words in shipped WGSL.
29
+ - **The GPU coupled steps on SwiftShader (#593, #601):** the Speed kernel and the Quality pixel pass with the coupled steps did not compile on SwiftShader (the GPU process ended after about 44 s; the same on the part alone). Bisected to the colour-matrix lookups (`m_of`, a `switch`) inside the luminance curve's nested loops; those loops now read the matrix rows from one local array (the same values), and the kernels compile in about 2 s. Every coupled case passes on SwiftShader, the hardware adapter and the float32 emulation.
30
+ - **CI capacity (owner decisions):** coverage runs as eight shards in `ci.yml` and `publish.yml` (six in 2.21.0; the coverage gate still merges every shard and enforces the thresholds on the whole suite), and the SwiftShader WebGPU suite as two parallel jobs, `webgpu conformance (SwiftShader, 1/2)` and `(2/2)` (`test/webgpu/display-view-quality.webgpu.ts`, split from `display-view.webgpu.ts`, runs in part 2). The slowest test files are split with the same tests: the Quality cases after the charts into `test/display-view-quality-gpu-cases.test.ts`, the coupled `reconstruct` case into `test/display-view-coupled-gpu-reconstruct.test.ts`, and the extra defocus cases into `test/display-view-defocus-gpu-extra.test.ts`. No job time limit was raised and no test was removed; `test/shard-weights.json` comes from this pull request's CI times.
31
+ - **#594 CPU split:** see "#594 On-demand Quality code" below.
32
+
33
+ ### #598, #600, #591 Phone DNG import and the channel-response helper
34
+
35
+ - **Phone RAW files (#598).** `parseDngRawImage` reads 8-bit lossy JPEG raw data (Compression 34892) with the engine's own sequential-DCT decoder (`parseLossyJpeg`, ITU-T T.81), and the new `importDngRawImage` also reads JPEG XL raw data (52546) through a caller-supplied decoder: `createJxlOxideDngDecoder` adapts the prebuilt jxl-oxide WebAssembly package, whose 16-bit PNG output is read with the platform's `DecompressionStream`. Lossless 8–16-bit tiles round-trip bit-exactly (synthetic fixtures). Results carry `lossySource`, the opcode lists, ForwardMatrix1/2, BaselineExposure, the profile gain table map, RGBTables, decoded semantic masks and an `assumptions` list naming every rendering tag that is present and not applied; no phone tag fails an import. Row/ColumnInterleaveFactor are undone (order inferred from the DNG SDK, reported). New `maximumBytes` memory pre-check (default 1 GiB) and `size: "half"` LinearRaw preview; the 64 MP default stays. Schema 1.1.0. Verification against the owner's phone files is pending.
36
+ - **DNG stage image and opcodes (#598, #600).** `developDngStageImage` applies OpcodeList1, linearization, black and white levels and OpcodeList2/3 (LinearRaw); `parseDngOpcodeList` and `applyDngOpcodes` apply MapTable, MapPolynomial, GainMap, the per-row and per-column opcodes, FixVignetteRadial, WarpRectilinear (cubic convolution at full quality, bilinear with `fast`: new registry entry `dngImport.warpRectilinear`; fast by default, full quality when `format` is an export) and FixBadPixelsConstant/List; WarpFisheye, TrimBounds and WarpRectilinear2 are reported.
37
+ - **Profile gain table map and RGBTables (#598).** `applyDngProfileGainTableMap` and `applyDngRgbTables` apply ProfileGainTableMap(2) and masked RGBTables (weighted sum or sequential) at their places in a development.
38
+ - **Channel response helper (#591).** `calculateCameraChannelResponse({ sensorProfile?, peakQuantumEfficiency?, spectralStepNanometers?, illuminant? })` returns the capture's own channel response (electrons per joule at its spectral nodes) and, with an illuminant, the `channelDispersion` for `calculateLateralColorFringe`; full quality (5 nm) by default; agrees with a capture's reported dispersion to 1e-9. Not part of the capture worker.
39
+ - Dev dependency jxl-oxide-wasm 0.12.6 (MIT OR Apache-2.0; chosen licence Apache-2.0) for tests; the licence check accepts SPDX `OR` expressions with an allowed choice. THIRD_PARTY.md has a JPEG XL note.
40
+
41
+ ### #595, #599, #597, #590, #603 Sensor figures, read noise, mixed light over prints, sample counts
42
+
43
+ - **#595 New: `result.sensor.native`**, the sensor figures of one native photosite (a tier's declared 6 µm reference, a sensor profile's own pitch) beside the as-rendered ones: full well, read noise, engineering dynamic range (and per ISO), `photositesPerSite`, the mid-gray figure on exact requests, the EMVA 1288 Release 4.0 dynamic range (§2.7, Eq. 26–28: μ_e.min = √(σ_d² + σ_q² + 1/4) + 1/2, in stops and dB, with the saturation capacity per µm²), and `normalized8Megapixel`, a convention (not EMVA): DR + ½·log₂(frame photosites / 8 000 000). New root types `SimulateCaptureNativeSensorReport` and `SimulateCaptureMidGreyFigure`. Model figures, exact arithmetic. Professional tier, ISO 100/800/6400: native 14.67/12.72/9.73 stops engineering, 14.36/12.09/9.10 EMVA, at every pitch.
44
+ - **#599 Changed:** the tiers' as-rendered read noise scales by √N above 6 µm ("Default changes"). Rendered dynamic range at 46.9 µm, ISO 100/800/6400: 15.70/15.69/15.18 → 15.66/15.20/12.69 stops (now the binned value N·S₀ / √(N·σ_read² + σ_q,N²)); 187.5 µm: 15.70 → 15.70/15.66/14.54. The mid-gray SNR changes only where read noise matters (46.9 µm, ISO 100: 688.6). Tests: `test/sensor-native-report.test.ts` (new; a dark-frame photon-transfer check at 60 µm).
45
+ - **#597 Mixed lighting over a print.** The 2.21.0 refusal is lifted. **CPU** (capture, display view, ideal reference, moving subject, exports): the print's reflectance spectrum at each point times the blended irradiance, per spectral node, integrated per site at use; no per-pixel signal is cached under a blend (owner rule), only the pixels' reflectance spectra (by row, the same values). Measured (320 × 240 photo on a 384 × 256 DNG, 2 × 2 box): one light 73 ms; a two-source gradient 760–780 ms, a point source 590–625 ms (about 1,240 and 1,070 ms without the spectra cache). **GPU** (owner decision): the print's Quality passes take a light table (the blend's geometry and stops, the regions, the point sources, and each light's spectrum at the nodes; no channel value) and pass 1 lights each lattice point: the blend in double-float32, weights rounded once, hard edges and region edges flagged, point sources' h/d³ as bounded values, flagged when the computed bound exceeds 39·E; the site-sum bound grows by 40·E. Speed requests take these passes too (a print under a blend has no per-pixel signals). Registered as `illumination/image-plane-gpu` (fast: `gpu: "auto"`, float32; exact: the CPU reference). The self-check adds a lit case. Emulation: every case inside its bounds, samples within one code, ΔE2000 ≤ 1.9e-5, no flags on the gradient, radial-with-hard-edge and regions-plus-point cases; a regions edge exactly on the lattice is flagged (40 points) or resolved on the host (140). Charts under a blend keep the CPU. Tests: `test/image-plane-mixed-lighting.test.ts` (new), `test/display-view-image-gpu.test.ts`, `test/fast-methods-registry.test.ts`. Not measured on a hardware adapter.
46
+ - **#603 Catalogue fast grid.** `CATALOGUE_FAST_GRID_SPEED_SUBDIVISIONS` (4) on fast requests, `CATALOGUE_FAST_GRID_MINIMUM_SUBDIVISIONS` (10) on exact ones, resolved from the request's `fast`; registered as `lens.catalogue/field-grid-cells`, reported as `minimumSubdivisions` in `fast.lens.catalogue`. Measurements (frame time, fallbacks and lateral color for both grids) in docs/LENS_CATALOGUE.md: lateral color 96–101 % of the model with either grid. Tests: `test/simulate-capture-lens-catalogue-fast.test.ts`.
47
+ - **#590 Sample counts follow the output.** New `src/color/spectral-spikiness.ts`: κ = Σ|Δ²S| / ΣS on a 1 nm grid and R(h) = h²·κ/24 (the composite midpoint rule's error term, DLMF §3.5(i), normalized; the 0.01 threshold is a design choice). Measured κ reproduces #590's table (fluorescent R(20) 0.835; LEDs 0.0384, 0.0306, 0.0167; CIE A, Planck 2700 and 6500 K ≤ 3.0e-4). The default step is resolved after the format and the request's `fast` (the parse order changed: `fast` is now resolved before the sensor options; the light is read early for the measure). Registered as `sensor.spectralStepNanometers`, which joins `SIMULATE_CAPTURE_FAST_OPTIONS` and `SIMULATE_CAPTURE_COST_OPTIONS` (owner decision 2026-11-11): under a spiky light with the default step `diagnostics.fast.applied` reports it (true on fast requests, with `assurance: "approximation-with-a-priori-bound"`; false on exact ones), so a DNG join refuses fast parts; otherwise no key is added. White under the fluorescent source, exact: 5 nm within 0.34 % of 1 nm per channel; 20 nm 70 % off in one. Cost of 5 nm against 20 nm on a 384 × 256 DNG (tabulated path): 44 against 40 ms. **Measured, not changed (owner: decide from the numbers):** `sensor.pixelAperture.samplesPerAxis` (default 4) against 16, noise-free expected values on 96 × 64 frames: RMS error 1.75e-2 / 4.1e-3 / 1.25e-2 of the peak (Siemens star / slanted edge / zone plate) at 4, 6.1e-3 / 1.1e-3 / 2.1e-3 at 8; cost on a 384 × 256 DNG 43 / 53 / 90 / 242 ms at 1 / 2 / 4 / 8. `sensor.temporalSampleCount` (default 8 when moving) against 64: RMS error 0.125 (subject, a long streak) and 0.18 (rotation) at 8, 0.074 and 0.096 at 16; a 384 × 256 DNG with a moving subject 6.8 / 13.2 / 25.9 / 51.3 s at 4 / 8 / 16 / 32. The error follows the streak length, so a fixed count cannot bound it; both defaults are left as they are and the choice is put to the owner. Diffraction radius and bokeh zones stay exact-only. Tests: `test/spectral-step-default.test.ts` (new).
48
+
49
+ ### #593, #601, #602 Display view: luminance tone curves, highlight reconstruction, desaturation
50
+
51
+ - **New (#593):** `simulateDisplayView` and `prepareDisplayView` render the luminance tone curves (`extended-reinhard-luminance`, `naka-rushton-luminance` with `toneCurveParameters`) and `output.highlightMode: "reconstruct"`, which they refused through 2.21.0. Photivra's own looks for the app: Standard (Naka–Rushton n = 1.5, Lw = 8, σ = 0.5009) and Flat (n = 1, Lw = 32, σ = 0.8465), both keeping 18 % gray at 0.18; request parameters, not engine presets. A tone curve's moments come from conditioning each developed component on luminance; `reconstruct` (and #601's desaturation), where a pixel reaches it, from a 3-D Gauss–Hermite rule over the whole development chain (`src/facade/display-view-rendering.ts`; docs/DISPLAY_VIEW.md, "Coupled rendering"). Reported in the assumption `view.rendering`.
52
+ - **New (#601):** `output.gamutHandling: "desaturate-to-luminance"` (opt-in; default `"clip-components"`) and the same value of `SdrRenderingProfile.gamutHandling`: after the tone curve, a pixel whose largest component exceeds 1 moves toward the gray of its own luminance until that component is 1, keeping luminance and hue. sRGB rendering only; captures (CPU), the display view (CPU and GPU). The image-kernel accelerator's WebGPU SDR kernels refuse it (`unsupported-on-backend`; their fallback runs the CPU reference).
53
+ - **New (#602):** `output.highlightMode: "reconstruct-neighbors"` (opt-in, an estimate): clipped channels rebuilt from the color ratios of the unclipped pixels around each clipped region (widened by 2 pixels, whose demosaic reads clipped sites): the ring's mean ratios (fast) or their harmonic interpolation inward (full). Captures, the ideal reference and the CPU display view; not with `output.region`; not on the GPU in 2.22.0 (`unsupported-on-backend`). `RawHighlightDiagnostics.mode` gains the value; `SensorColorDevelopment` gains `developWhiteBalancedInto`.
54
+ - **GPU (#593, #601):** the Speed kernel and the Quality pixel pass run the luminance tone curves (every exponent), `reconstruct` and the desaturation, with the fast and the full rules, from one on-demand WGSL module (`src/gpu/wgsl-display-view-coupled.ts`) with first-order running bounds (docs/WEBGPU.md, "Coupled rendering steps"); undecided threshold decisions are recomputed on the host. Measured on the float32 emulation and on a hardware adapter (Apple, Metal 3): every component inside its bound, at most one code.
55
+ - **Speed versus accuracy:** registry entries `output.toneCurve/view-luminance-moments` (fast: 8-point Gauss–Hermite in luminance; full: composite 4 × 16-point Gauss–Legendre), `output.highlightMode.reconstruct/view-moments` (fast: 6 points per axis; full: 16), `prepareDisplayView.gpu/view-coupled-moments` (the GPU kernels with either rule) and `output.highlightMode.reconstruct-neighbors/region-ratios` (fast: ring mean; full: harmonic fill). Fast on screen, full for exports and `fast: false`. Measured fast-versus-full gaps: tone 6.7·10⁻⁶; chain 1.5·10⁻² linear at channel σ up to 0.076 (2.4·10⁻⁴ at σ ≤ 0.01).
56
+ - **On demand:** the coupled steps and the neighbor reconstruction load with the CPU Quality kit (#594), and the GPU coupled steps (`src/gpu/wgsl-display-view-coupled.ts`) on their own, only for requests that ask for them; the default path imports their types and a 0.9 KB WGSL stub only.
57
+ - **Unchanged:** requests without these options are byte-identical (the display-view hash locks pass unchanged).
58
+ - **Size (owner decision: no trimming; the code moves on demand with #594):** measured on the part alone, the display view's added entry size was 49,573 B minified (budget 46,500 B); after the #594 split it is 38,370 B (see "#594 On-demand Quality code").
59
+ - **Tests:** `test/display-view-coupled-rendering.test.ts`, `test/display-view-coupled-gpu.test.ts`, `test/neighbor-highlight-reconstruction.test.ts` (new); coupled cases in `test/display-view-speed-gpu.test.ts`, `test/webgpu/display-view.webgpu.ts` and `test/fast-methods-registry.test.ts`.
60
+
61
+ ### The display view's AHD low-signal correction removed; per-site wording
62
+
63
+ The owner decided (2026-11-11) to drop the display view's AHD low-signal correction, which was kept as precomputed values on a grid of the three channel signal levels. Computing it per site at use instead was measured at about 240–1300× the view's time (one evaluation 18 ms with 2048 draws, 74 ms with 8192), so the correction is removed on every path rather than replaced. The view's signals stay integrated per site at use.
64
+
65
+ - **Changed (default change, display view with `output.demosaic: "ahd"`):** no low-signal correction on the CPU (fast and `fast: false`) or in the GPU Quality kernel. `view.demosaic` is now `{ capture: "ahd", view: "ahd-candidates-with-noise-free-choice", lowSignalNoiseChoice: "not-modelled", scientificStatus: "approximation", measuredBias8BitCodes }` (before: `ahd-candidates-with-low-signal-correction` with `correctedPixels`, `correctionDrawsPerState` and `correctionThresholdLinearStd`). Measured shift of the view against 2.21.0, mean over a centred 32 × 32 block at f/8: 1.4 µm at ISO 6400, red +3.8 to +4.6 codes (8-bit; at most 6.7 in a pixel); 6 µm at ISO 6400, within 0.3 code; the app's 6 µm ISO 400 Quality request, within 0.01 code. A view without AHD, or whose pixels' noise stayed under the old 0.01 threshold, is byte-identical.
66
+ - **Changed (fast methods):** the registry entry `output.demosaic.ahd/view-low-signal-correction` is removed; AHD has no fast method in the view either, so `diagnostics.fast.applied["output.demosaic.ahd"]` is false and an AHD view on its own reports `assurance: "engine-reference"` (the approximation is labelled in `view.demosaic`). `fast` and `fast: false` give the same AHD view.
67
+ - **Deprecated (kept until 3.0.0, documented in docs/DISPLAY_VIEW.md, "AHD at low signal"):** `serializeDisplayViewCorrectionStates()` returns `[]`; `bindDisplayViewCorrectionStates(entries)` checks its entries as before and returns 0; `prepared.correctionKey()` returns null; `prepared.collectCorrectionStates(view)` and `prepared.computeCorrectionStates(gridKeys)` check their arguments as before and return no keys or states; `diagnostics.ahdCorrectedPixelCount` is always 0. The types `DisplayViewCorrectionStateSet` and `DisplayViewCorrectionStateKeys` stay. No root export is removed.
68
+ - **Changed (label only):** the capture's per-site expected-signal path (#394) reports `raw.expectedSignal` `method: "per-site-integrated-electrons"` (before: `tabulated-expected-electrons`); its explanation says each site's electrons are integrated from its materials' spectra when the site is read, as since 2.21.0. RAW codes and samples are unchanged.
69
+ - **Tests:** the display view's uniform AHD hash lock (the one case where the correction applied) is re-recorded on x64 and arm64; the low-signal AHD comparison against the capture takes a 5-code limit for the 1.4 µm ISO 6400 case (4.25 measured); the state-exchange tests become one deprecation test; the GPU Quality tests drop the state-store test.
70
+
71
+ - **Changed (label only):** `lens.catalogue`'s fast method is described as what it has done since 2.21.0: each site's expected electrons are integrated from its taps' material spectra when the site is read. The `raw.expectedSignal` assumption's `method` of a capture that took this fast method is now `per-site-integrated-electrons-catalogue-psf-field-grid` (before: `tabulated-expected-electrons-catalogue-psf-field-grid`); its explanation and the fast-methods registry entry `lens.catalogue/field-grid` say the same. Samples, RAW codes and every other assumption field are unchanged. Code that matched the old `method` string must match the new one.
72
+
73
+ ### #165 Content Credentials writers (engine half)
74
+
75
+ - **New (#165, engine half):** `bindC2paManifestStore`, `calculateC2paDataHash`, `setC2paManifestStore` and `parseC2paManifestStore` embed a caller-built C2PA Manifest Store in a JPEG (APP11 segments right after APP0, as the reference SDK writes them, C2PA 2.4 §A.3.1), PNG (a `caBX` chunk right after IHDR, §A.3.2) or DNG/TIFF (tag 52545 in the main IFD, store at the end of the file, §A.3.6); return the `c2pa.hash.data` exclusions (§18.5.3: every C2PA APP11 segment; §18.5.4: the whole caBX chunk; §18.5.5: the entry's count field and the store); compute the SHA-256 over the bound bytes; write the signed store in place at exactly the placeholder's length (§10.4), so no offset moves; and find an existing store and its binding without verifying signatures. `C2PA_ALGORITHMIC_MEDIA_SOURCE_TYPE` (owner decision: IPTC `algorithmicMedia`) and `C2PA_MANIFEST_STORE_MAXIMUM_BYTES` (32 MiB). No keys, signing, manifest building or network calls in the engine. See [Content Credentials](https://github.com/Photivra/photivra/blob/main/docs/CONTENT_CREDENTIALS.md).
76
+ - **Root exports:** 15 new (6 runtime: 4 functions, 2 constants; 9 types). Nothing is removed and no existing output changes.
77
+ - **Sizes:** the app's capture-worker shape is unchanged by this part (the module is imported by nothing in the capture path; `test/capture-bundle.test.ts` asserts it stays out). An application importing the four functions adds 4,883 B gzip (11,978 B minified; rolldown 1.2.9).
78
+ - **Pending:** validation of signed files with the C2PA reference tooling (no validator offline, and the engine cannot sign).
79
+
80
+ ### #594 On-demand Quality code
81
+
82
+ - **Changed (bundling, #594):** the display view's GPU Quality host and its WGSL (`display-view-quality-gpu.ts`,
83
+ `wgsl-display-view-quality-kernel.ts`, `wgsl-exact-sum.ts`) load on demand, at the first GPU render that takes the Quality
84
+ passes (diffraction, AHD, more query offsets than the Speed kernel holds, a defocused view, an image-plane print). The combined
85
+ renderer moves to `src/gpu/display-view-gpu-renderer.ts`; the Speed path no longer imports the Quality host. Results are
86
+ unchanged: GPU renders through `prepareDisplayView` on the float32 emulation (two Quality, two defocus and one Speed request)
87
+ give the same sample and bound hashes before and after.
88
+ - **Changed (diagnostics timing, owner decision 2026-11-10):** the Quality kernels' two self-check cases run before a view's
89
+ first Quality render instead of before its first GPU render. A Speed-only view's `conformance.cases` lists the two built-in
90
+ cases and the Speed case; a view that takes the Quality passes appends the two Quality cases. A failure keeps that render and
91
+ every later one on the CPU, labelled `conformance-failed`. Internal: the renderer contract gains `onDemandConformanceCases`.
92
+ - **New:** `prepareQualityPaths()` (root export; owner decision 2026-11-10) loads the on-demand full-quality code ahead of time,
93
+ for example when the user opens Export. Optional, single flight, idempotent, no change to results; a failed load rejects with
94
+ `ImageKernelExecutionError` reason `quality-code-unavailable` and is retried by the next call or request. It loads the GPU
95
+ Quality chunk and the two CPU kits.
96
+ - **New fallback reason:** `quality-code-unavailable` (in `DisplayViewGpuFallbackReason` and `ImageKernelFailureReason`). If the
97
+ Quality chunk does not load, that render runs the CPU reference (the same full-quality result, slower), labelled; with
98
+ `gpu: "require"` it fails with that reason. Not sticky: the next render that needs the code loads it again. A failed
99
+ self-check stays sticky.
100
+ - **Changed (bundling, CPU, #594):** the CPU full-quality code is one on-demand chunk, the Quality kit (`src/facade/quality-kit.ts`:
101
+ diffraction, the capture's AHD demosaic, `lens.defocus` beyond the geometric disc (bokeh shapes, cat's eye, the large-disc
102
+ lattice) and the image-space provider (lens projection, distortion, vignetting, rotation, a moving subject), the display view's
103
+ coupled rendering steps and the neighbor highlight reconstruction), and the opt-in features another, the opt-in kit
104
+ (`src/facade/opt-in-kit.ts`: `sensor.profile`, `illumination`, `motion.subject`). Each loads once where the request is parsed
105
+ and is then called synchronously; every hash lock passes. The app's geometric-disc focus control stays on the fast path. Not
106
+ moved: the native-photosite report (#595; every capture reports it), the exposure build-up hooks (255 B, in the RAW
107
+ producers) and the display view's inline AHD candidates. `ImageKernelExecutionError` moves to `src/core/execution-error.ts`
108
+ (re-exported unchanged) so the loader adds no GPU code to the first load.
109
+ - **Size (app capture-worker shape, rolldown 1.2.9, gzip 9):** after the GPU split, fast worker 363,695 → 344,287 B and GPU
110
+ Quality chunk 20,026 B on demand (all chunks +618 B: two chunks compress separately; owner-accepted). With every part merged
111
+ the fast worker measured 358,680 B; after the CPU split it is **337,413 B** and on demand **46,977 B** (GPU Quality 19,401 B,
112
+ CPU Quality kit 18,249 B, GPU coupled steps 5,159 B, opt-in kit 4,168 B); all chunks 384,390 B. `test/capture-bundle.test.ts`
113
+ checks the owner's two rows, each its measurement rounded up to the next 100 B (owner answer, 2026-11-10): fast worker
114
+ ≤ 337,500 B, on demand ≤ 47,000 B, in place of 2.21.0's 363,700 B all-chunk limit. The bundler now gives the entry ten small
115
+ chunks it shares with the kits, so the display view's added size is measured on the entry and those chunks: 38,370 B minified
116
+ and 14,126 B gzip (budgets 46,500 B and 18,550 B, unchanged; 2.21.0: 46,270 B and 18,327 B). The app adopts the two rows
117
+ at app round 16.
118
+ - **Root exports:** 1 new (runtime): `prepareQualityPaths`.
119
+
120
+ ## 2.21.0 — published November 10, 2026
121
+
122
+ A minor release that combines the 2.21.0 and 2.22.0 parts (owner decision 2026-11-10): the chart-signal design choice on every render path, sensor profiles and phone lenses (#554), the image-plane scene (#581), catalogue-lens boundary ties (#588) and lateral color (#589), exposure build-up (#534) with subject motion (#551) and mixed lighting (#550), the lateral color fringe width (#580), Photivra's own light-source spectra (#557) and the print planner's contain fit (#365). See the [2.21.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_21_0.md).
123
+
124
+ - **New:** `sensor.profile` and the profile catalogue `SENSOR_PROFILES` / `resolveSensorProfile`, with three phone lenses (#554); the `image-plane` scene and `prepareImagePlaneScenes` (#581); `simulateExposureBuildUp` (#534); `motion.subject` (#551); `illumination` (#550); `calculateChannelEffectiveDispersion` (#589); `calculateLateralColorFringe` (#580, with an optional `channelDispersion`); the representative light sources and `createRepresentativeLightSourceIlluminant` (#557); `fit: { kind: "contain" }` in the print planner (#365).
125
+ - **Changed (shipped images and files; see "Default changes"):** chart signals are integrated from the material spectrum per site on every render path (a design choice; samples unchanged but for rounding, Quality paths slower); catalogue PSF kernels carry the PSF's own centroid (#589); catalogue-lens ties resolve as on the lattice (#588); `output.lensCorrections` and DNG lens-correction opcodes use the capture's own channel dispersion (#589); `PrintPlanInput.fit` is a union (#365).
126
+ - **Root exports:** 41 new, 1,890 → 1,931 (runtime exports 637 → 654): 6 from #554 (4 runtime), 3 from #581 (1 runtime), 6 from #534 (3 runtime), 1 from #551 (type), 5 from #550 (types), 3 from #589 (1 runtime), 4 from #580 (1 runtime), 11 from #557 (7 runtime), 2 from #365 (types). Nothing is removed.
127
+ - **Why minor:** new root exports and options; every default change below keeps the old behavior one option away or is a model correction. #365's union type needs a narrowing where code reads `fit.maximumRelativeAspectError`. Not in this release: #590 (sample counts by format, with the spiky-light 5 nm step) and #591 (the channel-response helper).
128
+
129
+ ### Default changes (migration)
130
+
131
+ | What changed | Before (2.20.0) | Now (2.21.0) | Keep the old behavior |
132
+ | --- | --- | --- | --- |
133
+ | Chart signals on every render path (design choice, owner decision 2026-11-09) | per-material channel values (and a white-integral shortcut for neutral materials) looked up per site | each site's signal integrated from its materials' spectra when it is read, on the CPU and GPU, capture, display view and ideal reference | no option (same samples but for rounding: 1 sample in 442,368 by one code in the measured Quality view; Quality paths 1.5–14× slower, the owner accepted; GPU Speed emulation about +14 %) |
134
+ | Catalogue PSF kernels (#589) | the cells' quantized centroid | each node kernel's centroid moved onto the PSF's own first moment (coma's tilt plus lateral color): rendered lateral color 96–101 % of the lens model | no option (a model correction; off-axis catalogue captures and views change) |
135
+ | Catalogue fast grid (#589) | at least 4 cells per field interval | at least 10 (no run-time fallbacks; 768-wide fast frames 36–40 % slower) | no option |
136
+ | Catalogue-lens ties (#588) | each tap's own point on the exact method | each tap read at its lattice point; under rolling-shutter rotation (or a moving subject) the per-tap points are `lens.catalogue`'s fast method (fast on screen, full for DNG, JPEG and PNG) | `lens: { catalogue: { kind, fast: true } }` keeps per-tap points there |
137
+ | `output.lensCorrections` lateral color and DNG `lensCorrectionOpcodes` (#589, #587) | channel scales at single wavelengths 610, 540 and 465 nm | each channel's effective dispersion for the capture's own channels and light (reported as `channelDispersion` in both assumptions) | no option; `createGenericLensCorrectionProfile` without `channelDispersion` keeps the single wavelengths |
138
+ | `PrintPlanInput.fit` type (#365) | one record kind | the union `PrintFit` (`confirmed-native-aspect` or `contain`) | narrow on `fit.kind`; `confirmed-native-aspect` plans are byte-identical |
139
+ | Catalogue lenses with `sensor.profile` (#554) | (new option) | framed on the profile's real sensor (its centered region of the output's aspect) | without `sensor.profile` nothing changes |
140
+ | Auto ISO's whole-stop grid (#554) | counted from ISO 100 | counted from the body's lowest ISO | the tiers are unchanged (their lowest is ISO 100) |
141
+ | Image-plane print surround (#581) | (new) | its channel signals integrated from its spectrum like a pixel's | — |
142
+
143
+ ### Integration (this release)
144
+
145
+ - **Merged** with `--no-ff` in this order: `release-2.20.0` (to join the squashed 2.20.0 history; tree unchanged), `e221-chart-signals`, `e221-588`, `e221-589`, `e221-554`, `e221-581`, `e222-534` (with #551 and #550 merged in), `e222-580`, `e222-557`, `e222-365`.
146
+ - **Speed versus accuracy (2.20.0's scheme):** #588's rolling-shutter choice resolves through `resolveFast(catalogueFast, fastTop)`, so `diagnostics.fast.requested` is the resolved top-level value with `defaultedFrom`, and PNG counts as an export; #565's and #588's lattice providers are one branch (the two never combine) with one assumption method name, `per-tap-source-points`; the registry gains `lens.catalogue/rolling-shutter-taps` (and #551's `motion.subject/temporal-midpoints`).
147
+ - **Design-around on the merged paths:** #581's GPU Speed adapter integrates the material's spectrum in the kernel (`material_signal`) instead of 581's per-material table; the image-plane print's surround is integrated from its spectrum instead of the white integral; #534's display-view lens signal integrates each material's spectrum at #551's times. Mixed light over an image-plane print is refused (its pixel signals are integrated under one illuminant).
148
+ - **#589 with #587:** the DNG opcodes take the same capture-channel dispersion as `output.lensCorrections`, so they still reproduce the developed correction exactly (as 2.20.0's test requires).
149
+ - **#580:** takes #589's dispersion (owner decision) and is re-measured against the RAW centroid measure.
150
+ - **#554 with #567:** the phone lenses' bokeh (the consumer construction; the 24 mm one has a cat's eye) is covered by the bokeh-value test.
151
+ - **CI capacity (owner-approved):** this release's suite is about 28 % longer (CI test legs 2,612 → 3,350 s), and three of 2.20.0's four coverage shards reached the 20-minute job limit. Coverage now runs as six time-balanced shards in `ci.yml` and `publish.yml` (the `coverage gate` job still merges them and enforces the thresholds on the whole suite), the five chart cases of `display-view-quality-gpu.test.ts` run from their own file, and `test/shard-weights.json` is re-estimated from this pull request's CI times. No test was removed and no job limit was raised.
152
+ - **Size:** one pass for savings, then the capture-worker limit raised by the remaining overage (owner rule for this release): 365,164 → 363,695 B after making #554's profile parser and #550's module static imports (as lazy chunks they split the core modules into shared chunks); limit 356,000 → 363,700 B. The display view's added size stays inside its unchanged budget (46,270 B minified, 18,327 B gzip). See the release record's "Sizes".
153
+
154
+ ### Chart scenes: signals integrated from the material spectrum per site
155
+
156
+ - **Changed (design choice, owner decision 2026-11-09): every chart-scene render path integrates each site's signal from its material's spectrum when the site is read.** A chart material is kept as its spectral radiance at the spectral nodes (a neutral one: its reflectance times the white radiance), and a site's signal is formed from the spectra of the materials it reads against its own channel's response (and kernel). No per-material channel value, and no white-integral shortcut for neutral materials, remains:
157
+ - **Capture** (the tabulated RAW path, #394, and #564's large-kernel tiles), **CPU display view** (direct and kernel paths, #563/#569 lens zones) and **ideal reference**: per-node kernels C_c(t, λ) = Σ_members w·A_c(λ)·K_λ(t), and per site the sums of C over the taps of each material it reads, then Σ_m Σ_λ L_m(λ)·P_m(λ) (`MaterialSums`, `src/facade/expected-signal.ts`). `neutralDirect`, `spectralDirect`, the per-material combined kernels and `uniformSite` are gone; a site that reads one material only takes the channel's kernel sums in the same order, so it is the same value.
158
+ - **Catalogue fast method (#568):** grid kernels per spectral node (A_c(λ)·K_{g,λ}), no material class folded in; each tap integrates its material's spectrum against the blend. The blend is taken along y once per grid column and native row.
159
+ - **Display view:** the per-material sample memo is removed (the per-real-pixel memo, within one render, stays).
160
+ - **GPU Speed:** the host uploads the channel responses and the material spectra (rounded once); the kernel integrates Σ_λ A_c(λ)·L_m(λ) per site in blocks of 16 nodes, inside the unchanged 64E site budget (up to 256 nodes).
161
+ - **GPU Quality:** the host uploads the per-node kernel values and the spectra; the site passes accumulate one partial per node and sum the partials and the dark electrons in integer arithmetic (`src/gpu/wgsl-exact-sum.ts`, rounded once), so the (T + 3)·E and blocked (cw + ch + G + 3)·E bounds are unchanged (at most 96 nodes; more fall back to the CPU). The factored neutral table and its material-row binding are gone; the uncertain-tap spread is bounded from the spectra's envelope.
162
+ - **`evaluateChartRadiance`, spectral-integral mode:** the GPU returns each query's material and the host integrates its spectrum against the channel weights per query (the same float64 operations; values unchanged).
163
+ - **Results.** Output samples are unchanged in every measured request but one: in the Quality display view of a 384 × 384 region of the composite chart, 1 of 442,368 samples moves by one code (an expected value's last bits crossing a code boundary). Float64 internals move by rounding: 11 display-view hash locks re-recorded (x64 with x64 Node 24.1.0 under Rosetta 2, which reproduces every earlier x64 lock; arm64 natively), expected values within 1.9·10⁻¹⁴ relative, except the #444 AHD case, where 30 of 360 expected components at a patch edge move by at most 0.025 8-bit codes (AHD's choice is now taken where the two candidates' means were equal to the last bit). Every GPU case stays inside its bound (emulation).
164
+ - **Cost** (Node, one thread, measured before and after interleaved in one run; the app's request shape: 6000 × 4000, 6 µm, 2 × 2 box, f/8, Quality with diffraction, the four-spot filter and AHD; the app's composite chart, all neutral, and the spectral `color-patches` chart; 21 spectral nodes):
165
+ - Capture 24 MP, Speed: 11.2 → 11.5 s (composite), 9.4 → 9.6 s (patches). Quality: 13.6 → 96.3 s, 10.9 → 15.9 s.
166
+ - 48-pixel quick frame, Speed: 21 → 23 ms, 20 → 20 ms. Quality: 65 → 68 ms, 64 → 65 ms.
167
+ - Display view, a 384 × 384 region of a 2028 × 1352 view, Speed: 1.06 → 1.06 s, 0.62 → 0.79 s. Quality: 2.7 → 33.9 s, 1.10 → 2.07 s.
168
+ - Display view, a 48-pixel view of the 24 MP frame, Speed: 0.73 → 0.93 s, 0.43 → 0.54 s. Quality: 5.6 → 76.7 s, 2.4 → 6.4 s.
169
+ - Ideal reference, 24 MP: 4.1 → 5.8 s, 2.9 → 3.1 s.
170
+ - Catalogue fast method (#568), 768-pixel quick frame: 193 → 242 ms (Siemens star), 207 → 242 ms (patches).
171
+ - GPU float32 emulation, a 384 × 384 Speed region: 0.93 → 1.07 s (checkerboard), 6.2 → 6.3 s (patches); Quality site pass of a 2028 × 32 band: 1.0 → 5.2 s (pixel pass unchanged, 14 s).
172
+ - The Quality paths integrate N products per kernel tap (400 taps at f/8) instead of reading one value, as the owner accepted.
173
+ - **Size.** The app's capture-worker shape measures 351,992 B gzip (main: 351,775 B; limit 352,000 B, not raised).
174
+ - **Tests:** `test/exact-sum.test.ts` (new); hash locks in `test/display-view.test.ts`, `test/display-view-pixel-memo.test.ts`, `test/display-view-speed-material.test.ts`.
175
+
176
+ ### #588 Catalogue lenses: read PSF quadrature taps at lattice points (chart-boundary ties)
177
+
178
+ - **Fixed:** `simulateCapture` with `lens.catalogue` (the exact method, the environment PSF quadrature) formed each tap's source point from its own site's arithmetic, so a tap exactly on a chart boundary (a Siemens star's diagonals through a symmetric frame's center, its vertical spokes through an odd frame's center column) could resolve to the other material than on the lattice that the fast method (#568), the display view (#569) and #548's image-space provider read. The quadrature's provider is now #548's image-space provider with a one-tap kernel (as #565 does for `lens.diffraction`), so each tap reads the scene at its lattice point; the fast method already read the lattice. A catalogue fisheye's lattice points beyond the horizon (outside the 180° image circle) read zero radiance, as the provider does (new internal `zeroBeyondHorizon` input of the image-space provider). With rotation under `motion.shutter: "rolling"` the reading follows `fast` (below).
179
+ - **Which results change:** exact-method catalogue captures, only at such ties. On a 20 × 14 Siemens star at 60 µm, *PV 24 1.8* wide open: 20 of 352 native sites (largest 15× the lattice value); 21 × 14: 8 sites (0.7 %); *PV 8 3.5 Fisheye Pro*: 18 and 8 sites. Elsewhere the expected electrons agree to 2.4e-15. No API, option or default change; `src/gpu` unchanged.
180
+ - **Rolling shutter with camera rotation (owner decision, 2.21.0):** the per-tap points are `lens.catalogue`'s fast choice and the lattice reading its full method, folded into the existing `fast` override (no new option name): the record's `fast`, else the top-level `fast`, else fast for `srgb-8`/`srgb-16` and full for `output.format` `"dng"`, `"jpeg"` or `"png"`. The field-grid method never runs with rotation, so there `diagnostics.fast.applied["lens.catalogue"]` is `true` exactly when the per-tap points ran (`assurance: "approximation-with-a-priori-bound"`, assumption `fast.lens.catalogue` with `method: "per-tap-source-points"`, as for `lens.diffraction`); `requested` follows 2.20.0's scheme (the resolved top-level value), so the on-screen default reports `requested: true, defaultedFrom: "format", applied: true`. The integration shares one lattice provider with #565's `lens.diffraction` branch. The lattice reading there costs 1.07–1.09× the per-tap time (docs/LENS_CATALOGUE.md). Which results change: on-screen captures keep 2.19.0's per-tap RAW; DNG, JPEG and PNG captures of this case now read the lattice (they differ only at ties). Joined parts match the whole frame only when every part reads the same way: set `fast` explicitly. Nothing else changes: rotation with a global shutter always reads the lattice, and the field-grid method's resolution is unchanged.
181
+ - **Cost:** in a CPU profile of a 96 × 64 exact capture (*PV 50 1.8*, f/8, 20 nm) the lattice reader takes about 2.5 % of the time; interleaved wall-clock runs (load average 6–78 from other work) did not separate it from noise (median ratios 0.95–1.03). The image-space provider now replaces an engine-built base result's radiance in place instead of copying the result per query (saves about 1 % there; also for `lens.defocus` in image space).
182
+ - **Bundle:** capture worker +150 B gzip on 2.19.0 (351,775 → 351,925 B; limit 352,000 B, not raised): the lattice branch +117 B and the rolling-shutter fast choice +118 B, less 38 B from sharing the image-space provider's lattice-point mapping and per-node sampling between its band and run-lattice paths and one error site (values bit-identical), and the catalogue's two fast-report updates sharing one function. The display view's added size: 18,153 → 18,154 B gzip (budget 18,550 B); the capture entry chunk 261,002 → 261,167 B.
183
+ - **Tests and docs:** `test/simulate-capture-lens-catalogue-ties.test.ts` (new; *PV 24 1.8* and *PV 8 3.5 Fisheye Pro*, 20 × 14 and 21 × 14 stars: the exact method's RAW equals the lattice reading's, the fast method's sum with exact kernels, and the per-tap points differ at ties; fails on 2.19.0; and, with rolling rotation, the default by format, every explicit override, the report, and equal RAW on dead leaves through both lenses). docs/LENS_CATALOGUE.md ("Ties on chart boundaries"), docs/DISPLAY_VIEW.md (the catalogue section's tie note), docs/API_STYLE.md ("Exact and fast modes"), API reference regenerated (`SimulateCaptureLensCatalogueRecord`).
184
+
185
+ ### #589 Catalogue lenses: check rendered lateral color against the lens model on large-blur corners
186
+
187
+ - **Measured:** the blue-minus-red displacement in RAW, independent of blur and demosaicing: the per-CFA-channel centroid of an isolated lattice-point source in a 6 µm region's expected electrons (summed over the four points of a 2 × 2 cell, so each channel sees every kernel tap), fast and exact. Against the lens model at the channels' effective dispersion (below; 0.833 of the single-wavelength L·R·H·(D(465) − D(610)) for the default sensor), *PV 24 1.8* wide open rendered 101 % / 109 % (x / y) at the corner and 121 % / 83 % mid-field, *PV 24 1.4 Pro* 73 % / 38 % and 19 % / 12 %. The large-blur consumer corner was not short of lateral color; the site cells quantized a narrow PSF's centroid and the kernel window's truncation moved a wide one's.
188
+ - **Fixed (owner decision):** each catalogue PSF node kernel's centroid is moved (a linear-interpolation shift between neighboring taps) onto the PSF's own first moment, the pupil-mean wavefront tilt 2·N_w·⟨∇W⟩ (coma's W131·H) plus the lateral color L·R·H·D(λ) (Fourier moment theorem; Goodman, *Introduction to Fourier Optics*, ch. 4). Now 96–101 % of the model for both lenses, corner and mid-field. On-axis kernels are unchanged. A cost-free accuracy gain in time (the profile builds in the same time), so no `fast` choice; an approximation with a blur cost: a move of f sites adds f·(1 − f) sites² of variance, so the 6 µm corner edge rise went from 1.09 to 1.32 px (pro) and from 1.44 to 1.56 px (consumer); the center is unchanged.
189
+ - **Channel effective dispersion (owner decision):** new `calculateChannelEffectiveDispersion({ wavelengthsNm, channelResponse, illuminant })` (approximation, model `generic-lens-channel-effective-dispersion` 1.0.0) gives each channel's D̄_c = Σ response·illuminant·D(λ) / Σ response·illuminant. `createGenericLensCorrectionProfile` takes it as the optional `channelDispersion` (scales 1 + L·D̄_c; without it, D at 610, 540 and 465 nm as before). `output.lensCorrections` now uses the capture's own channels and illuminant and reports `channelDispersion` in its assumption: for the default sensor the red scale is 1 + 0.20·L instead of 1 − 0.11·L, the blue 1 + 1.04·L instead of 1 + 0.90·L. `GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM` is unchanged and remains the profile's default. Root exports +3 (1,860 → 1,863): `calculateChannelEffectiveDispersion` (runtime), the types `CalculateChannelEffectiveDispersionInput` and `ChannelDispersion`.
190
+ - **Which results change:** every `lens.catalogue` capture and display view off the axis, and every capture with `output.lensCorrections.lateralColor` (its channel scales and fill). The chart measures (docs/LENS_CATALOGUE.md): corner rise above; coma 0.02 → 0.10 px (pro), 0.36 → 0.35 px (consumer); the developed corner edge's blue-minus-red 0.31 → 0.44 px (pro), 0.71 → 0.68 px (consumer). Hash lock `catalogue50` in `test/program-line-lens-range.test.ts`: samples and codes changed (off-axis kernels), exposure and assumptions unchanged.
191
+ - **Fast method (owner decision):** the field grid's minimum subdivisions go from 4 to 10 cells per interval (`CATALOGUE_FAST_GRID_MINIMUM_SUBDIVISIONS`, internal), so the run-time verification never falls back. Measured over 3,600 whole frames (every lens, widest and narrowest f-number, 14 charts, 192–6000 pixels wide): with 4 cells and aligned kernels 15 fell back to exact (0.42 %, all *PV 15 2.8 Fisheye Pro* at f/2.8 on 192 pixels), with 10 none (worst 8.0e-4 of the 5e-3 tolerance). Against the exact kernels: at most 2.0e-4 of the peak and 3.4e-3 of a site (the test's 1e-3 and 5e-3 bounds kept); 3.9 % of the codes differ at 375 µm (bound 2 % → 5 %). Cost: 768-wide fast captures 36–40 % slower (2.2–2.5× `lens.prime`, past #568's 2× target); 6000 × 4000 unchanged (25.2 s against 24.7 s). Fast results change accordingly; `fast.lens.catalogue` reports `minimumSubdivisions: 10`.
192
+ - **Why the developed edge differs from the model:** the channels' effective wavelengths, the color matrix that undoes their spectral overlap (it amplifies the blue image's offset from the green), the 50 % point being the line spread function's median under an asymmetric PSF, and the demosaic (docs/LENS_CATALOGUE.md, "Lateral color in RAW").
193
+ - **Bundle:** capture worker 351,775 → 351,997 B gzip (+222 B; limit 352,000 B, not raised). Added: the alignment, the channel dispersion and the two-step correction set-up. Saved, with bit-identical results: row FFTs in place, the phase-step scan in one loop, the window check without a running sum and mirroring folded into the crop (`src/optics/generic-lens-psf.ts`); one kernel centroid function shared by the PSF builder, the fast grid and the profile interpolation (`kernelCentroid`, now exported internally from `src/optics/lens-psf-profile.ts`); the lens corrections' corner vignetting read from the already resolved lens instead of resolving it again. Display view's added size unchanged.
194
+ - **Tests and docs:** `test/simulate-capture-lens-catalogue-lateral-color.test.ts` (new: the RAW measure for both lenses, corner and mid-field, fast and exact, which fails on 2.19.0; every node kernel's centroid on all 30 lenses at 1.5, 6 and 24 µm within 0.005 sites of the first moment; the helper's values and refusals, and the capture reporting the dispersion it corrects with); `test/simulate-capture-lens-catalogue.test.ts` measured values (its lateral check now pro < consumer on the developed edge); `test/simulate-capture-lens-corrections.test.ts` (the profile with the capture's reported dispersion equals its scales). docs/LENS_CATALOGUE.md ("Lateral color in RAW (#589)", "Channel effective dispersion", the measured and fast tables), docs/DIGITAL_OPTICS_FOUNDATION.md (the correction mapping), API reference regenerated.
195
+
196
+ ### #554 Sensor profile catalogue
197
+
198
+ - **New (#554): `sensor.profile`** for `simulateCapture` and `simulateDisplayView`: a sensor profile record (`SensorProfile`, checked strictly: every field, no unknown field, ranges, ISO markings, the dual-gain fields). Eight representative, unnamed profiles (`SENSOR_PROFILES`, `SENSOR_PROFILE_IDS`, `resolveSensorProfile(id)`) come from manufacturer-published specifications and an open paper, every value labelled published, derived or design; their sources are owner-approved (2026-11-09) and linked in docs/SENSOR_PROFILES.md. The capture never imports the catalogue (owner, 2026-11-09): the caller passes the record, so a capture worker does not bundle it. A profile replaces the body's ISO range, readout regimes (full well, read noise of each conversion gain, dual-gain switch, high-gain charge storage, ADC bit depth and black level), readout time and fixed-pattern noise, and sets the default pitch and peak QE; the #555 characteristics override it one at a time. The record's parser and body builder are a lazy chunk. Recorded in the assumption `sensor.profile`.
199
+ - **New (#554): three phone lenses in the generic lens catalogue**, a third set `"phone"`: PV 13 2.2 Phone, PV 24 1.8 Phone and PV 77 2.8 Phone (`pv-13-2.2-phone`, `pv-24-1.8-phone`, `pv-77-2.8-phone`; the owner's 13, 24 and 77 mm equivalents at fixed f/2.2, f/1.8 and f/2.8), with a phone set baseline and adjustments that are design values. They render only with a profile whose `fixedLenses` is `"phone"`, and such a profile takes only them. A fixed-aperture lens now defaults `lens.fNumber` to its one f-number (before, f/8 and a refusal). The catalogue has 33 lenses (`GENERIC_LENS_CATALOGUE`).
200
+ - **New (#554): catalogue lenses on a profile's real sensor** (owner, 2026-11-09). With `sensor.profile`, `lens.catalogue` is resolved on the profile's whole sensor (real focal length and field of view) and the capture is its largest centred region of the output's aspect (pitch = min(W/w, H/h); the other axis cropped, never stretched), or the centred crop an explicit pitch gives (a frame beyond the sensor is refused). A site of N photosites sums their charge and their read noise in quadrature (×√N; also explicit `readNoise`); `sensor.fixedPatternNoise` needs the profile's own pitch. Reported in the assumption `sensor.profileView`; the exact and fast catalogue paths and the display view agree. Without a profile nothing changes.
201
+ - **Shared:** #555's range checker (`checkFieldRanges` in `src/sensor/sensor-characteristics.ts`) now also checks the profile record (a predicate and a required mode added); #555's messages are unchanged.
202
+ - **ISO rating (proposal, owner question):** each profile's base ISO is re-rated from its full well, QE and pixel area, S0 = 100·(η/0.5)·(A/36 µm²)·(60 000 e⁻/FW), rounded to a third-stop marking (`SENSOR_PROFILE_ISO_ANCHOR`): the RAW white at base ISO is the full well and the engine's ISO anchor holds on every profile (the rounding, at most 1/6 stop, is `sensitivityOffsetStops`). The high gain's charge storage is the white charge at the switch ISO. A profile whose range starts above ISO 100 defaults to its base ISO.
203
+ - **Changed:** Auto ISO's whole-stop grid counts from the body's lowest ISO (unchanged for the tiers, whose lowest is ISO 100). The tiers' body builder takes a profile's ISO settings and provenance (tier assets byte-identical).
204
+ - **Root exports:** 6 new (4 runtime: `SENSOR_PROFILES`, `SENSOR_PROFILE_IDS`, `SENSOR_PROFILE_ISO_ANCHOR`, `resolveSensorProfile`; 2 types: `SensorProfile`, `SensorProfileValueKind`); `GenericLensSet` gains `"phone"`. Requests without `sensor.profile` and without a phone lens are byte-identical.
205
+ - **Docs and tests:** new docs/SENSOR_PROFILES.md; THIRD_PARTY.md (source review signed off), docs/LENS_CATALOGUE.md ("Phone lenses"), docs/QUICK_START.md, docs/USAGE.md, docs/GENERIC_TIER_PRESETS.md, README.md; `test/sensor-profiles.test.ts` (per profile: pitch, saturation, read noise, readout, ADC, dynamic range = full well ÷ read noise ⊕ quantization at base and switch ISO; the ISO anchor across profiles; overrides; fixed pattern; display view; no maker names; strict record checks; a modified record; the phone lenses on the phone profile only), `test/generic-lens-catalogue.test.ts` (33 lenses, fixed apertures).
206
+ - **Bundle:** see the part report for the capture-worker delta over 2.20.0 part A (limit 352,000 B). The catalogue data are not in it.
207
+
208
+ ### #581 Image-plane scene: a photo as a flat print in front of the camera
209
+
210
+ - **New: `prepareImagePlaneScenes()` and the `image-plane` scene.** Call `await prepareImagePlaneScenes()` once: it loads the scene's module and lattice data (a lazy chunk of 14,212 B gzip, and 7,857 B more for the GPU code; about +22.6 KB in all on the worker shape). A bundle that never imports it carries none of those bytes. Then the scene is (`{ kind: "image-plane", sceneId, image: { width, height, channels: 3 | 4, encoding: "srgb-8" | "srgb-16" | "linear-srgb", data }, paper?, interpolation?, maximumPixels? }`, types `ImagePlaneScene`, `ImagePlaneImage`). An sRGB (8- or 16-bit) or linear image prints as a flat matte Lambertian print on the scene plane, lit by the request's illuminant. It is taken by `simulateCapture`, `simulateIdealReference`, `simulateDisplayView` and `prepareDisplayView`, with exposure, noise, diffraction, defocus of the plane, distortion and vignetting acting on it. `createPlanarSceneRadianceProvider` refuses it. See docs/IMAGE_PLANE.md.
211
+ - **Method (owner decision 2026-11-09): the print reflectance lattice.**
212
+ - **Lattice.** 5 × 5 × 5 nodes at the sRGB code values 0, ¼, ½, ¾ and 1. The nodes are flat on the neutral axis; the other 120 are Photivra's own fits of Burns' LHTSS method (arXiv:1710.05732 v5, pp. 24–26, implemented from the equations; the CC BY-SA code is not used). They are fitted against CIE D65 and the CIE 1931 2° observer at fit time only (`scripts/fit-image-plane-lattice.mjs`) and stored at 10 nm over 380–780 nm in 16 bits.
213
+ - **Per pixel.** Each pixel is the trilinear interpolation of its cell's spectra in linear sRGB, then the paper: ρ = black + (white − black)·ρ. Defaults white 0.90 and black 0.01, labeled assumptions.
214
+ - **Round trip under D65** (the fit's check over a 17³ grid of sRGB codes): ΔE00 at most 6.4·10⁻⁴. Every spectrum stays in [0, 1].
215
+ - **Under other lights** the print is a metamer. The spread is measured on synthetic spectra and recorded in the docs, and the result is labeled an approximation (assumption `scene.imagePlane`).
216
+ - **No `fast` option.** The lattice is piecewise linear in RGB, so the tabulated paths stay exact.
217
+ - **Input and placement.** Alpha is composited over the paper. Linear samples outside [0, 1] are clipped and counted (`scene.parameters.clippedSampleCount`, assumption `scene.image.clippedSamples`). The image is limited to 24 MP by default, raised with `maximumPixels`. Reconstruction is bilinear by default (the pixels' spectra, so bilinear in linear sRGB under D65), with `nearest` as the option. `chartSize` sets the print, its height defaulting to the image's aspect; without it the print is fitted inside the frame on the 18 % surround. The scene digest covers the image's SHA-256.
218
+ - **GPU (owner decision 2026-11-09: Speed and Quality).** `prepareDisplayView` renders photos on WebGPU in both display-view paths, so a photo falls back to the CPU only for the reasons a chart does.
219
+ - **Speed.** A new image-plane module (`src/gpu/display-view-image-material-module.ts`). The Speed kernel's site lookup became `scene_value(p, c)`; material modules get a table adapter with the same value, so chart bytes are unchanged. The module's bound is ten extra roundings (γ_74 instead of γ_64 for that module).
220
+ - **Banded pixel data (owner decision 2026-11-09).** A photo whose 12-byte-per-pixel signals exceed one storage binding uploads per band only the pixel rows the band reaches, and the renderer caps a band's rows so they fit. Bounds and self-check are unchanged; a 2400 × 1600 photo under a 16 MB limit renders byte-identical to the whole upload.
221
+ - **Host memory (owner decision 2026-11-09).** The host builds the pixel signals 4 MiB of rows at a time, for the whole upload and per band, and keeps none: a 24 MP photo needs about 4 MiB for them instead of 288 MB per scene (576 MB with the two kept scenes). Every byte is unchanged (the same float64 operations, the lattice cell found once per pixel). A banded 24 MP render builds its rows each time.
222
+ - **Signals are integrated from the reconstructed spectrum per site (design choice 2026-11-09).** Every path that forms a sensor signal from a photo (capture, ideal reference, display view on the CPU, GPU Speed and Quality) integrates the pixel's reconstructed spectrum (its cell's node spectra blended at each spectral node, then the paper, times the illuminant) against the reading site's channel response when it is read; no per-channel values of the lattice nodes are formed. `ResolvedImagePlaneScene.channelTables` is replaced by `pixelSignals` (internal). Output bytes of the measured requests are unchanged; bounds are unchanged and every GPU case stays inside them.
223
+ - **Cost** (Node, one thread, 6000 × 4000 photo, 24 MP 6 µm frame, 20 spectral nodes): capture 24 MP 13.6 → 12.6 s, 48-pixel quick frame 0.08 s both; display view of a 384 × 384 region 0.95 → 0.92 s, a 48-pixel view of the 24 MP frame 1.16 → 1.39 s. GPU Speed's host side on the emulation: the whole upload's one-time build 0.75 → 1.8 s, a banded 24 MP render 2.0 → 2.8 s.
224
+ - **Recovered host time (owner decision 2026-11-09).** Each pixel's three channel sums are formed in one pass over its spectrum (the same sums in the same order), and a band's rows shared with the previous band are copied on the device from that band's buffer, which is kept until the next band; only the other rows are built, so a banded render builds each photo row it reaches once (it built 1.6× before). The bytes are unchanged, the host still holds at most one 4 MiB chunk of signals, and the device holds at most two band buffers at once. Before this, the one-time build took 2.2 s and a banded 24 MP render 4.5 s.
225
+ - **Quality.** Diffraction, `lens.defocus`, the anti-aliasing filter's spots and AHD, through the print's own passes 1 and 2 (`src/gpu/display-view-image-quality.ts`), behind a hook of `renderDisplayViewQualityOnGpu`.
226
+ - Pass 1: the radiance per spectral node at each lattice point, from per-band pixel lattice records.
227
+ - Pass 2: the blocked sum against the reference's per-node combined kernels, bounded at (32 + N + cw + ch + G + 4)·E relative.
228
+ - Flagged lattice points are resolved on the host with the reference's radiance when there are site kernels; without kernels their sites' pixels are recomputed.
229
+ - The pixel pass and finishing are unchanged.
230
+ - **Self-check.** The module checks itself (Speed and Quality) on a device's first photo; the renderer's own cases are unchanged.
231
+ - **Measured** against the CPU reference on the emulation and SwiftShader, in 11 Speed and Quality cases plus two print-edge cases: ΔE2000 at most 7.3·10⁻⁴ (Quality) and 4.2·10⁻⁵ (Speed), every component inside its bound, samples within one code. The SwiftShader display-view suite passes all 69 tests.
232
+ - **Size.** The app's capture-worker shape measures 351,950 B gzip (2.19.0: 351,775 B; limit 352,000 B, not raised).
233
+ - The scene's module, its lattice data and its GPU code load only through `prepareImagePlaneScenes()`, which that shape does not import. An app that calls it adds about 22.6 KB.
234
+ - The GPU display-view chunk is 54,591 B, 116 B below 2.19.0, because its photo routing and banding are paid for by sharing code: the self-checks' fixtures and verdict, the support checks, the display-pixel recomputation, the lattice formula and the materials' channel signals. The entry is +283 B (the facade's dispatch).
235
+ - **Root exports:** 3 new (`prepareImagePlaneScenes`, types `ImagePlaneScene`, `ImagePlaneImage`).
236
+ - **Tests:** `test/image-plane.test.ts`, `test/display-view-image-gpu.test.ts` (float32 emulation), image-plane cases in `test/webgpu/display-view.webgpu.ts` (SwiftShader).
237
+
238
+ ### #534 Exposure build-up
239
+
240
+ - **New (#534): `simulateExposureBuildUp(options, { fractions })`** renders fractions of one exposure as one accumulating process, like a camera's live readout of a long exposure.
241
+ - **Signal:** each fraction fⱼ < 1 is a capture of the request in manual mode at the whole exposure's resolved f-number and ISO and the shutter fⱼ·t. Its expected signal is integrated from the spectrum over [0, fⱼ·t] by the capture itself, so camera rotation, rolling shutter, `motion.subject` (#551) and mixed lighting (#550, which does not change over time) are supported (owner decision 2026-11-09). No per-channel signal is precomputed.
242
+ - **Counts:** going down the fractions, each site's photo and dark counts are split as N(fⱼ) ~ Binomial(N(fⱼ₊₁), λ(fⱼ)/λ(fⱼ₊₁)), with λ that render's expected mean at the site. This is the conditional law of a Poisson process with any rate over time. Each fraction's counts are therefore Poisson with its own mean (a capture's mean and variance at fⱼ·t), never decrease, and covary as the increment model says. Where the temporal quadrature puts an earlier mean above a later one, the count is kept and counted (`nonMonotoneSites` in the assumption `exposure.buildUp`).
243
+ - **Sensor:** fixed-pattern noise is the same in every fraction. Each fraction is clipped at the full well and read out once with its own read-noise stream, dual gain, pre-ADC/ADC saturation and ADC bits (#555; #554 profiles feed the same stage).
244
+ - **Byte-identity:** the fraction at 1 is `simulateCapture(options)` byte for byte, including its plan and assumptions (owner decision 2026-11-06).
245
+ - **Reports:** a fraction's `exposure` and `sensor` reports are the whole exposure's (owner decision 2026-11-09).
246
+ - **Limits:** on screen only, `"srgb-8"` and `"srgb-16"`; other formats are refused (owner decision 2026-11-09).
247
+ - **Fast:** the request's `fast` applies to every fraction. The build-up adds no fast method, so there is nothing to register.
248
+ - **Internal:** an exact seeded binomial sampler (`src/sensor/binomial-sample.ts`: inversion below a mean of 10, Hörmann's BTRS above; not a root export). The sensor stage takes an optional build-up stage (`src/capture/exposure-build-up-stage.ts`) in `calculateNativeRawSite`, the native RAW loop, the lean templates and the tabulated path. The stage is found by the native RAW input the facade builds (a callback of the internal `runCapture` sees it) and gets each site's expected means and ordinary draws. Without a stage, every path is unchanged.
249
+ - **Merge of #551 and #550 (here; owner decision 2026-11-09):** `motion.subject` works with `illumination`. #551's revealed 18 % background takes the plane's irradiance at each revealed point: the blend and #550's point sources, from the spectrum at use, in the capture and in the display view. The display view still refuses a rolling shutter and a subject rotation, as for any moving subject. Tests: `test/subject-motion-mixed-lighting.test.ts` (gradient blend and point source against a still 18 % scene under the same lighting, the display view, the build-up with both). +13 B gzip in the capture worker.
250
+ - **Capture-worker refactor (results bit-identical):** the lean sensor templates use the flat sensor stage (#449) instead of a second copy of the same stage (`leanSensorSite` removed), and one `reproducesSite` check serves both verifications.
251
+ - **Root exports:** 6 new (3 runtime): `simulateExposureBuildUp`, `EXPOSURE_BUILD_UP_MAXIMUM_FRACTIONS`, `EXPOSURE_BUILD_UP_STREAM_BASE` and the types `SimulateExposureBuildUpOptions`, `SimulateExposureBuildUpFrame`, `SimulateExposureBuildUpResult`.
252
+ - **Bundle:** #534 alone on 2.20.0 shrinks the capture-worker shape by 134 B gzip (355,994 → 355,860 B). This branch, with #551 and #550 (point sources included) merged, measures 359,781 B.
253
+ - **Docs and tests:** docs/QUICK_START.md ("Exposure build-up (#534)"), docs/USAGE.md, API reference; `test/exposure-build-up.test.ts`, `test/binomial-sample.test.ts`.
254
+
255
+ ### #551 Subject motion
256
+
257
+ - **New:** `motion.subject` (type `SimulateCaptureSubjectMotion`): the whole scene, or one declared rectangle of it over a still background, moves on the scene plane during the exposure: `velocityMPerSec` `{ x?, y? }` in m/s (+x right, +y up) and an optional `angularVelocityRadPerSec` about the optical axis (counter-clockwise as seen from the camera; about the region's center for a region). Each scene query reads the subject at its own time, through the same temporal midpoints as camera rotation (`sensor.temporalSampleCount`, default 8 with a moving subject), so a rolling shutter skews a moving subject by its readout skew, and a camera yaw that follows the subject (panning) keeps it sharp and streaks the background. A region's rest rectangle shows a neutral 18 % background once the subject leaves it. It combines with `lens.diffraction`, `lens.defocus` (global shutter) and the lens options; `lens.pupilSampling` is refused. Off by default: requests without it are byte-identical.
258
+ - **Display view:** renders sideways motion (global shutter) through the lens signal's temporal midpoints; refuses `motion.subject.angularVelocityRadPerSec` and a rolling shutter with a reason (`simulateCapture` renders both).
259
+ - **Fast method** (registered, `motion.subject/temporal-midpoints`; `diagnostics.fast.applied["motion.subject"]`, assumption `fast.motion.subject`): with the default count and a still camera, fast takes one temporal midpoint per site of the subject's streak plus one when that is fewer than 8; exact keeps 8; an explicit `sensor.temporalSampleCount` always wins. `"motion.subject"` joins `SIMULATE_CAPTURE_FAST_OPTIONS` and `SIMULATE_CAPTURE_COST_OPTIONS`.
260
+ - **Root exports:** 1 new type (`SimulateCaptureSubjectMotion`).
261
+
262
+ ### #550 Spatially varying illumination (mixed lighting)
263
+
264
+ - **New:** `simulateCapture` (and `simulateIdealReference`, `simulateDisplayView`, `prepareDisplayView`) option `illumination`: two to eight sources, each an illuminant at an illuminance, blended across the chart by a `linear-gradient`, a `radial` falloff or `regions` (docs/MIXED_ILLUMINATION.md). The irradiance at a point is Σ w_i·E_i(λ) with weights summing to 1 (exact for the declared blend; not a falloff model). It replaces `illuminant` and `illuminanceLux`. Root type exports: `MixedIllumination`, `MixedIlluminationBlend`, `MixedIlluminationPoint`, `MixedIlluminationPointSource`, `MixedIlluminationSource` (5 types, no runtime export).
265
+ - **Point sources (owner decision 2026-11-10, a step toward flash):** `illumination.points`, up to eight, each an illuminant, a position in metres in front of the plane and an intensity (`{ candela }` or `{ illuminanceLux, distanceM }`). Irradiance E = I·cos θ/d² (inverse square with Lambert's cosine law, from the CIE ILV definitions of luminous intensity and illuminance), added to the blend; exact for an ideal isotropic point, no occlusion. One source with points needs no blend; a point alone is allowed. Root type export `MixedIlluminationPointSource`.
266
+ - **Exposure and white balance:** scene-average metering reads the lit frame; the grey card sits at the frame center and reads the light there (`exposure.meteringCard`); `"scene-illuminant"` white balance uses the source strongest at the frame center (`output.whiteBalanceSource`); an explicit white balance wins. The assumption `scene.illumination` reports the blend.
267
+ - **Display view:** the CPU reference reads the radiance under the blend at every query point, as the capture does (no approximation, so no fast method). The GPU renderers refuse it with a reason (`unsupported-on-backend`, no device requested).
268
+ - **Capture:** a chart or material scene under a blend keeps the tabulated expected signal (#394), reading the radiance at each lattice point instead of per-material tables, verified per CFA phase against the chain (1e-9); caller reflectance functions take the chain.
269
+ - **Off by default:** requests without `illumination` keep their bytes. The module (`src/facade/mixed-illumination.ts`) was a lazy chunk on its branch; the integration made it a static import (size pass).
270
+ - **Size (rolldown 1.2.9, gzip level 9):** the app's capture-worker shape 355,994 → 358,671 B in total (+2,677 B: the lazily loaded `mixed-illumination` chunk 2,222 B with the point sources, the facade glue and the chunk split the rest), above its 356,000 B limit; per the owner, the integrator raises the limit by the exact remainder at integration. The capture-only entry chunk 263,817 → 264,706 B (+889 B); the display view's added entry size 18,187 → 16,197 B (the bundler moved shared modules between chunks).
271
+
272
+ ### #580 Lateral color as a fringe width in pixels for a lens and sensor
273
+
274
+ - **New: `calculateLateralColorFringe({ lensId, activeImagingArea, pixelPitchMicrometers, fieldPosition? })`** (model `generic-lens-lateral-color-fringe` 1.0.0, for the app's Guide page on color fringing): a catalogue lens's blue-minus-red image displacement at a field position (`{ x, y }` in fractions of the half-width and half-height; default the top-right corner), radial and as x and y parts, in µm and in pixels, plus the fringe width |Δr| and the lens's F-to-C displacement. It reads the lens's own lateral-color model Δr(λ) = L·R·H·D(λ) at the lens corrections' channel wavelengths (610 and 465 nm), an approximation. Full frame, corner: PV 24 1.8 7.24 µm (1.21 px at 6 µm), PV 24 1.4 Pro 2.11 µm (0.35 px).
275
+ - **Tests** (`test/generic-lens-lateral-color.test.ts`): the formula written out from the Fraunhofer lines, linearity in the field height, direction in every quadrant, sensor scaling, every catalogue lens, refusals, and the chart measurements of the catalogue tests: at the corner region's centre it predicts 0.28 px (pro, measured 0.31 px) and 0.96 px (consumer, measured 0.71 px; the rendered edge also carries the consumer lens's larger blur, the demosaic and the color conversion).
276
+ - **Integration (2.21.0, owner decision):** an optional `channelDispersion` (#589's `calculateChannelEffectiveDispersion`) takes the camera's own channels; the result reports `channelDispersion` (and `channelWavelengthsNm: null` when it is given). Re-measured against #589's RAW measure at the corner region's centre (6 µm, default sensor and light): 0.232 px for *PV 24 1.4 Pro* (RAW 0.22 px) and 0.797 px for *PV 24 1.8* (RAW 0.80 px); the single-wavelength default gives 0.278 and 0.957 px.
277
+ - **Root exports:** 4 new (1 runtime): `calculateLateralColorFringe` and the types `CalculateLateralColorFringeInput`, `LateralColorFringe`, `LateralColorDisplacement`. A pure function in its own module (`src/optics/generic-lens-lateral-color.ts`); the capture worker does not import it.
278
+ - **Docs:** docs/LENS_CATALOGUE.md ("Lateral color as a fringe width"), docs/USAGE.md, API reference regenerated.
279
+
280
+ ### #557 Representative light-source spectra designed by Photivra
281
+
282
+ - **New:** four representative light sources designed by Photivra (owner decision 2026-11-06: Photivra's own parametric spectra, no CIE data, no named products), each labeled "representative, not a named product": `tri-phosphor-fluorescent-4000k`, `white-led-low-rendering-4000k`, `white-led-high-rendering-4000k` and `warm-white-led-2700k`. Each is S(λ) = b + Σ aₖ·exp(−½((λ − μₖ)/σₖ)²) on 360–830 nm; formulas and design reasons in docs/LIGHT_SOURCES.md. `createRepresentativeLightSourceIlluminant({ sourceId })` returns `{ kind: "spectrum", spectrum, label }` for any `illuminant` option; `createRepresentativeLightSourceSpectrum({ sourceId })` the spectrum alone; `getRepresentativeLightSourceSet()` the frozen set.
283
+ - **Checked:** each spectrum's CCT with the engine's own locus search is within 1 K of its stated CCT (4000.8, 3999.6, 4000.0, 2699.9 K) with |Duv| ≤ 0.0001, and McCamy within 10 K. The same-CCT pair (low vs high color rendering, 4000 K) differs by ΔE00 5.05 on PV-S1, 4.67 on PV-P2 and 5.86 on PV-G1 (mean 3.68 over the 24 colored patches); against a Planckian reference at the same CCT the mean patch ΔE00 is 2.87, 4.00, 0.67 and 1.18. This is Photivra's own figure from its own patches, not CIE Ra or TM-30 (their data is not bundled).
284
+ - **Unchanged:** `illuminants.ts`, `scene-radiance.ts` and `simulateCapture` are untouched; the existing illuminants resolve byte-identically (hash locks of CIE A and Planckian 2700/6500 K, portable path). Use `sensor.spectralStepNanometers: 5` with these sources: at the default 20 nm the fluorescent's narrow bands leave a strong cast (documented and measured).
285
+ - **Root exports:** 11 new (7 runtime): `REPRESENTATIVE_LIGHT_SOURCE_SET_ID`, `REPRESENTATIVE_LIGHT_SOURCE_SET_VERSION`, `REPRESENTATIVE_LIGHT_SOURCE_IDS`, `REPRESENTATIVE_LIGHT_SOURCE_WAVELENGTH_RANGE_NANOMETERS`, `getRepresentativeLightSourceSet`, `createRepresentativeLightSourceSpectrum`, `createRepresentativeLightSourceIlluminant` and the types `RepresentativeLightSource`, `RepresentativeLightSourceBand`, `RepresentativeLightSourceId`, `RepresentativeLightSourceSet`.
286
+ - **Bundle:** the capture-worker shape is unchanged at 351,770 B gzip (limit 352,000 B); the sources reach only bundles that import them (+1,553 B gzip).
287
+ - **Docs and tests:** docs/LIGHT_SOURCES.md (new), docs/USAGE.md, docs/AGENT_GUIDE.md, llms.txt, THIRD_PARTY.md, API reference; `test/representative-light-sources.test.ts`.
288
+
289
+ ### #365 Print planner: fit an image inside paper of another aspect ratio (contain), with the borders reported
290
+
291
+ - **New: `fit: { kind: "contain", paperOrientation: "as-given" | "match-image" }`** for `calculatePrintPlan` and `calculatePrintSizeLimit`: `printedImage` is then the paper; the image keeps the native aspect ratio, is scaled to the largest size inside the paper (scale `min(P_w/W, P_h/H)`, the "contain" sizing of CSS Images Level 3 §5.5) and centered, and is planned exactly as a `confirmed-native-aspect` plan of that size. `match-image` turns the paper a quarter turn to the image's orientation. The plan adds `contain: { paperMm, paperRotated, bordersMm: { left, right, top, bottom } }` (null without a native raster) and the size limit adds `maximumPaperMm`. Exact geometry (binary64 rounding reported in `relativeAspectError`); layout only, no crop or resampling. 6000 × 4000 on 8 × 10 in gives a 10 × 6.667 in image with 0.667 in borders on the long edges; 3:2 paper gives zero borders.
292
+ - **Unchanged:** `confirmed-native-aspect` plans and size limits are byte-identical to 2.19.0 (no new keys; checked over 720 inputs across every sampling kind, provider constraint, unit, orientation and unavailable source).
293
+ - **Type change:** `PrintPlanInput.fit` is now the union `PrintFit`; code that reads `fit.maximumRelativeAspectError` must first narrow on `fit.kind === "confirmed-native-aspect"`.
294
+ - **Root exports:** 2 new types, `PrintFit` and `PrintContainLayout`. Pure module (`src/output/print-plan.ts`); not in the capture worker.
295
+ - **Docs and tests:** docs/PRINT_PLANNING.md ("Contain fit"), docs/USAGE.md, API reference regenerated; `test/print-plan-contain.test.ts` (the issue's 8 × 10 in and 3:2 cases, as-given paper, portrait and square images, equality with the direct plan, size limit, unknown raster, input errors).
296
+
297
+ ## 2.20.0 — published November 10, 2026
4
298
 
5
299
  A minor release of ten issues and one speed-versus-accuracy scheme: adjustable sensor characteristics and the mid-gray noise figure (#555, #535), the aperture diameter (#577), the crop that matches a longer lens (#579), which limits detail, the lens or the sensor (#575), the print size limit with blur and noise (#574), the lens profile's vignetting area factor (#584), lens corrections as optional DNG opcodes (#587), each catalogue lens's own bokeh (#567), chart-boundary ties in the diffraction PSF quadrature (#565) and the CI Node drift check (#231). See the [2.20.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_20_0.md).
6
300
 
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.20.0"
12
+ version: "2.22.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.20.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_20_0.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.22.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_22_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.20.0
38
+ npm install @photivra/engine@2.22.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.20.0` 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.22.0` tag.
79
79
 
80
80
  ## Documentation
81
81
 
@@ -363,13 +363,14 @@ The application can then use those results, while the photography calculations t
363
363
 
364
364
  ## Status
365
365
 
366
- - Repository package version: `2.20.0` (release candidate prepared November 9, 2026; publication is separate)
367
- - Engine API contract: `2.20.0`
366
+ - Repository package version: `2.22.0` (release candidate prepared November 11, 2026; publication is separate)
367
+ - Engine API contract: `2.22.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
+ - Sensor profiles from published specifications (#554, unnamed): [SENSOR_PROFILES.md](https://github.com/Photivra/photivra/blob/main/docs/SENSOR_PROFILES.md).
369
370
  - Composed POC simulation API contract: `1.0.0`
370
371
  - Stability: 1.0 root-package compatibility policy; bounded scientific models and independent POC contract
371
372
 
372
- From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.20.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
373
+ From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.22.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
373
374
 
374
375
  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
376
 
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.19.0**, published on November 9, 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.21.0**, published on November 10, 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
@@ -14,11 +14,20 @@ This file records direct development and CI dependencies intentionally used by t
14
14
  | Vitest | 5.0.1 | Unit/integration testing | MIT |
15
15
  | @vitest/coverage-v8 | 5.0.1 | V8-backed test coverage | MIT |
16
16
  | rolldown | 1.2.9 | Bundling test of capture-only consumers (`test/capture-bundle.test.ts`) | MIT |
17
+ | jxl-oxide-wasm | 0.12.6 | JPEG XL tile decoding in the phone DNG import tests (`test/dng-import-phone.test.ts`); see [JPEG XL](#jpeg-xl-2220) | MIT OR Apache-2.0 |
17
18
  | actions/checkout | v7 / `3d3c42e5aac5ba805825da76410c181273ba90b1` | CI checkout | MIT |
18
19
  | actions/setup-node | v7 / `820762786026740c76f36085b0efc47a31fe5020` | CI Node setup | MIT |
19
20
  | actions/upload-artifact | v7.0.1 / `043fb46d1a93c77aae656e7c1c64a875d1fc6a0a` | Verified release artifact transfer | MIT |
20
21
  | actions/download-artifact | v8.0.1 / `3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c` | Verified release artifact retrieval | MIT |
21
22
 
23
+ ## JPEG XL (2.22.0)
24
+
25
+ DNG 1.7 phone files may store their raw data as JPEG XL (Compression 52546; #598). The owner chose the jxl-oxide decoder (owner, 2026-11-10), as its prebuilt WebAssembly package from npm, `jxl-oxide-wasm` 0.12.6 (<https://github.com/tirr-c/jxl-oxide>), dual-licensed MIT OR Apache-2.0. **Chosen licence: Apache-2.0** (owner-accepted 2026-11-11, with the licence check's rule that an SPDX `OR` expression passes when one choice is allowed). It is a **devDependency only**: the published package declares no runtime dependency and does not import it. `createJxlOxideDngDecoder` takes the module the application loaded (structurally typed), so an application that opens JPEG XL DNGs installs and lazily loads jxl-oxide-wasm itself, and its licence then applies to that application. No jxl-oxide source is vendored or copied. `scripts/check-license-policy.mjs` accepts an SPDX `OR` expression when one of its choices is on the allowlist (here Apache-2.0 and MIT both are).
26
+
27
+ Patents (rule 2; not legal advice): JPEG XL is ISO/IEC 18181. At the 2026-11-10 research (#598), royalty-free patent declarations on JPEG XL had been filed by Google and Cloudinary only; Google's patent grant text covers its own implementations (libjxl, jxl-rs), not jxl-oxide. The owner accepted jxl-oxide on that basis (2026-11-10). The engine itself contains no JPEG XL coding: its tests carry a small test-only lossless encoder (`test/helpers/phone-dng-fixtures.ts`, written from ISO/IEC 18181-1:2022, modular mode with the zero predictor and prefix codes) to build fixtures, never shipped.
28
+
29
+ The 2.22.0 DNG opcode, ProfileGainTableMap and RGBTables readers (`src/capture/dng-opcodes.ts`, `dng-profile-maps.ts`) were written from the DNG specification's tag descriptions; because the 1.7.1.0 PDF could not be downloaded (HTTP 403), their byte layouts and evaluation order were only checked by reading the Adobe DNG SDK 1.7.1 source. No code was copied from the DNG SDK (layouts and behaviour checked only; the engine's code is written independently); the PDF check is pending with the owner; the Adobe DNG notice in `NOTICE` covers the DNG technology.
30
+
22
31
  Node built-in modules are used only by development tooling and the repository-local POC API; that API is excluded from the published package and the built-ins are not third-party dependencies.
23
32
 
24
33
  `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.
@@ -27,6 +36,8 @@ Node built-in modules are used only by development tooling and the repository-lo
27
36
 
28
37
  `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.
29
38
 
39
+ `src/capture/c2pa-embed.ts` (#165) is written from the C2PA Technical Specification 2.4 (free, published by the C2PA) and ISO 19566-5 box framing as that specification describes it. To produce the same JPEG and PNG bytes as the C2PA reference SDK, it uses interoperability facts read from c2pa-rs `sdk/src/asset_handlers/jpeg_io.rs` and `png_io.rs` (MIT OR Apache-2.0): the 64,000-byte split of the store across APP11 segments, the box instance number `En` = 0x0211 and the segments' place right after the last APP0 (or SOI); and the PNG `caBX` chunk's place right after IHDR with the whole chunk excluded. No c2pa-rs source is vendored or copied, and nothing is a runtime dependency. Checking a signed file with the C2PA reference validator is pending (docs/RELEASE_2_22_0.md, "Pending"); the engine signs nothing and makes no network request.
40
+
30
41
  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.
31
42
 
32
43
  ## Pending licence and source reviews
@@ -44,6 +55,11 @@ These human reviews were **pending** until the owner signed all five off on 2026
44
55
  | DNG/TIFF and JPEG sample provenance | All 26 tracked `.dng`/`.jpg` files name only "Photivra", "Photivra Virtual Camera" or "Photivra Print JPEG" (`strings` scan); no camera maker's name appears. The Adobe DNG notice is in `NOTICE`, `README.md` and the three DNG sources. **Not done:** byte-for-byte regeneration of the files. | **Signed off by the owner, 2026-11-09** (#544). |
45
56
  | Transitive dependency/SBOM review | **Evidence complete for the 2.14.0 lockfile.** Recount of `package-lock.json`: 168 entries, with the licence counts given in [its summary](#per-release-transitive-dependency--sbom-review); no entry is a runtime dependency. `npm run check:licenses` passes. GitHub reports MIT for all four GitHub Actions in the table above (their repositories' detected licence). | **Signed off by the owner for 2.14.0–2.19.0, 2026-11-09** (#544). From 2.20.0 it is reviewed per release: each release's pull request gives the `npm run check:licenses` result and any new or changed licence against the previous release's lockfile. |
46
57
  | Colorimetry published constants (`src/color/`). See [Colorimetry published constants and excluded datasets](#colorimetry-published-constants-and-excluded-datasets). | No CIE table is committed: the largest set of decimal constants in `src/color/` is 37, in `color-matching-functions.ts` (the engine's own fit). Each constant's source is cited in that section. On 2026-11-09 every constant was re-read against its free source (paywalled primaries through free secondaries, and numerically against the free CIE datasets), with no mismatch. | **Signed off by the owner, 2026-11-09** (#544). |
58
+ | Sensor profile sources (#554, `src/equipment/sensor-profiles.ts`). See [Sensor profile sources](#sensor-profile-sources-554). | **Signed off by the owner on 2026-11-09** (sources approved as listed; astronomy-camera manuals accepted for sensor values only; the retailer copy for one profile accepted, labelled; links allowed in `docs/SENSOR_PROFILES.md` only). Ten free sources and three standards (ISO 12232 labelled paywalled); every value is labelled published, derived or design. | Nothing for #554. |
59
+
60
+ ## Sensor profile sources (#554)
61
+
62
+ `src/equipment/sensor-profiles.ts` holds eight sensor profiles whose values are facts taken from manufacturer-published specifications (sensor dimensions, pixel counts, pixel pitch, full well, read noise, peak QE, ADC bit depth, frame rate) and one openly published paper (International Image Sensor Workshop 2017, paper R03, Table 1), or Photivra design values labelled as such. They are factual references (`reuseStatus: "factual-reference-only"`); no table, curve, image or text is copied, and no maker or product is named in the code, data or docs. Commercial or terms-restricted measurement databases and test websites were excluded and not used (owner decision, 2026-11-06). ISO 12232:2019 is named for a cross-check only; no value depends on it. Sources, with links, and derivations: `docs/SENSOR_PROFILES.md`. The owner signed off the source review on 2026-11-09 (table above).
47
63
 
48
64
  ## Licence summaries (prepared for owner sign-off)
49
65
 
@@ -99,6 +115,8 @@ The colorimetry modules in `src/color/` (see [docs/COLORIMETRY.md](https://githu
99
115
  | --- | --- | --- |
100
116
  | 28 coefficients of the engine's own multi-lobe CIE 1931 CMF fit (`color-matching-functions.ts`) | Fitted by Photivra with `scripts/fit-cie-1931-cmf.mjs` to the CIE 1931 2° table, DOI 10.25039/CIE.DS.xvudnb9b (CIE, CC BY-SA 4.0). The model form, a sum of piecewise Gaussians, follows the idea in Wyman, Sloan & Shirley (2013), JCGT 2(2); none of that paper's coefficients or text are used. | Fit-time use only: the script downloads the table into a temporary directory outside the repository, checks its SHA-256 and deletes it. No CIE value is stored in the repository, the package, the tests or the script's output. The committed coefficients are our own least-squares result; 28 numbers cannot reproduce the 1,413-value table (maximum error 0.022). Attribution to the CIE dataset is given here and in `docs/COLORIMETRY.md`. Replaced the Wyman et al. coefficients by owner decision (2026-10-17). |
101
117
  | Reference CIELAB and sRGB values of the engine's own color patches (`color-patches.ts`, `docs/COLOR_PATCHES.md`) | Computed by Photivra with `scripts/compute-color-patch-reference.mjs` from Photivra's own analytic spectra, using the CIE standard illuminant D65 table (DOI 10.25039/CIE.DS.hjfjmt59) and the CIE 1931 2° table (DOI 10.25039/CIE.DS.xvudnb9b), both CIE, CC BY-SA 4.0. The spectra themselves are Photivra's design; no ColorChecker, IT8 or other commercial target data, names, order or layout are used. | Computation-time use only, as for the CMF fit: the script downloads both tables into a temporary directory, checks their SHA-256 and deletes them. No CIE value is stored in the repository, the package, the tests or the script's output; the committed values (three CIELAB coordinates and one 8-bit sRGB colour per patch) are results computed from our spectra. Attribution to the CIE datasets is given here and in `docs/COLOR_PATCHES.md`. |
118
+ | Node spectra of the image-plane scene's print reflectance lattice (`image-plane-lattice-data.ts`, `docs/IMAGE_PLANE.md`, #581) | Fitted by Photivra with `scripts/fit-image-plane-lattice.mjs`: the LHTSS method of S. A. Burns, "Generating Reflectance Curves from sRGB Triplets", arXiv:1710.05732 v5 (2020), pp. 24–26 (the paper is CC BY-SA 4.0; its method is implemented from the equations, and neither its text nor its MATLAB code, also CC BY-SA 4.0, is used), against the CIE standard illuminant D65 table (DOI 10.25039/CIE.DS.hjfjmt59, https://files.cie.co.at/Publications-datasets/CIE_std_illum_D65.csv) and the CIE 1931 2° table (DOI 10.25039/CIE.DS.xvudnb9b, https://files.cie.co.at/Publications-datasets/CIE_xyz_1931_2deg.csv), both CIE, CC BY-SA 4.0. Owner decision 2026-11-09 (#581). | Fit-time use only, as for the CMF fit: the script downloads both tables into a temporary directory outside the repository, checks their SHA-256 and deletes them. No CIE value is stored in the repository, the package, the tests or the script's output. The committed data (120 spectra × 41 samples of 16 bits, delta-coded) are Photivra's own optimization results: each is the smoothest bounded spectrum that meets three integral constraints, and they cannot reproduce the tables. Attribution to the CIE datasets is given here and in `docs/IMAGE_PLANE.md`. |
119
+ | Representative light-source spectra (`representative-light-sources.ts`, `docs/LIGHT_SOURCES.md`, #557) | Photivra's own design (owner decision 2026-11-06): a constant plus Gaussian bands whose positions and widths Photivra chose for each kind of lamp, with two parameters per source solved by Photivra to put it on the Planckian locus at its stated CCT using the engine's own analytic CMF fit and Planck's law. No third-party spectral data is used: no CIE illuminant tables (the F-series fluorescent, LED-series or any other), no IES TM-30 or CIE test-sample reflectances, and no manufacturer or product spectra, names or data sheets. Their CCT and color-rendering figures are computed with the engine's own code and color patches only. | Photivra's own work under the project licence; nothing to attribute. Labeled "representative, not a named product". |
102
120
  | Distinctness check of the color patches' published colors and names (`scripts/check-color-patch-distinctness.mjs`, `docs/COLOR_PATCHES.md`) | Reference colors compared against, each downloaded from a pinned URL and checked by SHA-256: the CSS named colors (W3C, CSS Color Module Level 4, CRD 2025-04-24; W3C Software and Document License, as linked from that document); the ColorChecker Classic's manufacturer sRGB values as tabulated in Wikipedia, "ColorChecker", revision 1367818471 (CC BY-SA 4.0); the ColorChecker's published X-Rite (before and after November 2014), GretagMacbeth 2005 and BabelColor average values as collected in colour-science 0.4.6 (BSD-3-Clause); RAL Classic approximate sRGB values as tabulated in Wikipedia, "List of RAL colours", revision 1369650571 (CC BY-SA 4.0); brand colors from Simple Icons 1.20.0, 2.19.0, 4.25.0, 9.21.0 and 16.36.0 (its data is dedicated to the public domain). Pantone data is proprietary and is not used. | Check-time use only: the script parses each source in memory, compares it with Photivra's own values and discards it. No third-party color value, name or table is stored in the repository, the package, the tests or the script's output, apart from what the script prints and `docs/COLOR_PATCHES.md` reports per patch as a factual result: the closest reference color's name, its set, its 8-bit sRGB hex (the "Its sRGB" column, one value per patch, 30 in all) and the ΔE00. The "Nudged patches" table there also names the reference colors the first design was too close to. `docs/COLOR_PATCHES.md` is repository documentation and is not in the npm package. The 30 hex values are attributed under its Result table: 7 ColorChecker and 6 RAL Classic values copied unchanged from the Wikipedia tables (CC BY-SA 4.0, credited with the title, authors, revision link and licence link that CC BY-SA 4.0 §3(a) asks for), 1 ColorChecker value converted to sRGB by the script from colour-science 0.4.6's CIELAB data (BSD-3-Clause, by the Colour Developers), 2 CSS named colors (W3C) and 14 Simple Icons brand colors (CC0 1.0). The patch names and colors are Photivra's own; the check only confirms that they differ. |
103
121
  | Daylight-locus polynomial and M₁/M₂ coefficients; illuminant A formula constants; CIELAB, CIE94 and CIEDE2000 formula constants (`illuminants.ts`, `color-difference.ts`) | CIE 15:2018 (daylight and illuminant A constants; values checked against the free CIE datasets); CIELAB as in W3C CSS Color 4; CIE94 as stated in Wikipedia, "Color difference" (secondary source; the primary CIE 116-1995 is not free); Sharma, Wu & Dalal (2005), also CIE 142-2001 | Coefficients of standardized equations, used as factual constants. |
104
122
  | McCamy CCT cubic | McCamy (1992), Color Res. Appl. 17(2) | Published equation coefficients. |