use indicatrix::renderer::env_map::{EnvironmentMap, HdrLimits, environment_from_hdr_bytes};
use indicatrix_net::{
messages::{ContentHash, MAX_ASSET_LEN, content_hash, hash_hex},
scene::{HdrEnvironment, SceneEnvironment},
};
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex, PoisonError, Weak},
};
pub const KEEP_IN_MEMORY_MAX: usize = 64 * 1024 * 1024;
#[derive(Debug)]
pub struct HdrAsset {
content_hash: ContentHash,
width: u32,
height: u32,
path: PathBuf,
len: usize,
bytes: Option<Arc<[u8]>>,
}
impl HdrAsset {
#[must_use]
pub const fn content_hash(&self) -> &ContentHash {
&self.content_hash
}
#[must_use]
pub const fn environment(&self) -> HdrEnvironment {
HdrEnvironment {
content_hash: self.content_hash,
width: self.width,
height: self.height,
}
}
#[must_use]
pub fn transferable(&self) -> bool {
u32::try_from(self.len).is_ok_and(|len| len <= MAX_ASSET_LEN)
}
pub fn upload_bytes(&self) -> Result<Arc<[u8]>, String> {
if let Some(bytes) = &self.bytes {
return Ok(Arc::clone(bytes));
}
let bytes = std::fs::read(&self.path)
.map_err(|e| format!("cannot re-read the HDR map {}: {e}", self.path.display()))?;
if content_hash(&bytes) != self.content_hash {
return Err(format!(
"the HDR map {} changed on disk since it was loaded; reload it to render remotely",
self.path.display()
));
}
Ok(Arc::from(bytes))
}
}
static REGISTRY: Mutex<Vec<(Weak<EnvironmentMap>, Arc<HdrAsset>)>> = Mutex::new(Vec::new());
pub fn load_hdr_file(path: &Path) -> Result<(Arc<EnvironmentMap>, Arc<HdrAsset>), String> {
let bytes = std::fs::read(path).map_err(|e| format!("failed to decode HDR image: {e}"))?;
let map = Arc::new(
environment_from_hdr_bytes(&bytes, HdrLimits::DEFAULT).map_err(|e| e.to_string())?,
);
let asset = Arc::new(HdrAsset {
content_hash: content_hash(&bytes),
width: map.width() as u32,
height: map.height() as u32,
path: path.to_path_buf(),
len: bytes.len(),
bytes: (bytes.len() <= KEEP_IN_MEMORY_MAX).then(|| Arc::from(bytes)),
});
tracing::debug!(
"HDR map {} is asset {}",
path.display(),
hash_hex(asset.content_hash())
);
register(&map, Arc::clone(&asset));
Ok((map, asset))
}
fn register(map: &Arc<EnvironmentMap>, asset: Arc<HdrAsset>) {
let mut registry = REGISTRY.lock().unwrap_or_else(PoisonError::into_inner);
registry.retain(|(weak, _)| weak.strong_count() > 0);
registry.push((Arc::downgrade(map), asset));
}
#[must_use]
pub fn asset_for(map: &Arc<EnvironmentMap>) -> Option<Arc<HdrAsset>> {
let target = Arc::downgrade(map);
REGISTRY
.lock()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.find(|(weak, _)| weak.ptr_eq(&target))
.map(|(_, asset)| Arc::clone(asset))
}
#[must_use]
pub fn asset_by_hash(hash: &ContentHash) -> Option<Arc<HdrAsset>> {
REGISTRY
.lock()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.rev()
.find(|(weak, asset)| weak.strong_count() > 0 && asset.content_hash() == hash)
.map(|(_, asset)| Arc::clone(asset))
}
#[must_use]
pub fn scene_environment(env_map: Option<&Arc<EnvironmentMap>>) -> SceneEnvironment {
let Some(map) = env_map else {
return SceneEnvironment::Studio;
};
SceneEnvironment::Hdr(asset_for(map).map_or_else(
|| HdrEnvironment {
content_hash: [0; 32],
width: map.width() as u32,
height: map.height() as u32,
},
|asset| asset.environment(),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn write_temp_hdr(name: &str, salt: f32) -> PathBuf {
let pixels: Vec<image::Rgb<f32>> = (0..64)
.map(|i| image::Rgb([(i as f32).mul_add(0.01, salt), 0.5, 0.25]))
.collect();
let mut bytes = Vec::new();
image::codecs::hdr::HdrEncoder::new(&mut bytes)
.encode(&pixels, 16, 4)
.unwrap();
let path = std::env::temp_dir().join(format!(
"indicatrix-cut-hdr-asset-{name}-{}.hdr",
std::process::id()
));
std::fs::write(&path, &bytes).unwrap();
path
}
#[test]
fn a_loaded_map_is_named_by_its_file_hash_and_found_by_it() {
let path = write_temp_hdr("named", 0.1);
let (map, asset) = load_hdr_file(&path).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(asset.content_hash(), &content_hash(&bytes));
let SceneEnvironment::Hdr(env) = scene_environment(Some(&map)) else {
panic!("an HDR map must be named as one");
};
assert_eq!(env, asset.environment());
assert_eq!((env.width, env.height), (16, 4));
assert!(Arc::ptr_eq(
&asset_by_hash(&env.content_hash).unwrap(),
&asset
));
assert_eq!(&*asset.upload_bytes().unwrap(), bytes.as_slice());
assert!(asset.transferable());
assert_eq!(scene_environment(None), SceneEnvironment::Studio);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_changed_file_fails_the_upload_instead_of_sending_other_bytes() {
let path = write_temp_hdr("changed", 0.2);
let (_map, asset) = load_hdr_file(&path).unwrap();
let reread = HdrAsset {
content_hash: asset.content_hash,
width: asset.width,
height: asset.height,
path: asset.path.clone(),
len: asset.len,
bytes: None,
};
assert!(reread.upload_bytes().is_ok());
std::fs::write(&path, b"#?RADIANCE\nsomething else").unwrap();
let err = reread.upload_bytes().unwrap_err();
assert!(err.contains("changed on disk"), "{err}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_synthetic_map_has_no_asset() {
let map = Arc::new(EnvironmentMap::uniform(4, 2, [1.0, 1.0, 1.0]));
assert!(asset_for(&map).is_none());
let SceneEnvironment::Hdr(env) = scene_environment(Some(&map)) else {
panic!("still an HDR scene");
};
assert_eq!(env.content_hash, [0; 32]);
}
}