@photivra/engine 2.12.0 → 2.14.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 (148) hide show
  1. package/CHANGELOG.md +144 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +7 -7
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +3 -0
  6. package/dist/camera/lens-projection.js +2 -1
  7. package/dist/camera/lens-projection.js.map +1 -1
  8. package/dist/capture/dng-import.js +1 -1
  9. package/dist/capture/dng-import.js.map +1 -1
  10. package/dist/capture/export-jpeg.d.ts +1 -1
  11. package/dist/capture/export-jpeg.js +6 -4
  12. package/dist/capture/export-jpeg.js.map +1 -1
  13. package/dist/capture/host-yield-cadence.js +1 -1
  14. package/dist/capture/host-yield-cadence.js.map +1 -1
  15. package/dist/capture/native-environment-optical-stage.js +1 -1
  16. package/dist/capture/native-environment-optical-stage.js.map +1 -1
  17. package/dist/capture/native-raw.js +1 -1
  18. package/dist/capture/native-raw.js.map +1 -1
  19. package/dist/capture/packed-sensor-raw.js +3 -3
  20. package/dist/capture/packed-sensor-raw.js.map +1 -1
  21. package/dist/capture/sensor-raw-producer.js +1 -1
  22. package/dist/capture/sensor-raw-producer.js.map +1 -1
  23. package/dist/color/capture-color.js +3 -3
  24. package/dist/color/capture-color.js.map +1 -1
  25. package/dist/color/chromatic-adaptation.js +2 -2
  26. package/dist/color/chromatic-adaptation.js.map +1 -1
  27. package/dist/color/color-matching-functions.js +1 -1
  28. package/dist/color/color-matching-functions.js.map +1 -1
  29. package/dist/color/colorimetry-math.js +2 -2
  30. package/dist/color/colorimetry-math.js.map +1 -1
  31. package/dist/color/illuminants.js +3 -3
  32. package/dist/color/illuminants.js.map +1 -1
  33. package/dist/color/rgb-color-spaces.js +1 -1
  34. package/dist/color/rgb-color-spaces.js.map +1 -1
  35. package/dist/color/sensor-color-development.js +1 -1
  36. package/dist/color/sensor-color-development.js.map +1 -1
  37. package/dist/core/record-validation.js +3 -3
  38. package/dist/core/record-validation.js.map +1 -1
  39. package/dist/core/version.d.ts +1 -1
  40. package/dist/core/version.js +1 -1
  41. package/dist/core/version.js.map +1 -1
  42. package/dist/equipment/generic-prime-lenses.js +3 -3
  43. package/dist/equipment/generic-prime-lenses.js.map +1 -1
  44. package/dist/equipment/generic-tier-assets.d.ts +23 -1
  45. package/dist/equipment/generic-tier-assets.js +58 -30
  46. package/dist/equipment/generic-tier-assets.js.map +1 -1
  47. package/dist/equipment/generic-tier-presets.d.ts +6 -3
  48. package/dist/equipment/generic-tier-presets.js +26 -13
  49. package/dist/equipment/generic-tier-presets.js.map +1 -1
  50. package/dist/exposure/auto-iso-slowest-shutter.d.ts +120 -0
  51. package/dist/exposure/auto-iso-slowest-shutter.js +217 -0
  52. package/dist/exposure/auto-iso-slowest-shutter.js.map +1 -0
  53. package/dist/exposure/exposure-mode-resolver.d.ts +57 -1
  54. package/dist/exposure/exposure-mode-resolver.js +238 -33
  55. package/dist/exposure/exposure-mode-resolver.js.map +1 -1
  56. package/dist/exposure/flash-ttl.js +1 -1
  57. package/dist/exposure/flash-ttl.js.map +1 -1
  58. package/dist/exposure/flash.js +1 -1
  59. package/dist/exposure/flash.js.map +1 -1
  60. package/dist/facade/chart-material-layers.js +2 -2
  61. package/dist/facade/chart-material-layers.js.map +1 -1
  62. package/dist/facade/chart-radiance-batch.js +1 -1
  63. package/dist/facade/chart-radiance-batch.js.map +1 -1
  64. package/dist/facade/chart-scenes.d.ts +45 -8
  65. package/dist/facade/chart-scenes.js +101 -44
  66. package/dist/facade/chart-scenes.js.map +1 -1
  67. package/dist/facade/color-patches.d.ts +129 -0
  68. package/dist/facade/color-patches.js +209 -0
  69. package/dist/facade/color-patches.js.map +1 -0
  70. package/dist/facade/diffraction-provider.js +12 -13
  71. package/dist/facade/diffraction-provider.js.map +1 -1
  72. package/dist/facade/display-view-gpu.js +1 -1
  73. package/dist/facade/display-view-gpu.js.map +1 -1
  74. package/dist/facade/display-view.d.ts +27 -0
  75. package/dist/facade/display-view.js +234 -65
  76. package/dist/facade/display-view.js.map +1 -1
  77. package/dist/facade/scene-radiance.js +2 -2
  78. package/dist/facade/scene-radiance.js.map +1 -1
  79. package/dist/facade/simulate-capture.d.ts +139 -12
  80. package/dist/facade/simulate-capture.js +209 -36
  81. package/dist/facade/simulate-capture.js.map +1 -1
  82. package/dist/facade/simulate-display-view.js +2 -2
  83. package/dist/facade/simulate-display-view.js.map +1 -1
  84. package/dist/gpu/chart-composite-gpu.js +2 -2
  85. package/dist/gpu/chart-composite-gpu.js.map +1 -1
  86. package/dist/gpu/display-view-composite-material-module.js +1 -1
  87. package/dist/gpu/display-view-composite-material-module.js.map +1 -1
  88. package/dist/gpu/display-view-quality-gpu.d.ts +2 -1
  89. package/dist/gpu/display-view-quality-gpu.js +24 -14
  90. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  91. package/dist/gpu/image-kernel-execution.js +2 -2
  92. package/dist/gpu/image-kernel-execution.js.map +1 -1
  93. package/dist/gpu/temporal-sum-gpu.js +23 -23
  94. package/dist/gpu/webgpu-runtime.js +1 -1
  95. package/dist/gpu/webgpu-runtime.js.map +1 -1
  96. package/dist/gpu/wgsl-chart-composite-kernel.js +318 -318
  97. package/dist/gpu/wgsl-chart-composite-kernel.js.map +1 -1
  98. package/dist/gpu/wgsl-chart-kernel.js +117 -117
  99. package/dist/gpu/wgsl-chart-kernel.js.map +1 -1
  100. package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +13 -1
  101. package/dist/gpu/wgsl-display-view-quality-kernel.js +522 -488
  102. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
  103. package/dist/gpu/wgsl-display-view-speed-kernel.js +248 -248
  104. package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
  105. package/dist/gpu/wgsl-kernels.js +203 -203
  106. package/dist/gpu/wgsl-kernels.js.map +1 -1
  107. package/dist/gpu/wgsl-sensor-kernel.js +302 -302
  108. package/dist/gpu/wgsl-sensor-kernel.js.map +1 -1
  109. package/dist/index.d.ts +8 -5
  110. package/dist/index.js +6 -3
  111. package/dist/index.js.map +1 -1
  112. package/dist/motion/temporal-exposure-sum.js +1 -1
  113. package/dist/motion/temporal-exposure-sum.js.map +1 -1
  114. package/dist/optics/diffraction-psf-profile.js +4 -4
  115. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  116. package/dist/optics/front-of-lens-filter.js +1 -1
  117. package/dist/optics/front-of-lens-filter.js.map +1 -1
  118. package/dist/optics/irradiance-convolution.d.ts +10 -0
  119. package/dist/optics/irradiance-convolution.js +49 -14
  120. package/dist/optics/irradiance-convolution.js.map +1 -1
  121. package/dist/output/native-capture-sdr.js +1 -1
  122. package/dist/output/native-capture-sdr.js.map +1 -1
  123. package/dist/output/png-encoder.js +2 -2
  124. package/dist/output/png-encoder.js.map +1 -1
  125. package/dist/output/print-jpeg.js +13 -10
  126. package/dist/output/print-jpeg.js.map +1 -1
  127. package/dist/output/text-metadata.d.ts +22 -2
  128. package/dist/output/text-metadata.js +58 -9
  129. package/dist/output/text-metadata.js.map +1 -1
  130. package/dist/sensor/fixed-pattern-noise.js +1 -1
  131. package/dist/sensor/fixed-pattern-noise.js.map +1 -1
  132. package/dist/sensor/readout-exposure-linkage.js +1 -1
  133. package/dist/sensor/readout-exposure-linkage.js.map +1 -1
  134. package/docs/API_REFERENCE.md +49 -1
  135. package/docs/QUICK_START.md +104 -9
  136. package/docs/USAGE.md +241 -2
  137. package/docs/api/core-version.md +1 -1
  138. package/docs/api/equipment-generic-tier-assets.md +41 -1
  139. package/docs/api/equipment-generic-tier-presets.md +11 -3
  140. package/docs/api/exports.json +172 -2
  141. package/docs/api/exposure-auto-iso-slowest-shutter.md +139 -0
  142. package/docs/api/exposure-exposure-mode-resolver.md +68 -0
  143. package/docs/api/facade-chart-scenes.md +36 -5
  144. package/docs/api/facade-color-patches.md +150 -0
  145. package/docs/api/facade-simulate-capture.md +140 -9
  146. package/docs/api/output-text-metadata.md +34 -0
  147. package/llms.txt +1 -0
  148. package/package.json +2 -2
@@ -41,6 +41,7 @@ import { resolveGenericIsoSignalChain } from "../sensor/iso-signal-chain.js";
41
41
  import { meterRelativeExposure } from "../exposure/metering.js";
42
42
  import { createExposureMeterTargetFromMeteringResult, setExposureCompensationOnMeterTarget } from "../exposure/metering-target.js";
43
43
  import { resolveManualExposureMode, resolveAperturePriorityExposureMode, resolveAperturePriorityAutoIsoExposureMode, resolveShutterPriorityExposureMode, resolveProgramAutoExposureMode } from "../exposure/exposure-mode-resolver.js";
44
+ import { estimateAutoIsoSlowestShutter, GENERIC_HANDHELD_ANGULAR_VELOCITY_RAD_PER_SEC } from "../exposure/auto-iso-slowest-shutter.js";
44
45
  import { parseExposureProgramLineProfile } from "../exposure/program-line.js";
45
46
  import { resolveCaptureGeometry } from "../output/capture-geometry.js";
46
47
  import { estimateCie1931ColorMatchingFunctions } from "../color/color-matching-functions.js";
@@ -90,13 +91,14 @@ import { resolveChartMaterialLayers } from "./chart-material-layers.js";
90
91
  import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, resolveChartSizeOption, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W, planarProviderMaterialOfDirection } from "./scene-radiance.js";
91
92
  /** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
92
93
  export const SIMULATE_CAPTURE_FACADE_VERSION = "1.0.0";
94
+ export const SIMULATE_CAPTURE_METERING_MODES = /* @__PURE__ */ Object.freeze(["scene-average", "grey-card"]);
93
95
  /** Default midpoint samples per axis of `sensor.pixelAperture: "box"`. */
94
96
  export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
95
97
  /**
96
98
  * Options reported in `diagnostics.options` (2.1), in this order, when they ran: the ones that change how much work
97
99
  * a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
98
100
  */
99
- export const SIMULATE_CAPTURE_COST_OPTIONS = Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
101
+ export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
100
102
  "lens.diffraction", "lens.defocus", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
101
103
  "output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
102
104
  "output.format.jpeg", "output.format.png", "output.format.dng"]);
@@ -105,10 +107,10 @@ export const SIMULATE_CAPTURE_COST_OPTIONS = Object.freeze(["lens.pupilSampling"
105
107
  * the record form with `fast` and is reported in `diagnostics.fast.applied` when it runs. Every one of them takes the flag,
106
108
  * whether or not it has a fast method yet; one without runs exact and reports `false`.
107
109
  */
108
- export const SIMULATE_CAPTURE_FAST_OPTIONS = Object.freeze(["lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "output.demosaic.ahd"]);
109
- export const FACADE_EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
110
+ export const SIMULATE_CAPTURE_FAST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "output.demosaic.ahd"]);
111
+ export const FACADE_EVIDENCE = /* @__PURE__ */ Object.freeze([/* @__PURE__ */ Object.freeze({ sourceOrigin: "photivra",
110
112
  sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
111
- const DEFAULTS = Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
113
+ const DEFAULTS = /* @__PURE__ */ Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
112
114
  subjectDistanceM: 5, width: 64, height: 64, format: "srgb-8", toneCurve: "identity", highlightMode: "clip",
113
115
  whiteBalance: "scene-illuminant", jpegQuantizationStep: 2, pixelPitchMicrometers: 6, spectralStepNanometers: 20,
114
116
  temporalSampleCount: 1, noiseSeed: 1, illuminantKelvin: 6500 });
@@ -137,6 +139,33 @@ function standardExposureDeviationStops(fNumber, shutterSeconds, iso, meteredLum
137
139
  meteredLuminance + " cd/m²).");
138
140
  return deviation;
139
141
  }
142
+ /**
143
+ * The illuminance at which the settings give the standard exposure, E·L_ref/L (= E·2^Δ). A reflective scene's radiance is
144
+ * reflectance × k(E)·S(λ)/π with k linear in E (calculateIlluminantIrradianceScale), so the metered L is proportional to E
145
+ * and re-metering at this illuminance gives Δ = 0 up to rounding. A radiance-function scene ignores E, and a black scene
146
+ * has no standard exposure: null.
147
+ */
148
+ function standardExposureIlluminanceLux(fNumber, shutterSeconds, iso, meteredLuminance, illuminanceLux, radianceScalesWithIlluminance) {
149
+ if (!radianceScalesWithIlluminance || !(meteredLuminance > 0))
150
+ return null;
151
+ const lux = illuminanceLux * sosReferenceLuminance(fNumber, shutterSeconds, iso) / meteredLuminance;
152
+ if (!Number.isFinite(lux))
153
+ throw new InvalidScientificResultError("exposure.standardExposureIlluminanceLux is not finite (metered scene luminance " +
154
+ meteredLuminance + " cd/m²).");
155
+ return lux;
156
+ }
157
+ /**
158
+ * 2.14: deviation, in stops, of the settings from the standard exposure of an 18 % Lambertian grey card under illuminance E:
159
+ * Δ_grey = log2(L_ref(N, t, S) / (0.18·E/π)), computed as log2((π·L_ref/0.18) / E) so that at the default settings the
160
+ * numerator is SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX in the same operations, and the default request gives exactly 0.
161
+ * The grey card's luminance L = ρ·E/π is that of a Lambertian reflector (luminance = illuminance × reflectance / π).
162
+ */
163
+ function greyCardDeviationStops(fNumber, shutterSeconds, iso, illuminanceLux) {
164
+ const deviation = Math.log2(Math.PI * sosReferenceLuminance(fNumber, shutterSeconds, iso) / GREY_CARD_REFLECTANCE / illuminanceLux);
165
+ if (!Number.isFinite(deviation))
166
+ throw new InvalidScientificResultError("exposure.greyCardDeviationStops is not finite (illuminance " + illuminanceLux + " lux).");
167
+ return deviation;
168
+ }
140
169
  /**
141
170
  * Default illuminance, lux: an 18 % Lambertian grey card (L = 0.18·E/π) then gets exactly the CIPA DC-004 standard
142
171
  * output sensitivity reference exposure H = 10/S at f/8, 1/125 s, ISO 100:
@@ -268,7 +297,7 @@ export function apertureNodeOffsets(n) {
268
297
  const AIRY_THIRD_ZERO_PER_LAMBDA_N = 10.173468135062722 / Math.PI;
269
298
  /** Default temporal midpoints when the camera rotates (2.0 motion option). */
270
299
  const ROTATING_TEMPORAL_SAMPLE_COUNT = 8;
271
- const IDS = Object.freeze({ frame: "facade-frame", mode: "native-still", cfa: "photivra-generic-bayer", binding: "facade-native-cfa",
300
+ const IDS = /* @__PURE__ */ Object.freeze({ frame: "facade-frame", mode: "native-still", cfa: "photivra-generic-bayer", binding: "facade-native-cfa",
272
301
  sceneState: "facade-scene-state", scene: "facade-scene", provider: "facade-provider", lights: "facade-lights", materials: "facade-materials",
273
302
  capture: "facade-capture", spectral: "facade-colorimetric-eqe", sampling: "facade-sampling", stack: "facade-no-aa", stackAa: "facade-aa",
274
303
  stationarity: "facade-stationarity", operatingState: "facade-operating-state", dark: "facade-dark", completeness: "facade-completeness",
@@ -280,8 +309,8 @@ export const CHANNELS = ["red", "green", "blue"];
280
309
  * proportional to CIE XYZ (the Luther–Ives condition holds, up to quadrature error), and the camera→XYZ
281
310
  * matrix is diagonal.
282
311
  */
283
- const EQE_SCALE = Object.freeze({ red: 0.5, green: 0.5, blue: 0.25 });
284
- const CMF_KEY = Object.freeze({ red: "x", green: "y", blue: "z" });
312
+ const EQE_SCALE = /* @__PURE__ */ Object.freeze({ red: 0.5, green: 0.5, blue: 0.25 });
313
+ const CMF_KEY = /* @__PURE__ */ Object.freeze({ red: "x", green: "y", blue: "z" });
285
314
  export const CAMERA_TO_XYZ = Object.freeze([[EQE_SCALE.green / EQE_SCALE.red, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.green / EQE_SCALE.blue]].map((r) => Object.freeze(r)));
286
315
  const XYZ_TO_CAMERA = [[EQE_SCALE.red / EQE_SCALE.green, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.blue / EQE_SCALE.green]];
287
316
  /** Synthetic dark current per site at the operating temperature, e⁻/s. */
@@ -289,7 +318,7 @@ export const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
289
318
  const OPERATING_TEMPERATURE_C = 20;
290
319
  export function fail(message) { throw new InvalidConfigurationError(message); }
291
320
  /** Where `output.metadata` goes in each file format (reported in the `output.metadata` assumption). */
292
- const TEXT_METADATA_LAYOUT = Object.freeze({
321
+ const TEXT_METADATA_LAYOUT = /* @__PURE__ */ Object.freeze({
293
322
  jpeg: "XMP packet in APP1 (dc:description, xmp:CreatorTool, dc:rights, exif:UserComment), plus a COM segment holding the comment",
294
323
  png: "One tEXt (printable Latin-1) or iTXt (UTF-8) chunk per field, keywords Description, Software, Copyright, Comment, before the first IDAT",
295
324
  dng: "ImageDescription (statement, then the description) and Copyright when printable ASCII; an XMP packet in tag 700 with every field"
@@ -374,6 +403,19 @@ function luminanceOf(radiance) {
374
403
  }
375
404
  return PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W * sum;
376
405
  }
406
+ /**
407
+ * 2.14: the capabilities with the ISO grid cut to its whole stops (ISO 100·2^k, as the 2.0.0 tier bodies list them), for
408
+ * Auto ISO's default `isoSteps: "whole-stop"`. A whole-stop grid is returned unchanged, so 2.0.0 records resolve as before.
409
+ */
410
+ function wholeStopIsoCapabilities(capabilities) {
411
+ const grid = capabilities.iso.settingGrid;
412
+ if (grid.kind !== "discrete-values")
413
+ return capabilities;
414
+ const whole = grid.values.filter((iso) => Number.isInteger(Math.log2(iso / 100)));
415
+ if (whole.length === grid.values.length || whole.length === 0)
416
+ return capabilities;
417
+ return { ...capabilities, iso: { ...capabilities.iso, settingGrid: { kind: "discrete-values", values: Object.freeze(whole) } } };
418
+ }
377
419
  /** The white balance measured from a perfect white diffuser under the scene illuminant ("scene-illuminant"). */
378
420
  export function sceneIlluminantWhiteBalance(illuminant) {
379
421
  const xyz = illuminantXyz(illuminant), camera = XYZ_TO_CAMERA.map((row) => row[0] * xyz[0] + row[1] * xyz[1] + row[2] * xyz[2]);
@@ -412,16 +454,87 @@ export function bilinearPhaseProfiles() {
412
454
  * #516: the result's Auto ISO report, read from a resolved Auto ISO resolution (every mode's resolved Auto ISO branch carries
413
455
  * `idealIsoBeforeConstraints`, `isoResolution` and a resolved `targetResidual`; aperture priority adds its fallback flag).
414
456
  */
415
- function autoIsoReport(resolution, iso, aperturePriority) {
457
+ function autoIsoReport(resolution, iso, shutterPolicy, limits, automatic, isoSteps) {
416
458
  const r = resolution;
459
+ // Program reports its hold under slowestPreferredShutter (#504); aperture priority at the top level.
460
+ const program = r.slowestPreferredShutter;
417
461
  const ideal = r.idealIsoBeforeConstraints, residual = r.targetResidual?.residualStops, clamped = r.isoResolution?.clamped;
418
462
  if (typeof ideal !== "number" || !Number.isFinite(ideal) || typeof residual !== "number" || !Number.isFinite(residual) || r.targetResidual?.status !== "resolved" ||
419
463
  (clamped !== false && clamped !== "minimum" && clamped !== "maximum"))
420
464
  throw new InvalidScientificResultError("Auto ISO resolution did not report a finite ideal ISO and residual.");
421
465
  // The resolvers' Auto ISO range: the body's Auto ISO bounds, else its ISO range.
422
466
  return { idealIsoBeforeConstraints: ideal, autoIsoMinimum: iso.autoIsoMinimum ?? iso.minimum, autoIsoMaximum: iso.autoIsoMaximum ?? iso.maximum, clamped,
423
- limitingConstraint: r.targetResidual.limitingConstraint, residualStops: residual,
424
- ...(aperturePriority ? { ...aperturePriority, afterMaximumIsoFallbackUsed: r.afterMaximumIsoFallbackUsed === true } : {}) };
467
+ ...(limits ? { limits: { ...limits } } : {}), limitingConstraint: r.targetResidual.limitingConstraint, residualStops: residual,
468
+ ...(shutterPolicy ? { ...shutterPolicy, afterMaximumIsoFallbackUsed: (program ? program.afterMaximumIsoFallbackUsed : r.afterMaximumIsoFallbackUsed) === true } : {}),
469
+ ...(program ? { slowestPreferredShutterApplied: program.applied === true } : {}),
470
+ ...(automatic ? { automaticSlowestShutter: automatic } : {}), ...(isoSteps ? { isoSteps } : {}) };
471
+ }
472
+ /**
473
+ * #505: `exposure.autoIso.limits`, the photographer's Auto ISO range. Both limits are selectable ISOs inside the body's
474
+ * Auto ISO range, `minimum` at most `maximum`; anything else fails (the resolvers check the same, but an option defect
475
+ * is a configuration error here). `minimum` defaults to the body's Auto ISO minimum.
476
+ */
477
+ function parseAutoIsoLimitsOption(value, iso) {
478
+ const record = allowKeys(value, "exposure.autoIso.limits", ["maximum", "minimum"]);
479
+ if (record.maximum === undefined)
480
+ fail("exposure.autoIso.limits.maximum is required: the highest ISO Auto ISO may choose.");
481
+ const bodyMinimum = iso.autoIsoMinimum ?? iso.minimum, bodyMaximum = iso.autoIsoMaximum ?? iso.maximum;
482
+ const limit = (field) => {
483
+ const v = positive(record[field], "exposure.autoIso.limits." + field);
484
+ if (v < bodyMinimum || v > bodyMaximum)
485
+ fail("exposure.autoIso.limits." + field + " (ISO " + v + ") must be within the body's Auto ISO range, ISO " + bodyMinimum + " to " + bodyMaximum + ".");
486
+ if (iso.settingGrid.kind === "discrete-values" && !iso.settingGrid.values.includes(v))
487
+ fail("exposure.autoIso.limits." + field + " (ISO " + v + ") must be a selectable ISO of the body: " + iso.settingGrid.values.join(", ") + ".");
488
+ return v;
489
+ };
490
+ const maximum = limit("maximum"), minimum = record.minimum === undefined ? bodyMinimum : limit("minimum");
491
+ if (minimum > maximum)
492
+ fail("exposure.autoIso.limits.minimum (ISO " + minimum + ") must not exceed exposure.autoIso.limits.maximum (ISO " + maximum + ").");
493
+ return { minimum, maximum };
494
+ }
495
+ /**
496
+ * #504: `exposure.autoIso.slowestPreferredShutterSeconds: "auto"`: the slowest preferred shutter from the camera-shake model
497
+ * (`estimateAutoIsoSlowestShutter`) for this lens, focus and pixel pitch, within the body's shutter range. Option defects are
498
+ * configuration errors; the defaults are recorded as `default` assumptions and the derivation as a `model` assumption.
499
+ */
500
+ function resolveAutomaticSlowestShutterOption(value, context) {
501
+ const path = "exposure.autoIso.automaticSlowestShutter";
502
+ const record = allowKeys(value, path, ["fasterByStops", "blurBudgetPixels", "angularVelocityRadPerSec", "stabilizationStopsEquivalent"]);
503
+ const fasterByStops = context.pick(record, "fasterByStops", path + ".fasterByStops", 0, "No bias: the derived slowest shutter is used as is.");
504
+ if (typeof fasterByStops !== "number" || !Number.isInteger(fasterByStops) || Math.abs(fasterByStops) > 2)
505
+ fail(path + ".fasterByStops must be a whole number of stops from -2 to +2.");
506
+ const blurBudgetPixels = positive(context.pick(record, "blurBudgetPixels", path + ".blurBudgetPixels", 1, "A generic pixel-level blur budget: the shake trace during the exposure may be one pixel long. A choice, not a measured threshold."), path + ".blurBudgetPixels");
507
+ const angularOption = context.pick(record, "angularVelocityRadPerSec", path + ".angularVelocityRadPerSec", { ...GENERIC_HANDHELD_ANGULAR_VELOCITY_RAD_PER_SEC }, "Generic hand shake: 4.39 °/s (0.0766 rad/s) on yaw, the linear camera-motion rate Xiao, Farrell, Catrysse and Wandell (Proc. SPIE 7250, 2009) use for an average user " +
508
+ "holding a 100 g mobile handset with two hands. A labelled generic default, not a measurement of this photographer or camera.");
509
+ const angular = allowKeys(angularOption, path + ".angularVelocityRadPerSec", ["yaw", "pitch"]);
510
+ for (const axis of ["yaw", "pitch"]) {
511
+ const v = angular[axis];
512
+ if (typeof v !== "number" || !Number.isFinite(v) || Math.abs(v) > 100)
513
+ fail(path + ".angularVelocityRadPerSec." + axis + " must be a finite number within ±100 rad/s.");
514
+ }
515
+ const angularVelocityRadPerSec = { yaw: angular.yaw, pitch: angular.pitch };
516
+ if (angularVelocityRadPerSec.yaw === 0 && angularVelocityRadPerSec.pitch === 0)
517
+ fail(path + ".angularVelocityRadPerSec must be non-zero on at least one axis: without shake no slowest shutter follows.");
518
+ const stabilizationStopsEquivalent = context.pick(record, "stabilizationStopsEquivalent", path + ".stabilizationStopsEquivalent", 0, "No stabilization: the generic tiers declare none.");
519
+ if (typeof stabilizationStopsEquivalent !== "number" || !Number.isFinite(stabilizationStopsEquivalent) || stabilizationStopsEquivalent < 0 || stabilizationStopsEquivalent > 10)
520
+ fail(path + ".stabilizationStopsEquivalent must be a number of stops from 0 to 10.");
521
+ let estimate;
522
+ try {
523
+ estimate = estimateAutoIsoSlowestShutter({ focalLengthMm: context.focalLengthMm, focusDistanceM: context.focusDistanceM, angularVelocityRadPerSec,
524
+ stabilizationStopsEquivalent, blurBudget: { kind: "pixels", pixels: blurBudgetPixels, pixelPitchMicrometers: context.pitch }, fasterByStops,
525
+ shutterCapability: context.shutter }).value;
526
+ }
527
+ catch (error) {
528
+ fail(path + ": " + (error instanceof Error ? error.message : String(error)));
529
+ }
530
+ const report = { derivedShutterSeconds: estimate.derivedShutterSeconds, fasterByStops,
531
+ biasedShutterSeconds: estimate.biasedShutterSeconds, shutterSeconds: estimate.shutterSeconds, blurBudgetPixels, blurAtShutterPixels: estimate.blurAtShutterPixels,
532
+ angularVelocityRadPerSec, stabilizationStopsEquivalent };
533
+ context.assume("exposure.autoIso.automaticSlowestShutter", "model", { ...report, angularVelocityRadPerSec: { ...angularVelocityRadPerSec },
534
+ focalLengthMm: context.focalLengthMm, focusDistanceM: context.focusDistanceM, pixelPitchMicrometers: context.pitch }, "Automatic slowest shutter (#504): the exposure time at which estimateCameraShakeBlur()'s on-axis blur (constant angular velocity, thin-lens projection at the focus " +
535
+ "distance, stabilization as 2^-stops) equals the blur budget, times 2^-fasterByStops, held within the body's shutter range. An approximation; the 1/focal-length " +
536
+ "reciprocal rule is a rule of thumb and is not used.");
537
+ return report;
425
538
  }
426
539
  function defaultProgramLine() {
427
540
  // Generic educational program line written for the engine, relative to the f/8, 1/125 s reference. For less light
@@ -1217,7 +1330,11 @@ export async function runCapture(options, run, viewOption, renderView) {
1217
1330
  registerInternalRadianceAccess(provider.evaluateRadiance, { keyOfDirection: materialOfDirection, radianceOfKey: provider.materialRadiance, tabulated: true });
1218
1331
  assume("model.scene-radiometry", "model", "lambertian-plane", "Radiance = reflectance × irradiance / π on one plane; illuminance from K_m = 683 lm/W and the engine's analytic CIE 1931 ȳ fit.");
1219
1332
  // Metering (CIPA DC-004 standard output sensitivity and camera equation) and exposure-mode resolution
1220
- const exposureOptions = allowKeys(top.exposure, "exposure", ["mode", "iso", "shutterSeconds", "compensationStops", "autoIso"]);
1333
+ const exposureOptions = allowKeys(top.exposure, "exposure", ["mode", "iso", "shutterSeconds", "compensationStops", "autoIso", "metering"]);
1334
+ // 2.14: no default assumption when omitted, so 2.13 requests keep their assumptions; the result reports `exposure.metering`.
1335
+ const metering = exposureOptions.metering === undefined ? "scene-average" : exposureOptions.metering;
1336
+ if (!SIMULATE_CAPTURE_METERING_MODES.includes(metering))
1337
+ fail('exposure.metering must be "scene-average" or "grey-card".');
1221
1338
  const mode = pick(exposureOptions, "mode", "exposure.mode", "manual", "Manual exposure.");
1222
1339
  if (!["manual", "aperture-priority", "shutter-priority", "program"].includes(mode))
1223
1340
  fail("Unknown exposure mode.");
@@ -1227,8 +1344,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1227
1344
  if (typeof isoOption === "string" && !automaticIso)
1228
1345
  fail('exposure.iso must be a positive finite number or "auto".');
1229
1346
  const iso = automaticIso ? NaN : positive(isoOption, "exposure.iso");
1230
- if (exposureOptions.autoIso !== undefined && !(automaticIso && mode === "aperture-priority"))
1231
- fail('exposure.autoIso applies only to exposure.mode "aperture-priority" with exposure.iso "auto"; omit it.');
1347
+ if (exposureOptions.autoIso !== undefined && !automaticIso)
1348
+ fail('exposure.autoIso applies only with exposure.iso "auto"; omit it.');
1232
1349
  const compensation = exposureOptions.compensationStops === undefined ? DEFAULTS.compensationStops : exposureOptions.compensationStops;
1233
1350
  if (typeof compensation !== "number" || !Number.isFinite(compensation) || Math.abs(compensation) > 10)
1234
1351
  fail("exposure.compensationStops must be within ±10.");
@@ -1249,6 +1366,15 @@ export async function runCapture(options, run, viewOption, renderView) {
1249
1366
  }
1250
1367
  const meteredLuminance = luminanceSum / (zones * zones);
1251
1368
  assume("exposure.metering", "model", { policy: meterProfile.policy.kind, zones: zones * zones }, "The body's " + meterProfile.policy.kind + " meter reads scene luminance on a 16 × 16 zone grid, before the lens (no lens transmission, vignetting or flare).");
1369
+ if (metering === "grey-card") {
1370
+ // 2.14: incident metering. Every zone reads the 18 % Lambertian grey card under the request's illuminance, L = 0.18·E/π.
1371
+ if (callerScene?.kind === "radiance-function")
1372
+ fail('exposure.metering "grey-card" needs a scene lit by the illuminance; a radiance-function scene ignores illuminanceLux.');
1373
+ const cardLuminance = GREY_CARD_REFLECTANCE * illuminanceLux / Math.PI;
1374
+ for (const sample of zoneSamples)
1375
+ sample.relativeLinearSignal = meterProfile.target.targetRelativeSignal * cardLuminance / referenceLuminance;
1376
+ assume("exposure.meteringSource", "model", { metering, greyCardReflectance: GREY_CARD_REFLECTANCE, luminanceCdPerSquareMeter: cardLuminance }, "Grey-card (incident) metering: every meter zone reads an 18 % Lambertian grey card under the scene illuminance, L = 0.18·E/π, instead of the scene.");
1377
+ }
1252
1378
  const meterResult = meterRelativeExposure({ profile: meterProfile, sampleSet: { measurementId: "facade-meter", sceneStateId: IDS.sceneState,
1253
1379
  inputDomain: "relative-pre-exposure-linear-signal", captureRegion: "oriented-active-capture", captureGeometry: resolvedGeometry,
1254
1380
  processingState: { exposureSettingsApplied: false, whiteBalanceApplied: false, toneMappingApplied: false, displayGammaApplied: false, sharpeningApplied: false },
@@ -1279,45 +1405,89 @@ export async function runCapture(options, run, viewOption, renderView) {
1279
1405
  // #516: the resolvers' Auto ISO policies; their baseline is the lowest selectable ISO in the body's Auto ISO range.
1280
1406
  const autoIsoBaseline = { isoBaseline: "minimum-selectable", isoQuantizationPolicy: "nearest-log2-lower-on-tie" };
1281
1407
  let autoIsoAperturePriority;
1408
+ // #505: the photographer's Auto ISO limits, validated against the body's Auto ISO range; absent unless given.
1409
+ let autoIsoOptions = {};
1410
+ let autoIsoLimits;
1411
+ // #504: the slowest preferred shutter (aperture priority; optional in program) and, with "auto", how it was derived.
1412
+ let autoIsoShutterPolicy;
1413
+ let automaticSlowestShutter;
1414
+ // 2.14: the capabilities the exposure-mode resolvers see; with Auto ISO in whole stops, the ISO grid's whole stops only.
1415
+ let resolverCapabilities = capabilities, autoIsoIsoSteps = "whole-stop";
1282
1416
  if (automaticIso) {
1283
1417
  if (capabilities.iso.autoIsoAvailability !== "supported")
1284
1418
  fail('exposure.iso "auto" needs a body with Auto ISO; this camera\'s Auto ISO is ' + capabilities.iso.autoIsoAvailability + ".");
1285
- assume("exposure.autoIso", "model", { minimum: capabilities.iso.autoIsoMinimum ?? capabilities.iso.minimum, maximum: capabilities.iso.autoIsoMaximum ?? capabilities.iso.maximum, ...autoIsoBaseline }, "Auto ISO: the engine's exposure-mode resolver chooses the ISO from the meter target, starting from the lowest selectable ISO in the body's Auto ISO range and " +
1286
- "snapping to the nearest selectable ISO in log2 (the lower on a tie), held at the range's ends. A generic policy, not a manufacturer's Auto ISO.");
1287
- }
1288
- if (automaticIso && mode === "aperture-priority") {
1289
- const autoIsoOptions = allowKeys(exposureOptions.autoIso, "exposure.autoIso", ["slowestPreferredShutterSeconds", "afterMaximumIso"]);
1419
+ autoIsoOptions = allowKeys(exposureOptions.autoIso, "exposure.autoIso", ["slowestPreferredShutterSeconds", "afterMaximumIso", "limits", "automaticSlowestShutter", "isoSteps"]);
1420
+ // 2.14: Auto ISO steps in whole stops unless asked for thirds, so its choices are those of 2.13 on the third-stop bodies.
1421
+ if (autoIsoOptions.isoSteps !== undefined && autoIsoOptions.isoSteps !== "whole-stop" && autoIsoOptions.isoSteps !== "third-stop")
1422
+ fail('exposure.autoIso.isoSteps must be "whole-stop" or "third-stop".');
1423
+ if (autoIsoOptions.isoSteps === "third-stop")
1424
+ autoIsoIsoSteps = "third-stop";
1425
+ if (autoIsoIsoSteps === "whole-stop")
1426
+ resolverCapabilities = wholeStopIsoCapabilities(capabilities);
1427
+ if (mode !== "aperture-priority" && mode !== "program")
1428
+ for (const key of ["slowestPreferredShutterSeconds", "afterMaximumIso", "automaticSlowestShutter"])
1429
+ if (autoIsoOptions[key] !== undefined)
1430
+ fail("exposure.autoIso." + key + ' applies only to exposure.mode "aperture-priority" or "program"; omit it.');
1431
+ if (autoIsoOptions.limits !== undefined)
1432
+ autoIsoLimits = parseAutoIsoLimitsOption(autoIsoOptions.limits, resolverCapabilities.iso);
1433
+ assume("exposure.autoIso", "model", { minimum: capabilities.iso.autoIsoMinimum ?? capabilities.iso.minimum, maximum: capabilities.iso.autoIsoMaximum ?? capabilities.iso.maximum,
1434
+ ...(autoIsoLimits ? { limits: { ...autoIsoLimits } } : {}), ...autoIsoBaseline }, "Auto ISO: the engine's exposure-mode resolver chooses the ISO from the meter target, starting from the lowest selectable ISO in the body's Auto ISO range and " +
1435
+ "snapping to the nearest selectable ISO in log2 (the lower on a tie), held at the range's ends. A generic policy, not a manufacturer's Auto ISO." +
1436
+ (autoIsoLimits ? " The photographer's limits (exposure.autoIso.limits, #505) narrow that range to ISO " + autoIsoLimits.minimum + " to " + autoIsoLimits.maximum + "." : ""));
1437
+ }
1438
+ if (automaticIso && mode === "program" && autoIsoOptions.slowestPreferredShutterSeconds === undefined) {
1439
+ for (const key of ["afterMaximumIso", "automaticSlowestShutter"])
1440
+ if (autoIsoOptions[key] !== undefined)
1441
+ fail("exposure.autoIso." + key + ' in program needs exposure.autoIso.slowestPreferredShutterSeconds; omit it or set the slowest shutter.');
1442
+ }
1443
+ if (automaticIso && (mode === "aperture-priority" || (mode === "program" && autoIsoOptions.slowestPreferredShutterSeconds !== undefined))) {
1290
1444
  if (autoIsoOptions.slowestPreferredShutterSeconds === undefined)
1291
- fail('exposure.autoIso.slowestPreferredShutterSeconds is required with exposure.mode "aperture-priority" and exposure.iso "auto": the slowest shutter, in seconds, before Auto ISO raises the ISO.');
1292
- const slowestPreferredShutterSeconds = positive(autoIsoOptions.slowestPreferredShutterSeconds, "exposure.autoIso.slowestPreferredShutterSeconds");
1293
- if (slowestPreferredShutterSeconds < capabilities.shutter.minimumSeconds || slowestPreferredShutterSeconds > capabilities.shutter.maximumSeconds)
1294
- fail("exposure.autoIso.slowestPreferredShutterSeconds must be within the body's shutter range, " + capabilities.shutter.minimumSeconds + " s to " + capabilities.shutter.maximumSeconds + " s.");
1445
+ fail('exposure.autoIso.slowestPreferredShutterSeconds is required with exposure.mode "aperture-priority" and exposure.iso "auto": the slowest shutter, in seconds, before Auto ISO raises the ISO, or "auto".');
1446
+ if (autoIsoOptions.automaticSlowestShutter !== undefined && autoIsoOptions.slowestPreferredShutterSeconds !== "auto")
1447
+ fail('exposure.autoIso.automaticSlowestShutter applies only with exposure.autoIso.slowestPreferredShutterSeconds "auto"; omit it.');
1448
+ let slowestPreferredShutterSeconds;
1449
+ if (autoIsoOptions.slowestPreferredShutterSeconds === "auto") {
1450
+ automaticSlowestShutter = resolveAutomaticSlowestShutterOption(autoIsoOptions.automaticSlowestShutter, { focalLengthMm, focusDistanceM, pitch,
1451
+ shutter: capabilities.shutter, assume, pick });
1452
+ slowestPreferredShutterSeconds = automaticSlowestShutter.shutterSeconds;
1453
+ }
1454
+ else {
1455
+ if (typeof autoIsoOptions.slowestPreferredShutterSeconds === "string")
1456
+ fail('exposure.autoIso.slowestPreferredShutterSeconds must be a positive number of seconds or "auto".');
1457
+ slowestPreferredShutterSeconds = positive(autoIsoOptions.slowestPreferredShutterSeconds, "exposure.autoIso.slowestPreferredShutterSeconds");
1458
+ if (slowestPreferredShutterSeconds < capabilities.shutter.minimumSeconds || slowestPreferredShutterSeconds > capabilities.shutter.maximumSeconds)
1459
+ fail("exposure.autoIso.slowestPreferredShutterSeconds must be within the body's shutter range, " + capabilities.shutter.minimumSeconds + " s to " + capabilities.shutter.maximumSeconds + " s.");
1460
+ }
1295
1461
  const afterMaximumIso = pick(autoIsoOptions, "afterMaximumIso", "exposure.autoIso.afterMaximumIso", "allow-slower-shutter", "At the Auto ISO maximum, the shutter may get slower than the slowest preferred shutter to reach the meter target.");
1296
1462
  if (afterMaximumIso !== "allow-slower-shutter" && afterMaximumIso !== "hold-preferred-shutter")
1297
1463
  fail('exposure.autoIso.afterMaximumIso must be "allow-slower-shutter" or "hold-preferred-shutter".');
1298
- autoIsoAperturePriority = { slowestPreferredShutterSeconds, afterMaximumIso };
1464
+ autoIsoShutterPolicy = { slowestPreferredShutterSeconds, afterMaximumIso };
1465
+ if (mode === "aperture-priority")
1466
+ autoIsoAperturePriority = autoIsoShutterPolicy;
1299
1467
  }
1468
+ const limitsPolicy = autoIsoLimits ? { autoIsoLimits: { ...autoIsoLimits } } : {};
1300
1469
  if (mode === "manual") {
1301
- resolution = resolveManualExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber, manualShutterSeconds: shutterSeconds,
1302
- isoControl: automaticIso ? { kind: "automatic", quantizationPolicy: autoIsoBaseline.isoQuantizationPolicy } : { kind: "manual", iso } });
1470
+ resolution = resolveManualExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, manualAperture: fNumber, manualShutterSeconds: shutterSeconds,
1471
+ isoControl: automaticIso ? { kind: "automatic", quantizationPolicy: autoIsoBaseline.isoQuantizationPolicy, ...limitsPolicy } : { kind: "manual", iso } });
1303
1472
  }
1304
1473
  else if (autoIsoAperturePriority) {
1305
- resolution = resolveAperturePriorityAutoIsoExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber,
1306
- policy: { kind: "minimum-iso-until-slowest-preferred-shutter", ...autoIsoBaseline, shutterSelectionPolicy: "not-longer-than-target", ...autoIsoAperturePriority } });
1474
+ resolution = resolveAperturePriorityAutoIsoExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, manualAperture: fNumber,
1475
+ policy: { kind: "minimum-iso-until-slowest-preferred-shutter", ...autoIsoBaseline, shutterSelectionPolicy: "not-longer-than-target", ...autoIsoAperturePriority, ...limitsPolicy } });
1307
1476
  }
1308
1477
  else if (mode === "aperture-priority") {
1309
- resolution = resolveAperturePriorityExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber, manualIso: iso,
1478
+ resolution = resolveAperturePriorityExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, manualAperture: fNumber, manualIso: iso,
1310
1479
  shutterQuantizationPolicy: "nearest-log2-shorter-on-tie" });
1311
1480
  }
1312
1481
  else if (mode === "shutter-priority") {
1313
- resolution = resolveShutterPriorityExposureMode({ target, capabilities, referenceExposure: reference, manualShutterSeconds: shutterSeconds,
1314
- isoControl: automaticIso ? { kind: "automatic", policy: { kind: "minimum-iso-aperture-first", apertureSelectionPolicy: "not-wider-than-target", ...autoIsoBaseline } } :
1482
+ resolution = resolveShutterPriorityExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, manualShutterSeconds: shutterSeconds,
1483
+ isoControl: automaticIso ? { kind: "automatic", policy: { kind: "minimum-iso-aperture-first", apertureSelectionPolicy: "not-wider-than-target", ...autoIsoBaseline, ...limitsPolicy } } :
1315
1484
  { kind: "manual", iso, apertureQuantizationPolicy: "nearest-log2-narrower-on-tie" } });
1316
1485
  }
1317
1486
  else {
1318
1487
  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.");
1319
- resolution = resolveProgramAutoExposureMode({ target, capabilities, referenceExposure: reference, programLine: defaultProgramLine(),
1320
- isoControl: automaticIso ? { kind: "automatic", ...autoIsoBaseline } : { kind: "manual", iso } });
1488
+ resolution = resolveProgramAutoExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, programLine: defaultProgramLine(),
1489
+ isoControl: automaticIso ? { kind: "automatic", ...autoIsoBaseline, ...limitsPolicy, ...(autoIsoShutterPolicy ? { slowestPreferredShutter: { ...autoIsoShutterPolicy } } : {}) } :
1490
+ { kind: "manual", iso } });
1321
1491
  }
1322
1492
  if (resolution.status !== "resolved" || !resolution.resolvedSettings)
1323
1493
  fail("Exposure could not be resolved (" + resolution.status + "); the scene may have no signal.");
@@ -1326,8 +1496,10 @@ export async function runCapture(options, run, viewOption, renderView) {
1326
1496
  const resolvedIso = resolution.resolvedSettings.iso ?? iso;
1327
1497
  if (!Number.isFinite(resolvedIso) || resolvedIso <= 0)
1328
1498
  fail("Exposure could not be resolved: the resolved ISO is not a positive finite number.");
1329
- const isoReport = automaticIso ? { isoControl: "automatic", autoIso: autoIsoReport(resolution, capabilities.iso, autoIsoAperturePriority) } : { isoControl: "manual" };
1499
+ const isoReport = automaticIso ? { isoControl: "automatic", autoIso: autoIsoReport(resolution, resolverCapabilities.iso, autoIsoShutterPolicy, autoIsoLimits, automaticSlowestShutter, autoIsoOptions.isoSteps === undefined ? undefined : autoIsoIsoSteps) } : { isoControl: "manual" };
1330
1500
  const exposureDeviationStops = standardExposureDeviationStops(fNumber, shutterSeconds, resolvedIso, meteredLuminance);
1501
+ const exposureIlluminanceLux = standardExposureIlluminanceLux(fNumber, shutterSeconds, resolvedIso, meteredLuminance, illuminanceLux, callerScene?.kind !== "radiance-function");
1502
+ const exposureGreyCardDeviationStops = callerScene?.kind === "radiance-function" ? null : greyCardDeviationStops(fNumber, shutterSeconds, resolvedIso, illuminanceLux);
1331
1503
  const defocusDisc = defocusModel === undefined ? undefined :
1332
1504
  resolveDefocusOption({ run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch, diffraction: diffractionRadiusOption !== undefined });
1333
1505
  // #492: the capture applies the disc as an object-space convolution, which needs every disc point a site sees to be another site's scene point.
@@ -1768,7 +1940,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1768
1940
  // Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
1769
1941
  // that only captures does not contain the display view.
1770
1942
  if (displayView)
1771
- return await renderView({ ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, materialLayers, chartReport, chartSize, diffraction, defocus: displayViewDefocus, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, isoReport, standardExposureDeviationStops: exposureDeviationStops, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
1943
+ return await renderView({ ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, materialLayers, chartReport, chartSize, diffraction, defocus: displayViewDefocus, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, isoReport, standardExposureDeviationStops: exposureDeviationStops, standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, metering, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
1772
1944
  // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1773
1945
  if (idealReference) {
1774
1946
  const referenceStarted = clockMs();
@@ -2132,7 +2304,8 @@ export async function runCapture(options, run, viewOption, renderView) {
2132
2304
  request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
2133
2305
  ...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" } } : {}) },
2134
2306
  exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport,
2135
- meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops, resolution },
2307
+ metering, meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops,
2308
+ standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, resolution },
2136
2309
  scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
2137
2310
  illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
2138
2311
  framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },