@photivra/engine 2.11.1 → 2.12.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,23 @@
1
1
  # Changelog
2
2
 
3
- ## 2.11.1 — unreleased (prepared October 31, 2026)
3
+ ## 2.12.0 — unreleased (prepared October 31, 2026)
4
+
5
+ A minor release for the app's Beta camera: Auto ISO through the one-call API, and the deviation from the standard exposure for an exposure meter. With `iso` absent or a number, samples, RAW codes and files are the same as 2.11.1; the exposure result gains `isoControl` and `standardExposureDeviationStops`.
6
+ - **New:** `exposure.iso: "auto"` in every exposure mode, with `exposure.autoIso` in aperture priority and an Auto ISO report in the result (#516, #518); `exposure.standardExposureDeviationStops` (#515, #517).
7
+ - **Compatibility:** two new root type exports (1,767), none removed; `exposure.iso` widens to `number | "auto"`; new result fields only.
8
+
9
+ ### Added
10
+
11
+ - **`simulateCapture` and the display view: Auto ISO in every exposure mode (#516).** `exposure.iso: "auto"` hands the ISO to the existing exposure-mode resolvers' Auto ISO branches, within the body's Auto ISO range, in `manual`, `aperture-priority`, `shutter-priority` and `program`; the default stays ISO 100. With `iso` absent or a number nothing changes: every hash lock holds, and the only result change is the new `exposure.isoControl: "manual"`.
12
+ - **Per mode:** manual keeps the aperture and shutter (`resolveManualExposureMode`, automatic ISO); aperture priority uses `resolveAperturePriorityAutoIsoExposureMode` with the new `exposure.autoIso: { slowestPreferredShutterSeconds, afterMaximumIso? }` (the slowest preferred shutter is required, within the body's shutter range; `afterMaximumIso` is `"allow-slower-shutter"`, the default, or `"hold-preferred-shutter"`); shutter priority uses the `minimum-iso-aperture-first` policy; program uses the program line at the lowest ISO and raises the ISO beyond the line's end. Every policy starts at the lowest selectable Auto ISO and snaps to the nearest selectable ISO in stops (the lower on a tie). The automatic slowest shutter (#504) and photographer-set limits (#505) are not part of this change.
13
+ - **Result:** `exposure.settings.iso` is the ISO used; `exposure.isoControl` is `"automatic"`; `exposure.autoIso` (`SimulateCaptureAutoIsoReport`) gives `idealIsoBeforeConstraints`, `autoIsoMinimum`, `autoIsoMaximum`, `clamped`, the resolver's `limitingConstraint` (for example `"iso-maximum"`) and `residualStops`, and in aperture priority the policy, the slowest preferred shutter and `afterMaximumIsoFallbackUsed`. The display view (`simulateDisplayView`, `prepareDisplayView`) and the ideal reference report the same fields; a new `exposure.autoIso` assumption records the policy.
14
+ - **Validation:** a string other than `"auto"`, `exposure.autoIso` outside aperture priority with Auto ISO, a missing, non-finite or out-of-range slowest preferred shutter, an unknown `afterMaximumIso` or key, and a body without Auto ISO fail with `InvalidConfigurationError`.
15
+ - **Tests** (`test/simulate-capture-auto-iso.test.ts`): each mode with `"auto"` (the default scene at f/8, 1/125 s gives ISO 100; three stops darker gives ISO 800, byte-identical to a manual ISO 800 capture in manual and aperture priority; compensation; the minimum and maximum with their limiting constraints; both `afterMaximumIso` policies; shutter priority past the widest aperture; program past the line's end); a capture, its display view (direct and prepared), its ideal reference and its region parts give the same exposure and report, and the parts stitch to the capture's bytes; fourteen invalid options fail with a domain error in the capture and the prepared view.
16
+ - New root exports: the types `SimulateCaptureAutoIsoAfterMaximum` and `SimulateCaptureAutoIsoReport` (1,767). `SimulateCaptureOptions.exposure.iso` widens to `number | "auto"` and gains `autoIso`; the capture, display-view and ideal-reference `exposure` gain `isoControl` and the optional `autoIso`. Docs: `docs/QUICK_START.md` ("Inputs and defaults", "Auto ISO") and `docs/USAGE.md`.
17
+ - **The deviation from the standard exposure, in stops (#515, for Photivra/apps#124).** `simulateCapture`'s and the display view's `exposure` gain `standardExposureDeviationStops`: Δ = log₂(N²/t) − log₂(0.65·L·S/10), from the CIPA DC-004-2020 relations the facade already meters with (S = 10/H, §3.2; H = 0.65·L·t/N², §5.5 (4)), with the settings used and `meteredSceneLuminanceCdPerSquareMeter`; no new metering. Positive is darker than the standard exposure (under), negative brighter (over); in an automatic mode it is minus the compensation plus any step or limit the resolver hit. It is `null` when the metered luminance is 0 (a black scene, which manual mode captures), never infinite. No other result, sample or RAW code changes; every hash lock holds.
18
+ - Tests: `test/exposure-deviation.test.ts`: 0 with the defaults (|Δ| ≈ 1.2 × 10⁻⁵ from the metering quadrature), +0.919 at f/11, +1.000 at f/8√2, −1.059 at 1/60 s, −1.000 at ISO 200, −⅔ in aperture priority with +⅔ stop, the 30 s limit at 0.01 lux (+9.16), the display view's value equal to the capture's, and `null` for a black scene.
19
+
20
+ ## 2.11.1 — published October 31, 2026
4
21
 
5
22
  A patch release that publishes 2.11.0's engine to npm. The owner tagged `v2.11.0` on `6bef763` and published its GitHub release on October 28, 2026, but its npm publish run (40636849817) was cancelled at the verify job's 25-minute limit while the single-job coverage gate was still running, so `@photivra/engine@2.11.0` was never published; npm `latest` is 2.10.0. A published tag runs the publish workflow of its own commit, so 2.11.0 cannot be published again; 2.11.1 carries the fixed workflow.
6
23
  - **No API, behaviour or value change from 2.11.0:** `src/` differs only in `ENGINE_API_VERSION`; the packed files differ only in the version fields and the release documentation (docs/RELEASE_2_11_1.md, "Same package").
package/CITATION.cff CHANGED
@@ -9,7 +9,7 @@ abstract: "An open TypeScript engine for the science and mathematics of photogra
9
9
  repository-code: "https://github.com/photivra/photivra"
10
10
  url: "https://photivra.com"
11
11
  license: "Apache-2.0"
12
- version: "2.11.1"
12
+ version: "2.12.0"
13
13
  keywords:
14
14
  - photography
15
15
  - camera optics
package/NODE_SUPPORT.md CHANGED
@@ -90,11 +90,9 @@ For **7 days** from a line's LTS start, the guard therefore accepts a build of t
90
90
  - Other majors, prereleases and end-of-life dates are checked as before.
91
91
  - A build that carries the LTS marker passes as before.
92
92
 
93
- ### Early warning
93
+ ### Checking ahead
94
94
 
95
- `.github/workflows/node-lts-lookahead.yml` runs daily and on manual dispatch. It does not run on pull requests and is not a required check. It fetches `https://nodejs.org/dist/index.json`, takes the newest stable release of each supported major, and runs the policy guard against it at 00:00 UTC on today and each of the next 14 days (`.github/node-lts-lookahead.mjs`). If any date fails, the job fails and names the major, the first failing date, the build and the reason. That leaves two weeks to act before the required checks fail.
96
-
97
- The warning is expected once a year: from 7 days before a line's LTS start until Node publishes its first LTS build, it reports the end of the grace period. It clears by itself when the LTS build appears. If it stays red near the end of the grace period, review the snapshot against the official schedule. Do not widen a window just to clear it. An end of life inside the next 14 days fails it the same way.
95
+ No scheduled job checks the policy ahead of time; the guard in every CI job is the check, so a date boundary first shows as a failing required check on the first run after it. To see a boundary coming, for example before a release, run `node .github/node-lts-lookahead.mjs`. It fetches `https://nodejs.org/dist/index.json`, takes the newest stable release of each supported major, and runs the policy guard against it at 00:00 UTC on today and each of the next 14 days. A failing date names the major, the first failing date, the build and the reason. Do not widen a window just to clear it.
98
96
 
99
97
  Routine minor and patch updates within an approved major may follow normal green-PR rules. Do not auto-merge a major upgrade of the Node runtime, a Node image, or `@types/node` on its own. Where Dependabot is configured, keep Node typings major upgrades out of its routine queue, while still allowing updates within supported majors.
100
98
 
package/README.md CHANGED
@@ -32,10 +32,10 @@ The source is licensed under Apache-2.0.
32
32
 
33
33
  ## Quick start
34
34
 
35
- Install the published ESM package below. The [2.11.1 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_11_1.md), the [2.11.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_11_0.md) and [changelog](https://github.com/Photivra/photivra/blob/main/CHANGELOG.md) describe the current release:
35
+ Install the published ESM package below. The [2.12.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_12_0.md) and [changelog](https://github.com/Photivra/photivra/blob/main/CHANGELOG.md) describe the current release:
36
36
 
37
37
  ```sh
38
- npm install @photivra/engine@2.11.1
38
+ npm install @photivra/engine@2.12.0
39
39
  ```
40
40
 
41
41
  Simulate a photograph of a test chart with a generic camera, then read the developed sRGB pixels:
@@ -75,7 +75,7 @@ console.log(horizontal.provenance);
75
75
 
76
76
  Start with the [Developer Guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md). Then use the [Usage Guide](https://github.com/Photivra/photivra/blob/main/docs/USAGE.md) and the [API reference](https://github.com/Photivra/photivra/blob/main/docs/API_REFERENCE.md), which is generated from the package exports.
77
77
 
78
- Repository `main` may describe newer, unreleased work. For the stable contract, use the docs bundled in the published package, or the exact `v2.11.1` tag.
78
+ Repository `main` may describe newer, unreleased work. For the stable contract, use the docs bundled in the published package, or the exact `v2.12.0` tag.
79
79
 
80
80
  ## Documentation
81
81
 
@@ -363,13 +363,13 @@ The application can then use those results, while the photography calculations t
363
363
 
364
364
  ## Status
365
365
 
366
- - Repository package version: `2.11.1` (release candidate prepared October 31, 2026; publication is separate)
367
- - Engine API contract: `2.11.1`
366
+ - Repository package version: `2.12.0` (release candidate prepared October 31, 2026; publication is separate)
367
+ - Engine API contract: `2.12.0`
368
368
  - Versioned generic tier reference assets: [GENERIC_TIER_PRESETS.md](https://github.com/Photivra/photivra/blob/main/docs/GENERIC_TIER_PRESETS.md).
369
369
  - Composed POC simulation API contract: `1.0.0`
370
370
  - Stability: 1.0 root-package compatibility policy; bounded scientific models and independent POC contract
371
371
 
372
- From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.11.1; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
372
+ From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.12.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
373
373
 
374
374
  A breaking change to the root contracts now needs a new package major version. Additive and corrective changes follow [compatibility and migration](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_1_3_0.md). A 1.0 package is not a claim of calibrated physical accuracy.
375
375
 
package/SECURITY.md CHANGED
@@ -4,7 +4,7 @@ Please do not open a public issue that contains vulnerability details.
4
4
 
5
5
  ## Supported versions
6
6
 
7
- The published 1.0.0 release started the 1.x root-package compatibility line, and 2.0.0 started the 2.x line. The current release is **2.10.0**, published on October 27, 2026 (see [CHANGELOG.md](CHANGELOG.md)).
7
+ The published 1.0.0 release started the 1.x root-package compatibility line, and 2.0.0 started the 2.x line. The current release is **2.11.1**, published on October 31, 2026 (see [CHANGELOG.md](CHANGELOG.md)).
8
8
 
9
9
  Security fixes target the current maintained development and release line. Do not assume that older 1.x releases, snapshots or pre-1.0 releases will receive fixes.
10
10
 
@@ -6,4 +6,4 @@
6
6
  * own versions. Release checks confirm it matches package.json and the packed package.
7
7
  * @see docs/RELEASE_1_0_1.md
8
8
  */
9
- export declare const ENGINE_API_VERSION: "2.11.1";
9
+ export declare const ENGINE_API_VERSION: "2.12.0";
@@ -7,5 +7,5 @@
7
7
  * own versions. Release checks confirm it matches package.json and the packed package.
8
8
  * @see docs/RELEASE_1_0_1.md
9
9
  */
10
- export const ENGINE_API_VERSION = "2.11.1";
10
+ export const ENGINE_API_VERSION = "2.12.0";
11
11
  //# sourceMappingURL=version.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"version.js","sourceRoot":"","sources":["../../src/core/version.ts"],"names":[],"mappings":"AAAA,sCAAsC;AAEtC;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,QAAiB,CAAC","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Version of the engine release. Since 1.0.1 it equals the npm package version.\n *\n * New captures and plans record this version as their creator. Archived records keep the\n * version they were made with. Proof-of-concept (POC), schema and model contracts have their\n * own versions. Release checks confirm it matches package.json and the packed package.\n * @see docs/RELEASE_1_0_1.md\n */\nexport const ENGINE_API_VERSION = \"2.11.1\" as const;\n"]}
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../../src/core/version.ts"],"names":[],"mappings":"AAAA,sCAAsC;AAEtC;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,QAAiB,CAAC","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Version of the engine release. Since 1.0.1 it equals the npm package version.\n *\n * New captures and plans record this version as their creator. Archived records keep the\n * version they were made with. Proof-of-concept (POC), schema and model contracts have their\n * own versions. Release checks confirm it matches package.json and the packed package.\n * @see docs/RELEASE_1_0_1.md\n */\nexport const ENGINE_API_VERSION = \"2.12.0\" as const;\n"]}
@@ -1,5 +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
4
  import { resolveColorTemperatureWhiteBalance, type ResolvedWhiteBalanceState } from "../color/white-balance.js";
4
5
  import { type CaptureWhiteBalanceIntent } from "../capture/simulated-capture.js";
5
6
  import { type NativeRawOutput, type NativeRawPlan } from "../capture/native-raw.js";
@@ -27,6 +28,37 @@ import { type ChartSizeOption, type CaptureIlluminant, type CallerMaterialScene,
27
28
  /** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
28
29
  export declare const SIMULATE_CAPTURE_FACADE_VERSION: "1.0.0";
29
30
  export type SimulateCaptureExposureMode = "manual" | "aperture-priority" | "shutter-priority" | "program";
31
+ /**
32
+ * #516: what aperture priority does with Auto ISO once the ISO reaches the body's Auto ISO maximum and the exposure is
33
+ * still under the meter target: `"allow-slower-shutter"` lengthens the shutter past the slowest preferred shutter to reach
34
+ * the target; `"hold-preferred-shutter"` keeps the preferred shutter and leaves the exposure under target.
35
+ */
36
+ export type SimulateCaptureAutoIsoAfterMaximum = "allow-slower-shutter" | "hold-preferred-shutter";
37
+ /**
38
+ * #516: how Auto ISO chose the ISO (`exposure.iso: "auto"`), reported in the result's `exposure.autoIso`. The full
39
+ * resolver output stays in the capture's `exposure.resolution`.
40
+ */
41
+ export interface SimulateCaptureAutoIsoReport {
42
+ /** The ISO the meter target asked for at the resolved aperture and shutter, before the Auto ISO range and the ISO grid. */
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). */
45
+ autoIsoMinimum: number;
46
+ autoIsoMaximum: number;
47
+ /** Whether the ISO was held at an end of the Auto ISO range. */
48
+ clamped: false | "minimum" | "maximum";
49
+ /**
50
+ * 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).
53
+ */
54
+ limitingConstraint: ExposureResolutionConstraint;
55
+ /** The resolver's residual, log2(target / achieved exposure), in stops: positive when the exposure is under the meter target, negative when over it. */
56
+ residualStops: number;
57
+ /** Aperture priority only: the policy and slowest preferred shutter used, and whether the slower-shutter fallback ran. */
58
+ afterMaximumIso?: SimulateCaptureAutoIsoAfterMaximum;
59
+ slowestPreferredShutterSeconds?: number;
60
+ afterMaximumIsoFallbackUsed?: boolean;
61
+ }
30
62
  export type SimulateCaptureOutputFormat = "srgb-8" | "srgb-16" | "jpeg" | "png" | "dng";
31
63
  export type SimulateCaptureWhiteBalance = "scene-illuminant" | "auto" | "none" | {
32
64
  kelvin: number;
@@ -191,8 +223,24 @@ export interface SimulateCaptureOptions {
191
223
  exposure?: {
192
224
  /** Default "manual". */
193
225
  mode?: SimulateCaptureExposureMode;
194
- /** Default 100. */
195
- iso?: number;
226
+ /**
227
+ * Default 100. #516: `"auto"` lets the body's Auto ISO choose the ISO from the meter target, within its Auto ISO range
228
+ * (the tiers: ISO 100 to their maximum), in every mode: manual keeps the aperture and shutter; aperture priority keeps the
229
+ * aperture and follows `autoIso`; shutter priority picks the aperture at the lowest Auto ISO (no wider than the target
230
+ * needs, so at most the widest) and raises the ISO for the rest; program picks the program line at the lowest Auto ISO and
231
+ * raises the ISO beyond the line's end. The result reports the ISO in `exposure.settings.iso` and how it was chosen in
232
+ * `exposure.autoIso`.
233
+ */
234
+ iso?: number | "auto";
235
+ /**
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"`).
239
+ */
240
+ autoIso?: {
241
+ slowestPreferredShutterSeconds: number;
242
+ afterMaximumIso?: SimulateCaptureAutoIsoAfterMaximum;
243
+ };
196
244
  /** Shutter for manual and shutter-priority modes. Default 1/125 s. */
197
245
  shutterSeconds?: number;
198
246
  /** Exposure compensation applied to the meter target, in stops. Default 0. */
@@ -430,7 +478,18 @@ export interface SimulateCaptureResult {
430
478
  };
431
479
  /** Exposure value at ISO 100 of the settings, log2(N²/t). */
432
480
  ev100: number;
481
+ /** #516: `"automatic"` when the request asked for `iso: "auto"`, else `"manual"`. */
482
+ isoControl: "manual" | "automatic";
483
+ /** #516: present with `iso: "auto"`: how Auto ISO chose `settings.iso` and what limited it. */
484
+ autoIso?: SimulateCaptureAutoIsoReport;
433
485
  meteredSceneLuminanceCdPerSquareMeter: number;
486
+ /**
487
+ * #515: deviation, in stops, of the exposure used from the CIPA DC-004-2020 standard exposure for the metered luminance L:
488
+ * log2(N²/t) − log2(0.65·L·S/10). Positive is darker than the standard exposure (under), negative brighter (over). In an
489
+ * automatic mode it is the compensation with the opposite sign plus any step or limit the resolver hit. Finite, or null when
490
+ * the metered luminance is 0 (a black scene has no standard exposure).
491
+ */
492
+ standardExposureDeviationStops: number | null;
434
493
  /** Full output of the existing exposure-mode resolver. */
435
494
  resolution: unknown;
436
495
  };
@@ -619,6 +678,9 @@ export interface IdealReferenceResult {
619
678
  iso: number;
620
679
  };
621
680
  ev100: number;
681
+ /** #516: as in the capture result. */
682
+ isoControl: "manual" | "automatic";
683
+ autoIso?: SimulateCaptureAutoIsoReport;
622
684
  };
623
685
  scene: SimulateCaptureResult["scene"];
624
686
  region?: {
@@ -699,7 +761,7 @@ export interface DisplayViewResult {
699
761
  height: number;
700
762
  };
701
763
  };
702
- /** The resolved exposure of the capture this view shows. */
764
+ /** The resolved exposure of the capture this view shows; `standardExposureDeviationStops` as in {@link SimulateCaptureResult}. */
703
765
  exposure: {
704
766
  mode: SimulateCaptureExposureMode;
705
767
  settings: {
@@ -708,6 +770,10 @@ export interface DisplayViewResult {
708
770
  iso: number;
709
771
  };
710
772
  ev100: number;
773
+ standardExposureDeviationStops: number | null;
774
+ /** #516: as in the capture result. */
775
+ isoControl: "manual" | "automatic";
776
+ autoIso?: SimulateCaptureAutoIsoReport;
711
777
  };
712
778
  scene: SimulateCaptureResult["scene"];
713
779
  /**
@@ -809,6 +875,13 @@ export interface DisplayViewRunContext {
809
875
  regimeId: string;
810
876
  }>;
811
877
  resolvedIso: number;
878
+ /** #516: how the ISO was chosen, reported in the view's `exposure`. */
879
+ isoReport: {
880
+ isoControl: "manual" | "automatic";
881
+ autoIso?: SimulateCaptureAutoIsoReport;
882
+ };
883
+ /** #515: the capture's `exposure.standardExposureDeviationStops`. */
884
+ standardExposureDeviationStops: number | null;
812
885
  sceneOption: unknown;
813
886
  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";
814
887
  shutterSeconds: number;
@@ -30,6 +30,7 @@
30
30
  */
31
31
  import { markInternalNativeTileReader, markInternalSceneRadianceEvaluator, registerInternalRadianceAccess } from "../core/internal-radiance-evaluator.js";
32
32
  import { InvalidConfigurationError } from "../core/configuration-error.js";
33
+ import { InvalidScientificResultError } from "../core/validation.js";
33
34
  import { requireInputObject } from "../core/record-validation.js";
34
35
  import { ENGINE_API_VERSION } from "../core/version.js";
35
36
  import { GENERIC_TIER_ASSETS } from "../equipment/generic-tier-assets.js";
@@ -39,7 +40,7 @@ import { resolveGenericEquipmentExposureCapabilities } from "../equipment/exposu
39
40
  import { resolveGenericIsoSignalChain } from "../sensor/iso-signal-chain.js";
40
41
  import { meterRelativeExposure } from "../exposure/metering.js";
41
42
  import { createExposureMeterTargetFromMeteringResult, setExposureCompensationOnMeterTarget } from "../exposure/metering-target.js";
42
- import { resolveManualExposureMode, resolveAperturePriorityExposureMode, resolveShutterPriorityExposureMode, resolveProgramAutoExposureMode } from "../exposure/exposure-mode-resolver.js";
43
+ import { resolveManualExposureMode, resolveAperturePriorityExposureMode, resolveAperturePriorityAutoIsoExposureMode, resolveShutterPriorityExposureMode, resolveProgramAutoExposureMode } from "../exposure/exposure-mode-resolver.js";
43
44
  import { parseExposureProgramLineProfile } from "../exposure/program-line.js";
44
45
  import { resolveCaptureGeometry } from "../output/capture-geometry.js";
45
46
  import { estimateCie1931ColorMatchingFunctions } from "../color/color-matching-functions.js";
@@ -121,6 +122,21 @@ const GREY_CARD_REFLECTANCE = 0.18;
121
122
  function sosReferenceLuminance(fNumber, shutterSeconds, iso) {
122
123
  return SOS_REFERENCE_EXPOSURE_NUMERATOR_LUX_SECONDS * fNumber ** 2 / (CAMERA_EQUATION_CONSTANT * shutterSeconds * iso);
123
124
  }
125
+ /**
126
+ * #515: deviation, in stops, of the exposure the settings give from the CIPA DC-004-2020 standard exposure for the metered
127
+ * luminance L: Δ = log2(N²/t) − log2(0.65·L·S/10) = log2(L_ref(N, t, S) / L), with L_ref the luminance that the settings
128
+ * expose at H = 10/S (S = 10/H, §3.2; H = 0.65·L·t/N², §5.5 (4)). Positive is darker than the standard exposure (under).
129
+ * A scene with no metered luminance (L = 0, which manual mode still captures) has no standard exposure: null, never ±Infinity.
130
+ */
131
+ function standardExposureDeviationStops(fNumber, shutterSeconds, iso, meteredLuminance) {
132
+ if (!(meteredLuminance > 0))
133
+ return null;
134
+ const deviation = Math.log2(sosReferenceLuminance(fNumber, shutterSeconds, iso) / meteredLuminance);
135
+ if (!Number.isFinite(deviation))
136
+ throw new InvalidScientificResultError("exposure.standardExposureDeviationStops is not finite (metered scene luminance " +
137
+ meteredLuminance + " cd/m²).");
138
+ return deviation;
139
+ }
124
140
  /**
125
141
  * Default illuminance, lux: an 18 % Lambertian grey card (L = 0.18·E/π) then gets exactly the CIPA DC-004 standard
126
142
  * output sensitivity reference exposure H = 10/S at f/8, 1/125 s, ISO 100:
@@ -392,6 +408,21 @@ export function bilinearPhaseProfiles() {
392
408
  kernels: CHANNELS.map((c) => ({ outputChannelId: c, contributions: kernels[px + "," + py][c] })), evidence: FACADE_EVIDENCE,
393
409
  limitations: ["Bilinear interpolation of the RGGB mosaic; no edge-directed demosaic, false-colour suppression or calibration."] } }));
394
410
  }
411
+ /**
412
+ * #516: the result's Auto ISO report, read from a resolved Auto ISO resolution (every mode's resolved Auto ISO branch carries
413
+ * `idealIsoBeforeConstraints`, `isoResolution` and a resolved `targetResidual`; aperture priority adds its fallback flag).
414
+ */
415
+ function autoIsoReport(resolution, iso, aperturePriority) {
416
+ const r = resolution;
417
+ const ideal = r.idealIsoBeforeConstraints, residual = r.targetResidual?.residualStops, clamped = r.isoResolution?.clamped;
418
+ if (typeof ideal !== "number" || !Number.isFinite(ideal) || typeof residual !== "number" || !Number.isFinite(residual) || r.targetResidual?.status !== "resolved" ||
419
+ (clamped !== false && clamped !== "minimum" && clamped !== "maximum"))
420
+ throw new InvalidScientificResultError("Auto ISO resolution did not report a finite ideal ISO and residual.");
421
+ // The resolvers' Auto ISO range: the body's Auto ISO bounds, else its ISO range.
422
+ 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 } : {}) };
425
+ }
395
426
  function defaultProgramLine() {
396
427
  // Generic educational program line written for the engine, relative to the f/8, 1/125 s reference. For less light
397
428
  // it first opens the aperture toward f/4, then lengthens the shutter. For more light it first closes toward
@@ -1186,11 +1217,18 @@ export async function runCapture(options, run, viewOption, renderView) {
1186
1217
  registerInternalRadianceAccess(provider.evaluateRadiance, { keyOfDirection: materialOfDirection, radianceOfKey: provider.materialRadiance, tabulated: true });
1187
1218
  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.");
1188
1219
  // Metering (CIPA DC-004 standard output sensitivity and camera equation) and exposure-mode resolution
1189
- const exposureOptions = allowKeys(top.exposure, "exposure", ["mode", "iso", "shutterSeconds", "compensationStops"]);
1220
+ const exposureOptions = allowKeys(top.exposure, "exposure", ["mode", "iso", "shutterSeconds", "compensationStops", "autoIso"]);
1190
1221
  const mode = pick(exposureOptions, "mode", "exposure.mode", "manual", "Manual exposure.");
1191
1222
  if (!["manual", "aperture-priority", "shutter-priority", "program"].includes(mode))
1192
1223
  fail("Unknown exposure mode.");
1193
- const iso = positive(pick(exposureOptions, "iso", "exposure.iso", DEFAULTS.iso, "ISO 100, the tiers' base ISO."), "exposure.iso");
1224
+ const isoOption = pick(exposureOptions, "iso", "exposure.iso", DEFAULTS.iso, "ISO 100, the tiers' base ISO.");
1225
+ // #516: "auto" resolves the ISO with the mode's Auto ISO resolver; NaN marks the ISO as not yet known.
1226
+ const automaticIso = isoOption === "auto";
1227
+ if (typeof isoOption === "string" && !automaticIso)
1228
+ fail('exposure.iso must be a positive finite number or "auto".');
1229
+ 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.');
1194
1232
  const compensation = exposureOptions.compensationStops === undefined ? DEFAULTS.compensationStops : exposureOptions.compensationStops;
1195
1233
  if (typeof compensation !== "number" || !Number.isFinite(compensation) || Math.abs(compensation) > 10)
1196
1234
  fail("exposure.compensationStops must be within ±10.");
@@ -1238,9 +1276,34 @@ export async function runCapture(options, run, viewOption, renderView) {
1238
1276
  fail("exposure.shutterSeconds is resolved automatically in " + mode + " mode; omit it.");
1239
1277
  shutterSeconds = NaN;
1240
1278
  }
1279
+ // #516: the resolvers' Auto ISO policies; their baseline is the lowest selectable ISO in the body's Auto ISO range.
1280
+ const autoIsoBaseline = { isoBaseline: "minimum-selectable", isoQuantizationPolicy: "nearest-log2-lower-on-tie" };
1281
+ let autoIsoAperturePriority;
1282
+ if (automaticIso) {
1283
+ if (capabilities.iso.autoIsoAvailability !== "supported")
1284
+ 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"]);
1290
+ 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.");
1295
+ 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
+ if (afterMaximumIso !== "allow-slower-shutter" && afterMaximumIso !== "hold-preferred-shutter")
1297
+ fail('exposure.autoIso.afterMaximumIso must be "allow-slower-shutter" or "hold-preferred-shutter".');
1298
+ autoIsoAperturePriority = { slowestPreferredShutterSeconds, afterMaximumIso };
1299
+ }
1241
1300
  if (mode === "manual") {
1242
1301
  resolution = resolveManualExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber, manualShutterSeconds: shutterSeconds,
1243
- isoControl: { kind: "manual", iso } });
1302
+ isoControl: automaticIso ? { kind: "automatic", quantizationPolicy: autoIsoBaseline.isoQuantizationPolicy } : { kind: "manual", iso } });
1303
+ }
1304
+ 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 } });
1244
1307
  }
1245
1308
  else if (mode === "aperture-priority") {
1246
1309
  resolution = resolveAperturePriorityExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber, manualIso: iso,
@@ -1248,17 +1311,23 @@ export async function runCapture(options, run, viewOption, renderView) {
1248
1311
  }
1249
1312
  else if (mode === "shutter-priority") {
1250
1313
  resolution = resolveShutterPriorityExposureMode({ target, capabilities, referenceExposure: reference, manualShutterSeconds: shutterSeconds,
1251
- isoControl: { kind: "manual", iso, apertureQuantizationPolicy: "nearest-log2-narrower-on-tie" } });
1314
+ isoControl: automaticIso ? { kind: "automatic", policy: { kind: "minimum-iso-aperture-first", apertureSelectionPolicy: "not-wider-than-target", ...autoIsoBaseline } } :
1315
+ { kind: "manual", iso, apertureQuantizationPolicy: "nearest-log2-narrower-on-tie" } });
1252
1316
  }
1253
1317
  else {
1254
1318
  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.");
1255
- resolution = resolveProgramAutoExposureMode({ target, capabilities, referenceExposure: reference, programLine: defaultProgramLine(), isoControl: { kind: "manual", iso } });
1319
+ resolution = resolveProgramAutoExposureMode({ target, capabilities, referenceExposure: reference, programLine: defaultProgramLine(),
1320
+ isoControl: automaticIso ? { kind: "automatic", ...autoIsoBaseline } : { kind: "manual", iso } });
1256
1321
  }
1257
1322
  if (resolution.status !== "resolved" || !resolution.resolvedSettings)
1258
1323
  fail("Exposure could not be resolved (" + resolution.status + "); the scene may have no signal.");
1259
1324
  fNumber = resolution.resolvedSettings.aperture ?? fNumber;
1260
1325
  shutterSeconds = resolution.resolvedSettings.shutterSeconds ?? shutterSeconds;
1261
1326
  const resolvedIso = resolution.resolvedSettings.iso ?? iso;
1327
+ if (!Number.isFinite(resolvedIso) || resolvedIso <= 0)
1328
+ 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" };
1330
+ const exposureDeviationStops = standardExposureDeviationStops(fNumber, shutterSeconds, resolvedIso, meteredLuminance);
1262
1331
  const defocusDisc = defocusModel === undefined ? undefined :
1263
1332
  resolveDefocusOption({ run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch, diffraction: diffractionRadiusOption !== undefined });
1264
1333
  // #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.
@@ -1699,7 +1768,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1699
1768
  // Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
1700
1769
  // that only captures does not contain the display view.
1701
1770
  if (displayView)
1702
- 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, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
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 });
1703
1772
  // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1704
1773
  if (idealReference) {
1705
1774
  const referenceStarted = clockMs();
@@ -1772,7 +1841,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1772
1841
  "Each pixel samples the scene at its centre, like the capture's default point aperture; use sensor.pixelAperture \"box\" for photosite-averaged pixels.");
1773
1842
  return Object.freeze({ kind: "ideal-reference", label: "Ideal reference (no camera effects); not a capture.", facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
1774
1843
  image: { width: outputWidth, height: outputHeight, format, samples, ...(png ? { png, colorEncoding: colorEncoding } : {}) },
1775
- exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds) },
1844
+ exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport },
1776
1845
  scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
1777
1846
  illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
1778
1847
  framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
@@ -2062,8 +2131,8 @@ export async function runCapture(options, run, viewOption, renderView) {
2062
2131
  ...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } } } : {}),
2063
2132
  request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
2064
2133
  ...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" } } : {}) },
2065
- exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds),
2066
- meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, resolution },
2134
+ exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport,
2135
+ meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops, resolution },
2067
2136
  scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
2068
2137
  illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
2069
2138
  framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },