From f0f4758c01581d79577b9bf5046c06f4e77b1f18 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 04:08:23 -0500 Subject: [PATCH] image-codecs: preserve PNG HDR metadata and PQ output (#1249) * image-codecs: preserve PNG HDR colour metadata and PQ output * image-codecs: keep PQ PNG dimension checks overflow-safe * image-codecs: test empty PQ PNG input rejection * fix(png): preserve HDR color metadata semantics --- crates/image-codecs/README.md | 2 + crates/image-codecs/src/cicp.rs | 39 ++++- crates/image-codecs/src/codecs/png.rs | 209 +++++++++++++++++++++++++- crates/image-codecs/src/image.rs | 4 + crates/image-codecs/src/lib.rs | 5 +- 5 files changed, 247 insertions(+), 12 deletions(-) diff --git a/crates/image-codecs/README.md b/crates/image-codecs/README.md index 0fdcd6ae4..c2ca866b3 100644 --- a/crates/image-codecs/README.md +++ b/crates/image-codecs/README.md @@ -304,6 +304,8 @@ also passed to the underlying decoders. A violation returns `CodecError::LimitEx defaults are 262144 px per side, 2^30 pixels and 8 GiB (2 GiB on 32-bit targets such as wasm). PNG also bounds the aggregate decoded text and XMP (UTF-8 keywords and values) by `max_alloc`, including chunks after IDAT. This budget is separate from pixels, not a total process-memory cap. +PNG `cICP` and `cLLI` values are retained as image color metadata. `encode_png_pq` writes linear-light +Rec. 2020 RGB as 16-bit BT.2100 PQ, with measured content-light levels and the required chunks. ## Tests diff --git a/crates/image-codecs/src/cicp.rs b/crates/image-codecs/src/cicp.rs index fc148837c..290c82bc3 100644 --- a/crates/image-codecs/src/cicp.rs +++ b/crates/image-codecs/src/cicp.rs @@ -27,6 +27,8 @@ impl Cicp { pub const TRANSFER_PQ: u16 = 16; /// ARIB STD-B67 / BT.2100 hybrid log-gamma. pub const TRANSFER_HLG: u16 = 18; + /// Linear light. + pub const TRANSFER_LINEAR: u16 = 8; /// PQ or HLG: samples that [`linearize`] decodes to linear light. pub fn is_hdr(self) -> bool { @@ -41,6 +43,15 @@ pub const HLG_PEAK_NITS: f32 = 1000.0; /// PQ's absolute peak. pub const PQ_PEAK_NITS: f32 = 10_000.0; +/// HDR content light levels in the units used by PNG `cLLI` (0.0001 cd/m² per unit). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ContentLightLevel { + /// Maximum content light level in 0.0001 cd/m² units. + pub max_content_light_level: u32, + /// Maximum frame-average light level in 0.0001 cd/m² units. + pub max_frame_average_light_level: u32, +} + /// BT.2100 HLG inverse OETF: non-linear signal `E'` (0..1) to normalised scene light `E` (0..1). pub fn hlg_inverse_oetf(e: f32) -> f32 { const A: f32 = 0.178_832_77; @@ -74,6 +85,18 @@ pub fn pq_to_linear(e: f32) -> f32 { y * PQ_PEAK_NITS / REFERENCE_WHITE_NITS } +/// BT.2100 PQ signal for linear light relative to [`REFERENCE_WHITE_NITS`]. +pub fn linear_to_pq(linear: f32) -> f32 { + const M1: f32 = 2610.0 / 16384.0; + const M2: f32 = 2523.0 / 4096.0 * 128.0; + const C1: f32 = 3424.0 / 4096.0; + const C2: f32 = 2413.0 / 4096.0 * 32.0; + const C3: f32 = 2392.0 / 4096.0 * 32.0; + let nits = if linear.is_finite() { linear.max(0.0) * REFERENCE_WHITE_NITS } else { 0.0 }; + let y = (nits / PQ_PEAK_NITS).clamp(0.0, 1.0).powf(M1); + ((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2) +} + /// Relative luminance weights of the RGB primaries `code` (BT.2020's for anything else, the /// space BT.2100 defines HLG in). fn luma_weights(code: u16) -> [f32; 3] { @@ -104,7 +127,8 @@ pub fn linearize(img: Image, cicp: Cicp) -> Result { let mut out = Image::from_raw(w, h, layout, SampleType::F32, v.iter().flat_map(|x| x.to_ne_bytes()).collect())?; out.meta = img.meta; out.warnings = img.warnings; - out.cicp = Some(cicp); + // CICP describes the samples, not their source encoding. These samples are now linear. + out.cicp = Some(Cicp { transfer: Cicp::TRANSFER_LINEAR, matrix: 0, full_range: true, ..cicp }); Ok(out) } @@ -156,7 +180,10 @@ mod tests { assert!(close(pq_to_linear(1.0), 10_000.0 / 203.0, 0.05)); // 203 cd/m² is PQ signal ≈ 0.5807 (BT.2408). assert!(close(pq_to_linear(0.5807), 1.0, 0.005), "{}", pq_to_linear(0.5807)); + assert!(close(linear_to_pq(1.0), 0.5807, 0.0001)); + assert!(close(pq_to_linear(linear_to_pq(4.0)), 4.0, 0.001)); assert!(pq_to_linear(f32::NAN).is_finite()); + assert!(linear_to_pq(f32::NAN).is_finite()); } #[test] @@ -167,7 +194,15 @@ mod tests { .unwrap() .with_icc(Some(b"encoded-profile".to_vec())); let out = linearize(img, hlg).unwrap(); - assert_eq!((out.sample_type(), out.layout(), out.icc.as_deref(), out.cicp), (SampleType::F32, ChannelLayout::Rgba, None, Some(hlg))); + assert_eq!( + (out.sample_type(), out.layout(), out.icc.as_deref(), out.cicp), + ( + SampleType::F32, + ChannelLayout::Rgba, + None, + Some(Cicp { primaries: hlg.primaries, transfer: Cicp::TRANSFER_LINEAR, matrix: 0, full_range: true }) + ) + ); let v = out.to_normalized(); assert!(v[..3].iter().all(|&x| close(x, 1.0, 0.005)), "{v:?}"); assert!(close(v[3], 1.0, 1e-6) && v[4..7] == [0.0; 3] && close(v[7], 0.5, 1e-3), "{v:?}"); diff --git a/crates/image-codecs/src/codecs/png.rs b/crates/image-codecs/src/codecs/png.rs index cf061f49b..4ceb75403 100644 --- a/crates/image-codecs/src/codecs/png.rs +++ b/crates/image-codecs/src/codecs/png.rs @@ -581,6 +581,10 @@ impl> PngDecodeRows { let info = self.reader.info(); let icc = info.icc_profile.as_ref().map(|c| c.to_vec()); let mut meta = Metadata { exif: info.exif_metadata.as_ref().map(|c| c.to_vec()), ..Default::default() }; + meta.content_light_level = info.content_light_level.map(|light| crate::cicp::ContentLightLevel { + max_content_light_level: light.max_content_light_level, + max_frame_average_light_level: light.max_frame_average_light_level, + }); if let Some(d) = info.pixel_dims && d.unit == png::Unit::Meter && d.xppu > 0 @@ -597,7 +601,13 @@ impl> PngDecodeRows { warnings.push(DecodeWarning::MoreFrames { total: u32::try_from(self.images).ok() }); } let orientation = meta.exif.as_deref().map_or(1, crate::orientation::exif_orientation); - PngExtras { icc, meta, warnings, orientation } + let cicp = info.coding_independent_code_points.map(|c| crate::cicp::Cicp { + primaries: u16::from(c.color_primaries), + transfer: u16::from(c.transfer_function), + matrix: u16::from(c.matrix_coefficients), + full_range: c.is_video_full_range_image, + }); + PngExtras { icc, cicp, meta, warnings, orientation } } /// The image itself, with what the metadata scan accepted. @@ -606,6 +616,7 @@ impl> PngDecodeRows { let mut img = Image::from_raw(self.width, height, self.layout, self.sample, std::mem::take(&mut self.buf))?; let extras = self.extras(); img.icc = extras.icc; + img.cicp = extras.cicp; img.meta = extras.meta; img.warnings = extras.warnings; Ok(img) @@ -624,6 +635,8 @@ fn to_native_endian(samples: &mut [u8]) { /// they are complete only once the pixels have been read. pub struct PngExtras { pub icc: Option>, + /// The PNG `cICP` colour description, if present. + pub cicp: Option, pub meta: Metadata, pub warnings: Vec, /// The EXIF orientation (1-8) the pixels are stored in; 1 when there is none. @@ -1050,29 +1063,114 @@ impl Write for FallibleOutput<'_> { pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); let (w, h) = img.dimensions(); - let bpp = img.layout().channels() * img.sample_type().bytes(); // Big-endian sample bytes. let mut data = std::borrow::Cow::Borrowed(img.data()); if img.sample_type() == SampleType::U16 { to_big_endian(data.to_mut()); } + encode_data( + RawPng { + width: w, + height: h, + layout: img.layout(), + sample: img.sample_type(), + icc: img.icc.as_ref(), + cicp: img.cicp, + meta: &img.meta, + data: &data, + }, + opts, + ) +} + +struct RawPng<'a> { + width: u32, + height: u32, + layout: ChannelLayout, + sample: SampleType, + icc: Option<&'a Vec>, + cicp: Option, + meta: &'a Metadata, + data: &'a [u8], +} + +fn encode_data(raw: RawPng<'_>, opts: &EncodeOptions) -> Result, CodecError> { + let RawPng { width: w, height: h, layout, sample, icc, cicp, meta, data } = raw; + let bpp = layout.channels() * sample.bytes(); let mut out = Vec::new(); { let mut output = FallibleOutput(&mut out); - let mut writer = start(&mut output, (w, h), (img.layout(), img.sample_type()), img.icc.as_ref(), &img.meta, opts)?; + let mut writer = start(&mut output, (w, h), (layout, sample), icc, cicp, meta, opts)?; if opts.png_interlaced { - let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?; + let idat = adam7_idat(data, w as usize, h as usize, bpp, opts.png_compression)?; write_idat(&mut writer, &idat, MAX_IDAT)?; - } else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, bpp, opts.png_compression) { + } else if let Some(idat) = parallel_idat(data, w as usize, h as usize, bpp, opts.png_compression) { write_idat(&mut writer, &idat, MAX_IDAT)?; } else { - writer.write_image_data(&data).map_err(|e| CodecError::encode(F, e))?; + writer.write_image_data(data).map_err(|e| CodecError::encode(F, e))?; } writer.finish().map_err(|e| CodecError::encode(F, e))?; } Ok(out) } +/// Encodes linear-light Rec. 2020 RGB as 16-bit PNG with BT.2100 PQ (`cICP`) and measured +/// content-light levels (`cLLI`). Input samples are relative to 203 cd/m² diffuse white, as in +/// [`crate::cicp::linearize`]. Negative and non-finite values become black; values above 10,000 +/// cd/m² are clipped to the PQ signal range. EXIF, XMP, and resolution metadata follow `opts`. +pub fn encode_pq(src: &Image, opts: &EncodeOptions) -> Result, CodecError> { + if src.layout() != ChannelLayout::Rgb || src.sample_type() != SampleType::F32 { + return Err(CodecError::encode(F, "PQ PNG input must be linear-light F32 RGB")); + } + let (width, height) = src.dimensions(); + if width == 0 || height == 0 { + return Err(CodecError::InvalidImage("cannot encode an empty image".into())); + } + let pixels = usize::try_from(u64::from(width) * u64::from(height)).map_err(|_| CodecError::encode(F, "image is too large"))?; + let samples = pixels.checked_mul(3).ok_or_else(|| CodecError::encode(F, "image is too large"))?; + let bytes = samples.checked_mul(2).ok_or_else(|| CodecError::encode(F, "image is too large"))?; + if src.data().len() != samples.checked_mul(4).ok_or_else(|| CodecError::encode(F, "image is too large"))? { + return Err(CodecError::encode(F, "RGB buffer length does not match the image dimensions")); + } + let mut output = Vec::new(); + output.try_reserve_exact(bytes).map_err(|_| CodecError::encode(F, "not enough memory for PQ samples"))?; + let mut max_nits = 0.0f32; + let mut sum_luma = 0.0f64; + for rgb in src.data().as_chunks::<12>().0 { + let read = |at| -> Result { + let bytes = rgb.get(at..at + 4).ok_or_else(|| CodecError::encode(F, "RGB buffer is truncated"))?; + let bytes: [u8; 4] = bytes.try_into().map_err(|_| CodecError::encode(F, "RGB sample is truncated"))?; + Ok(f32::from_ne_bytes(bytes)) + }; + let values = [read(0)?, read(4)?, read(8)?]; + let nits = values.map(|v| if v.is_finite() { (v.max(0.0) * crate::cicp::REFERENCE_WHITE_NITS).min(crate::cicp::PQ_PEAK_NITS) } else { 0.0 }); + max_nits = max_nits.max(nits[0]).max(nits[1]).max(nits[2]); + // CTA-861.3-A defines MaxFALL as the average of each pixel's maximum linear RGB channel. + sum_luma += f64::from(nits[0].max(nits[1]).max(nits[2])); + for value in nits { + let encoded = crate::cicp::linear_to_pq(value / crate::cicp::REFERENCE_WHITE_NITS); + output.extend_from_slice(&((encoded * 65535.0).round().clamp(0.0, 65535.0) as u16).to_be_bytes()); + } + } + let max_cll = ((max_nits.ceil().clamp(1.0, crate::cicp::PQ_PEAK_NITS)) as u32).saturating_mul(10_000); + let max_fall = (((sum_luma / pixels as f64).ceil().clamp(1.0, f64::from(crate::cicp::PQ_PEAK_NITS))) as u32).saturating_mul(10_000); + let mut meta = src.meta.clone(); + meta.content_light_level = Some(crate::cicp::ContentLightLevel { max_content_light_level: max_cll, max_frame_average_light_level: max_fall }); + encode_data( + RawPng { + width, + height, + layout: ChannelLayout::Rgb, + sample: SampleType::U16, + icc: None, + cicp: Some(crate::cicp::Cicp { primaries: 9, transfer: crate::cicp::Cicp::TRANSFER_PQ, matrix: 0, full_range: true }), + meta: &meta, + data: &output, + }, + opts, + ) +} + /// 16-bit native-endian samples to PNG's big-endian order, in place. fn to_big_endian(data: &mut [u8]) { for c in data.as_chunks_mut::<2>().0 { @@ -1088,6 +1186,7 @@ fn start( (w, h): (u32, u32), (layout, sample): (ChannelLayout, SampleType), icc: Option<&Vec>, + cicp: Option, meta: &Metadata, opts: &EncodeOptions, ) -> Result, CodecError> { @@ -1154,6 +1253,22 @@ fn start( } } let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?; + if let Some(cicp) = cicp { + let point = |value: u16, name: &str| { + u8::try_from(value).map_err(|_| CodecError::encode(F, format!("CICP {name} value {value} does not fit in a byte"))) + }; + // PNG stores RGB samples, so cICP's matrix coefficients byte is always zero. + let cicp_bytes = [point(cicp.primaries, "primaries")?, point(cicp.transfer, "transfer")?, 0, u8::from(cicp.full_range)]; + writer.write_chunk(png::chunk::cICP, &cicp_bytes).map_err(|e| CodecError::encode(F, e))?; + } + if opts.embed_metadata + && let Some(light) = meta.content_light_level + { + let mut bytes = [0; 8]; + bytes[..4].copy_from_slice(&light.max_content_light_level.to_be_bytes()); + bytes[4..].copy_from_slice(&light.max_frame_average_light_level.to_be_bytes()); + writer.write_chunk(png::chunk::cLLI, &bytes).map_err(|e| CodecError::encode(F, e))?; + } if let Some(stereo) = opts.png_stereo { // Ancillary chunk before IDAT, as the spec recommends for `sTER`. writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?; @@ -1208,7 +1323,7 @@ impl PngRows { return Err(CodecError::InvalidImage("cannot encode an empty image".into())); } let plan = crate::fidelity::plan(first, F, opts); - let writer = start(out, (width, height), (plan.layout, plan.sample), first.icc.as_ref(), &first.meta, opts)?; + let writer = start(out, (width, height), (plan.layout, plan.sample), first.icc.as_ref(), first.cicp, &first.meta, opts)?; Ok(Self { writer, plan, @@ -1599,6 +1714,83 @@ pub fn encode_indexed( Ok(out) } +#[cfg(test)] +mod hdr_tests { + use super::*; + + #[test] + fn generic_png_roundtrip_keeps_cicp_and_content_light_level() { + let mut image = Image::from_raw(1, 1, ChannelLayout::Rgb, SampleType::U16, vec![0, 0, 0, 0, 0, 0]).unwrap(); + image.cicp = Some(crate::cicp::Cicp { primaries: 9, transfer: 16, matrix: 9, full_range: true }); + image.meta.content_light_level = + Some(crate::cicp::ContentLightLevel { max_content_light_level: 2_030_000, max_frame_average_light_level: 1_250_000 }); + + let encoded = encode(&image, Plan { layout: ChannelLayout::Rgb, sample: SampleType::U16 }, &EncodeOptions::default()).unwrap(); + let decoded = decode(&encoded, &Limits::default()).unwrap(); + assert_eq!(decoded.cicp, Some(crate::cicp::Cicp { matrix: 0, ..image.cicp.unwrap() })); + assert_eq!(decoded.meta.content_light_level, image.meta.content_light_level); + } + + #[test] + fn generic_png_keeps_cicp_alongside_icc() { + let mut image = Image::from_raw(1, 1, ChannelLayout::Rgb, SampleType::U16, vec![0, 0, 0, 0, 0, 0]).unwrap(); + image.cicp = Some(crate::cicp::Cicp { primaries: 9, transfer: 16, matrix: 0, full_range: true }); + image.icc = Some(b"synthetic ICC profile".to_vec()); + + let encoded = encode(&image, Plan { layout: ChannelLayout::Rgb, sample: SampleType::U16 }, &EncodeOptions::default()).unwrap(); + let decoded = decode(&encoded, &Limits::default()).unwrap(); + assert_eq!(decoded.cicp, image.cicp); + assert_eq!(decoded.icc, image.icc); + } + + #[test] + fn linearized_pq_pixels_are_tagged_as_linear_in_generic_png() { + let cicp = crate::cicp::Cicp { primaries: 9, transfer: crate::cicp::Cicp::TRANSFER_PQ, matrix: 0, full_range: true }; + let encoded_white = (0.5807f32 * 65535.0).round() as u16; + let mut image = Image::from_u16(1, 1, ChannelLayout::Rgb, &[encoded_white; 3]).unwrap(); + image.cicp = Some(cicp); + let linear = crate::cicp::linearize(image, cicp).unwrap(); + let png = encode(&linear, Plan { layout: ChannelLayout::Rgb, sample: SampleType::U16 }, &EncodeOptions::default()).unwrap(); + let decoded = decode(&png, &Limits::default()).unwrap(); + assert_eq!(decoded.cicp.unwrap().transfer, crate::cicp::Cicp::TRANSFER_LINEAR); + assert!(decoded.to_normalized().iter().all(|&v| (v - 1.0).abs() < 0.001)); + } + + #[test] + fn pq_png_writes_rec2020_codes_and_measured_light_levels() { + let image = Image::from_f32(1, 1, ChannelLayout::Rgb, &[1.0, 1.0, 1.0]).unwrap(); + let encoded = encode_pq(&image, &EncodeOptions::default()).unwrap(); + let mut reader = png::Decoder::new(Cursor::new(&encoded)).read_info().unwrap(); + let info = reader.info(); + let cicp = info.coding_independent_code_points.unwrap(); + assert_eq!((cicp.color_primaries, cicp.transfer_function, cicp.matrix_coefficients, cicp.is_video_full_range_image), (9, 16, 0, true)); + let light = info.content_light_level.unwrap(); + assert_eq!((light.max_content_light_level, light.max_frame_average_light_level), (2_030_000, 2_030_000)); + let mut bytes = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut bytes).unwrap(); + let rgb = bytes.get(..frame.buffer_size()).unwrap().as_chunks::<2>().0.iter().map(|v| u16::from_be_bytes(*v)).collect::>(); + assert_eq!(rgb.len(), 3); + for sample in rgb { + assert!((f32::from(sample) / 65535.0 - 0.5807).abs() < 0.001); + } + } + + #[test] + fn pq_png_maxfall_uses_highest_channel_for_saturated_pixels() { + let image = Image::from_f32(2, 1, ChannelLayout::Rgb, &[1.0, 0.0, 0.0, 0.0, 0.0, 1.0]).unwrap(); + let encoded = encode_pq(&image, &EncodeOptions::default()).unwrap(); + let reader = png::Decoder::new(Cursor::new(encoded)).read_info().unwrap(); + let light = reader.info().content_light_level.unwrap(); + assert_eq!((light.max_content_light_level, light.max_frame_average_light_level), (2_030_000, 2_030_000)); + } + + #[test] + fn pq_png_rejects_empty_images() { + let image = Image::from_f32(0, 1, ChannelLayout::Rgb, &[]).unwrap(); + assert!(encode_pq(&image, &EncodeOptions::default()).is_err()); + } +} + #[cfg(test)] mod stereo_tests { use super::*; @@ -2030,7 +2222,8 @@ mod streamed_tests { let whole = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); let (_, zlib, _) = split(&whole); let mut out = Vec::new(); - let mut w = start(&mut out, (64, 40), (ChannelLayout::Rgb, SampleType::U8), None, &Metadata::default(), &EncodeOptions::default()).unwrap(); + let mut w = + start(&mut out, (64, 40), (ChannelLayout::Rgb, SampleType::U8), None, None, &Metadata::default(), &EncodeOptions::default()).unwrap(); write_idat(&mut w, &zlib, 100).unwrap(); w.finish().unwrap(); let (_, joined, count) = split(&out); diff --git a/crates/image-codecs/src/image.rs b/crates/image-codecs/src/image.rs index dc555c42b..a6f417c72 100644 --- a/crates/image-codecs/src/image.rs +++ b/crates/image-codecs/src/image.rs @@ -142,6 +142,9 @@ pub struct Metadata { pub dpi: Option<(f32, f32)>, /// Free-form key/value text (PNG tEXt/iTXt, TIFF ASCII tags). pub text: Vec<(String, String)>, + /// HDR content light levels carried by formats such as PNG `cLLI`, in units of + /// 0.0001 cd/m² per value. A zero value means that level is unknown. + pub content_light_level: Option, /// Photoshop image resources (`8BIM` resource blocks): TIFF tag 34377. Kept opaque here; /// `photocraft-io` reads and writes them through `photocraft-psd`. pub photoshop_resources: Option>, @@ -174,6 +177,7 @@ impl Metadata { && self.xmp.is_none() && self.dpi.is_none() && self.text.is_empty() + && self.content_light_level.is_none() && self.photoshop_resources.is_none() && self.photoshop_layers.is_none() && self.extra_samples.is_empty() diff --git a/crates/image-codecs/src/lib.rs b/crates/image-codecs/src/lib.rs index e02c438ec..d3ab272fa 100644 --- a/crates/image-codecs/src/lib.rs +++ b/crates/image-codecs/src/lib.rs @@ -32,11 +32,12 @@ pub mod source_settings; pub mod web; pub use crate::allocation::probe_allocation_size; pub use crate::cicp::Cicp; +pub use crate::cicp::ContentLightLevel; pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color}; pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; pub use crate::codecs::png::{ - PngDecodeRows, PngExtras, PngHeader, PngPalette, PngRowSink, PngRows, SharedBytes, encode_indexed as encode_png_indexed, palette as png_palette, - stereo as png_stereo, + PngDecodeRows, PngExtras, PngHeader, PngPalette, PngRowSink, PngRows, SharedBytes, encode_indexed as encode_png_indexed, + encode_pq as encode_png_pq, palette as png_palette, stereo as png_stereo, }; pub use crate::codecs::tiff::{ PhotoshopTags as TiffPhotoshopTags, TiffBands, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian,