use concinnity_core::bake::color_lut::{
LutFormat, classify_source, parse_cube, serialise, validate_size,
};
use std::path::Path;
fn color_lut_source(args: &serde_json::Value) -> Result<&str, String> {
let source = args.get("source").and_then(|v| v.as_str()).unwrap_or("");
if source.is_empty() {
return Err("ColorLut requires a `source` path".into());
}
Ok(source)
}
#[cfg(test)]
pub(crate) fn validate_color_lut_args(args: &serde_json::Value) -> Result<(), String> {
classify_source(color_lut_source(args)?)?;
Ok(())
}
pub(crate) fn compile_color_lut_payload(
args: &serde_json::Value,
assets_dir: Option<&Path>,
) -> Result<Vec<u8>, String> {
let source = color_lut_source(args)?;
let (size, data) = decode(&concinnity_host::store::source::resolve_source_path(
source, assets_dir,
))?;
Ok(serialise(size, &data))
}
pub fn decode_source(path: &str) -> Result<(u32, Vec<u8>), String> {
decode(path)
}
fn decode(path: &str) -> Result<(u32, Vec<u8>), String> {
match classify_source(path)? {
LutFormat::Cube => {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read LUT source '{}': {}", path, e))?;
parse_cube(&text)
}
LutFormat::Png => parse_png_strip(path),
}
}
pub(crate) fn parse_png_strip(path: &str) -> Result<(u32, Vec<u8>), String> {
let (width, height, pixels) = load_png_rgba8(path)?;
let size = height;
validate_size(size)?;
if width != size * size {
return Err(format!(
"ColorLut strip '{}' is {}x{}; a size-{} strip must be {}x{}",
path,
width,
height,
size,
size * size,
size
));
}
let n = size as usize;
let mut data = Vec::with_capacity(n * n * n * 4);
for b in 0..n {
for g in 0..n {
for r in 0..n {
let px = b * n + r;
let src = (g * width as usize + px) * 4;
data.extend_from_slice(&pixels[src..src + 4]);
}
}
}
Ok((size, data))
}
fn load_png_rgba8(path: &str) -> Result<(u32, u32, Vec<u8>), String> {
use png::ColorType;
let file = std::fs::File::open(path)
.map_err(|e| format!("failed to open LUT source '{}': {}", path, e))?;
let decoder = png::Decoder::new(std::io::BufReader::new(file));
let mut reader = decoder
.read_info()
.map_err(|e| format!("failed to read PNG info for '{}': {}", path, e))?;
let mut buf = vec![
0u8;
reader
.output_buffer_size()
.ok_or("failed to compute PNG output buffer size")?
];
let info = reader
.next_frame(&mut buf)
.map_err(|e| format!("failed to decode PNG frame for '{}': {}", path, e))?;
let raw = &buf[..info.buffer_size()];
let pixels = match info.color_type {
ColorType::Rgba => raw.to_vec(),
ColorType::Rgb => {
let mut out = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for chunk in raw.chunks_exact(3) {
out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
}
out
}
other => {
return Err(format!(
"ColorLut strip '{}' has unsupported PNG colour type {:?} (need RGB/RGBA)",
path, other
));
}
};
Ok((info.width, info.height, pixels))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_args_requires_supported_source() {
assert!(validate_color_lut_args(&serde_json::json!({})).is_err());
assert!(validate_color_lut_args(&serde_json::json!({"source": "g.tga"})).is_err());
validate_color_lut_args(&serde_json::json!({"source": "g.cube"})).expect("ok");
}
#[test]
fn validate_args_rejects_a_non_string_source() {
let err = validate_color_lut_args(&serde_json::json!({"source": 5})).unwrap_err();
assert!(err.contains("requires a `source` path"), "got: {err}");
}
fn write_png(
dir: &tempfile::TempDir,
name: &str,
width: u32,
height: u32,
color: png::ColorType,
data: &[u8],
) -> String {
let bytes = concinnity_testing::fixtures::png::encode(width, height, color, data);
concinnity_testing::utf8(&concinnity_testing::write_into(dir.path(), name, bytes))
}
fn strip2_pixels() -> Vec<u8> {
let mut data = Vec::new();
for y in 0..2u8 {
for x in 0..4u8 {
data.extend_from_slice(&[x * 10, y * 10, 42, 255]);
}
}
data
}
#[test]
fn parse_png_strip_reorders_slices_into_texel_order() {
let dir = tempfile::tempdir().expect("tempdir");
let src = write_png(&dir, "s.png", 4, 2, png::ColorType::Rgba, &strip2_pixels());
let (size, data) = parse_png_strip(&src).expect("parse strip");
assert_eq!(size, 2);
assert_eq!(data.len(), 2 * 2 * 2 * 4);
assert_eq!(&data[0..4], &[0, 0, 42, 255]);
assert_eq!(&data[4 * 4..4 * 4 + 4], &[20, 0, 42, 255]);
assert_eq!(&data[28..32], &[30, 10, 42, 255]);
}
#[test]
fn parse_png_strip_accepts_rgb_and_fills_alpha() {
let dir = tempfile::tempdir().expect("tempdir");
let rgb: Vec<u8> = strip2_pixels()
.chunks_exact(4)
.flat_map(|c| [c[0], c[1], c[2]])
.collect();
let src = write_png(&dir, "s.png", 4, 2, png::ColorType::Rgb, &rgb);
let (size, data) = parse_png_strip(&src).expect("parse strip");
assert_eq!(size, 2);
assert!(data.chunks_exact(4).all(|c| c[3] == 255));
}
#[test]
fn parse_png_strip_rejects_a_wrong_width() {
let dir = tempfile::tempdir().expect("tempdir");
let src = write_png(&dir, "s.png", 3, 2, png::ColorType::Rgba, &[0u8; 3 * 2 * 4]);
let err = parse_png_strip(&src).unwrap_err();
assert!(err.contains("must be 4x2"), "got: {err}");
}
#[test]
fn parse_png_strip_rejects_an_out_of_range_size() {
let dir = tempfile::tempdir().expect("tempdir");
let src = write_png(&dir, "s.png", 1, 1, png::ColorType::Rgba, &[0u8; 4]);
let err = parse_png_strip(&src).unwrap_err();
assert!(err.contains("out of range"), "got: {err}");
}
#[test]
fn parse_png_strip_rejects_grayscale() {
let dir = tempfile::tempdir().expect("tempdir");
let src = write_png(&dir, "s.png", 4, 2, png::ColorType::Grayscale, &[0u8; 8]);
let err = parse_png_strip(&src).unwrap_err();
assert!(err.contains("unsupported PNG colour type"), "got: {err}");
}
#[test]
fn parse_png_strip_reports_a_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("missing.png");
let err = parse_png_strip(path.to_str().unwrap()).unwrap_err();
assert!(err.contains("failed to open LUT source"), "got: {err}");
}
const CUBE_2: &str = "\
# identity-ish test cube
TITLE \"test\"
LUT_3D_SIZE 2
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
";
fn write_cube(dir: &tempfile::TempDir, text: &str) -> String {
let path = dir.path().join("t.cube");
std::fs::write(&path, text).expect("write cube");
path.to_string_lossy().into_owned()
}
#[test]
fn compile_color_lut_payload_packs_a_cube_lut() {
let dir = tempfile::tempdir().expect("tempdir");
let src = write_cube(&dir, CUBE_2);
let payload =
compile_color_lut_payload(&serde_json::json!({"source": src}), None).expect("compile");
assert_eq!(&payload[0..4], &0x3354554cu32.to_le_bytes());
assert_eq!(&payload[4..8], &2u32.to_le_bytes());
assert_eq!(&payload[8..12], &0u32.to_le_bytes());
assert_eq!(payload.len(), 12 + 8 * 4);
assert_eq!(&payload[12..16], &[0, 0, 0, 255]);
assert_eq!(&payload[40..44], &[255, 255, 255, 255]);
}
#[test]
fn compile_color_lut_payload_finds_a_bare_source_under_the_assets_dir() {
let assets = tempfile::tempdir().expect("tempdir");
let nested = assets.path().join("grades");
std::fs::create_dir_all(&nested).expect("assets tree");
std::fs::write(nested.join("t.cube"), CUBE_2).expect("write cube");
let args = serde_json::json!({"source": "t.cube"});
let payload =
compile_color_lut_payload(&args, Some(assets.path())).expect("bare source resolves");
assert_eq!(&payload[4..8], &2u32.to_le_bytes());
let elsewhere = tempfile::tempdir().expect("tempdir");
assert!(compile_color_lut_payload(&args, Some(elsewhere.path())).is_err());
assert!(compile_color_lut_payload(&args, None).is_err());
}
#[test]
fn compile_color_lut_payload_packs_a_png_strip() {
let dir = tempfile::tempdir().expect("tempdir");
let src = write_png(&dir, "s.png", 4, 2, png::ColorType::Rgba, &strip2_pixels());
let payload =
compile_color_lut_payload(&serde_json::json!({"source": src}), None).expect("compile");
assert_eq!(&payload[0..4], &0x3354554cu32.to_le_bytes());
assert_eq!(&payload[4..8], &2u32.to_le_bytes());
assert_eq!(payload.len(), 12 + 8 * 4);
assert_eq!(&payload[12..16], &[0, 0, 42, 255]);
assert_eq!(&payload[40..44], &[30, 10, 42, 255]);
}
#[test]
fn compile_color_lut_payload_reports_a_missing_cube_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("missing.cube");
let args = serde_json::json!({"source": path.to_str().unwrap()});
let err = compile_color_lut_payload(&args, None).unwrap_err();
assert!(err.contains("failed to read LUT source"), "got: {err}");
}
#[test]
fn decode_source_dispatches_cube_and_png_by_extension() {
let dir = tempfile::tempdir().expect("tempdir");
let cube = write_cube(&dir, CUBE_2);
let (size, data) = decode_source(&cube).expect("cube decode");
assert_eq!(size, 2);
assert_eq!(data.len(), 32);
let strip = write_png(&dir, "s.png", 4, 2, png::ColorType::Rgba, &strip2_pixels());
let (size, data) = decode_source(&strip).expect("png decode");
assert_eq!(size, 2);
assert_eq!(data.len(), 32);
}
#[test]
fn decode_source_rejects_an_unknown_extension() {
let err = decode_source("grade.tga").unwrap_err();
assert!(err.contains("must be a .cube or .png"), "got: {err}");
}
#[test]
fn compile_color_lut_payload_surfaces_a_malformed_cube() {
let dir = tempfile::tempdir().expect("tempdir");
let src = write_cube(&dir, "LUT_3D_SIZE 2\n0 0 0\n");
let err = compile_color_lut_payload(&serde_json::json!({"source": src}), None).unwrap_err();
assert_eq!(err, "ColorLut .cube has 1 entries, expected 8 for size 2");
}
#[test]
fn compile_color_lut_payload_surfaces_a_bad_png_strip() {
let dir = tempfile::tempdir().expect("tempdir");
let src = write_png(&dir, "s.png", 3, 2, png::ColorType::Rgba, &[0u8; 3 * 2 * 4]);
let err = compile_color_lut_payload(&serde_json::json!({"source": src}), None).unwrap_err();
assert!(err.contains("must be 4x2"), "got: {err}");
}
#[test]
fn decode_source_reports_a_missing_cube_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("missing.cube");
let err = decode_source(path.to_str().unwrap()).unwrap_err();
assert!(err.contains("failed to read LUT source"), "got: {err}");
}
#[test]
fn parse_png_strip_rejects_a_file_that_is_not_a_png() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("s.png");
std::fs::write(&path, b"definitely not a png").expect("write");
let err = parse_png_strip(path.to_str().unwrap()).unwrap_err();
assert!(err.contains("failed to read PNG info"), "got: {err}");
}
#[test]
fn parse_png_strip_rejects_a_truncated_png() {
let dir = tempfile::tempdir().expect("tempdir");
let src = write_png(&dir, "s.png", 4, 2, png::ColorType::Rgba, &strip2_pixels());
let mut bytes = std::fs::read(&src).expect("read png");
bytes.truncate(bytes.len() - 20);
std::fs::write(&src, &bytes).expect("write truncated png");
let err = parse_png_strip(&src).unwrap_err();
assert!(err.contains("failed to decode PNG frame"), "got: {err}");
}
}