@photivra/engine 2.12.0 → 2.13.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 (54) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +1 -0
  6. package/dist/capture/export-jpeg.d.ts +1 -1
  7. package/dist/capture/export-jpeg.js +6 -4
  8. package/dist/capture/export-jpeg.js.map +1 -1
  9. package/dist/core/version.d.ts +1 -1
  10. package/dist/core/version.js +1 -1
  11. package/dist/core/version.js.map +1 -1
  12. package/dist/exposure/auto-iso-slowest-shutter.d.ts +120 -0
  13. package/dist/exposure/auto-iso-slowest-shutter.js +216 -0
  14. package/dist/exposure/auto-iso-slowest-shutter.js.map +1 -0
  15. package/dist/exposure/exposure-mode-resolver.d.ts +57 -1
  16. package/dist/exposure/exposure-mode-resolver.js +238 -33
  17. package/dist/exposure/exposure-mode-resolver.js.map +1 -1
  18. package/dist/facade/diffraction-provider.js +12 -13
  19. package/dist/facade/diffraction-provider.js.map +1 -1
  20. package/dist/facade/display-view.d.ts +27 -0
  21. package/dist/facade/display-view.js +234 -65
  22. package/dist/facade/display-view.js.map +1 -1
  23. package/dist/facade/simulate-capture.d.ts +93 -10
  24. package/dist/facade/simulate-capture.js +146 -22
  25. package/dist/facade/simulate-capture.js.map +1 -1
  26. package/dist/facade/simulate-display-view.js +2 -2
  27. package/dist/facade/simulate-display-view.js.map +1 -1
  28. package/dist/gpu/display-view-quality-gpu.d.ts +2 -1
  29. package/dist/gpu/display-view-quality-gpu.js +24 -14
  30. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  31. package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +13 -1
  32. package/dist/gpu/wgsl-display-view-quality-kernel.js +36 -2
  33. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
  34. package/dist/index.d.ts +4 -3
  35. package/dist/index.js +2 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/optics/irradiance-convolution.d.ts +10 -0
  38. package/dist/optics/irradiance-convolution.js +48 -13
  39. package/dist/optics/irradiance-convolution.js.map +1 -1
  40. package/dist/output/print-jpeg.js +12 -9
  41. package/dist/output/print-jpeg.js.map +1 -1
  42. package/dist/output/text-metadata.d.ts +22 -2
  43. package/dist/output/text-metadata.js +58 -9
  44. package/dist/output/text-metadata.js.map +1 -1
  45. package/docs/API_REFERENCE.md +21 -1
  46. package/docs/QUICK_START.md +65 -5
  47. package/docs/USAGE.md +207 -2
  48. package/docs/api/core-version.md +1 -1
  49. package/docs/api/exports.json +67 -2
  50. package/docs/api/exposure-auto-iso-slowest-shutter.md +139 -0
  51. package/docs/api/exposure-exposure-mode-resolver.md +68 -0
  52. package/docs/api/facade-simulate-capture.md +90 -9
  53. package/docs/api/output-text-metadata.md +34 -0
  54. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import type { EvidenceProvenance } from "../core/evidence-provenance.js";
2
2
  import { type GenericEquipmentTier, type GenericEquipmentTierPreset } from "../equipment/generic-tier-assets.js";
3
- import { type ExposureResolutionConstraint } from "../exposure/exposure-mode-resolver.js";
3
+ import { type AutoIsoLimits, type ExposureResolutionConstraint } from "../exposure/exposure-mode-resolver.js";
4
4
  import { resolveColorTemperatureWhiteBalance, type ResolvedWhiteBalanceState } from "../color/white-balance.js";
5
5
  import { type CaptureWhiteBalanceIntent } from "../capture/simulated-capture.js";
6
6
  import { type NativeRawOutput, type NativeRawPlan } from "../capture/native-raw.js";
@@ -41,23 +41,81 @@ export type SimulateCaptureAutoIsoAfterMaximum = "allow-slower-shutter" | "hold-
41
41
  export interface SimulateCaptureAutoIsoReport {
42
42
  /** The ISO the meter target asked for at the resolved aperture and shutter, before the Auto ISO range and the ISO grid. */
43
43
  idealIsoBeforeConstraints: number;
44
- /** The Auto ISO range the ISO was chosen in: the body's Auto ISO bounds, or its ISO range when it declares none (the tiers: ISO 100 to their maximum). */
44
+ /**
45
+ * The body's Auto ISO range: its Auto ISO bounds, or its ISO range when it declares none (the tiers: ISO 100 to their
46
+ * maximum). Without `limits` the ISO was chosen in this range.
47
+ */
45
48
  autoIsoMinimum: number;
46
49
  autoIsoMaximum: number;
50
+ /**
51
+ * #505: present only with `exposure.autoIso.limits`: the photographer's Auto ISO range the ISO was chosen in, inside the
52
+ * body's (`minimum` is the body's Auto ISO minimum when the option omits it).
53
+ */
54
+ limits?: {
55
+ minimum: number;
56
+ maximum: number;
57
+ };
47
58
  /** Whether the ISO was held at an end of the Auto ISO range. */
48
59
  clamped: false | "minimum" | "maximum";
49
60
  /**
50
61
  * The constraint that limited the resolved exposure: the resolver's `targetResidual.limitingConstraint`, for example
51
- * `"iso-maximum"` (the exposure stays under target at the Auto ISO maximum), `"iso-grid-quantization"` (the nearest
52
- * selectable ISO) or `"none"` (on target).
62
+ * `"iso-maximum"` (the exposure stays under target at the body's Auto ISO maximum), `"user-iso-maximum"` (#505: at the
63
+ * photographer's `limits.maximum`, below the body's), `"iso-grid-quantization"` (the nearest selectable ISO) or `"none"`
64
+ * (on target).
53
65
  */
54
66
  limitingConstraint: ExposureResolutionConstraint;
55
67
  /** The resolver's residual, log2(target / achieved exposure), in stops: positive when the exposure is under the meter target, negative when over it. */
56
68
  residualStops: number;
57
- /** Aperture priority only: the policy and slowest preferred shutter used, and whether the slower-shutter fallback ran. */
69
+ /**
70
+ * Aperture priority, and program with a slowest preferred shutter (#504): the policy and slowest preferred shutter used,
71
+ * and whether the slower-shutter fallback ran.
72
+ */
58
73
  afterMaximumIso?: SimulateCaptureAutoIsoAfterMaximum;
59
74
  slowestPreferredShutterSeconds?: number;
60
75
  afterMaximumIsoFallbackUsed?: boolean;
76
+ /** #504, program only: whether the program line's shutter was longer than the slowest preferred shutter and was held. */
77
+ slowestPreferredShutterApplied?: boolean;
78
+ /** #504: present only with `slowestPreferredShutterSeconds: "auto"`: how the engine derived it (`estimateAutoIsoSlowestShutter`). */
79
+ automaticSlowestShutter?: SimulateCaptureAutomaticSlowestShutterReport;
80
+ }
81
+ /**
82
+ * #504: the settings of `exposure.autoIso.slowestPreferredShutterSeconds: "auto"`. Every field is optional; the defaults are
83
+ * labelled generic values, recorded as `default` assumptions.
84
+ */
85
+ export interface SimulateCaptureAutomaticSlowestShutterOptions {
86
+ /** The bias in whole stops, as cameras' Auto minimum-shutter settings offer: +1 one stop faster, −1 one stop slower; −2 to +2. Default 0. */
87
+ fasterByStops?: number;
88
+ /** The shake blur allowed, in sensor pixels of `sensor.pixelPitchMicrometers`. Default 1 (a generic pixel-level budget). */
89
+ blurBudgetPixels?: number;
90
+ /**
91
+ * The hand's constant angular velocity, rad/s (signs as in `motion.angularVelocityRadPerSec`; only the magnitude matters).
92
+ * Default `GENERIC_HANDHELD_ANGULAR_VELOCITY_RAD_PER_SEC`: 4.39 °/s on yaw, a cited generic value for a 100 g phone held in
93
+ * two hands (Xiao et al., Proc. SPIE 7250, 2009), not a measurement of you or of this camera.
94
+ */
95
+ angularVelocityRadPerSec?: {
96
+ yaw: number;
97
+ pitch: number;
98
+ };
99
+ /** Stabilization in equivalent stops (≥ 0, at most 10), scaling the motion by 2^−stops. Default 0: the tiers declare no stabilization. */
100
+ stabilizationStopsEquivalent?: number;
101
+ }
102
+ /** #504: the automatic slowest preferred shutter in the Auto ISO report. */
103
+ export interface SimulateCaptureAutomaticSlowestShutterReport {
104
+ /** The exposure time whose camera-shake blur equals the budget (`estimateCameraShakeBlur`), before the bias and the grid. */
105
+ derivedShutterSeconds: number;
106
+ fasterByStops: number;
107
+ /** derivedShutterSeconds · 2^−fasterByStops. */
108
+ biasedShutterSeconds: number;
109
+ /** The slowest preferred shutter used: the biased time within the body's shutter range. */
110
+ shutterSeconds: number;
111
+ blurBudgetPixels: number;
112
+ /** The shake blur at `shutterSeconds`, in pixels. */
113
+ blurAtShutterPixels: number;
114
+ angularVelocityRadPerSec: {
115
+ yaw: number;
116
+ pitch: number;
117
+ };
118
+ stabilizationStopsEquivalent: number;
61
119
  }
62
120
  export type SimulateCaptureOutputFormat = "srgb-8" | "srgb-16" | "jpeg" | "png" | "dng";
63
121
  export type SimulateCaptureWhiteBalance = "scene-illuminant" | "auto" | "none" | {
@@ -233,13 +291,27 @@ export interface SimulateCaptureOptions {
233
291
  */
234
292
  iso?: number | "auto";
235
293
  /**
236
- * #516: aperture priority with `iso: "auto"` only (required there). The shutter gets as long as
237
- * `slowestPreferredShutterSeconds` (seconds, within the body's shutter range) at the lowest Auto ISO; beyond it the ISO
238
- * rises; at the Auto ISO maximum `afterMaximumIso` decides (default `"allow-slower-shutter"`).
294
+ * Auto ISO settings, with `iso: "auto"` only.
295
+ *
296
+ * - `slowestPreferredShutterSeconds` and `afterMaximumIso` (#516): aperture priority, where the slowest shutter is
297
+ * required, and (#504) program, where it is optional. The shutter gets as long as `slowestPreferredShutterSeconds`
298
+ * (seconds, within the body's shutter range) at the lowest Auto ISO; beyond it the ISO rises; at the Auto ISO maximum
299
+ * `afterMaximumIso` decides (default `"allow-slower-shutter"`; in program the shutter then never gets longer than the
300
+ * program line's own). Without it, program follows its line as before.
301
+ * - `slowestPreferredShutterSeconds: "auto"` (#504): the engine derives it from the focal length, the pixel pitch and a
302
+ * hand-shake profile with `estimateAutoIsoSlowestShutter` (the camera-shake model, not the 1/focal-length rule of
303
+ * thumb); `automaticSlowestShutter` sets the bias, the blur budget, the shake and the stabilization.
304
+ * - `limits` (#505): every mode. The photographer's Auto ISO range `{ maximum, minimum? }`, a choice kept apart from
305
+ * the body's Auto ISO capability: both are selectable ISOs inside the body's Auto ISO range, `minimum` at most
306
+ * `maximum` (default: the body's minimum). Outside the body's range or off its ISO grid the request fails; the
307
+ * engine never clips a limit. When the ISO stops at a limit strictly inside the body's range, the report's
308
+ * `limitingConstraint` is `"user-iso-maximum"` or `"user-iso-minimum"`.
239
309
  */
240
310
  autoIso?: {
241
- slowestPreferredShutterSeconds: number;
311
+ slowestPreferredShutterSeconds?: number | "auto";
242
312
  afterMaximumIso?: SimulateCaptureAutoIsoAfterMaximum;
313
+ limits?: AutoIsoLimits;
314
+ automaticSlowestShutter?: SimulateCaptureAutomaticSlowestShutterOptions;
243
315
  };
244
316
  /** Shutter for manual and shutter-priority modes. Default 1/125 s. */
245
317
  shutterSeconds?: number;
@@ -490,6 +562,13 @@ export interface SimulateCaptureResult {
490
562
  * the metered luminance is 0 (a black scene has no standard exposure).
491
563
  */
492
564
  standardExposureDeviationStops: number | null;
565
+ /**
566
+ * The illuminance, lux, at which the settings used give the standard exposure: E·L_ref(N, t, S)/L = E·2^Δ, with E the
567
+ * request's `illuminanceLux`. Exact for a reflective scene (a chart, a reflectance function or a material scene), whose
568
+ * metered luminance is proportional to E. Null for a black scene (L = 0) and for a radiance-function scene, whose
569
+ * radiance does not depend on the illuminance. It may exceed the 1e7 lux that `illuminanceLux` accepts.
570
+ */
571
+ standardExposureIlluminanceLux: number | null;
493
572
  /** Full output of the existing exposure-mode resolver. */
494
573
  resolution: unknown;
495
574
  };
@@ -761,7 +840,7 @@ export interface DisplayViewResult {
761
840
  height: number;
762
841
  };
763
842
  };
764
- /** The resolved exposure of the capture this view shows; `standardExposureDeviationStops` as in {@link SimulateCaptureResult}. */
843
+ /** The resolved exposure of the capture this view shows; `standardExposureDeviationStops` and `standardExposureIlluminanceLux` as in {@link SimulateCaptureResult}. */
765
844
  exposure: {
766
845
  mode: SimulateCaptureExposureMode;
767
846
  settings: {
@@ -771,6 +850,8 @@ export interface DisplayViewResult {
771
850
  };
772
851
  ev100: number;
773
852
  standardExposureDeviationStops: number | null;
853
+ /** As in the capture result. */
854
+ standardExposureIlluminanceLux: number | null;
774
855
  /** #516: as in the capture result. */
775
856
  isoControl: "manual" | "automatic";
776
857
  autoIso?: SimulateCaptureAutoIsoReport;
@@ -882,6 +963,8 @@ export interface DisplayViewRunContext {
882
963
  };
883
964
  /** #515: the capture's `exposure.standardExposureDeviationStops`. */
884
965
  standardExposureDeviationStops: number | null;
966
+ /** The capture's `exposure.standardExposureIlluminanceLux`. */
967
+ standardExposureIlluminanceLux: number | null;
885
968
  sceneOption: unknown;
886
969
  scenePreset: "composite" | "uniform" | "patches" | "slanted-edge" | "siemens-star" | "step-wedge" | "grey-ramp" | "checkerboard" | "point-grid" | "dead-leaves" | "bar-groups" | "hyperbolic-wedge" | "zone-plate" | "slanted-squares" | "line-grid" | "fiducial" | "reflectance-function" | "radiance-function" | "material-function";
887
970
  shutterSeconds: number;
@@ -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";
@@ -137,6 +138,21 @@ function standardExposureDeviationStops(fNumber, shutterSeconds, iso, meteredLum
137
138
  meteredLuminance + " cd/m²).");
138
139
  return deviation;
139
140
  }
141
+ /**
142
+ * The illuminance at which the settings give the standard exposure, E·L_ref/L (= E·2^Δ). A reflective scene's radiance is
143
+ * reflectance × k(E)·S(λ)/π with k linear in E (calculateIlluminantIrradianceScale), so the metered L is proportional to E
144
+ * and re-metering at this illuminance gives Δ = 0 up to rounding. A radiance-function scene ignores E, and a black scene
145
+ * has no standard exposure: null.
146
+ */
147
+ function standardExposureIlluminanceLux(fNumber, shutterSeconds, iso, meteredLuminance, illuminanceLux, radianceScalesWithIlluminance) {
148
+ if (!radianceScalesWithIlluminance || !(meteredLuminance > 0))
149
+ return null;
150
+ const lux = illuminanceLux * sosReferenceLuminance(fNumber, shutterSeconds, iso) / meteredLuminance;
151
+ if (!Number.isFinite(lux))
152
+ throw new InvalidScientificResultError("exposure.standardExposureIlluminanceLux is not finite (metered scene luminance " +
153
+ meteredLuminance + " cd/m²).");
154
+ return lux;
155
+ }
140
156
  /**
141
157
  * Default illuminance, lux: an 18 % Lambertian grey card (L = 0.18·E/π) then gets exactly the CIPA DC-004 standard
142
158
  * output sensitivity reference exposure H = 10/S at f/8, 1/125 s, ISO 100:
@@ -412,16 +428,87 @@ export function bilinearPhaseProfiles() {
412
428
  * #516: the result's Auto ISO report, read from a resolved Auto ISO resolution (every mode's resolved Auto ISO branch carries
413
429
  * `idealIsoBeforeConstraints`, `isoResolution` and a resolved `targetResidual`; aperture priority adds its fallback flag).
414
430
  */
415
- function autoIsoReport(resolution, iso, aperturePriority) {
431
+ function autoIsoReport(resolution, iso, shutterPolicy, limits, automatic) {
416
432
  const r = resolution;
433
+ // Program reports its hold under slowestPreferredShutter (#504); aperture priority at the top level.
434
+ const program = r.slowestPreferredShutter;
417
435
  const ideal = r.idealIsoBeforeConstraints, residual = r.targetResidual?.residualStops, clamped = r.isoResolution?.clamped;
418
436
  if (typeof ideal !== "number" || !Number.isFinite(ideal) || typeof residual !== "number" || !Number.isFinite(residual) || r.targetResidual?.status !== "resolved" ||
419
437
  (clamped !== false && clamped !== "minimum" && clamped !== "maximum"))
420
438
  throw new InvalidScientificResultError("Auto ISO resolution did not report a finite ideal ISO and residual.");
421
439
  // The resolvers' Auto ISO range: the body's Auto ISO bounds, else its ISO range.
422
440
  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 } : {}) };
441
+ ...(limits ? { limits: { ...limits } } : {}), limitingConstraint: r.targetResidual.limitingConstraint, residualStops: residual,
442
+ ...(shutterPolicy ? { ...shutterPolicy, afterMaximumIsoFallbackUsed: (program ? program.afterMaximumIsoFallbackUsed : r.afterMaximumIsoFallbackUsed) === true } : {}),
443
+ ...(program ? { slowestPreferredShutterApplied: program.applied === true } : {}),
444
+ ...(automatic ? { automaticSlowestShutter: automatic } : {}) };
445
+ }
446
+ /**
447
+ * #505: `exposure.autoIso.limits`, the photographer's Auto ISO range. Both limits are selectable ISOs inside the body's
448
+ * Auto ISO range, `minimum` at most `maximum`; anything else fails (the resolvers check the same, but an option defect
449
+ * is a configuration error here). `minimum` defaults to the body's Auto ISO minimum.
450
+ */
451
+ function parseAutoIsoLimitsOption(value, iso) {
452
+ const record = allowKeys(value, "exposure.autoIso.limits", ["maximum", "minimum"]);
453
+ if (record.maximum === undefined)
454
+ fail("exposure.autoIso.limits.maximum is required: the highest ISO Auto ISO may choose.");
455
+ const bodyMinimum = iso.autoIsoMinimum ?? iso.minimum, bodyMaximum = iso.autoIsoMaximum ?? iso.maximum;
456
+ const limit = (field) => {
457
+ const v = positive(record[field], "exposure.autoIso.limits." + field);
458
+ if (v < bodyMinimum || v > bodyMaximum)
459
+ fail("exposure.autoIso.limits." + field + " (ISO " + v + ") must be within the body's Auto ISO range, ISO " + bodyMinimum + " to " + bodyMaximum + ".");
460
+ if (iso.settingGrid.kind === "discrete-values" && !iso.settingGrid.values.includes(v))
461
+ fail("exposure.autoIso.limits." + field + " (ISO " + v + ") must be a selectable ISO of the body: " + iso.settingGrid.values.join(", ") + ".");
462
+ return v;
463
+ };
464
+ const maximum = limit("maximum"), minimum = record.minimum === undefined ? bodyMinimum : limit("minimum");
465
+ if (minimum > maximum)
466
+ fail("exposure.autoIso.limits.minimum (ISO " + minimum + ") must not exceed exposure.autoIso.limits.maximum (ISO " + maximum + ").");
467
+ return { minimum, maximum };
468
+ }
469
+ /**
470
+ * #504: `exposure.autoIso.slowestPreferredShutterSeconds: "auto"`: the slowest preferred shutter from the camera-shake model
471
+ * (`estimateAutoIsoSlowestShutter`) for this lens, focus and pixel pitch, within the body's shutter range. Option defects are
472
+ * configuration errors; the defaults are recorded as `default` assumptions and the derivation as a `model` assumption.
473
+ */
474
+ function resolveAutomaticSlowestShutterOption(value, context) {
475
+ const path = "exposure.autoIso.automaticSlowestShutter";
476
+ const record = allowKeys(value, path, ["fasterByStops", "blurBudgetPixels", "angularVelocityRadPerSec", "stabilizationStopsEquivalent"]);
477
+ const fasterByStops = context.pick(record, "fasterByStops", path + ".fasterByStops", 0, "No bias: the derived slowest shutter is used as is.");
478
+ if (typeof fasterByStops !== "number" || !Number.isInteger(fasterByStops) || Math.abs(fasterByStops) > 2)
479
+ fail(path + ".fasterByStops must be a whole number of stops from -2 to +2.");
480
+ 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");
481
+ 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 " +
482
+ "holding a 100 g mobile handset with two hands. A labelled generic default, not a measurement of this photographer or camera.");
483
+ const angular = allowKeys(angularOption, path + ".angularVelocityRadPerSec", ["yaw", "pitch"]);
484
+ for (const axis of ["yaw", "pitch"]) {
485
+ const v = angular[axis];
486
+ if (typeof v !== "number" || !Number.isFinite(v) || Math.abs(v) > 100)
487
+ fail(path + ".angularVelocityRadPerSec." + axis + " must be a finite number within ±100 rad/s.");
488
+ }
489
+ const angularVelocityRadPerSec = { yaw: angular.yaw, pitch: angular.pitch };
490
+ if (angularVelocityRadPerSec.yaw === 0 && angularVelocityRadPerSec.pitch === 0)
491
+ fail(path + ".angularVelocityRadPerSec must be non-zero on at least one axis: without shake no slowest shutter follows.");
492
+ const stabilizationStopsEquivalent = context.pick(record, "stabilizationStopsEquivalent", path + ".stabilizationStopsEquivalent", 0, "No stabilization: the generic tiers declare none.");
493
+ if (typeof stabilizationStopsEquivalent !== "number" || !Number.isFinite(stabilizationStopsEquivalent) || stabilizationStopsEquivalent < 0 || stabilizationStopsEquivalent > 10)
494
+ fail(path + ".stabilizationStopsEquivalent must be a number of stops from 0 to 10.");
495
+ let estimate;
496
+ try {
497
+ estimate = estimateAutoIsoSlowestShutter({ focalLengthMm: context.focalLengthMm, focusDistanceM: context.focusDistanceM, angularVelocityRadPerSec,
498
+ stabilizationStopsEquivalent, blurBudget: { kind: "pixels", pixels: blurBudgetPixels, pixelPitchMicrometers: context.pitch }, fasterByStops,
499
+ shutterCapability: context.shutter }).value;
500
+ }
501
+ catch (error) {
502
+ fail(path + ": " + (error instanceof Error ? error.message : String(error)));
503
+ }
504
+ const report = { derivedShutterSeconds: estimate.derivedShutterSeconds, fasterByStops,
505
+ biasedShutterSeconds: estimate.biasedShutterSeconds, shutterSeconds: estimate.shutterSeconds, blurBudgetPixels, blurAtShutterPixels: estimate.blurAtShutterPixels,
506
+ angularVelocityRadPerSec, stabilizationStopsEquivalent };
507
+ context.assume("exposure.autoIso.automaticSlowestShutter", "model", { ...report, angularVelocityRadPerSec: { ...angularVelocityRadPerSec },
508
+ 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 " +
509
+ "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 " +
510
+ "reciprocal rule is a rule of thumb and is not used.");
511
+ return report;
425
512
  }
426
513
  function defaultProgramLine() {
427
514
  // Generic educational program line written for the engine, relative to the f/8, 1/125 s reference. For less light
@@ -1227,8 +1314,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1227
1314
  if (typeof isoOption === "string" && !automaticIso)
1228
1315
  fail('exposure.iso must be a positive finite number or "auto".');
1229
1316
  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.');
1317
+ if (exposureOptions.autoIso !== undefined && !automaticIso)
1318
+ fail('exposure.autoIso applies only with exposure.iso "auto"; omit it.');
1232
1319
  const compensation = exposureOptions.compensationStops === undefined ? DEFAULTS.compensationStops : exposureOptions.compensationStops;
1233
1320
  if (typeof compensation !== "number" || !Number.isFinite(compensation) || Math.abs(compensation) > 10)
1234
1321
  fail("exposure.compensationStops must be within ±10.");
@@ -1279,31 +1366,65 @@ export async function runCapture(options, run, viewOption, renderView) {
1279
1366
  // #516: the resolvers' Auto ISO policies; their baseline is the lowest selectable ISO in the body's Auto ISO range.
1280
1367
  const autoIsoBaseline = { isoBaseline: "minimum-selectable", isoQuantizationPolicy: "nearest-log2-lower-on-tie" };
1281
1368
  let autoIsoAperturePriority;
1369
+ // #505: the photographer's Auto ISO limits, validated against the body's Auto ISO range; absent unless given.
1370
+ let autoIsoOptions = {};
1371
+ let autoIsoLimits;
1372
+ // #504: the slowest preferred shutter (aperture priority; optional in program) and, with "auto", how it was derived.
1373
+ let autoIsoShutterPolicy;
1374
+ let automaticSlowestShutter;
1282
1375
  if (automaticIso) {
1283
1376
  if (capabilities.iso.autoIsoAvailability !== "supported")
1284
1377
  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"]);
1378
+ autoIsoOptions = allowKeys(exposureOptions.autoIso, "exposure.autoIso", ["slowestPreferredShutterSeconds", "afterMaximumIso", "limits", "automaticSlowestShutter"]);
1379
+ if (mode !== "aperture-priority" && mode !== "program")
1380
+ for (const key of ["slowestPreferredShutterSeconds", "afterMaximumIso", "automaticSlowestShutter"])
1381
+ if (autoIsoOptions[key] !== undefined)
1382
+ fail("exposure.autoIso." + key + ' applies only to exposure.mode "aperture-priority" or "program"; omit it.');
1383
+ if (autoIsoOptions.limits !== undefined)
1384
+ autoIsoLimits = parseAutoIsoLimitsOption(autoIsoOptions.limits, capabilities.iso);
1385
+ assume("exposure.autoIso", "model", { minimum: capabilities.iso.autoIsoMinimum ?? capabilities.iso.minimum, maximum: capabilities.iso.autoIsoMaximum ?? capabilities.iso.maximum,
1386
+ ...(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
+ "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." +
1388
+ (autoIsoLimits ? " The photographer's limits (exposure.autoIso.limits, #505) narrow that range to ISO " + autoIsoLimits.minimum + " to " + autoIsoLimits.maximum + "." : ""));
1389
+ }
1390
+ if (automaticIso && mode === "program" && autoIsoOptions.slowestPreferredShutterSeconds === undefined) {
1391
+ for (const key of ["afterMaximumIso", "automaticSlowestShutter"])
1392
+ if (autoIsoOptions[key] !== undefined)
1393
+ fail("exposure.autoIso." + key + ' in program needs exposure.autoIso.slowestPreferredShutterSeconds; omit it or set the slowest shutter.');
1394
+ }
1395
+ if (automaticIso && (mode === "aperture-priority" || (mode === "program" && autoIsoOptions.slowestPreferredShutterSeconds !== undefined))) {
1290
1396
  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.");
1397
+ 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".');
1398
+ if (autoIsoOptions.automaticSlowestShutter !== undefined && autoIsoOptions.slowestPreferredShutterSeconds !== "auto")
1399
+ fail('exposure.autoIso.automaticSlowestShutter applies only with exposure.autoIso.slowestPreferredShutterSeconds "auto"; omit it.');
1400
+ let slowestPreferredShutterSeconds;
1401
+ if (autoIsoOptions.slowestPreferredShutterSeconds === "auto") {
1402
+ automaticSlowestShutter = resolveAutomaticSlowestShutterOption(autoIsoOptions.automaticSlowestShutter, { focalLengthMm, focusDistanceM, pitch,
1403
+ shutter: capabilities.shutter, assume, pick });
1404
+ slowestPreferredShutterSeconds = automaticSlowestShutter.shutterSeconds;
1405
+ }
1406
+ else {
1407
+ if (typeof autoIsoOptions.slowestPreferredShutterSeconds === "string")
1408
+ fail('exposure.autoIso.slowestPreferredShutterSeconds must be a positive number of seconds or "auto".');
1409
+ slowestPreferredShutterSeconds = positive(autoIsoOptions.slowestPreferredShutterSeconds, "exposure.autoIso.slowestPreferredShutterSeconds");
1410
+ if (slowestPreferredShutterSeconds < capabilities.shutter.minimumSeconds || slowestPreferredShutterSeconds > capabilities.shutter.maximumSeconds)
1411
+ fail("exposure.autoIso.slowestPreferredShutterSeconds must be within the body's shutter range, " + capabilities.shutter.minimumSeconds + " s to " + capabilities.shutter.maximumSeconds + " s.");
1412
+ }
1295
1413
  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
1414
  if (afterMaximumIso !== "allow-slower-shutter" && afterMaximumIso !== "hold-preferred-shutter")
1297
1415
  fail('exposure.autoIso.afterMaximumIso must be "allow-slower-shutter" or "hold-preferred-shutter".');
1298
- autoIsoAperturePriority = { slowestPreferredShutterSeconds, afterMaximumIso };
1416
+ autoIsoShutterPolicy = { slowestPreferredShutterSeconds, afterMaximumIso };
1417
+ if (mode === "aperture-priority")
1418
+ autoIsoAperturePriority = autoIsoShutterPolicy;
1299
1419
  }
1420
+ const limitsPolicy = autoIsoLimits ? { autoIsoLimits: { ...autoIsoLimits } } : {};
1300
1421
  if (mode === "manual") {
1301
1422
  resolution = resolveManualExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber, manualShutterSeconds: shutterSeconds,
1302
- isoControl: automaticIso ? { kind: "automatic", quantizationPolicy: autoIsoBaseline.isoQuantizationPolicy } : { kind: "manual", iso } });
1423
+ isoControl: automaticIso ? { kind: "automatic", quantizationPolicy: autoIsoBaseline.isoQuantizationPolicy, ...limitsPolicy } : { kind: "manual", iso } });
1303
1424
  }
1304
1425
  else if (autoIsoAperturePriority) {
1305
1426
  resolution = resolveAperturePriorityAutoIsoExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber,
1306
- policy: { kind: "minimum-iso-until-slowest-preferred-shutter", ...autoIsoBaseline, shutterSelectionPolicy: "not-longer-than-target", ...autoIsoAperturePriority } });
1427
+ policy: { kind: "minimum-iso-until-slowest-preferred-shutter", ...autoIsoBaseline, shutterSelectionPolicy: "not-longer-than-target", ...autoIsoAperturePriority, ...limitsPolicy } });
1307
1428
  }
1308
1429
  else if (mode === "aperture-priority") {
1309
1430
  resolution = resolveAperturePriorityExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber, manualIso: iso,
@@ -1311,13 +1432,14 @@ export async function runCapture(options, run, viewOption, renderView) {
1311
1432
  }
1312
1433
  else if (mode === "shutter-priority") {
1313
1434
  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 } } :
1435
+ isoControl: automaticIso ? { kind: "automatic", policy: { kind: "minimum-iso-aperture-first", apertureSelectionPolicy: "not-wider-than-target", ...autoIsoBaseline, ...limitsPolicy } } :
1315
1436
  { kind: "manual", iso, apertureQuantizationPolicy: "nearest-log2-narrower-on-tie" } });
1316
1437
  }
1317
1438
  else {
1318
1439
  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
1440
  resolution = resolveProgramAutoExposureMode({ target, capabilities, referenceExposure: reference, programLine: defaultProgramLine(),
1320
- isoControl: automaticIso ? { kind: "automatic", ...autoIsoBaseline } : { kind: "manual", iso } });
1441
+ isoControl: automaticIso ? { kind: "automatic", ...autoIsoBaseline, ...limitsPolicy, ...(autoIsoShutterPolicy ? { slowestPreferredShutter: { ...autoIsoShutterPolicy } } : {}) } :
1442
+ { kind: "manual", iso } });
1321
1443
  }
1322
1444
  if (resolution.status !== "resolved" || !resolution.resolvedSettings)
1323
1445
  fail("Exposure could not be resolved (" + resolution.status + "); the scene may have no signal.");
@@ -1326,8 +1448,9 @@ export async function runCapture(options, run, viewOption, renderView) {
1326
1448
  const resolvedIso = resolution.resolvedSettings.iso ?? iso;
1327
1449
  if (!Number.isFinite(resolvedIso) || resolvedIso <= 0)
1328
1450
  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" };
1451
+ const isoReport = automaticIso ? { isoControl: "automatic", autoIso: autoIsoReport(resolution, capabilities.iso, autoIsoShutterPolicy, autoIsoLimits, automaticSlowestShutter) } : { isoControl: "manual" };
1330
1452
  const exposureDeviationStops = standardExposureDeviationStops(fNumber, shutterSeconds, resolvedIso, meteredLuminance);
1453
+ const exposureIlluminanceLux = standardExposureIlluminanceLux(fNumber, shutterSeconds, resolvedIso, meteredLuminance, illuminanceLux, callerScene?.kind !== "radiance-function");
1331
1454
  const defocusDisc = defocusModel === undefined ? undefined :
1332
1455
  resolveDefocusOption({ run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch, diffraction: diffractionRadiusOption !== undefined });
1333
1456
  // #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 +1891,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1768
1891
  // Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
1769
1892
  // that only captures does not contain the display view.
1770
1893
  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 });
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 });
1772
1895
  // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1773
1896
  if (idealReference) {
1774
1897
  const referenceStarted = clockMs();
@@ -2132,7 +2255,8 @@ export async function runCapture(options, run, viewOption, renderView) {
2132
2255
  request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
2133
2256
  ...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" } } : {}) },
2134
2257
  exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport,
2135
- meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops, resolution },
2258
+ meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops,
2259
+ standardExposureIlluminanceLux: exposureIlluminanceLux, resolution },
2136
2260
  scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
2137
2261
  illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
2138
2262
  framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },