render: bounded cooperative HDR input foundation (#837)

A cancellable, bounded Radiance HDR decoder for render lighting input; reference images still accept PNG and JPEG only.
This commit is contained in:
selune authored and GitHub committed 2026-10-13 16:33:51 -04:00
1 parent 8319c35a32
commit e99b3bd4e8
11 files changed
+697 -2

No files matched your search

+10
View File
@@ -86,6 +86,16 @@ Generated-in-code assets are original and have no file to list:
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — | | LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 | | .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
## Incremental HDR codec adapter
`crates/render/src/lighting/hdr.rs` adapts the scanline/component/old-RLE decoding
state machines from the Radiance decoder in `image` 0.25.10
([source](https://docs.rs/crate/image/0.25.10/source/src/codecs/hdr/decoder.rs)),
MIT OR Apache-2.0. Metadata parsing still uses that library. The adapter makes row
work cooperative and adds checked run lengths, zero-run rejection, width validation,
header/input/radiance bounds and cancellation. Original licence texts are retained
in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
## Recovered contributions ## Recovered contributions
| SolidCraft file | Original contribution | Licence | | SolidCraft file | Original contribution | Licence |
+1 -1
View File
@@ -51,7 +51,7 @@ quick-xml = "0.41"
ttf-parser = { version = "0.25", default-features = false, features = ["std"] } ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
# images # images
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
# ui # ui
egui = "0.36" egui = "0.36"
+5
View File
@@ -19,6 +19,11 @@ licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0. (https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
The incremental Radiance RGBE row adapter in crates/render/src/lighting/hdr.rs adapts
decoding state machines from image 0.25.10 (https://github.com/image-rs/image),
MIT OR Apache-2.0. ATTRIBUTION.md describes the bounded/cancellable changes; the
original licence texts are included in THIRD-PARTY-LICENSES.txt.
Most bundled assets are drawn in code or contributed originally; third-party fonts (Liberation Sans Most bundled assets are drawn in code or contributed originally; third-party fonts (Liberation Sans
for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL OFL 1.1. The for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL OFL 1.1. The
per-file list is in ATTRIBUTION.md. per-file list is in ATTRIBUTION.md.
+3
View File
@@ -61,6 +61,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Render foundation ## Render foundation
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
it does not yet enable HDRI/PBR rendering or a document import command. See
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
- R1 adds renderer-independent backend/profile metadata, strict request resolution and - R1 adds renderer-independent backend/profile metadata, strict request resolution and
bounded image/scratch admission checks, exposed by the read-only `render.backends` bounded image/scratch admission checks, exposed by the read-only `render.backends`
command. It describes the current CPU raster snapshot path without evaluating the model. command. It describes the current CPU raster snapshot path without evaluating the model.
+1
View File
@@ -7,6 +7,7 @@
mod backend; mod backend;
mod camera; mod camera;
mod curve_display; mod curve_display;
pub mod lighting;
mod raster; mod raster;
pub use backend::{ pub use backend::{
+305
View File
@@ -0,0 +1,305 @@
//! Incremental RGBE scanline adapter for image 0.25.10's Radiance codec.
//! Row/RLE handling is adapted from its `read_scanline`, `decode_component` and
//! `decode_old_rle` (MIT OR Apache-2.0; see ATTRIBUTION.md and NOTICE). Metadata still
//! uses the library. Changes add cooperative steps, checked runs, width validation
//! and bounded radiance; no third-party asserts or input indexing are retained.
use super::{HdrError, HdrErrorCode as ErrorCode, Result, check_cancelled};
use serde::Serialize;
use std::{io::Cursor, sync::Arc};
pub const MAX_HDR_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_HDR_WIDTH: u32 = 2048;
pub const MAX_HDR_HEIGHT: u32 = 1024;
pub const MAX_HDR_PIXELS: u64 = 2 * 1024 * 1024;
const MAX_HEADER_BYTES: usize = 64 * 1024;
const MAX_RADIANCE: f64 = 1e8;
fn invalid(message: impl Into<String>) -> HdrError {
HdrError::new(ErrorCode::InvalidInput, message)
}
fn limit(message: impl Into<String>) -> HdrError {
HdrError::new(ErrorCode::LimitExceeded, message)
}
#[derive(Clone, Debug, Serialize)]
pub struct HdrInfo {
pub width: u32,
pub height: u32,
pub encoded_bytes: u64,
pub decoded_rgb_bytes: u64,
/// Stored RGBE values are divided by these declared header corrections.
pub exposure: f64,
pub color_correction: [f64; 3],
/// Source chromaticities (Rx Ry Gx Gy Bx By Wx Wy); absent means unspecified.
pub primaries: Option<[f64; 8]>,
#[serde(skip)]
offset: usize,
}
/// Immutable input. Construction only inspects a bounded header; `validate` also checks
/// every encoded row and corrected radiance before an import commits it to a document.
#[derive(Clone)]
pub struct HdrSource {
bytes: Arc<Vec<u8>>,
info: HdrInfo,
}
impl HdrSource {
pub fn new(bytes: Vec<u8>) -> Result<Self> {
if bytes.is_empty() || bytes.len() > MAX_HDR_BYTES || bytes.capacity() > MAX_HDR_BYTES {
return Err(limit("HDR input must contain at most 16 MiB"));
}
let mut cursor = Cursor::new(bytes.get(..bytes.len().min(MAX_HEADER_BYTES)).ok_or_else(|| invalid("invalid HDR header range"))?);
let metadata = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
image::codecs::hdr::HdrDecoder::new(&mut cursor).map(|decoder| decoder.metadata())
}))
.map_err(|_| invalid("HDR metadata decoder failed"))?
.map_err(|e| invalid(format!("invalid or oversized Radiance header: {e}")))?;
let pixels = u64::from(metadata.width).checked_mul(u64::from(metadata.height)).ok_or_else(|| limit("HDR dimensions overflow"))?;
if metadata.width == 0
|| metadata.height == 0
|| metadata.width > MAX_HDR_WIDTH
|| metadata.height > MAX_HDR_HEIGHT
|| pixels > MAX_HDR_PIXELS
{
return Err(limit("HDR preview input exceeds 2048x1024 / 2 million pixels"));
}
if metadata.width != metadata.height.saturating_mul(2) || metadata.orientation != ((1, 0), (0, 1)) {
return Err(invalid("HDR preview requires a 2:1 equirectangular image with standard -Y +X orientation"));
}
let exposure = f64::from(metadata.exposure.unwrap_or(1.0));
let (r, g, b) = metadata.color_correction.unwrap_or((1.0, 1.0, 1.0));
let correction = [f64::from(r), f64::from(g), f64::from(b)];
if [exposure].into_iter().chain(correction).any(|x| !x.is_finite() || x <= 0.0)
|| metadata.pixel_aspect_ratio.is_some_and(|x| !x.is_finite() || (x - 1.0).abs() > 1e-6)
{
return Err(invalid("HDR exposure, colour correction and square-pixel metadata must be finite and positive"));
}
let offset = usize::try_from(cursor.position()).map_err(|_| invalid("HDR data offset overflow"))?;
if offset.checked_add(4).is_none_or(|end| end > bytes.len()) {
return Err(invalid("HDR input has no pixel data"));
}
let mut primaries = None;
for (key, value) in &metadata.custom_attributes {
if key == "PRIMARIES" {
if primaries.is_some() {
return Err(invalid("duplicate HDR primaries"));
}
let mut parsed = [0.0; 8];
let mut values = value.split_whitespace();
for out in &mut parsed {
*out = values
.next()
.ok_or_else(|| invalid("missing HDR chromaticity"))?
.parse::<f64>()
.map_err(|_| invalid("invalid HDR chromaticity"))?;
if !out.is_finite() || !(0.0..=1.0).contains(out) {
return Err(invalid("invalid HDR chromaticity"));
}
}
if values.next().is_some() {
return Err(invalid("extra HDR chromaticity"));
}
primaries = Some(parsed);
}
}
let info = HdrInfo {
width: metadata.width,
height: metadata.height,
encoded_bytes: bytes.len() as u64,
decoded_rgb_bytes: pixels.checked_mul(12).ok_or_else(|| limit("HDR decoded size overflow"))?,
exposure,
color_correction: correction,
primaries,
offset,
};
Ok(Self { bytes: Arc::new(bytes), info })
}
pub fn info(&self) -> &HdrInfo {
&self.info
}
pub fn encoded_bytes(&self) -> &[u8] {
&self.bytes
}
pub fn validate(&self, cancelled: impl Fn() -> bool) -> Result<()> {
check_cancelled(&cancelled)?;
let mut rows = Rows::new(self.clone())?;
let mut steps = 0u64;
while !rows.done() {
check_cancelled(&cancelled)?;
if rows.ready() {
for x in 0..self.info.width as usize {
check_cancelled(&cancelled)?;
rows.linear(x)?;
}
rows.next_row()?;
} else {
rows.step()?;
}
steps = steps.checked_add(1).ok_or_else(|| limit("HDR decode work overflow"))?;
if steps > MAX_HDR_PIXELS * 6 + 4096 {
return Err(limit("HDR decode work budget exceeded"));
}
}
Ok(())
}
}
#[cfg(test)]
#[path = "hdr_tests.rs"]
mod tests;
enum Stage {
Start,
Channel { channel: usize, x: usize },
Old { x: usize, previous: [u8; 4], shift: u32, repeat: usize },
Ready,
Done,
}
pub(super) struct Rows {
source: HdrSource,
offset: usize,
row: u32,
pixels: Vec<[u8; 4]>,
scales: [f64; 256],
stage: Stage,
}
impl Rows {
pub(super) fn new(source: HdrSource) -> Result<Self> {
let mut pixels = Vec::new();
pixels
.try_reserve_exact(source.info.width as usize)
.map_err(|_| HdrError::new(ErrorCode::AllocationFailed, "could not reserve HDR scanline"))?;
pixels.resize(source.info.width as usize, [0; 4]);
Ok(Self {
offset: source.info.offset,
source,
row: 0,
pixels,
scales: std::array::from_fn(|e| 2f64.powi(e as i32 - 136)),
stage: Stage::Start,
})
}
pub(super) fn info(&self) -> &HdrInfo {
&self.source.info
}
#[cfg(test)]
pub(super) fn row(&self) -> u32 {
self.row
}
pub(super) fn ready(&self) -> bool {
matches!(self.stage, Stage::Ready)
}
pub(super) fn done(&self) -> bool {
matches!(self.stage, Stage::Done)
}
fn byte(&mut self) -> Result<u8> {
let b = self.source.bytes.get(self.offset).copied().ok_or_else(|| invalid("truncated HDR scanline"))?;
self.offset = self.offset.checked_add(1).ok_or_else(|| invalid("HDR offset overflow"))?;
Ok(b)
}
fn pixel(&mut self) -> Result<[u8; 4]> {
Ok([self.byte()?, self.byte()?, self.byte()?, self.byte()?])
}
fn put(&mut self, x: usize, pixel: [u8; 4]) -> Result<()> {
*self.pixels.get_mut(x).ok_or_else(|| invalid("HDR run exceeds its row"))? = pixel;
Ok(())
}
/// One packet (at most 128 component writes), or one old-style repeated pixel.
pub(super) fn step(&mut self) -> Result<()> {
let width = self.pixels.len();
let stage = std::mem::replace(&mut self.stage, Stage::Done);
self.stage = match stage {
Stage::Start => {
let first = self.pixel()?;
if first.get(..2) == Some(&[2, 2]) && first.get(2).is_some_and(|v| *v < 128) {
let declared = usize::from(*first.get(2).ok_or_else(|| invalid("missing HDR width"))?) * 256
+ usize::from(*first.get(3).ok_or_else(|| invalid("missing HDR width"))?);
if declared != width {
return Err(invalid("HDR scanline width differs from its header"));
}
Stage::Channel { channel: 0, x: 0 }
} else {
if first.get(..3) == Some(&[1, 1, 1]) {
return Err(invalid("HDR row starts with an old run marker"));
}
self.put(0, first)?;
if width == 1 { Stage::Ready } else { Stage::Old { x: 1, previous: first, shift: 0, repeat: 0 } }
}
}
Stage::Channel { channel, x } => {
let code = self.byte()?;
if code == 0 {
return Err(invalid("zero-length HDR RLE packet"));
}
let run = code > 128;
let count = usize::from(if run { code - 128 } else { code });
let end = x.checked_add(count).filter(|end| *end <= width).ok_or_else(|| invalid("HDR RLE packet exceeds its row"))?;
let repeated = if run { self.byte()? } else { 0 };
for at in x..end {
let value = if run { repeated } else { self.byte()? };
*self.pixels.get_mut(at).and_then(|p| p.get_mut(channel)).ok_or_else(|| invalid("invalid HDR component address"))? = value;
}
if end == width {
if channel == 3 { Stage::Ready } else { Stage::Channel { channel: channel + 1, x: 0 } }
} else {
Stage::Channel { channel, x: end }
}
}
Stage::Old { x, previous, shift, repeat } => {
if repeat > 0 {
self.put(x, previous)?;
if x + 1 == width { Stage::Ready } else { Stage::Old { x: x + 1, previous, shift, repeat: repeat - 1 } }
} else {
let pixel = self.pixel()?;
if pixel.get(..3) == Some(&[1, 1, 1]) {
let count = u64::from(*pixel.get(3).ok_or_else(|| invalid("missing HDR repeat count"))?)
.checked_shl(shift)
.filter(|n| *n <= (width - x) as u64)
.ok_or_else(|| invalid("old HDR run exceeds its row"))?;
let next_shift = shift.checked_add(8).filter(|s| *s <= 32).ok_or_else(|| invalid("old HDR run multiplier overflow"))?;
Stage::Old { x, previous, shift: next_shift, repeat: count as usize }
} else {
self.put(x, pixel)?;
if x + 1 == width { Stage::Ready } else { Stage::Old { x: x + 1, previous: pixel, shift: 0, repeat: 0 } }
}
}
}
Stage::Ready => return Err(invalid("HDR row must be consumed before decoding another")),
Stage::Done => return Err(invalid("HDR input already consumed")),
};
Ok(())
}
pub(super) fn linear(&self, x: usize) -> Result<[f32; 3]> {
if !self.ready() {
return Err(invalid("HDR row is not ready"));
}
let pixel = self.pixels.get(x).ok_or_else(|| invalid("invalid HDR pixel address"))?;
let exponent = *pixel.get(3).ok_or_else(|| invalid("missing RGBE exponent"))?;
if exponent == 0 {
return Ok([0.0; 3]);
}
let scale = self.scales.get(usize::from(exponent)).copied().ok_or_else(|| invalid("invalid RGBE exponent"))? / self.source.info.exposure;
let mut rgb = [0.0; 3];
for (channel, out) in rgb.iter_mut().enumerate() {
let value = f64::from(*pixel.get(channel).ok_or_else(|| invalid("invalid RGBE channel"))?) * scale
/ self.source.info.color_correction.get(channel).copied().ok_or_else(|| invalid("invalid HDR correction"))?;
if !value.is_finite() || !(0.0..=MAX_RADIANCE).contains(&value) {
return Err(limit("corrected HDR radiance exceeds 100 million"));
}
*out = value as f32;
}
Ok(rgb)
}
pub(super) fn next_row(&mut self) -> Result<()> {
if !self.ready() {
return Err(invalid("HDR row is not ready"));
}
self.row = self.row.checked_add(1).ok_or_else(|| invalid("HDR row overflow"))?;
self.stage = if self.row == self.source.info.height { Stage::Done } else { Stage::Start };
Ok(())
}
}
+172
View File
@@ -0,0 +1,172 @@
use super::*;
use crate::lighting::{HdrDecodeTask, HdrTaskPoll};
#[test]
fn cooperative_task_matches_codec_and_is_consumed_once() {
for bytes in [uncompressed(""), rle(), literal_rle(), old_rle()] {
let expected = image::load_from_memory_with_format(&bytes, image::ImageFormat::Hdr).unwrap().into_rgb32f();
let mut task = HdrDecodeTask::new(HdrSource::new(bytes).unwrap()).unwrap();
let mut calls = 0;
loop {
calls += 1;
assert!(calls < 1000);
match task.step(1, || false).unwrap() {
HdrTaskPoll::Pending(progress) => assert!(progress.decoded_pixels < progress.total_pixels),
HdrTaskPoll::Complete(result) => {
assert_eq!(result.pixels().len(), expected.pixels().len());
for (actual, expected) in result.pixels().iter().zip(expected.pixels()) {
assert_eq!(actual, &expected.0);
}
break;
}
}
}
assert!(calls > 8);
assert_eq!(task.step(1, || false).err().unwrap().code, ErrorCode::Consumed);
}
}
#[test]
fn cancellation_or_bad_input_cannot_publish_partial_pixels_later() {
let mut task = HdrDecodeTask::new(HdrSource::new(rle()).unwrap()).unwrap();
assert!(matches!(task.step(7, || false).unwrap(), HdrTaskPoll::Pending(_)));
assert_eq!(task.step(1, || true).err().unwrap().code, ErrorCode::Cancelled);
assert_eq!(task.step(16384, || false).err().unwrap().code, ErrorCode::Consumed);
let mut bytes = rle();
bytes.pop();
let mut task = HdrDecodeTask::new(HdrSource::new(bytes).unwrap()).unwrap();
assert_eq!(task.step(16384, || false).err().unwrap().code, ErrorCode::InvalidInput);
assert_eq!(task.step(1, || false).err().unwrap().code, ErrorCode::Consumed);
let checks = std::cell::Cell::new(0);
let mut task = HdrDecodeTask::new(HdrSource::new(rle()).unwrap()).unwrap();
let result = task.step(16384, || {
checks.set(checks.get() + 1);
checks.get() == 10
});
assert_eq!(result.err().unwrap().code, ErrorCode::Cancelled);
assert_eq!(checks.get(), 10);
assert_eq!(task.step(1, || false).err().unwrap().code, ErrorCode::Consumed);
}
#[test]
fn source_primaries_are_preserved_without_assuming_srgb() {
let source = HdrSource::new(uncompressed("PRIMARIES=0.64 0.33 0.3 0.6 0.15 0.06 0.3127 0.329\n")).unwrap();
assert_eq!(source.info().primaries, Some([0.64, 0.33, 0.3, 0.6, 0.15, 0.06, 0.3127, 0.329]));
assert!(HdrSource::new(uncompressed("PRIMARIES=NaN 0 0 0 0 0 0 0\n")).is_err());
let mut excessive_capacity = Vec::with_capacity(MAX_HDR_BYTES + 1);
excessive_capacity.extend_from_slice(&rle());
assert_eq!(HdrSource::new(excessive_capacity).err().unwrap().code, ErrorCode::LimitExceeded);
}
fn header(width: u32, height: u32, extra: &str) -> Vec<u8> {
format!("#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n{extra}\n-Y {height} +X {width}\n").into_bytes()
}
fn uncompressed(extra: &str) -> Vec<u8> {
let mut bytes = header(4, 2, extra);
for _ in 0..8 {
bytes.extend_from_slice(&[128, 64, 32, 131]);
}
bytes
}
fn rle() -> Vec<u8> {
let mut bytes = header(8, 4, "");
for _ in 0..4 {
bytes.extend_from_slice(&[2, 2, 0, 8]);
for value in [128, 64, 32, 129] {
bytes.extend_from_slice(&[136, value]);
}
}
bytes
}
fn literal_rle() -> Vec<u8> {
let mut bytes = header(8, 4, "");
for _ in 0..4 {
bytes.extend_from_slice(&[2, 2, 0, 8]);
for value in [128, 64, 32, 129] {
bytes.push(8);
bytes.extend_from_slice(&[value; 8]);
}
}
bytes
}
fn old_rle() -> Vec<u8> {
let mut bytes = header(8, 4, "");
for _ in 0..4 {
bytes.extend_from_slice(&[128, 64, 32, 129, 1, 1, 1, 7]);
}
bytes
}
#[test]
fn incremental_rows_match_the_original_codec_without_clipping_hdr_values() {
for bytes in [uncompressed(""), rle(), literal_rle(), old_rle()] {
let expected = image::load_from_memory_with_format(&bytes, image::ImageFormat::Hdr).unwrap().into_rgb32f();
let source = HdrSource::new(bytes).unwrap();
source.validate(|| false).unwrap();
let mut rows = Rows::new(source).unwrap();
while !rows.done() {
if rows.ready() {
for x in 0..rows.info().width as usize {
assert_eq!(rows.linear(x).unwrap(), expected.get_pixel(x as u32, rows.row()).0);
}
rows.next_row().unwrap();
} else {
rows.step().unwrap();
}
}
}
}
#[test]
fn header_exposure_and_colour_correction_restore_linear_radiance() {
let source = HdrSource::new(uncompressed("EXPOSURE=2\nCOLORCORR=1 2 4\n")).unwrap();
let mut rows = Rows::new(source).unwrap();
while !rows.ready() {
rows.step().unwrap();
}
assert_eq!(rows.linear(0).unwrap(), [2.0, 0.5, 0.125]);
}
#[test]
fn malformed_runs_truncation_nonfinite_metadata_and_hostile_sizes_are_refused() {
let mut truncated = rle();
truncated.pop();
let mut wrong_width = rle();
let offset = HdrSource::new(wrong_width.clone()).unwrap().info.offset;
wrong_width[offset + 3] = 9;
let mut zero_run = rle();
zero_run[offset + 4] = 0;
let mut overflow = header(8, 4, "");
overflow.extend_from_slice(&[128, 64, 32, 129]);
for _ in 0..6 {
overflow.extend_from_slice(&[1, 1, 1, 0]);
}
for bytes in [truncated, wrong_width, zero_run, overflow] {
assert!(HdrSource::new(bytes).unwrap().validate(|| false).is_err());
}
for bytes in [header(u32::MAX, 1, ""), uncompressed("EXPOSURE=NaN\n"), uncompressed("PIXASPECT=0\n")] {
assert!(HdrSource::new(bytes).is_err());
}
let mut too_large = rle();
too_large.resize(MAX_HDR_BYTES + 1, 0);
assert_eq!(HdrSource::new(too_large).err().unwrap().code, ErrorCode::LimitExceeded);
let mut too_bright = uncompressed("");
let offset = HdrSource::new(too_bright.clone()).unwrap().info.offset;
too_bright[offset + 3] = 255;
assert_eq!(HdrSource::new(too_bright).unwrap().validate(|| false).unwrap_err().code, ErrorCode::LimitExceeded);
let source = HdrSource::new(rle()).unwrap();
assert_eq!(source.validate(|| true).unwrap_err().code, ErrorCode::Cancelled);
}
#[test]
fn header_work_is_bounded_before_accepting_metadata() {
let mut bytes = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n".to_vec();
bytes.extend(std::iter::repeat_n(b'x', MAX_HEADER_BYTES + 1));
bytes.extend_from_slice(b"\n\n-Y 2 +X 4\n");
bytes.extend_from_slice(&[128, 64, 32, 129]);
assert!(HdrSource::new(bytes).is_err());
}
+43
View File
@@ -0,0 +1,43 @@
//! Device-independent, bounded linear HDR input preparation.
mod hdr;
mod task;
pub use hdr::{HdrInfo, HdrSource, MAX_HDR_BYTES, MAX_HDR_HEIGHT, MAX_HDR_PIXELS, MAX_HDR_WIDTH};
pub use task::{DecodedHdr, HdrDecodeTask, HdrProgress, HdrTaskPoll};
use serde::Serialize;
use std::fmt;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HdrErrorCode {
InvalidInput,
LimitExceeded,
AllocationFailed,
Cancelled,
Consumed,
Internal,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct HdrError {
pub code: HdrErrorCode,
pub message: String,
}
impl HdrError {
pub fn new(code: HdrErrorCode, message: impl Into<String>) -> Self {
Self { code, message: message.into() }
}
}
impl fmt::Display for HdrError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for HdrError {}
pub type Result<T> = std::result::Result<T, HdrError>;
fn check_cancelled(cancelled: &impl Fn() -> bool) -> Result<()> {
if cancelled() { Err(HdrError::new(HdrErrorCode::Cancelled, "HDR decoding cancelled")) } else { Ok(()) }
}
+84
View File
@@ -0,0 +1,84 @@
use super::{HdrError, HdrErrorCode, HdrInfo, HdrSource, MAX_HDR_PIXELS, Result, check_cancelled, hdr::Rows};
/// Linear RGB in the source's colour space, without gamut conversion or tone mapping.
pub struct DecodedHdr {
info: HdrInfo,
pixels: Vec<[f32; 3]>,
}
impl DecodedHdr {
pub fn info(&self) -> &HdrInfo {
&self.info
}
pub fn pixels(&self) -> &[[f32; 3]] {
&self.pixels
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HdrProgress {
pub decoded_pixels: u64,
pub total_pixels: u64,
}
pub enum HdrTaskPoll {
Pending(HdrProgress),
Complete(DecodedHdr),
}
/// Caller-owned cooperative preparation; no scheduler, GPU or document dependency.
pub struct HdrDecodeTask {
rows: Option<Rows>,
pixels: Vec<[f32; 3]>,
x: usize,
work: u64,
}
impl HdrDecodeTask {
pub fn new(source: HdrSource) -> Result<Self> {
let count = u64::from(source.info().width) * u64::from(source.info().height);
let count = usize::try_from(count).map_err(|_| HdrError::new(HdrErrorCode::LimitExceeded, "HDR pixel count overflow"))?;
let mut pixels = Vec::new();
pixels.try_reserve_exact(count).map_err(|_| HdrError::new(HdrErrorCode::AllocationFailed, "could not reserve HDR pixels"))?;
Ok(Self { rows: Some(Rows::new(source)?), pixels, x: 0, work: 0 })
}
/// Each unit consumes one RLE packet (at most 128 bytes) or one RGB pixel.
/// Error/cancellation consumes the task and releases its partial buffers.
pub fn step(&mut self, units: usize, cancelled: impl Fn() -> bool) -> Result<HdrTaskPoll> {
let result = self.advance(units, cancelled);
if result.is_err() {
self.rows = None;
self.pixels = Vec::new();
}
result
}
fn advance(&mut self, units: usize, cancelled: impl Fn() -> bool) -> Result<HdrTaskPoll> {
let rows = self.rows.as_mut().ok_or_else(|| HdrError::new(HdrErrorCode::Consumed, "HDR task already consumed"))?;
for _ in 0..units.clamp(1, 16384) {
check_cancelled(&cancelled)?;
self.work = self
.work
.checked_add(1)
.filter(|n| *n <= MAX_HDR_PIXELS * 6 + 4096)
.ok_or_else(|| HdrError::new(HdrErrorCode::LimitExceeded, "HDR decode work budget exceeded"))?;
if rows.ready() {
self.pixels.push(rows.linear(self.x)?);
self.x += 1;
if self.x == rows.info().width as usize {
rows.next_row()?;
self.x = 0;
}
} else {
rows.step()?;
}
if rows.done() {
check_cancelled(&cancelled)?;
let info = rows.info().clone();
self.rows = None;
return Ok(HdrTaskPoll::Complete(DecodedHdr { info, pixels: std::mem::take(&mut self.pixels) }));
}
}
Ok(HdrTaskPoll::Pending(HdrProgress {
decoded_pixels: self.pixels.len() as u64,
total_pixels: u64::from(rows.info().width) * u64::from(rows.info().height),
}))
}
}
+26 -1
View File
@@ -28,6 +28,15 @@ thread_local! {
static VERSION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) }; static VERSION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
} }
// Codec features are unified across crates. Enabling bounded HDR preparation must
// not route HDR through this synchronous whole-image canvas decoder.
fn decode_reference(bytes: &[u8]) -> Option<image::DynamicImage> {
match image::guess_format(bytes).ok()? {
image::ImageFormat::Png | image::ImageFormat::Jpeg => image::load_from_memory(bytes).ok(),
_ => None,
}
}
fn pixels(c: &Canvas) -> Option<Decoded> { fn pixels(c: &Canvas) -> Option<Decoded> {
PIXELS.with(|m| { PIXELS.with(|m| {
let mut m = m.borrow_mut(); let mut m = m.borrow_mut();
@@ -36,7 +45,7 @@ fn pixels(c: &Canvas) -> Option<Decoded> {
{ {
return d.clone(); return d.clone();
} }
let d = c.bytes().and_then(|b| image::load_from_memory(&b).ok()).map(|i| { let d = c.bytes().and_then(|b| decode_reference(&b)).map(|i| {
let i = if i.width() > MAX_SIDE || i.height() > MAX_SIDE { i.thumbnail(MAX_SIDE, MAX_SIDE) } else { i }; let i = if i.width() > MAX_SIDE || i.height() > MAX_SIDE { i.thumbnail(MAX_SIDE, MAX_SIDE) } else { i };
let rgba = i.to_rgba8(); let rgba = i.to_rgba8();
let v = VERSION.with(|v| { let v = VERSION.with(|v| {
@@ -138,3 +147,19 @@ pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj)
painter.add(Shape::mesh(mesh)); painter.add(Shape::mesh(mesh));
} }
} }
#[cfg(test)]
mod codec_tests {
use super::decode_reference;
#[test]
fn hdr_feature_does_not_enable_synchronous_canvas_hdr_decoding() {
let mut hdr = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 1\n".to_vec();
hdr.extend_from_slice(&[128, 64, 32, 129]);
assert!(image::load_from_memory(&hdr).is_ok());
assert!(decode_reference(&hdr).is_none());
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::new_rgba8(1, 1).write_to(&mut png, image::ImageFormat::Png).unwrap();
assert!(decode_reference(png.get_ref()).is_some());
}
}
+47
View File
@@ -0,0 +1,47 @@
# Bounded Radiance input
`solidcraft_render::lighting` provides a caller-owned cooperative RGBE decoder.
This is input preparation only: it enables no environment import command, document
format, PBR material, lighting capability, GPU upload or image-render backend.
The existing canvas decoder explicitly accepts PNG/JPEG only, so Cargo feature
unification cannot send HDR through its synchronous whole-image loading path.
It is the independent input foundation for #813 / #620, coordinated with the
maintainer's renderer work described in #792.
`HdrSource::new(Vec<u8>)` checks a bounded header using the existing `image`
metadata codec. Success does **not** mean the pixel stream is valid. Use
`validate(cancelled)` for a synchronous full-stream check, or construct
`HdrDecodeTask` and repeatedly call `step(units, cancelled)` for cooperative
preparation. Only `Complete(DecodedHdr)` contains publishable pixels. Cancellation
and errors release partial buffers; subsequent calls return `Consumed`, including
after successful completion. The cancellation callback must itself be bounded.
The consumer owns scheduling and must not perform synchronous `validate` on a
latency-sensitive UI thread.
Input limits are 16 MiB of encoded Vec capacity, a 64 KiB header, 2048x1024 and
2,097,152 pixels. Only 2:1, square-pixel, standard `-Y +X` RGBE is accepted.
EXR, XYZE and other orientations are unsupported. A call accepts 1–16,384 work
units; each unit decodes one packet (at most 128 component writes) or converts one
pixel. An overall work cap bounds malformed old-style runs. Corrected channels
must be finite, nonnegative and at most 100,000,000. Limits are intentional preview
policy, not a statement about all valid Radiance files.
The float pixel allocation is at most 24 MiB, with one RGBE scanline at most 8 KiB
and a shared encoded source at most 16 MiB. Construction reserves float storage
fallibly; initialization happens incrementally. This is a per-task estimate,
not a process memory quota. The consumer must admit concurrent jobs, source
ownership, decoded copies, colour conversion, GPU textures and IBL maps within
its own total budget before starting.
RGBE conversion follows `image` 0.25.10's mantissa/exponent convention, with no
tone mapping or clipping to [0,1]. Stored values are divided by header EXPOSURE
and COLORCORR. Physical units are not guaranteed by arbitrary input files.
`HdrInfo::primaries` preserves explicit PRIMARIES chromaticities; absence is
unspecified. Output is linear RGB in the source colour space, **not implicitly
linear sRGB**. Consumers must choose and validate a working-space transform;
basic finite/range validation of chromaticities here does not prove an invertible
or physically meaningful gamut. No gamut conversion is performed by this utility.
The scanline adapter is derived from the permissively licensed `image` codec;
see ATTRIBUTION.md, NOTICE and THIRD-PARTY-LICENSES.txt. Test images are original
inline byte fixtures. No commercial-product images or materials are included.