@photivra/engine 2.13.0 → 2.14.1

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 (128) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/CITATION.cff +1 -1
  3. package/NOTICE +5 -4
  4. package/README.md +10 -8
  5. package/SECURITY.md +1 -1
  6. package/THIRD_PARTY.md +16 -10
  7. package/dist/camera/lens-projection.js +2 -1
  8. package/dist/camera/lens-projection.js.map +1 -1
  9. package/dist/capture/dng-import.js +1 -1
  10. package/dist/capture/dng-import.js.map +1 -1
  11. package/dist/capture/host-yield-cadence.js +1 -1
  12. package/dist/capture/host-yield-cadence.js.map +1 -1
  13. package/dist/capture/native-environment-optical-stage.js +1 -1
  14. package/dist/capture/native-environment-optical-stage.js.map +1 -1
  15. package/dist/capture/native-raw.js +1 -1
  16. package/dist/capture/native-raw.js.map +1 -1
  17. package/dist/capture/packed-sensor-raw.js +3 -3
  18. package/dist/capture/packed-sensor-raw.js.map +1 -1
  19. package/dist/capture/sensor-raw-producer.js +1 -1
  20. package/dist/capture/sensor-raw-producer.js.map +1 -1
  21. package/dist/color/capture-color.js +3 -3
  22. package/dist/color/capture-color.js.map +1 -1
  23. package/dist/color/chromatic-adaptation.js +2 -2
  24. package/dist/color/chromatic-adaptation.js.map +1 -1
  25. package/dist/color/color-matching-functions.js +1 -1
  26. package/dist/color/color-matching-functions.js.map +1 -1
  27. package/dist/color/colorimetry-math.js +2 -2
  28. package/dist/color/colorimetry-math.js.map +1 -1
  29. package/dist/color/illuminants.js +3 -3
  30. package/dist/color/illuminants.js.map +1 -1
  31. package/dist/color/rgb-color-spaces.js +1 -1
  32. package/dist/color/rgb-color-spaces.js.map +1 -1
  33. package/dist/color/sensor-color-development.js +1 -1
  34. package/dist/color/sensor-color-development.js.map +1 -1
  35. package/dist/core/record-validation.js +3 -3
  36. package/dist/core/record-validation.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/generic-prime-lenses.js +3 -3
  41. package/dist/equipment/generic-prime-lenses.js.map +1 -1
  42. package/dist/equipment/generic-tier-assets.d.ts +23 -1
  43. package/dist/equipment/generic-tier-assets.js +58 -30
  44. package/dist/equipment/generic-tier-assets.js.map +1 -1
  45. package/dist/equipment/generic-tier-presets.d.ts +6 -3
  46. package/dist/equipment/generic-tier-presets.js +26 -13
  47. package/dist/equipment/generic-tier-presets.js.map +1 -1
  48. package/dist/exposure/auto-iso-slowest-shutter.js +2 -1
  49. package/dist/exposure/auto-iso-slowest-shutter.js.map +1 -1
  50. package/dist/exposure/flash-ttl.js +1 -1
  51. package/dist/exposure/flash-ttl.js.map +1 -1
  52. package/dist/exposure/flash.js +1 -1
  53. package/dist/exposure/flash.js.map +1 -1
  54. package/dist/facade/chart-material-layers.js +2 -2
  55. package/dist/facade/chart-material-layers.js.map +1 -1
  56. package/dist/facade/chart-radiance-batch.js +1 -1
  57. package/dist/facade/chart-radiance-batch.js.map +1 -1
  58. package/dist/facade/chart-scenes.d.ts +45 -8
  59. package/dist/facade/chart-scenes.js +101 -44
  60. package/dist/facade/chart-scenes.js.map +1 -1
  61. package/dist/facade/color-patches.d.ts +129 -0
  62. package/dist/facade/color-patches.js +209 -0
  63. package/dist/facade/color-patches.js.map +1 -0
  64. package/dist/facade/display-view-gpu.js +1 -1
  65. package/dist/facade/display-view-gpu.js.map +1 -1
  66. package/dist/facade/scene-radiance.js +2 -2
  67. package/dist/facade/scene-radiance.js.map +1 -1
  68. package/dist/facade/simulate-capture.d.ts +47 -3
  69. package/dist/facade/simulate-capture.js +71 -22
  70. package/dist/facade/simulate-capture.js.map +1 -1
  71. package/dist/facade/simulate-display-view.js +2 -2
  72. package/dist/facade/simulate-display-view.js.map +1 -1
  73. package/dist/gpu/chart-composite-gpu.js +2 -2
  74. package/dist/gpu/chart-composite-gpu.js.map +1 -1
  75. package/dist/gpu/display-view-composite-material-module.js +1 -1
  76. package/dist/gpu/display-view-composite-material-module.js.map +1 -1
  77. package/dist/gpu/image-kernel-execution.js +2 -2
  78. package/dist/gpu/image-kernel-execution.js.map +1 -1
  79. package/dist/gpu/temporal-sum-gpu.js +23 -23
  80. package/dist/gpu/webgpu-runtime.js +1 -1
  81. package/dist/gpu/webgpu-runtime.js.map +1 -1
  82. package/dist/gpu/wgsl-chart-composite-kernel.js +318 -318
  83. package/dist/gpu/wgsl-chart-composite-kernel.js.map +1 -1
  84. package/dist/gpu/wgsl-chart-kernel.js +117 -117
  85. package/dist/gpu/wgsl-chart-kernel.js.map +1 -1
  86. package/dist/gpu/wgsl-display-view-quality-kernel.js +505 -505
  87. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
  88. package/dist/gpu/wgsl-display-view-speed-kernel.js +248 -248
  89. package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
  90. package/dist/gpu/wgsl-kernels.js +203 -203
  91. package/dist/gpu/wgsl-kernels.js.map +1 -1
  92. package/dist/gpu/wgsl-sensor-kernel.js +302 -302
  93. package/dist/gpu/wgsl-sensor-kernel.js.map +1 -1
  94. package/dist/index.d.ts +5 -3
  95. package/dist/index.js +4 -2
  96. package/dist/index.js.map +1 -1
  97. package/dist/motion/temporal-exposure-sum.js +1 -1
  98. package/dist/motion/temporal-exposure-sum.js.map +1 -1
  99. package/dist/optics/diffraction-psf-profile.js +4 -4
  100. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  101. package/dist/optics/front-of-lens-filter.js +1 -1
  102. package/dist/optics/front-of-lens-filter.js.map +1 -1
  103. package/dist/optics/irradiance-convolution.js +1 -1
  104. package/dist/optics/irradiance-convolution.js.map +1 -1
  105. package/dist/output/native-capture-sdr.js +1 -1
  106. package/dist/output/native-capture-sdr.js.map +1 -1
  107. package/dist/output/png-encoder.js +2 -2
  108. package/dist/output/png-encoder.js.map +1 -1
  109. package/dist/output/print-contrast-reference.js +2 -2
  110. package/dist/output/print-contrast-reference.js.map +1 -1
  111. package/dist/output/print-jpeg.js +1 -1
  112. package/dist/output/print-jpeg.js.map +1 -1
  113. package/dist/sensor/fixed-pattern-noise.js +1 -1
  114. package/dist/sensor/fixed-pattern-noise.js.map +1 -1
  115. package/dist/sensor/readout-exposure-linkage.js +1 -1
  116. package/dist/sensor/readout-exposure-linkage.js.map +1 -1
  117. package/docs/API_REFERENCE.md +29 -1
  118. package/docs/QUICK_START.md +40 -5
  119. package/docs/USAGE.md +35 -1
  120. package/docs/api/core-version.md +1 -1
  121. package/docs/api/equipment-generic-tier-assets.md +41 -1
  122. package/docs/api/equipment-generic-tier-presets.md +11 -3
  123. package/docs/api/exports.json +107 -2
  124. package/docs/api/facade-chart-scenes.md +36 -5
  125. package/docs/api/facade-color-patches.md +150 -0
  126. package/docs/api/facade-simulate-capture.md +52 -2
  127. package/llms.txt +1 -0
  128. package/package.json +2 -2
@@ -91,13 +91,14 @@ import { resolveChartMaterialLayers } from "./chart-material-layers.js";
91
91
  import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, resolveChartSizeOption, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W, planarProviderMaterialOfDirection } from "./scene-radiance.js";
92
92
  /** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
93
93
  export const SIMULATE_CAPTURE_FACADE_VERSION = "1.0.0";
94
+ export const SIMULATE_CAPTURE_METERING_MODES = /* @__PURE__ */ Object.freeze(["scene-average", "grey-card"]);
94
95
  /** Default midpoint samples per axis of `sensor.pixelAperture: "box"`. */
95
96
  export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
96
97
  /**
97
98
  * Options reported in `diagnostics.options` (2.1), in this order, when they ran: the ones that change how much work
98
99
  * a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
99
100
  */
100
- 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",
101
102
  "lens.diffraction", "lens.defocus", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
102
103
  "output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
103
104
  "output.format.jpeg", "output.format.png", "output.format.dng"]);
@@ -106,10 +107,10 @@ export const SIMULATE_CAPTURE_COST_OPTIONS = Object.freeze(["lens.pupilSampling"
106
107
  * the record form with `fast` and is reported in `diagnostics.fast.applied` when it runs. Every one of them takes the flag,
107
108
  * whether or not it has a fast method yet; one without runs exact and reports `false`.
108
109
  */
109
- export const SIMULATE_CAPTURE_FAST_OPTIONS = Object.freeze(["lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "output.demosaic.ahd"]);
110
- 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",
111
112
  sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
112
- 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,
113
114
  subjectDistanceM: 5, width: 64, height: 64, format: "srgb-8", toneCurve: "identity", highlightMode: "clip",
114
115
  whiteBalance: "scene-illuminant", jpegQuantizationStep: 2, pixelPitchMicrometers: 6, spectralStepNanometers: 20,
115
116
  temporalSampleCount: 1, noiseSeed: 1, illuminantKelvin: 6500 });
@@ -153,6 +154,18 @@ function standardExposureIlluminanceLux(fNumber, shutterSeconds, iso, meteredLum
153
154
  meteredLuminance + " cd/m²).");
154
155
  return lux;
155
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
+ }
156
169
  /**
157
170
  * Default illuminance, lux: an 18 % Lambertian grey card (L = 0.18·E/π) then gets exactly the CIPA DC-004 standard
158
171
  * output sensitivity reference exposure H = 10/S at f/8, 1/125 s, ISO 100:
@@ -284,7 +297,7 @@ export function apertureNodeOffsets(n) {
284
297
  const AIRY_THIRD_ZERO_PER_LAMBDA_N = 10.173468135062722 / Math.PI;
285
298
  /** Default temporal midpoints when the camera rotates (2.0 motion option). */
286
299
  const ROTATING_TEMPORAL_SAMPLE_COUNT = 8;
287
- 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",
288
301
  sceneState: "facade-scene-state", scene: "facade-scene", provider: "facade-provider", lights: "facade-lights", materials: "facade-materials",
289
302
  capture: "facade-capture", spectral: "facade-colorimetric-eqe", sampling: "facade-sampling", stack: "facade-no-aa", stackAa: "facade-aa",
290
303
  stationarity: "facade-stationarity", operatingState: "facade-operating-state", dark: "facade-dark", completeness: "facade-completeness",
@@ -296,8 +309,8 @@ export const CHANNELS = ["red", "green", "blue"];
296
309
  * proportional to CIE XYZ (the Luther–Ives condition holds, up to quadrature error), and the camera→XYZ
297
310
  * matrix is diagonal.
298
311
  */
299
- const EQE_SCALE = Object.freeze({ red: 0.5, green: 0.5, blue: 0.25 });
300
- 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" });
301
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)));
302
315
  const XYZ_TO_CAMERA = [[EQE_SCALE.red / EQE_SCALE.green, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.blue / EQE_SCALE.green]];
303
316
  /** Synthetic dark current per site at the operating temperature, e⁻/s. */
@@ -305,7 +318,7 @@ export const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
305
318
  const OPERATING_TEMPERATURE_C = 20;
306
319
  export function fail(message) { throw new InvalidConfigurationError(message); }
307
320
  /** Where `output.metadata` goes in each file format (reported in the `output.metadata` assumption). */
308
- const TEXT_METADATA_LAYOUT = Object.freeze({
321
+ const TEXT_METADATA_LAYOUT = /* @__PURE__ */ Object.freeze({
309
322
  jpeg: "XMP packet in APP1 (dc:description, xmp:CreatorTool, dc:rights, exif:UserComment), plus a COM segment holding the comment",
310
323
  png: "One tEXt (printable Latin-1) or iTXt (UTF-8) chunk per field, keywords Description, Software, Copyright, Comment, before the first IDAT",
311
324
  dng: "ImageDescription (statement, then the description) and Copyright when printable ASCII; an XMP packet in tag 700 with every field"
@@ -390,6 +403,19 @@ function luminanceOf(radiance) {
390
403
  }
391
404
  return PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W * sum;
392
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
+ }
393
419
  /** The white balance measured from a perfect white diffuser under the scene illuminant ("scene-illuminant"). */
394
420
  export function sceneIlluminantWhiteBalance(illuminant) {
395
421
  const xyz = illuminantXyz(illuminant), camera = XYZ_TO_CAMERA.map((row) => row[0] * xyz[0] + row[1] * xyz[1] + row[2] * xyz[2]);
@@ -428,7 +454,7 @@ export function bilinearPhaseProfiles() {
428
454
  * #516: the result's Auto ISO report, read from a resolved Auto ISO resolution (every mode's resolved Auto ISO branch carries
429
455
  * `idealIsoBeforeConstraints`, `isoResolution` and a resolved `targetResidual`; aperture priority adds its fallback flag).
430
456
  */
431
- function autoIsoReport(resolution, iso, shutterPolicy, limits, automatic) {
457
+ function autoIsoReport(resolution, iso, shutterPolicy, limits, automatic, isoSteps) {
432
458
  const r = resolution;
433
459
  // Program reports its hold under slowestPreferredShutter (#504); aperture priority at the top level.
434
460
  const program = r.slowestPreferredShutter;
@@ -441,7 +467,7 @@ function autoIsoReport(resolution, iso, shutterPolicy, limits, automatic) {
441
467
  ...(limits ? { limits: { ...limits } } : {}), limitingConstraint: r.targetResidual.limitingConstraint, residualStops: residual,
442
468
  ...(shutterPolicy ? { ...shutterPolicy, afterMaximumIsoFallbackUsed: (program ? program.afterMaximumIsoFallbackUsed : r.afterMaximumIsoFallbackUsed) === true } : {}),
443
469
  ...(program ? { slowestPreferredShutterApplied: program.applied === true } : {}),
444
- ...(automatic ? { automaticSlowestShutter: automatic } : {}) };
470
+ ...(automatic ? { automaticSlowestShutter: automatic } : {}), ...(isoSteps ? { isoSteps } : {}) };
445
471
  }
446
472
  /**
447
473
  * #505: `exposure.autoIso.limits`, the photographer's Auto ISO range. Both limits are selectable ISOs inside the body's
@@ -1304,7 +1330,11 @@ export async function runCapture(options, run, viewOption, renderView) {
1304
1330
  registerInternalRadianceAccess(provider.evaluateRadiance, { keyOfDirection: materialOfDirection, radianceOfKey: provider.materialRadiance, tabulated: true });
1305
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.");
1306
1332
  // Metering (CIPA DC-004 standard output sensitivity and camera equation) and exposure-mode resolution
1307
- 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".');
1308
1338
  const mode = pick(exposureOptions, "mode", "exposure.mode", "manual", "Manual exposure.");
1309
1339
  if (!["manual", "aperture-priority", "shutter-priority", "program"].includes(mode))
1310
1340
  fail("Unknown exposure mode.");
@@ -1336,6 +1366,15 @@ export async function runCapture(options, run, viewOption, renderView) {
1336
1366
  }
1337
1367
  const meteredLuminance = luminanceSum / (zones * zones);
1338
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
+ }
1339
1378
  const meterResult = meterRelativeExposure({ profile: meterProfile, sampleSet: { measurementId: "facade-meter", sceneStateId: IDS.sceneState,
1340
1379
  inputDomain: "relative-pre-exposure-linear-signal", captureRegion: "oriented-active-capture", captureGeometry: resolvedGeometry,
1341
1380
  processingState: { exposureSettingsApplied: false, whiteBalanceApplied: false, toneMappingApplied: false, displayGammaApplied: false, sharpeningApplied: false },
@@ -1372,16 +1411,25 @@ export async function runCapture(options, run, viewOption, renderView) {
1372
1411
  // #504: the slowest preferred shutter (aperture priority; optional in program) and, with "auto", how it was derived.
1373
1412
  let autoIsoShutterPolicy;
1374
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";
1375
1416
  if (automaticIso) {
1376
1417
  if (capabilities.iso.autoIsoAvailability !== "supported")
1377
1418
  fail('exposure.iso "auto" needs a body with Auto ISO; this camera\'s Auto ISO is ' + capabilities.iso.autoIsoAvailability + ".");
1378
- autoIsoOptions = allowKeys(exposureOptions.autoIso, "exposure.autoIso", ["slowestPreferredShutterSeconds", "afterMaximumIso", "limits", "automaticSlowestShutter"]);
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);
1379
1427
  if (mode !== "aperture-priority" && mode !== "program")
1380
1428
  for (const key of ["slowestPreferredShutterSeconds", "afterMaximumIso", "automaticSlowestShutter"])
1381
1429
  if (autoIsoOptions[key] !== undefined)
1382
1430
  fail("exposure.autoIso." + key + ' applies only to exposure.mode "aperture-priority" or "program"; omit it.');
1383
1431
  if (autoIsoOptions.limits !== undefined)
1384
- autoIsoLimits = parseAutoIsoLimitsOption(autoIsoOptions.limits, capabilities.iso);
1432
+ autoIsoLimits = parseAutoIsoLimitsOption(autoIsoOptions.limits, resolverCapabilities.iso);
1385
1433
  assume("exposure.autoIso", "model", { minimum: capabilities.iso.autoIsoMinimum ?? capabilities.iso.minimum, maximum: capabilities.iso.autoIsoMaximum ?? capabilities.iso.maximum,
1386
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 " +
1387
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." +
@@ -1419,25 +1467,25 @@ export async function runCapture(options, run, viewOption, renderView) {
1419
1467
  }
1420
1468
  const limitsPolicy = autoIsoLimits ? { autoIsoLimits: { ...autoIsoLimits } } : {};
1421
1469
  if (mode === "manual") {
1422
- resolution = resolveManualExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber, manualShutterSeconds: shutterSeconds,
1470
+ resolution = resolveManualExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, manualAperture: fNumber, manualShutterSeconds: shutterSeconds,
1423
1471
  isoControl: automaticIso ? { kind: "automatic", quantizationPolicy: autoIsoBaseline.isoQuantizationPolicy, ...limitsPolicy } : { kind: "manual", iso } });
1424
1472
  }
1425
1473
  else if (autoIsoAperturePriority) {
1426
- resolution = resolveAperturePriorityAutoIsoExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber,
1474
+ resolution = resolveAperturePriorityAutoIsoExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, manualAperture: fNumber,
1427
1475
  policy: { kind: "minimum-iso-until-slowest-preferred-shutter", ...autoIsoBaseline, shutterSelectionPolicy: "not-longer-than-target", ...autoIsoAperturePriority, ...limitsPolicy } });
1428
1476
  }
1429
1477
  else if (mode === "aperture-priority") {
1430
- resolution = resolveAperturePriorityExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber, manualIso: iso,
1478
+ resolution = resolveAperturePriorityExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, manualAperture: fNumber, manualIso: iso,
1431
1479
  shutterQuantizationPolicy: "nearest-log2-shorter-on-tie" });
1432
1480
  }
1433
1481
  else if (mode === "shutter-priority") {
1434
- resolution = resolveShutterPriorityExposureMode({ target, capabilities, referenceExposure: reference, manualShutterSeconds: shutterSeconds,
1482
+ resolution = resolveShutterPriorityExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, manualShutterSeconds: shutterSeconds,
1435
1483
  isoControl: automaticIso ? { kind: "automatic", policy: { kind: "minimum-iso-aperture-first", apertureSelectionPolicy: "not-wider-than-target", ...autoIsoBaseline, ...limitsPolicy } } :
1436
1484
  { kind: "manual", iso, apertureQuantizationPolicy: "nearest-log2-narrower-on-tie" } });
1437
1485
  }
1438
1486
  else {
1439
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.");
1440
- resolution = resolveProgramAutoExposureMode({ target, capabilities, referenceExposure: reference, programLine: defaultProgramLine(),
1488
+ resolution = resolveProgramAutoExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, programLine: defaultProgramLine(),
1441
1489
  isoControl: automaticIso ? { kind: "automatic", ...autoIsoBaseline, ...limitsPolicy, ...(autoIsoShutterPolicy ? { slowestPreferredShutter: { ...autoIsoShutterPolicy } } : {}) } :
1442
1490
  { kind: "manual", iso } });
1443
1491
  }
@@ -1448,9 +1496,10 @@ export async function runCapture(options, run, viewOption, renderView) {
1448
1496
  const resolvedIso = resolution.resolvedSettings.iso ?? iso;
1449
1497
  if (!Number.isFinite(resolvedIso) || resolvedIso <= 0)
1450
1498
  fail("Exposure could not be resolved: the resolved ISO is not a positive finite number.");
1451
- const isoReport = automaticIso ? { isoControl: "automatic", autoIso: autoIsoReport(resolution, capabilities.iso, autoIsoShutterPolicy, autoIsoLimits, automaticSlowestShutter) } : { isoControl: "manual" };
1499
+ const isoReport = automaticIso ? { isoControl: "automatic", autoIso: autoIsoReport(resolution, resolverCapabilities.iso, autoIsoShutterPolicy, autoIsoLimits, automaticSlowestShutter, autoIsoOptions.isoSteps === undefined ? undefined : autoIsoIsoSteps) } : { isoControl: "manual" };
1452
1500
  const exposureDeviationStops = standardExposureDeviationStops(fNumber, shutterSeconds, resolvedIso, meteredLuminance);
1453
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);
1454
1503
  const defocusDisc = defocusModel === undefined ? undefined :
1455
1504
  resolveDefocusOption({ run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch, diffraction: diffractionRadiusOption !== undefined });
1456
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.
@@ -1891,7 +1940,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1891
1940
  // Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
1892
1941
  // that only captures does not contain the display view.
1893
1942
  if (displayView)
1894
- 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, 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 });
1895
1944
  // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1896
1945
  if (idealReference) {
1897
1946
  const referenceStarted = clockMs();
@@ -2255,8 +2304,8 @@ export async function runCapture(options, run, viewOption, renderView) {
2255
2304
  request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
2256
2305
  ...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" } } : {}) },
2257
2306
  exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport,
2258
- meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops,
2259
- standardExposureIlluminanceLux: exposureIlluminanceLux, resolution },
2307
+ metering, meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops,
2308
+ standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, resolution },
2260
2309
  scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
2261
2310
  illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
2262
2311
  framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },