@photivra/engine 2.18.0 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/dist/capture/focus-control.js +2 -75
  6. package/dist/capture/focus-control.js.map +1 -1
  7. package/dist/capture/native-environment-lean.d.ts +1 -0
  8. package/dist/capture/native-environment-lean.js +1 -1
  9. package/dist/capture/native-environment-lean.js.map +1 -1
  10. package/dist/capture/native-environment-optical-stage.d.ts +1 -0
  11. package/dist/capture/native-environment-optical-stage.js +2 -5
  12. package/dist/capture/native-environment-optical-stage.js.map +1 -1
  13. package/dist/capture/native-environment-raw.js +1 -5
  14. package/dist/capture/native-environment-raw.js.map +1 -1
  15. package/dist/capture/release-sequence.js +2 -47
  16. package/dist/capture/release-sequence.js.map +1 -1
  17. package/dist/color/white-balance.d.ts +2 -0
  18. package/dist/color/white-balance.js +3 -56
  19. package/dist/color/white-balance.js.map +1 -1
  20. package/dist/composition/image-formation-plan.d.ts +1 -0
  21. package/dist/composition/image-formation-plan.js +3 -49
  22. package/dist/composition/image-formation-plan.js.map +1 -1
  23. package/dist/core/evidence-provenance.js +1 -16
  24. package/dist/core/evidence-provenance.js.map +1 -1
  25. package/dist/core/record-validation.d.ts +54 -0
  26. package/dist/core/record-validation.js +176 -0
  27. package/dist/core/record-validation.js.map +1 -1
  28. package/dist/core/scientific-assurance.js +1 -18
  29. package/dist/core/scientific-assurance.js.map +1 -1
  30. package/dist/core/shared-helpers.d.ts +33 -0
  31. package/dist/core/shared-helpers.js +34 -0
  32. package/dist/core/shared-helpers.js.map +1 -0
  33. package/dist/core/spectral-composition.js +1 -36
  34. package/dist/core/spectral-composition.js.map +1 -1
  35. package/dist/core/spectral.js +1 -19
  36. package/dist/core/spectral.js.map +1 -1
  37. package/dist/core/version.d.ts +1 -1
  38. package/dist/core/version.js +1 -1
  39. package/dist/core/version.js.map +1 -1
  40. package/dist/equipment/exposure-capabilities.d.ts +1 -0
  41. package/dist/equipment/exposure-capabilities.js +2 -36
  42. package/dist/equipment/exposure-capabilities.js.map +1 -1
  43. package/dist/equipment/iso-capabilities.js +2 -49
  44. package/dist/equipment/iso-capabilities.js.map +1 -1
  45. package/dist/equipment/metering-capabilities.js +1 -26
  46. package/dist/equipment/metering-capabilities.js.map +1 -1
  47. package/dist/equipment/release-capabilities.js +1 -53
  48. package/dist/equipment/release-capabilities.js.map +1 -1
  49. package/dist/exposure/auto-iso-slowest-shutter.js +1 -7
  50. package/dist/exposure/auto-iso-slowest-shutter.js.map +1 -1
  51. package/dist/exposure/exposure-duration-control.js +1 -16
  52. package/dist/exposure/exposure-duration-control.js.map +1 -1
  53. package/dist/exposure/exposure-mode-resolver.js +1 -17
  54. package/dist/exposure/exposure-mode-resolver.js.map +1 -1
  55. package/dist/exposure/filtered-exposure-time.d.ts +93 -0
  56. package/dist/exposure/filtered-exposure-time.js +129 -0
  57. package/dist/exposure/filtered-exposure-time.js.map +1 -0
  58. package/dist/exposure/flash-ttl.d.ts +1 -0
  59. package/dist/exposure/flash-ttl.js +1 -1
  60. package/dist/exposure/flash-ttl.js.map +1 -1
  61. package/dist/exposure/flash.js +1 -49
  62. package/dist/exposure/flash.js.map +1 -1
  63. package/dist/exposure/metering-scene-radiance.js +1 -25
  64. package/dist/exposure/metering-scene-radiance.js.map +1 -1
  65. package/dist/exposure/metering-target.js +1 -30
  66. package/dist/exposure/metering-target.js.map +1 -1
  67. package/dist/exposure/metering-temporal.js +1 -8
  68. package/dist/exposure/metering-temporal.js.map +1 -1
  69. package/dist/exposure/metering.js +1 -72
  70. package/dist/exposure/metering.js.map +1 -1
  71. package/dist/exposure/program-line.js +1 -45
  72. package/dist/exposure/program-line.js.map +1 -1
  73. package/dist/facade/develop-joined-parts.d.ts +29 -0
  74. package/dist/facade/develop-joined-parts.js +33 -0
  75. package/dist/facade/develop-joined-parts.js.map +1 -0
  76. package/dist/facade/generic-lens-capture.d.ts +4 -2
  77. package/dist/facade/generic-lens-capture.js +4 -2
  78. package/dist/facade/generic-lens-capture.js.map +1 -1
  79. package/dist/facade/simulate-capture.d.ts +37 -5
  80. package/dist/facade/simulate-capture.js +153 -96
  81. package/dist/facade/simulate-capture.js.map +1 -1
  82. package/dist/gpu/chart-composite-gpu.js +1 -3
  83. package/dist/gpu/chart-composite-gpu.js.map +1 -1
  84. package/dist/gpu/chart-radiance-gpu.js +1 -5
  85. package/dist/gpu/chart-radiance-gpu.js.map +1 -1
  86. package/dist/gpu/display-view-quality-gpu.d.ts +13 -1
  87. package/dist/gpu/display-view-quality-gpu.js +4 -8
  88. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  89. package/dist/gpu/display-view-speed-gpu.js +3 -25
  90. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  91. package/dist/gpu/sensor-stage-gpu.js +1 -5
  92. package/dist/gpu/sensor-stage-gpu.js.map +1 -1
  93. package/dist/gpu/webgpu-runtime.d.ts +2 -0
  94. package/dist/gpu/webgpu-runtime.js +3 -0
  95. package/dist/gpu/webgpu-runtime.js.map +1 -1
  96. package/dist/index.d.ts +4 -1
  97. package/dist/index.js +3 -0
  98. package/dist/index.js.map +1 -1
  99. package/dist/motion/camera-rotation.js +1 -6
  100. package/dist/motion/camera-rotation.js.map +1 -1
  101. package/dist/motion/capture-rotation-inverse-mapping.d.ts +12 -0
  102. package/dist/motion/capture-rotation-inverse-mapping.js +2 -2
  103. package/dist/motion/capture-rotation-inverse-mapping.js.map +1 -1
  104. package/dist/motion/capture-rotation-temporal-quadrature.d.ts +1 -0
  105. package/dist/motion/capture-rotation-temporal-quadrature.js +1 -1
  106. package/dist/motion/capture-rotation-temporal-quadrature.js.map +1 -1
  107. package/dist/motion/capture-rotation-trajectory.js +2 -17
  108. package/dist/motion/capture-rotation-trajectory.js.map +1 -1
  109. package/dist/motion/capture-translation-parallax-temporal-quadrature.d.ts +1 -0
  110. package/dist/motion/capture-translation-parallax-temporal-quadrature.js +3 -14
  111. package/dist/motion/capture-translation-parallax-temporal-quadrature.js.map +1 -1
  112. package/dist/motion/extended-object-projection.js +5 -40
  113. package/dist/motion/extended-object-projection.js.map +1 -1
  114. package/dist/optics/atmospheric-haze.js +1 -7
  115. package/dist/optics/atmospheric-haze.js.map +1 -1
  116. package/dist/optics/brown-conrady-distortion.js +1 -7
  117. package/dist/optics/brown-conrady-distortion.js.map +1 -1
  118. package/dist/optics/complex-pupil-psf.d.ts +1 -0
  119. package/dist/optics/complex-pupil-psf.js +2 -84
  120. package/dist/optics/complex-pupil-psf.js.map +1 -1
  121. package/dist/optics/focus-state.js +1 -11
  122. package/dist/optics/focus-state.js.map +1 -1
  123. package/dist/optics/front-of-lens-filter.d.ts +5 -0
  124. package/dist/optics/front-of-lens-filter.js +3 -65
  125. package/dist/optics/front-of-lens-filter.js.map +1 -1
  126. package/dist/optics/generic-nd-filter-presets.d.ts +12 -0
  127. package/dist/optics/generic-nd-filter-presets.js +48 -0
  128. package/dist/optics/generic-nd-filter-presets.js.map +1 -0
  129. package/dist/optics/illumination-vignetting.js +1 -6
  130. package/dist/optics/illumination-vignetting.js.map +1 -1
  131. package/dist/optics/lens-psf-profile.d.ts +4 -1
  132. package/dist/optics/lens-psf-profile.js +12 -111
  133. package/dist/optics/lens-psf-profile.js.map +1 -1
  134. package/dist/optics/longitudinal-chromatic-aberration.js +1 -7
  135. package/dist/optics/longitudinal-chromatic-aberration.js.map +1 -1
  136. package/dist/optics/pupil-vignetting.js +1 -7
  137. package/dist/optics/pupil-vignetting.js.map +1 -1
  138. package/dist/optics/radial-distortion.js +1 -6
  139. package/dist/optics/radial-distortion.js.map +1 -1
  140. package/dist/optics/scene-to-sensor-irradiance.js +4 -96
  141. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  142. package/dist/optics/stray-light.d.ts +1 -0
  143. package/dist/optics/stray-light.js +1 -1
  144. package/dist/optics/stray-light.js.map +1 -1
  145. package/dist/optics/zernike-wavefront.js +1 -7
  146. package/dist/optics/zernike-wavefront.js.map +1 -1
  147. package/dist/output/capture-geometry.js +1 -8
  148. package/dist/output/capture-geometry.js.map +1 -1
  149. package/dist/output/developed-lens-corrections.d.ts +62 -0
  150. package/dist/output/developed-lens-corrections.js +135 -0
  151. package/dist/output/developed-lens-corrections.js.map +1 -0
  152. package/dist/output/generic-lens-corrections.d.ts +2 -6
  153. package/dist/output/generic-lens-corrections.js +3 -6
  154. package/dist/output/generic-lens-corrections.js.map +1 -1
  155. package/dist/output/geometric-transforms.js +1 -4
  156. package/dist/output/geometric-transforms.js.map +1 -1
  157. package/dist/output/native-capture-sdr.d.ts +2 -2
  158. package/dist/output/native-capture-sdr.js +3 -2
  159. package/dist/output/native-capture-sdr.js.map +1 -1
  160. package/dist/processing/development-detail.d.ts +29 -0
  161. package/dist/processing/development-detail.js +128 -107
  162. package/dist/processing/development-detail.js.map +1 -1
  163. package/dist/schema/illumination-temporal.js +3 -72
  164. package/dist/schema/illumination-temporal.js.map +1 -1
  165. package/dist/schema/illumination.d.ts +1 -0
  166. package/dist/schema/illumination.js +2 -65
  167. package/dist/schema/illumination.js.map +1 -1
  168. package/dist/schema/scene-radiance-temporal.js +2 -14
  169. package/dist/schema/scene-radiance-temporal.js.map +1 -1
  170. package/dist/schema/scene-radiance.js +2 -60
  171. package/dist/schema/scene-radiance.js.map +1 -1
  172. package/dist/schema/validation.d.ts +2 -0
  173. package/dist/schema/validation.js +2 -17
  174. package/dist/schema/validation.js.map +1 -1
  175. package/dist/sensor/accumulated-charge.js +2 -59
  176. package/dist/sensor/accumulated-charge.js.map +1 -1
  177. package/dist/sensor/architecture.js +1 -16
  178. package/dist/sensor/architecture.js.map +1 -1
  179. package/dist/sensor/camera-saturation-capacity.d.ts +1 -0
  180. package/dist/sensor/camera-saturation-capacity.js +3 -55
  181. package/dist/sensor/camera-saturation-capacity.js.map +1 -1
  182. package/dist/sensor/capture-color-sampling-binding.js +1 -26
  183. package/dist/sensor/capture-color-sampling-binding.js.map +1 -1
  184. package/dist/sensor/capture-mode-timing.js +1 -39
  185. package/dist/sensor/capture-mode-timing.js.map +1 -1
  186. package/dist/sensor/capture-mode.js +1 -23
  187. package/dist/sensor/capture-mode.js.map +1 -1
  188. package/dist/sensor/color-sampling.js +1 -26
  189. package/dist/sensor/color-sampling.js.map +1 -1
  190. package/dist/sensor/constant-rate-temporal-integration.js +2 -46
  191. package/dist/sensor/constant-rate-temporal-integration.js.map +1 -1
  192. package/dist/sensor/dark-current.js +2 -45
  193. package/dist/sensor/dark-current.js.map +1 -1
  194. package/dist/sensor/eqe-electron-rate.js +2 -35
  195. package/dist/sensor/eqe-electron-rate.js.map +1 -1
  196. package/dist/sensor/exposure-window.d.ts +5 -0
  197. package/dist/sensor/exposure-window.js +7 -22
  198. package/dist/sensor/exposure-window.js.map +1 -1
  199. package/dist/sensor/iso-gain.js +1 -7
  200. package/dist/sensor/iso-gain.js.map +1 -1
  201. package/dist/sensor/iso-signal-chain.js +1 -43
  202. package/dist/sensor/iso-signal-chain.js.map +1 -1
  203. package/dist/sensor/optical-stack.js +1 -25
  204. package/dist/sensor/optical-stack.js.map +1 -1
  205. package/dist/sensor/photon-energy.js +1 -26
  206. package/dist/sensor/photon-energy.js.map +1 -1
  207. package/dist/sensor/physical-charge-capacity.js +3 -77
  208. package/dist/sensor/physical-charge-capacity.js.map +1 -1
  209. package/dist/sensor/radiometry-readiness.js +2 -23
  210. package/dist/sensor/radiometry-readiness.js.map +1 -1
  211. package/dist/sensor/raw-readout.d.ts +3 -0
  212. package/dist/sensor/raw-readout.js +4 -66
  213. package/dist/sensor/raw-readout.js.map +1 -1
  214. package/dist/sensor/raw-reconstruction.js +2 -64
  215. package/dist/sensor/raw-reconstruction.js.map +1 -1
  216. package/dist/sensor/readout-exposure-linkage.js +1 -9
  217. package/dist/sensor/readout-exposure-linkage.js.map +1 -1
  218. package/dist/sensor/readout-timing.js +3 -57
  219. package/dist/sensor/readout-timing.js.map +1 -1
  220. package/dist/sensor/response-application-compatibility.d.ts +2 -0
  221. package/dist/sensor/response-application-compatibility.js +3 -29
  222. package/dist/sensor/response-application-compatibility.js.map +1 -1
  223. package/dist/sensor/response-operating-range.d.ts +1 -0
  224. package/dist/sensor/response-operating-range.js +3 -79
  225. package/dist/sensor/response-operating-range.js.map +1 -1
  226. package/dist/sensor/responsivity-photocurrent.js +4 -108
  227. package/dist/sensor/responsivity-photocurrent.js.map +1 -1
  228. package/dist/sensor/sampling-aperture.js +1 -25
  229. package/dist/sensor/sampling-aperture.js.map +1 -1
  230. package/dist/sensor/spatial-sample-reduction.js +2 -19
  231. package/dist/sensor/spatial-sample-reduction.js.map +1 -1
  232. package/dist/sensor/spatial-sampling-quadrature.js +1 -3
  233. package/dist/sensor/spatial-sampling-quadrature.js.map +1 -1
  234. package/dist/sensor/spatio-spectral-reduction.js +1 -18
  235. package/dist/sensor/spatio-spectral-reduction.js.map +1 -1
  236. package/dist/sensor/spectral-coverage.d.ts +10 -1
  237. package/dist/sensor/spectral-coverage.js +1 -1
  238. package/dist/sensor/spectral-coverage.js.map +1 -1
  239. package/dist/sensor/spectral-quadrature.js +2 -35
  240. package/dist/sensor/spectral-quadrature.js.map +1 -1
  241. package/dist/sensor/spectral-response.js +1 -47
  242. package/dist/sensor/spectral-response.js.map +1 -1
  243. package/dist/stabilization/camera-shake.js +1 -6
  244. package/dist/stabilization/camera-shake.js.map +1 -1
  245. package/dist/stabilization/system.js +2 -80
  246. package/dist/stabilization/system.js.map +1 -1
  247. package/docs/AGENT_GUIDE.md +1 -0
  248. package/docs/API_REFERENCE.md +41 -2
  249. package/docs/QUICK_START.md +90 -8
  250. package/docs/USAGE.md +47 -2
  251. package/docs/api/core-version.md +1 -1
  252. package/docs/api/exports.json +53 -3
  253. package/docs/api/exposure-filtered-exposure-time.md +105 -0
  254. package/docs/api/facade-develop-joined-parts.md +32 -0
  255. package/docs/api/facade-simulate-capture.md +36 -5
  256. package/docs/api/optics-generic-nd-filter-presets.md +34 -0
  257. package/docs/api/optics-lens-psf-profile.md +3 -1
  258. package/docs/api/output-developed-lens-corrections.md +12 -0
  259. package/docs/api/output-generic-lens-corrections.md +0 -9
  260. package/llms.txt +1 -0
  261. package/package.json +1 -1
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Shutter time that keeps the same exposure after neutral-density (or any passive) filters are put
3
+ * in front of the lens.
4
+ *
5
+ * Focal-plane exposure is H = 0.65·L·t/N² (CIPA DC-004-2020 §5.5 (4), free:
6
+ * https://www.cipa.jp/e/std/std-sec.html) and, more generally, the time integral of image-plane
7
+ * irradiance, H = E·t (EMVA Standard 1288 Release 4.0 Linear, §2, free). A front-of-lens filter
8
+ * with intensity transmittance T multiplies the light reaching the lens, and so E, by T. The same
9
+ * exposure at the same f-number therefore needs
10
+ *
11
+ * t_filtered = t / T = t · 2^stops, stops = −log2 T = OD / log10 2,
12
+ *
13
+ * exact under the linear (reciprocity) model of a digital sensor, whose collected charge is
14
+ * proportional to H. Film reciprocity failure is not modelled. T comes from the caller's
15
+ * front-of-lens filter profiles through `composeFrontOfLensFilterTransmission`, so nominal filter
16
+ * values (a "10-stop" filter, or OD 3.0 = 9.97 stops) are only as good as the profile: real filters
17
+ * vary from their nominal rating and with wavelength.
18
+ * @see docs/PHYSICS_FOUNDATION.md "Exposure time with an ND filter".
19
+ */
20
+ import { type CalculationResult } from "../core/calculation-result.js";
21
+ import type { SpectralWavelengthBasis } from "../core/spectral.js";
22
+ import { type ComposedFrontOfLensFilterTransmission, type FrontOfLensFilterProfile, type LinearPolarizationState } from "../optics/front-of-lens-filter.js";
23
+ /** Wavelength used when the caller gives none: 550 nm, near the photopic peak. Neutral filters do not depend on it. */
24
+ export declare const FILTERED_EXPOSURE_DEFAULT_WAVELENGTH_NANOMETERS: 550;
25
+ export interface CalculateFilteredExposureTimeInput {
26
+ /** Shutter time without the filter, in seconds. */
27
+ shutterSeconds: number;
28
+ /**
29
+ * One front-of-lens filter profile, or several in light-path order (scene side first), as
30
+ * accepted by `composeFrontOfLensFilterTransmission`.
31
+ */
32
+ filter: FrontOfLensFilterProfile | readonly FrontOfLensFilterProfile[];
33
+ /**
34
+ * Optional shutter dial, in seconds (for example `GENERIC_THIRD_STOP_SHUTTER_SECONDS`). When
35
+ * given, the result also reports the nearest dial value (nearest in stops, the shorter on a tie).
36
+ */
37
+ shutterDialSeconds?: readonly number[];
38
+ /** Wavelength at which the filters are resolved. Defaults to 550 nm. Neutral filters do not depend on it. */
39
+ wavelengthNanometers?: number;
40
+ /** Wavelength basis; defaults to the first filter profile's basis and must match every profile. */
41
+ wavelengthBasis?: Exclude<SpectralWavelengthBasis, "unspecified">;
42
+ /** Incident polarization, which matters only when a polarizer is in the stack. Omitted means unpolarized. */
43
+ incidentPolarization?: LinearPolarizationState;
44
+ }
45
+ /** The dial value nearest the exact filtered shutter time. */
46
+ export interface FilteredExposureDialSetting {
47
+ /** Nearest dial value in seconds. */
48
+ shutterSeconds: number;
49
+ /**
50
+ * log2(dial / exact filtered time): exposure of the dial setting relative to the unfiltered
51
+ * exposure, in stops. Positive is brighter (longer), negative darker.
52
+ */
53
+ exposureErrorStops: number;
54
+ /** `"maximum"` when the exact time is longer than every dial value (use bulb for the exact time), `"minimum"` when shorter than every one. */
55
+ clamped: false | "minimum" | "maximum";
56
+ /** How the dial value was chosen. */
57
+ quantizationPolicy: "nearest-log2-shorter-on-tie";
58
+ }
59
+ export interface FilteredExposureTime {
60
+ /** Shutter time without the filter, in seconds. */
61
+ shutterSeconds: number;
62
+ /** Shutter time that gives the same exposure with the filter: shutterSeconds / linearTransmissionFactor. */
63
+ filteredShutterSeconds: number;
64
+ /** Combined intensity transmittance T of the filter stack, in (0, 1]. */
65
+ linearTransmissionFactor: number;
66
+ /** Combined attenuation −log2 T, in stops. */
67
+ attenuationStops: number;
68
+ /** Shutter multiplier 1/T = 2^attenuationStops. */
69
+ exposureFactor: number;
70
+ /** Wavelength at which the filters were resolved, in nanometres. */
71
+ wavelengthNanometers: number;
72
+ /** The filter-stack transmission this result is based on, with each filter's identity and evidence. */
73
+ filterTransmission: ComposedFrontOfLensFilterTransmission;
74
+ /** Nearest shutter-dial value; present only when `shutterDialSeconds` was given. */
75
+ dial?: FilteredExposureDialSetting;
76
+ }
77
+ /**
78
+ * Shutter time that keeps the same exposure after adding front-of-lens filters, such as a
79
+ * neutral-density (ND) filter: t / T, with T the combined transmittance of the filter profiles.
80
+ * Aperture and ISO are unchanged.
81
+ *
82
+ * Exact under the linear sensor model (exposure H = E·t); film reciprocity failure is not modelled.
83
+ * Nominal filters vary: a "10-stop" or ND1000 (OD 3.0, 9.97 stops) filter transmits roughly, not
84
+ * exactly, its rating, so the result is only as good as the profile. The envelope is an
85
+ * approximation whenever any profile's `scientificStatus` is `"approximation"`.
86
+ *
87
+ * Example: 1/125 s with `GENERIC_ND_FILTER_PROFILES[10]` (exactly 2^-10) gives 1024/125 = 8.192 s;
88
+ * on a third-stop dial, 8 s.
89
+ *
90
+ * @param input Unfiltered shutter time, filter profile(s), optional dial, wavelength and polarization.
91
+ * @returns The filtered shutter time, the filter attenuation and, with a dial, the nearest dial value.
92
+ */
93
+ export declare function calculateFilteredExposureTime(input: CalculateFilteredExposureTimeInput): CalculationResult<FilteredExposureTime>;
@@ -0,0 +1,129 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shutter time that keeps the same exposure after neutral-density (or any passive) filters are put
4
+ * in front of the lens.
5
+ *
6
+ * Focal-plane exposure is H = 0.65·L·t/N² (CIPA DC-004-2020 §5.5 (4), free:
7
+ * https://www.cipa.jp/e/std/std-sec.html) and, more generally, the time integral of image-plane
8
+ * irradiance, H = E·t (EMVA Standard 1288 Release 4.0 Linear, §2, free). A front-of-lens filter
9
+ * with intensity transmittance T multiplies the light reaching the lens, and so E, by T. The same
10
+ * exposure at the same f-number therefore needs
11
+ *
12
+ * t_filtered = t / T = t · 2^stops, stops = −log2 T = OD / log10 2,
13
+ *
14
+ * exact under the linear (reciprocity) model of a digital sensor, whose collected charge is
15
+ * proportional to H. Film reciprocity failure is not modelled. T comes from the caller's
16
+ * front-of-lens filter profiles through `composeFrontOfLensFilterTransmission`, so nominal filter
17
+ * values (a "10-stop" filter, or OD 3.0 = 9.97 stops) are only as good as the profile: real filters
18
+ * vary from their nominal rating and with wavelength.
19
+ * @see docs/PHYSICS_FOUNDATION.md "Exposure time with an ND filter".
20
+ */
21
+ import { approximationResult, calculatedResult } from "../core/calculation-result.js";
22
+ import { InvalidConfigurationError } from "../core/configuration-error.js";
23
+ import { portableLog2 } from "../core/portable-math.js";
24
+ import { requireInputKeys } from "../core/record-validation.js";
25
+ import { InvalidScientificResultError, requirePositiveFinite } from "../core/validation.js";
26
+ import { composeFrontOfLensFilterTransmission } from "../optics/front-of-lens-filter.js";
27
+ /** Wavelength used when the caller gives none: 550 nm, near the photopic peak. Neutral filters do not depend on it. */
28
+ export const FILTERED_EXPOSURE_DEFAULT_WAVELENGTH_NANOMETERS = 550;
29
+ function nearestDialValue(exactSeconds, dial) {
30
+ const values = [...dial].sort((a, b) => a - b);
31
+ const first = values[0], last = values[values.length - 1];
32
+ let selected = first;
33
+ let clamped = false;
34
+ if (exactSeconds < first) {
35
+ clamped = "minimum";
36
+ }
37
+ else if (exactSeconds > last) {
38
+ selected = last;
39
+ clamped = "maximum";
40
+ }
41
+ else {
42
+ let best = Math.abs(portableLog2(exactSeconds / first));
43
+ for (let index = 1; index < values.length; index += 1) {
44
+ const candidate = values[index];
45
+ const distance = Math.abs(portableLog2(exactSeconds / candidate));
46
+ // Ascending order: a later candidate wins only when strictly nearer, so a tie keeps the shorter time.
47
+ if (distance < best - 1e-12) {
48
+ selected = candidate;
49
+ best = distance;
50
+ }
51
+ }
52
+ }
53
+ return {
54
+ shutterSeconds: selected,
55
+ exposureErrorStops: portableLog2(selected / exactSeconds),
56
+ clamped,
57
+ quantizationPolicy: "nearest-log2-shorter-on-tie"
58
+ };
59
+ }
60
+ /**
61
+ * Shutter time that keeps the same exposure after adding front-of-lens filters, such as a
62
+ * neutral-density (ND) filter: t / T, with T the combined transmittance of the filter profiles.
63
+ * Aperture and ISO are unchanged.
64
+ *
65
+ * Exact under the linear sensor model (exposure H = E·t); film reciprocity failure is not modelled.
66
+ * Nominal filters vary: a "10-stop" or ND1000 (OD 3.0, 9.97 stops) filter transmits roughly, not
67
+ * exactly, its rating, so the result is only as good as the profile. The envelope is an
68
+ * approximation whenever any profile's `scientificStatus` is `"approximation"`.
69
+ *
70
+ * Example: 1/125 s with `GENERIC_ND_FILTER_PROFILES[10]` (exactly 2^-10) gives 1024/125 = 8.192 s;
71
+ * on a third-stop dial, 8 s.
72
+ *
73
+ * @param input Unfiltered shutter time, filter profile(s), optional dial, wavelength and polarization.
74
+ * @returns The filtered shutter time, the filter attenuation and, with a dial, the nearest dial value.
75
+ */
76
+ export function calculateFilteredExposureTime(input) {
77
+ requireInputKeys(input, "calculateFilteredExposureTime", ["shutterSeconds", "filter", "shutterDialSeconds", "wavelengthNanometers", "wavelengthBasis", "incidentPolarization"]);
78
+ requirePositiveFinite("shutterSeconds", input.shutterSeconds);
79
+ const filters = Array.isArray(input.filter)
80
+ ? input.filter
81
+ : [input.filter];
82
+ const firstFilter = filters[0];
83
+ if (typeof firstFilter !== "object" || firstFilter === null) {
84
+ throw new InvalidConfigurationError("filter must be a front-of-lens filter profile or a non-empty array of them.");
85
+ }
86
+ const wavelengthNanometers = input.wavelengthNanometers ?? FILTERED_EXPOSURE_DEFAULT_WAVELENGTH_NANOMETERS;
87
+ const wavelengthBasis = input.wavelengthBasis ?? firstFilter.wavelengthBasis;
88
+ if (input.shutterDialSeconds !== undefined) {
89
+ if (!Array.isArray(input.shutterDialSeconds) || input.shutterDialSeconds.length === 0) {
90
+ throw new InvalidConfigurationError("shutterDialSeconds must be a non-empty array of shutter times in seconds.");
91
+ }
92
+ input.shutterDialSeconds.forEach((value, index) => {
93
+ requirePositiveFinite(`shutterDialSeconds[${index}]`, value);
94
+ });
95
+ }
96
+ const filterTransmission = composeFrontOfLensFilterTransmission({
97
+ filters,
98
+ wavelengthNanometers,
99
+ wavelengthBasis,
100
+ ...(input.incidentPolarization === undefined ? {} : { incidentPolarization: input.incidentPolarization })
101
+ });
102
+ const linearTransmissionFactor = filterTransmission.combinedLinearTransmissionFactor;
103
+ const exposureFactor = 1 / linearTransmissionFactor;
104
+ const filteredShutterSeconds = input.shutterSeconds / linearTransmissionFactor;
105
+ if (!Number.isFinite(exposureFactor) || !Number.isFinite(filteredShutterSeconds)) {
106
+ throw new InvalidScientificResultError("Filtered shutter time must remain finite.");
107
+ }
108
+ const value = {
109
+ shutterSeconds: input.shutterSeconds,
110
+ filteredShutterSeconds,
111
+ linearTransmissionFactor,
112
+ attenuationStops: filterTransmission.combinedAttenuationStops,
113
+ exposureFactor,
114
+ wavelengthNanometers: filterTransmission.wavelengthNanometers,
115
+ filterTransmission,
116
+ ...(input.shutterDialSeconds === undefined ? {} : { dial: nearestDialValue(filteredShutterSeconds, input.shutterDialSeconds) })
117
+ };
118
+ const assumptions = [
119
+ "Same exposure at the same f-number and ISO: filtered time = shutter time / T, from exposure H = E·t (linear sensor, CIPA DC-004-2020 §5.5; EMVA 1288 §2).",
120
+ "Reciprocity failure is not modelled: digital sensors respond linearly to exposure; film does not at long times.",
121
+ "T is the filter profiles' combined transmittance; nominal filters vary from their rating and with wavelength.",
122
+ `Filters resolved at ${filterTransmission.wavelengthNanometers} nm (${wavelengthBasis}); neutral filters do not depend on wavelength.`,
123
+ "Inter-filter reflections, flare, light leaks around the filter and through the viewfinder are not modelled."
124
+ ];
125
+ return filterTransmission.scientificStatus === "approximation"
126
+ ? approximationResult(value, "filtered-exposure-time", "1.0.0", assumptions)
127
+ : calculatedResult(value, "filtered-exposure-time", "1.0.0", assumptions);
128
+ }
129
+ //# sourceMappingURL=filtered-exposure-time.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filtered-exposure-time.js","sourceRoot":"","sources":["../../src/exposure/filtered-exposure-time.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Shutter time that keeps the same exposure after neutral-density (or any passive) filters are put\n * in front of the lens.\n *\n * Focal-plane exposure is H = 0.65·L·t/N² (CIPA DC-004-2020 §5.5 (4), free:\n * https://www.cipa.jp/e/std/std-sec.html) and, more generally, the time integral of image-plane\n * irradiance, H = E·t (EMVA Standard 1288 Release 4.0 Linear, §2, free). A front-of-lens filter\n * with intensity transmittance T multiplies the light reaching the lens, and so E, by T. The same\n * exposure at the same f-number therefore needs\n *\n * t_filtered = t / T = t · 2^stops, stops = −log2 T = OD / log10 2,\n *\n * exact under the linear (reciprocity) model of a digital sensor, whose collected charge is\n * proportional to H. Film reciprocity failure is not modelled. T comes from the caller's\n * front-of-lens filter profiles through `composeFrontOfLensFilterTransmission`, so nominal filter\n * values (a \"10-stop\" filter, or OD 3.0 = 9.97 stops) are only as good as the profile: real filters\n * vary from their nominal rating and with wavelength.\n * @see docs/PHYSICS_FOUNDATION.md \"Exposure time with an ND filter\".\n */\n\nimport { approximationResult, calculatedResult, type CalculationResult } from \"../core/calculation-result.js\";\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport { portableLog2 } from \"../core/portable-math.js\";\nimport { requireInputKeys } from \"../core/record-validation.js\";\nimport type { SpectralWavelengthBasis } from \"../core/spectral.js\";\nimport { InvalidScientificResultError, requirePositiveFinite } from \"../core/validation.js\";\nimport {\n composeFrontOfLensFilterTransmission,\n type ComposedFrontOfLensFilterTransmission,\n type FrontOfLensFilterProfile,\n type LinearPolarizationState\n} from \"../optics/front-of-lens-filter.js\";\n\n/** Wavelength used when the caller gives none: 550 nm, near the photopic peak. Neutral filters do not depend on it. */\nexport const FILTERED_EXPOSURE_DEFAULT_WAVELENGTH_NANOMETERS = 550 as const;\n\nexport interface CalculateFilteredExposureTimeInput {\n /** Shutter time without the filter, in seconds. */\n shutterSeconds: number;\n /**\n * One front-of-lens filter profile, or several in light-path order (scene side first), as\n * accepted by `composeFrontOfLensFilterTransmission`.\n */\n filter: FrontOfLensFilterProfile | readonly FrontOfLensFilterProfile[];\n /**\n * Optional shutter dial, in seconds (for example `GENERIC_THIRD_STOP_SHUTTER_SECONDS`). When\n * given, the result also reports the nearest dial value (nearest in stops, the shorter on a tie).\n */\n shutterDialSeconds?: readonly number[];\n /** Wavelength at which the filters are resolved. Defaults to 550 nm. Neutral filters do not depend on it. */\n wavelengthNanometers?: number;\n /** Wavelength basis; defaults to the first filter profile's basis and must match every profile. */\n wavelengthBasis?: Exclude<SpectralWavelengthBasis, \"unspecified\">;\n /** Incident polarization, which matters only when a polarizer is in the stack. Omitted means unpolarized. */\n incidentPolarization?: LinearPolarizationState;\n}\n\n/** The dial value nearest the exact filtered shutter time. */\nexport interface FilteredExposureDialSetting {\n /** Nearest dial value in seconds. */\n shutterSeconds: number;\n /**\n * log2(dial / exact filtered time): exposure of the dial setting relative to the unfiltered\n * exposure, in stops. Positive is brighter (longer), negative darker.\n */\n exposureErrorStops: number;\n /** `\"maximum\"` when the exact time is longer than every dial value (use bulb for the exact time), `\"minimum\"` when shorter than every one. */\n clamped: false | \"minimum\" | \"maximum\";\n /** How the dial value was chosen. */\n quantizationPolicy: \"nearest-log2-shorter-on-tie\";\n}\n\nexport interface FilteredExposureTime {\n /** Shutter time without the filter, in seconds. */\n shutterSeconds: number;\n /** Shutter time that gives the same exposure with the filter: shutterSeconds / linearTransmissionFactor. */\n filteredShutterSeconds: number;\n /** Combined intensity transmittance T of the filter stack, in (0, 1]. */\n linearTransmissionFactor: number;\n /** Combined attenuation −log2 T, in stops. */\n attenuationStops: number;\n /** Shutter multiplier 1/T = 2^attenuationStops. */\n exposureFactor: number;\n /** Wavelength at which the filters were resolved, in nanometres. */\n wavelengthNanometers: number;\n /** The filter-stack transmission this result is based on, with each filter's identity and evidence. */\n filterTransmission: ComposedFrontOfLensFilterTransmission;\n /** Nearest shutter-dial value; present only when `shutterDialSeconds` was given. */\n dial?: FilteredExposureDialSetting;\n}\n\nfunction nearestDialValue(exactSeconds: number, dial: readonly number[]): FilteredExposureDialSetting {\n const values = [...dial].sort((a, b) => a - b);\n const first = values[0]!, last = values[values.length - 1]!;\n let selected = first;\n let clamped: FilteredExposureDialSetting[\"clamped\"] = false;\n if (exactSeconds < first) {\n clamped = \"minimum\";\n } else if (exactSeconds > last) {\n selected = last;\n clamped = \"maximum\";\n } else {\n let best = Math.abs(portableLog2(exactSeconds / first));\n for (let index = 1; index < values.length; index += 1) {\n const candidate = values[index]!;\n const distance = Math.abs(portableLog2(exactSeconds / candidate));\n // Ascending order: a later candidate wins only when strictly nearer, so a tie keeps the shorter time.\n if (distance < best - 1e-12) {\n selected = candidate;\n best = distance;\n }\n }\n }\n return {\n shutterSeconds: selected,\n exposureErrorStops: portableLog2(selected / exactSeconds),\n clamped,\n quantizationPolicy: \"nearest-log2-shorter-on-tie\"\n };\n}\n\n/**\n * Shutter time that keeps the same exposure after adding front-of-lens filters, such as a\n * neutral-density (ND) filter: t / T, with T the combined transmittance of the filter profiles.\n * Aperture and ISO are unchanged.\n *\n * Exact under the linear sensor model (exposure H = E·t); film reciprocity failure is not modelled.\n * Nominal filters vary: a \"10-stop\" or ND1000 (OD 3.0, 9.97 stops) filter transmits roughly, not\n * exactly, its rating, so the result is only as good as the profile. The envelope is an\n * approximation whenever any profile's `scientificStatus` is `\"approximation\"`.\n *\n * Example: 1/125 s with `GENERIC_ND_FILTER_PROFILES[10]` (exactly 2^-10) gives 1024/125 = 8.192 s;\n * on a third-stop dial, 8 s.\n *\n * @param input Unfiltered shutter time, filter profile(s), optional dial, wavelength and polarization.\n * @returns The filtered shutter time, the filter attenuation and, with a dial, the nearest dial value.\n */\nexport function calculateFilteredExposureTime(\n input: CalculateFilteredExposureTimeInput\n): CalculationResult<FilteredExposureTime> {\n requireInputKeys(input, \"calculateFilteredExposureTime\",\n [\"shutterSeconds\", \"filter\", \"shutterDialSeconds\", \"wavelengthNanometers\", \"wavelengthBasis\", \"incidentPolarization\"]);\n requirePositiveFinite(\"shutterSeconds\", input.shutterSeconds);\n const filters: readonly FrontOfLensFilterProfile[] = Array.isArray(input.filter)\n ? input.filter\n : [input.filter as FrontOfLensFilterProfile];\n const firstFilter: unknown = filters[0];\n if (typeof firstFilter !== \"object\" || firstFilter === null) {\n throw new InvalidConfigurationError(\"filter must be a front-of-lens filter profile or a non-empty array of them.\");\n }\n const wavelengthNanometers = input.wavelengthNanometers ?? FILTERED_EXPOSURE_DEFAULT_WAVELENGTH_NANOMETERS;\n const wavelengthBasis = input.wavelengthBasis ?? (firstFilter as FrontOfLensFilterProfile).wavelengthBasis;\n if (input.shutterDialSeconds !== undefined) {\n if (!Array.isArray(input.shutterDialSeconds) || input.shutterDialSeconds.length === 0) {\n throw new InvalidConfigurationError(\"shutterDialSeconds must be a non-empty array of shutter times in seconds.\");\n }\n input.shutterDialSeconds.forEach((value: unknown, index) => {\n requirePositiveFinite(`shutterDialSeconds[${index}]`, value as number);\n });\n }\n\n const filterTransmission = composeFrontOfLensFilterTransmission({\n filters,\n wavelengthNanometers,\n wavelengthBasis,\n ...(input.incidentPolarization === undefined ? {} : { incidentPolarization: input.incidentPolarization })\n });\n const linearTransmissionFactor = filterTransmission.combinedLinearTransmissionFactor;\n const exposureFactor = 1 / linearTransmissionFactor;\n const filteredShutterSeconds = input.shutterSeconds / linearTransmissionFactor;\n if (!Number.isFinite(exposureFactor) || !Number.isFinite(filteredShutterSeconds)) {\n throw new InvalidScientificResultError(\"Filtered shutter time must remain finite.\");\n }\n\n const value: FilteredExposureTime = {\n shutterSeconds: input.shutterSeconds,\n filteredShutterSeconds,\n linearTransmissionFactor,\n attenuationStops: filterTransmission.combinedAttenuationStops,\n exposureFactor,\n wavelengthNanometers: filterTransmission.wavelengthNanometers,\n filterTransmission,\n ...(input.shutterDialSeconds === undefined ? {} : { dial: nearestDialValue(filteredShutterSeconds, input.shutterDialSeconds) })\n };\n const assumptions = [\n \"Same exposure at the same f-number and ISO: filtered time = shutter time / T, from exposure H = E·t (linear sensor, CIPA DC-004-2020 §5.5; EMVA 1288 §2).\",\n \"Reciprocity failure is not modelled: digital sensors respond linearly to exposure; film does not at long times.\",\n \"T is the filter profiles' combined transmittance; nominal filters vary from their rating and with wavelength.\",\n `Filters resolved at ${filterTransmission.wavelengthNanometers} nm (${wavelengthBasis}); neutral filters do not depend on wavelength.`,\n \"Inter-filter reflections, flare, light leaks around the filter and through the viewfinder are not modelled.\"\n ];\n return filterTransmission.scientificStatus === \"approximation\"\n ? approximationResult(value, \"filtered-exposure-time\", \"1.0.0\", assumptions)\n : calculatedResult(value, \"filtered-exposure-time\", \"1.0.0\", assumptions);\n}\n"]}
@@ -18,6 +18,7 @@ export declare const TTL_FLASH_MODEL: Readonly<{
18
18
  readonly id: "preflash-linear-ttl";
19
19
  readonly version: "1.0.0";
20
20
  }>;
21
+ export declare function requirePositive(value: unknown, name: string): number;
21
22
  export interface FlashGuideNumberInput {
22
23
  /** Guide number at full power for ISO 100, in metres (GN = distance · f-number). */
23
24
  fullPowerGuideNumberMetersIso100: number;
@@ -19,7 +19,7 @@ import { rejectUnknownFields, requireInputKeys, requireInputObject } from "../co
19
19
  import { parseManualFlashProfile } from "./flash.js";
20
20
  import { meterRelativeExposure, parseExposureMeteringProfile } from "./metering.js";
21
21
  export const TTL_FLASH_MODEL = /* @__PURE__ */ Object.freeze({ id: "preflash-linear-ttl", version: "1.0.0" });
22
- function requirePositive(value, name) {
22
+ export function requirePositive(value, name) {
23
23
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
24
24
  throw new InvalidScientificInputError(name + " must be a finite number greater than zero.");
25
25
  }
@@ -1 +1 @@
1
- {"version":3,"file":"flash-ttl.js","sourceRoot":"","sources":["../../src/exposure/flash-ttl.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Flash guide numbers, and through-the-lens (TTL) flash exposure with a pre-flash that does not\n * depend on any manufacturer.\n *\n * How TTL works here:\n * 1. The pre-flash is measured with the existing relative exposure meter.\n * 2. The solved power fraction is applied to a ManualFlashProfile.\n * 3. That pulse is placed by the existing front/rear-curtain sync (resolveManualFlashSync) or by\n * high-speed sync (resolveHighSpeedFlashSync).\n * Flash metering stays separate from ambient metering. The ambient meter result is an input and is\n * not changed. No vendor's TTL algorithm, zone weighting table or flash protocol is copied.\n * @see docs/SCENE_SENSOR_TEMPORAL_EXPOSURE.md#flash-guide-number-ttl-and-high-speed-sync\n */\n\nimport { approximationResult, type CalculationResult } from \"../core/calculation-result.js\";\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport { InvalidScientificInputError } from \"../core/validation.js\";\nimport { rejectUnknownFields, requireInputKeys, requireInputObject } from \"../core/record-validation.js\";\nimport { parseManualFlashProfile, type ManualFlashProfile } from \"./flash.js\";\nimport {\n meterRelativeExposure,\n parseExposureMeteringProfile,\n type ExposureMeteringProfile,\n type ExposureMeteringSampleSet\n} from \"./metering.js\";\n\nexport const TTL_FLASH_MODEL = /* @__PURE__ */ Object.freeze({ id: \"preflash-linear-ttl\", version: \"1.0.0\" } as const);\n\nfunction requirePositive(value: unknown, name: string): number {\n if (typeof value !== \"number\" || !Number.isFinite(value) || value <= 0) {\n throw new InvalidScientificInputError(name + \" must be a finite number greater than zero.\");\n }\n return value;\n}\n\nfunction requireFraction(value: unknown, name: string): number {\n const parsed = requirePositive(value, name);\n if (parsed > 1) throw new InvalidScientificInputError(name + \" must lie in (0, 1].\");\n return parsed;\n}\n\nexport interface FlashGuideNumberInput {\n /** Guide number at full power for ISO 100, in metres (GN = distance · f-number). */\n fullPowerGuideNumberMetersIso100: number;\n /** Output as a fraction of full power, (0, 1]. */\n powerFraction: number;\n iso: number;\n}\n\n/**\n * Effective guide number GN = GN_full · √p · √(ISO/100).\n *\n * The guide number relation GN = d·N follows from the inverse-square law. Light on the subject from a\n * point-like flash falls as 1/d², and sensor exposure falls as 1/N². So exposure stays correct while\n * d·N is constant (camera equation H = 0.65·L·t/N² and reference exposure H = 10/S, CIPA DC-004-2020,\n * free). Exposure grows linearly with emitted energy (power fraction p), and the exposure needed falls\n * inversely with ISO speed. So GN ∝ √p·√ISO. The engine does not model how a guide number is measured;\n * the caller supplies it.\n * @see docs/SCENE_SENSOR_TEMPORAL_EXPOSURE.md for the full derivation.\n */\nexport function calculateFlashGuideNumber(input: FlashGuideNumberInput): number {\n requireInputKeys(input, \"calculateFlashGuideNumber\", [\"fullPowerGuideNumberMetersIso100\", \"powerFraction\", \"iso\"]);\n return requirePositive(input.fullPowerGuideNumberMetersIso100, \"fullPowerGuideNumberMetersIso100\") *\n Math.sqrt(requireFraction(input.powerFraction, \"powerFraction\")) *\n Math.sqrt(requirePositive(input.iso, \"iso\") / 100);\n}\n\nexport interface FlashPowerForDistanceInput {\n fullPowerGuideNumberMetersIso100: number;\n subjectDistanceM: number;\n apertureFNumber: number;\n iso: number;\n /** Lowest available power fraction, (0, 1]. */\n minimumPowerFraction: number;\n}\n\nexport interface FlashPowerForDistance {\n /** p = (d·N / (GN_full·√(ISO/100)))², before clamping. */\n requiredPowerFraction: number;\n powerFraction: number;\n status: \"within-range\" | \"clamped-at-minimum-power\" | \"clamped-at-full-power\";\n /** log2(delivered / required) flash exposure; positive is overexposure. */\n flashExposureErrorStops: number;\n /** GN_full·√(ISO/100)/N: the farthest correctly exposed distance at full power. */\n maximumDistanceM: number;\n}\n\n/** Manual guide-number exposure: the power fraction that exposes a subject at distance d correctly. */\nexport function calculateFlashPowerForDistance(input: FlashPowerForDistanceInput): FlashPowerForDistance {\n requireInputKeys(input, \"calculateFlashPowerForDistance\", [\"fullPowerGuideNumberMetersIso100\", \"subjectDistanceM\",\n \"apertureFNumber\", \"iso\", \"minimumPowerFraction\"]);\n const gnIso = calculateFlashGuideNumber({ fullPowerGuideNumberMetersIso100: input.fullPowerGuideNumberMetersIso100,\n powerFraction: 1, iso: input.iso });\n const distance = requirePositive(input.subjectDistanceM, \"subjectDistanceM\");\n const aperture = requirePositive(input.apertureFNumber, \"apertureFNumber\");\n const minimum = requireFraction(input.minimumPowerFraction, \"minimumPowerFraction\");\n const required = (distance * aperture / gnIso) ** 2;\n const power = Math.min(1, Math.max(minimum, required));\n return { requiredPowerFraction: required, powerFraction: power,\n status: required < minimum ? \"clamped-at-minimum-power\" : required > 1 ? \"clamped-at-full-power\" : \"within-range\",\n flashExposureErrorStops: Math.log2(power / required), maximumDistanceM: gnIso / aperture };\n}\n\nexport interface ResolveTtlFlashExposureInput {\n /** Spatially linear metering profile (multi-zone-uniform, center-weighted-radial or spot). */\n meteringProfile: ExposureMeteringProfile;\n /** Measurement-window samples with the flash off. */\n ambientSampleSet: ExposureMeteringSampleSet;\n /** The same zones and settings with the pre-flash fired. */\n preFlashSampleSet: ExposureMeteringSampleSet;\n /** Pre-flash output as a fraction of full power, (0, 1]. */\n preFlashPowerFraction: number;\n /**\n * Ratio of ambient light collected in the main exposure to that in the measurement window, at the\n * same aperture and ISO (for example t_main/t_measure). Must be ≥ 0. The flash pulse falls inside\n * both windows, so it is not scaled.\n */\n ambientScaleToMainExposure: number;\n /** Flash exposure compensation applied to the TTL target, in stops. */\n flashExposureCompensationStops: number;\n /** Lowest available main-flash power fraction, (0, 1]. */\n minimumPowerFraction: number;\n /**\n * Flash energy each line receives, relative to an ordinary pulse at the same power fraction. 1 for\n * front/rear-curtain sync; η·t_e/D for high-speed sync (see resolveHighSpeedFlashSync). Defaults to 1.\n */\n syncEnergyFactor?: number;\n /** Optional guide-number reporting. */\n guideNumber?: { fullPowerGuideNumberMetersIso100: number; iso: number; apertureFNumber: number };\n}\n\nexport interface TtlFlashExposure {\n modelId: typeof TTL_FLASH_MODEL.id;\n modelVersion: typeof TTL_FLASH_MODEL.version;\n status: \"resolved\" | \"clamped-at-minimum-power\" | \"clamped-at-full-power\" | \"no-flash-return\";\n /** Metered ambient signal in the main exposure: k·M(ambient). */\n ambientMainExposureMeteredSignal: number;\n /** Metered flash-only signal per unit full power: M(preflash − ambient)/p_pre. */\n flashMeteredSignalPerFullPower: number;\n /** T·2^FEC. */\n targetRelativeSignal: number;\n /** Unclamped solution (T' − k·A)/(F·syncEnergyFactor); null with no flash return. */\n requiredPowerFraction: number | null;\n /** Commanded main-flash power fraction (clamped); null with no flash return. */\n powerFraction: number | null;\n ambientAloneReachesTarget: boolean;\n predictedMeteredSignal: number;\n /** log2(predicted / target); 0 when the solution is within range; null when nothing is predicted. */\n predictedExposureErrorStops: number | null;\n effectiveGuideNumberMetersAtIso: number | null;\n flashRangeAtApertureM: number | null;\n preFlashInsideMainExposure: false;\n ambientMeteringModified: false;\n}\n\nconst LINEAR_POLICIES = new Set([\"multi-zone-uniform\", \"center-weighted-radial\", \"spot\"]);\n\nfunction sameZones(a: ExposureMeteringSampleSet, b: ExposureMeteringSampleSet): void {\n if (a.sceneStateId !== b.sceneStateId || a.samples.length !== b.samples.length ||\n JSON.stringify(a.captureGeometry) !== JSON.stringify(b.captureGeometry)) {\n throw new InvalidScientificInputError(\"Ambient and pre-flash measurements must share scene state, geometry and zones.\");\n }\n a.samples.forEach((sample, index) => {\n const other = b.samples[index]!;\n requireInputObject(sample, \"ambientSampleSet.samples[\" + index + \"]\");\n requireInputObject(other, \"preFlashSampleSet.samples[\" + index + \"]\");\n requireInputObject(sample.positionOrientedCaptureUv, \"ambientSampleSet.samples[\" + index + \"].positionOrientedCaptureUv\");\n requireInputObject(other.positionOrientedCaptureUv, \"preFlashSampleSet.samples[\" + index + \"].positionOrientedCaptureUv\");\n if (sample.sampleId !== other.sampleId || sample.areaWeight !== other.areaWeight ||\n sample.positionOrientedCaptureUv.u !== other.positionOrientedCaptureUv.u ||\n sample.positionOrientedCaptureUv.v !== other.positionOrientedCaptureUv.v) {\n throw new InvalidScientificInputError(\"Ambient and pre-flash zones must match exactly and in order.\");\n }\n });\n}\n\n/**\n * Solves the main-flash power from a pre-flash measurement (manufacturer-independent TTL).\n *\n * The meter is linear for the policies allowed here: M(a + b) = M(a) + M(b).\n * - The pre-flash at power p_pre adds a flash-only signal f_i = pre_i − ambient_i in each zone.\n * - Flash signal is proportional to emitted energy, so at main power p it adds (p/p_pre)·M(f).\n * - With ambient scaled to the main exposure by k, the metered total is k·A + p·F, where\n * F = M(f)/p_pre.\n * - Setting this equal to the target T' = T·2^FEC gives p = (T' − k·A)/(F·s). Here s is the sync\n * energy factor (1 for ordinary sync, η·t_e/D for high-speed sync).\n * - p is clamped to [p_min, 1], the guide-number range.\n * Highlight policies are rejected: their weights depend on the signal, so the total is no longer\n * linear in p.\n */\nexport function resolveTtlFlashExposure(input: ResolveTtlFlashExposureInput): CalculationResult<TtlFlashExposure> {\n requireInputKeys(input, \"resolveTtlFlashExposure\", [\"meteringProfile\", \"ambientSampleSet\", \"preFlashSampleSet\", \"preFlashPowerFraction\", \"ambientScaleToMainExposure\", \"flashExposureCompensationStops\", \"minimumPowerFraction\", \"syncEnergyFactor\", \"guideNumber\"]);\n requireInputObject(input.ambientSampleSet, \"resolveTtlFlashExposure ambientSampleSet\");\n requireInputObject(input.preFlashSampleSet, \"resolveTtlFlashExposure preFlashSampleSet\");\n if (input.guideNumber !== undefined) {\n requireInputObject(input.guideNumber, \"resolveTtlFlashExposure guideNumber\");\n rejectUnknownFields(input.guideNumber, [\"fullPowerGuideNumberMetersIso100\", \"iso\", \"apertureFNumber\"], \"resolveTtlFlashExposure guideNumber\");\n }\n if (!Array.isArray(input.ambientSampleSet.samples) || !Array.isArray(input.preFlashSampleSet.samples)) {\n throw new InvalidConfigurationError(\"TTL sample sets must contain sample arrays.\");\n }\n const profile = parseExposureMeteringProfile(input.meteringProfile);\n if (!LINEAR_POLICIES.has(profile.policy.kind)) {\n throw new InvalidScientificInputError(\n \"TTL flash requires a spatially linear metering policy (multi-zone-uniform, center-weighted-radial or spot).\"\n );\n }\n sameZones(input.ambientSampleSet, input.preFlashSampleSet);\n const preFlash = requireFraction(input.preFlashPowerFraction, \"preFlashPowerFraction\");\n const minimum = requireFraction(input.minimumPowerFraction, \"minimumPowerFraction\");\n const k = input.ambientScaleToMainExposure;\n if (typeof k !== \"number\" || !Number.isFinite(k) || k < 0) {\n throw new InvalidScientificInputError(\"ambientScaleToMainExposure must be a finite number greater than or equal to zero.\");\n }\n const fec = input.flashExposureCompensationStops;\n if (typeof fec !== \"number\" || !Number.isFinite(fec)) {\n throw new InvalidScientificInputError(\"flashExposureCompensationStops must be finite.\");\n }\n const syncFactor = input.syncEnergyFactor === undefined ? 1 : requireFraction(input.syncEnergyFactor, \"syncEnergyFactor\");\n const flashOnly: ExposureMeteringSampleSet = { ...input.preFlashSampleSet,\n measurementId: input.preFlashSampleSet.measurementId + \":flash-only\",\n samples: input.preFlashSampleSet.samples.map((sample, index) => {\n const difference = sample.relativeLinearSignal - input.ambientSampleSet.samples[index]!.relativeLinearSignal;\n if (!(difference >= 0)) {\n throw new InvalidScientificInputError(\"Pre-flash signal must not be below the ambient signal in any zone.\");\n }\n return { ...sample, relativeLinearSignal: difference };\n }) };\n const ambient = meterRelativeExposure({ profile, sampleSet: input.ambientSampleSet }).value.meteredRelativeSignal;\n const flashPerFull = meterRelativeExposure({ profile, sampleSet: flashOnly }).value.meteredRelativeSignal / preFlash;\n const ambientMain = k * ambient;\n const target = profile.target.targetRelativeSignal * Math.pow(2, fec);\n const deliveredPerFull = flashPerFull * syncFactor;\n let status: TtlFlashExposure[\"status\"], required: number | null, power: number | null;\n if (deliveredPerFull === 0) {\n status = \"no-flash-return\"; required = null; power = null;\n } else {\n required = (target - ambientMain) / deliveredPerFull;\n power = Math.min(1, Math.max(minimum, required));\n status = required < minimum ? \"clamped-at-minimum-power\" : required > 1 ? \"clamped-at-full-power\" : \"resolved\";\n }\n const predicted = ambientMain + (power ?? 0) * deliveredPerFull;\n const gn = input.guideNumber;\n const effectiveGn = gn === undefined || power === null ? null : calculateFlashGuideNumber({\n fullPowerGuideNumberMetersIso100: gn.fullPowerGuideNumberMetersIso100, powerFraction: power, iso: gn.iso }) * Math.sqrt(syncFactor);\n return approximationResult({\n modelId: TTL_FLASH_MODEL.id,\n modelVersion: TTL_FLASH_MODEL.version,\n status,\n ambientMainExposureMeteredSignal: ambientMain,\n flashMeteredSignalPerFullPower: flashPerFull,\n targetRelativeSignal: target,\n requiredPowerFraction: required,\n powerFraction: power,\n ambientAloneReachesTarget: ambientMain >= target,\n predictedMeteredSignal: predicted,\n predictedExposureErrorStops: predicted > 0 ? Math.log2(predicted / target) : null,\n effectiveGuideNumberMetersAtIso: effectiveGn,\n flashRangeAtApertureM: effectiveGn === null || gn === undefined ? null : effectiveGn / requirePositive(gn.apertureFNumber, \"apertureFNumber\"),\n preFlashInsideMainExposure: false,\n ambientMeteringModified: false\n }, TTL_FLASH_MODEL.id, TTL_FLASH_MODEL.version, [\n \"Relative pre-exposure meter signals only; no calibrated flash energy, subject reflectance or distance is inferred.\",\n \"Flash signal is assumed proportional to emitted energy and fully inside the main exposure (pulse) or each line's slit (HSS factor).\",\n \"The pre-flash precedes the main exposure; subject motion and ambient change between pre-flash and exposure are not modeled.\",\n \"This is a generic linear pre-flash solution, not any manufacturer's TTL algorithm, zone weighting or flash protocol.\"\n ]);\n}\n\n/**\n * Returns a copy of a manual flash profile with its relative output scaled by a resolved TTL power\n * fraction, ready for resolveManualFlashSync (front/rear curtain). For high-speed sync, instead pass\n * the unscaled profile, and pass `ttl.powerFraction` as resolveHighSpeedFlashSync's powerFraction.\n * That way power is applied only once.\n */\nexport function createTtlScaledFlashProfile(flashProfile: ManualFlashProfile, ttl: TtlFlashExposure): ManualFlashProfile {\n const flash = parseManualFlashProfile(flashProfile);\n requireInputObject(ttl, \"createTtlScaledFlashProfile ttl\");\n if (ttl.powerFraction === null || typeof ttl.powerFraction !== \"number\") {\n throw new InvalidScientificInputError(\"TTL found no flash return; there is no power fraction to apply.\");\n }\n return { ...flash, outputControl: { ...flash.outputControl, scale: flash.outputControl.scale * ttl.powerFraction } };\n}\n"]}
1
+ {"version":3,"file":"flash-ttl.js","sourceRoot":"","sources":["../../src/exposure/flash-ttl.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Flash guide numbers, and through-the-lens (TTL) flash exposure with a pre-flash that does not\n * depend on any manufacturer.\n *\n * How TTL works here:\n * 1. The pre-flash is measured with the existing relative exposure meter.\n * 2. The solved power fraction is applied to a ManualFlashProfile.\n * 3. That pulse is placed by the existing front/rear-curtain sync (resolveManualFlashSync) or by\n * high-speed sync (resolveHighSpeedFlashSync).\n * Flash metering stays separate from ambient metering. The ambient meter result is an input and is\n * not changed. No vendor's TTL algorithm, zone weighting table or flash protocol is copied.\n * @see docs/SCENE_SENSOR_TEMPORAL_EXPOSURE.md#flash-guide-number-ttl-and-high-speed-sync\n */\n\nimport { approximationResult, type CalculationResult } from \"../core/calculation-result.js\";\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport { InvalidScientificInputError } from \"../core/validation.js\";\nimport { rejectUnknownFields, requireInputKeys, requireInputObject } from \"../core/record-validation.js\";\nimport { parseManualFlashProfile, type ManualFlashProfile } from \"./flash.js\";\nimport {\n meterRelativeExposure,\n parseExposureMeteringProfile,\n type ExposureMeteringProfile,\n type ExposureMeteringSampleSet\n} from \"./metering.js\";\n\nexport const TTL_FLASH_MODEL = /* @__PURE__ */ Object.freeze({ id: \"preflash-linear-ttl\", version: \"1.0.0\" } as const);\n\nexport function requirePositive(value: unknown, name: string): number {\n if (typeof value !== \"number\" || !Number.isFinite(value) || value <= 0) {\n throw new InvalidScientificInputError(name + \" must be a finite number greater than zero.\");\n }\n return value;\n}\n\nfunction requireFraction(value: unknown, name: string): number {\n const parsed = requirePositive(value, name);\n if (parsed > 1) throw new InvalidScientificInputError(name + \" must lie in (0, 1].\");\n return parsed;\n}\n\nexport interface FlashGuideNumberInput {\n /** Guide number at full power for ISO 100, in metres (GN = distance · f-number). */\n fullPowerGuideNumberMetersIso100: number;\n /** Output as a fraction of full power, (0, 1]. */\n powerFraction: number;\n iso: number;\n}\n\n/**\n * Effective guide number GN = GN_full · √p · √(ISO/100).\n *\n * The guide number relation GN = d·N follows from the inverse-square law. Light on the subject from a\n * point-like flash falls as 1/d², and sensor exposure falls as 1/N². So exposure stays correct while\n * d·N is constant (camera equation H = 0.65·L·t/N² and reference exposure H = 10/S, CIPA DC-004-2020,\n * free). Exposure grows linearly with emitted energy (power fraction p), and the exposure needed falls\n * inversely with ISO speed. So GN ∝ √p·√ISO. The engine does not model how a guide number is measured;\n * the caller supplies it.\n * @see docs/SCENE_SENSOR_TEMPORAL_EXPOSURE.md for the full derivation.\n */\nexport function calculateFlashGuideNumber(input: FlashGuideNumberInput): number {\n requireInputKeys(input, \"calculateFlashGuideNumber\", [\"fullPowerGuideNumberMetersIso100\", \"powerFraction\", \"iso\"]);\n return requirePositive(input.fullPowerGuideNumberMetersIso100, \"fullPowerGuideNumberMetersIso100\") *\n Math.sqrt(requireFraction(input.powerFraction, \"powerFraction\")) *\n Math.sqrt(requirePositive(input.iso, \"iso\") / 100);\n}\n\nexport interface FlashPowerForDistanceInput {\n fullPowerGuideNumberMetersIso100: number;\n subjectDistanceM: number;\n apertureFNumber: number;\n iso: number;\n /** Lowest available power fraction, (0, 1]. */\n minimumPowerFraction: number;\n}\n\nexport interface FlashPowerForDistance {\n /** p = (d·N / (GN_full·√(ISO/100)))², before clamping. */\n requiredPowerFraction: number;\n powerFraction: number;\n status: \"within-range\" | \"clamped-at-minimum-power\" | \"clamped-at-full-power\";\n /** log2(delivered / required) flash exposure; positive is overexposure. */\n flashExposureErrorStops: number;\n /** GN_full·√(ISO/100)/N: the farthest correctly exposed distance at full power. */\n maximumDistanceM: number;\n}\n\n/** Manual guide-number exposure: the power fraction that exposes a subject at distance d correctly. */\nexport function calculateFlashPowerForDistance(input: FlashPowerForDistanceInput): FlashPowerForDistance {\n requireInputKeys(input, \"calculateFlashPowerForDistance\", [\"fullPowerGuideNumberMetersIso100\", \"subjectDistanceM\",\n \"apertureFNumber\", \"iso\", \"minimumPowerFraction\"]);\n const gnIso = calculateFlashGuideNumber({ fullPowerGuideNumberMetersIso100: input.fullPowerGuideNumberMetersIso100,\n powerFraction: 1, iso: input.iso });\n const distance = requirePositive(input.subjectDistanceM, \"subjectDistanceM\");\n const aperture = requirePositive(input.apertureFNumber, \"apertureFNumber\");\n const minimum = requireFraction(input.minimumPowerFraction, \"minimumPowerFraction\");\n const required = (distance * aperture / gnIso) ** 2;\n const power = Math.min(1, Math.max(minimum, required));\n return { requiredPowerFraction: required, powerFraction: power,\n status: required < minimum ? \"clamped-at-minimum-power\" : required > 1 ? \"clamped-at-full-power\" : \"within-range\",\n flashExposureErrorStops: Math.log2(power / required), maximumDistanceM: gnIso / aperture };\n}\n\nexport interface ResolveTtlFlashExposureInput {\n /** Spatially linear metering profile (multi-zone-uniform, center-weighted-radial or spot). */\n meteringProfile: ExposureMeteringProfile;\n /** Measurement-window samples with the flash off. */\n ambientSampleSet: ExposureMeteringSampleSet;\n /** The same zones and settings with the pre-flash fired. */\n preFlashSampleSet: ExposureMeteringSampleSet;\n /** Pre-flash output as a fraction of full power, (0, 1]. */\n preFlashPowerFraction: number;\n /**\n * Ratio of ambient light collected in the main exposure to that in the measurement window, at the\n * same aperture and ISO (for example t_main/t_measure). Must be ≥ 0. The flash pulse falls inside\n * both windows, so it is not scaled.\n */\n ambientScaleToMainExposure: number;\n /** Flash exposure compensation applied to the TTL target, in stops. */\n flashExposureCompensationStops: number;\n /** Lowest available main-flash power fraction, (0, 1]. */\n minimumPowerFraction: number;\n /**\n * Flash energy each line receives, relative to an ordinary pulse at the same power fraction. 1 for\n * front/rear-curtain sync; η·t_e/D for high-speed sync (see resolveHighSpeedFlashSync). Defaults to 1.\n */\n syncEnergyFactor?: number;\n /** Optional guide-number reporting. */\n guideNumber?: { fullPowerGuideNumberMetersIso100: number; iso: number; apertureFNumber: number };\n}\n\nexport interface TtlFlashExposure {\n modelId: typeof TTL_FLASH_MODEL.id;\n modelVersion: typeof TTL_FLASH_MODEL.version;\n status: \"resolved\" | \"clamped-at-minimum-power\" | \"clamped-at-full-power\" | \"no-flash-return\";\n /** Metered ambient signal in the main exposure: k·M(ambient). */\n ambientMainExposureMeteredSignal: number;\n /** Metered flash-only signal per unit full power: M(preflash − ambient)/p_pre. */\n flashMeteredSignalPerFullPower: number;\n /** T·2^FEC. */\n targetRelativeSignal: number;\n /** Unclamped solution (T' − k·A)/(F·syncEnergyFactor); null with no flash return. */\n requiredPowerFraction: number | null;\n /** Commanded main-flash power fraction (clamped); null with no flash return. */\n powerFraction: number | null;\n ambientAloneReachesTarget: boolean;\n predictedMeteredSignal: number;\n /** log2(predicted / target); 0 when the solution is within range; null when nothing is predicted. */\n predictedExposureErrorStops: number | null;\n effectiveGuideNumberMetersAtIso: number | null;\n flashRangeAtApertureM: number | null;\n preFlashInsideMainExposure: false;\n ambientMeteringModified: false;\n}\n\nconst LINEAR_POLICIES = new Set([\"multi-zone-uniform\", \"center-weighted-radial\", \"spot\"]);\n\nfunction sameZones(a: ExposureMeteringSampleSet, b: ExposureMeteringSampleSet): void {\n if (a.sceneStateId !== b.sceneStateId || a.samples.length !== b.samples.length ||\n JSON.stringify(a.captureGeometry) !== JSON.stringify(b.captureGeometry)) {\n throw new InvalidScientificInputError(\"Ambient and pre-flash measurements must share scene state, geometry and zones.\");\n }\n a.samples.forEach((sample, index) => {\n const other = b.samples[index]!;\n requireInputObject(sample, \"ambientSampleSet.samples[\" + index + \"]\");\n requireInputObject(other, \"preFlashSampleSet.samples[\" + index + \"]\");\n requireInputObject(sample.positionOrientedCaptureUv, \"ambientSampleSet.samples[\" + index + \"].positionOrientedCaptureUv\");\n requireInputObject(other.positionOrientedCaptureUv, \"preFlashSampleSet.samples[\" + index + \"].positionOrientedCaptureUv\");\n if (sample.sampleId !== other.sampleId || sample.areaWeight !== other.areaWeight ||\n sample.positionOrientedCaptureUv.u !== other.positionOrientedCaptureUv.u ||\n sample.positionOrientedCaptureUv.v !== other.positionOrientedCaptureUv.v) {\n throw new InvalidScientificInputError(\"Ambient and pre-flash zones must match exactly and in order.\");\n }\n });\n}\n\n/**\n * Solves the main-flash power from a pre-flash measurement (manufacturer-independent TTL).\n *\n * The meter is linear for the policies allowed here: M(a + b) = M(a) + M(b).\n * - The pre-flash at power p_pre adds a flash-only signal f_i = pre_i − ambient_i in each zone.\n * - Flash signal is proportional to emitted energy, so at main power p it adds (p/p_pre)·M(f).\n * - With ambient scaled to the main exposure by k, the metered total is k·A + p·F, where\n * F = M(f)/p_pre.\n * - Setting this equal to the target T' = T·2^FEC gives p = (T' − k·A)/(F·s). Here s is the sync\n * energy factor (1 for ordinary sync, η·t_e/D for high-speed sync).\n * - p is clamped to [p_min, 1], the guide-number range.\n * Highlight policies are rejected: their weights depend on the signal, so the total is no longer\n * linear in p.\n */\nexport function resolveTtlFlashExposure(input: ResolveTtlFlashExposureInput): CalculationResult<TtlFlashExposure> {\n requireInputKeys(input, \"resolveTtlFlashExposure\", [\"meteringProfile\", \"ambientSampleSet\", \"preFlashSampleSet\", \"preFlashPowerFraction\", \"ambientScaleToMainExposure\", \"flashExposureCompensationStops\", \"minimumPowerFraction\", \"syncEnergyFactor\", \"guideNumber\"]);\n requireInputObject(input.ambientSampleSet, \"resolveTtlFlashExposure ambientSampleSet\");\n requireInputObject(input.preFlashSampleSet, \"resolveTtlFlashExposure preFlashSampleSet\");\n if (input.guideNumber !== undefined) {\n requireInputObject(input.guideNumber, \"resolveTtlFlashExposure guideNumber\");\n rejectUnknownFields(input.guideNumber, [\"fullPowerGuideNumberMetersIso100\", \"iso\", \"apertureFNumber\"], \"resolveTtlFlashExposure guideNumber\");\n }\n if (!Array.isArray(input.ambientSampleSet.samples) || !Array.isArray(input.preFlashSampleSet.samples)) {\n throw new InvalidConfigurationError(\"TTL sample sets must contain sample arrays.\");\n }\n const profile = parseExposureMeteringProfile(input.meteringProfile);\n if (!LINEAR_POLICIES.has(profile.policy.kind)) {\n throw new InvalidScientificInputError(\n \"TTL flash requires a spatially linear metering policy (multi-zone-uniform, center-weighted-radial or spot).\"\n );\n }\n sameZones(input.ambientSampleSet, input.preFlashSampleSet);\n const preFlash = requireFraction(input.preFlashPowerFraction, \"preFlashPowerFraction\");\n const minimum = requireFraction(input.minimumPowerFraction, \"minimumPowerFraction\");\n const k = input.ambientScaleToMainExposure;\n if (typeof k !== \"number\" || !Number.isFinite(k) || k < 0) {\n throw new InvalidScientificInputError(\"ambientScaleToMainExposure must be a finite number greater than or equal to zero.\");\n }\n const fec = input.flashExposureCompensationStops;\n if (typeof fec !== \"number\" || !Number.isFinite(fec)) {\n throw new InvalidScientificInputError(\"flashExposureCompensationStops must be finite.\");\n }\n const syncFactor = input.syncEnergyFactor === undefined ? 1 : requireFraction(input.syncEnergyFactor, \"syncEnergyFactor\");\n const flashOnly: ExposureMeteringSampleSet = { ...input.preFlashSampleSet,\n measurementId: input.preFlashSampleSet.measurementId + \":flash-only\",\n samples: input.preFlashSampleSet.samples.map((sample, index) => {\n const difference = sample.relativeLinearSignal - input.ambientSampleSet.samples[index]!.relativeLinearSignal;\n if (!(difference >= 0)) {\n throw new InvalidScientificInputError(\"Pre-flash signal must not be below the ambient signal in any zone.\");\n }\n return { ...sample, relativeLinearSignal: difference };\n }) };\n const ambient = meterRelativeExposure({ profile, sampleSet: input.ambientSampleSet }).value.meteredRelativeSignal;\n const flashPerFull = meterRelativeExposure({ profile, sampleSet: flashOnly }).value.meteredRelativeSignal / preFlash;\n const ambientMain = k * ambient;\n const target = profile.target.targetRelativeSignal * Math.pow(2, fec);\n const deliveredPerFull = flashPerFull * syncFactor;\n let status: TtlFlashExposure[\"status\"], required: number | null, power: number | null;\n if (deliveredPerFull === 0) {\n status = \"no-flash-return\"; required = null; power = null;\n } else {\n required = (target - ambientMain) / deliveredPerFull;\n power = Math.min(1, Math.max(minimum, required));\n status = required < minimum ? \"clamped-at-minimum-power\" : required > 1 ? \"clamped-at-full-power\" : \"resolved\";\n }\n const predicted = ambientMain + (power ?? 0) * deliveredPerFull;\n const gn = input.guideNumber;\n const effectiveGn = gn === undefined || power === null ? null : calculateFlashGuideNumber({\n fullPowerGuideNumberMetersIso100: gn.fullPowerGuideNumberMetersIso100, powerFraction: power, iso: gn.iso }) * Math.sqrt(syncFactor);\n return approximationResult({\n modelId: TTL_FLASH_MODEL.id,\n modelVersion: TTL_FLASH_MODEL.version,\n status,\n ambientMainExposureMeteredSignal: ambientMain,\n flashMeteredSignalPerFullPower: flashPerFull,\n targetRelativeSignal: target,\n requiredPowerFraction: required,\n powerFraction: power,\n ambientAloneReachesTarget: ambientMain >= target,\n predictedMeteredSignal: predicted,\n predictedExposureErrorStops: predicted > 0 ? Math.log2(predicted / target) : null,\n effectiveGuideNumberMetersAtIso: effectiveGn,\n flashRangeAtApertureM: effectiveGn === null || gn === undefined ? null : effectiveGn / requirePositive(gn.apertureFNumber, \"apertureFNumber\"),\n preFlashInsideMainExposure: false,\n ambientMeteringModified: false\n }, TTL_FLASH_MODEL.id, TTL_FLASH_MODEL.version, [\n \"Relative pre-exposure meter signals only; no calibrated flash energy, subject reflectance or distance is inferred.\",\n \"Flash signal is assumed proportional to emitted energy and fully inside the main exposure (pulse) or each line's slit (HSS factor).\",\n \"The pre-flash precedes the main exposure; subject motion and ambient change between pre-flash and exposure are not modeled.\",\n \"This is a generic linear pre-flash solution, not any manufacturer's TTL algorithm, zone weighting or flash protocol.\"\n ]);\n}\n\n/**\n * Returns a copy of a manual flash profile with its relative output scaled by a resolved TTL power\n * fraction, ready for resolveManualFlashSync (front/rear curtain). For high-speed sync, instead pass\n * the unscaled profile, and pass `ttl.powerFraction` as resolveHighSpeedFlashSync's powerFraction.\n * That way power is applied only once.\n */\nexport function createTtlScaledFlashProfile(flashProfile: ManualFlashProfile, ttl: TtlFlashExposure): ManualFlashProfile {\n const flash = parseManualFlashProfile(flashProfile);\n requireInputObject(ttl, \"createTtlScaledFlashProfile ttl\");\n if (ttl.powerFraction === null || typeof ttl.powerFraction !== \"number\") {\n throw new InvalidScientificInputError(\"TTL found no flash return; there is no power fraction to apply.\");\n }\n return { ...flash, outputControl: { ...flash.outputControl, scale: flash.outputControl.scale * ttl.powerFraction } };\n}\n"]}
@@ -20,7 +20,7 @@ import { parseEvidenceList } from "../core/evidence-provenance.js";
20
20
  import { InvalidScientificInputError } from "../core/validation.js";
21
21
  import { parseSceneIlluminationProfile } from "../schema/illumination.js";
22
22
  import { parseSceneIlluminationTemporalProfile } from "../schema/illumination-temporal.js";
23
- import { requireInputObject, requireInputKeys, rejectUnknownFields } from "../core/record-validation.js";
23
+ import { requireInputObject, requireInputKeys, requireConfigRecord as requireRecord, requireTrimmedConfigString as requireNonEmptyString, requireConfigFinite as requireFinite, parseConfigLimitations as parseLimitations, requireConfigFiniteAboveZero as requirePositiveFinite } from "../core/record-validation.js";
24
24
  export const MANUAL_FLASH_PROFILE_SCHEMA_VERSION = "0.1.0";
25
25
  export const FLASH_SYNC_CAPABILITY_SCHEMA_VERSION = "0.1.0";
26
26
  /** Sync-capability schema that may declare high-speed sync (a fast pulse train that acts as continuous light). */
@@ -34,54 +34,6 @@ const ORDINARY_SYNC_MODES = new Set([
34
34
  "front-curtain",
35
35
  "rear-curtain"
36
36
  ]);
37
- function requireRecord(value, path, allowedKeys) {
38
- if (typeof value !== "object" ||
39
- value === null ||
40
- Array.isArray(value)) {
41
- throw new InvalidConfigurationError(path + " must be an object.");
42
- }
43
- if (allowedKeys !== null)
44
- rejectUnknownFields(value, allowedKeys, path);
45
- return value;
46
- }
47
- function requireNonEmptyString(value, path) {
48
- if (typeof value !== "string" ||
49
- value.trim().length === 0) {
50
- throw new InvalidConfigurationError(path +
51
- " must be a non-empty string.");
52
- }
53
- return value.trim();
54
- }
55
- function requireFinite(value, path) {
56
- if (typeof value !== "number" ||
57
- !Number.isFinite(value)) {
58
- throw new InvalidConfigurationError(path + " must be finite.");
59
- }
60
- return value;
61
- }
62
- function requirePositiveFinite(value, path) {
63
- const parsed = requireFinite(value, path);
64
- if (parsed <= 0) {
65
- throw new InvalidConfigurationError(path +
66
- " must be greater than zero.");
67
- }
68
- return parsed;
69
- }
70
- function parseLimitations(value, path) {
71
- if (!Array.isArray(value)) {
72
- throw new InvalidConfigurationError(path + " must be an array.");
73
- }
74
- const parsed = value.map((entry, index) => requireNonEmptyString(entry, path +
75
- "[" +
76
- index +
77
- "]"));
78
- if (new Set(parsed).size !==
79
- parsed.length) {
80
- throw new InvalidConfigurationError(path +
81
- " must not contain duplicates.");
82
- }
83
- return parsed;
84
- }
85
37
  function parsePulseSamples(value, supportDurationSeconds, path) {
86
38
  if (!Array.isArray(value) ||
87
39
  value.length < 2) {