@photivra/engine 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/dist/capture/export-jpeg.js +7 -1
  6. package/dist/capture/export-jpeg.js.map +1 -1
  7. package/dist/capture/native-environment-lean.js +5 -2
  8. package/dist/capture/native-environment-lean.js.map +1 -1
  9. package/dist/capture/native-environment-raw.js +2 -2
  10. package/dist/capture/native-environment-raw.js.map +1 -1
  11. package/dist/capture/native-raw-development.js +1 -1
  12. package/dist/capture/native-raw-development.js.map +1 -1
  13. package/dist/capture/native-raw.d.ts +16 -0
  14. package/dist/capture/native-raw.js +16 -3
  15. package/dist/capture/native-raw.js.map +1 -1
  16. package/dist/capture/packed-sensor-raw.d.ts +7 -0
  17. package/dist/capture/packed-sensor-raw.js +6 -2
  18. package/dist/capture/packed-sensor-raw.js.map +1 -1
  19. package/dist/core/pixel-limits.d.ts +14 -0
  20. package/dist/core/pixel-limits.js +40 -0
  21. package/dist/core/pixel-limits.js.map +1 -0
  22. package/dist/core/version.d.ts +1 -1
  23. package/dist/core/version.js +1 -1
  24. package/dist/core/version.js.map +1 -1
  25. package/dist/facade/chart-radiance-batch.d.ts +7 -0
  26. package/dist/facade/chart-radiance-batch.js +7 -3
  27. package/dist/facade/chart-radiance-batch.js.map +1 -1
  28. package/dist/facade/simulate-capture.d.ts +8 -1
  29. package/dist/facade/simulate-capture.js +69 -18
  30. package/dist/facade/simulate-capture.js.map +1 -1
  31. package/dist/index.d.ts +2 -1
  32. package/dist/index.js +2 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/motion/temporal-exposure-sum.d.ts +7 -0
  35. package/dist/motion/temporal-exposure-sum.js +6 -2
  36. package/dist/motion/temporal-exposure-sum.js.map +1 -1
  37. package/dist/optics/irradiance-convolution.d.ts +7 -0
  38. package/dist/optics/irradiance-convolution.js +6 -2
  39. package/dist/optics/irradiance-convolution.js.map +1 -1
  40. package/dist/optics/lens-psf-kernels.d.ts +3 -1
  41. package/dist/optics/lens-psf-kernels.js +7 -2
  42. package/dist/optics/lens-psf-kernels.js.map +1 -1
  43. package/dist/output/native-capture-sdr.d.ts +7 -0
  44. package/dist/output/native-capture-sdr.js +6 -3
  45. package/dist/output/native-capture-sdr.js.map +1 -1
  46. package/dist/output/png-encoder.d.ts +10 -1
  47. package/dist/output/png-encoder.js +10 -4
  48. package/dist/output/png-encoder.js.map +1 -1
  49. package/dist/output/print-jpeg.d.ts +18 -0
  50. package/dist/output/print-jpeg.js +15 -4
  51. package/dist/output/print-jpeg.js.map +1 -1
  52. package/dist/sensor/sampling-aperture.d.ts +15 -0
  53. package/dist/sensor/sampling-aperture.js +31 -10
  54. package/dist/sensor/sampling-aperture.js.map +1 -1
  55. package/docs/API_REFERENCE.md +11 -1
  56. package/docs/QUICK_START.md +112 -4
  57. package/docs/USAGE.md +3 -1
  58. package/docs/api/capture-native-raw.md +11 -2
  59. package/docs/api/capture-packed-sensor-raw.md +7 -1
  60. package/docs/api/core-pixel-limits.md +19 -0
  61. package/docs/api/core-version.md +1 -1
  62. package/docs/api/exports.json +17 -2
  63. package/docs/api/facade-chart-radiance-batch.md +8 -2
  64. package/docs/api/facade-simulate-capture.md +8 -1
  65. package/docs/api/motion-temporal-exposure-sum.md +7 -1
  66. package/docs/api/optics-irradiance-convolution.md +7 -1
  67. package/docs/api/output-native-capture-sdr.md +7 -2
  68. package/docs/api/output-png-encoder.md +8 -1
  69. package/docs/api/output-print-jpeg.md +17 -0
  70. package/package.json +1 -1
@@ -43,21 +43,22 @@ import { estimateCie1931ColorMatchingFunctions } from "../color/color-matching-f
43
43
  import { createColorTemperatureWhiteBalanceIntent, resolveColorTemperatureWhiteBalance, resolveCustomWhiteBalance, resolveManualWhiteBalance, estimateAutoWhiteBalance } from "../color/white-balance.js";
44
44
  import { createCaptureWhiteBalanceIntent } from "../capture/simulated-capture.js";
45
45
  import { SENSOR_RAW_PRODUCER_NOISE_MODEL } from "../capture/sensor-raw-producer.js";
46
- import { calculateNativeRawPlan } from "../capture/native-raw.js";
46
+ import { calculateNativeRawPlan, nativeRawMaximumProviderEvaluations, NATIVE_RAW_LIMITS } from "../capture/native-raw.js";
47
47
  import { createNativeEnvironmentRawTask } from "../capture/native-environment-raw.js";
48
48
  import { createNativeRawDevelopmentTask } from "../capture/native-raw-development.js";
49
49
  import { parseSdrRenderingProfile } from "../output/sdr-rendering.js";
50
- import { createPrintJpegTask } from "../output/print-jpeg.js";
50
+ import { createPrintJpegTask, PRINT_JPEG_LIMITS } from "../output/print-jpeg.js";
51
51
  import { checkNativeRawIntegrity, prepareNativeRawDevelopment } from "../capture/native-raw-development.js";
52
52
  import { createRawPixelDeveloper, normalizedRawSite, rawSiteSaturated } from "../capture/raw-development-kernel.js";
53
53
  import { createAhdDemosaicTask } from "../processing/demosaic.js";
54
+ import { PROCESSING_MAX_PIXELS } from "../processing/processing-task.js";
54
55
  import { renderSdrRunInto } from "../output/native-capture-sdr.js";
55
56
  import { prepareSdrPixelTone } from "../output/sdr-rendering.js";
56
57
  import { BUILT_IN_BAYER_DEMOSAIC_MODEL_VERSION as BUILT_IN_DEMOSAIC_VERSION, createBuiltInBayerDemosaicProfiles } from "../sensor/raw-reconstruction.js";
57
58
  import { transformOrientedRasterPointToNative } from "../output/capture-geometry.js";
58
59
  import { resolveCaptureColorModel } from "../color/capture-color.js";
59
60
  import { evaluateBt709Oetf, evaluateSrgbEncoding, resolveRgbColorSpace } from "../color/rgb-color-spaces.js";
60
- import { serializePngImage } from "../output/png-encoder.js";
61
+ import { serializePngImage, PNG_ENCODER_LIMITS } from "../output/png-encoder.js";
61
62
  import { parseLensProjectionModel } from "../camera/lens-projection.js";
62
63
  import { prepareEnvironmentLensFieldMapping } from "../optics/sensor-environment-query.js";
63
64
  import { createRollingShutterCaptureExposureSchedule, resolveElectronicRollingShutterExposureSchedule } from "../sensor/readout-exposure-linkage.js";
@@ -69,7 +70,7 @@ import { resolveAntiAliasingSpatialKernel } from "../sensor/optical-stack.js";
69
70
  import { multiplyMatrices } from "../color/colorimetry-math.js";
70
71
  import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
71
72
  import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
72
- import { resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
73
+ import { parseSensorReadoutConversionProfile, resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
73
74
  import { resolveChartScene, CHART_SCENE_PRESETS } from "./chart-scenes.js";
74
75
  import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W } from "./scene-radiance.js";
75
76
  /** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
@@ -105,6 +106,12 @@ function sosReferenceLuminance(fNumber, shutterSeconds, iso) {
105
106
  */
106
107
  export const SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX = Math.PI * sosReferenceLuminance(DEFAULTS.fNumber, DEFAULTS.shutterSeconds, DEFAULTS.iso) /
107
108
  GREY_CARD_REFLECTANCE;
109
+ /**
110
+ * Sensitive-aperture area (µm²) at which the tier's ISO signal chain applies unchanged: the default 6 µm square
111
+ * aperture with fill factor 1. At other areas the facade scales the electron-domain readout values by
112
+ * area / this reference, so the same focal-plane exposure H gives the same output code (see `isoAreaScaledReadout`).
113
+ */
114
+ const ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS = DEFAULTS.pixelPitchMicrometers * DEFAULTS.pixelPitchMicrometers;
108
115
  /** Demosaic border, native sites per side. */
109
116
  const MARGIN = 1;
110
117
  /**
@@ -184,6 +191,17 @@ const XYZ_TO_CAMERA = [[EQE_SCALE.red / EQE_SCALE.green, 0, 0], [0, 1, 0], [0, 0
184
191
  const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
185
192
  const OPERATING_TEMPERATURE_C = 20;
186
193
  function fail(message) { throw new InvalidConfigurationError(message); }
194
+ /**
195
+ * The readout profile with every regime's charge-domain transfer scaled by `areaScale`: system conversion gain
196
+ * (e⁻ per code) and pre-ADC saturation (e⁻ equivalent) are multiplied by it, so codes per unit focal-plane exposure
197
+ * stay those of the reference aperture. Read noise stays in electrons per pixel; codes, black level, ADC and digital
198
+ * gain are unchanged. The result is re-checked by the readout parser.
199
+ */
200
+ function isoAreaScaledReadout(profile, areaScale) {
201
+ return parseSensorReadoutConversionProfile({ ...profile, regimes: profile.regimes.map((regime) => ({ ...regime,
202
+ systemConversionGainElectronsPerCode: { ...regime.systemConversionGainElectronsPerCode, value: regime.systemConversionGainElectronsPerCode.value * areaScale },
203
+ preAdcSaturationElectronEquivalent: { ...regime.preAdcSaturationElectronEquivalent, value: regime.preAdcSaturationElectronEquivalent.value * areaScale } })) });
204
+ }
187
205
  /** Diagnostic clock for the 2.1 stage timings: `performance.now()` where the host has it. Never affects output. */
188
206
  function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
189
207
  function positive(value, name) {
@@ -343,8 +361,9 @@ async function developAhd(v, raw, repeatWidth, repeatHeight, width, height, at,
343
361
  const intent = v.plan.exposure.whiteBalanceIntent, gains = intent ? [intent.channelGains.red, intent.channelGains.green, intent.channelGains.blue] : [1, 1, 1];
344
362
  const m = v.color.normalizedCameraChannelsToXyz;
345
363
  const cameraToXyz = [0, 1, 2].map((r) => [0, 1, 2].map((c) => m[r][c] * gains[c]));
364
+ // A caller-raised pixel limit already bounds the raster, so its working memory is not capped again at the 1 GiB default.
346
365
  const task = createAhdDemosaicTask({ plane: { width: W, height: H, captureModeId: v.plan.frame.modeId, colorSamplingProfile: v.plan.frame.colorSamplingProfile, samples },
347
- channelRoles: { red: "red", green: "green", blue: "blue" }, cameraToXyz }, { yieldControl: async () => { await host.yieldControl(); host.checkAbort(); } });
366
+ channelRoles: { red: "red", green: "green", blue: "blue" }, cameraToXyz, ...(v.plan.maximumPixels === undefined ? {} : { maximumWorkingBytes: Number.MAX_SAFE_INTEGER }) }, { yieldControl: async () => { await host.yieldControl(); host.checkAbort(); } });
348
367
  const demosaicStarted = clockMs();
349
368
  await task.run();
350
369
  host.onDemosaicMs?.(clockMs() - demosaicStarted);
@@ -551,9 +570,12 @@ export async function simulateCapture(options) {
551
570
  const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
552
571
  // Output and sensor
553
572
  const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "whiteBalance", "jpegQuantizationStep",
554
- "colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng"]);
555
- const width = integerIn(pick(outputOptions, "width", "output.width", DEFAULTS.width, "64 × 64 output pixels."), "output.width", 1, 4094);
556
- const height = integerIn(pick(outputOptions, "height", "output.height", DEFAULTS.height, "64 × 64 output pixels."), "output.height", 1, 4094);
573
+ "colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels"]);
574
+ // Opt-in large images: a caller-raised native pixel limit. Without it the 4094-pixel sides are unchanged.
575
+ const raisedPixels = outputOptions.maximumPixels === undefined ? undefined : integerIn(outputOptions.maximumPixels, "output.maximumPixels", 1, NATIVE_RAW_LIMITS.ceilingPixels);
576
+ const maximumSide = raisedPixels === undefined ? 4094 : NATIVE_RAW_LIMITS.maximumDimension;
577
+ const width = integerIn(pick(outputOptions, "width", "output.width", DEFAULTS.width, "64 × 64 output pixels."), "output.width", 1, maximumSide);
578
+ const height = integerIn(pick(outputOptions, "height", "output.height", DEFAULTS.height, "64 × 64 output pixels."), "output.height", 1, maximumSide);
557
579
  const format = pick(outputOptions, "format", "output.format", DEFAULTS.format, "8-bit sRGB samples.");
558
580
  if (!["srgb-8", "srgb-16", "jpeg", "png", "dng"].includes(format))
559
581
  fail("output.format must be srgb-8, srgb-16, jpeg, png or dng.");
@@ -628,6 +650,14 @@ export async function simulateCapture(options) {
628
650
  fail("output.demosaic \"ahd\" with mirror borders needs an output of at least 3 × 3 pixels.");
629
651
  // Geometry: the demosaic border (if any) on each side; output crop is the active area.
630
652
  const nativeWidth = width + 2 * margin, nativeHeight = height + 2 * margin;
653
+ if (raisedPixels !== undefined) {
654
+ if (nativeWidth > NATIVE_RAW_LIMITS.maximumDimension || nativeHeight > NATIVE_RAW_LIMITS.maximumDimension || nativeWidth * nativeHeight > raisedPixels) {
655
+ fail("output.maximumPixels (" + raisedPixels + ") must cover the native sensor raster of " + nativeWidth + " × " + nativeHeight +
656
+ " sites (the output plus the demosaic margin), at most " + NATIVE_RAW_LIMITS.maximumDimension + " per side.");
657
+ }
658
+ if (ahd && nativeWidth * nativeHeight > PROCESSING_MAX_PIXELS)
659
+ fail("output.demosaic \"ahd\" admits at most " + PROCESSING_MAX_PIXELS + " native sites.");
660
+ }
631
661
  const imagingArea = { widthMm: nativeWidth * pitch / 1000, heightMm: nativeHeight * pitch / 1000 };
632
662
  const geometry = { imagingArea, nativeRaster: { pixelWidth: nativeWidth, pixelHeight: nativeHeight }, orientation: "landscape",
633
663
  activeCaptureRect: { x: margin, y: margin, width, height }, outputRaster: { pixelWidth: width, pixelHeight: height } };
@@ -773,17 +803,31 @@ export async function simulateCapture(options) {
773
803
  fNumber = resolution.resolvedSettings.aperture ?? fNumber;
774
804
  shutterSeconds = resolution.resolvedSettings.shutterSeconds ?? shutterSeconds;
775
805
  const resolvedIso = resolution.resolvedSettings.iso ?? iso;
776
- // Sensor readout for the ISO setting
806
+ // Sensor readout for the ISO setting. Standard output sensitivity S = 10/H (CIPA DC-004 §3.2) is defined at the
807
+ // output, independent of pixel size, while the collected charge is proportional to the sensitive-aperture area
808
+ // (EMVA 1288 Release 4.0, Eq. 3). The tier's signal chain is anchored to the 6 µm aperture; at another area the
809
+ // charge-domain readout scales with the area so the same H gives the same code. At 6 µm nothing is changed.
810
+ const areaScale = pitch * pitch / ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS;
777
811
  const regimeByChannel = {};
778
812
  for (const channel of CHANNELS) {
779
813
  const asset = body.readout.find((a) => a.profile.channelId === channel) ?? fail("Camera readout lacks channel " + channel + ".");
780
814
  const chain = resolveGenericIsoSignalChain({ profile: body.signalChain, isoCapability: body.iso, requestedIsoSetting: { kind: "standard", exposureIndex: resolvedIso },
781
815
  captureModeId: IDS.mode, readoutProfile: asset.profile });
782
- regimeByChannel[channel] = { readoutProfile: asset.profile, regimeId: chain.readout.regime.regimeId };
816
+ regimeByChannel[channel] = { readoutProfile: areaScale === 1 ? asset.profile : isoAreaScaledReadout(asset.profile, areaScale), regimeId: chain.readout.regime.regimeId };
783
817
  }
784
- const baseRegime = body.readout[0].profile.regimes[0];
785
- const capacityElectrons = Math.max(...body.readout[0].profile.regimes.map((r) => r.preAdcSaturationElectronEquivalent.value), baseRegime.preAdcSaturationElectronEquivalent.value);
818
+ const capacityRegimes = (areaScale === 1 ? body.readout[0].profile : isoAreaScaledReadout(body.readout[0].profile, areaScale)).regimes;
819
+ const baseRegime = capacityRegimes[0];
820
+ const capacityElectrons = Math.max(...capacityRegimes.map((r) => r.preAdcSaturationElectronEquivalent.value), baseRegime.preAdcSaturationElectronEquivalent.value);
786
821
  assume("sensor.capacityElectrons", "derived", capacityElectrons, "Physical charge capacity set to the body's largest declared pre-ADC saturation (its base-ISO saturation signal), so storage never clips before the declared readout white.");
822
+ if (areaScale !== 1) {
823
+ assume("sensor.isoAreaNormalization", "model", { referenceApertureAreaSquareMicrometers: ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS,
824
+ apertureAreaSquareMicrometers: pitch * pitch, areaScale }, "Approximation. The tier's ISO signal chain is declared for a 6 µm square aperture. Standard output sensitivity S = 10/H (CIPA DC-004-2020 §3.2) is defined at the output, " +
825
+ "independent of pixel size, and the collected charge is proportional to the aperture area (EMVA 1288 Release 4.0, Eq. 3), so the system conversion gain (e⁻ per code) and the " +
826
+ "pre-ADC saturation of every ISO regime are multiplied by area / 36 µm² = " + areaScale + ". The same exposure gives the same output code at any pitch; per-pixel shot-noise SNR " +
827
+ "falls with smaller pixels. Read noise and dark current stay per pixel in electrons. The charge capacity follows the scaled base-ISO saturation, so it is proportional to " +
828
+ "the aperture area: a design consequence of holding the tier's ISO rating and highlight headroom fixed, not a measured full-well model (published sensor data grow less than " +
829
+ "proportionally with area across technologies).");
830
+ }
787
831
  assume("sensor.darkCurrent", "model", { electronsPerSecond: DARK_CURRENT_ELECTRONS_PER_SECOND, temperatureC: OPERATING_TEMPERATURE_C }, "Synthetic uniform dark current of 1 e⁻/s per site at 20 °C (the tier presets declare none); dark shot noise follows from it.");
788
832
  assume("sensor.spectralResponse", "model", { kind: "colorimetric-cmf-eqe", peakScale: EQE_SCALE }, "Idealized colorimetric sensor: channel EQE = a·{x̄, ȳ, z̄}(λ)·555/λ (the engine's analytic CIE 1931 fit), so RAW channels are proportional to CIE XYZ (Luther–Ives) and the camera matrix is diagonal. Not a real camera's response; CMF air wavelengths are used as vacuum wavelengths (difference about 0.03 %).");
789
833
  if (!antiAliasing)
@@ -894,7 +938,7 @@ export async function simulateCapture(options) {
894
938
  whiteBalanceIntent: intent, adoptedWhiteXyz: null,
895
939
  models: [{ profile: { id: "photivra-simulate-capture", version: SIMULATE_CAPTURE_FACADE_VERSION }, scientificStatus: "approximation", publicEvidenceIds: ["photivra:simulate-capture-facade"] }] },
896
940
  frameId: IDS.frame, modeId: IDS.mode, captureModeProfile, colorSamplingProfile, bindingProfile, exposureWindow,
897
- maximumOutputBytes: nativeWidth * nativeHeight * 7, tileWidth
941
+ maximumOutputBytes: nativeWidth * nativeHeight * 7, tileWidth, ...(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels })
898
942
  });
899
943
  const sceneBinding = { sceneStateId: IDS.sceneState, providerSceneId: IDS.scene, evidence };
900
944
  // Shared per-site environment pieces (identical objects for every site keep the native task's shared-state fast path).
@@ -985,7 +1029,10 @@ export async function simulateCapture(options) {
985
1029
  const lambdaN = wavelengths[wavelengths.length - 1] / 1000 * fNumber * imageDistanceMm / focalLengthMm;
986
1030
  // The environment resolves the PSF at each pre-filter source point, up to the largest split offset outside the imaging area.
987
1031
  const aaReachMm = antiAliasing ? Math.max(...antiAliasing.components.map((c) => Math.max(Math.abs(c.offsetMicrometers.x), Math.abs(c.offsetMicrometers.y)))) / 1000 : 0;
988
- const kernelRadiusSamples = diffractionRadiusOption ?? Math.min(32, Math.max(1, Math.ceil(AIRY_THIRD_ZERO_PER_LAMBDA_N * lambdaN / pitch)));
1032
+ // The default reach is capped at the 32-site convolution limit; a capped window ends inside the third dark ring, and the
1033
+ // larger tail energy outside it is listed per wavelength like any other (2.2.1 states the cap in the explanation).
1034
+ const thirdRingSites = Math.max(1, Math.ceil(AIRY_THIRD_ZERO_PER_LAMBDA_N * lambdaN / pitch));
1035
+ const kernelRadiusSamples = diffractionRadiusOption ?? Math.min(32, thirdRingSites);
989
1036
  diffraction = createDiffractionLimitedPsfProfile({ aperture: "circular", focalLengthMm, apertureFNumber: fNumber, focus: { kind: "finite", distanceM: focusDistanceM },
990
1037
  wavelengthsNm: wavelengths, samplePitchMicrometers: pitch, kernelRadiusSamples, fieldHalfExtentMm: aaReachMm === 0 ? { x: imagingArea.widthMm / 2, y: imagingArea.heightMm / 2 } :
991
1038
  { x: imagingArea.widthMm / 2 + aaReachMm, y: imagingArea.heightMm / 2 + aaReachMm },
@@ -998,7 +1045,8 @@ export async function simulateCapture(options) {
998
1045
  truncatedEnergyByWavelength: diffraction.wavelengths.map((w) => ({ wavelengthNm: w.wavelengthNm, truncatedEnergyFraction: w.truncatedEnergyFraction })),
999
1046
  execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" }, "Scalar Fraunhofer diffraction of an ideal, aberration-free circular pupil (Airy pattern, createDiffractionLimitedPsfProfile) at the resolved f-number, in focus, with D = f/N and the thin-lens image distance (working f-number N·v/f). " +
1000
1047
  "One kernel per spectral node, binned into site-sized cells over a " + (2 * kernelRadiusSamples + 1) + " × " + (2 * kernelRadiusSamples + 1) + " window" +
1001
- (diffractionRadiusOption === null ? " (reaching the third dark ring at the longest node)" : "") + "; the Airy tail energy outside it (listed per wavelength) is put back by unit-sum renormalization. " +
1048
+ (diffractionRadiusOption !== null ? "" : thirdRingSites > 32 ? " (capped at 32 sites; the third dark ring at the longest node needs " + thirdRingSites + ")" :
1049
+ " (reaching the third dark ring at the longest node)") + "; the Airy tail energy outside it (listed per wavelength) is put back by unit-sum renormalization. " +
1002
1050
  (diffractionObjectSpace ? "Applied as an object-space convolution: the scene radiance on the site lattice is convolved once per spectral node (calculateIrradianceConvolution) and the provider answers each site query with it, so the site inputs declare no PSF; this equals the per-tap environment PSF quadrature for this still, distortion-free planar scene (request.diffraction.psf). " :
1003
1051
  "Applied through the environment PSF quadrature (one scene query per kernel tap), because camera rotation, a lens field mapping or vignetting moves the taps off the site lattice. ") +
1004
1052
  "No defocus, aberration or pupil clipping; the sensor pixel aperture is still point-sampled at the site centre.");
@@ -1054,7 +1102,7 @@ export async function simulateCapture(options) {
1054
1102
  // The native task admits at most 100 000 radiance queries per tile.
1055
1103
  const tileWidth = Math.max(1, Math.min(256, Math.floor(100_000 / perSite)));
1056
1104
  const environmentRaw = { raw: buildRawInput(intentOf(whiteBalanceState)), sceneBinding,
1057
- maximumProviderEvaluations: Math.min(2_000_000_000, Math.max(nativeWidth * nativeHeight, evaluations)) };
1105
+ maximumProviderEvaluations: Math.min(nativeRawMaximumProviderEvaluations(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels }), Math.max(nativeWidth * nativeHeight, evaluations)) };
1058
1106
  async function runTask(task) {
1059
1107
  const onAbort = () => task.cancel();
1060
1108
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -1214,7 +1262,9 @@ export async function simulateCapture(options) {
1214
1262
  // The encoder reads every pixel once, in source tiles of at most 256 × 32 aligned to the crop origin.
1215
1263
  const reads = Math.ceil(height / 32) * Math.ceil(width / 256), jpegTick = progress("jpeg", reads);
1216
1264
  const jpegTask = createPrintJpegTask({ source, crop: { x: 0, y: 0, width, height }, outputWidth: width, outputHeight: height, quantizationStep: jpegStep,
1217
- maximumEncodedBytes: Math.max(65536, width * height * 6), maximumProviderReads: reads }, {
1265
+ maximumEncodedBytes: Math.max(65536, width * height * 6), maximumProviderReads: reads,
1266
+ // The JPEG records its admission plan, so the raised limit is passed (and recorded) only when the image needs it.
1267
+ ...(raisedPixels === undefined || width * height <= PRINT_JPEG_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) }, {
1218
1268
  readTile: async (request) => {
1219
1269
  jpegTick();
1220
1270
  const out = new Uint8Array(request.width * request.height * 3);
@@ -1233,7 +1283,8 @@ export async function simulateCapture(options) {
1233
1283
  let png;
1234
1284
  if (format === "png") {
1235
1285
  const pngStarted = clockMs();
1236
- png = serializePngImage({ width, height, bitDepth: pngBitDepth, samples: developed.integerSamples, colourEncoding: pngColourEncoding(colorEncoding) });
1286
+ png = serializePngImage({ width, height, bitDepth: pngBitDepth, samples: developed.integerSamples, colourEncoding: pngColourEncoding(colorEncoding),
1287
+ ...(raisedPixels === undefined || width * height <= PNG_ENCODER_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) });
1237
1288
  encodeMs = clockMs() - pngStarted;
1238
1289
  assume("output.png", "model", { encoder: "serializePngImage", bitDepth: pngBitDepth, colourEncoding: pngColourEncoding(colorEncoding) }, "Deterministic PNG (8/16-bit RGB, in-house zlib) with the colour encoding signalled in the file: the sRGB chunk for sRGB, otherwise a cICP chunk with ITU-T H.273 primaries/transfer code points. No mastering-display (mDCV) or content-light-level (cLLI) metadata is written.");
1239
1290
  }