@photivra/engine 2.18.0 → 2.19.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 (261) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/dist/capture/focus-control.js +2 -75
  6. package/dist/capture/focus-control.js.map +1 -1
  7. package/dist/capture/native-environment-lean.d.ts +1 -0
  8. package/dist/capture/native-environment-lean.js +1 -1
  9. package/dist/capture/native-environment-lean.js.map +1 -1
  10. package/dist/capture/native-environment-optical-stage.d.ts +1 -0
  11. package/dist/capture/native-environment-optical-stage.js +2 -5
  12. package/dist/capture/native-environment-optical-stage.js.map +1 -1
  13. package/dist/capture/native-environment-raw.js +1 -5
  14. package/dist/capture/native-environment-raw.js.map +1 -1
  15. package/dist/capture/release-sequence.js +2 -47
  16. package/dist/capture/release-sequence.js.map +1 -1
  17. package/dist/color/white-balance.d.ts +2 -0
  18. package/dist/color/white-balance.js +3 -56
  19. package/dist/color/white-balance.js.map +1 -1
  20. package/dist/composition/image-formation-plan.d.ts +1 -0
  21. package/dist/composition/image-formation-plan.js +3 -49
  22. package/dist/composition/image-formation-plan.js.map +1 -1
  23. package/dist/core/evidence-provenance.js +1 -16
  24. package/dist/core/evidence-provenance.js.map +1 -1
  25. package/dist/core/record-validation.d.ts +54 -0
  26. package/dist/core/record-validation.js +176 -0
  27. package/dist/core/record-validation.js.map +1 -1
  28. package/dist/core/scientific-assurance.js +1 -18
  29. package/dist/core/scientific-assurance.js.map +1 -1
  30. package/dist/core/shared-helpers.d.ts +33 -0
  31. package/dist/core/shared-helpers.js +34 -0
  32. package/dist/core/shared-helpers.js.map +1 -0
  33. package/dist/core/spectral-composition.js +1 -36
  34. package/dist/core/spectral-composition.js.map +1 -1
  35. package/dist/core/spectral.js +1 -19
  36. package/dist/core/spectral.js.map +1 -1
  37. package/dist/core/version.d.ts +1 -1
  38. package/dist/core/version.js +1 -1
  39. package/dist/core/version.js.map +1 -1
  40. package/dist/equipment/exposure-capabilities.d.ts +1 -0
  41. package/dist/equipment/exposure-capabilities.js +2 -36
  42. package/dist/equipment/exposure-capabilities.js.map +1 -1
  43. package/dist/equipment/iso-capabilities.js +2 -49
  44. package/dist/equipment/iso-capabilities.js.map +1 -1
  45. package/dist/equipment/metering-capabilities.js +1 -26
  46. package/dist/equipment/metering-capabilities.js.map +1 -1
  47. package/dist/equipment/release-capabilities.js +1 -53
  48. package/dist/equipment/release-capabilities.js.map +1 -1
  49. package/dist/exposure/auto-iso-slowest-shutter.js +1 -7
  50. package/dist/exposure/auto-iso-slowest-shutter.js.map +1 -1
  51. package/dist/exposure/exposure-duration-control.js +1 -16
  52. package/dist/exposure/exposure-duration-control.js.map +1 -1
  53. package/dist/exposure/exposure-mode-resolver.js +1 -17
  54. package/dist/exposure/exposure-mode-resolver.js.map +1 -1
  55. package/dist/exposure/filtered-exposure-time.d.ts +93 -0
  56. package/dist/exposure/filtered-exposure-time.js +129 -0
  57. package/dist/exposure/filtered-exposure-time.js.map +1 -0
  58. package/dist/exposure/flash-ttl.d.ts +1 -0
  59. package/dist/exposure/flash-ttl.js +1 -1
  60. package/dist/exposure/flash-ttl.js.map +1 -1
  61. package/dist/exposure/flash.js +1 -49
  62. package/dist/exposure/flash.js.map +1 -1
  63. package/dist/exposure/metering-scene-radiance.js +1 -25
  64. package/dist/exposure/metering-scene-radiance.js.map +1 -1
  65. package/dist/exposure/metering-target.js +1 -30
  66. package/dist/exposure/metering-target.js.map +1 -1
  67. package/dist/exposure/metering-temporal.js +1 -8
  68. package/dist/exposure/metering-temporal.js.map +1 -1
  69. package/dist/exposure/metering.js +1 -72
  70. package/dist/exposure/metering.js.map +1 -1
  71. package/dist/exposure/program-line.js +1 -45
  72. package/dist/exposure/program-line.js.map +1 -1
  73. package/dist/facade/develop-joined-parts.d.ts +29 -0
  74. package/dist/facade/develop-joined-parts.js +33 -0
  75. package/dist/facade/develop-joined-parts.js.map +1 -0
  76. package/dist/facade/generic-lens-capture.d.ts +4 -2
  77. package/dist/facade/generic-lens-capture.js +4 -2
  78. package/dist/facade/generic-lens-capture.js.map +1 -1
  79. package/dist/facade/simulate-capture.d.ts +37 -5
  80. package/dist/facade/simulate-capture.js +153 -96
  81. package/dist/facade/simulate-capture.js.map +1 -1
  82. package/dist/gpu/chart-composite-gpu.js +1 -3
  83. package/dist/gpu/chart-composite-gpu.js.map +1 -1
  84. package/dist/gpu/chart-radiance-gpu.js +1 -5
  85. package/dist/gpu/chart-radiance-gpu.js.map +1 -1
  86. package/dist/gpu/display-view-quality-gpu.d.ts +13 -1
  87. package/dist/gpu/display-view-quality-gpu.js +4 -8
  88. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  89. package/dist/gpu/display-view-speed-gpu.js +3 -25
  90. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  91. package/dist/gpu/sensor-stage-gpu.js +1 -5
  92. package/dist/gpu/sensor-stage-gpu.js.map +1 -1
  93. package/dist/gpu/webgpu-runtime.d.ts +2 -0
  94. package/dist/gpu/webgpu-runtime.js +3 -0
  95. package/dist/gpu/webgpu-runtime.js.map +1 -1
  96. package/dist/index.d.ts +4 -1
  97. package/dist/index.js +3 -0
  98. package/dist/index.js.map +1 -1
  99. package/dist/motion/camera-rotation.js +1 -6
  100. package/dist/motion/camera-rotation.js.map +1 -1
  101. package/dist/motion/capture-rotation-inverse-mapping.d.ts +12 -0
  102. package/dist/motion/capture-rotation-inverse-mapping.js +2 -2
  103. package/dist/motion/capture-rotation-inverse-mapping.js.map +1 -1
  104. package/dist/motion/capture-rotation-temporal-quadrature.d.ts +1 -0
  105. package/dist/motion/capture-rotation-temporal-quadrature.js +1 -1
  106. package/dist/motion/capture-rotation-temporal-quadrature.js.map +1 -1
  107. package/dist/motion/capture-rotation-trajectory.js +2 -17
  108. package/dist/motion/capture-rotation-trajectory.js.map +1 -1
  109. package/dist/motion/capture-translation-parallax-temporal-quadrature.d.ts +1 -0
  110. package/dist/motion/capture-translation-parallax-temporal-quadrature.js +3 -14
  111. package/dist/motion/capture-translation-parallax-temporal-quadrature.js.map +1 -1
  112. package/dist/motion/extended-object-projection.js +5 -40
  113. package/dist/motion/extended-object-projection.js.map +1 -1
  114. package/dist/optics/atmospheric-haze.js +1 -7
  115. package/dist/optics/atmospheric-haze.js.map +1 -1
  116. package/dist/optics/brown-conrady-distortion.js +1 -7
  117. package/dist/optics/brown-conrady-distortion.js.map +1 -1
  118. package/dist/optics/complex-pupil-psf.d.ts +1 -0
  119. package/dist/optics/complex-pupil-psf.js +2 -84
  120. package/dist/optics/complex-pupil-psf.js.map +1 -1
  121. package/dist/optics/focus-state.js +1 -11
  122. package/dist/optics/focus-state.js.map +1 -1
  123. package/dist/optics/front-of-lens-filter.d.ts +5 -0
  124. package/dist/optics/front-of-lens-filter.js +3 -65
  125. package/dist/optics/front-of-lens-filter.js.map +1 -1
  126. package/dist/optics/generic-nd-filter-presets.d.ts +12 -0
  127. package/dist/optics/generic-nd-filter-presets.js +48 -0
  128. package/dist/optics/generic-nd-filter-presets.js.map +1 -0
  129. package/dist/optics/illumination-vignetting.js +1 -6
  130. package/dist/optics/illumination-vignetting.js.map +1 -1
  131. package/dist/optics/lens-psf-profile.d.ts +4 -1
  132. package/dist/optics/lens-psf-profile.js +12 -111
  133. package/dist/optics/lens-psf-profile.js.map +1 -1
  134. package/dist/optics/longitudinal-chromatic-aberration.js +1 -7
  135. package/dist/optics/longitudinal-chromatic-aberration.js.map +1 -1
  136. package/dist/optics/pupil-vignetting.js +1 -7
  137. package/dist/optics/pupil-vignetting.js.map +1 -1
  138. package/dist/optics/radial-distortion.js +1 -6
  139. package/dist/optics/radial-distortion.js.map +1 -1
  140. package/dist/optics/scene-to-sensor-irradiance.js +4 -96
  141. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  142. package/dist/optics/stray-light.d.ts +1 -0
  143. package/dist/optics/stray-light.js +1 -1
  144. package/dist/optics/stray-light.js.map +1 -1
  145. package/dist/optics/zernike-wavefront.js +1 -7
  146. package/dist/optics/zernike-wavefront.js.map +1 -1
  147. package/dist/output/capture-geometry.js +1 -8
  148. package/dist/output/capture-geometry.js.map +1 -1
  149. package/dist/output/developed-lens-corrections.d.ts +62 -0
  150. package/dist/output/developed-lens-corrections.js +135 -0
  151. package/dist/output/developed-lens-corrections.js.map +1 -0
  152. package/dist/output/generic-lens-corrections.d.ts +2 -6
  153. package/dist/output/generic-lens-corrections.js +3 -6
  154. package/dist/output/generic-lens-corrections.js.map +1 -1
  155. package/dist/output/geometric-transforms.js +1 -4
  156. package/dist/output/geometric-transforms.js.map +1 -1
  157. package/dist/output/native-capture-sdr.d.ts +2 -2
  158. package/dist/output/native-capture-sdr.js +3 -2
  159. package/dist/output/native-capture-sdr.js.map +1 -1
  160. package/dist/processing/development-detail.d.ts +29 -0
  161. package/dist/processing/development-detail.js +128 -107
  162. package/dist/processing/development-detail.js.map +1 -1
  163. package/dist/schema/illumination-temporal.js +3 -72
  164. package/dist/schema/illumination-temporal.js.map +1 -1
  165. package/dist/schema/illumination.d.ts +1 -0
  166. package/dist/schema/illumination.js +2 -65
  167. package/dist/schema/illumination.js.map +1 -1
  168. package/dist/schema/scene-radiance-temporal.js +2 -14
  169. package/dist/schema/scene-radiance-temporal.js.map +1 -1
  170. package/dist/schema/scene-radiance.js +2 -60
  171. package/dist/schema/scene-radiance.js.map +1 -1
  172. package/dist/schema/validation.d.ts +2 -0
  173. package/dist/schema/validation.js +2 -17
  174. package/dist/schema/validation.js.map +1 -1
  175. package/dist/sensor/accumulated-charge.js +2 -59
  176. package/dist/sensor/accumulated-charge.js.map +1 -1
  177. package/dist/sensor/architecture.js +1 -16
  178. package/dist/sensor/architecture.js.map +1 -1
  179. package/dist/sensor/camera-saturation-capacity.d.ts +1 -0
  180. package/dist/sensor/camera-saturation-capacity.js +3 -55
  181. package/dist/sensor/camera-saturation-capacity.js.map +1 -1
  182. package/dist/sensor/capture-color-sampling-binding.js +1 -26
  183. package/dist/sensor/capture-color-sampling-binding.js.map +1 -1
  184. package/dist/sensor/capture-mode-timing.js +1 -39
  185. package/dist/sensor/capture-mode-timing.js.map +1 -1
  186. package/dist/sensor/capture-mode.js +1 -23
  187. package/dist/sensor/capture-mode.js.map +1 -1
  188. package/dist/sensor/color-sampling.js +1 -26
  189. package/dist/sensor/color-sampling.js.map +1 -1
  190. package/dist/sensor/constant-rate-temporal-integration.js +2 -46
  191. package/dist/sensor/constant-rate-temporal-integration.js.map +1 -1
  192. package/dist/sensor/dark-current.js +2 -45
  193. package/dist/sensor/dark-current.js.map +1 -1
  194. package/dist/sensor/eqe-electron-rate.js +2 -35
  195. package/dist/sensor/eqe-electron-rate.js.map +1 -1
  196. package/dist/sensor/exposure-window.d.ts +5 -0
  197. package/dist/sensor/exposure-window.js +7 -22
  198. package/dist/sensor/exposure-window.js.map +1 -1
  199. package/dist/sensor/iso-gain.js +1 -7
  200. package/dist/sensor/iso-gain.js.map +1 -1
  201. package/dist/sensor/iso-signal-chain.js +1 -43
  202. package/dist/sensor/iso-signal-chain.js.map +1 -1
  203. package/dist/sensor/optical-stack.js +1 -25
  204. package/dist/sensor/optical-stack.js.map +1 -1
  205. package/dist/sensor/photon-energy.js +1 -26
  206. package/dist/sensor/photon-energy.js.map +1 -1
  207. package/dist/sensor/physical-charge-capacity.js +3 -77
  208. package/dist/sensor/physical-charge-capacity.js.map +1 -1
  209. package/dist/sensor/radiometry-readiness.js +2 -23
  210. package/dist/sensor/radiometry-readiness.js.map +1 -1
  211. package/dist/sensor/raw-readout.d.ts +3 -0
  212. package/dist/sensor/raw-readout.js +4 -66
  213. package/dist/sensor/raw-readout.js.map +1 -1
  214. package/dist/sensor/raw-reconstruction.js +2 -64
  215. package/dist/sensor/raw-reconstruction.js.map +1 -1
  216. package/dist/sensor/readout-exposure-linkage.js +1 -9
  217. package/dist/sensor/readout-exposure-linkage.js.map +1 -1
  218. package/dist/sensor/readout-timing.js +3 -57
  219. package/dist/sensor/readout-timing.js.map +1 -1
  220. package/dist/sensor/response-application-compatibility.d.ts +2 -0
  221. package/dist/sensor/response-application-compatibility.js +3 -29
  222. package/dist/sensor/response-application-compatibility.js.map +1 -1
  223. package/dist/sensor/response-operating-range.d.ts +1 -0
  224. package/dist/sensor/response-operating-range.js +3 -79
  225. package/dist/sensor/response-operating-range.js.map +1 -1
  226. package/dist/sensor/responsivity-photocurrent.js +4 -108
  227. package/dist/sensor/responsivity-photocurrent.js.map +1 -1
  228. package/dist/sensor/sampling-aperture.js +1 -25
  229. package/dist/sensor/sampling-aperture.js.map +1 -1
  230. package/dist/sensor/spatial-sample-reduction.js +2 -19
  231. package/dist/sensor/spatial-sample-reduction.js.map +1 -1
  232. package/dist/sensor/spatial-sampling-quadrature.js +1 -3
  233. package/dist/sensor/spatial-sampling-quadrature.js.map +1 -1
  234. package/dist/sensor/spatio-spectral-reduction.js +1 -18
  235. package/dist/sensor/spatio-spectral-reduction.js.map +1 -1
  236. package/dist/sensor/spectral-coverage.d.ts +10 -1
  237. package/dist/sensor/spectral-coverage.js +1 -1
  238. package/dist/sensor/spectral-coverage.js.map +1 -1
  239. package/dist/sensor/spectral-quadrature.js +2 -35
  240. package/dist/sensor/spectral-quadrature.js.map +1 -1
  241. package/dist/sensor/spectral-response.js +1 -47
  242. package/dist/sensor/spectral-response.js.map +1 -1
  243. package/dist/stabilization/camera-shake.js +1 -6
  244. package/dist/stabilization/camera-shake.js.map +1 -1
  245. package/dist/stabilization/system.js +2 -80
  246. package/dist/stabilization/system.js.map +1 -1
  247. package/docs/AGENT_GUIDE.md +1 -0
  248. package/docs/API_REFERENCE.md +41 -2
  249. package/docs/QUICK_START.md +90 -8
  250. package/docs/USAGE.md +47 -2
  251. package/docs/api/core-version.md +1 -1
  252. package/docs/api/exports.json +53 -3
  253. package/docs/api/exposure-filtered-exposure-time.md +105 -0
  254. package/docs/api/facade-develop-joined-parts.md +32 -0
  255. package/docs/api/facade-simulate-capture.md +36 -5
  256. package/docs/api/optics-generic-nd-filter-presets.md +34 -0
  257. package/docs/api/optics-lens-psf-profile.md +3 -1
  258. package/docs/api/output-developed-lens-corrections.md +12 -0
  259. package/docs/api/output-generic-lens-corrections.md +0 -9
  260. package/llms.txt +1 -0
  261. package/package.json +1 -1
@@ -90,7 +90,7 @@ import { resolveAntiAliasingSpatialKernel } from "../sensor/optical-stack.js";
90
90
  import { multiplyMatrices } from "../color/colorimetry-math.js";
91
91
  import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
92
92
  import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
93
- import { applyDiffractionCorrection, defaultDiffractionCorrectionRadiusPixels, designDiffractionCorrectionFilter, parseDiffractionCorrectionSettings, parseUnsharpMaskSettings, renderSdrWithUnsharpMask, DEVELOPMENT_DETAIL_MODEL_VERSION } from "../processing/development-detail.js";
93
+ import { correctDiffractionInPlace, defaultDiffractionCorrectionRadiusPixels, designDiffractionCorrectionFilter, parseDiffractionCorrectionSettings, parseUnsharpMaskSettings, renderSdrWithUnsharpMaskInPlace, DEVELOPMENT_DETAIL_MODEL_VERSION } from "../processing/development-detail.js";
94
94
  import { parseSensorReadoutConversionProfile, resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
95
95
  import { resolveChartScene, chartMaterialInputs, CHART_SCENE_PRESETS } from "./chart-scenes.js";
96
96
  import { resolveChartMaterialLayers } from "./chart-material-layers.js";
@@ -107,7 +107,7 @@ export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
107
107
  export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
108
108
  "lens.diffraction", "lens.defocus", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
109
109
  "output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
110
- "output.diffractionCorrection", "output.sharpening", "output.format.jpeg", "output.format.png", "output.format.dng"]);
110
+ "output.diffractionCorrection", "output.sharpening", "output.lensCorrections", "output.format.jpeg", "output.format.png", "output.format.dng"]);
111
111
  /**
112
112
  * The options that take the exact-or-fast flag (#395), in the path style of {@link SIMULATE_CAPTURE_COST_OPTIONS}: each accepts
113
113
  * the record form with `fast` and is reported in `diagnostics.fast.applied` when it runs. Every one of them takes the flag,
@@ -553,20 +553,20 @@ function resolveAutomaticSlowestShutterOption(value, context) {
553
553
  "reciprocal rule is a rule of thumb and is not used.");
554
554
  return report;
555
555
  }
556
- function defaultProgramLine() {
556
+ function defaultProgramLine(wide, narrow, anchor) {
557
557
  // Generic educational program line written for the engine, relative to the f/8, 1/125 s reference. For less light
558
558
  // it first opens the aperture toward f/4, then lengthens the shutter. For more light it first closes toward
559
- // f/16, then shortens the shutter.
560
- return parseExposureProgramLineProfile({ schemaVersion: "0.1.0", profileId: "facade-generic-program-line", profileVersion: "1.0.0",
559
+ // f/16, then shortens the shutter. #583: a lens range without f/4–f/16 moves those ends to the range (wide/narrow, the line's
560
+ // f/4 and f/16 clamped to it): the same shape, the aperture held at the range's end and the shutter taking up the stops
561
+ // (stops = log2 of t/N² over the anchor's t/N²; #585 may move the anchor), still from 1/2000 s to 1.024 s.
562
+ const own = wide === 4 && narrow === 16;
563
+ return parseExposureProgramLineProfile({ schemaVersion: "0.1.0", profileId: "facade-generic-program-line" + (own ? "" : "-lens-range"), profileVersion: "1.0.0",
561
564
  scientificStatus: "approximation", policyKind: "generic-program-line", interpolation: "log2-aperture-shutter", evidence: FACADE_EVIDENCE,
562
565
  limitations: ["Generic educational program line authored for the capture facade; not any manufacturer's program."],
563
- nodes: [
564
- { nodeId: "minus-6", opticalExposureStopsFromReference: -6, aperture: 16, shutterSeconds: 1 / 2000 },
565
- { nodeId: "minus-2", opticalExposureStopsFromReference: -2, aperture: 16, shutterSeconds: 1 / 125 },
566
- { nodeId: "zero", opticalExposureStopsFromReference: 0, aperture: 8, shutterSeconds: 1 / 125 },
567
- { nodeId: "plus-2", opticalExposureStopsFromReference: 2, aperture: 4, shutterSeconds: 1 / 125 },
568
- { nodeId: "plus-9", opticalExposureStopsFromReference: 9, aperture: 4, shutterSeconds: 128 / 125 }
569
- ] });
566
+ nodes: (own ? [["minus-6", -6, 16, 1 / 2000], ["minus-2", -2, 16, 1 / 125], ["zero", 0, 8, 1 / 125], ["plus-2", 2, 4, 1 / 125], ["plus-9", 9, 4, 128 / 125]] :
567
+ [[narrow, 1 / 2000], [narrow, 1 / 125], [wide, 1 / 125], [wide, 128 / 125]].filter((_, i) => i !== 2 || wide < narrow)
568
+ .map(([aperture, shutterSeconds], i) => ["n" + i, Math.log2(shutterSeconds * anchor.aperture ** 2 / anchor.shutterSeconds / aperture ** 2), aperture, shutterSeconds]))
569
+ .map(([nodeId, opticalExposureStopsFromReference, aperture, shutterSeconds]) => ({ nodeId, opticalExposureStopsFromReference, aperture, shutterSeconds })) });
570
570
  }
571
571
  /** PNG colour signalling: the sRGB chunk for sRGB, ITU-T H.273 code points through cICP otherwise. */
572
572
  function pngColourEncoding(encoding) {
@@ -744,13 +744,13 @@ export async function serializeSimulatedCaptureDngFromParts(options, parts) {
744
744
  return await runCapture(options, "dng-from-parts", parts);
745
745
  }
746
746
  /** The whole frame's packed RAW from region parts (see `serializeSimulatedCaptureDngFromParts`). */
747
- function joinNativeRawParts(value, plan, width, height) {
747
+ function joinNativeRawParts(value, plan, width, height, caller) {
748
748
  if (!Array.isArray(value) || value.length === 0)
749
- throw new InvalidConfigurationError("serializeSimulatedCaptureDngFromParts needs at least one part.");
749
+ throw new InvalidConfigurationError(caller + " needs at least one part.");
750
750
  const parts = value, count = width * height;
751
751
  const codes = new Uint16Array(count), blackLevels = new Uint16Array(count), digitalSaturationCodes = new Uint16Array(count), saturationFlags = new Uint8Array(count);
752
752
  const covered = new Uint8Array(count), white = new Uint16Array(4);
753
- const bad = (k, what) => { throw new InvalidConfigurationError("DNG part " + k + ": " + what); };
753
+ const bad = (k, what) => { throw new InvalidConfigurationError("Part " + k + ": " + what); };
754
754
  parts.forEach((part, k) => {
755
755
  const rect = part?.region?.nativeRect, raw = part?.raw;
756
756
  if (!rect || !raw)
@@ -792,9 +792,9 @@ function joinNativeRawParts(value, plan, width, height) {
792
792
  }
793
793
  });
794
794
  if (covered.includes(0))
795
- throw new InvalidConfigurationError("The DNG parts do not cover every native site of the frame.");
795
+ throw new InvalidConfigurationError("The parts do not cover every native site of the frame.");
796
796
  if (white.includes(0))
797
- throw new InvalidConfigurationError("The DNG parts lack a white level for a CFA phase.");
797
+ throw new InvalidConfigurationError("The parts lack a white level for a CFA phase.");
798
798
  return { plan, codes, blackLevels, digitalSaturationCodes, saturationFlags, effectiveSaturationCodes: white };
799
799
  }
800
800
  /**
@@ -1027,8 +1027,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1027
1027
  }
1028
1028
  else if (lensOptions.focusScatter !== undefined)
1029
1029
  fail("lens.focusScatter needs lens.catalogue: the focus scatter is a catalogue lens's own.");
1030
- const lensRange = prime ? { id: prime.lensId, name: prime.lensId, focalLengthMm: prime.focalLengthMm, widestFNumber: prime.widestFNumber, narrowestFNumber: prime.narrowestFNumber } :
1031
- catalogue ? { id: catalogue.lensId, name: catalogue.name, focalLengthMm: catalogue.focalLengthMm, widestFNumber: catalogue.widestFNumber, narrowestFNumber: catalogue.narrowestFNumber } : ideal;
1030
+ const listed = prime ?? catalogue, lensRange = listed ? { id: listed.lensId, name: catalogue ? catalogue.name : listed.lensId, focalLengthMm: listed.focalLengthMm,
1031
+ widestFNumber: listed.widestFNumber, narrowestFNumber: listed.narrowestFNumber } : ideal;
1032
1032
  const lensExposure = lensRange ? { ...lens.exposure, profileId: "photivra-" + lensRange.id + ":exposure",
1033
1033
  focalLengthMmRange: { ...lens.exposure.focalLengthMmRange, value: { minimum: lensRange.focalLengthMm, maximum: lensRange.focalLengthMm } },
1034
1034
  aperture: { ...lens.exposure.aperture, widestAvailableFNumber: { kind: "constant", fNumber: { value: lensRange.widestFNumber, evidence: FACADE_EVIDENCE } },
@@ -1181,7 +1181,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1181
1181
  const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
1182
1182
  // Output and sensor
1183
1183
  const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "exposureCompensationStops", "whiteBalance", "jpegQuantizationStep",
1184
- "colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region", "metadata", "diffractionCorrection", "sharpening"]);
1184
+ "colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region", "metadata", "diffractionCorrection", "sharpening", "lensCorrections"]);
1185
1185
  // Opt-in large images: a caller-raised native pixel limit. Without it the 4094-pixel sides are unchanged.
1186
1186
  const raisedPixels = outputOptions.maximumPixels === undefined ? undefined : integerIn(outputOptions.maximumPixels, "output.maximumPixels", 1, NATIVE_RAW_LIMITS.ceilingPixels);
1187
1187
  const maximumSide = raisedPixels === undefined ? 4094 : NATIVE_RAW_LIMITS.maximumDimension;
@@ -1303,6 +1303,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1303
1303
  unsupported("output.highlightMode \"reconstruct\"", "the view supports the per-channel highlight clip.");
1304
1304
  if (outputOptions.diffractionCorrection !== undefined || outputOptions.sharpening !== undefined)
1305
1305
  unsupported("output.diffractionCorrection or output.sharpening", "the development detail stages filter the whole developed capture image.");
1306
+ if (outputOptions.lensCorrections !== undefined)
1307
+ unsupported("output.lensCorrections", "the corrections resample the developed capture image.");
1306
1308
  if (defocusBokeh?.catsEye)
1307
1309
  unsupported("lens.defocus.catsEye", "the expected-signal tables hold one kernel per spectral node, and the cat's eye varies over the field; " +
1308
1310
  "simulateCapture renders it. lens.defocus.blades and lens.defocus.sphericalAberration are supported.");
@@ -1355,23 +1357,46 @@ export async function runCapture(options, run, viewOption, renderView) {
1355
1357
  fail("output.demosaic \"ahd\" with mirror borders needs an output of at least 3 × 3 pixels.");
1356
1358
  // Geometry: the demosaic border (if any) on each side; output crop is the active area.
1357
1359
  const nativeWidth = width + 2 * margin, nativeHeight = height + 2 * margin;
1360
+ // Lens corrections (#570): the catalogue lens's corrections on the developed linear colour; all off is no option at all.
1361
+ let lensCorrection, lensCorrectionSelection;
1362
+ if (outputOptions.lensCorrections !== undefined) {
1363
+ const keys = ["distortion", "lateralColor", "vignetting"], o = allowKeys(outputOptions.lensCorrections, "output.lensCorrections", keys), selection = {};
1364
+ for (const key of keys) {
1365
+ if (o[key] !== undefined && typeof o[key] !== "boolean")
1366
+ fail("output.lensCorrections." + key + " must be true or false.");
1367
+ selection[key] = o[key] === true;
1368
+ }
1369
+ if (keys.some((key) => selection[key])) {
1370
+ if (idealReference)
1371
+ fail("simulateIdealReference does not take output.lensCorrections: a reference has no camera development.");
1372
+ if (!catalogue || !lensKit)
1373
+ fail("output.lensCorrections needs lens.catalogue: the corrections are the catalogue lens's own profile.");
1374
+ lensCorrectionSelection = selection;
1375
+ lensCorrection = lensKit.prepareDevelopedLensCorrection(catalogue, selection, width, height, pitch / 1000, lensKit.genericLensDispersion);
1376
+ }
1377
+ }
1358
1378
  // Region (2.3): a rectangle of the whole frame. Its native sites are the region plus the demosaic reach on each side
1359
1379
  // (clipped to the raster, where mirror borders apply exactly as in the whole render), with the origin rounded down to
1360
1380
  // the 2 × 2 CFA repeat so the region raster keeps the frame's CFA phase.
1361
- let region, regionNative;
1381
+ let region, regionNative, imageRegion;
1362
1382
  if (outputOptions.region !== undefined) {
1363
1383
  const r = allowKeys(outputOptions.region, "output.region", ["x", "y", "width", "height"]);
1364
1384
  const x = integerIn(r.x, "output.region.x", 0, width - 1), y = integerIn(r.y, "output.region.y", 0, height - 1);
1365
1385
  region = { x, y, width: integerIn(r.width, "output.region.width", 1, width - x), height: integerIn(r.height, "output.region.height", 1, height - y) };
1386
+ // #570: a corrected region develops the frame pixels its corrected pixels read.
1387
+ if (lensCorrection) {
1388
+ imageRegion = region;
1389
+ region = lensKit.developedLensCorrectionSourceRect(lensCorrection, region);
1390
+ }
1366
1391
  const reach = margin > 0 ? margin : ahd ? AHD_MARGIN : builtInDemosaic ? builtInDemosaic.kernelReachSites : MARGIN;
1367
1392
  let x0 = Math.max(0, region.x + margin - reach), y0 = Math.max(0, region.y + margin - reach);
1368
1393
  x0 -= x0 % 2;
1369
1394
  y0 -= y0 % 2;
1370
1395
  regionNative = { x: x0, y: y0, width: Math.min(nativeWidth, region.x + region.width + margin + reach) - x0, height: Math.min(nativeHeight, region.y + region.height + margin + reach) - y0 };
1371
1396
  }
1372
- const outputWidth = region ? region.width : width, outputHeight = region ? region.height : height;
1397
+ let outputWidth = region ? region.width : width, outputHeight = region ? region.height : height;
1373
1398
  if (region && regionNative)
1374
- assume("output.region", "model", { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } }, "Only this rectangle of the " + width + " × " + height + " frame is rendered. Framing, metering, exposure, diffraction kernels and every site's physics, noise seeds and exposure window (rolling-shutter row timing) are those of the whole frame (absolute native indices), " +
1399
+ assume("output.region", "model", { ...imageRegion ?? region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } }, "Only this rectangle of the " + width + " × " + height + " frame is rendered. Framing, metering, exposure, diffraction kernels and every site's physics, noise seeds and exposure window (rolling-shutter row timing) are those of the whole frame (absolute native indices), " +
1375
1400
  "and the native rectangle includes the demosaic reach on each side (" + (margin > 0 ? margin : "mirror-border") + "), so the pixels are byte-identical to the same pixels of the whole render. " +
1376
1401
  // 2.6: with lens.diffraction the convolution is limited to the region; the sentence of 2.3–2.5 is kept verbatim otherwise, so
1377
1402
  // region results without diffraction stay byte-identical to 2.5.0.
@@ -1555,13 +1580,16 @@ export async function runCapture(options, run, viewOption, renderView) {
1555
1580
  assume("exposure.compensationStops", "default", 0, "No exposure compensation.");
1556
1581
  // #569: a lens whose range excludes f/8 (the catalogue's PV 800 9, f/9–f/32; an ideal lens with a narrowed range) takes the same reference
1557
1582
  // exposure at the aperture of its range nearest f/8, N0 with t = 1/125 s × (N0/8)², which has the same N²/t and so the same reference
1558
- // luminance; every other request keeps f/8, 1/125 s.
1559
- const referenceAperture = lensRange ? Math.min(lensRange.narrowestFNumber, Math.max(lensRange.widestFNumber, DEFAULTS.fNumber)) : DEFAULTS.fNumber;
1560
- const reference = { aperture: referenceAperture, shutterSeconds: referenceAperture === DEFAULTS.fNumber ? DEFAULTS.shutterSeconds :
1561
- DEFAULTS.shutterSeconds * (referenceAperture / DEFAULTS.fNumber) ** 2, iso: DEFAULTS.iso };
1583
+ // luminance; every other request keeps f/8, 1/125 s. #585: when that t is shorter than the body's shortest shutter (N0 below about f/1),
1584
+ // the anchor takes the shortest shutter instead, a lower N²/t; the meter, the resolvers and the program line all measure from the anchor.
1585
+ // An f-number clamped to the lens's range (#583 limits the program line's f/4 and f/16 with it).
1586
+ const clamp = (n) => lensRange ? Math.min(lensRange.narrowestFNumber, Math.max(lensRange.widestFNumber, n)) : n, referenceAperture = clamp(DEFAULTS.fNumber);
1587
+ // (N0/8)² is exactly 1 at f/8, so f/8 keeps 1/125 s exactly.
1588
+ const sameShutter = DEFAULTS.shutterSeconds * (referenceAperture / DEFAULTS.fNumber) ** 2;
1589
+ const reference = { aperture: referenceAperture, shutterSeconds: Math.max(sameShutter, capabilities.shutter.minimumSeconds), iso: DEFAULTS.iso };
1562
1590
  const referenceLuminance = sosReferenceLuminance(reference.aperture, reference.shutterSeconds, reference.iso);
1563
1591
  assume("exposure.referenceExposure", "model", { ...reference, meteredLuminanceCdPerSquareMeter: referenceLuminance }, "Exposure anchor: f/8, 1/125 s, ISO 100 gives the CIPA DC-004 standard output sensitivity reference exposure H = 10/S = 0.1 lx·s for an average scene luminance of 10·N²/(0.65·t·S) ≈ 1230.77 cd/m² (camera equation H = 0.65·L·t/N²)" +
1564
- (referenceAperture === DEFAULTS.fNumber ? "." : "; the lens excludes f/8: the same N²/t at f/" + referenceAperture + "."));
1592
+ (referenceAperture === DEFAULTS.fNumber ? "." : "; the lens excludes f/8: " + (reference.shutterSeconds > sameShutter ? "the shortest shutter" : "the same N²/t") + " at f/" + referenceAperture + "."));
1565
1593
  const meterProfile = body.metering[0].profile, zones = 16, zoneSamples = [];
1566
1594
  let luminanceSum = 0;
1567
1595
  for (let j = 0; j < zones; j++)
@@ -1593,7 +1621,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1593
1621
  let resolution;
1594
1622
  let fNumber, shutterSeconds;
1595
1623
  const checkFNumber = () => {
1596
- if (lensRange && (fNumber < lensRange.widestFNumber || fNumber > lensRange.narrowestFNumber))
1624
+ if (clamp(fNumber) !== fNumber)
1597
1625
  fail("lens.fNumber " + fNumber + " is outside the " + lensRange.name + " range f/" + lensRange.widestFNumber + "–f/" + lensRange.narrowestFNumber + ".");
1598
1626
  };
1599
1627
  if (mode === "manual" || mode === "aperture-priority") {
@@ -1695,8 +1723,11 @@ export async function runCapture(options, run, viewOption, renderView) {
1695
1723
  { kind: "manual", iso, apertureQuantizationPolicy: "nearest-log2-narrower-on-tie" } });
1696
1724
  }
1697
1725
  else {
1698
- assume("exposure.programLine", "model", "facade-generic-program-line", "Engine-authored generic program line (f/16 1/2000 s … f/8 1/125 s … f/4 1.024 s); not a manufacturer program.");
1699
- resolution = resolveProgramAutoExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, programLine: defaultProgramLine(),
1726
+ // #583: a lens range without f/4–f/16 limits the line to it; every other request keeps the line and its assumption.
1727
+ const programLine = defaultProgramLine(clamp(4), clamp(16), reference);
1728
+ // A limited line (#583) records its nodes under the profile id "facade-generic-program-line-lens-range".
1729
+ assume("exposure.programLine", "model", programLine.nodes.length > 4 ? programLine.profileId : programLine, "Engine-authored generic program line (f/16 1/2000 s … f/8 1/125 s … f/4 1.024 s); not a manufacturer program.");
1730
+ resolution = resolveProgramAutoExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, programLine,
1700
1731
  isoControl: automaticIso ? { kind: "automatic", ...autoIsoBaseline, ...limitsPolicy, ...(autoIsoShutterPolicy ? { slowestPreferredShutter: { ...autoIsoShutterPolicy } } : {}) } :
1701
1732
  { kind: "manual", iso } });
1702
1733
  }
@@ -2483,9 +2514,10 @@ export async function runCapture(options, run, viewOption, renderView) {
2483
2514
  // pupil rays or caller PSF) takes its expected electrons from the tabulated signal, verified per CFA phase against the chain.
2484
2515
  const tabulatedScene = chart !== undefined || callerScene?.kind === "material-function";
2485
2516
  // #391: a DNG from region parts takes the whole frame's RAW from the parts instead of rendering it.
2486
- if (run === "dng-from-parts" && regionNative)
2487
- fail("serializeSimulatedCaptureDngFromParts writes the whole frame: the options take no output.region.");
2488
- const dngParts = run === "dng-from-parts" ? viewOption : undefined;
2517
+ // #571: simulateCaptureFromParts takes the RAW from the parts in the same way, then develops it as a capture does.
2518
+ const dngParts = run === "dng-from-parts" || run === "develop-from-parts" ? viewOption : undefined, partsCaller = run === "dng-from-parts" ? "serializeSimulatedCaptureDngFromParts" : "simulateCaptureFromParts";
2519
+ if (dngParts !== undefined && regionNative)
2520
+ fail(partsCaller + " takes the whole frame: the options take no output.region.");
2489
2521
  let tabulated;
2490
2522
  // #568: lens.catalogue's fast method: tabulated expected electrons with the PSF blended over a field grid (catalogue-lens-signal.ts), for a
2491
2523
  // still camera, site-centre queries and a chart or material scene; verified per CFA phase against the exact chain within its tolerance.
@@ -2544,8 +2576,11 @@ export async function runCapture(options, run, viewOption, renderView) {
2544
2576
  electrons: createDisplayViewExpectedSignal(signalInput), onTile: rawTick, tileWidth, yieldControl: () => yieldControl(), checkAbort });
2545
2577
  }
2546
2578
  let rawOutput;
2547
- if (dngParts !== undefined)
2548
- rawOutput = { raw: joinNativeRawParts(dngParts, plan, nativeWidth, nativeHeight), providerEvaluationCount: 0 };
2579
+ if (dngParts !== undefined) {
2580
+ rawOutput = { raw: joinNativeRawParts(dngParts, plan, nativeWidth, nativeHeight, partsCaller), providerEvaluationCount: 0 };
2581
+ if (run === "develop-from-parts")
2582
+ assume("parts", "model", { partCount: dngParts.length }, "The whole render's RAW, joined from region renders of this request (#571); how they were rendered is in their results.");
2583
+ }
2549
2584
  else if (tabulated && catalogueFastRun) {
2550
2585
  rawOutput = { raw: tabulated.raw, providerEvaluationCount: tabulated.providerEvaluationCount };
2551
2586
  fastApplied["lens.catalogue"] = true;
@@ -2642,7 +2677,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2642
2677
  assume("output.dng", "model", { writer: "serializeNativeRawDng", schemaVersion: dng.schemaVersion, whiteLevel: dng.whiteLevel, blackLevels: dng.blackLevels,
2643
2678
  noiseProfile: dng.noiseProfile, unrepresentedSaturationFlagCount: dng.unrepresentedSaturationFlagCount }, "Lossless uncompressed 16-bit CFA DNG of exactly the packed native RAW codes: per-phase BlackLevel, WhiteLevel = smallest per-phase effective white, ActiveArea and DefaultCrop from the capture geometry, ColorMatrix1/ForwardMatrix1 from the facade's diagonal camera→XYZ profile (D65), AsShotNeutral from the white-balance intent, BaselineExposure 0, and NoiseProfile from each channel's readout regime (conversion gain ÷ digital gain, combined read noise; Poisson + Gaussian + quantization only). No linear master or developed image is declared; saturation flags below WhiteLevel are not representable and are counted.");
2644
2679
  }
2645
- if (dngParts !== undefined)
2680
+ if (run === "dng-from-parts")
2646
2681
  return dng;
2647
2682
  // Development
2648
2683
  const development = { phaseProfiles, colorProfile,
@@ -2675,60 +2710,94 @@ export async function runCapture(options, run, viewOption, renderView) {
2675
2710
  "Below f_c the gain is bounded, so attenuated detail is only partly restored and noise is amplified by up to the measured responsePeakGain. " +
2676
2711
  "Not inverted: the pixel aperture, optical anti-aliasing filter, demosaic blur, defocus and aberrations. One design wavelength for all channels (the simulated blur is polychromatic).");
2677
2712
  }
2678
- const correctLinear = (rgb) => {
2679
- if (!diffractionFilter)
2680
- return rgb;
2681
- const corrected = applyDiffractionCorrection(rgb, outputWidth, outputHeight, diffractionFilter);
2682
- detailDiagnostics.diffractionCorrection = { ...corrected.diagnostics, cutoffCyclesPerPixel: diffractionFilter.cutoffCyclesPerPixel,
2683
- responsePeakGain: diffractionFilter.responsePeakGain, responseMaximumBeyondCutoff: diffractionFilter.responseMaximumBeyondCutoff };
2684
- return corrected.samples;
2713
+ // #570: the lens corrections after the diffraction correction (which acts on the sensor's own lattice), before the tone curve.
2714
+ let cornerStops = null;
2715
+ if (lensCorrection) {
2716
+ const s = lensCorrectionSelection, c = lensCorrection, k = (channel) => c.scales[channel];
2717
+ if (s.vignetting)
2718
+ cornerStops = lensKit.resolveGenericLens({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber }).character.vignetting.cornerStops;
2719
+ assume("output.lensCorrections", "model", { profileId: "photivra-" + catalogue.lensId + ":corrections", ...s, k1: c.k1, channelScales: { red: k(0), green: k(1), blue: k(2) },
2720
+ cornerStops, cornerGain: cornerStops === null ? 1 : 2 ** cornerStops, magnification: 1 / c.fill, ...(imageRegion ? { developedRect: region } : {}), dng: "uncorrected" }, "The catalogue lens's correction profile (createGenericLensCorrectionProfile; an approximation) on the developed linear colour: pixel p reads channel c bilinearly at q(1 + k1|q|²/R²), q = a_c·p/magnification " +
2721
+ "(the smallest magnification ≥ 1 that keeps every source in the frame), times det J/E at the source, so a flat field stays flat (E = (1 − bρ²)², 1/det J the distortion's area factor). " +
2722
+ "Corners lose resolution, corner noise rises with the gain, axial colour stays. The DNG is not corrected.");
2723
+ }
2724
+ // Row progress of a development stage, yielding to the host about every 8 ms. #571: the detail stages run row by row in place
2725
+ // (one developed image in memory) and report each output row.
2726
+ const rowProgress = (stage) => {
2727
+ let lastYield = Date.now();
2728
+ return async (completed, total) => {
2729
+ onProgress?.({ stage, completedTileCount: completed, totalTileCount: total });
2730
+ if (Date.now() - lastYield >= 8) {
2731
+ await yieldControl();
2732
+ checkAbort();
2733
+ lastYield = Date.now();
2734
+ }
2735
+ };
2736
+ };
2737
+ const detailRows = async (stage, rows) => {
2738
+ const tick = rowProgress(stage);
2739
+ let step = rows.next();
2740
+ for (; !step.done; step = rows.next())
2741
+ await tick(step.value, outputHeight);
2742
+ return step.value;
2743
+ };
2744
+ const correctLinear = async (rgb) => {
2745
+ if (diffractionFilter) {
2746
+ const corrected = await detailRows("diffraction-correction", correctDiffractionInPlace(rgb, outputWidth, outputHeight, diffractionFilter));
2747
+ detailDiagnostics.diffractionCorrection = { ...corrected, cutoffCyclesPerPixel: diffractionFilter.cutoffCyclesPerPixel,
2748
+ responsePeakGain: diffractionFilter.responsePeakGain, responseMaximumBeyondCutoff: diffractionFilter.responseMaximumBeyondCutoff };
2749
+ }
2750
+ if (lensCorrection) {
2751
+ const into = imageRegion ?? { x: 0, y: 0, width, height };
2752
+ rgb = lensKit.applyDevelopedLensCorrection(lensCorrection, rgb, region ?? into, into, cornerStops);
2753
+ outputWidth = into.width;
2754
+ outputHeight = into.height;
2755
+ }
2756
+ return rgb;
2685
2757
  };
2686
2758
  if (sharpening)
2687
2759
  assume("output.sharpening", "model", { model: "development-detail", modelVersion: DEVELOPMENT_DETAIL_MODEL_VERSION, method: "unsharp-mask", ...sharpening,
2688
2760
  kernelRadiusPixels: Math.ceil(3 * sharpening.radiusPixels), domain: "srgb-encoded", stage: "after the tone curve, gamut handling and sRGB transfer; before quantization", edgeHandling: "clamp" }, "Unsharp mask y = x + amount·(x − G_σ∗x) where |x − G_σ∗x| ≥ threshold (the GIMP and ImageMagick unsharp mask: radius, amount, threshold), per channel on the sRGB-encoded values in [0, 1], " +
2689
2761
  "separable Gaussian truncated at ⌈3σ⌉ with clamped edges, then clipped to [0, 1] and rounded. It raises contrast at edges and creates halos (overshoot and undershoot; see diagnostics.detail.sharpening) " +
2690
2762
  "and sharpens noise above the threshold. It adds no optical detail.");
2691
- if (detailRequested)
2763
+ if (detailRequested || lensCorrection)
2692
2764
  assume("output.developmentOrder", "model", [...(exposureGain === 1 ? [] : ["development exposure push (RAW)"]), "demosaic", "colour and white balance", ...(diffractionCorrectionOption ? ["diffraction correction (linear)"] : []),
2765
+ ...(lensCorrection ? ["lens corrections (linear)"] : []),
2693
2766
  "rendering exposure and tone curve", "gamut handling", "transfer function", ...(sharpening ? ["unsharp mask"] : []), "quantization"], "Development detail order: the deconvolution acts on linear light, where the blur is a convolution; the unsharp mask acts on the encoded values just before quantization." +
2694
2767
  (exposureGain === 1 ? "" : " A development exposure push (output.exposureCompensationStops) scales the black-subtracted, white-normalized RAW first, so pushed sites clip at white before the deconvolution sees them."));
2695
- if (ahd || (detailRequested && !linearPath)) {
2696
- let lastYield = Date.now();
2697
- const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, async (completed, total) => {
2698
- onProgress?.({ stage: "development", completedTileCount: completed, totalTileCount: total });
2699
- if (Date.now() - lastYield >= 8) {
2700
- await yieldControl();
2701
- checkAbort();
2702
- lastYield = Date.now();
2768
+ // Wide gamut: linear sRGB/D65 → target primaries (perceptual gamut mapping when asked), encoded.
2769
+ const encodeLinear = (rgb, highlight) => {
2770
+ const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
2771
+ const encoded = encodeSdrFromLinear(rgb, colorEncoding, pngBitDepth, mapping);
2772
+ if (mapping)
2773
+ gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
2774
+ adjustedPixelCount: encoded.adjustedPixelCount, lightnessClippedPixelCount: encoded.lightnessClippedPixelCount };
2775
+ return { raw: rawOutput.raw, integerSamples: encoded.samples, highlight, diagnostics: encoded.diagnostics };
2776
+ };
2777
+ if (ahd || ((detailRequested || lensCorrection) && !linearPath)) {
2778
+ const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, rowProgress("development"), ahd ? { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } } : undefined);
2779
+ const rgb = await correctLinear(linear.rgb);
2780
+ if (!linearPath) {
2781
+ let codes, counts;
2782
+ if (sharpening) {
2783
+ const rendered = await detailRows("sharpening", renderSdrWithUnsharpMaskInPlace(rgb, outputWidth, outputHeight, rendering, sharpening));
2784
+ detailDiagnostics.sharpening = rendered.sharpening;
2785
+ ({ codes, counts } = rendered);
2786
+ }
2787
+ else {
2788
+ // The native development's SDR kernel, row by row, on the AHD-developed linear sRGB.
2789
+ const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
2790
+ codes = rendering.bitDepth === 8 ? new Uint8Array(outputWidth * outputHeight * 3) : new Uint16Array(outputWidth * outputHeight * 3);
2791
+ counts = { negative: 0, above: 0, changed: 0, low: 0, high: 0, outOfGamut: false };
2792
+ for (let y = 0; y < outputHeight; y++)
2793
+ renderSdrRunInto(rgb, y * outputWidth * 3, outputWidth, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * outputWidth * 3, scratch, counts);
2703
2794
  }
2704
- }, ahd ? { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } } : undefined);
2705
- const rgb = correctLinear(linear.rgb);
2706
- if (!linearPath && sharpening) {
2707
- const rendered = renderSdrWithUnsharpMask(rgb, outputWidth, outputHeight, rendering, sharpening);
2708
- detailDiagnostics.sharpening = rendered.sharpening;
2709
- developed = { raw: rawOutput.raw, integerSamples: rendered.codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: rendered.counts.negative,
2710
- renderingAboveReferenceSampleCount: rendered.counts.above, toneChangedSampleCount: rendered.counts.changed, gamutClippedLowSampleCount: rendered.counts.low,
2711
- gamutClippedHighSampleCount: rendered.counts.high, captureSaturation: "consumed-in-raw-development" } };
2712
- }
2713
- else if (!linearPath) {
2714
- // The native development's SDR kernel, row by row, on the AHD-developed linear sRGB.
2715
- const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
2716
- const codes = rendering.bitDepth === 8 ? new Uint8Array(outputWidth * outputHeight * 3) : new Uint16Array(outputWidth * outputHeight * 3);
2717
- const counts = { negative: 0, above: 0, changed: 0, low: 0, high: 0, outOfGamut: false };
2718
- for (let y = 0; y < outputHeight; y++)
2719
- renderSdrRunInto(rgb, y * outputWidth * 3, outputWidth, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * outputWidth * 3, scratch, counts);
2720
2795
  developed = { raw: rawOutput.raw, integerSamples: codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: counts.negative,
2721
2796
  renderingAboveReferenceSampleCount: counts.above, toneChangedSampleCount: counts.changed, gamutClippedLowSampleCount: counts.low,
2722
2797
  gamutClippedHighSampleCount: counts.high, captureSaturation: "consumed-in-raw-development" } };
2723
2798
  }
2724
- else {
2725
- const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
2726
- const encoded = encodeSdrFromLinear(rgb, colorEncoding, pngBitDepth, mapping);
2727
- developed = { raw: rawOutput.raw, integerSamples: encoded.samples, highlight: linear.highlight, diagnostics: encoded.diagnostics };
2728
- if (mapping)
2729
- gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
2730
- adjustedPixelCount: encoded.adjustedPixelCount, lightnessClippedPixelCount: encoded.lightnessClippedPixelCount };
2731
- }
2799
+ else
2800
+ developed = encodeLinear(rgb, linear.highlight);
2732
2801
  }
2733
2802
  else if (!linearPath) {
2734
2803
  const devTask = createNativeRawDevelopmentTask({ ...development, raw: rawOutput.raw }, { yieldControl,
@@ -2738,22 +2807,10 @@ export async function runCapture(options, run, viewOption, renderView) {
2738
2807
  }
2739
2808
  else {
2740
2809
  // Wide gamut: the native development operator up to linear sRGB/D65 (unclipped), then XYZ → target primaries.
2741
- let lastYield = Date.now();
2742
- const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, async (completed, total) => {
2743
- onProgress?.({ stage: "development", completedTileCount: completed, totalTileCount: total });
2744
- if (Date.now() - lastYield >= 8) {
2745
- await yieldControl();
2746
- checkAbort();
2747
- lastYield = Date.now();
2748
- }
2749
- });
2810
+ const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, rowProgress("development"));
2750
2811
  {
2751
- const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
2752
- const encoded = encodeSdrFromLinear(correctLinear(linear.rgb), colorEncoding, pngBitDepth, mapping);
2753
- developed = { raw: rawOutput.raw, integerSamples: encoded.samples, highlight: linear.highlight, diagnostics: encoded.diagnostics };
2754
- if (mapping) {
2755
- gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
2756
- adjustedPixelCount: encoded.adjustedPixelCount, lightnessClippedPixelCount: encoded.lightnessClippedPixelCount };
2812
+ developed = encodeLinear(await correctLinear(linear.rgb), linear.highlight);
2813
+ if (perceptual) {
2757
2814
  assume("output.gamutMapping", "model", { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION, colorSpace: colorEncoding }, "prepareRgbGamutMapping: CIELAB L* and hue held, chroma above 90 % of the boundary chroma C_max(L*, h) compressed with a tanh knee so it never reaches C_max (Morovič 2008, §10). In-gamut colours below the knee are unchanged; saturated in-gamut colours above it are desaturated slightly. L* ≥ 100 (above the RAW white) maps to white. CIELAB hue is not perceptually constant for saturated blues, so they can shift toward purple.");
2758
2815
  }
2759
2816
  assume("output.colorEncoding.conversion", "model", { from: "linear-srgb-d65 (unclipped)", via: "CIE XYZ", to: colorEncoding, gamutHandling: perceptual ? "cielab-hue-preserving-chroma-compression" : "clip-components",
@@ -2812,14 +2869,14 @@ export async function runCapture(options, run, viewOption, renderView) {
2812
2869
  ...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
2813
2870
  ...(highlightMode === "reconstruct" ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
2814
2871
  ...(perceptual ? ["output.gamutMapping.perceptual"] : []), ...(diffractionFilter ? ["output.diffractionCorrection"] : []),
2815
- ...(sharpening ? ["output.sharpening"] : []), ...(format === "jpeg" || format === "png" || format === "dng" ? ["output.format." + format] : [])]);
2872
+ ...(sharpening ? ["output.sharpening"] : []), ...(lensCorrection ? ["output.lensCorrections"] : []), ...(format === "jpeg" || format === "png" || format === "dng" ? ["output.format." + format] : [])]);
2816
2873
  // #552: the detail stages' contracts, in execution order inside the development.
2817
- const detailContracts = diffractionFilter ? ["designDiffractionCorrectionFilter", "applyDiffractionCorrection"] : [], sdrContract = sharpening ? "renderSdrWithUnsharpMask" : "SDR rendering kernel";
2874
+ const detailContracts = [...(diffractionFilter ? ["designDiffractionCorrectionFilter", "applyDiffractionCorrection"] : []), ...(lensCorrection ? ["createGenericLensCorrectionProfile (developed linear colour)"] : [])], sdrContract = sharpening ? "renderSdrWithUnsharpMask" : "SDR rendering kernel";
2818
2875
  return Object.freeze({
2819
2876
  facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
2820
2877
  image: { width: outputWidth, height: outputHeight, format, samples: developed.integerSamples, ...(jpeg ? { jpeg } : {}), ...(png ? { png, colorEncoding: colorEncoding } : {}), ...(dng ? { dng: dng.bytes } : {}) },
2821
2878
  raw: developed.raw, plan: developmentPlan,
2822
- ...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } } } : {}),
2879
+ ...(region && regionNative ? { region: { ...imageRegion ?? region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } } } : {}),
2823
2880
  request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
2824
2881
  ...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" :
2825
2882
  defocusImageSpace ? "image-space-convolution" : "environment-psf-quadrature" } } : {}) },
@@ -2846,7 +2903,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2846
2903
  ...(builtInDemosaic ? ["createBuiltInBayerDemosaicProfiles"] : []), ...(ahd ? ["createAhdDemosaicTask"] : []),
2847
2904
  ...(ahd ? ["native RAW development operator (colour, after AHD)", ...detailContracts, ...(linearPath ? ["resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : [sdrContract])] :
2848
2905
  linearPath ? ["native RAW development operator (linear, pre-clip)", ...detailContracts, "resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] :
2849
- detailRequested ? ["native RAW development operator (linear, pre-clip)", ...detailContracts, sdrContract] : ["createNativeRawDevelopmentTask"]),
2906
+ detailRequested || lensCorrection ? ["native RAW development operator (linear, pre-clip)", ...detailContracts, sdrContract] : ["createNativeRawDevelopmentTask"]),
2850
2907
  ...(jpeg ? ["createPrintJpegTask"] : []), ...(png ? ["serializePngImage"] : []), ...(dng ? ["resolveSensorReadoutRegime", "serializeNativeRawDng"] : [])],
2851
2908
  limitations: ["Educational approximation: synthetic tier sensor values, idealized colorimetric sensor, planar Lambertian scene and point optics; not a calibrated camera emulation.",
2852
2909
  "Not offered by the facade: atmospheric haze and polarizers (the scene-radiance provider contract declares no volumetric transport or polarization), flash, video, HDR brackets and processing operators; compose those APIs directly."] },