@photivra/engine 2.18.0 → 2.20.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 (335) hide show
  1. package/CHANGELOG.md +179 -1
  2. package/CITATION.cff +1 -1
  3. package/NODE_SUPPORT.md +7 -5
  4. package/README.md +6 -6
  5. package/SECURITY.md +1 -1
  6. package/THIRD_PARTY.md +14 -10
  7. package/dist/capture/dng-lens-correction-opcodes.d.ts +32 -0
  8. package/dist/capture/dng-lens-correction-opcodes.js +119 -0
  9. package/dist/capture/dng-lens-correction-opcodes.js.map +1 -0
  10. package/dist/capture/focus-control.js +2 -75
  11. package/dist/capture/focus-control.js.map +1 -1
  12. package/dist/capture/native-environment-lean.d.ts +1 -0
  13. package/dist/capture/native-environment-lean.js +2 -6
  14. package/dist/capture/native-environment-lean.js.map +1 -1
  15. package/dist/capture/native-environment-optical-stage.d.ts +1 -0
  16. package/dist/capture/native-environment-optical-stage.js +2 -5
  17. package/dist/capture/native-environment-optical-stage.js.map +1 -1
  18. package/dist/capture/native-environment-raw.js +1 -5
  19. package/dist/capture/native-environment-raw.js.map +1 -1
  20. package/dist/capture/native-raw-dng.d.ts +6 -0
  21. package/dist/capture/native-raw-dng.js +16 -2
  22. package/dist/capture/native-raw-dng.js.map +1 -1
  23. package/dist/capture/release-sequence.js +2 -47
  24. package/dist/capture/release-sequence.js.map +1 -1
  25. package/dist/capture/sensor-raw-producer.js +3 -5
  26. package/dist/capture/sensor-raw-producer.js.map +1 -1
  27. package/dist/color/white-balance.d.ts +2 -0
  28. package/dist/color/white-balance.js +3 -56
  29. package/dist/color/white-balance.js.map +1 -1
  30. package/dist/composition/image-formation-plan.d.ts +1 -0
  31. package/dist/composition/image-formation-plan.js +3 -49
  32. package/dist/composition/image-formation-plan.js.map +1 -1
  33. package/dist/core/evidence-provenance.js +1 -16
  34. package/dist/core/evidence-provenance.js.map +1 -1
  35. package/dist/core/record-validation.d.ts +54 -0
  36. package/dist/core/record-validation.js +176 -0
  37. package/dist/core/record-validation.js.map +1 -1
  38. package/dist/core/scientific-assurance.js +1 -18
  39. package/dist/core/scientific-assurance.js.map +1 -1
  40. package/dist/core/shared-helpers.d.ts +35 -0
  41. package/dist/core/shared-helpers.js +38 -0
  42. package/dist/core/shared-helpers.js.map +1 -0
  43. package/dist/core/spectral-composition.js +1 -36
  44. package/dist/core/spectral-composition.js.map +1 -1
  45. package/dist/core/spectral.js +1 -19
  46. package/dist/core/spectral.js.map +1 -1
  47. package/dist/core/version.d.ts +1 -1
  48. package/dist/core/version.js +1 -1
  49. package/dist/core/version.js.map +1 -1
  50. package/dist/equipment/exposure-capabilities.d.ts +1 -0
  51. package/dist/equipment/exposure-capabilities.js +2 -36
  52. package/dist/equipment/exposure-capabilities.js.map +1 -1
  53. package/dist/equipment/generic-lens-catalogue.d.ts +8 -2
  54. package/dist/equipment/generic-lens-catalogue.js +31 -2
  55. package/dist/equipment/generic-lens-catalogue.js.map +1 -1
  56. package/dist/equipment/generic-tier-assets.js +2 -3
  57. package/dist/equipment/generic-tier-assets.js.map +1 -1
  58. package/dist/equipment/generic-tier-presets.js +2 -3
  59. package/dist/equipment/generic-tier-presets.js.map +1 -1
  60. package/dist/equipment/iso-capabilities.js +2 -49
  61. package/dist/equipment/iso-capabilities.js.map +1 -1
  62. package/dist/equipment/metering-capabilities.js +1 -26
  63. package/dist/equipment/metering-capabilities.js.map +1 -1
  64. package/dist/equipment/release-capabilities.js +1 -53
  65. package/dist/equipment/release-capabilities.js.map +1 -1
  66. package/dist/exposure/auto-iso-slowest-shutter.js +1 -7
  67. package/dist/exposure/auto-iso-slowest-shutter.js.map +1 -1
  68. package/dist/exposure/exposure-duration-control.js +1 -16
  69. package/dist/exposure/exposure-duration-control.js.map +1 -1
  70. package/dist/exposure/exposure-mode-resolver.js +1 -17
  71. package/dist/exposure/exposure-mode-resolver.js.map +1 -1
  72. package/dist/exposure/filtered-exposure-time.d.ts +93 -0
  73. package/dist/exposure/filtered-exposure-time.js +129 -0
  74. package/dist/exposure/filtered-exposure-time.js.map +1 -0
  75. package/dist/exposure/flash-ttl.d.ts +1 -0
  76. package/dist/exposure/flash-ttl.js +1 -1
  77. package/dist/exposure/flash-ttl.js.map +1 -1
  78. package/dist/exposure/flash.js +1 -49
  79. package/dist/exposure/flash.js.map +1 -1
  80. package/dist/exposure/metering-scene-radiance.js +1 -25
  81. package/dist/exposure/metering-scene-radiance.js.map +1 -1
  82. package/dist/exposure/metering-target.js +1 -30
  83. package/dist/exposure/metering-target.js.map +1 -1
  84. package/dist/exposure/metering-temporal.js +1 -8
  85. package/dist/exposure/metering-temporal.js.map +1 -1
  86. package/dist/exposure/metering.js +1 -72
  87. package/dist/exposure/metering.js.map +1 -1
  88. package/dist/exposure/program-line.js +1 -45
  89. package/dist/exposure/program-line.js.map +1 -1
  90. package/dist/facade/develop-joined-parts.d.ts +29 -0
  91. package/dist/facade/develop-joined-parts.js +33 -0
  92. package/dist/facade/develop-joined-parts.js.map +1 -0
  93. package/dist/facade/display-view-gpu.d.ts +7 -4
  94. package/dist/facade/display-view-gpu.js +4 -4
  95. package/dist/facade/display-view-gpu.js.map +1 -1
  96. package/dist/facade/display-view-lens-signal.d.ts +1 -1
  97. package/dist/facade/display-view-lens-signal.js +5 -3
  98. package/dist/facade/display-view-lens-signal.js.map +1 -1
  99. package/dist/facade/display-view.d.ts +3 -1
  100. package/dist/facade/display-view.js +1 -1
  101. package/dist/facade/display-view.js.map +1 -1
  102. package/dist/facade/fast-methods.d.ts +50 -0
  103. package/dist/facade/fast-methods.js +50 -0
  104. package/dist/facade/fast-methods.js.map +1 -0
  105. package/dist/facade/fast-resolution.d.ts +12 -0
  106. package/dist/facade/fast-resolution.js +18 -0
  107. package/dist/facade/fast-resolution.js.map +1 -0
  108. package/dist/facade/generic-lens-capture.d.ts +6 -3
  109. package/dist/facade/generic-lens-capture.js +6 -3
  110. package/dist/facade/generic-lens-capture.js.map +1 -1
  111. package/dist/facade/simulate-capture.d.ts +88 -16
  112. package/dist/facade/simulate-capture.js +279 -153
  113. package/dist/facade/simulate-capture.js.map +1 -1
  114. package/dist/facade/simulate-display-view.d.ts +3 -2
  115. package/dist/facade/simulate-display-view.js +22 -12
  116. package/dist/facade/simulate-display-view.js.map +1 -1
  117. package/dist/gpu/chart-composite-gpu.js +1 -3
  118. package/dist/gpu/chart-composite-gpu.js.map +1 -1
  119. package/dist/gpu/chart-radiance-gpu.js +1 -5
  120. package/dist/gpu/chart-radiance-gpu.js.map +1 -1
  121. package/dist/gpu/display-view-quality-gpu.d.ts +13 -1
  122. package/dist/gpu/display-view-quality-gpu.js +4 -8
  123. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  124. package/dist/gpu/display-view-speed-gpu.js +3 -25
  125. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  126. package/dist/gpu/sensor-stage-gpu.js +1 -5
  127. package/dist/gpu/sensor-stage-gpu.js.map +1 -1
  128. package/dist/gpu/webgpu-runtime.d.ts +2 -0
  129. package/dist/gpu/webgpu-runtime.js +3 -0
  130. package/dist/gpu/webgpu-runtime.js.map +1 -1
  131. package/dist/index.d.ts +11 -1
  132. package/dist/index.js +8 -0
  133. package/dist/index.js.map +1 -1
  134. package/dist/motion/camera-rotation.js +1 -6
  135. package/dist/motion/camera-rotation.js.map +1 -1
  136. package/dist/motion/capture-rotation-inverse-mapping.d.ts +12 -0
  137. package/dist/motion/capture-rotation-inverse-mapping.js +2 -2
  138. package/dist/motion/capture-rotation-inverse-mapping.js.map +1 -1
  139. package/dist/motion/capture-rotation-temporal-quadrature.d.ts +1 -0
  140. package/dist/motion/capture-rotation-temporal-quadrature.js +1 -1
  141. package/dist/motion/capture-rotation-temporal-quadrature.js.map +1 -1
  142. package/dist/motion/capture-rotation-trajectory.js +2 -17
  143. package/dist/motion/capture-rotation-trajectory.js.map +1 -1
  144. package/dist/motion/capture-translation-parallax-temporal-quadrature.d.ts +1 -0
  145. package/dist/motion/capture-translation-parallax-temporal-quadrature.js +3 -14
  146. package/dist/motion/capture-translation-parallax-temporal-quadrature.js.map +1 -1
  147. package/dist/motion/extended-object-projection.js +5 -40
  148. package/dist/motion/extended-object-projection.js.map +1 -1
  149. package/dist/optics/aperture-diameter.d.ts +47 -0
  150. package/dist/optics/aperture-diameter.js +50 -0
  151. package/dist/optics/aperture-diameter.js.map +1 -0
  152. package/dist/optics/atmospheric-haze.js +1 -7
  153. package/dist/optics/atmospheric-haze.js.map +1 -1
  154. package/dist/optics/brown-conrady-distortion.js +1 -7
  155. package/dist/optics/brown-conrady-distortion.js.map +1 -1
  156. package/dist/optics/complex-pupil-psf.d.ts +1 -0
  157. package/dist/optics/complex-pupil-psf.js +2 -84
  158. package/dist/optics/complex-pupil-psf.js.map +1 -1
  159. package/dist/optics/detail-limit.d.ts +182 -0
  160. package/dist/optics/detail-limit.js +336 -0
  161. package/dist/optics/detail-limit.js.map +1 -0
  162. package/dist/optics/focus-state.js +1 -11
  163. package/dist/optics/focus-state.js.map +1 -1
  164. package/dist/optics/front-of-lens-filter.d.ts +5 -0
  165. package/dist/optics/front-of-lens-filter.js +3 -65
  166. package/dist/optics/front-of-lens-filter.js.map +1 -1
  167. package/dist/optics/generic-lens-psf.d.ts +18 -2
  168. package/dist/optics/generic-lens-psf.js +25 -2
  169. package/dist/optics/generic-lens-psf.js.map +1 -1
  170. package/dist/optics/generic-nd-filter-presets.d.ts +12 -0
  171. package/dist/optics/generic-nd-filter-presets.js +48 -0
  172. package/dist/optics/generic-nd-filter-presets.js.map +1 -0
  173. package/dist/optics/illumination-vignetting.js +1 -6
  174. package/dist/optics/illumination-vignetting.js.map +1 -1
  175. package/dist/optics/lens-psf-profile.d.ts +4 -1
  176. package/dist/optics/lens-psf-profile.js +12 -111
  177. package/dist/optics/lens-psf-profile.js.map +1 -1
  178. package/dist/optics/longitudinal-chromatic-aberration.js +1 -7
  179. package/dist/optics/longitudinal-chromatic-aberration.js.map +1 -1
  180. package/dist/optics/pupil-vignetting.js +1 -7
  181. package/dist/optics/pupil-vignetting.js.map +1 -1
  182. package/dist/optics/radial-distortion.js +1 -6
  183. package/dist/optics/radial-distortion.js.map +1 -1
  184. package/dist/optics/scene-to-sensor-irradiance.d.ts +13 -0
  185. package/dist/optics/scene-to-sensor-irradiance.js +11 -96
  186. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  187. package/dist/optics/stray-light.d.ts +1 -0
  188. package/dist/optics/stray-light.js +1 -1
  189. package/dist/optics/stray-light.js.map +1 -1
  190. package/dist/optics/zernike-wavefront.js +1 -7
  191. package/dist/optics/zernike-wavefront.js.map +1 -1
  192. package/dist/output/capture-geometry.js +1 -8
  193. package/dist/output/capture-geometry.js.map +1 -1
  194. package/dist/output/crop-to-match-focal-length.d.ts +62 -0
  195. package/dist/output/crop-to-match-focal-length.js +63 -0
  196. package/dist/output/crop-to-match-focal-length.js.map +1 -0
  197. package/dist/output/developed-lens-corrections.d.ts +62 -0
  198. package/dist/output/developed-lens-corrections.js +135 -0
  199. package/dist/output/developed-lens-corrections.js.map +1 -0
  200. package/dist/output/generic-lens-corrections.d.ts +5 -7
  201. package/dist/output/generic-lens-corrections.js +11 -10
  202. package/dist/output/generic-lens-corrections.js.map +1 -1
  203. package/dist/output/geometric-transforms.js +1 -4
  204. package/dist/output/geometric-transforms.js.map +1 -1
  205. package/dist/output/lens-corrections.d.ts +12 -2
  206. package/dist/output/lens-corrections.js +22 -7
  207. package/dist/output/lens-corrections.js.map +1 -1
  208. package/dist/output/native-capture-sdr.d.ts +2 -2
  209. package/dist/output/native-capture-sdr.js +3 -2
  210. package/dist/output/native-capture-sdr.js.map +1 -1
  211. package/dist/output/print-contrast-reference.js +1 -1
  212. package/dist/output/print-contrast-reference.js.map +1 -1
  213. package/dist/output/print-size-blur-noise.d.ts +110 -0
  214. package/dist/output/print-size-blur-noise.js +138 -0
  215. package/dist/output/print-size-blur-noise.js.map +1 -0
  216. package/dist/processing/development-detail.d.ts +29 -0
  217. package/dist/processing/development-detail.js +128 -107
  218. package/dist/processing/development-detail.js.map +1 -1
  219. package/dist/schema/illumination-temporal.js +3 -72
  220. package/dist/schema/illumination-temporal.js.map +1 -1
  221. package/dist/schema/illumination.d.ts +1 -0
  222. package/dist/schema/illumination.js +2 -65
  223. package/dist/schema/illumination.js.map +1 -1
  224. package/dist/schema/scene-radiance-temporal.js +2 -14
  225. package/dist/schema/scene-radiance-temporal.js.map +1 -1
  226. package/dist/schema/scene-radiance.js +2 -60
  227. package/dist/schema/scene-radiance.js.map +1 -1
  228. package/dist/schema/validation.d.ts +2 -0
  229. package/dist/schema/validation.js +2 -17
  230. package/dist/schema/validation.js.map +1 -1
  231. package/dist/sensor/accumulated-charge.js +2 -59
  232. package/dist/sensor/accumulated-charge.js.map +1 -1
  233. package/dist/sensor/architecture.js +1 -16
  234. package/dist/sensor/architecture.js.map +1 -1
  235. package/dist/sensor/camera-saturation-capacity.d.ts +1 -0
  236. package/dist/sensor/camera-saturation-capacity.js +3 -55
  237. package/dist/sensor/camera-saturation-capacity.js.map +1 -1
  238. package/dist/sensor/capture-color-sampling-binding.js +1 -26
  239. package/dist/sensor/capture-color-sampling-binding.js.map +1 -1
  240. package/dist/sensor/capture-mode-timing.js +1 -39
  241. package/dist/sensor/capture-mode-timing.js.map +1 -1
  242. package/dist/sensor/capture-mode.js +1 -23
  243. package/dist/sensor/capture-mode.js.map +1 -1
  244. package/dist/sensor/color-sampling.js +1 -26
  245. package/dist/sensor/color-sampling.js.map +1 -1
  246. package/dist/sensor/constant-rate-temporal-integration.js +2 -46
  247. package/dist/sensor/constant-rate-temporal-integration.js.map +1 -1
  248. package/dist/sensor/dark-current.js +2 -45
  249. package/dist/sensor/dark-current.js.map +1 -1
  250. package/dist/sensor/eqe-electron-rate.js +2 -41
  251. package/dist/sensor/eqe-electron-rate.js.map +1 -1
  252. package/dist/sensor/exposure-window.d.ts +7 -0
  253. package/dist/sensor/exposure-window.js +9 -24
  254. package/dist/sensor/exposure-window.js.map +1 -1
  255. package/dist/sensor/iso-gain.js +1 -7
  256. package/dist/sensor/iso-gain.js.map +1 -1
  257. package/dist/sensor/iso-signal-chain.js +1 -43
  258. package/dist/sensor/iso-signal-chain.js.map +1 -1
  259. package/dist/sensor/optical-stack.js +1 -25
  260. package/dist/sensor/optical-stack.js.map +1 -1
  261. package/dist/sensor/photon-energy.js +1 -26
  262. package/dist/sensor/photon-energy.js.map +1 -1
  263. package/dist/sensor/physical-charge-capacity.js +3 -77
  264. package/dist/sensor/physical-charge-capacity.js.map +1 -1
  265. package/dist/sensor/radiometry-readiness.js +2 -23
  266. package/dist/sensor/radiometry-readiness.js.map +1 -1
  267. package/dist/sensor/raw-readout.d.ts +3 -0
  268. package/dist/sensor/raw-readout.js +4 -66
  269. package/dist/sensor/raw-readout.js.map +1 -1
  270. package/dist/sensor/raw-reconstruction.js +2 -64
  271. package/dist/sensor/raw-reconstruction.js.map +1 -1
  272. package/dist/sensor/readout-exposure-linkage.js +1 -9
  273. package/dist/sensor/readout-exposure-linkage.js.map +1 -1
  274. package/dist/sensor/readout-timing.js +4 -79
  275. package/dist/sensor/readout-timing.js.map +1 -1
  276. package/dist/sensor/response-application-compatibility.d.ts +2 -0
  277. package/dist/sensor/response-application-compatibility.js +4 -35
  278. package/dist/sensor/response-application-compatibility.js.map +1 -1
  279. package/dist/sensor/response-operating-range.d.ts +1 -0
  280. package/dist/sensor/response-operating-range.js +3 -79
  281. package/dist/sensor/response-operating-range.js.map +1 -1
  282. package/dist/sensor/responsivity-photocurrent.js +4 -114
  283. package/dist/sensor/responsivity-photocurrent.js.map +1 -1
  284. package/dist/sensor/sampling-aperture.js +1 -25
  285. package/dist/sensor/sampling-aperture.js.map +1 -1
  286. package/dist/sensor/sensor-characteristics.d.ts +137 -0
  287. package/dist/sensor/sensor-characteristics.js +115 -0
  288. package/dist/sensor/sensor-characteristics.js.map +1 -0
  289. package/dist/sensor/spatial-sample-reduction.js +3 -24
  290. package/dist/sensor/spatial-sample-reduction.js.map +1 -1
  291. package/dist/sensor/spatial-sampling-quadrature.js +1 -3
  292. package/dist/sensor/spatial-sampling-quadrature.js.map +1 -1
  293. package/dist/sensor/spatio-spectral-reduction.js +1 -18
  294. package/dist/sensor/spatio-spectral-reduction.js.map +1 -1
  295. package/dist/sensor/spectral-coverage.d.ts +10 -1
  296. package/dist/sensor/spectral-coverage.js +1 -1
  297. package/dist/sensor/spectral-coverage.js.map +1 -1
  298. package/dist/sensor/spectral-quadrature.js +3 -41
  299. package/dist/sensor/spectral-quadrature.js.map +1 -1
  300. package/dist/sensor/spectral-response.js +1 -47
  301. package/dist/sensor/spectral-response.js.map +1 -1
  302. package/dist/sensor/temporal-photo-signal.d.ts +3 -0
  303. package/dist/sensor/temporal-photo-signal.js +5 -2
  304. package/dist/sensor/temporal-photo-signal.js.map +1 -1
  305. package/dist/stabilization/camera-shake.js +1 -6
  306. package/dist/stabilization/camera-shake.js.map +1 -1
  307. package/dist/stabilization/system.d.ts +9 -0
  308. package/dist/stabilization/system.js +7 -86
  309. package/dist/stabilization/system.js.map +1 -1
  310. package/docs/AGENT_GUIDE.md +5 -0
  311. package/docs/API_REFERENCE.md +116 -3
  312. package/docs/QUICK_START.md +178 -14
  313. package/docs/USAGE.md +111 -3
  314. package/docs/api/capture-native-raw-dng.md +6 -0
  315. package/docs/api/core-version.md +1 -1
  316. package/docs/api/equipment-generic-lens-catalogue.md +7 -2
  317. package/docs/api/exports.json +203 -3
  318. package/docs/api/exposure-filtered-exposure-time.md +105 -0
  319. package/docs/api/facade-develop-joined-parts.md +32 -0
  320. package/docs/api/facade-display-view-gpu.md +5 -2
  321. package/docs/api/facade-fast-methods.md +91 -0
  322. package/docs/api/facade-simulate-capture.md +72 -16
  323. package/docs/api/facade-simulate-display-view.md +3 -2
  324. package/docs/api/optics-aperture-diameter.md +51 -0
  325. package/docs/api/optics-detail-limit.md +203 -0
  326. package/docs/api/optics-generic-nd-filter-presets.md +34 -0
  327. package/docs/api/optics-lens-psf-profile.md +3 -1
  328. package/docs/api/output-crop-to-match-focal-length.md +67 -0
  329. package/docs/api/output-developed-lens-corrections.md +12 -0
  330. package/docs/api/output-generic-lens-corrections.md +3 -10
  331. package/docs/api/output-lens-corrections.md +11 -1
  332. package/docs/api/output-print-size-blur-noise.md +115 -0
  333. package/docs/api/sensor-sensor-characteristics.md +105 -0
  334. package/llms.txt +5 -0
  335. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,184 @@
1
1
  # Changelog
2
2
 
3
- ## 2.18.0 — unreleased (prepared November 9, 2026)
3
+ ## 2.20.0 — unreleased (prepared November 9, 2026)
4
+
5
+ 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
+
7
+ - **New:** `calculateApertureDiameter` (#577), `calculateCropToMatchFocalLength` (#579), `calculateDetailLimit` (#575), `calculatePrintSizeLimitWithBlurAndNoise` (#574, with a `capture-noise-figure` input for #535's figure), the `sensor` characteristics and `result.sensor` (#555, #535), `output.dng.lensCorrectionOpcodes` (#587), `diagnostics.fast.defaultedFrom`, `SimulateCaptureDngPart.fast`.
8
+ - **Changed (shipped images and files; see "Default changes"):** `fast` follows the output format; `prepareDisplayView`'s `gpu` follows `fast`; JPEG is saved at quantization step 1; catalogue lenses with `lens.defocus` render their own bokeh (#567); `createGenericLensCorrectionProfile` profiles are version 1.1.0 (#584); in-focus diffraction quadrature ties resolve as on the lattice (#565).
9
+ - **Root exports:** 30 new, 1,860 → 1,890 (runtime exports 631 → 637): 3 from #577 (1 runtime), 3 from #579 (1 runtime), 12 from #575 (2 runtime), 7 from #574 (1 runtime), 3 from #555/#535 (types), and the scheme's registry `SIMULATE_CAPTURE_FAST_METHODS` with its type `SimulateCaptureFastMethod`.
10
+ - **Why minor:** new root exports and options. Several defaults change (below); each old behavior stays one option away. Nothing is removed. Part F's `lens.catalogue.catsEye` and part G's `lens.diffraction.rollingShutterLatticeTaps` were never released; the integration folded them into the `fast` scheme.
11
+
12
+ ### Default changes (migration)
13
+
14
+ | What changed | Before (2.19.0) | Now (2.20.0) | Keep the old behavior |
15
+ | --- | --- | --- | --- |
16
+ | `fast` when omitted | exact (`false`) for every request | resolved by format: fast for `output.format` "srgb-8" and "srgb-16" and for the display view; exact for "dng", "jpeg", "png" and `simulateIdealReference` (`diagnostics.fast.defaultedFrom: "format"`). An sRGB capture with `lens.catalogue` now uses #568's fast method | `fast: false` |
17
+ | `prepareDisplayView` `gpu` when omitted | `"off"` | `"auto"` when the request resolves to fast (the view's default), `"off"` with `fast: false`; an explicit value wins | `gpu: "off"` (or `fast: false`) |
18
+ | `output.jpegQuantizationStep` | 2 | 1 (100 % quality; detailed scenes about 25–35 % larger files, flat ones about 2.4×) | `jpegQuantizationStep: 2` |
19
+ | Catalogue lens with `lens.defocus` | the plain disc | the lens's own bokeh: blades and its spherical aberration always; its cat's eye (fast wides and normals) on exact requests, so on DNG, JPEG and PNG by default (#567; the owner approved the sample images and blade values) | no option (a corrected model); `fast: true` leaves the cat's eye out |
20
+ | The cat's eye on exports | n/a (new) | on for "dng", "jpeg" and "png" (PNG counts as an export) | `fast: true` |
21
+ | Rolling-shutter rotation with in-focus `lens.diffraction` | each PSF tap at its own point | lattice points on exact requests (DNG, JPEG, PNG by default), each tap's own point on fast ones (#565) | `lens.diffraction: { kind: "circular-aperture", fast: true }` |
22
+ | `createGenericLensCorrectionProfile` | profile 1.0.0, gain without the distortion's area factor | profile and vignetting component 1.1.0 with `distortionAreaFactor` (#584) | build the component without `distortionAreaFactor` |
23
+ | `result.sensor.midGrey` | n/a (new) | computed only when the request resolves to exact | `fast: false` |
24
+ | Display view AHD correction, uniform-pixel sampling, catalogue PSF zones | engine-reference label | unchanged values, now labeled as the view's fast methods: `diagnostics.fast.applied` names them and `diagnostics.fast.assurance` is `"approximation-with-a-priori-bound"` by default for AHD and catalogue-lens views (owner accepted, 2026-11-09); `fast: false` selects their exact methods (8192 draws at the same 0.01 threshold; full sampling; one PSF zone per site), which are slower | `fast: false` for the exact methods and the `"engine-reference"` label |
25
+
26
+ ### Integration (this release)
27
+
28
+ - **One speed-versus-accuracy scheme** (owner decisions 2026-11-09): every speed-versus-accuracy choice resolves through `resolveFast` and `fastFormatDefault` (`src/facade/fast-resolution.ts`): an option record's `fast`, else the top-level `fast`, else the format default. `src/facade/fast-methods.ts` registers every choice (`SIMULATE_CAPTURE_FAST_METHODS`) and `test/fast-methods-registry.test.ts` fails when a record accepts `fast` without an entry, an entry lacks its resolver or report, or a format default leaves the rule (docs/API_STYLE.md, "Exact and fast modes"). `diagnostics.fast.requested` is the resolved value, with the new `defaultedFrom`. The display view gains three fast methods with exact alternatives: its catalogue PSF zones (`lens.catalogue`), its AHD low-signal correction (`output.demosaic.ahd`: 8192 instead of 2048 draws per state) and its quarter sampling of uniform pixels (top-level `fast`). `SimulateCaptureDngPart` takes an optional `fast` (whether a fast method rendered the part); `serializeSimulatedCaptureDngFromParts` and `simulateCaptureFromParts` refuse a fast part unless their own request resolves to fast. The app's calculator passes `fast: false`.
29
+ - **#535 in #574:** `calculatePrintSizeLimitWithBlurAndNoise` takes `noise: { kind: "capture-noise-figure", midGrey }`, a capture's own figure, through the same `resolvePixelSnr` (a clipped figure is refused).
30
+ - **Shared helpers (capture-worker size, results bit-identical, every hash lock passes):** one `approximatelyEqual` (core/shared-helpers.ts) replaces six private copies; `validateActiveCaptureRect` and `validateShutterMechanism` of `exposure-window.ts` replace `readout-timing.ts`'s identical copies; the photo-signal flag lists (`PHOTO_SIGNAL_TRUE_FIELDS`, `PHOTO_SIGNAL_DOWNSTREAM_FALSE_FIELDS`), the scene-to-sensor bridge scope (`SCENE_TO_SENSOR_BRIDGE_SCOPE`) and the stabilization scope flags (`STABILIZATION_UNMODELED_SCOPE`) are one module constant each, spread at the same position so every record keeps its key order. 415 B saved in all (none is a root export).
31
+ - **Docs:** the catalogue's optical vignetting fades over four stops of stopping down (log2(N/N0) = 2; the comment and docs said two; no image change). US spelling in #575's prose; #577's uncited "also see" reference removed.
32
+ - **CI (#231, owner decisions):** the primary leg (the coverage shards and the required `verify (lts/*)` check, whose name is kept) is pinned to Node 24, the tooling major; Node 26 is tested by the `node` leg; a `test` leg that resolves to the primary leg's release (`lts/-1` today) skips its test work while its required check still runs. The drift check accepts the pin and requires the skip.
33
+ - **Licences (#544):** the owner signed off all five pending reviews on 2026-11-09 (`THIRD_PARTY.md`).
34
+ - **Bundle:** the app's capture-worker shape is 355,994 B gzip (2.19.0: 351,775 B): the eight parts merged measured 355,892 B (#555/#535 +2,157 B, #587 +1,197 B, #567 +595 B, #565 +197 B), the scheme +517 B, the shared helpers above −415 B. **`test/capture-bundle.test.ts`'s limit is raised from 352,000 to 356,000 B** (the measured total rounded up to the next 100 B; owner-approved 2026-11-09 for 2.20.0 only, for the 2.20.0 features, after one refactor pass for savings; docs/RELEASE_2_20_0.md, "Sizes"). The display view adds 46,008 B minified and 18,118 B gzip (budgets 46,500 B and 18,550 B).
35
+
36
+ ### #231 CI Node matrix drift test and runner-minute baseline
37
+
38
+ - **CI only; no package, API or bundle change.** `.github/node-ci-roles.mjs` derives the CI Node roles (exact `engines.node` minimum, maintained LTS majors, Current major, primary `lts/*` leg, tooling major) from `.github/node-lts-policy.mjs` (new export `lifecycleAt`) and checks `ci.yml`, `publish.yml`, `NODE_SUPPORT.md` and `package.json` against them. It runs in the existing `change scope` job (under a second; no new job, no install). Drift fails; owner-decision findings print as notices.
39
+ - **Tests:** `.github/test-node-ci-roles.mjs` (alias resolution before and after Node 26 entered LTS and at Node 22's end of life, deduplication findings, and nine kinds of drift).
40
+ - **Runner-minute baseline:** recorded per leg in docs/DEVELOPERS.md from 25 pull-request runs (2026-10-31 to 2026-11-09): median 90.1 Linux job-minutes per run (101 billed), 121.2 over the last three.
41
+ - **Docs:** NODE_SUPPORT.md describes the check.
42
+
43
+ ### #577 Aperture (entrance pupil) diameter in mm
44
+
45
+ - **New: `calculateApertureDiameter({ focalLengthMm, fNumber })`** (model `aperture-diameter` 1.0.0, for the app's Guide page "F-stops explained" and its aperture animation): the entrance-pupil diameter `D = f/N` in millimetres, returned as `entrancePupilDiameterMm` with the inputs. 50 mm at f/2 gives 25 mm. Exact by the definition of the f-number for an object at infinity (Greivenkamp, OPTI-502 course notes, sec. 12, free). Stated, not modelled: the close-focus working f-number `N·(1 + m/P)` with pupil magnification `P`, and focal-length changes while focusing. `D` is the entrance pupil, not the diaphragm's physical opening. `calculateSensorEquivalence` already reported the same number as `entrancePupilDiameterMm` (tested equal).
46
+ - **Root exports:** 3 new (1 runtime): `calculateApertureDiameter` and the types `CalculateApertureDiameterInput`, `ApertureDiameter` (new module `src/optics/aperture-diameter.ts`). Pure function; no capture, RAW or bundle change (the app's capture-worker shape measures 351,775 B gzip, unchanged; the display view's added size is unchanged).
47
+ - **Docs and tests:** docs/PHYSICS_FOUNDATION.md ("Aperture (entrance-pupil) diameter"), docs/USAGE.md (a new last section with a runnable example, so no fragment index shifts), docs/AGENT_GUIDE.md, llms.txt, API reference task index; `test/aperture-diameter-and-crop-to-match.test.ts`; strict-input list in `test/root-export-input-errors.test.ts`.
48
+
49
+ ### #579 Crop to match a longer focal length: factor and remaining pixels
50
+
51
+ - **New: `calculateCropToMatchFocalLength({ focalLengthMm, targetFocalLengthMm, pixelWidth, pixelHeight })`** (model `crop-to-match-focal-length` 1.0.0, for the app's Guide page "Cropping vs a longer focal length"): the linear crop factor `targetFocalLengthMm / focalLengthMm` and the centred crop it leaves (`pixelWidth`, `pixelHeight`, `megapixels`, `sourceMegapixels`, `retainedAreaFraction`), the crop being `calculateCenteredCrop`'s (aspect ratio kept to whole pixels). 24 MP (6000 × 4000) at 50 mm matched to 100 mm: factor 2, 3000 × 2000, 6 MP. Exact for ideal rectilinear lenses focused at infinity, from the same camera position (image height `f·tan θ`, Greivenkamp sec. 12). Stated, not applied: at close focus the thin-lens matching factor is `F·(d − f)/(f·(d − F))` at object distance `d` (2.11 instead of 2 for 50 → 100 mm at 1 m); distortion and focus breathing. A target shorter than the focal length is refused. A new function rather than a change to `calculateCenteredCrop`, whose contract stays as it is.
52
+ - **Root exports:** 3 new (1 runtime): `calculateCropToMatchFocalLength` and the types `CalculateCropToMatchFocalLengthInput`, `CropToMatchFocalLength` (new module `src/output/crop-to-match-focal-length.ts`). Pure function; no capture, RAW or bundle change (capture-worker shape unchanged at 351,775 B gzip).
53
+ - **Docs and tests:** docs/PHYSICS_FOUNDATION.md ("Crop to match a longer focal length"), docs/USAGE.md, docs/AGENT_GUIDE.md, llms.txt, the API reference task index row for crops; `test/aperture-diameter-and-crop-to-match.test.ts`.
54
+
55
+ ### #575 Which limits detail: the lens or the sensor
56
+
57
+ - **New: `calculateDetailLimit({ sensor, lens, fNumber, focus?, fieldPosition?, wavelengthNm? })`** (model `detail-limit` 1.0.0, for the app's Learn section): says whether the lens (diffraction at this f-number, aberrations, defocus) or the sensor's pixel pitch limits detail. `lens` is `{ kind: "ideal", focalLengthMm }` or `{ kind: "catalogue", lensId }` (the generic catalogue's Seidel aberrations at `fieldPosition`, 0 center to 1 corner); `sensor` is `{ widthMm, heightMm, pixelWidth, colorFilterArray? }` (`"bayer"` by default, or `"none"`). Returns `limitedBy` (`"lens"` | `"sensor"`), `limitingFactor` (`"diffraction"` | `"aberrations"` | `"defocus"` | `"pixel-pitch"`), `opticsToNyquistRatio`, the optics (working f-number, cut-off 1/(λN_w), MTF50 tangential and sagittal, the diffraction-only MTF50, MTF at Nyquist, RMS wavefront error of the aberrations and of the defocus, the defocus blur circle), the sampling (pitch, Nyquist 1/(2p), the Bayer red and blue sites' 1/(4p), pixel aperture MTF), the system (optics × pixel aperture MTF50, also in line pairs per picture height) and `diffractionLimitedFNumber` (≈ f/8.8 on 6 µm pixels at 550 nm).
58
+ - **Model:** the optics MTF is the exact scalar incoherent OTF (pupil autocorrelation, MIT OCW 2.71 lectures 21–22; Goodman §7.3–7.4) of the stated wavefront at one wavelength, by Gauss–Legendre quadrature (doubling the nodes changes it by under 1e-9; an independent brute-force grid agrees within its 3e-3 edge error). Defocus W020 = dz/(8N_w²) (Wyant & Creath 1992 eq. 18). **Approximations:** the decision rule (the sensor limits when the optics MTF50 reaches the Nyquist frequency; the lens through diffraction when its RMS wavefront error, piston and tilt removed, is within λ/14, the Maréchal criterion, else aberrations or defocus, whichever is larger), the catalogue lenses' wavefronts, the ideal square pixel aperture (fill factor 1). Monochromatic (550 nm by default); demosaicing, the optical low-pass filter, lateral color, noise and motion are excluded. Wavefronts above 64 waves are refused (a geometric blur; `calculateDefocusCircle`), `DETAIL_LIMIT_LIMITS`.
59
+ - Examples: an ideal 50 mm at f/22 on 36 × 24 mm, 6000 pixels wide: `"diffraction"` (MTF50 ≈ 33.4 cycles/mm, Nyquist ≈ 83.3); *PV 600 6.3* wide open at the corner on 9504 pixels: `"aberrations"`; *PV 50 1.2 Pro* at f/5.6 on 3000 pixels: `"pixel-pitch"`; a subject at 2.5 m with focus at 3 m, 50 mm f/2: `"defocus"`.
60
+ - **Root exports:** 12 new (2 runtime), 1,860 → 1,872 (runtime 631 → 633): `calculateDetailLimit`, `DETAIL_LIMIT_LIMITS` and the types `CalculateDetailLimitInput`, `DetailLimit`, `DetailLimitBasis`, `DetailLimitCatalogueLens`, `DetailLimitIdealLens`, `DetailLimitOptics`, `DetailLimitSampling`, `DetailLimitSystem`, `DetailLimitedBy`, `DetailLimitingFactor`. New module `src/optics/detail-limit.ts`, a pure function; no capture, RAW, schema or `src/gpu` change. The export block sits at the end of `src/index.ts` so the bundler's module order, and so the app's capture-worker shape, is unchanged: 0 B gzip (the display view's budget too).
61
+ - **Docs and tests:** docs/PHYSICS_FOUNDATION.md ("Which limits detail: the lens or the sensor"), docs/USAGE.md (a runnable example, appended at the end so no fragment index shifts), docs/AGENT_GUIDE.md, llms.txt, API reference task index; `test/detail-limit.test.ts` (the three cases of the issue, defocus, the switch at the diffraction-limited f-number, quadrature convergence and a brute-force check, aberrations never above the diffraction limit, refusals).
62
+
63
+ ### #574 Print size limit that counts blur and noise, not just pixels
64
+
65
+ - **New: `calculatePrintSizeLimitWithBlurAndNoise({ raster, pixelPitchMm, viewingDistance, criterion, blur, noise })`** (model `print-size-limit-blur-noise` 1.0.0, an approximation; for the app's Learn question "Can I print this, and how big?"): the largest print, at a fixed viewing distance and the raster's aspect ratio, at which the capture still looks sharp and clean, the limiting factor (`pixels`, `blur` or `noise`) and each factor's own limit. With s = 2·d·tan(θ/2) the criterion angle on the print: pixels W ≤ N_w·s; blur W ≤ N_w·s/b (b the blur diameter in pixels; parts in root-sum-square, exact for Gaussian spots); noise W ≤ N_w·s·SNR_px/SNR_min (the eye averages the pixels inside s; none when SNR_px ≥ SNR_min). Noise comes from the engine's noise model (`calculateSignalToNoise`, shot plus read noise) or a caller's per-pixel SNR. The criterion (default-free: one arcminute is normal acuity, EyeWiki; the SNR threshold is the caller's) and its sources are in docs/PRINT_PLANNING.md. 6000 × 4000 pixels of 4 µm at 500 mm and 1 arcmin: 872.7 mm wide pixel-limited, 268.5 mm with 13 µm of blur, 427.9 mm at a pixel SNR of 9.8 (100 e⁻, 2 e⁻ read noise) against 20.
66
+ - **Root exports:** 7 new (1 runtime): `calculatePrintSizeLimitWithBlurAndNoise` and the types `PrintBlurInput`, `PrintNoiseInput`, `PrintSharpCleanCriterion`, `PrintSizeLimitWithBlurAndNoiseInput`, `PrintImageMm`, `PrintSizeLimitWithBlurAndNoise`. Pure function in its own module (`src/output/print-size-blur-noise.ts`); no capture, RAW or bundle change.
67
+ - **Docs and tests:** docs/PRINT_PLANNING.md ("Print size limit with blur and noise", with a runnable example), docs/USAGE.md, docs/AGENT_GUIDE.md, llms.txt, API reference regenerated; `test/print-size-blur-noise.test.ts` (pixel-, blur- and noise-limited captures, agreement with `calculatePrintSizeLimit`, the shot-noise square-root scaling, distance scaling, zero signal and input errors).
68
+
69
+ ### #584 Lens correction profile: include the distortion's area factor in the vignetting correction
70
+
71
+ - **Changed (versioned): `createGenericLensCorrectionProfile` profiles are version 1.1.0** and their `vignetting` component is version 1.1.0 with the new optional field `distortionAreaFactor` (the lens's own radial distortion profile). A capture through a distorting lens also scales irradiance by its area factor 1/det J (the environment's `inverse-distortion-jacobian`); the component now corrects T = E(ρ_s)/det J(ρ_i), so the profile flattens a flat-field catalogue capture. Without it the sensor corner's gain was off by exactly 1/det J: 23.8 % too bright on PV 16 2.8 (barrel), 6.6 % too dark on PV 300 5.6 (pincushion).
72
+ - **New field: `IlluminationLensCorrection.distortionAreaFactor?: RadialDistortionProfile`**, parsed strictly by `parseGenericLensCorrectionProfile` (geometry and lateral-colour components refuse it). With it, `calculatePeripheralIlluminationCorrection` reads `imagePointMm` as the capture's sensor point and `calculateLensCorrectedCapture` evaluates the gain at each output pixel's source point (per channel), where the capture applied it. Components without the field (every caller-built or 1.0.0 profile) give exactly the previous gain at the previous point.
73
+ - **Decision recorded:** `output.lensCorrections` keeps its own inlined kernel (`src/output/developed-lens-corrections.ts`, about five times faster than a call per sample); a test pins its gain to the profile's to 1e-9.
74
+ - **Tests:** `test/lens-correction-area-factor.test.ts`: flat-field captures through PV 16 2.8 and PV 300 5.6 corrected through the profile APIs come out flat within 0.06 % and 0.16 % (corners against the centre; without the area factor off by several per cent), with and without the geometric corrections; the closed-form 1/det J at the corner; strict parsing; equality with the #570 kernel. `test/generic-lens-catalogue.test.ts` updated for the new corner gain (2^stops · det J).
75
+ - **Docs:** docs/DIGITAL_OPTICS_FOUNDATION.md, "Generic camera correction profiles"; API reference regenerated. No root export added; no bundle change (the profile modules are not in the capture worker).
76
+
77
+ ### #587 DNG: embed the lens corrections as DNG opcodes (non-destructive), each switchable
78
+
79
+ - **New option: `output.dng.lensCorrectionOpcodes: { distortion?, lateralColor?, vignetting? }`** (with `lens.catalogue`, format "dng", whole frame): the catalogue lens's corrections written into the DNG's OpcodeList3 (tag 51022) as optional DNG 1.3 opcodes (flag bit 0 set): FixVignetteRadial (gain fitted to the #584 profile gain det J/E; near-minimax, at most 0.062 % over the whole catalogue; a fit worse than 0.1 % is refused), then WarpRectilinear (one plane, or red, green, blue for lateral colour; exactly `output.lensCorrections`' mapping with the zoom-to-fill folded into kr0). Each switches separately; all off or omitted writes byte-identical DNGs; the RAW codes are never changed. The assumption `output.dng.lensCorrectionOpcodes` records the opcodes, coefficients, magnification and fit error. `serializeSimulatedCaptureDngFromParts` writes the same bytes from parts.
80
+ - **New input: `serializeNativeRawDng({ ..., opcodeList3? })`**: caller OpcodeList3 bytes, framing checked; without it the bytes are unchanged.
81
+ - **Limits (documented):** some editors apply DNG opcodes automatically with no control; the guaranteed switch is leaving a correction out in Photivra. Spec: Adobe DNG Specification, "Opcode List Processing" (opcode lists from 1.3.0.0).
82
+ - **Tests:** `test/dng-lens-correction-opcodes.test.ts` (independent opcode-list parser and the spec's equations: mapping to 1e-9 px and gain within 0.1 % on PV 16 2.8 and PV 300 5.6; byte identity with none; RAW codes and every parsed tag unchanged; all catalogue lenses fit; parts; refusals).
83
+ - **Bundle:** capture-worker shape 352,972 B gzip (+1,197 B: entry +412 B, the lazily loaded catalogue chunk +785 B), over the 352,000 B limit, which is not raised; for the integrator and the owner. Display view delta 45,821 B minified / 18,158 B gzip (budgets 46,500 / 18,550).
84
+
85
+ ### #565 Capture: in-focus diffraction PSF quadrature resolves chart-boundary ties differently from the lattice path
86
+
87
+ - **Fixed:** `simulateCapture` with `lens.diffraction` in focus and `lens.vignetting`, `lens.distortion`, `lens.projection` or camera rotation (the environment PSF quadrature) formed each tap's source point from its own site, while the request without those options (the object-space convolution), the display view and the out-of-focus capture read the scene on the site lattice. A source point exactly on a chart boundary could therefore resolve to the other material: on a 20 × 14 Siemens star at f/11, an identity vignetting profile (every coefficient 0) changed the site next to the centre from 3,560 to 12,663 e⁻. The quadrature now reads each tap's source at its lattice point's own plane point (#548's lattice formula: the image-space provider with a one-tap kernel), so a tie resolves the same way on every path; the identity profile now gives the same expected electrons (within 1e-9 relative, measured ≤ 1e-15) and the same RAW codes as the request without it. With camera rotation under `motion.shutter: "rolling"` (every row has its own instants, so the lattice is read once per instant) it takes the new flag below; the catalogue lens PSF quadrature is unchanged (#588).
88
+ - **Which results change:** in-focus `lens.diffraction` captures with one of those lens options, only where a tap's source point lies exactly on a scene boundary (symmetric charts in symmetric frames; for example a Siemens star with radial distortion, vignetting and the four-spot filter changes RAW codes along its diagonals). Elsewhere a value changes by at most rounding (≤ 1e-15 relative; one multiplication and division by the field factor). Site inputs (`request.siteInput`), `request.diffraction.execution` (`"environment-psf-quadrature"`) and the provider evaluation count are unchanged; the `lens.diffraction` assumption adds that each tap reads the scene at its lattice point. No hash lock or golden image changed.
89
+ - **Rolling-shutter rotation: lens.diffraction's fast method** (owner decision 2026-11-09, "build both, default to the faster"; the part's `rollingShutterLatticeTaps` flag was folded into the `fast` scheme at integration): with camera rotation under `motion.shutter: "rolling"`, the exact method reads each tap at its lattice point as above, and the fast method keeps each tap's own point. The format default decides (fast for sRGB samples; exact for DNG, JPEG and PNG); `lens.diffraction: { kind, fast }` or the top-level `fast` set it. The fast method reports `diagnostics.fast.applied["lens.diffraction"]: true` and the assumption `fast.lens.diffraction`. No effect without rolling-shutter rotation and in-focus diffraction. Cost about 1.2× (dead leaves 48 × 32 at f/8: 4.0–4.1 s → 4.8–5.0 s; Siemens star 32 × 24 at f/16: 6.8 s → 8.1–8.2 s; interleaved twice). The two readings differ only at boundary ties; with the default the RAW depends on `output.format`, so region renders joined into one frame should set the flag explicitly (docs/QUICK_START.md, "Optics"). No root export added (a new optional field of `SimulateCaptureLensDiffraction`).
90
+ - **Cost:** no slowdown measured (scratch benchmark, main against this change interleaved twice, load average 14–25): dead leaves 40 × 28 with vignetting 2.7–4.2 s → 2.6–2.9 s; a 24 × 16 star with distortion, vignetting and four-spot 17–24 s → 16–19 s; rotation 2.2–2.5 s → 2.1–2.5 s.
91
+ - **Bundle:** capture worker +197 B gzip in all (351,775 → 351,972 B; limit 352,000 B, not raised): +113 B for the lattice reading, +84 B for the flag after reusing the existing `queryOffsetsSites` in the object-space provider branches (the same values; it saved about 50 B). Display view's addition +4 B gzip (18,153 → 18,157 B).
92
+ - **Tests and docs:** `test/simulate-capture-boundary-ties.test.ts` (new; a 20 × 14 star and an odd-width 21 × 14 star: same RAW and expected electrons with and without the identity profile, fails on 2.19.0; the flag's defaults, a DNG, a JPEG and explicit values on a pitch-rotated odd-width star, whose centre column lies on the vertical spokes, and dead leaves unchanged by the flag); docs/DISPLAY_VIEW.md ("Ties on chart boundaries"), docs/QUICK_START.md ("Optics", the `lens.diffraction` row) and the #563 test comment (the generated API reference does not change).
93
+
94
+ ### #567 Lens catalogue: feed each lens's spherical aberration and character into bokeh
95
+
96
+ - **Changed (shipped images):** `simulateCapture` (and the display view, which reads the same PSF) with `lens.catalogue` and `lens.defocus` out of focus now convolves the lens's own bokeh kernel into its PSF instead of the plain disc of 2.17.0–2.19.0: #553's shaped kernel (`createDefocusBokehSiteKernel`) with the lens's blades, its own resolved W040 as `sphericalAberration`, and, for the fast (f/2 or faster) rectilinear wides and normals (at most 50 mm equivalent), a cat's eye. The defocus is referred to the lens's balanced focus (W020 = −W040), so the kernel is #553's for the signed blur σc − b (b = 8·N·W040/pitch). With a cat's eye each of the PSF's 5 × 5 field nodes gets its own field point's kernel (rectilinear chief-ray tangent, distortion neglected). Kernel rows are flipped to the profile's +Y-up convention. A geometric approximation, labeled as such. Requests without `lens.catalogue` + `lens.defocus`, and catalogue captures in focus or without `lens.defocus`, are byte-identical.
97
+ - **New (public field, no new root export):** `resolveGenericLens(...).character.bokeh` (`GenericLensCharacter.bokeh`, a `DefocusBokehOptions`): blades (professional 9 at curvature 0.7, consumer 7 at 0.3, circular at the widest aperture), `sphericalAberration` (the resolved W040 in µm at this f-number) and `catsEye` (one clipping circle of the widest pupil's radius, shifted so that wide open the open pupil at the frame corner is 2^−s_o, s_o the lens's optical vignetting in stops).
98
+ - **The cat's eye follows lens.catalogue's `fast` (owner speed rule; the part's `catsEye` flag was folded into the `fast` scheme at integration).** The cat's eye makes the PSF vary over the field, so the fast catalogue method would need a much finer field grid: measured 2.2–3.4× slower on the fast path (for example a whole 750 × 500 frame with *PV 35 1.8* at f/1.8: 0.69 s → 2.33 s), no measurable cost on the exact path. The exact method has it; the fast one leaves it out (assumption `fast.lens.catalogue.catsEye`). The format default decides: on for DNG, JPEG and PNG, off for sRGB samples and the display view. Blades and spherical aberration cost nothing measurable and always apply (fast path 0.31–0.42 s against 2.19.0's 0.35–0.39 s on the same regions).
99
+ - **Both catalogue methods:** the bokeh is in the PSF profile both read. The exact method interpolates it per site; the fast method resolves it on its field grid (refined automatically where the cat's eye moves the kernels' centroids) with its usual run-time check against the exact chain. Measured: fast and exact agree within 2 % of the peak on the cat's-eye point.
100
+ - **Kept:** `lens.defocus.blades`, `sphericalAberration` and `catsEye` stay refused with `lens.catalogue` (the lens sets all three; the message now says so). The combined 16-site limit is unchanged; #564's run path is not used with a catalogue lens.
101
+ - **Assumptions:** `lens.defocus` (catalogue) reports `bokeh: { catsEye, method, sphericalAberrationSites, axisOpenAreaRatio }` and its actual kernel radius; `lens.catalogue.character` reports `bokeh` (no longer under `notRendered`); the catalogue PSF profile's `responseIncludes.pupilClippingShape` is true with a cat's eye. Every catalogue capture's assumptions therefore change (its image, RAW and exposure do not without `lens.defocus`): `test/program-line-lens-range.test.ts` re-pins `catalogue50`'s assumptions hash.
102
+ - **Tests:** `test/simulate-capture-lens-catalogue-bokeh.test.ts` (values and cat's-eye rule for all 30 lenses; the flag's defaults and its refusal of a non-boolean; exact against fast; on the point grid the *PV 50 1.8* f/4 heptagon's seven-fold outline in the kernel's orientation, its f/1.8 cat's eye's centroid shift and tilt at the top-left point against #553's kernel, and the *PV 200 5.6* soft-edged behind / bright-rimmed in front). Each check fails for the 2.19.0 disc.
103
+ - **Docs:** docs/LENS_CATALOGUE.md ("Bokeh (#567)", with each value's reason), docs/QUICK_START.md, docs/USAGE.md, docs/DISPLAY_VIEW.md; API reference regenerated.
104
+ - **Bundle:** the capture-worker shape +595 B gzip (351,775 → 352,370 B; entry chunk +97 B, catalogue chunk +500 B), over the 352,000 B limit by 370 B on this branch alone; the limit is not raised (`test/capture-bundle.test.ts` fails on this branch until the integrator settles the total). The capture-only entry +95 B; the display view's added size +10 B (18,163 B, budget 18,550 B).
105
+
106
+ ### #555 Adjustable sensor characteristics and #535 noise figure
107
+
108
+ - **New (#555): sensor characteristics under `simulateCapture({ sensor })`**, each off by default (`SimulateCaptureSensorCharacteristics`): `peakQuantumEfficiency` (green EQE at 555 nm, 0.05–0.9; every channel's curve scaled, shape kept), `fullWell` (`{ electrons, link?: "pixel-area" | "none", referencePixelPitchMicrometers? }`; linked to pixel area by default), `readNoise` (`{ lowGainElectrons?, highGainElectrons?, highGainFromIso? }`, the dual-gain switch ISO), `readoutSeconds` (the rolling-shutter skew), `adcBitDepth` (12, 14 or 16; 16-bit RAW and DNG are new) and `fixedPatternNoise` (the body's synthetic pattern; refused by `simulateDisplayView`). Every value set is listed in the new assumption `sensor.characteristics`, with `chargeScale` and `sensitivityOffsetStops` (how far the output per unit exposure moves from the body's ISO rating) and a note when the full well is unusual for the pitch. A request that sets none is byte-identical to 2.19.0.
109
+ - **New (#555, #535): `result.sensor`** (`SimulateCaptureSensorReport`, also on `DisplayViewResult`): the characteristics used, the engineering dynamic range log₂(S_sat / √(σ_read² + σ_q²)) at the capture's ISO and per ISO setting (EMVA 1288 Release 4.0 temporal dark noise, limited by the charge capacity and the ADC range), and (on exact requests, `fast: false`, the exports' default; integration decision) `midGrey`, the expected SNR of an 18 % Lambertian gray patch on the green channel at the capture's exposure, split into photon shot, read, dark-current, quantization and (opt-in) fixed-pattern noise, in electrons, as a ratio and in dB. Model figures, not measurements.
110
+ - **Root exports:** 3 new types (`SimulateCaptureSensorCharacteristics`, `SimulateCaptureSensorReport`, `SimulateCaptureNoiseComponents`); no runtime export. The result gains the `sensor` field (additive).
111
+ - **Docs and tests:** docs/QUICK_START.md ("Sensor characteristics (#555)"); `test/simulate-capture-sensor-characteristics.test.ts` (QE scales the charge, full-well link and unlink, read noise and switch ISO in dark photon-transfer variance, readout skew, 16-bit DNG round trip, fixed pattern, dynamic range against the formula, mid-grey figure against the measured variance of a uniform patch, its change with exposure and ISO, the display view); API reference regenerated.
112
+ - **Bundle:** the capture-worker shape grows by 2,157 B gzip (351,775 → 353,932 B, over the 352,000 B limit), all in the new options' code and the report that `simulateCapture` computes on every capture; see the part report.
113
+
114
+ ## 2.19.0 — published November 9, 2026
115
+
116
+ A minor release of six issues: exposure time with an ND filter and generic ND presets (#578), the program line within the lens's f-number range (#583), very fast `lens.ideal` ranges (#585), developing a capture rendered in parts (#571), lens corrections in the development (#570) and the sampled-PSF orientation fix (#566). See the [2.19.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_19_0.md).
117
+
118
+ - **New:** `calculateFilteredExposureTime` and the generic ND presets `GENERIC_ND_FILTER_PROFILES` / `GENERIC_ND_FILTER_STOPS` (#578); `simulateCaptureFromParts` (#571); `output.lensCorrections` (#570); the progress stages `"diffraction-correction"` and `"sharpening"` (#571).
119
+ - **Changed:** program mode limits the generic program line to a lens range that does not hold f/4–f/16 instead of refusing it (#583); a `lens.ideal` range narrower than about f/1 now resolves in every exposure mode (#585).
120
+ - **Breaking fix (#566):** the rotationally symmetric `LensSampledPsfProfile` interpolation no longer mirrors asymmetric kernels in y (caller-supplied profiles in that mode only; no built-in lens uses it).
121
+ - **Root exports:** 10 new, 1,850 → 1,860 (runtime exports 626 → 631): 8 from #578 (4 runtime), 1 from #571 (runtime), 1 from #570 (type). `GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM` moves module (same value, still a root export).
122
+ - **Why minor:** new root exports and opt-in options; requests that 2.18.0 refused now resolve (#583, #585). #566 changes results only for caller-supplied profiles in the rotationally symmetric mode, a breaking fix the owner decided to ship in a minor release (2026-11-07, confirmed 2026-11-09). Nothing is removed, and no default changes.
123
+ - **Integration (this release):** `simulateCaptureFromParts` honors `output.lensCorrections` exactly as the whole-frame capture does (byte-identical; a part may keep or drop the option); the lens corrections run between the `diffraction-correction` and `sharpening` progress stages and report no stage of their own. 285 private copies of the same field checks and helpers in the domain parsers (for example `requireRecord` in 44 modules) now share one export each, and 35 identical private helpers found in two modules each are shared too; messages, error classes and results are unchanged.
124
+ - **Bundle:** the app's capture-worker shape (every chunk of `NATIVE_RAW_LIMITS`, `prepareDisplayView`, `serializeSimulatedCaptureDngFromParts`, `simulateCapture`, `simulateIdealReference`) is 351,775 B gzip (2.18.0: 351,859 B; limit 352,000 B, not raised). The six parts merged measured 353,811 B; sharing the duplicated helpers saved 2,036 B.
125
+
126
+ ### #566 LensSampledPsfProfile rotationally symmetric mode mirrors asymmetric PSFs in y
127
+
128
+ - **Breaking fix (owner decision 2026-11-07; confirmed 2026-11-09: a breaking fix, as this entry states):** the `rotationally-symmetric-radial-field-centroid-aligned` interpolation of `LensSampledPsfProfile` rotated each kernel as if its row index ran towards image −Y, while the environment PSF quadrature (`sensorPsfSourcePoint`) applies the row index as image +Y. At field azimuth θ the kernel was therefore turned to −θ: the y mirror of the intended kernel. It now rotates with the quadrature's convention, so an asymmetric kernel renders with the same orientation as a 2-D grid node declared at that field point.
129
+ - **Which results change:** only caller-supplied profiles that use this interpolation mode, at field points off the horizontal axis (`fieldYmm ≠ 0`), with a kernel that is not rotationally symmetric (a radial shift such as lateral color, radial elongation, coma, a decentered blur). Their `resolveLensSampledPsf` kernels (and the internal `resolveLensSampledPsfKernel`), and every capture, display view, RAW and convolution kernel built from them (`simulateCapture`, the native environment paths, `calculateSensorPsfIrradianceQuadrature`, `resolveLensPsfConvolutionKernels`), stop being mirrored in y. Kernels on the horizontal axis and rotationally symmetric kernels are unchanged (at θ = 0 and π the old and new rotations agree; a rotationally symmetric kernel is invariant). No built-in lens uses this mode (the generic lens catalogue uses the 2-D grid mode), so every hash lock holds. No API, schema or `src/gpu` change; the model id is unchanged.
130
+ - **Tests** (`test/lens-psf-interpolation.test.ts`): an asymmetric kernel (a radially shifted core plus a blob to the counter-clockwise side) resolved in the rotationally symmetric mode and as an exact 2-D grid node, at (±6, ±6) mm in all four quadrants; the light of a point source, placed through `sensorPsfSourcePoint`, has the declared radial (2.25 samples) and across-radius (0.6 samples) moments in both modes, the kernels differ by L1 < 0.25 (bilinear resampling) and the rotated kernel is far from the grid kernel's y mirror (L1 > 1). The test fails on 2.18.0. The test helpers now build kernels with the row index increasing with image +Y; the existing four-orientation rotation test, which used the old convention in its helpers, is unchanged otherwise.
131
+ - **Docs:** docs/PSF_FOUNDATION.md states the row convention of the rotation; docs/LENS_CATALOGUE.md drops the caveat; API reference regenerated (`LensSampledPsfInterpolation`).
132
+
133
+ ### #578 Exposure time with an ND filter
134
+
135
+ - **New: `calculateFilteredExposureTime({ shutterSeconds, filter, shutterDialSeconds?, wavelengthNanometers?, wavelengthBasis?, incidentPolarization? })`** (model `filtered-exposure-time` 1.0.0, for the app's Guide page on ND filters and the calculator's ND table): the shutter time that keeps the same exposure at the same f-number and ISO after adding front-of-lens filters, `shutterSeconds / T`, with `T` from the existing filter profiles through `composeFrontOfLensFilterTransmission` (one profile or a stack). Returns `filteredShutterSeconds`, `linearTransmissionFactor`, `attenuationStops`, `exposureFactor`, the full `filterTransmission` and, with a dial list, `dial` (nearest value in stops, shorter on a tie, its exposure error in stops, and `clamped` outside the dial). 1/125 s with a 10-stop filter gives 1024/125 ≈ 8.19 s, 8 s on `GENERIC_THIRD_STOP_SHUTTER_SECONDS`; an ND1000 (OD 3.0) gives 8 s. Exact under the linear sensor model (H = E·t, CIPA DC-004-2020 §5.5, EMVA 1288 §2); reciprocity failure is not modelled; nominal filters vary, and the envelope is an approximation whenever a profile is. Filters are resolved at 550 nm by default (neutral filters do not depend on it).
136
+ - **New: generic ND presets `GENERIC_ND_FILTER_PROFILES[3 | 6 | 10]` and `GENERIC_ND_FILTER_STOPS`** (owner decision 2026-11-09): frozen, unbranded front-of-lens filter profiles (`generic-nd-3-stop`, `generic-nd-6-stop`, `generic-nd-10-stop`) that transmit exactly 2^-N (1/8, 1/64, 1/1024; "N-stop" means exactly 2^-N), spectrally flat (`neutral-linear-transmission`) over 380–780 nm in air, and marked idealized (`scientificStatus: "approximation"`, uncertainty not quantified, evidence note "Idealized"). 1/125 s with the 10-stop preset gives exactly 8.192 s, 8 s on the third-stop dial.
137
+ - **Root exports:** 8 new (4 runtime): `calculateFilteredExposureTime`, `FILTERED_EXPOSURE_DEFAULT_WAVELENGTH_NANOMETERS`, `GENERIC_ND_FILTER_PROFILES`, `GENERIC_ND_FILTER_STOPS` and the types `CalculateFilteredExposureTimeInput`, `FilteredExposureTime`, `FilteredExposureDialSetting`, `GenericNdFilterStops`. Pure function and constant data; no capture, RAW or bundle change.
138
+ - **Docs and tests:** docs/PHYSICS_FOUNDATION.md ("Exposure time with an ND filter"), docs/USAGE.md (with a runnable example; later USAGE.md fragment indices in docs/validation/documentation-fragments.json shift by one), docs/AGENT_GUIDE.md, llms.txt, API reference task index; `test/filtered-exposure-time.test.ts` (including exact preset values, flatness, freezing and parser round trip).
139
+
140
+ ### #583 Program mode: the program line follows the lens's f-number range
141
+
142
+ - **Changed (requests that 2.18.0 refused now resolve):** in `exposure.mode: "program"`, `simulateCapture`, `simulateDisplayView` and `prepareDisplayView` limit the engine's generic program line (f/16 1/2000 s … f/8 1/125 s … f/4 1.024 s) to the lens's f-number range when the range does not hold f/4–f/16, where 2.18.0 refused it ("Program-line aperture lies outside the resolved equipment capability range."). This lifts the 2.18.0 limitation for `lens.ideal` `widestFNumber`/`narrowestFNumber` (#576; deferred to 2.19.0 by the owner on 2026-11-09), and applies equally to the `lens.prime` and `lens.catalogue` ranges that do not hold the line: the 800 and 1200 mm-equivalent primes, *PV 800 5.6 Pro*, *PV 1200 8 Pro*, *PV 200 5.6*, *PV 300 5.6*, *PV 400 5.6*, *PV 500 5.6*, *PV 600 6.3* and *PV 800 9*.
143
+ - **Model (an approximation; a generic line, not a manufacturer's program):** the line's f/4 and f/16 are clamped to the range, giving the same shape: the aperture held at the range's end and the shutter keeping each point's exposure (t/N², camera equation H = 0.65·L·t/N²), still from 1/2000 s to 1.024 s. Where both lines run, the aperture is the old line's clamped in log2 to the range; beyond the line's ends the resolver holds them as before (Auto ISO raises the ISO; a fixed ISO under- or overexposes, reported by `standardExposureDeviationStops`). Example: f/8–f/32 gives f/16 1/2000 s, f/16 1/125 s, f/8 1/125 s, f/8 1.024 s. The `exposure.programLine` assumption records a limited line's profile (id `"facade-generic-program-line-lens-range"`, with its nodes) (`defaultProgramLine` in `src/facade/simulate-capture.ts`).
144
+ - **Unchanged:** requests with no range, and every lens whose range holds f/4–f/16, keep the line, its assumption and their bytes (pinned SHA-256 digests of samples, RAW codes, exposure and assumptions from 3f9d65b for the tier lens, Auto ISO, `lens.ideal` without and with a holding range, `lens.prime: 50` and `pv-50-1.8`). No root export added; no `src/gpu` change. The app's capture-worker shape measured 351,989 B gzip with this part alone (2.18.0: 351,859 B; limit 352,000 B, not raised).
145
+ - **Owner decision (2026-11-09):** keep the line's 1/2000–1.024 s span; the limited line applies to `lens.prime` and `lens.catalogue` ranges as well as `lens.ideal`.
146
+ - **Tests:** `test/program-line-lens-range.test.ts` (byte pins; the limited line equals the old line clamped to the range at the same exposure at 201 points for ten ranges, from 1/2000 s to 1.024 s; program resolves within the range for seven ideal ranges at 5, 2000 and 200,000 lx; the 800 mm prime and *PV 800 9*); `test/ideal-lens.test.ts` updated (the refusal is now an exposure).
147
+ - **Docs:** docs/QUICK_START.md ("Program with a range"), docs/USAGE.md ("The capture facade's program line and the lens's f-number range").
148
+
149
+ ### #585 lens.ideal: a range narrower than about f/1 is refused in every exposure mode
150
+
151
+ - **Fix (requests that 2.18.0 refused now resolve):** a `lens.ideal` range whose narrowest f-number N0 is below about f/1 (for example f/0.5–f/0.9) was refused in every exposure mode ("referenceExposure.shutterSeconds lies outside the resolved equipment capability range."): #569's exposure anchor at the range's aperture nearest f/8 with the same N²/t needs t = 1/125 s × (N0/8)², shorter than the body's shortest shutter (1/9877 s at f/0.9 against 1/8000 s). The anchor now takes the body's shortest shutter when that t is shorter, a lower N²/t. The anchor is only where the stops are measured from (CIPA DC-004 reference luminance from the camera equation at the anchor, the meter's relative signals and the resolvers all use it), so exposures are the same as with an f/8 anchor: aperture and shutter priority at f/0.7 and 1/4000 s resolve the open ideal lens's settings to 1e-12. The #583 program line's node stops are measured from the anchor. The `exposure.referenceExposure` explanation says "the shortest shutter at f/N0" in that case. Owner decision 2026-11-09: fix in 2.19.0.
152
+ - **Unchanged:** every request that resolved before gives the same bytes (SHA-256 pins of samples, RAW codes, exposure and assumptions for ten requests, including anchors off f/8 at f/9 and f/4 and a fixed f/1 lens). The app's capture-worker shape measures 351,990 B gzip (limit 352,000 B, not raised); the fix's code was paid for by sharing the range clamp between the anchor, the program line and the f-number check, and by a shorter program-line node table.
153
+ - **Tests:** `test/program-line-lens-range.test.ts` (f/0.5–f/0.9 in program with fixed and Auto ISO, aperture priority, shutter priority and manual; the pins).
154
+ - **Docs:** docs/QUICK_START.md ("An optional f-number range"), docs/USAGE.md.
155
+
156
+ ### #571 Develop joined parts: whole-image sharpening and diffraction correction
157
+
158
+ - **New: `simulateCaptureFromParts(options, parts)`** (#571, for Photivra/apps#253's "Applying … to the whole image" step): develops the whole frame of `options` from region renders of the same request (the `SimulateCaptureDngPart` records `serializeSimulatedCaptureDngFromParts` takes, joined by the same code with the same checks), including `output.diffractionCorrection` and `output.sharpening`, which a region refuses. The joined RAW is the whole render's RAW, so `image.samples`, the JPEG or PNG bytes, `raw` and `plan` equal `simulateCapture(options)` bit for bit. Differences, documented: `diagnostics.providerEvaluationCount` is 0, `timingsMs` are the call's own (`raw` is the join), `diagnostics.fast` and the `raw.*` assumptions stay in the parts' results, and a new `parts` assumption (`{ partCount }`) records the join.
159
+ - **Progress:** `SimulateCaptureProgress.stage` gains `"diffraction-correction"` and `"sharpening"`, one event per output row of the whole image (last event `completedTileCount === totalTileCount`), reported by `simulateCapture` too when those options are on; the stages yield to the host about every 8 ms, so `signal` now cancels them. `simulateCaptureFromParts` reports no `raw` stage. Code with an exhaustive `switch` over `stage` sees two new values.
160
+ - **Memory:** the detail stages run row by row in place (`correctDiffractionInPlace`: an (R + 1)-row ring; `renderSdrWithUnsharpMaskInPlace`: each row encoded in place by the native SDR kernel, which takes `maximum` 0 for unquantized output, and a (2r + 1)-row ring of the horizontal pass), with the same sums in the same order: one developed image in memory instead of up to six. At 24 MP the whole `simulateCapture` with both stages peaked at 924 MiB (2.18.0: 3,670 MB, resident set sampled every 50 ms). Captures, files, diagnostics and assumptions of every request without `simulateCaptureFromParts` are unchanged (byte comparison of six detail requests against 2.18.0's build: JPEG, 16-bit, thresholded mask, Display P3 PNG, AHD with auto white balance and a push, a 3 × 2 image with σ = 8).
161
+ - **Measured at 24 MP** (6000 × 4000 at 6 µm, slanted edge at f/8 with `lens.diffraction`, 8 parts; Apple M5 Pro, Node.js 24.19.0, one thread, shared machine, two runs; `node scripts/benchmark-joined-development.mjs`): join and develop 1.9–2.2 s and 532–551 MiB peak without a detail stage; with sharpening 3.3–3.7 s, with diffraction correction (R = 3) 3.7–3.9 s, with both 5.1–5.4 s (development 1.2 s, correction 2.0–2.1 s, sharpening 1.7–1.8 s), 1,079–1,080 MiB peak each; the whole-frame `simulateCapture` with both took 10.0–10.4 s. The peak includes the parts' RAW and the joined RAW (7 bytes per native site each) and one 64-bit developed image (549 MiB).
162
+ - **Root exports:** 1 new (runtime), 1,850 → 1,851: `simulateCaptureFromParts` (new module `src/facade/develop-joined-parts.ts`), named in the `simulate*` family of docs/API_STYLE.md (orchestration returning a `SimulateCaptureResult`), since "develop" is not one of the documented verb families. Messages of the parts join now read "Part k: …" and "The parts …" (were "DNG part k: …" and "The DNG parts …").
163
+ - **Decisions (coordinator, 2026-11-09):** the name `simulateCaptureFromParts`; the extra Native performance workflow run that the change to `src/output/native-capture-sdr.ts` triggers is accepted; the two new progress stages count as additive (code with an exhaustive `switch` over `stage` must add them).
164
+ - **Bundle (part branch):** the app's capture-worker shape was 351,929 B gzip (2.18.0: 351,852 B; limit 352,000 B, unchanged); with `simulateCaptureFromParts` added to the worker's imports, 351,937 B. The row-by-row stages add about 250 B; reusing the native SDR kernel for the unsharp mask's encoding and sharing the facade's row-progress and wide-gamut encoding code save about 170 B.
165
+ - **Tests:** `test/simulate-capture-develop-parts.test.ts` (whole frame against joined parts byte for byte on six requests: JPEG with both stages, 16-bit with a thresholded mask, AHD with auto white balance, a push and the four-spot filter, Display P3 PNG with correction, DNG with both stages, no stage; progress order and rows; refusals and abort; the in-place stages against whole-image passes on odd sizes, R up to 16 on a 12 × 20 image).
166
+ - **Docs:** docs/QUICK_START.md ("Developing a capture rendered in parts (#571)", the `onProgress` row, the sharpening section), docs/USAGE.md; API reference regenerated.
167
+
168
+ ### #570 Development option: apply lens corrections to the JPEG
169
+
170
+ - **New: `output.lensCorrections: { distortion?, lateralColor?, vignetting? }`** (#570, for Photivra/apps#253 story 3.1), with `lens.catalogue`: the lens's generic correction profile (`createGenericLensCorrectionProfile`, #549) applied to the developed linear colour (after demosaic, colour, white balance and `diffractionCorrection`; before the tone curve and `sharpening`), for the JPEG, PNG and `image.samples`. Each correction is off unless `true`; all off (or omitted) develops byte for byte as before. **The RAW and the DNG are never corrected** (tested: identical DNG bytes).
171
+ - **Model** (labelled approximation, the new `output.lensCorrections` assumption): inverse mapping s = q(1 + k1|q|²/R²), q = m·a_c·p, bilinear, one resampling per channel; the image keeps its size and is magnified by 1/m, the smallest that leaves no missing pixel (1 for the barrel wide angles, 1.017 for *PV 300 5.6*). Vignetting is corrected to a flat field with det J/E at the source point, E the lens's relative illumination and 1/det J its distortion's area factor, which the capture applies (flat to 0.03 %; E alone would leave a *PV 16 2.8* corner about 18 % bright). The kernel matches `calculateLensCorrectedCapture` to 1e-12 for distortion and lateral colour.
172
+ - **Regions and parts:** byte-identical to the whole frame (barrel, pincushion with fill, AHD 16-bit PNG). A region develops the frame pixels its corrected pixels read (`developedRect`; `region.nativeRect`, `raw` and `plan` describe that rectangle).
173
+ - **Trade-offs measured through the option** (#552's): distortion correction × 0.706 corner detail (centre × 0.998); vignetting correction raises corner signal × 2.44 and noise × 2.50 (SNR unchanged).
174
+ - **Not offered** by `simulateDisplayView` or `simulateIdealReference` (refused by name); refused without `lens.catalogue`.
175
+ - **Cost:** about 1.3 s per 24 MP frame on one thread (Apple M5 Pro), loaded with the catalogue chunk on demand.
176
+ - **API:** one new root type, `SimulateCaptureLensCorrections`; `SIMULATE_CAPTURE_COST_OPTIONS` gains `"output.lensCorrections"`. `GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM` now lives in `src/output/developed-lens-corrections.ts` (same value, still a root export).
177
+ - **Owner decisions (2026-11-09):** zoom to fill (no missing pixels, no change of output size); the vignetting correction includes the distortion's area factor (the correction profile's own vignetting component, which holds E only, is follow-up #584); the display view refuses `output.lensCorrections` for now; the region rectangles (`developedRect` in the assumption; `region.nativeRect`, `raw` and `plan` for the developed rectangle) are accepted as they are.
178
+ - **Bundle:** on its own the part grew the app's capture-worker shape by 1,735 B gzip (entry +756 B, catalogue chunk +979 B), over the 352,000 B limit; the integration's shared helpers bring the release under it (see the 2.19.0 summary). The limit is not raised.
179
+ - **Docs and tests:** docs/DIGITAL_OPTICS_FOUNDATION.md ("Lens corrections in the development"), docs/QUICK_START.md, docs/USAGE.md; `test/simulate-capture-lens-corrections.test.ts`.
180
+
181
+ ## 2.18.0 — published November 9, 2026
4
182
 
5
183
  A minor release of four issues: equivalence numbers for teaching (#573), an ideal lens at any physical focal length and f-number (#576), catalogue lenses in the display view (#569) and fast catalogue lens captures (#568). Captures, RAW codes, files and assumptions are the same as 2.17.0 for every request that 2.17.0 renders and that uses none of the new options (every hash lock holds).
6
184
  - **New:** `calculateSensorEquivalence` and `calculateBlurShareOfFrame` (#573); `lens.ideal: { focalLengthMm, widestFNumber?, narrowestFNumber? }` and `IDEAL_LENS_LIMITS` (#576; the optional f-number range added at the owner's request, 2026-11-09); `simulateDisplayView` and `prepareDisplayView` render `lens.catalogue` (#569); a fast method for `lens.catalogue` behind `fast` or `lens.catalogue: { kind, fast }` (#568).
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.18.0"
12
+ version: "2.20.0"
13
13
  keywords:
14
14
  - photography
15
15
  - camera optics
package/NODE_SUPPORT.md CHANGED
@@ -35,17 +35,17 @@ An upstream line being eligible does not mean this repository has tested it. The
35
35
 
36
36
  This repository tests Node 22, 24 and 26. That includes the current patched release of each line and a regression job on the Node 22.13.0 minimum.
37
37
 
38
- CI picks the patched lines with setup-node aliases that move by themselves:
38
+ CI picks the patched lines with setup-node versions; the test legs use aliases that move by themselves:
39
39
 
40
40
  | Alias | Line | Role |
41
41
  | --- | --- | --- |
42
- | `lts/*` | newest LTS | Primary leg. Also runs the static, coverage, documentation and package-surface gates. |
43
- | `lts/-1` | the LTS line before it | Test leg. |
44
- | `node` | newest stable release (Current, or the newest LTS while no Current line has a stable build) | Test leg. |
42
+ | `24` | Node 24, the tooling major (pinned) | Primary leg (owner decision for 2.20.0, #231). Runs the coverage shards and the static, coverage, documentation and package-surface gates; its required check keeps the name `verify (lts/*)` (owner decision for 2.20.0: the name is a required status check of the repository's `main` ruleset, so renaming it would mean changing the ruleset). |
43
+ | `lts/-1` | the LTS line before the newest | Test leg. While it resolves to the primary leg's release (Node 24 since Node 26 entered LTS), it skips its test work; its required `verify (lts/-1)` check still runs. |
44
+ | `node` | newest stable release (Current, or the newest LTS while no Current line has a stable build) | Test leg (Node 26 since it entered LTS). |
45
45
 
46
46
  CI resolves `node` itself to the newest stable version in `https://nodejs.org/dist/index.json` (`.github/node-lts-lookahead.mjs --newest-stable`), because setup-node's alias takes the index's first entry, which can be a prerelease: `v27.0.0-alpha.0.0.0` was listed first on 2026-10-29 and every `node` job failed the guard with "Use a stable, exact Node/typings version" ([#511](https://github.com/photivra/photivra/pull/511)).
47
47
 
48
- Every leg runs the test suite and the packed-consumer acceptance check.
48
+ Every leg runs the test suite and the packed-consumer acceptance check, except a leg that resolves to exactly the primary leg's release (`node .github/node-lts-lookahead.mjs --same-as-primary`): the primary leg already runs the whole suite (under coverage) and the packed-consumer acceptance on it, so that leg skips them and its required check passes.
49
49
 
50
50
  - The minimum-version leg matches `engines.node`. It rises when that line reaches end of life.
51
51
  - The policy guard runs before installation. If an alias moves to an unreviewed or end-of-life major, CI fails instead of adopting it. An alias cannot silently widen the support set.
@@ -70,6 +70,8 @@ A broad `engines.node` range is a compatibility hint. It is not evidence of supp
70
70
 
71
71
  `.github/test-node-lts-policy.mjs` tests the acceptance and rejection boundaries, the grace period and the look-ahead against recorded release data. Both scripts run before dependency installation in every Node CI job. That includes each verification matrix leg and the `native-performance.yml` benchmark job (path-filtered and nightly).
72
72
 
73
+ `.github/node-ci-roles.mjs` derives the CI roles from the same snapshot: the exact `engines.node` minimum, the maintained LTS majors, the Current major, the primary leg (Node 24, pinned to the tooling major) and the tooling major. The `change scope` job checks the files against them on every pull request and merge-queue entry, so the matrix cannot drift from the policy unnoticed. It fails when the `test` and `verify` lines differ, when an exact-version leg is not the `engines.node` minimum, when the primary jobs or `publish.yml` leave their roles, when the alias table above differs from the matrix, when a supported line has no leg, or when the `test` job lacks the skip of a leg on the primary leg's release. It reports two legs on one runtime, a primary leg off the tooling major and a line tested only at its minimum as notices for review. `.github/test-node-ci-roles.mjs` tests it on the repository's files across the lifecycle transitions.
74
+
73
75
  The lifecycle snapshot is local and reviewed. Ordinary CI does not fetch it from the network. It uses UTC dates and fails closed on the recorded end-of-life date.
74
76
 
75
77
  Reverify the official schedule:
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.18.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_18_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.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:
36
36
 
37
37
  ```sh
38
- npm install @photivra/engine@2.18.0
38
+ npm install @photivra/engine@2.20.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.18.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.20.0` tag.
79
79
 
80
80
  ## Documentation
81
81
 
@@ -363,13 +363,13 @@ The application can then use those results, while the photography calculations t
363
363
 
364
364
  ## Status
365
365
 
366
- - Repository package version: `2.18.0` (release candidate prepared November 9, 2026; publication is separate)
367
- - Engine API contract: `2.18.0`
366
+ - Repository package version: `2.20.0` (release candidate prepared November 9, 2026; publication is separate)
367
+ - Engine API contract: `2.20.0`
368
368
  - Versioned generic tier reference assets: [GENERIC_TIER_PRESETS.md](https://github.com/Photivra/photivra/blob/main/docs/GENERIC_TIER_PRESETS.md).
369
369
  - Composed POC simulation API contract: `1.0.0`
370
370
  - Stability: 1.0 root-package compatibility policy; bounded scientific models and independent POC contract
371
371
 
372
- From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.18.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
372
+ From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.20.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
373
373
 
374
374
  A breaking change to the root contracts now needs a new package major version. Additive and corrective changes follow [compatibility and migration](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_1_3_0.md). A 1.0 package is not a claim of calibrated physical accuracy.
375
375
 
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.17.0**, published on November 8, 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.19.0**, published on November 9, 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
@@ -31,21 +31,23 @@ This inventory does not replace a transitive dependency/SBOM review before a rel
31
31
 
32
32
  ## Pending licence and source reviews
33
33
 
34
- These human reviews are **pending**. This inventory records them and the evidence for each; it does not complete them. A review is complete only when the owner signs it off: a check by an AI system is not legal evidence (`CONTRIBUTING.md`). Each is described in full in its section below.
34
+ These human reviews were **pending** until the owner signed all five off on 2026-11-09 (#544; see the status below). This inventory records them and the evidence for each. A review is complete only when the owner signs it off: a check by an AI system is not legal evidence (`CONTRIBUTING.md`). Each is described in full in its section below.
35
+
36
+ **Status on 2026-11-09.** The owner signed off all five reviews on [#544](https://github.com/Photivra/photivra/issues/544) on 2026-11-09: "castleCSF-derived contrast reference", "ReportLab/pypdf offline PDF test-artifact tools", "DNG/TIFF and JPEG sample provenance", "Colorimetry published constants", and "Transitive dependency / SBOM review" for 2.14.0–2.19.0 (from 2.20.0 that review is repeated for each release's exact lockfile, with the `npm run check:licenses` result and any new or changed licence listed in the release's pull request).
35
37
 
36
38
  **Status on 2026-11-02.** 2.14.0 was tagged and published to npm with the owner's approval ([release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_14_0.md)) while these five reviews were open. **The owner accepted 2.14.0 shipping with these reviews open, 2026-11-02.** None of them is signed off. What each still needs is tracked in [#544](https://github.com/Photivra/photivra/issues/544). The evidence below was checked on 2026-11-02 against `main` at `v2.14.0` (`f4b09a7`), using the repository's own records.
37
39
 
38
40
  | Review | Evidence checked on 2026-11-02 | Still needed from the owner |
39
41
  | --- | --- | --- |
40
- | castleCSF-derived contrast reference (`src/output/print-contrast-reference.ts`) | **Licence evidence complete.** The MIT text in `NOTICE` is identical to `docs/licenses/castleCSF-MIT.txt`. A licence-compliance pass on 2026-11-02 compared that file with upstream `LICENSE` at revision `f4b0b722af83001d7af979281e06ca642d36e4e8` through the GitHub API: identical. The adapter's header names the holder and is kept in the published `dist/output/print-contrast-reference.js`. | Sign-off of the licence summary, and the separate scientific/source review ([the model/protocol](https://github.com/Photivra/photivra/blob/main/docs/PRINT_CONTRAST_REFERENCE.md)), including whether upstream holds the model material under other terms. |
41
- | ReportLab/pypdf offline PDF test-artifact tools | The committed `output/pdf/print-proof-reference.pdf` has no `FontFile` entry and declares only `/BaseFont /Helvetica`, so no font program is embedded. The versions used are recorded as ReportLab 4.4.9 and pypdf 6.10.0 (`test/fixtures/print-detail/proof-layout-reference.json`, `proof-inspection-reference.json`, `docs/validation/print-196-candidate-evidence.json`). Neither tool is vendored or in the package. **Not verified:** the licence files of those exact versions (not installed locally). | The substantive human source review. |
42
- | 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. | The provenance/licence review. |
43
- | 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). | Sign-off for 2.14.0. The review is repeated for each release's exact lockfile. |
44
- | 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. The primary sources were not re-read in this check. | The human source/licence review of each cited source. |
42
+ | castleCSF-derived contrast reference (`src/output/print-contrast-reference.ts`) | **Licence evidence complete.** The MIT text in `NOTICE` is identical to `docs/licenses/castleCSF-MIT.txt`. A licence-compliance pass on 2026-11-02 compared that file with upstream `LICENSE` at revision `f4b0b722af83001d7af979281e06ca642d36e4e8` through the GitHub API: identical. The adapter's header names the holder and is kept in the published `dist/output/print-contrast-reference.js`. | **Signed off by the owner, 2026-11-09** (#544), after a scientific and source review: the adapter matches the pinned upstream code in every constant and step of its restricted domain (relative difference at most 3.8e-9, and 1.7e-15 against the 80-digit reference); where the paper's printed Eq. 21–22 differ from the code, the code wins ([the model/protocol](https://github.com/Photivra/photivra/blob/main/docs/PRINT_CONTRAST_REFERENCE.md)). |
43
+ | ReportLab/pypdf offline PDF test-artifact tools | The committed `output/pdf/print-proof-reference.pdf` has no `FontFile` entry and declares only `/BaseFont /Helvetica`, so no font program is embedded. The versions used are recorded as ReportLab 4.4.9 and pypdf 6.10.0 (`test/fixtures/print-detail/proof-layout-reference.json`, `proof-inspection-reference.json`, `docs/validation/print-196-candidate-evidence.json`). Neither tool is vendored or in the package. The licences of the exact versions were checked on 2026-11-09: ReportLab 4.4.9 is BSD ("BSD license", ReportLab Inc.; PyPI metadata; the `LICENSE` text was read from an unofficial mirror, unchanged since 2024-12-17) and pypdf 6.10.0 is BSD-3-Clause (PyPI metadata and the `LICENSE` at the pypdf `6.10.0` tag). The PDF embeds no library code or font program, and neither licence sets conditions on output made with the software, so no notice is owed for the generated artifact. Pillow (a ReportLab dependency, used to read the JPEG header; the bytes pass through unchanged) was also present when it was generated; its version was not recorded. | **Signed off by the owner, 2026-11-09** (#544). |
44
+ | 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
+ | 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
+ | 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). |
45
47
 
46
48
  ## Licence summaries (prepared for owner sign-off)
47
49
 
48
- Prepared on 2026-10-11 from local files only (repository files, `package-lock.json` and locally installed package metadata). These are factual summaries for the owner to sign off; they are **not** completed reviews, and every item above stays pending until the owner signs it off (see the status above: 2.14.0 was released with them open). "Not verified" means the fact could not be checked locally.
50
+ Prepared on 2026-10-11 from local files only (repository files, `package-lock.json` and locally installed package metadata). These are factual summaries for the owner to sign off; they are **not** completed reviews, and each item above stayed pending until the owner signed it off (2.14.0 was released with them open; all five were signed off on 2026-11-09). "Not verified" means the fact could not be checked locally.
49
51
 
50
52
  ### castleCSF-derived contrast reference
51
53
 
@@ -62,7 +64,7 @@ Prepared on 2026-10-11 from local files only (repository files, `package-lock.js
62
64
  - **Permissions:** both licences permit commercial use, modification and redistribution in source and binary form.
63
65
  - **Obligations:** keep the copyright notice, conditions and disclaimer on redistribution, and do not use the contributors' names for endorsement. Neither tool is vendored or redistributed here, so no notice is currently owed.
64
66
  - **Concerns:**
65
- - The installed versions are ReportLab 5.0.1 and pypdf 6.17.0. The versions recorded for the committed artifact (ReportLab 4.4.9, pypdf 6.10.0) are not installed locally, so their exact licence files are not verified.
67
+ - The licences of the exact versions were checked on 2026-11-09: ReportLab 4.4.9 is BSD ("BSD license", ReportLab Inc.; PyPI metadata; the `LICENSE` text was read from an unofficial mirror, unchanged since 2024-12-17) and pypdf 6.10.0 is BSD-3-Clause (PyPI metadata and the `LICENSE` at the pypdf `6.10.0` tag). The PDF embeds no library code or font program, and neither licence sets conditions on output made with the software, so no notice is owed for the generated artifact. Pillow (a ReportLab dependency, used to read the JPEG header; the bytes pass through unchanged) was also present when it was generated; its version was not recorded.
66
68
  - The ReportLab distribution also bundles font files, including DarkGarden (with a GPL copying file) and Vera. The committed `output/pdf/print-proof-reference.pdf` contains no `FontFile` entries and declares only `/BaseFont /Helvetica`, so no bundled font program is embedded (checked locally).
67
69
 
68
70
  ### DNG/TIFF and JPEG sample provenance
@@ -100,6 +102,8 @@ The colorimetry modules in `src/color/` (see [docs/COLORIMETRY.md](https://githu
100
102
  | 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. |
101
103
  | 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. |
102
104
  | McCamy CCT cubic | McCamy (1992), Color Res. Appl. 17(2) | Published equation coefficients. |
105
+ | CIE 1960 UCS (u, v) formulas: u = 4x/(−2x + 12y + 3), v = 6y/(−2x + 12y + 3) and their inverse (`colorimetry-math.ts`) | CIE 15 (the CIE 1960 UCS definition; paywalled, checked through the free secondary Wikipedia, "CIE 1960 color space") | Standard definitional formula constants (added to this inventory 2026-11-09, #544). |
106
+ | The 4-decimal sRGB → XYZ matrix in the GPU display-view test fixture (`src/gpu/display-view-quality-gpu.ts`, its `ahdCorrection.labMatrix`) | Derived from the ITU-R BT.709-6 primaries and the D65 white point (free); it equals the 4-decimal matrix of IEC 61966-2-1 | Standardized constants derived from free sources (added to this inventory 2026-11-09, #544). |
103
107
  | Bradford and CAT16 matrices (`chromatic-adaptation.ts`) | Lam (1985) / ICC.1:2010 Annex E; CAT16: Li et al. (2016), IS&T CIC24, Eq. (15), doi:10.2352/ISSN.2169-2629.2017.32.208 (free; checked 2026-10-17 to match the engine; journal version Li et al. 2017) | Published 3×3 model constants. CAT02, the Hunt–Pointer–Estévez matrix and the CIECAM02 degree-of-adaptation formula were removed from 2.0 (owner decisions, 2026-10-17) because no confirmed free source prints them. |
104
108
  | Primaries and white points; BT.709 and BT.1886 constants (`rgb-color-spaces.ts`) | ITU-R BT.709-6, BT.1886, BT.2020-2; W3C CSS Color 4 (`srgb`, `display-p3`); also IEC 61966-2-1, SMPTE EG 432-1 | Standardized numeric constants; every value is available from the freely published ITU-R and W3C documents. |
105
109
  | h, c, k | BIPM SI Brochure (2019) | Exact SI defining constants (facts). |
@@ -148,14 +152,14 @@ The Malvar–He–Cutler demosaic kernels and their parameters, and the BT.2408
148
152
 
149
153
  The original scalar adapter in `src/output/print-contrast-reference.ts` uses numerical equations/parameters from [castleCSF revision f4b0b722af83001d7af979281e06ca642d36e4e8](https://github.com/gfxdisp/castleCSF/tree/f4b0b722af83001d7af979281e06ca642d36e4e8): `matlab/CSF_castleCSF.m`, `CSF_stelaCSF_lum_peak.m`, `CSF_castleCSF_chrom.m` and `CSF_base.m`. The upstream code/model material is MIT-licensed, copyright (c) 2023 Graphics and Displays group - University of Cambridge. The exact license is preserved at `docs/licenses/castleCSF-MIT.txt` and its notice is retained in `NOTICE` and adapter source. New TypeScript/reference code is distributed under the project Apache-2.0 terms while retaining the MIT notice for upstream numerical material.
150
154
 
151
- No upstream observational CSV, paper text/figure/table, color-matching dataset or runtime dependency is incorporated. The model's presence does not certify camera, print, observer or natural-image visibility applicability. [The model/protocol](https://github.com/Photivra/photivra/blob/main/docs/PRINT_CONTRAST_REFERENCE.md) records the restricted envelope, independent numerical evaluation and pending human scientific/source/license review. The root still declares no runtime npm dependencies.
155
+ No upstream observational CSV, paper text/figure/table, color-matching dataset or runtime dependency is incorporated. The model's presence does not certify camera, print, observer or natural-image visibility applicability. [The model/protocol](https://github.com/Photivra/photivra/blob/main/docs/PRINT_CONTRAST_REFERENCE.md) records the restricted envelope, independent numerical evaluation and the scientific/source/licence review the owner signed off on 2026-11-09 (#544). The root still declares no runtime npm dependencies.
152
156
 
153
157
 
154
158
  ## Optional offline physical-proof tools
155
159
 
156
160
  The optional repository-only QA scripts `scripts/verify-photographic-export-fixtures.py` and `scripts/verify-producer-export-acceptance.py` load an installed LibRaw (LGPL-2.1 or CDDL-1.0) dynamically through its C API. Nothing from LibRaw is copied or distributed, and the scripts are not part of the package.
157
161
 
158
- The owned PDF geometry reference is generated with ReportLab 4.4.9 and independently inspected with pypdf 6.10.0. Their installed distribution license files were reviewed as BSD-3-Clause-style terms (ReportLab metadata calls its license BSD; pypdf declares BSD-3-Clause). These are optional offline test-artifact tools, not runtime/npm dependencies. No library source, binary or embedded font program is vendored. The PDF preserves existing owned JPEG streams and uses the PDF standard Helvetica font declaration; its DeviceRGB appearance and actual printing remain unqualified. Generator/parser versions and artifact hashes are retained in the candidate evidence record. Substantive human source review remains pending.
162
+ The owned PDF geometry reference is generated with ReportLab 4.4.9 and independently inspected with pypdf 6.10.0. The licences of the exact versions were checked on 2026-11-09: ReportLab 4.4.9 is BSD ("BSD license", ReportLab Inc.; PyPI metadata; the `LICENSE` text was read from an unofficial mirror, unchanged since 2024-12-17) and pypdf 6.10.0 is BSD-3-Clause (PyPI metadata and the `LICENSE` at the pypdf `6.10.0` tag). The PDF embeds no library code or font program, and neither licence sets conditions on output made with the software, so no notice is owed for the generated artifact. Pillow (a ReportLab dependency, used to read the JPEG header; the bytes pass through unchanged) was also present when it was generated; its version was not recorded. These are optional offline test-artifact tools, not runtime/npm dependencies. No library source, binary or embedded font program is vendored. The PDF preserves existing owned JPEG streams and uses the PDF standard Helvetica font declaration; its DeviceRGB appearance and actual printing remain unqualified. Generator/parser versions and artifact hashes are retained in the candidate evidence record. The owner signed off this review on 2026-11-09 (#544).
159
163
 
160
164
  ## Automated license policy
161
165
 
@@ -0,0 +1,32 @@
1
+ import { prepareDevelopedLensCorrection, type DevelopedLensCorrectionSelection } from "../output/developed-lens-corrections.js";
2
+ /** @internal An opcode as written (application order), for the assumptions. */
3
+ export type DngLensCorrectionOpcode =
4
+ /** k0…k4 of g = 1 + k0r² + … + k4r¹⁰, and the largest |g/G − 1| on 2 048 radii in [0, 1]. */
5
+ {
6
+ opcode: "FixVignetteRadial";
7
+ coefficients: number[];
8
+ maximumRelativeFitError: number;
9
+ }
10
+ /** kr0…kr3, kt0, kt1 of one plane (all channels) or red, green, blue; the zoom-to-fill 1/m folded into kr0. */
11
+ | {
12
+ opcode: "WarpRectilinear";
13
+ planes: number[][];
14
+ magnification: number;
15
+ };
16
+ /** @internal Largest accepted |g/G − 1| of the FixVignetteRadial fit. */
17
+ export declare const DNG_VIGNETTE_FIT_TOLERANCE = 0.001;
18
+ /**
19
+ * @internal The OpcodeList3 bytes and the opcodes for a catalogue lens (`resolveGenericLens` at the capture's f-number, on the
20
+ * W × H ActiveArea of pitch `pitchMm`) and the selected corrections, at least one on (see the module notes).
21
+ */
22
+ export declare function createDngLensCorrectionOpcodes(lens: Parameters<typeof prepareDevelopedLensCorrection>[0] & {
23
+ name: string;
24
+ character: {
25
+ vignetting: {
26
+ cornerStops: number;
27
+ };
28
+ };
29
+ }, selection: DevelopedLensCorrectionSelection, width: number, height: number, pitchMm: number, dispersion: (wavelengthNm: number) => number): {
30
+ opcodeList3: Uint8Array;
31
+ opcodes: DngLensCorrectionOpcode[];
32
+ };