Skip to main content

rawshift_image/formats/
encode.rs

1//! Standard image format encode functions.
2//!
3//! [`encode_rgb_image_to_vec`] is the single in-memory entry point and supports
4//! every output format. [`encode_rgb_image_to_writer`] and [`encode_rgb_image`]
5//! are thin wrappers that stream or persist its result.
6//!
7//! Metadata (EXIF / ICC / XMP) is embedded entirely in-memory, so no format
8//! needs a file path — including AVIF and JXL, which previously did.
9
10use std::path::Path;
11
12#[cfg(any_standard_encode)]
13use crate::core::BitDepth;
14use crate::core::image::RgbImage;
15use crate::core::metadata::ImageMetadata;
16#[cfg(any_standard_encode)]
17use crate::error::EncodeError;
18use crate::error::{RawError, RawResult};
19
20use super::export::EncodeOptions;
21#[cfg(feature = "webp-encode")]
22use super::export::WebPMode;
23
24/// Encode a linear RGB image to an in-memory byte buffer.
25///
26/// This is the core encode entry point — every output format (PNG, JPEG, WebP,
27/// AVIF, JXL, DNG) is supported, with EXIF/ICC/XMP metadata embedded according
28/// to the encoder's [`CommonEncodeOptions`](super::export::CommonEncodeOptions).
29///
30/// `image` must contain 16-bit RGB data normalized to `[0, 65535]`.
31#[cfg_attr(not(any_standard_encode), allow(unused_variables, unreachable_code))]
32pub fn encode_rgb_image_to_vec(
33    image: &RgbImage,
34    metadata: &ImageMetadata,
35    encode_options: &EncodeOptions,
36) -> RawResult<Vec<u8>> {
37    match encode_options {
38        #[cfg(feature = "png-encode")]
39        EncodeOptions::PngZune(cfg) => encode_png(image, metadata, cfg),
40        #[cfg(feature = "jpeg-encode")]
41        EncodeOptions::JpegJpegEnc(cfg) => encode_jpeg(image, metadata, cfg),
42        #[cfg(feature = "webp-encode")]
43        EncodeOptions::WebpLibwebp(cfg) => encode_webp(image, metadata, cfg),
44        #[cfg(feature = "avif-encode")]
45        EncodeOptions::AvifRavif(cfg) => encode_avif(image, metadata, cfg),
46        #[cfg(feature = "jxl-encode")]
47        EncodeOptions::JxlZune(cfg) => encode_jxl(image, metadata, cfg),
48        #[cfg(feature = "jxl-encode-libjxl")]
49        EncodeOptions::JxlLibjxl(cfg) => encode_jxl_libjxl(image, metadata, cfg),
50        #[cfg(feature = "dng-encode")]
51        EncodeOptions::Dng(cfg) => {
52            let mut buf = std::io::Cursor::new(Vec::new());
53            super::dng_export::export_dng_to_writer(&mut buf, image, metadata, cfg)?;
54            Ok(buf.into_inner())
55        }
56        #[allow(unreachable_patterns)]
57        _ => Err(RawError::Unsupported(
58            "This encode format is not available with the current feature flags.".to_string(),
59        )),
60    }
61}
62
63/// Encode a linear RGB image to any writer.
64///
65/// Convenience wrapper over [`encode_rgb_image_to_vec`]; supports every format.
66pub fn encode_rgb_image_to_writer<W: std::io::Write>(
67    image: &RgbImage,
68    metadata: &ImageMetadata,
69    writer: &mut W,
70    encode_options: &EncodeOptions,
71) -> RawResult<()> {
72    let bytes = encode_rgb_image_to_vec(image, metadata, encode_options)?;
73    writer.write_all(&bytes)?;
74    Ok(())
75}
76
77/// Encode a linear RGB image to a file.
78///
79/// Convenience wrapper over [`encode_rgb_image_to_vec`].
80pub fn encode_rgb_image(
81    image: &RgbImage,
82    metadata: &ImageMetadata,
83    path: &Path,
84    encode_options: &EncodeOptions,
85) -> RawResult<()> {
86    let bytes = encode_rgb_image_to_vec(image, metadata, encode_options)?;
87    std::fs::write(path, bytes)?;
88    Ok(())
89}
90
91// ── Sample packing helpers ────────────────────────────────────────────────────
92
93/// Pack 16-bit RGB samples down to 8 bits each (the high byte of each sample).
94#[cfg(any_standard_encode)]
95#[allow(dead_code)] // unused when only `dng-encode` is enabled
96fn pack_rgb8(image: &RgbImage) -> Vec<u8> {
97    image.data.iter().map(|&p| (p >> 8) as u8).collect()
98}
99
100/// Validate a bit-depth request for a backend that only emits 8-bit output.
101///
102/// `Eight` and `Sixteen` are both accepted (`Sixteen` is down-converted, as
103/// before); deeper requests yield [`EncodeError::UnsupportedBitDepth`].
104#[cfg(any_standard_encode)]
105#[allow(dead_code)] // unused when only `dng-encode` is enabled
106fn check_8bit_backend(bit_depth: BitDepth, format: &'static str) -> Result<(), EncodeError> {
107    match bit_depth {
108        BitDepth::Eight | BitDepth::Sixteen => Ok(()),
109        _ => Err(EncodeError::UnsupportedBitDepth {
110            format,
111            requested: bit_depth,
112        }),
113    }
114}
115
116// ── PNG ───────────────────────────────────────────────────────────────────────
117
118#[cfg(feature = "png-encode")]
119fn encode_png(
120    image: &RgbImage,
121    metadata: &ImageMetadata,
122    cfg: &super::export::ZunePngEncodeConfig,
123) -> RawResult<Vec<u8>> {
124    use zune_core::colorspace::ColorSpace as ZuneColorSpace;
125    use zune_core::options::EncoderOptions;
126    use zune_png::PngEncoder;
127
128    // PNG genuinely supports 8- and 16-bit output.
129    let (data_bytes, depth) = match cfg.common.bit_depth {
130        BitDepth::Eight => (pack_rgb8(image), zune_core::bit_depth::BitDepth::Eight),
131        BitDepth::Sixteen => {
132            let mut bytes = Vec::with_capacity(image.data.len() * 2);
133            for &pixel in &image.data {
134                bytes.extend_from_slice(&pixel.to_be_bytes());
135            }
136            (bytes, zune_core::bit_depth::BitDepth::Sixteen)
137        }
138        other => {
139            return Err(RawError::Encode(EncodeError::UnsupportedBitDepth {
140                format: "PNG",
141                requested: other,
142            }));
143        }
144    };
145
146    let options = EncoderOptions::default()
147        .set_width(image.width() as usize)
148        .set_height(image.height() as usize)
149        .set_colorspace(ZuneColorSpace::RGB)
150        .set_depth(depth);
151
152    let mut encoder = PngEncoder::new(&data_bytes, options);
153    let mut output = Vec::new();
154    encoder.encode(&mut output).map_err(|e| {
155        RawError::Encode(EncodeError::Encoding {
156            format: "PNG",
157            message: format!("PNG encoding error: {e:?}"),
158        })
159    })?;
160
161    let m = &cfg.common.metadata;
162    if m.embed_exif || m.embed_icc || m.embed_xmp {
163        use crate::metadata::exif::ExifBuilder;
164        use crate::metadata::icc::IccProfile;
165        use img_parts::png::{Png, PngChunk};
166        use img_parts::{Bytes, ImageEXIF, ImageICC};
167
168        match Png::from_bytes(Bytes::from(output.clone())) {
169            Ok(mut png) => {
170                if m.embed_icc {
171                    let icc = IccProfile::srgb();
172                    png.set_icc_profile(Some(Bytes::from(icc.as_bytes().to_vec())));
173                }
174                if m.embed_exif {
175                    let exif_builder = ExifBuilder::new(metadata);
176                    match exif_builder.build_bytes() {
177                        Ok(bytes) => png.set_exif(Some(Bytes::from(bytes))),
178                        Err(e) => tracing::warn!("Failed to embed EXIF in PNG: {e}"),
179                    }
180                }
181                if m.embed_xmp
182                    && let Some(xmp_data) = &metadata.xmp
183                {
184                    // iTXt: keyword\0 + compression_flag + compression_method
185                    //       + language_tag\0 + translated_keyword\0 + text
186                    let mut chunk_data = Vec::with_capacity(22 + xmp_data.len());
187                    chunk_data.extend_from_slice(b"XML:com.adobe.xmp\0");
188                    chunk_data.push(0); // compression_flag = 0
189                    chunk_data.push(0); // compression_method = 0
190                    chunk_data.push(0); // language_tag (empty)
191                    chunk_data.push(0); // translated_keyword (empty)
192                    chunk_data.extend_from_slice(xmp_data);
193                    let chunk = PngChunk::new(*b"iTXt", Bytes::from(chunk_data));
194                    let idx = png.chunks().len().saturating_sub(1);
195                    png.chunks_mut().insert(idx, chunk);
196                }
197                use std::io::Cursor;
198                let mut buf = Cursor::new(Vec::new());
199                match png.encoder().write_to(&mut buf) {
200                    Ok(_) => output = buf.into_inner(),
201                    Err(e) => tracing::warn!("Failed to write PNG with metadata: {e}"),
202                }
203            }
204            Err(e) => tracing::warn!("Failed to parse PNG for metadata embedding: {e}"),
205        }
206    }
207
208    Ok(output)
209}
210
211// ── JPEG ──────────────────────────────────────────────────────────────────────
212
213#[cfg(feature = "jpeg-encode")]
214fn encode_jpeg(
215    image: &RgbImage,
216    metadata: &ImageMetadata,
217    cfg: &super::export::JpegEncEncodeConfig,
218) -> RawResult<Vec<u8>> {
219    use crate::metadata::exif::ExifBuilder;
220    use crate::metadata::icc::IccProfile;
221    use jpeg_encoder::{ColorType, Encoder};
222
223    check_8bit_backend(cfg.common.bit_depth, "JPEG")?;
224    let data_8bit = pack_rgb8(image);
225
226    let quality = if cfg.quality == 0 { 90 } else { cfg.quality };
227    let mut jpeg_buf = Vec::new();
228    let encoder = Encoder::new(&mut jpeg_buf, quality);
229    encoder.encode(
230        &data_8bit,
231        image.width() as u16,
232        image.height() as u16,
233        ColorType::Rgb,
234    )?;
235
236    let m = &cfg.common.metadata;
237    if m.embed_exif {
238        let exif_builder = ExifBuilder::new(metadata);
239        match exif_builder.append_to_jpeg(jpeg_buf.clone()) {
240            Ok(data) => jpeg_buf = data,
241            Err(e) => tracing::warn!("Failed to embed EXIF in JPEG: {e}"),
242        }
243    }
244    if m.embed_icc {
245        match IccProfile::srgb().append_to_jpeg(jpeg_buf.clone()) {
246            Ok(data) => jpeg_buf = data,
247            Err(e) => tracing::warn!("Failed to embed ICC in JPEG: {e}"),
248        }
249    }
250    if m.embed_xmp
251        && let Some(xmp_data) = &metadata.xmp
252    {
253        use crate::metadata::xmp::append_xmp_to_jpeg;
254        match append_xmp_to_jpeg(xmp_data, jpeg_buf.clone()) {
255            Ok(data) => jpeg_buf = data,
256            Err(e) => tracing::warn!("Failed to embed XMP in JPEG: {e}"),
257        }
258    }
259
260    Ok(jpeg_buf)
261}
262
263// ── WebP ──────────────────────────────────────────────────────────────────────
264
265#[cfg(feature = "webp-encode")]
266fn encode_webp(
267    image: &RgbImage,
268    metadata: &ImageMetadata,
269    cfg: &super::export::LibwebpEncodeConfig,
270) -> RawResult<Vec<u8>> {
271    use crate::codecs::webp::{build_webp_config, encode_webp_rgb, mux_webp};
272    use crate::metadata::exif::ExifBuilder;
273    use crate::metadata::icc::IccProfile;
274
275    check_8bit_backend(cfg.common.bit_depth, "WebP")?;
276
277    let lossless = cfg.mode == WebPMode::Lossless;
278    let config = build_webp_config(lossless, cfg.quality, cfg.method, cfg.near_lossless)
279        .map_err(|e| RawError::Encode(EncodeError::WebP(e)))?;
280
281    let data_8bit = pack_rgb8(image);
282    let encoded = encode_webp_rgb(&data_8bit, image.width(), image.height(), &config)
283        .map_err(|e| RawError::Encode(EncodeError::WebP(e)))?;
284
285    let m = &cfg.common.metadata;
286    let exif_bytes = if m.embed_exif {
287        match ExifBuilder::new(metadata).build_bytes() {
288            Ok(bytes) => Some(bytes),
289            Err(e) => {
290                tracing::warn!("Failed to build EXIF for WebP: {e}");
291                None
292            }
293        }
294    } else {
295        None
296    };
297    let icc_bytes = if m.embed_icc {
298        Some(IccProfile::srgb().as_bytes().to_vec())
299    } else {
300        None
301    };
302    let xmp_bytes = if m.embed_xmp {
303        metadata.xmp.as_deref()
304    } else {
305        None
306    };
307
308    mux_webp(
309        &encoded,
310        exif_bytes.as_deref(),
311        icc_bytes.as_deref(),
312        xmp_bytes,
313    )
314    .map_err(|e| RawError::Encode(EncodeError::WebP(e)))
315}
316
317// ── AVIF ──────────────────────────────────────────────────────────────────────
318
319#[cfg(feature = "avif-encode")]
320fn encode_avif(
321    image: &RgbImage,
322    metadata: &ImageMetadata,
323    cfg: &super::export::RavifEncodeConfig,
324) -> RawResult<Vec<u8>> {
325    use crate::metadata::exif::ExifBuilder;
326    use crate::metadata::icc::IccProfile;
327    use crate::metadata::xmp::append_xmp_to_avif;
328    use ravif::{Encoder, Img, RGBA8};
329
330    check_8bit_backend(cfg.common.bit_depth, "AVIF")?;
331
332    let rgba_data: Vec<RGBA8> = image
333        .data
334        .chunks_exact(3)
335        .map(|rgb| {
336            RGBA8::new(
337                (rgb[0] >> 8) as u8,
338                (rgb[1] >> 8) as u8,
339                (rgb[2] >> 8) as u8,
340                255,
341            )
342        })
343        .collect();
344
345    let img = Img::new(
346        rgba_data.as_slice(),
347        image.width() as usize,
348        image.height() as usize,
349    );
350
351    let encoder = Encoder::new()
352        .with_quality(cfg.quality as f32)
353        .with_speed(cfg.speed);
354
355    // Encode failures are domain errors, never panics — this runs on a worker
356    // pool and a failed target must be reported, not crash the process.
357    let result = encoder.encode_rgba(img).map_err(|e| {
358        RawError::Encode(EncodeError::Encoding {
359            format: "AVIF",
360            message: format!("{e:?}"),
361        })
362    })?;
363    let mut avif_bytes = result.avif_file;
364
365    let m = &cfg.common.metadata;
366    if m.embed_icc {
367        match IccProfile::srgb().append_to_avif(avif_bytes.clone()) {
368            Ok(data) => avif_bytes = data,
369            Err(e) => tracing::warn!("Failed to embed ICC in AVIF: {e}"),
370        }
371    }
372    if m.embed_exif {
373        match ExifBuilder::new(metadata).append_to_avif(avif_bytes.clone()) {
374            Ok(data) => avif_bytes = data,
375            Err(e) => tracing::warn!("Failed to embed EXIF in AVIF: {e}"),
376        }
377    }
378    if m.embed_xmp
379        && let Some(xmp_data) = &metadata.xmp
380    {
381        match append_xmp_to_avif(xmp_data, avif_bytes.clone()) {
382            Ok(data) => avif_bytes = data,
383            Err(e) => tracing::warn!("Failed to embed XMP in AVIF: {e}"),
384        }
385    }
386
387    Ok(avif_bytes)
388}
389
390// ── JPEG XL ───────────────────────────────────────────────────────────────────
391
392#[cfg(feature = "jxl-encode")]
393fn encode_jxl(
394    image: &RgbImage,
395    metadata: &ImageMetadata,
396    cfg: &super::export::ZuneJxlEncodeConfig,
397) -> RawResult<Vec<u8>> {
398    use crate::metadata::exif::ExifBuilder;
399    use crate::metadata::icc::IccProfile;
400    use crate::metadata::xmp::append_xmp_to_jxl;
401    use zune_core::colorspace::ColorSpace as ZuneColorSpace;
402    use zune_core::options::EncoderOptions;
403    use zune_jpegxl::JxlSimpleEncoder;
404
405    check_8bit_backend(cfg.common.bit_depth, "JXL")?;
406    let data_8bit = pack_rgb8(image);
407
408    let quality = if cfg.quality == 0.0 {
409        100
410    } else {
411        cfg.quality as u8
412    };
413    let enc_options = EncoderOptions::default()
414        .set_width(image.width() as usize)
415        .set_height(image.height() as usize)
416        .set_colorspace(ZuneColorSpace::RGB)
417        .set_quality(quality);
418
419    let encoder = JxlSimpleEncoder::new(&data_8bit, enc_options);
420    let mut encoded: Vec<u8> = Vec::new();
421    encoder.encode(&mut encoded).map_err(|e| {
422        RawError::Encode(EncodeError::Encoding {
423            format: "JXL",
424            message: format!("{e:?}"),
425        })
426    })?;
427
428    let m = &cfg.common.metadata;
429    if m.embed_exif {
430        match ExifBuilder::new(metadata).append_to_jxl(encoded.clone()) {
431            Ok(data) => encoded = data,
432            Err(e) => tracing::warn!("Failed to embed EXIF in JXL: {e}"),
433        }
434    }
435    if m.embed_icc {
436        match IccProfile::srgb().append_to_jxl(encoded.clone()) {
437            Ok(data) => encoded = data,
438            Err(e) => tracing::warn!("Failed to embed ICC in JXL: {e}"),
439        }
440    }
441    if m.embed_xmp
442        && let Some(xmp_data) = &metadata.xmp
443    {
444        match append_xmp_to_jxl(xmp_data, encoded.clone()) {
445            Ok(data) => encoded = data,
446            Err(e) => tracing::warn!("Failed to embed XMP in JXL: {e}"),
447        }
448    }
449
450    Ok(encoded)
451}
452
453// ── JPEG XL (libjxl reference encoder) ──────────────────────────────────────────
454
455#[cfg(feature = "jxl-encode-libjxl")]
456fn encode_jxl_libjxl(
457    image: &RgbImage,
458    metadata: &ImageMetadata,
459    cfg: &super::export::LibjxlEncodeConfig,
460) -> RawResult<Vec<u8>> {
461    use super::export::{LibjxlColorTransform, LibjxlModular};
462    use crate::codecs::jxl_libjxl::{self, JxlEncodeParams};
463    use crate::metadata::exif::ExifBuilder;
464    use crate::metadata::icc::IccProfile;
465    use crate::metadata::xmp::append_xmp_to_jxl;
466
467    // Bit depth → packed sample bytes. Unlike the 8-bit-only backends, libjxl
468    // genuinely encodes 16-bit, so `Sixteen` is passed through (native-endian to
469    // match the `JXL_NATIVE_ENDIAN` pixel format the wrapper requests).
470    let (samples, bits_per_sample) = match cfg.common.bit_depth {
471        BitDepth::Eight => (pack_rgb8(image), 8u32),
472        BitDepth::Sixteen => {
473            let mut bytes = Vec::with_capacity(image.data.len() * 2);
474            for &sample in &image.data {
475                bytes.extend_from_slice(&sample.to_ne_bytes());
476            }
477            (bytes, 16u32)
478        }
479        other => {
480            return Err(RawError::Encode(EncodeError::UnsupportedBitDepth {
481                format: "JXL",
482                requested: other,
483            }));
484        }
485    };
486
487    // Resolve the typed config to libjxl's `-1`/`0`/`1`… frame-setting values.
488    let modular = match cfg.modular {
489        LibjxlModular::Auto => -1,
490        LibjxlModular::VarDct => 0,
491        LibjxlModular::Modular => 1,
492    };
493    let color_transform = match cfg.color_transform {
494        LibjxlColorTransform::Auto => -1,
495        LibjxlColorTransform::Xyb => 0,
496        LibjxlColorTransform::None => 1,
497        LibjxlColorTransform::YCbCr => 2,
498    };
499    let tri = |o: Option<bool>| match o {
500        None => -1,
501        Some(false) => 0,
502        Some(true) => 1,
503    };
504
505    let params = JxlEncodeParams {
506        distance: cfg.distance,
507        quality: cfg.quality,
508        lossless: cfg.lossless,
509        effort: i64::from(cfg.effort),
510        brotli_effort: i64::from(cfg.brotli_effort),
511        decoding_speed: i64::from(cfg.decoding_speed),
512        progressive: cfg.progressive,
513        modular,
514        color_transform,
515        epf: i64::from(cfg.epf),
516        gaborish: tri(cfg.gaborish),
517        noise: tri(cfg.noise),
518        dots: tri(cfg.dots),
519        patches: tri(cfg.patches),
520        photon_noise_iso: cfg.photon_noise_iso,
521        resampling: i64::from(cfg.resampling),
522        use_container: cfg.use_container,
523        codestream_level: i32::from(cfg.codestream_level),
524        extra_int_options: cfg.extra_int_options.clone(),
525        extra_float_options: cfg.extra_float_options.clone(),
526    };
527
528    let mut encoded = jxl_libjxl::encode(
529        &samples,
530        image.width(),
531        image.height(),
532        bits_per_sample,
533        &params,
534    )
535    .map_err(|e| RawError::Encode(EncodeError::Jxl(e)))?;
536
537    // Metadata embedding mirrors the zune `encode_jxl` path exactly.
538    let m = &cfg.common.metadata;
539    if m.embed_exif {
540        match ExifBuilder::new(metadata).append_to_jxl(encoded.clone()) {
541            Ok(data) => encoded = data,
542            Err(e) => tracing::warn!("Failed to embed EXIF in JXL: {e}"),
543        }
544    }
545    if m.embed_icc {
546        match IccProfile::srgb().append_to_jxl(encoded.clone()) {
547            Ok(data) => encoded = data,
548            Err(e) => tracing::warn!("Failed to embed ICC in JXL: {e}"),
549        }
550    }
551    if m.embed_xmp
552        && let Some(xmp_data) = &metadata.xmp
553    {
554        match append_xmp_to_jxl(xmp_data, encoded.clone()) {
555            Ok(data) => encoded = data,
556            Err(e) => tracing::warn!("Failed to embed XMP in JXL: {e}"),
557        }
558    }
559
560    Ok(encoded)
561}