Skip to main content

lunar_image/
encode.rs

1use crate::error::EncodeError;
2use crate::filter;
3use crate::format::{self, ChunkType};
4use crate::simd;
5
6/// options for encoding an image to .li format.
7#[derive(Debug, Clone)]
8pub struct EncodeOptions {
9	/// zstd compression level (1-22).
10	/// level 9 sits at the pareto knee — ~80% of max ratio for ~20% of max encode cost.
11	/// encoding is a build-time operation so levels above 3 are always worth it.
12	pub compression_level: i32,
13	/// whether the image contains alpha channel data.
14	pub has_alpha: bool,
15	/// whether the pixel data has premultiplied alpha.
16	pub premultiplied: bool,
17	/// optional metadata string (e.g. json) embedded in the file.
18	pub metadata: Option<String>,
19}
20
21impl Default for EncodeOptions {
22	fn default() -> Self {
23		Self {
24			compression_level: 9,
25			has_alpha: true,
26			premultiplied: false,
27			metadata: None,
28		}
29	}
30}
31
32/// encode RGBA pixels to .li format bytes using default options.
33///
34/// # Errors
35/// returns an error if the pixel buffer size does not match `width * height * 4`.
36pub fn encode(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, EncodeError> {
37	encode_with_opts(width, height, rgba, &EncodeOptions::default())
38}
39
40/// encode RGBA pixels to .li format bytes with custom options.
41///
42/// pixel data is deinterleaved into channel planes before compression.
43/// this gives zstd coherent per-channel statistics and significantly better ratios,
44/// especially for sprites with solid or near-solid regions.
45///
46/// # Errors
47/// returns an error if the pixel buffer size does not match `width * height * 4`,
48/// or if zstd compression fails.
49pub fn encode_with_opts(
50	width: u32,
51	height: u32,
52	rgba: &[u8],
53	opts: &EncodeOptions,
54) -> Result<Vec<u8>, EncodeError> {
55	let expected_bytes = (width as usize) * (height as usize) * 4;
56	if rgba.len() != expected_bytes {
57		return Err(EncodeError::BufferSizeMismatch {
58			expected: expected_bytes,
59			actual: rgba.len(),
60		});
61	}
62
63	// deinterleave then compress — channels separate means zstd sees coherent data
64	let planar = simd::deinterleave_rgba(rgba);
65	let unfiltered = zstd::encode_all(planar.as_slice(), opts.compression_level)
66		.map_err(EncodeError::ZstdError)?;
67
68	// also try a per-row delta filter on the planes and keep whichever compresses
69	// smaller, so filtering can never make a file larger. encode is build-time, so
70	// the extra zstd pass costs nothing at runtime.
71	let (pixel_data, pixel_uncompressed_size, filtered) = if width > 0 && height > 0 {
72		let filtered_planar = filter::filter_planes(&planar, width as usize, height as usize, 4);
73		let filtered_compressed =
74			zstd::encode_all(filtered_planar.as_slice(), opts.compression_level)
75				.map_err(EncodeError::ZstdError)?;
76		if filtered_compressed.len() < unfiltered.len() {
77			(filtered_compressed, filtered_planar.len(), true)
78		} else {
79			(unfiltered, expected_bytes, false)
80		}
81	} else {
82		(unfiltered, expected_bytes, false)
83	};
84
85	let mut flags = format::FLAG_PLANAR;
86	if opts.has_alpha {
87		flags |= format::FLAG_HAS_ALPHA;
88	}
89	if opts.premultiplied {
90		flags |= format::FLAG_PREMULTIPLIED;
91	}
92	if opts.metadata.is_some() {
93		flags |= format::FLAG_HAS_METADATA;
94	}
95	if filtered {
96		flags |= format::FLAG_FILTERED;
97	}
98
99	let mut out = Vec::with_capacity(expected_bytes / 2);
100	format::write_header(&mut out, width, height, flags);
101
102	format::ChunkHeader::write(
103		&mut out,
104		ChunkType::PixelData,
105		u32::try_from(pixel_uncompressed_size).unwrap_or(u32::MAX),
106		u32::try_from(pixel_data.len()).unwrap_or(u32::MAX),
107		0,
108	);
109	out.extend_from_slice(&pixel_data);
110
111	if let Some(meta) = &opts.metadata {
112		let meta_bytes = meta.as_bytes();
113		let compressed_meta =
114			zstd::encode_all(meta_bytes, opts.compression_level).map_err(EncodeError::ZstdError)?;
115		format::ChunkHeader::write(
116			&mut out,
117			ChunkType::Metadata,
118			u32::try_from(meta_bytes.len()).unwrap_or(u32::MAX),
119			u32::try_from(compressed_meta.len()).unwrap_or(u32::MAX),
120			0,
121		);
122		out.extend_from_slice(&compressed_meta);
123	}
124
125	Ok(out)
126}