diff --git a/CHANGELOG.md b/CHANGELOG.md index c0170b4..8728a8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ dependency is a registry package. 0.x source compatibility is a non-goal (see All entries below are **breaking**, grouped by area. +#### Package boundaries + +- Image formats now live in 17 independently publishable + `rawshift-image-*` leaf crates. `rawshift-image` remains a source-compatible + feature facade and owns processing, transforms, and camera data. +- `rawshift-image-core` provides the canonical image/error types and open + format capability traits; metadata, TIFF/IFD helpers, and lossless JPEG are + isolated in focused support crates. + #### Core types (`rawshift-core`) - Generic primitives are gamut re-exports; only the sensor domain stays diff --git a/Cargo.lock b/Cargo.lock index 485ea7d..f33e310 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1230,6 +1230,27 @@ dependencies = [ "gif", "rawshift-core", "rawshift-hwdec", + "rawshift-image-arw", + "rawshift-image-avif", + "rawshift-image-core", + "rawshift-image-cr2", + "rawshift-image-cr3", + "rawshift-image-crw", + "rawshift-image-dng", + "rawshift-image-gif", + "rawshift-image-heic", + "rawshift-image-ifd", + "rawshift-image-jpeg", + "rawshift-image-jxl", + "rawshift-image-ljpeg", + "rawshift-image-metadata", + "rawshift-image-nef", + "rawshift-image-png", + "rawshift-image-ppm", + "rawshift-image-raf", + "rawshift-image-svg", + "rawshift-image-tiff", + "rawshift-image-webp", "rayon", "resvg", "serde", @@ -1243,6 +1264,226 @@ dependencies = [ "zune-ppm", ] +[[package]] +name = "rawshift-image-arw" +version = "0.1.1" +dependencies = [ + "gamut-ifd", + "rawshift-image-core", + "rawshift-image-ifd", + "rawshift-image-ljpeg", + "tracing", +] + +[[package]] +name = "rawshift-image-avif" +version = "0.1.1" +dependencies = [ + "gamut-avif", + "gamut-color", + "gamut-core", + "gamut-isobmff", + "rawshift-hwdec", + "rawshift-image-core", + "rawshift-image-metadata", + "serde", +] + +[[package]] +name = "rawshift-image-core" +version = "0.1.1" +dependencies = [ + "gamut-core", + "rawshift-core", + "rawshift-hwdec", + "serde", + "thiserror", +] + +[[package]] +name = "rawshift-image-cr2" +version = "0.1.1" +dependencies = [ + "gamut-ifd", + "rawshift-image-core", + "rawshift-image-ifd", + "rawshift-image-ljpeg", +] + +[[package]] +name = "rawshift-image-cr3" +version = "0.1.1" +dependencies = [ + "gamut-ifd", + "rawshift-image-core", + "rawshift-image-ifd", + "tracing", +] + +[[package]] +name = "rawshift-image-crw" +version = "0.1.1" +dependencies = [ + "rawshift-image-core", +] + +[[package]] +name = "rawshift-image-dng" +version = "0.1.1" +dependencies = [ + "gamut-dng", + "gamut-ifd", + "rawshift-image-core", + "rawshift-image-ifd", + "serde", + "tracing", +] + +[[package]] +name = "rawshift-image-gif" +version = "0.1.1" +dependencies = [ + "gif", + "rawshift-image-core", + "serde", +] + +[[package]] +name = "rawshift-image-heic" +version = "0.1.1" +dependencies = [ + "gamut-color", + "gamut-core", + "gamut-heic", + "gamut-isobmff", + "rawshift-hwdec", + "rawshift-image-core", + "rawshift-image-metadata", + "serde", +] + +[[package]] +name = "rawshift-image-ifd" +version = "0.1.1" +dependencies = [ + "gamut-ifd", + "rawshift-core", + "rawshift-image-core", + "tracing", +] + +[[package]] +name = "rawshift-image-jpeg" +version = "0.1.1" +dependencies = [ + "gamut-core", + "gamut-jpeg", + "gamut-xmp", + "rawshift-image-core", + "rawshift-image-metadata", + "serde", +] + +[[package]] +name = "rawshift-image-jxl" +version = "0.1.1" +dependencies = [ + "gamut-core", + "gamut-jxl 0.4.0", + "rawshift-image-core", + "rawshift-image-metadata", + "serde", +] + +[[package]] +name = "rawshift-image-ljpeg" +version = "0.1.1" +dependencies = [ + "rawshift-image-core", +] + +[[package]] +name = "rawshift-image-metadata" +version = "0.1.1" +dependencies = [ + "gamut-exif", + "gamut-icc", + "gamut-ifd", + "gamut-metadata", + "gamut-xmp", + "rawshift-core", +] + +[[package]] +name = "rawshift-image-nef" +version = "0.1.1" +dependencies = [ + "gamut-ifd", + "rawshift-image-core", + "rawshift-image-ifd", + "rawshift-image-ljpeg", +] + +[[package]] +name = "rawshift-image-png" +version = "0.1.1" +dependencies = [ + "gamut-core", + "gamut-png", + "rawshift-image-core", + "rawshift-image-metadata", + "serde", +] + +[[package]] +name = "rawshift-image-ppm" +version = "0.1.1" +dependencies = [ + "rawshift-image-core", + "serde", + "zune-core 0.5.1", + "zune-ppm", +] + +[[package]] +name = "rawshift-image-raf" +version = "0.1.1" +dependencies = [ + "rawshift-image-core", + "tracing", +] + +[[package]] +name = "rawshift-image-svg" +version = "0.1.1" +dependencies = [ + "rawshift-image-core", + "resvg", + "serde", +] + +[[package]] +name = "rawshift-image-tiff" +version = "0.1.1" +dependencies = [ + "rawshift-image-core", + "serde", + "tiff", +] + +[[package]] +name = "rawshift-image-webp" +version = "0.1.1" +dependencies = [ + "gamut-core", + "gamut-metadata", + "gamut-webp", + "gamut-xmp", + "rawshift-image-core", + "rawshift-image-metadata", + "serde", +] + [[package]] name = "rawshift-video" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 5ce298c..dd400d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,27 @@ members = [ "crates/rawshift-core", "crates/rawshift-hwdec", "crates/rawshift-image", + "crates/rawshift-image-core", + "crates/rawshift-image-metadata", + "crates/rawshift-image-ifd", + "crates/rawshift-image-ljpeg", + "crates/rawshift-image-arw", + "crates/rawshift-image-avif", + "crates/rawshift-image-cr2", + "crates/rawshift-image-cr3", + "crates/rawshift-image-crw", + "crates/rawshift-image-dng", + "crates/rawshift-image-nef", + "crates/rawshift-image-raf", + "crates/rawshift-image-heic", + "crates/rawshift-image-gif", + "crates/rawshift-image-jpeg", + "crates/rawshift-image-jxl", + "crates/rawshift-image-png", + "crates/rawshift-image-ppm", + "crates/rawshift-image-svg", + "crates/rawshift-image-tiff", + "crates/rawshift-image-webp", "crates/rawshift-video", ] @@ -50,6 +71,27 @@ serde = { version = "1.0", features = ["derive"] } rawshift-core = { path = "crates/rawshift-core", version = "0.1.1" } rawshift-hwdec = { path = "crates/rawshift-hwdec", version = "0.1.1" } rawshift-image = { path = "crates/rawshift-image", version = "0.1.1" } +rawshift-image-core = { path = "crates/rawshift-image-core", version = "0.1.1" } +rawshift-image-metadata = { path = "crates/rawshift-image-metadata", version = "0.1.1" } +rawshift-image-ifd = { path = "crates/rawshift-image-ifd", version = "0.1.1" } +rawshift-image-ljpeg = { path = "crates/rawshift-image-ljpeg", version = "0.1.1" } +rawshift-image-arw = { path = "crates/rawshift-image-arw", version = "0.1.1", default-features = false } +rawshift-image-avif = { path = "crates/rawshift-image-avif", version = "0.1.1", default-features = false } +rawshift-image-cr2 = { path = "crates/rawshift-image-cr2", version = "0.1.1", default-features = false } +rawshift-image-cr3 = { path = "crates/rawshift-image-cr3", version = "0.1.1", default-features = false } +rawshift-image-crw = { path = "crates/rawshift-image-crw", version = "0.1.1", default-features = false } +rawshift-image-dng = { path = "crates/rawshift-image-dng", version = "0.1.1", default-features = false } +rawshift-image-nef = { path = "crates/rawshift-image-nef", version = "0.1.1", default-features = false } +rawshift-image-raf = { path = "crates/rawshift-image-raf", version = "0.1.1", default-features = false } +rawshift-image-heic = { path = "crates/rawshift-image-heic", version = "0.1.1", default-features = false } +rawshift-image-gif = { path = "crates/rawshift-image-gif", version = "0.1.1", default-features = false } +rawshift-image-jpeg = { path = "crates/rawshift-image-jpeg", version = "0.1.1", default-features = false } +rawshift-image-jxl = { path = "crates/rawshift-image-jxl", version = "0.1.1", default-features = false } +rawshift-image-png = { path = "crates/rawshift-image-png", version = "0.1.1", default-features = false } +rawshift-image-ppm = { path = "crates/rawshift-image-ppm", version = "0.1.1", default-features = false } +rawshift-image-svg = { path = "crates/rawshift-image-svg", version = "0.1.1", default-features = false } +rawshift-image-tiff = { path = "crates/rawshift-image-tiff", version = "0.1.1", default-features = false } +rawshift-image-webp = { path = "crates/rawshift-image-webp", version = "0.1.1", default-features = false } rawshift-video = { path = "crates/rawshift-video", version = "0.1.1" } # Profiles must live at the workspace root — Cargo ignores profiles declared in diff --git a/PRINCIPLES.md b/PRINCIPLES.md index 15527ed..0a55769 100644 --- a/PRINCIPLES.md +++ b/PRINCIPLES.md @@ -7,12 +7,12 @@ ## Safety Boundaries - `crates/rawshift-core`: `#![forbid(unsafe_code)]`. -- `crates/rawshift-image/src/formats`: Safe Rust is strictly required. +- `crates/rawshift-image-*`: Safe Rust is strictly required. - `crates/rawshift-image/src/data`: Safe Rust is strictly required. - `crates/rawshift-image/src/metadata`: Safe Rust is strictly required. - `crates/rawshift-image/src/processing`: Unsafe Rust is acceptable as long as it is constrained to hot paths. - `crates/rawshift-image/src/transforms`: Unsafe Rust is acceptable as long as it is constrained to hot paths. -- `crates/rawshift-hwdec`: Unsafe FFI is permitted — `#![deny(unsafe_op_in_unsafe_fn)]`, every public item is a safe wrapper, every unsafe block documents its invariants. No platform unsafe lives anywhere else. +- `crates/rawshift-hwdec`: Unsafe platform FFI is permitted — `#![deny(unsafe_op_in_unsafe_fn)]`, every public item is a safe wrapper, every unsafe block documents its invariants. - `**/**`: TBD ## Testing Strategy diff --git a/README.md b/README.md index aaa4aaf..8595071 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ rawshift is a Cargo workspace: | ---------------- | ---------------------------------------------------------------------------------------------------------------- | | [`rawshift`](./crates/rawshift) | Facade. Re-exports `rawshift-image` behind the coarse `image` feature. Most consumers depend on this. | | [`rawshift-image`](./crates/rawshift-image/README.md) | Still-image decoding, RAW processing, and encoding. Carries the full per-format feature system. | +| `rawshift-image-*` format crates | Independently usable format leaves (`jpeg`, `png`, `dng`, `arw`, and others) behind shared `rawshift-image-core` contracts. | +| `rawshift-image-{metadata,ifd,ljpeg}` | Focused support crates shared by format leaves; applications normally use them transitively. | | [`rawshift-video`](./crates/rawshift-video/README.md) | Video support — parked and unpublished for v1 (see [Video](#video)). | | [`rawshift-core`](./crates/rawshift-core/README.md) | Shared types — geometry, codec descriptors, the metadata model. Charter is documented on the crate. | diff --git a/crates/rawshift-image-arw/Cargo.toml b/crates/rawshift-image-arw/Cargo.toml new file mode 100644 index 0000000..060832b --- /dev/null +++ b/crates/rawshift-image-arw/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "rawshift-image-arw" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Sony ARW decoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true } +rawshift-image-ifd = { workspace = true, optional = true } +rawshift-image-ljpeg = { workspace = true, optional = true } +gamut-ifd = { workspace = true, optional = true, features = ["bigtiff"] } +tracing = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:rawshift-image-ifd", "dep:rawshift-image-ljpeg", "dep:gamut-ifd", "dep:tracing"] diff --git a/crates/rawshift-image/src/formats/arw.rs b/crates/rawshift-image-arw/src/decoder.rs similarity index 97% rename from crates/rawshift-image/src/formats/arw.rs rename to crates/rawshift-image-arw/src/decoder.rs index d5e1896..b8f425c 100644 --- a/crates/rawshift-image/src/formats/arw.rs +++ b/crates/rawshift-image-arw/src/decoder.rs @@ -4,16 +4,19 @@ //! which are based on the TIFF container format with Sony-specific extensions. //! //! IFD structure walking is backed by [`gamut_ifd`]; Sony tag semantics stay -//! here (see [`super::ifd::tags`]). +//! here (see [`rawshift_image_ifd::tags`]). use std::io::{Read, Seek}; use std::marker::PhantomData; use gamut_ifd::{ByteOrder, Ifd, IfdReader, Value, Variant}; -use super::ifd::{self, tags}; -use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth}; -use crate::error::{FormatError, ParseError, RawError, RawResult}; +use rawshift_image_core::image::{ + CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth, +}; +use rawshift_image_core::{FormatError, ParseError, RawError, RawResult}; +use rawshift_image_ifd as ifd; +use rawshift_image_ifd::tags; /// Metadata extracted from a Sony ARW file. #[derive(Debug, Clone)] @@ -51,11 +54,11 @@ pub struct ArwMetadata { /// As Shot Neutral (converted from WB multipliers if found) pub as_shot_neutral: Option<[f64; 3]>, /// EXIF exposure/capture settings - pub exif: crate::core::metadata::ExifInfo, + pub exif: rawshift_image_core::metadata::ExifInfo, /// Date/time information - pub datetime: crate::core::metadata::DateTimeInfo, + pub datetime: rawshift_image_core::metadata::DateTimeInfo, /// GPS location data - pub gps: crate::core::metadata::GpsInfo, + pub gps: rawshift_image_core::metadata::GpsInfo, /// Lens make pub lens_make: Option, /// Lens model @@ -614,7 +617,7 @@ impl ArwFile { // 7 = JPEG (Lossless for Sony ARW) if metadata.compression == 7 { - use crate::codecs::ljpeg::LjpegDecoder; + use rawshift_image_ljpeg::LjpegDecoder; let width = metadata.sensor_size.width as usize; let height = metadata.sensor_size.height as usize; @@ -725,9 +728,9 @@ impl ArwFile { } } -impl crate::core::ExtractMetadata for ArwFile { - fn extract_metadata(&self) -> crate::core::ImageMetadata { - use crate::core::metadata::*; +impl rawshift_image_core::ExtractMetadata for ArwFile { + fn extract_metadata(&self) -> rawshift_image_core::ImageMetadata { + use rawshift_image_core::metadata::*; let m = self.metadata.as_ref(); let as_shot_neutral = m.and_then(|x| x.as_shot_neutral); diff --git a/crates/rawshift-image-arw/src/lib.rs b/crates/rawshift-image-arw/src/lib.rs new file mode 100644 index 0000000..b238c91 --- /dev/null +++ b/crates/rawshift-image-arw/src/lib.rs @@ -0,0 +1,37 @@ +//! Sony ARW format support. +#![forbid(unsafe_code)] + +#[cfg(feature = "decode")] +mod decoder; +#[cfg(feature = "decode")] +pub use decoder::*; + +/// Sony ARW format marker. +pub struct Arw; + +#[cfg(feature = "decode")] +impl rawshift_image_core::FormatSniffer for Arw { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Arw; + fn matches(data: &[u8]) -> bool { + tiff_make(data).is_some_and(|make| make.to_ascii_lowercase().contains("sony")) + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Arw { + type Options = (); + type Output = rawshift_image_core::RawImage; + fn decode(data: &[u8], _: &()) -> rawshift_image_core::RawResult { + ArwFile::parse(std::io::Cursor::new(data))?.decode_raw() + } +} + +#[cfg(feature = "decode")] +fn tiff_make(data: &[u8]) -> Option { + use gamut_ifd::{IfdReader, StreamSource}; + let mut cursor = std::io::Cursor::new(data); + let mut reader = IfdReader::open(StreamSource::new(&mut cursor)).ok()?; + let ifd = reader.read_ifd(reader.first_ifd_offset()).ok()?; + let entry = ifd.entry(rawshift_image_ifd::tags::MAKE)?; + reader.value(entry).ok()?.as_str().map(str::to_owned) +} diff --git a/crates/rawshift-image-avif/Cargo.toml b/crates/rawshift-image-avif/Cargo.toml new file mode 100644 index 0000000..7c6098f --- /dev/null +++ b/crates/rawshift-image-avif/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "rawshift-image-avif" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "AVIF decoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true } +rawshift-image-metadata = { workspace = true, features = ["exif"], optional = true } +gamut-avif = { workspace = true } +gamut-core = { workspace = true } +gamut-isobmff = { workspace = true } +rawshift-hwdec = { workspace = true, optional = true } +serde = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:rawshift-image-metadata"] +encode = ["dep:rawshift-image-metadata", "rawshift-image-metadata/avif-encode"] +serde = ["dep:serde", "rawshift-image-core/serde"] +hw = ["dep:rawshift-hwdec", "rawshift-hwdec/hw", "rawshift-image-core/hw-planes"] +hw-videotoolbox = ["hw", "rawshift-hwdec/videotoolbox"] +hw-vaapi = ["hw", "rawshift-hwdec/vaapi"] +hw-mediacodec = ["hw", "rawshift-hwdec/mediacodec"] + +[dev-dependencies] +gamut-isobmff = { workspace = true } +gamut-color = { workspace = true } diff --git a/crates/rawshift-image/src/formats/avif.rs b/crates/rawshift-image-avif/src/decoder.rs similarity index 98% rename from crates/rawshift-image/src/formats/avif.rs rename to crates/rawshift-image-avif/src/decoder.rs index 62514fe..bf38981 100644 --- a/crates/rawshift-image/src/formats/avif.rs +++ b/crates/rawshift-image-avif/src/decoder.rs @@ -33,10 +33,10 @@ use gamut_avif::{AvifContainer, AvifImage, AvifItem}; use gamut_isobmff::ColourInformation; -use crate::core::RgbImage; -use crate::core::metadata::{ImageMetadata, MetadataKey, MetadataNamespace, MetadataValue}; -use crate::error::{FormatError, RawError, RawResult}; -use crate::metadata::exif::ExifParser; +use rawshift_image_core::RgbImage; +use rawshift_image_core::metadata::{ImageMetadata, MetadataKey, MetadataNamespace, MetadataValue}; +use rawshift_image_core::{FormatError, RawError, RawResult}; +use rawshift_image_metadata::exif::ExifParser; /// The codec name reported in [`RawError::HwDecoderUnavailable`]. const AV1: &str = "AV1"; @@ -189,7 +189,7 @@ impl AvifFile { }; let mut adapter = hw::HwAv1Adapter::new(decoder); match self.image.decode_item_rgba8(id, &mut adapter) { - Ok(rgba) => super::hw_planes::rgba8_to_rgb_image(&rgba), + Ok(rgba) => rawshift_image_core::hw_planes::rgba8_to_rgb_image(&rgba), Err(source) => Err(adapter.into_raw_error(source)), } } @@ -227,8 +227,8 @@ pub fn avif_hw_decode_available() -> bool { /// /// Reads the embedded EXIF, XMP, and ICC profile and maps them onto /// [`ImageMetadata`]. Returns a default (empty) value when the file carries no -/// metadata or cannot be parsed. Used by both [`AvifFile::metadata`] and -/// [`read_standard_image_metadata`](crate::formats::read_standard_image_metadata). +/// metadata or cannot be parsed. Used by both [`AvifFile::metadata`] and the +/// `rawshift-image` facade's standard metadata reader. pub fn read_avif_metadata(data: &[u8]) -> ImageMetadata { match AvifContainer::parse(data) { Ok(container) => metadata_from_image(container.image()), @@ -408,8 +408,10 @@ mod hw { PixelFormat, StillDecodeRequest, }; - use super::super::hw_planes::{deinterleave_8, deinterleave_16, read_plane_8, read_plane_16}; - use crate::error::{FormatError, RawError}; + use rawshift_image_core::hw_planes::{ + deinterleave_8, deinterleave_16, read_plane_8, read_plane_16, + }; + use rawshift_image_core::{FormatError, RawError}; /// An [`Av1StillDecoder`] over a hardware [`HwStillDecoder`]. /// diff --git a/crates/rawshift-image-avif/src/lib.rs b/crates/rawshift-image-avif/src/lib.rs new file mode 100644 index 0000000..dff3220 --- /dev/null +++ b/crates/rawshift-image-avif/src/lib.rs @@ -0,0 +1,75 @@ +//! AVIF format support. +#![forbid(unsafe_code)] + +#[cfg(feature = "decode")] +mod decoder; +#[cfg(feature = "decode")] +pub use decoder::*; + +/// AVIF format marker. +pub struct Avif; + +impl rawshift_image_core::FormatSniffer for Avif { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Avif; + fn matches(data: &[u8]) -> bool { + data.len() >= 12 + && &data[4..8] == b"ftyp" + && matches!(&data[8..12], b"avif" | b"avis" | b"mif1") + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Avif { + type Options = (); + type Output = rawshift_image_core::RgbImage; + fn decode(data: &[u8], _: &()) -> rawshift_image_core::RawResult { + AvifFile::open(data.to_vec())?.decode_primary() + } +} + +#[cfg(feature = "encode")] +impl rawshift_image_core::ImageEncoder for Avif { + type Options = (); + type Input = rawshift_image_core::RgbImage; + fn encode_to_writer( + input: &Self::Input, + metadata: &rawshift_image_core::ImageMetadata, + _: &Self::Options, + mut writer: W, + ) -> rawshift_image_core::RawResult<()> { + use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; + use rawshift_image_metadata::{ + exif::ExifBuilder, icc::IccProfile, xmp::append_xmp_to_avif, + }; + let error = |error: gamut_core::Error| { + rawshift_image_core::RawError::Encode(rawshift_image_core::EncodeError::Encoding { + format: "AVIF", + message: error.to_string(), + }) + }; + let dimensions = Dimensions::new(input.width(), input.height()).map_err(error)?; + let samples: Vec = input + .data() + .iter() + .map(|value| (value >> 8) as u8) + .collect(); + let image = ImageRef::::new(&samples, dimensions).map_err(error)?; + let mut output = Vec::new(); + gamut_avif::AvifEncoder::lossless() + .encode_image(image, &mut output) + .map_err(error)?; + if let Ok(with_icc) = IccProfile::srgb().append_to_avif(output.clone()) { + output = with_icc; + } + if let Ok(with_exif) = ExifBuilder::new(metadata).append_to_avif(output.clone()) { + output = with_exif; + } + if let Some(xmp) = metadata.xmp.as_deref() + && let Ok(with_xmp) = append_xmp_to_avif(xmp, output.clone()) + { + output = with_xmp; + } + writer.write_all(&output)?; + Ok(()) + } +} diff --git a/crates/rawshift-image-core/Cargo.toml b/crates/rawshift-image-core/Cargo.toml new file mode 100644 index 0000000..de76537 --- /dev/null +++ b/crates/rawshift-image-core/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "rawshift-image-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Shared contracts and image types for rawshift format crates" +documentation = "https://docs.rs/rawshift-image-core" +keywords = ["raw", "image", "codec", "metadata"] +categories = ["multimedia::images"] + +[dependencies] +rawshift-core = { workspace = true } +gamut-core = { workspace = true } +thiserror = { workspace = true } +serde = { workspace = true, optional = true } +rawshift-hwdec = { workspace = true, optional = true } + +[features] +serde = ["dep:serde", "rawshift-core/serde"] +cr2-decode = [] +cr3-decode = [] +crw-decode = [] +nef-decode = [] +raf-decode = [] +hw-planes = ["dep:rawshift-hwdec"] diff --git a/crates/rawshift-image-core/src/error.rs b/crates/rawshift-image-core/src/error.rs new file mode 100644 index 0000000..e6996b2 --- /dev/null +++ b/crates/rawshift-image-core/src/error.rs @@ -0,0 +1,287 @@ +//! Error types for RAW image processing. +//! +//! This module defines comprehensive error types for TIFF parsing, +//! format-specific errors, and I/O errors. +//! +//! Errors are organized into categories: +//! - [`ParseError`] — TIFF/binary parse issues +//! - [`FormatError`] — Format-specific decode failures +//! - [`ProcessingError`] — Demosaic, color, tonemap +//! - [`EncodeError`] — Output encoding +//! - [`RawError::Unsupported`] — Feature not implemented + +use std::io; +use thiserror::Error; + +use crate::BitDepth; + +/// Main error type for the rawshift library. +#[derive(Debug, Error)] +pub enum RawError { + /// I/O error during file operations. + #[error("I/O error: {0}")] + Io(#[from] io::Error), + + /// TIFF/binary parse error. + #[error(transparent)] + Parse(#[from] ParseError), + + /// Format-specific decode error. + #[error(transparent)] + Format(#[from] FormatError), + + /// Processing pipeline error. + #[error(transparent)] + Processing(#[from] ProcessingError), + + /// Output encoding error. + #[error(transparent)] + Encode(#[from] EncodeError), + + /// Feature not yet implemented. + #[error("Unsupported: {0}")] + Unsupported(String), + + /// Error surfaced by a gamut primitive (buffer/dimension validation, + /// codec-independent invariants). + /// + /// `context` names the rawshift operation that invoked gamut, since the + /// upstream error alone rarely identifies the call site (structured + /// diagnostic context upstream is visualcommons/gamut#254). + #[error("{context}: {source}")] + Gamut { + /// The rawshift operation that invoked gamut. + context: &'static str, + /// The underlying gamut error. + #[source] + source: gamut_core::Error, + }, + + /// Pixel decode was requested for a hardware-decoded codec (HEVC/HEIC, + /// AV1/AVIF) but no hardware decoder is available — none compiled in + /// (build without `hw`/`hw-*`), the target has no hardware decode API + /// (see `docs/SUPPORT.md`), or the runtime probe failed. + /// + /// Container parsing, metadata, and auxiliary-image enumeration always + /// work regardless; only pixel decode fails with this error. Probe + /// availability up front with `formats::heic_hw_decode_available()` + /// (requires the `heic` feature). + #[error("no hardware decoder available for {codec}: {reason}")] + HwDecoderUnavailable { + /// The codec that needed a hardware decoder (e.g. `"HEVC"`). + codec: &'static str, + /// Why no decoder is available. + reason: String, + }, +} + +impl RawError { + /// Wrap a gamut error with the rawshift operation it occurred in. + pub fn gamut(context: &'static str, source: gamut_core::Error) -> Self { + RawError::Gamut { context, source } + } +} + +/// TIFF and binary parse errors. +#[derive(Debug, Error)] +pub enum ParseError { + /// Invalid TIFF magic number. + #[error("Invalid TIFF magic number: expected {expected}, found {found}")] + InvalidMagic { + /// Expected magic number + expected: u16, + /// Actual magic number found + found: u16, + }, + + /// Invalid byte order marker. + #[error("Invalid byte order marker: 0x{0:04X} (expected 'II' or 'MM')")] + InvalidByteOrder(u16), + + /// Invalid or malformed IFD. + #[error("Invalid IFD at offset {offset}: {reason}")] + InvalidIfd { + /// Offset where the IFD was expected + offset: u64, + /// Description of what's wrong + reason: String, + }, + + /// Required tag not found, identified by its raw 16-bit id. + /// + /// Used by the gamut-ifd-based decoders, which address tags numerically. + #[error("Required tag not found: 0x{0:04X}")] + MissingTag(u16), + + /// Offset exceeds file boundaries. + #[error("Offset out of bounds: offset {offset} + size {size} exceeds file size {file_size}")] + OffsetOutOfBounds { + /// The offset that's out of bounds + offset: u64, + /// Size of data being accessed + size: u64, + /// Total file size + file_size: u64, + }, + + /// Unknown TIFF data type. + #[error("Unknown TIFF data type: {0}")] + UnknownDataType(u16), + + /// Invalid image dimensions. + #[error("Invalid image dimensions: {width}x{height}")] + InvalidDimensions { + /// Image width + width: u32, + /// Image height + height: u32, + }, + + /// Circular reference detected in IFD chain. + #[error("Circular reference detected in IFD chain at offset {0}")] + CircularReference(u64), + + /// Binary parse error (from format-specific binary parsers). + #[error("Binary parse error: {0}")] + BinaryParse(String), +} + +/// Format-specific decode errors. +#[derive(Debug, Error)] +pub enum FormatError { + /// Canon CR2 format error. + #[cfg(feature = "cr2-decode")] + #[error("CR2 error: {0}")] + Cr2(String), + + /// Nikon NEF format error. + #[cfg(feature = "nef-decode")] + #[error("NEF error: {0}")] + Nef(String), + + /// Canon CR3/ISOBMFF format error. + #[cfg(feature = "cr3-decode")] + #[error("CR3 error: {0}")] + Cr3(String), + + /// Fujifilm RAF format error. + #[cfg(feature = "raf-decode")] + #[error("RAF error: {0}")] + Raf(String), + + /// Canon CRW/CIFF format error. + #[cfg(feature = "crw-decode")] + #[error("CRW error: {0}")] + Crw(String), + + /// Standard image format decoding error. + #[error("Image decode error ({format}): {message}")] + ImageDecode { + /// Format name (e.g., "JPEG", "PNG") + format: &'static str, + /// Error description + message: String, + }, + + /// Decompression error. + #[error("Decompression error: {0}")] + Decompression(String), +} + +/// Processing pipeline errors. +#[derive(Debug, Error)] +pub enum ProcessingError { + /// Demosaicing error. + #[error("Demosaic error: {0}")] + Demosaic(String), + + /// Color processing error. + #[error("Color processing error: {0}")] + Color(String), +} + +/// Output encoding errors. +/// +/// `#[non_exhaustive]`: new encoder backends may introduce new error variants +/// without that being a breaking change. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum EncodeError { + /// Generic encoding/export error. + #[error("Encoding error ({format}): {message}")] + Encoding { + /// Format name (e.g., "JPEG", "PNG") + format: &'static str, + /// Error description + message: String, + }, + + /// The selected encoder does not support the requested output bit depth. + #[error("{format} encoder does not support {requested:?} output")] + UnsupportedBitDepth { + /// Format name (e.g., "JPEG", "AVIF") + format: &'static str, + /// The bit depth that was requested but is not supported. + requested: BitDepth, + }, +} + +/// Result type alias using RawError. +pub type RawResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_display() { + let err = RawError::Parse(ParseError::InvalidMagic { + expected: 42, + found: 0, + }); + let s = format!("{}", err); + assert!(s.contains("Invalid TIFF magic")); + + let err = RawError::Parse(ParseError::MissingTag(0x0100)); + let s = format!("{}", err); + assert!(s.contains("0x0100")); + } + + #[test] + fn test_io_error_conversion() { + let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found"); + let raw_err: RawError = io_err.into(); + assert!(matches!(raw_err, RawError::Io(_))); + } + + #[test] + fn test_parse_error_conversion() { + let parse_err = ParseError::InvalidByteOrder(0x1234); + let raw_err: RawError = parse_err.into(); + assert!(matches!( + raw_err, + RawError::Parse(ParseError::InvalidByteOrder(0x1234)) + )); + } + + #[cfg(feature = "cr2-decode")] + #[test] + fn test_format_error_conversion() { + let fmt_err = FormatError::Cr2("test error".to_string()); + let raw_err: RawError = fmt_err.into(); + assert!(matches!(raw_err, RawError::Format(FormatError::Cr2(_)))); + } + + #[test] + fn test_encode_error_conversion() { + let enc_err = EncodeError::Encoding { + format: "PNG", + message: "test".to_string(), + }; + let raw_err: RawError = enc_err.into(); + assert!(matches!( + raw_err, + RawError::Encode(EncodeError::Encoding { .. }) + )); + } +} diff --git a/crates/rawshift-image/src/formats/hw_planes.rs b/crates/rawshift-image-core/src/hw_planes.rs similarity index 91% rename from crates/rawshift-image/src/formats/hw_planes.rs rename to crates/rawshift-image-core/src/hw_planes.rs index c1a37e8..40affc1 100644 --- a/crates/rawshift-image/src/formats/hw_planes.rs +++ b/crates/rawshift-image-core/src/hw_planes.rs @@ -14,11 +14,11 @@ use rawshift_hwdec::Plane; -use crate::core::RgbImage; -use crate::error::RawResult; +use crate::RawResult; +use crate::RgbImage; /// Copy an 8-bit plane into `u16` samples, honouring the row stride. -pub(crate) fn read_plane_8(plane: &Plane, width: usize, rows: usize) -> Vec { +pub fn read_plane_8(plane: &Plane, width: usize, rows: usize) -> Vec { let mut out = Vec::with_capacity(width * rows); for row in 0..rows { let start = row * plane.stride; @@ -32,7 +32,7 @@ pub(crate) fn read_plane_8(plane: &Plane, width: usize, rows: usize) -> Vec } /// Split an 8-bit interleaved CbCr plane into separate Cb/Cr samples. -pub(crate) fn deinterleave_8(plane: &Plane, width: usize, rows: usize) -> (Vec, Vec) { +pub fn deinterleave_8(plane: &Plane, width: usize, rows: usize) -> (Vec, Vec) { let mut cb = Vec::with_capacity(width * rows); let mut cr = Vec::with_capacity(width * rows); for row in 0..rows { @@ -48,7 +48,7 @@ pub(crate) fn deinterleave_8(plane: &Plane, width: usize, rows: usize) -> (Vec u16 { /// Convert a gamut presentation output (8-bit RGBA, transforms applied) to /// rawshift's 16-bit [`RgbImage`]: alpha dropped, samples scaled by `*257` /// (exact at both endpoints). -pub(crate) fn rgba8_to_rgb_image( - rgba: &gamut_core::ImageBuf, -) -> RawResult { +pub fn rgba8_to_rgb_image(rgba: &gamut_core::ImageBuf) -> RawResult { let (width, height) = (rgba.width(), rgba.height()); let samples = rgba.as_samples(); let mut rgb = Vec::with_capacity(width as usize * height as usize * 3); diff --git a/crates/rawshift-image-core/src/lib.rs b/crates/rawshift-image-core/src/lib.rs new file mode 100644 index 0000000..becadbb --- /dev/null +++ b/crates/rawshift-image-core/src/lib.rs @@ -0,0 +1,122 @@ +//! Shared still-image contracts for rawshift's per-format crates. +#![forbid(unsafe_code)] + +pub mod error; +#[cfg(feature = "hw-planes")] +#[doc(hidden)] +pub mod hw_planes; +mod rgb_image; + +use std::io::Write; + +pub use error::{EncodeError, FormatError, ParseError, ProcessingError, RawError, RawResult}; +pub use rawshift_core::*; +pub use rgb_image::RgbImage; + +/// Stable identity for every image format known to rawshift. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum FormatId { + Gif, + Jpeg, + Png, + WebP, + Jxl, + Tiff, + Avif, + Heic, + Svg, + Apv, + Ppm, + Arw, + Cr2, + Cr3, + Crw, + Dng, + Nef, + Raf, +} + +/// Header-level facts available without a full image decode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageProbe { + pub format: FormatId, + pub dimensions: Dimensions, + pub bit_depth: Option, +} + +/// Compile-time capabilities for one known format. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FormatCapabilities { + pub format: FormatId, + pub sniff: bool, + pub probe: bool, + pub metadata: bool, + pub decode: bool, + pub encode: bool, + pub decoder: Option, + pub encoder: Option, +} + +impl FormatCapabilities { + pub fn unavailable(format: FormatId) -> Self { + Self { + format, + sniff: false, + probe: false, + metadata: false, + decode: false, + encode: false, + decoder: None, + encoder: None, + } + } +} + +/// Cheap signature detection for one encoded format. +pub trait FormatSniffer { + const FORMAT: FormatId; + fn matches(data: &[u8]) -> bool; +} + +/// Header probing for one encoded format. +pub trait FormatProber: FormatSniffer { + fn probe(data: &[u8]) -> RawResult; +} + +/// Embedded metadata extraction for one encoded format. +pub trait MetadataReader: FormatSniffer { + fn read_metadata(data: &[u8]) -> RawResult; +} + +/// Pixel or sensor decode for one encoded format. +pub trait ImageDecoder: FormatSniffer { + type Options: Default; + type Output; + + fn decode(data: &[u8], options: &Self::Options) -> RawResult; +} + +/// Encoding contract shared by all encoding-capable format crates. +pub trait ImageEncoder: FormatSniffer { + type Options: Default; + type Input: ?Sized; + + fn encode_to_writer( + input: &Self::Input, + metadata: &ImageMetadata, + options: &Self::Options, + writer: W, + ) -> RawResult<()>; + + fn encode_to_vec( + input: &Self::Input, + metadata: &ImageMetadata, + options: &Self::Options, + ) -> RawResult> { + let mut output = Vec::new(); + Self::encode_to_writer(input, metadata, options, &mut output)?; + Ok(output) + } +} diff --git a/crates/rawshift-image/src/core/rgb_image.rs b/crates/rawshift-image-core/src/rgb_image.rs similarity index 99% rename from crates/rawshift-image/src/core/rgb_image.rs rename to crates/rawshift-image-core/src/rgb_image.rs index 529bce5..17fcff1 100644 --- a/crates/rawshift-image/src/core/rgb_image.rs +++ b/crates/rawshift-image-core/src/rgb_image.rs @@ -9,8 +9,8 @@ use rawshift_core::{ColorDescription, Dimensions, ImageBuf, Rgb16}; -use crate::core::Rect; -use crate::error::{RawError, RawResult}; +use crate::{RawError, RawResult}; +use rawshift_core::Rect; /// A container for interleaved 16-bit RGB image data (R, G, B, R, G, B, …). /// diff --git a/crates/rawshift-image-cr2/Cargo.toml b/crates/rawshift-image-cr2/Cargo.toml new file mode 100644 index 0000000..16018f1 --- /dev/null +++ b/crates/rawshift-image-cr2/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rawshift-image-cr2" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Canon CR2 decoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true, features = ["cr2-decode"] } +rawshift-image-ifd = { workspace = true, optional = true } +rawshift-image-ljpeg = { workspace = true, optional = true } +gamut-ifd = { workspace = true, optional = true, features = ["bigtiff"] } + +[features] +default = ["decode"] +decode = ["dep:rawshift-image-ifd", "dep:rawshift-image-ljpeg", "dep:gamut-ifd"] diff --git a/crates/rawshift-image/src/formats/cr2.rs b/crates/rawshift-image-cr2/src/decoder.rs similarity index 97% rename from crates/rawshift-image/src/formats/cr2.rs rename to crates/rawshift-image-cr2/src/decoder.rs index df912f7..846a483 100644 --- a/crates/rawshift-image/src/formats/cr2.rs +++ b/crates/rawshift-image-cr2/src/decoder.rs @@ -23,9 +23,12 @@ use std::marker::PhantomData; use gamut_ifd::{Ifd, Value}; -use super::ifd::{self, tags}; -use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth}; -use crate::error::{FormatError, ParseError, RawError, RawResult}; +use rawshift_image_core::image::{ + CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth, +}; +use rawshift_image_core::{FormatError, ParseError, RawError, RawResult}; +use rawshift_image_ifd as ifd; +use rawshift_image_ifd::tags; /// Magic marker bytes: byte offset 8-10 in a CR2 file. /// Bytes 8-9 = "CR", byte 10 = 0x02 (CR2 version). @@ -294,7 +297,7 @@ impl Cr2File { )?; // Decode with LJPEG decoder - use crate::codecs::ljpeg::LjpegDecoder; + use rawshift_image_ljpeg::LjpegDecoder; let mut decoder = LjpegDecoder::new(); decoder.set_dimensions(metadata.sensor_size.width, metadata.sensor_size.height); @@ -350,9 +353,9 @@ pub fn is_cr2(data: &[u8]) -> bool { && data[CR2_MAGIC_OFFSET + 2] == CR2_MAGIC[2] } -impl crate::core::ExtractMetadata for Cr2File { - fn extract_metadata(&self) -> crate::core::ImageMetadata { - use crate::core::metadata::*; +impl rawshift_image_core::ExtractMetadata for Cr2File { + fn extract_metadata(&self) -> rawshift_image_core::ImageMetadata { + use rawshift_image_core::metadata::*; let m = self.metadata.as_ref(); diff --git a/crates/rawshift-image-cr2/src/lib.rs b/crates/rawshift-image-cr2/src/lib.rs new file mode 100644 index 0000000..cf7dac6 --- /dev/null +++ b/crates/rawshift-image-cr2/src/lib.rs @@ -0,0 +1,27 @@ +//! Canon CR2 format support. +#![forbid(unsafe_code)] + +#[cfg(feature = "decode")] +mod decoder; +#[cfg(feature = "decode")] +pub use decoder::*; + +/// Canon CR2 format marker. +pub struct Cr2; + +#[cfg(feature = "decode")] +impl rawshift_image_core::FormatSniffer for Cr2 { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Cr2; + fn matches(data: &[u8]) -> bool { + is_cr2(data) + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Cr2 { + type Options = (); + type Output = rawshift_image_core::RawImage; + fn decode(data: &[u8], _: &()) -> rawshift_image_core::RawResult { + Cr2File::parse(std::io::Cursor::new(data))?.decode_raw() + } +} diff --git a/crates/rawshift-image-cr3/Cargo.toml b/crates/rawshift-image-cr3/Cargo.toml new file mode 100644 index 0000000..da71fd9 --- /dev/null +++ b/crates/rawshift-image-cr3/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rawshift-image-cr3" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Canon CR3 decoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true, features = ["cr3-decode"] } +rawshift-image-ifd = { workspace = true, optional = true } +gamut-ifd = { workspace = true, optional = true, features = ["bigtiff"] } +tracing = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:rawshift-image-ifd", "dep:gamut-ifd", "dep:tracing"] diff --git a/crates/rawshift-image/src/formats/cr3.rs b/crates/rawshift-image-cr3/src/decoder.rs similarity index 98% rename from crates/rawshift-image/src/formats/cr3.rs rename to crates/rawshift-image-cr3/src/decoder.rs index 18d6ccf..28f0962 100644 --- a/crates/rawshift-image/src/formats/cr3.rs +++ b/crates/rawshift-image-cr3/src/decoder.rs @@ -24,9 +24,12 @@ use std::io::{Read, Seek, SeekFrom}; use gamut_ifd::{IfdReader, Value}; use tracing::instrument; -use super::ifd::{self, tags}; -use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth}; -use crate::error::{FormatError, RawError, RawResult}; +use rawshift_image_core::image::{ + CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth, +}; +use rawshift_image_core::{FormatError, RawError, RawResult}; +use rawshift_image_ifd as ifd; +use rawshift_image_ifd::tags; // ── Canon UUID ──────────────────────────────────────────────────────────────── @@ -631,9 +634,9 @@ impl Cr3File { // ── ExtractMetadata trait ───────────────────────────────────────────────────── -impl crate::core::ExtractMetadata for Cr3File { - fn extract_metadata(&self) -> crate::core::ImageMetadata { - use crate::core::metadata::*; +impl rawshift_image_core::ExtractMetadata for Cr3File { + fn extract_metadata(&self) -> rawshift_image_core::ImageMetadata { + use rawshift_image_core::metadata::*; let m = self.metadata.as_ref(); diff --git a/crates/rawshift-image-cr3/src/lib.rs b/crates/rawshift-image-cr3/src/lib.rs new file mode 100644 index 0000000..ce21c6c --- /dev/null +++ b/crates/rawshift-image-cr3/src/lib.rs @@ -0,0 +1,27 @@ +//! Canon CR3 format support. +#![forbid(unsafe_code)] + +#[cfg(feature = "decode")] +mod decoder; +#[cfg(feature = "decode")] +pub use decoder::*; + +/// Canon CR3 format marker. +pub struct Cr3; + +#[cfg(feature = "decode")] +impl rawshift_image_core::FormatSniffer for Cr3 { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Cr3; + fn matches(data: &[u8]) -> bool { + is_cr3(data) + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Cr3 { + type Options = (); + type Output = rawshift_image_core::RawImage; + fn decode(data: &[u8], _: &()) -> rawshift_image_core::RawResult { + Cr3File::parse(std::io::Cursor::new(data))?.decode_raw() + } +} diff --git a/crates/rawshift-image-crw/Cargo.toml b/crates/rawshift-image-crw/Cargo.toml new file mode 100644 index 0000000..5bc809f --- /dev/null +++ b/crates/rawshift-image-crw/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "rawshift-image-crw" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Canon CRW decoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true, features = ["crw-decode"] } + +[features] +default = ["decode"] +decode = [] diff --git a/crates/rawshift-image/src/formats/crw.rs b/crates/rawshift-image-crw/src/decoder.rs similarity index 97% rename from crates/rawshift-image/src/formats/crw.rs rename to crates/rawshift-image-crw/src/decoder.rs index 03d21fb..0bc3764 100644 --- a/crates/rawshift-image/src/formats/crw.rs +++ b/crates/rawshift-image-crw/src/decoder.rs @@ -18,8 +18,10 @@ use std::io::{Read, Seek}; -use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth}; -use crate::error::{FormatError, RawError, RawResult}; +use rawshift_image_core::image::{ + CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth, +}; +use rawshift_image_core::{FormatError, RawError, RawResult}; // ── CIFF signature ──────────────────────────────────────────────────────────── @@ -171,8 +173,7 @@ impl CrwFile { /// /// Full CRW/CIFF pixel decoding requires a complete CIFF heap parser and a /// Canon-specific RAW decompressor, which are not yet implemented. This - /// method returns [`RawError::UnsupportedOperation`] until that work is - /// done. + /// method returns [`RawError::Unsupported`] until that work is done. pub fn decode_raw(&mut self) -> RawResult { Err(RawError::Unsupported( "CRW pixel decode is not yet implemented; \ @@ -217,9 +218,9 @@ pub fn is_crw(data: &[u8]) -> bool { // ── ExtractMetadata impl ────────────────────────────────────────────────────── -impl crate::core::ExtractMetadata for CrwFile { - fn extract_metadata(&self) -> crate::core::ImageMetadata { - use crate::core::metadata::*; +impl rawshift_image_core::ExtractMetadata for CrwFile { + fn extract_metadata(&self) -> rawshift_image_core::ImageMetadata { + use rawshift_image_core::metadata::*; let m = self.metadata.as_ref(); diff --git a/crates/rawshift-image-crw/src/lib.rs b/crates/rawshift-image-crw/src/lib.rs new file mode 100644 index 0000000..49d34e9 --- /dev/null +++ b/crates/rawshift-image-crw/src/lib.rs @@ -0,0 +1,27 @@ +//! Canon CRW format support. +#![forbid(unsafe_code)] + +#[cfg(feature = "decode")] +mod decoder; +#[cfg(feature = "decode")] +pub use decoder::*; + +/// Canon CRW format marker. +pub struct Crw; + +#[cfg(feature = "decode")] +impl rawshift_image_core::FormatSniffer for Crw { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Crw; + fn matches(data: &[u8]) -> bool { + is_crw(data) + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Crw { + type Options = (); + type Output = rawshift_image_core::RawImage; + fn decode(data: &[u8], _: &()) -> rawshift_image_core::RawResult { + CrwFile::parse(std::io::Cursor::new(data))?.decode_raw() + } +} diff --git a/crates/rawshift-image-dng/Cargo.toml b/crates/rawshift-image-dng/Cargo.toml new file mode 100644 index 0000000..5deca37 --- /dev/null +++ b/crates/rawshift-image-dng/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rawshift-image-dng" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Adobe DNG decoding and encoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true } +rawshift-image-ifd = { workspace = true, optional = true } +gamut-dng = { workspace = true, optional = true } +gamut-ifd = { workspace = true, optional = true, features = ["bigtiff"] } +serde = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:rawshift-image-ifd", "dep:gamut-dng", "dep:gamut-ifd", "dep:tracing"] +encode = ["dep:gamut-dng"] +serde = ["dep:serde", "rawshift-image-core/serde"] diff --git a/crates/rawshift-image/src/formats/dng.rs b/crates/rawshift-image-dng/src/decoder.rs similarity index 97% rename from crates/rawshift-image/src/formats/dng.rs rename to crates/rawshift-image-dng/src/decoder.rs index f521987..942afd2 100644 --- a/crates/rawshift-image/src/formats/dng.rs +++ b/crates/rawshift-image-dng/src/decoder.rs @@ -3,7 +3,7 @@ //! Raw-image decoding (uncompressed, Deflate, lossless JPEG, and DNG 1.7 //! JPEG XL — the iPhone ProRAW scheme) is backed by [`gamut_dng::DngDecoder`]; //! the EXIF/GPS/thumbnail tree walk reuses the shared [`gamut_ifd`] helpers in -//! [`super::ifd`]. DNG tag semantics that gamut-dng does not type yet +//! [`rawshift_image_ifd`]. DNG tag semantics that gamut-dng does not type yet //! (NoiseProfile, ProfileToneCurve) are read from the decoder's verbatim //! `RawTag` extras. @@ -13,10 +13,11 @@ use std::marker::PhantomData; use gamut_dng::{DecodedDng, DngDecoder, RawLevels, RawPhotometry, RawTag}; use gamut_ifd::{Ifd, Value}; -use super::ifd::{self, tags}; -use crate::core::RgbImage; -use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect}; -use crate::error::{RawError, RawResult}; +use rawshift_image_core::RgbImage; +use rawshift_image_core::image::{CfaPattern, Dimensions, RawImage, Rect}; +use rawshift_image_core::{RawError, RawResult}; +use rawshift_image_ifd as ifd; +use rawshift_image_ifd::tags; /// Metadata extracted from a DNG file. #[derive(Debug, Clone)] @@ -79,11 +80,11 @@ pub struct DngMetadata { /// Profile tone curve pub profile_tone_curve: Option>, /// EXIF exposure/capture settings - pub exif: crate::core::metadata::ExifInfo, + pub exif: rawshift_image_core::metadata::ExifInfo, /// Date/time information - pub datetime: crate::core::metadata::DateTimeInfo, + pub datetime: rawshift_image_core::metadata::DateTimeInfo, /// GPS location data - pub gps: crate::core::metadata::GpsInfo, + pub gps: rawshift_image_core::metadata::GpsInfo, /// Lens make pub lens_make: Option, /// Lens model @@ -382,8 +383,7 @@ impl DngFile { // (post-demosaic) data. This is where GainMap (lens shading // correction) lives for iPhone ProRAW. if !raw.opcode_list2().is_empty() { - let opcode_list = - crate::transforms::opcodes::OpcodeList::parse(&raw.opcode_list2().to_bytes()); + let opcode_list = crate::opcodes::OpcodeList::parse(&raw.opcode_list2().to_bytes()); opcode_list.apply_to_rgb(&mut image); } @@ -564,9 +564,9 @@ fn flatten_ifds<'a>(ifds: &'a [Ifd], out: &mut Vec<&'a Ifd>) { } } -impl crate::core::ExtractMetadata for DngFile { - fn extract_metadata(&self) -> crate::core::ImageMetadata { - use crate::core::metadata::*; +impl rawshift_image_core::ExtractMetadata for DngFile { + fn extract_metadata(&self) -> rawshift_image_core::ImageMetadata { + use rawshift_image_core::metadata::*; let m = self.metadata.as_ref(); diff --git a/crates/rawshift-image/src/formats/dng_export.rs b/crates/rawshift-image-dng/src/encode.rs similarity index 96% rename from crates/rawshift-image/src/formats/dng_export.rs rename to crates/rawshift-image-dng/src/encode.rs index 8f3c766..c4aef0a 100644 --- a/crates/rawshift-image/src/formats/dng_export.rs +++ b/crates/rawshift-image-dng/src/encode.rs @@ -12,9 +12,9 @@ use std::path::Path; use gamut_dng::values::CalibrationIlluminant; use gamut_dng::{CameraProfile, DngEncoder, RawLevels}; -use crate::core::RgbImage; -use crate::core::metadata::ImageMetadata; -use crate::error::{RawError, RawResult}; +use rawshift_image_core::RgbImage; +use rawshift_image_core::metadata::ImageMetadata; +use rawshift_image_core::{RawError, RawResult}; /// DNG encode configuration. #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -185,7 +185,7 @@ fn build_camera_profile(metadata: &ImageMetadata) -> RawResult { /// The EXIF capture settings gamut-dng can embed. fn build_exif_metadata(metadata: &ImageMetadata) -> gamut_dng::ExifMetadata { - let rational = |r: &crate::core::metadata::URational| (r.numerator, r.denominator); + let rational = |r: &rawshift_image_core::metadata::URational| (r.numerator, r.denominator); gamut_dng::ExifMetadata { exposure_time: metadata.exif.exposure_time.as_ref().map(rational), f_number: metadata.exif.f_number.as_ref().map(rational), @@ -198,9 +198,9 @@ fn build_exif_metadata(metadata: &ImageMetadata) -> gamut_dng::ExifMetadata { #[cfg(test)] mod tests { use super::*; - use crate::core::RgbImage; - use crate::core::metadata::URational; use gamut_dng::{DngDecoder, RawPhotometry}; + use rawshift_image_core::RgbImage; + use rawshift_image_core::metadata::URational; fn test_metadata() -> ImageMetadata { let mut metadata = ImageMetadata::default(); diff --git a/crates/rawshift-image-dng/src/lib.rs b/crates/rawshift-image-dng/src/lib.rs new file mode 100644 index 0000000..1edf87f --- /dev/null +++ b/crates/rawshift-image-dng/src/lib.rs @@ -0,0 +1,66 @@ +//! Adobe DNG format support. +#![forbid(unsafe_code)] + +#[cfg(feature = "decode")] +mod decoder; +#[cfg(feature = "decode")] +pub use decoder::*; +#[cfg(feature = "encode")] +mod encode; +#[cfg(feature = "encode")] +pub use encode::*; +#[cfg(feature = "decode")] +pub mod opcodes; + +/// Adobe DNG format marker. +pub struct Dng; + +impl rawshift_image_core::FormatSniffer for Dng { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Dng; + fn matches(data: &[u8]) -> bool { + #[cfg(feature = "decode")] + { + use gamut_ifd::{IfdReader, StreamSource}; + let mut cursor = std::io::Cursor::new(data); + let Ok(mut reader) = IfdReader::open(StreamSource::new(&mut cursor)) else { + return false; + }; + let Ok(ifd) = reader.read_ifd(reader.first_ifd_offset()) else { + return false; + }; + ifd.entry(rawshift_image_ifd::tags::DNG_VERSION).is_some() + } + #[cfg(not(feature = "decode"))] + { + let tiff = data.starts_with(b"II\x2a\0") || data.starts_with(b"MM\0\x2a"); + tiff && data.get(..data.len().min(4096)).is_some_and(|header| { + header + .windows(2) + .any(|bytes| bytes == [0xc6, 0x12] || bytes == [0x12, 0xc6]) + }) + } + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Dng { + type Options = (); + type Output = rawshift_image_core::RawImage; + fn decode(data: &[u8], _: &()) -> rawshift_image_core::RawResult { + DngFile::parse(std::io::Cursor::new(data))?.decode_raw() + } +} + +#[cfg(feature = "encode")] +impl rawshift_image_core::ImageEncoder for Dng { + type Options = DngEncodeConfig; + type Input = rawshift_image_core::RgbImage; + fn encode_to_writer( + input: &Self::Input, + metadata: &rawshift_image_core::ImageMetadata, + options: &Self::Options, + writer: W, + ) -> rawshift_image_core::RawResult<()> { + export_dng_to_writer(writer, input, metadata, options) + } +} diff --git a/crates/rawshift-image/src/transforms/opcodes.rs b/crates/rawshift-image-dng/src/opcodes.rs similarity index 99% rename from crates/rawshift-image/src/transforms/opcodes.rs rename to crates/rawshift-image-dng/src/opcodes.rs index 9025929..2f8cbb9 100644 --- a/crates/rawshift-image/src/transforms/opcodes.rs +++ b/crates/rawshift-image-dng/src/opcodes.rs @@ -19,7 +19,7 @@ //! 2. `FixBadPixelsList` (ID 5) — replace specific known bad pixels //! 3. `GainMap` (ID 9) — spatially-varying lens-shading correction (critical for ProRAW) -use crate::core::RgbImage; +use rawshift_image_core::RgbImage; // ============================================================================ // Opcode data structures diff --git a/crates/rawshift-image-gif/Cargo.toml b/crates/rawshift-image-gif/Cargo.toml new file mode 100644 index 0000000..2f6b3c9 --- /dev/null +++ b/crates/rawshift-image-gif/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rawshift-image-gif" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "GIF decoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true } +gif = { version = "0.13", optional = true } +serde = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:gif"] +serde = ["dep:serde", "rawshift-image-core/serde"] diff --git a/crates/rawshift-image-gif/src/lib.rs b/crates/rawshift-image-gif/src/lib.rs new file mode 100644 index 0000000..f7e40a0 --- /dev/null +++ b/crates/rawshift-image-gif/src/lib.rs @@ -0,0 +1,72 @@ +//! GIF format support. +#![forbid(unsafe_code)] + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct GifDecodeConfig {} + +pub struct Gif; + +impl rawshift_image_core::FormatSniffer for Gif { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Gif; + fn matches(data: &[u8]) -> bool { + data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Gif { + type Options = GifDecodeConfig; + type Output = rawshift_image_core::RgbImage; + fn decode(data: &[u8], _: &Self::Options) -> rawshift_image_core::RawResult { + use gif::{ColorOutput, DecodeOptions}; + use rawshift_image_core::{FormatError, RawError}; + let error = |error: gif::DecodingError| { + RawError::Format(FormatError::ImageDecode { + format: "GIF", + message: error.to_string(), + }) + }; + let mut options = DecodeOptions::new(); + options.set_color_output(ColorOutput::RGBA); + let mut decoder = options + .read_info(std::io::Cursor::new(data)) + .map_err(error)?; + let (width, height) = (u32::from(decoder.width()), u32::from(decoder.height())); + let frame = decoder.read_next_frame().map_err(error)?.ok_or_else(|| { + RawError::Format(FormatError::ImageDecode { + format: "GIF", + message: "no frames in GIF".to_owned(), + }) + })?; + let mut output = vec![0; width as usize * height as usize * 3]; + let (fw, fh, left, top) = ( + frame.width as usize, + frame.height as usize, + frame.left as usize, + frame.top as usize, + ); + if frame.buffer.len() < fw * fh * 4 { + return Err(RawError::Format(FormatError::ImageDecode { + format: "GIF", + message: "frame buffer too small".to_owned(), + })); + } + for row in 0..fh { + for column in 0..fw { + let (x, y) = (left + column, top + row); + if x >= width as usize || y >= height as usize { + continue; + } + let source = (row * fw + column) * 4; + let target = (y * width as usize + x) * 3; + output[target..target + 3].copy_from_slice(&[ + u16::from(frame.buffer[source]) * 257, + u16::from(frame.buffer[source + 1]) * 257, + u16::from(frame.buffer[source + 2]) * 257, + ]); + } + } + rawshift_image_core::RgbImage::new(width, height, output) + } +} diff --git a/crates/rawshift-image-heic/Cargo.toml b/crates/rawshift-image-heic/Cargo.toml new file mode 100644 index 0000000..3d491c8 --- /dev/null +++ b/crates/rawshift-image-heic/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "rawshift-image-heic" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "HEIC and HEIF decoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true } +rawshift-image-metadata = { workspace = true, features = ["exif"] } +gamut-heic = { workspace = true } +gamut-core = { workspace = true } +gamut-isobmff = { workspace = true } +rawshift-hwdec = { workspace = true, optional = true } +serde = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = [] +serde = ["dep:serde", "rawshift-image-core/serde"] +hw = ["dep:rawshift-hwdec", "rawshift-hwdec/hw", "rawshift-image-core/hw-planes"] +hw-videotoolbox = ["hw", "rawshift-hwdec/videotoolbox"] +hw-vaapi = ["hw", "rawshift-hwdec/vaapi"] +hw-mediacodec = ["hw", "rawshift-hwdec/mediacodec"] + +[dev-dependencies] +gamut-isobmff = { workspace = true } +gamut-color = { workspace = true } diff --git a/crates/rawshift-image/src/formats/heic.rs b/crates/rawshift-image-heic/src/decoder.rs similarity index 98% rename from crates/rawshift-image/src/formats/heic.rs rename to crates/rawshift-image-heic/src/decoder.rs index 358fff3..894d503 100644 --- a/crates/rawshift-image/src/formats/heic.rs +++ b/crates/rawshift-image-heic/src/decoder.rs @@ -25,10 +25,10 @@ use gamut_heic::{HeifContainer, HeifImage, HeifItem}; use gamut_isobmff::ColourInformation; -use crate::core::RgbImage; -use crate::core::metadata::{ImageMetadata, MetadataKey, MetadataNamespace, MetadataValue}; -use crate::error::{FormatError, RawError, RawResult}; -use crate::metadata::exif::ExifParser; +use rawshift_image_core::RgbImage; +use rawshift_image_core::metadata::{ImageMetadata, MetadataKey, MetadataNamespace, MetadataValue}; +use rawshift_image_core::{FormatError, RawError, RawResult}; +use rawshift_image_metadata::exif::ExifParser; /// The codec name reported in [`RawError::HwDecoderUnavailable`]. const HEVC: &str = "HEVC"; @@ -213,8 +213,8 @@ pub fn heic_hw_decode_available() -> bool { /// /// Reads the embedded EXIF, XMP, and ICC profile and maps them onto /// [`ImageMetadata`]. Returns a default (empty) value when the file carries no -/// metadata or cannot be parsed. Used by both [`HeicFile::metadata`] and -/// [`read_standard_image_metadata`](crate::formats::read_standard_image_metadata). +/// metadata or cannot be parsed. Used by both [`HeicFile::metadata`] and the +/// `rawshift-image` facade's standard metadata reader. pub fn read_heic_metadata(data: &[u8]) -> ImageMetadata { match HeifContainer::parse(data) { Ok(container) => metadata_from_image(container.image()), @@ -379,9 +379,9 @@ fn metadata_from_image(image: &HeifImage) -> ImageMetadata { // ── decoded-frame presentation ─────────────────────────────────────────────── // gamut-heic RGBA8 output → rawshift 16-bit `RgbImage` (shared with the AVIF -// adapter — see `super::hw_planes`). +// adapter — see `rawshift_image_core::hw_planes`). #[cfg(feature = "hw")] -use super::hw_planes::rgba8_to_rgb_image; +use rawshift_image_core::hw_planes::rgba8_to_rgb_image; // ── rawshift-hwdec adapter ─────────────────────────────────────────────────── @@ -404,8 +404,10 @@ mod hw { PixelFormat, StillDecodeRequest, }; - use super::super::hw_planes::{deinterleave_8, deinterleave_16, read_plane_8, read_plane_16}; - use crate::error::{FormatError, RawError}; + use rawshift_image_core::hw_planes::{ + deinterleave_8, deinterleave_16, read_plane_8, read_plane_16, + }; + use rawshift_image_core::{FormatError, RawError}; /// A [`HevcDecoder`] over a hardware [`HwStillDecoder`]. /// diff --git a/crates/rawshift-image-heic/src/lib.rs b/crates/rawshift-image-heic/src/lib.rs new file mode 100644 index 0000000..ee4bda4 --- /dev/null +++ b/crates/rawshift-image-heic/src/lib.rs @@ -0,0 +1,28 @@ +//! HEIC and HEIF format support. +#![forbid(unsafe_code)] + +#[cfg(feature = "decode")] +mod decoder; +#[cfg(feature = "decode")] +pub use decoder::*; + +/// HEIC/HEIF format marker. +pub struct Heic; + +impl rawshift_image_core::FormatSniffer for Heic { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Heic; + fn matches(data: &[u8]) -> bool { + data.len() >= 12 + && &data[4..8] == b"ftyp" + && matches!(&data[8..12], b"heic" | b"heis" | b"hevc" | b"hevx") + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Heic { + type Options = (); + type Output = rawshift_image_core::RgbImage; + fn decode(data: &[u8], _: &()) -> rawshift_image_core::RawResult { + HeicFile::open(data.to_vec())?.decode_primary() + } +} diff --git a/crates/rawshift-image-ifd/Cargo.toml b/crates/rawshift-image-ifd/Cargo.toml new file mode 100644 index 0000000..fadded8 --- /dev/null +++ b/crates/rawshift-image-ifd/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "rawshift-image-ifd" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Shared IFD helpers for rawshift RAW format crates" +documentation = "https://docs.rs/rawshift-image-ifd" +keywords = ["raw", "image", "ifd", "tiff"] +categories = ["multimedia::images", "parser-implementations"] + +[dependencies] +rawshift-core = { workspace = true } +rawshift-image-core = { workspace = true } +gamut-ifd = { workspace = true, features = ["bigtiff"] } +tracing = { workspace = true } diff --git a/crates/rawshift-image/src/formats/ifd.rs b/crates/rawshift-image-ifd/src/lib.rs similarity index 94% rename from crates/rawshift-image/src/formats/ifd.rs rename to crates/rawshift-image-ifd/src/lib.rs index be3cc33..672ac07 100644 --- a/crates/rawshift-image/src/formats/ifd.rs +++ b/crates/rawshift-image-ifd/src/lib.rs @@ -23,15 +23,15 @@ use std::io::{Read, Seek, SeekFrom}; use gamut_ifd::{ByteOrder, Ifd, Value, Variant, read_ifd_at}; -use crate::core::metadata::{DateTimeInfo, ExifInfo, GpsInfo, SRational, URational}; -use crate::error::{ParseError, RawError, RawResult}; +use rawshift_core::metadata::{DateTimeInfo, ExifInfo, GpsInfo, SRational, URational}; +use rawshift_image_core::{ParseError, RawError, RawResult}; /// The tag catalogue used by the ARW/CR2/NEF decoders, CR3's CMT TIFF blocks, /// and format detection. /// /// Numeric ids per TIFF 6.0, TIFF/EP, Exif 3.0, DNG 1.7, and the community /// Sony SR2 documentation. Names follow the legacy `TiffTag` variants. -pub(crate) mod tags { +pub mod tags { // ── Baseline TIFF (TIFF 6.0 §8) ────────────────────────────────────────── pub const NEW_SUBFILE_TYPE: u16 = 0x00FE; pub const IMAGE_WIDTH: u16 = 0x0100; @@ -134,7 +134,7 @@ const MAX_SUB_IFD_DEPTH: usize = 8; /// A parsed TIFF container: its layout parameters and the IFD chain with the /// standard sub-IFD pointer tree resolved. -pub(crate) struct TiffTree { +pub struct TiffTree { /// The byte order the stream was written in. pub order: ByteOrder, /// Classic TIFF or BigTIFF. @@ -146,7 +146,7 @@ pub(crate) struct TiffTree { /// Reads the whole stream into memory (from absolute offset 0, matching the /// legacy parser, which always sought to the file start). -pub(crate) fn read_all(mut reader: R) -> RawResult> { +pub fn read_all(mut reader: R) -> RawResult> { reader.seek(SeekFrom::Start(0))?; let mut data = Vec::new(); reader.read_to_end(&mut data)?; @@ -159,7 +159,7 @@ pub(crate) fn read_all(mut reader: R) -> RawResult> { /// A malformed *child* directory is skipped with a warning (matching the /// legacy parser); a malformed header or top-level chain is an error, wrapped /// as [`RawError::Gamut`] under `context`. -pub(crate) fn parse_tree(data: &[u8], context: &'static str) -> RawResult { +pub fn parse_tree(data: &[u8], context: &'static str) -> RawResult { let file = gamut_ifd::read(data).map_err(|e| RawError::gamut(context, e))?; let mut ifds = file.ifds; let mut visited: Vec = Vec::new(); @@ -223,7 +223,7 @@ fn resolve_pointers( } /// The child IFDs attached under pointer `tag`, or an empty slice. -pub(crate) fn sub_ifd_group(ifd: &Ifd, tag: u16) -> &[Ifd] { +pub fn sub_ifd_group(ifd: &Ifd, tag: u16) -> &[Ifd] { ifd.sub_ifds() .iter() .find(|group| group.tag == tag) @@ -232,29 +232,29 @@ pub(crate) fn sub_ifd_group(ifd: &Ifd, tag: u16) -> &[Ifd] { } /// The EXIF private sub-IFD of `ifd0`, if it was resolved. -pub(crate) fn exif_ifd(ifd0: &Ifd) -> Option<&Ifd> { +pub fn exif_ifd(ifd0: &Ifd) -> Option<&Ifd> { sub_ifd_group(ifd0, tags::EXIF_IFD_POINTER).first() } /// The GPS sub-IFD of `ifd0`, if it was resolved. -pub(crate) fn gps_ifd(ifd0: &Ifd) -> Option<&Ifd> { +pub fn gps_ifd(ifd0: &Ifd) -> Option<&Ifd> { sub_ifd_group(ifd0, tags::GPS_INFO_IFD_POINTER).first() } /// Normalises an on-disk string the way the legacy parser did: trailing NUL /// padding stripped, then surrounding whitespace trimmed. -pub(crate) fn clean_ascii(s: &str) -> String { +pub fn clean_ascii(s: &str) -> String { s.trim_end_matches('\0').trim().to_string() } /// Reads `tag` as a normalised string (see [`clean_ascii`]). -pub(crate) fn ascii_tag(ifd: &Ifd, tag: u16) -> Option { +pub fn ascii_tag(ifd: &Ifd, tag: u16) -> Option { ifd.get(tag).and_then(Value::as_str).map(clean_ascii) } /// The first element of an unsigned-integer `tag` (`BYTE`/`SHORT`/`LONG`), /// the coercion array-shaped tags like `BitsPerSample` need. -pub(crate) fn first_u32(ifd: &Ifd, tag: u16) -> Option { +pub fn first_u32(ifd: &Ifd, tag: u16) -> Option { match ifd.get(tag)? { Value::Byte(v) => v.first().map(|&x| u32::from(x)), Value::Short(v) => v.first().map(|&x| u32::from(x)), @@ -265,7 +265,7 @@ pub(crate) fn first_u32(ifd: &Ifd, tag: u16) -> Option { /// Borrows `len` bytes at `offset` of `data`, or reports the out-of-bounds /// access as [`ParseError::OffsetOutOfBounds`]. -pub(crate) fn read_range(data: &[u8], offset: u64, len: usize) -> RawResult<&[u8]> { +pub fn read_range(data: &[u8], offset: u64, len: usize) -> RawResult<&[u8]> { usize::try_from(offset) .ok() .and_then(|start| data.get(start..start.checked_add(len)?)) @@ -278,7 +278,7 @@ pub(crate) fn read_range(data: &[u8], offset: u64, len: usize) -> RawResult<&[u8 /// Extracts the embedded JPEG thumbnail referenced by IFD0's /// `JPEGInterchangeFormat`/`JPEGInterchangeFormatLength` pair, if present. -pub(crate) fn jpeg_thumbnail(data: &[u8], ifd0: &Ifd) -> RawResult>> { +pub fn jpeg_thumbnail(data: &[u8], ifd0: &Ifd) -> RawResult>> { let Some(offset) = first_u32(ifd0, tags::JPEG_INTERCHANGE_FORMAT) else { return Ok(None); }; @@ -346,7 +346,7 @@ fn urational3_tag(ifd: &Ifd, tag: u16) -> Option<[URational; 3]> { // ============================================================================ /// Extracts EXIF exposure/capture settings from IFD0's EXIF sub-IFD. -pub(crate) fn extract_exif(ifd0: &Ifd) -> ExifInfo { +pub fn extract_exif(ifd0: &Ifd) -> ExifInfo { let Some(exif) = exif_ifd(ifd0) else { return ExifInfo::default(); }; @@ -367,7 +367,7 @@ pub(crate) fn extract_exif(ifd0: &Ifd) -> ExifInfo { } /// Extracts date/time information from IFD0 and its EXIF sub-IFD. -pub(crate) fn extract_datetime(ifd0: &Ifd) -> DateTimeInfo { +pub fn extract_datetime(ifd0: &Ifd) -> DateTimeInfo { let modify_date = ascii_tag(ifd0, tags::DATE_TIME); let exif = exif_ifd(ifd0); @@ -390,7 +390,7 @@ pub(crate) fn extract_datetime(ifd0: &Ifd) -> DateTimeInfo { } /// Extracts GPS location data from IFD0's GPS sub-IFD. -pub(crate) fn extract_gps(ifd0: &Ifd) -> GpsInfo { +pub fn extract_gps(ifd0: &Ifd) -> GpsInfo { let Some(gps) = gps_ifd(ifd0) else { return GpsInfo::default(); }; @@ -410,7 +410,7 @@ pub(crate) fn extract_gps(ifd0: &Ifd) -> GpsInfo { } /// Extracts lens make/model from IFD0's EXIF sub-IFD. -pub(crate) fn extract_lens_info(ifd0: &Ifd) -> (Option, Option) { +pub fn extract_lens_info(ifd0: &Ifd) -> (Option, Option) { let Some(exif) = exif_ifd(ifd0) else { return (None, None); }; @@ -421,7 +421,7 @@ pub(crate) fn extract_lens_info(ifd0: &Ifd) -> (Option, Option) } /// Extracts the orientation (1-8) from IFD0. -pub(crate) fn extract_orientation(ifd0: &Ifd) -> Option { +pub fn extract_orientation(ifd0: &Ifd) -> Option { u16_tag(ifd0, tags::ORIENTATION) } diff --git a/crates/rawshift-image-jpeg/Cargo.toml b/crates/rawshift-image-jpeg/Cargo.toml new file mode 100644 index 0000000..fb2792f --- /dev/null +++ b/crates/rawshift-image-jpeg/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rawshift-image-jpeg" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "JPEG decoding and encoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true } +rawshift-image-metadata = { workspace = true, optional = true, features = ["exif"] } +gamut-core = { workspace = true, optional = true } +gamut-jpeg = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +gamut-xmp = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:gamut-core", "dep:gamut-jpeg"] +encode = ["dep:gamut-core", "dep:gamut-jpeg", "dep:rawshift-image-metadata", "dep:gamut-xmp"] +serde = ["dep:serde", "rawshift-image-core/serde"] diff --git a/crates/rawshift-image-jpeg/src/lib.rs b/crates/rawshift-image-jpeg/src/lib.rs new file mode 100644 index 0000000..960bfb4 --- /dev/null +++ b/crates/rawshift-image-jpeg/src/lib.rs @@ -0,0 +1,127 @@ +//! JPEG format support. +#![forbid(unsafe_code)] + +#[cfg(feature = "decode")] +use rawshift_image_core::FormatError; +use rawshift_image_core::{FormatId, FormatSniffer}; +#[cfg(any(feature = "decode", feature = "encode"))] +use rawshift_image_core::{RawError, RawResult, RgbImage}; + +/// JPEG decoder configuration. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct JpegDecodeConfig {} + +/// JPEG format marker. +pub struct Jpeg; + +impl FormatSniffer for Jpeg { + const FORMAT: FormatId = FormatId::Jpeg; + fn matches(data: &[u8]) -> bool { + data.starts_with(&[0xff, 0xd8, 0xff]) + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Jpeg { + type Options = JpegDecodeConfig; + type Output = RgbImage; + fn decode(data: &[u8], options: &Self::Options) -> RawResult { + decode(data, options) + } +} + +#[cfg(feature = "decode")] +pub fn decode(data: &[u8], _config: &JpegDecodeConfig) -> RawResult { + use gamut_core::{Cmyk8, DecodeImage, ImageBuf, Rgb8}; + use gamut_jpeg::JpegDecoder; + + let jpeg_err = |error: gamut_core::Error| { + RawError::Format(FormatError::ImageDecode { + format: "JPEG", + message: error.to_string(), + }) + }; + let info = gamut_jpeg::info(data).map_err(jpeg_err)?; + if info.components == 4 { + let decoded: ImageBuf = JpegDecoder::new().decode_image(data).map_err(jpeg_err)?; + let dims = decoded.dimensions(); + let samples = decoded + .as_samples() + .chunks_exact(4) + .flat_map(|px| { + [ + scale(blinn(px[0], px[3])), + scale(blinn(px[1], px[3])), + scale(blinn(px[2], px[3])), + ] + }) + .collect(); + return RgbImage::new(dims.width, dims.height, samples); + } + let decoded: ImageBuf = JpegDecoder::new().decode_image(data).map_err(jpeg_err)?; + let dims = decoded.dimensions(); + RgbImage::new( + dims.width, + dims.height, + decoded.as_samples().iter().map(|&v| scale(v)).collect(), + ) +} + +#[cfg(feature = "decode")] +fn blinn(value: u8, factor: u8) -> u8 { + let product = i32::from(value) * i32::from(factor) + 128; + ((product + (product >> 8)) >> 8) as u8 +} + +#[cfg(feature = "decode")] +fn scale(value: u8) -> u16 { + u16::from(value) * 257 +} + +#[cfg(feature = "encode")] +impl rawshift_image_core::ImageEncoder for Jpeg { + type Options = (); + type Input = RgbImage; + fn encode_to_writer( + input: &Self::Input, + metadata: &rawshift_image_core::ImageMetadata, + _: &Self::Options, + mut writer: W, + ) -> RawResult<()> { + use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; + use gamut_jpeg::{ChromaSubsampling, JpegEncoder}; + use rawshift_image_metadata::{exif::ExifBuilder, icc::IccProfile}; + let error = |error: gamut_core::Error| { + RawError::Encode(rawshift_image_core::EncodeError::Encoding { + format: "JPEG", + message: error.to_string(), + }) + }; + let dimensions = Dimensions::new(input.width(), input.height()).map_err(error)?; + let samples: Vec = input + .data() + .iter() + .map(|value| (value >> 8) as u8) + .collect(); + let image = ImageRef::::new(&samples, dimensions).map_err(error)?; + let exif = ExifBuilder::new(metadata).build_bytes().ok(); + let icc = IccProfile::srgb(); + let mut encoder = JpegEncoder::new() + .with_quality(90) + .with_subsampling(ChromaSubsampling::Ycbcr420) + .with_icc_profile(icc.as_bytes()); + if let Some(bytes) = exif.as_deref() { + encoder = encoder.with_exif(bytes); + } + if let Some(xmp) = metadata.xmp.as_deref() + && gamut_xmp::XmpMeta::from_packet(xmp).is_ok() + { + encoder = encoder.with_xmp(xmp); + } + let mut output = Vec::new(); + encoder.encode_image(image, &mut output).map_err(error)?; + writer.write_all(&output)?; + Ok(()) + } +} diff --git a/crates/rawshift-image-jxl/Cargo.toml b/crates/rawshift-image-jxl/Cargo.toml new file mode 100644 index 0000000..4b40dea --- /dev/null +++ b/crates/rawshift-image-jxl/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rawshift-image-jxl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "JPEG XL decoding and encoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true } +rawshift-image-metadata = { workspace = true, optional = true, features = ["exif"] } +gamut-core = { workspace = true, optional = true } +gamut-jxl = { workspace = true, optional = true } +serde = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:gamut-core", "dep:gamut-jxl", "gamut-jxl/decode"] +encode = ["dep:gamut-core", "dep:gamut-jxl", "gamut-jxl/encode", "dep:rawshift-image-metadata"] +serde = ["dep:serde", "rawshift-image-core/serde"] diff --git a/crates/rawshift-image-jxl/src/lib.rs b/crates/rawshift-image-jxl/src/lib.rs new file mode 100644 index 0000000..397e1f1 --- /dev/null +++ b/crates/rawshift-image-jxl/src/lib.rs @@ -0,0 +1,79 @@ +//! JPEG XL format support. +#![forbid(unsafe_code)] + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct JxlDecodeConfig {} + +pub struct Jxl; + +impl rawshift_image_core::FormatSniffer for Jxl { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Jxl; + fn matches(data: &[u8]) -> bool { + data.starts_with(&[0xff, 0x0a]) || (data.len() >= 8 && &data[4..8] == b"JXL ") + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Jxl { + type Options = JxlDecodeConfig; + type Output = rawshift_image_core::RgbImage; + fn decode(data: &[u8], _: &Self::Options) -> rawshift_image_core::RawResult { + use gamut_core::{DecodeImage, ImageBuf, Rgb16}; + let decoded: ImageBuf = + gamut_jxl::JxlDecoder::new() + .decode_image(data) + .map_err(|error| { + rawshift_image_core::RawError::Format( + rawshift_image_core::FormatError::ImageDecode { + format: "JXL", + message: error.to_string(), + }, + ) + })?; + let dims = decoded.dimensions(); + rawshift_image_core::RgbImage::new(dims.width, dims.height, decoded.into_samples()) + } +} + +#[cfg(feature = "encode")] +impl rawshift_image_core::ImageEncoder for Jxl { + type Options = (); + type Input = rawshift_image_core::RgbImage; + fn encode_to_writer( + input: &Self::Input, + metadata: &rawshift_image_core::ImageMetadata, + _: &Self::Options, + mut writer: W, + ) -> rawshift_image_core::RawResult<()> { + use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb16}; + use gamut_jxl::{ColorSpec, Container, JxlEncoder}; + use rawshift_image_metadata::{exif::ExifBuilder, icc::IccProfile}; + let error = |error: gamut_core::Error| { + rawshift_image_core::RawError::Encode(rawshift_image_core::EncodeError::Encoding { + format: "JXL", + message: error.to_string(), + }) + }; + let dimensions = Dimensions::new(input.width(), input.height()).map_err(error)?; + let image = ImageRef::::new(input.data(), dimensions).map_err(error)?; + let icc = IccProfile::srgb(); + let mut encoder = JxlEncoder::lossless() + .with_color(ColorSpec::Icc(icc.as_bytes().to_vec())) + .with_container(Container::IsoBmff); + if let Ok(exif) = ExifBuilder::new(metadata).build_bytes() { + encoder = encoder.with_exif(&exif); + } + if let Some(xmp) = metadata + .xmp + .as_deref() + .and_then(|value| std::str::from_utf8(value).ok()) + { + encoder = encoder.with_xmp(xmp); + } + let mut output = Vec::new(); + encoder.encode_image(image, &mut output).map_err(error)?; + writer.write_all(&output)?; + Ok(()) + } +} diff --git a/crates/rawshift-image-ljpeg/Cargo.toml b/crates/rawshift-image-ljpeg/Cargo.toml new file mode 100644 index 0000000..922a377 --- /dev/null +++ b/crates/rawshift-image-ljpeg/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rawshift-image-ljpeg" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Lossless JPEG decode support for rawshift RAW format crates" +documentation = "https://docs.rs/rawshift-image-ljpeg" +keywords = ["raw", "image", "jpeg", "lossless"] +categories = ["multimedia::images", "compression"] + +[dependencies] +rawshift-image-core = { workspace = true } diff --git a/crates/rawshift-image/src/codecs/bit_pump.rs b/crates/rawshift-image-ljpeg/src/bit_pump.rs similarity index 100% rename from crates/rawshift-image/src/codecs/bit_pump.rs rename to crates/rawshift-image-ljpeg/src/bit_pump.rs diff --git a/crates/rawshift-image/src/codecs/ljpeg.rs b/crates/rawshift-image-ljpeg/src/lib.rs similarity index 99% rename from crates/rawshift-image/src/codecs/ljpeg.rs rename to crates/rawshift-image-ljpeg/src/lib.rs index b01c9df..654e6cd 100644 --- a/crates/rawshift-image/src/codecs/ljpeg.rs +++ b/crates/rawshift-image-ljpeg/src/lib.rs @@ -13,7 +13,9 @@ //! - Mode 6: Rb + (Ra - Rc) / 2 //! - Mode 7: (Ra + Rb) / 2 -use crate::error::{FormatError, RawError, RawResult}; +pub mod bit_pump; + +use rawshift_image_core::{FormatError, RawError, RawResult}; /// JPEG Markers #[allow(dead_code)] @@ -133,7 +135,7 @@ impl HuffmanTable { } // Re-export BitPump for external use -pub use super::bit_pump::BitPump; +pub use crate::bit_pump::BitPump; /// Lossless JPEG Decoder. /// diff --git a/crates/rawshift-image-metadata/Cargo.toml b/crates/rawshift-image-metadata/Cargo.toml new file mode 100644 index 0000000..56f3c00 --- /dev/null +++ b/crates/rawshift-image-metadata/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rawshift-image-metadata" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Metadata adapters shared by rawshift image format crates" +documentation = "https://docs.rs/rawshift-image-metadata" +keywords = ["image", "exif", "icc", "xmp"] +categories = ["multimedia::images"] + +[dependencies] +rawshift-core = { workspace = true } +gamut-exif = { workspace = true, optional = true } +gamut-icc = { workspace = true } +gamut-ifd = { workspace = true, optional = true, features = ["bigtiff"] } +gamut-metadata = { workspace = true, optional = true } +gamut-xmp = { workspace = true, optional = true } + +[features] +exif = ["dep:gamut-exif", "dep:gamut-metadata", "dep:gamut-xmp"] +avif-encode = ["dep:gamut-xmp"] +ifd-parser = ["dep:gamut-ifd"] diff --git a/crates/rawshift-image/src/metadata/bridge.rs b/crates/rawshift-image-metadata/src/bridge.rs similarity index 95% rename from crates/rawshift-image/src/metadata/bridge.rs rename to crates/rawshift-image-metadata/src/bridge.rs index c2c8a20..141cf6c 100644 --- a/crates/rawshift-image/src/metadata/bridge.rs +++ b/crates/rawshift-image-metadata/src/bridge.rs @@ -25,19 +25,17 @@ //! EXIF fields, mirrors every EXIF tag into `extra`, and stores the XMP/ICC //! carriers back as blobs. -use crate::core::metadata::ImageMetadata; -#[cfg(feature = "webp-encode")] -use crate::metadata::exif::ExifBuilder; -use crate::metadata::exif::ExifParser; +use crate::exif::{ExifBuilder, ExifParser}; use gamut_exif::ExifWriter; use gamut_metadata::Metadata; +use rawshift_core::metadata::ImageMetadata; /// Project an [`ImageMetadata`] into gamut's unified [`Metadata`] model. /// /// EXIF is rebuilt from the typed fields with [`ExifBuilder`]; the stored XMP /// and ICC blobs are parsed into their typed gamut forms (and skipped when /// malformed). See the module docs for what the projection drops. -#[cfg(feature = "webp-encode")] +#[allow(dead_code)] // consumed by format crates that opt into metadata support pub fn to_gamut(md: &ImageMetadata) -> Metadata { let exif_model = ExifBuilder::new(md).build(); let exif = (!exif_model.image().fields().is_empty() @@ -63,7 +61,7 @@ pub fn to_gamut(md: &ImageMetadata) -> Metadata { /// parser uses (typed fields plus the generic `extra` mirror, and the /// re-serialized blob in `exif_raw`); XMP and ICC are serialized back into /// their blob fields. -#[cfg(any(feature = "webp-decode", feature = "webp-encode"))] +#[allow(dead_code)] // consumed by format crates that opt into metadata support pub fn from_gamut(metadata: &Metadata) -> ImageMetadata { let mut md = match &metadata.exif { Some(exif) => ExifParser::parse_metadata(exif), @@ -87,7 +85,7 @@ pub fn from_gamut(metadata: &Metadata) -> ImageMetadata { #[cfg(test)] mod tests { use super::*; - use crate::core::metadata::*; + use rawshift_core::metadata::*; fn sample_metadata() -> ImageMetadata { ImageMetadata { @@ -190,7 +188,7 @@ mod tests { let xmp_packet = gamut_xmp::XmpMeta::new().to_packet(); let md = ImageMetadata { xmp: Some(xmp_packet), - icc_profile: Some(crate::metadata::icc::IccProfile::srgb().as_bytes().to_vec()), + icc_profile: Some(crate::icc::IccProfile::srgb().as_bytes().to_vec()), ..Default::default() }; diff --git a/crates/rawshift-image/src/metadata/exif.rs b/crates/rawshift-image-metadata/src/exif.rs similarity index 95% rename from crates/rawshift-image/src/metadata/exif.rs rename to crates/rawshift-image-metadata/src/exif.rs index 423ac70..24bf8eb 100644 --- a/crates/rawshift-image/src/metadata/exif.rs +++ b/crates/rawshift-image-metadata/src/exif.rs @@ -4,15 +4,15 @@ //! EXIF model — see the Upstream-First Policy) and converts them to and from //! [`ImageMetadata`]. Container-level concerns stay on this side only for the //! formats whose codec has not yet migrated to gamut: AVIF embedding goes -//! through the crate's ISOBMFF box splicing ([`crate::metadata::isobmff`]), +//! through the crate's ISOBMFF box splicing ([`crate::isobmff`]), //! and the decode-side blob *location* (`eXIf` chunk, `EXIF` chunk, `Exif` //! item) is scanned here. JPEG APP segments are read and written by //! `gamut-jpeg` itself (`gamut_jpeg::metadata` / `JpegEncoder::with_exif`); //! the remaining container surgery migrates behind the gamut codec boundaries //! with the per-format codec issues. -use crate::core::metadata::ImageMetadata; use gamut_exif::{ByteOrder, Exif, ExifTag, ExifWriter, Ifd, Value}; +use rawshift_core::metadata::ImageMetadata; /// Error type for EXIF operations. #[derive(Debug)] @@ -49,7 +49,6 @@ pub struct ExifBuilder<'a> { impl<'a> ExifBuilder<'a> { /// Create a new ExifBuilder from ImageMetadata. - #[cfg_attr(not(any_standard_encode), allow(dead_code))] pub fn new(metadata: &'a ImageMetadata) -> Self { Self { metadata } } @@ -169,7 +168,7 @@ impl<'a> ExifBuilder<'a> { /// Build GPS-related EXIF tags. fn build_gps(&self, exif: &mut Exif) { let gps = &self.metadata.gps; - let triple = |v: &[crate::core::metadata::URational; 3]| { + let triple = |v: &[rawshift_core::metadata::URational; 3]| { Value::Rational(vec![ (v[0].numerator, v[0].denominator), (v[1].numerator, v[1].denominator), @@ -235,9 +234,9 @@ impl<'a> ExifBuilder<'a> { /// /// AVIF uses the HEIF/ISOBMFF container; the EXIF payload is stored as an /// `Exif` item (an `ExifDataBlock`: a 4-byte TIFF-header offset followed by - /// the TIFF stream) and wired into `iinf`/`iloc`/`iref` — see - /// [`crate::metadata::isobmff::insert_item`]. - #[cfg_attr(not(feature = "avif"), allow(dead_code))] + /// the TIFF stream) and wired into `iinf`/`iloc`/`iref` by the private + /// `isobmff::insert_item` helper. + #[cfg_attr(not(feature = "avif-encode"), allow(dead_code))] pub fn append_to_avif(&self, avif_data: Vec) -> Result, ExifError> { let tiff_bytes = self.build_bytes()?; // ExifDataBlock (ISO 23008-12): exif_tiff_header_offset then the payload. @@ -245,7 +244,7 @@ impl<'a> ExifBuilder<'a> { let mut payload = Vec::with_capacity(4 + tiff_bytes.len()); payload.extend_from_slice(&0u32.to_be_bytes()); payload.extend_from_slice(&tiff_bytes); - crate::metadata::isobmff::insert_item(avif_data, *b"Exif", &payload) + crate::isobmff::insert_item(avif_data, *b"Exif", &payload) .map_err(|e| ExifError::Container(format!("AVIF EXIF embedding failed: {e}"))) } } @@ -297,7 +296,7 @@ impl ExifParser { /// Convert an already-parsed [`gamut_exif::Exif`] into [`ImageMetadata`]. pub fn parse_metadata(exif: &Exif) -> ImageMetadata { - use crate::core::metadata::*; + use rawshift_core::metadata::*; let mut md = ImageMetadata::default(); @@ -398,8 +397,8 @@ impl ExifParser { } /// Populate [`ImageMetadata::extra`] with a typed mirror of every EXIF tag. - fn populate_extra(exif: &Exif, md: &mut crate::core::metadata::ImageMetadata) { - use crate::core::metadata::{MetadataKey, MetadataNamespace}; + fn populate_extra(exif: &Exif, md: &mut rawshift_core::metadata::ImageMetadata) { + use rawshift_core::metadata::{MetadataKey, MetadataNamespace}; let directories: [(MetadataNamespace, Option<&Ifd>); 5] = [ (MetadataNamespace::Exif, Some(exif.image())), @@ -456,21 +455,21 @@ fn first_u16(value: &Value) -> Option { } /// The first element of a `RATIONAL` value, as a [`URational`]. -fn first_urational(value: &Value) -> Option { +fn first_urational(value: &Value) -> Option { match value { Value::Rational(v) => v .first() - .map(|&(n, d)| crate::core::metadata::URational::new(n, d)), + .map(|&(n, d)| rawshift_core::metadata::URational::new(n, d)), _ => None, } } /// The first element of an `SRATIONAL` value, as an [`SRational`]. -fn first_srational(value: &Value) -> Option { +fn first_srational(value: &Value) -> Option { match value { Value::SRational(v) => v .first() - .map(|&(n, d)| crate::core::metadata::SRational::new(n, d)), + .map(|&(n, d)| rawshift_core::metadata::SRational::new(n, d)), _ => None, } } @@ -479,8 +478,8 @@ fn first_srational(value: &Value) -> Option { /// /// Single-element values collapse to a scalar; multi-element values become a /// [`MetadataValue::Array`]. -fn exif_value_to_metadata(value: &Value) -> crate::core::metadata::MetadataValue { - use crate::core::metadata::{MetadataValue, SRational, URational}; +fn exif_value_to_metadata(value: &Value) -> rawshift_core::metadata::MetadataValue { + use rawshift_core::metadata::{MetadataValue, SRational, URational}; fn collapse(mut vals: Vec) -> MetadataValue { if vals.len() == 1 { @@ -574,7 +573,7 @@ fn exif_value_to_metadata(value: &Value) -> crate::core::metadata::MetadataValue #[cfg(test)] mod tests { use super::*; - use crate::core::metadata::*; + use rawshift_core::metadata::*; fn sample_metadata() -> ImageMetadata { ImageMetadata { diff --git a/crates/rawshift-image/src/metadata/icc.rs b/crates/rawshift-image-metadata/src/icc.rs similarity index 99% rename from crates/rawshift-image/src/metadata/icc.rs rename to crates/rawshift-image-metadata/src/icc.rs index 2809cc9..bc6abee 100644 --- a/crates/rawshift-image/src/metadata/icc.rs +++ b/crates/rawshift-image-metadata/src/icc.rs @@ -6,7 +6,7 @@ //! move it behind the gamut codec boundaries (JPEG, PNG, and JXL already //! embed through their gamut encoders). -use crate::metadata::isobmff::{find_box, patch_iloc_extents, read_u32_be, write_u32_be}; +use crate::isobmff::{find_box, patch_iloc_extents, read_u32_be, write_u32_be}; /// Error type for ICC operations. #[derive(Debug)] diff --git a/crates/rawshift-image/src/metadata/isobmff.rs b/crates/rawshift-image-metadata/src/isobmff.rs similarity index 99% rename from crates/rawshift-image/src/metadata/isobmff.rs rename to crates/rawshift-image-metadata/src/isobmff.rs index 98ee193..8d9fd27 100644 --- a/crates/rawshift-image/src/metadata/isobmff.rs +++ b/crates/rawshift-image-metadata/src/isobmff.rs @@ -309,7 +309,7 @@ fn infe_boxes(data: &[u8], iinf_start: usize) -> impl Iterator Option> { let meta_start = find_box(data, 0, data.len(), b"meta")?; let meta_end = meta_start + read_u32_be(data, meta_start) as usize; diff --git a/crates/rawshift-image-metadata/src/lib.rs b/crates/rawshift-image-metadata/src/lib.rs new file mode 100644 index 0000000..c45ee81 --- /dev/null +++ b/crates/rawshift-image-metadata/src/lib.rs @@ -0,0 +1,11 @@ +//! Metadata infrastructure shared by rawshift's image format crates. +#![forbid(unsafe_code)] + +#[cfg(feature = "exif")] +pub mod bridge; +#[cfg(feature = "exif")] +pub mod exif; +pub mod icc; +pub mod isobmff; +#[cfg(feature = "avif-encode")] +pub mod xmp; diff --git a/crates/rawshift-image/src/metadata/xmp.rs b/crates/rawshift-image-metadata/src/xmp.rs similarity index 98% rename from crates/rawshift-image/src/metadata/xmp.rs rename to crates/rawshift-image-metadata/src/xmp.rs index 531dbf8..0685199 100644 --- a/crates/rawshift-image/src/metadata/xmp.rs +++ b/crates/rawshift-image-metadata/src/xmp.rs @@ -48,7 +48,7 @@ fn validate_xmp(xmp_bytes: &[u8]) -> Result<(), XmpError> { /// end of the data. Since the new box follows all existing boxes (including /// `mdat`), no `iloc` extent offsets need to be patched. /// The payload is validated with `gamut-xmp` first. -#[cfg_attr(not(feature = "avif"), allow(dead_code))] +#[cfg_attr(not(feature = "avif-encode"), allow(dead_code))] pub fn append_xmp_to_avif(xmp_bytes: &[u8], avif_data: Vec) -> Result, XmpError> { validate_xmp(xmp_bytes)?; diff --git a/crates/rawshift-image-nef/Cargo.toml b/crates/rawshift-image-nef/Cargo.toml new file mode 100644 index 0000000..95ae655 --- /dev/null +++ b/crates/rawshift-image-nef/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rawshift-image-nef" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Nikon NEF decoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true, features = ["nef-decode"] } +rawshift-image-ifd = { workspace = true, optional = true } +rawshift-image-ljpeg = { workspace = true, optional = true } +gamut-ifd = { workspace = true, optional = true, features = ["bigtiff"] } + +[features] +default = ["decode"] +decode = ["dep:rawshift-image-ifd", "dep:rawshift-image-ljpeg", "dep:gamut-ifd"] diff --git a/crates/rawshift-image/src/formats/nef.rs b/crates/rawshift-image-nef/src/decoder.rs similarity index 97% rename from crates/rawshift-image/src/formats/nef.rs rename to crates/rawshift-image-nef/src/decoder.rs index 71a5575..4fca325 100644 --- a/crates/rawshift-image/src/formats/nef.rs +++ b/crates/rawshift-image-nef/src/decoder.rs @@ -10,9 +10,12 @@ use std::marker::PhantomData; use gamut_ifd::{Ifd, Value}; -use super::ifd::{self, tags}; -use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth}; -use crate::error::{FormatError, ParseError, RawError, RawResult}; +use rawshift_image_core::image::{ + CfaPattern, Dimensions, RawImage, Rect, white_level_from_bit_depth, +}; +use rawshift_image_core::{FormatError, ParseError, RawError, RawResult}; +use rawshift_image_ifd as ifd; +use rawshift_image_ifd::tags; /// Metadata extracted from a Nikon NEF file. #[derive(Debug, Clone)] @@ -328,7 +331,7 @@ impl NefFile { // LJPEG compressed (6 = old JPEG/LJPEG, 34713 = Nikon LJPEG) 6 | 34713 => { - use crate::codecs::ljpeg::LjpegDecoder; + use rawshift_image_ljpeg::LjpegDecoder; let data = self.read_raw_data()?; let mut decoder = LjpegDecoder::new(); @@ -367,9 +370,9 @@ impl NefFile { } } -impl crate::core::ExtractMetadata for NefFile { - fn extract_metadata(&self) -> crate::core::ImageMetadata { - use crate::core::metadata::*; +impl rawshift_image_core::ExtractMetadata for NefFile { + fn extract_metadata(&self) -> rawshift_image_core::ImageMetadata { + use rawshift_image_core::metadata::*; let m = self.metadata.as_ref(); diff --git a/crates/rawshift-image-nef/src/lib.rs b/crates/rawshift-image-nef/src/lib.rs new file mode 100644 index 0000000..0d6d422 --- /dev/null +++ b/crates/rawshift-image-nef/src/lib.rs @@ -0,0 +1,37 @@ +//! Nikon NEF format support. +#![forbid(unsafe_code)] + +#[cfg(feature = "decode")] +mod decoder; +#[cfg(feature = "decode")] +pub use decoder::*; + +/// Nikon NEF format marker. +pub struct Nef; + +#[cfg(feature = "decode")] +impl rawshift_image_core::FormatSniffer for Nef { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Nef; + fn matches(data: &[u8]) -> bool { + tiff_make(data).is_some_and(|make| make.to_ascii_lowercase().contains("nikon")) + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Nef { + type Options = (); + type Output = rawshift_image_core::RawImage; + fn decode(data: &[u8], _: &()) -> rawshift_image_core::RawResult { + NefFile::parse(std::io::Cursor::new(data))?.decode_raw() + } +} + +#[cfg(feature = "decode")] +fn tiff_make(data: &[u8]) -> Option { + use gamut_ifd::{IfdReader, StreamSource}; + let mut cursor = std::io::Cursor::new(data); + let mut reader = IfdReader::open(StreamSource::new(&mut cursor)).ok()?; + let ifd = reader.read_ifd(reader.first_ifd_offset()).ok()?; + let entry = ifd.entry(rawshift_image_ifd::tags::MAKE)?; + reader.value(entry).ok()?.as_str().map(str::to_owned) +} diff --git a/crates/rawshift-image-png/Cargo.toml b/crates/rawshift-image-png/Cargo.toml new file mode 100644 index 0000000..372f664 --- /dev/null +++ b/crates/rawshift-image-png/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rawshift-image-png" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "PNG decoding and encoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true } +rawshift-image-metadata = { workspace = true, optional = true, features = ["exif"] } +gamut-core = { workspace = true, optional = true } +gamut-png = { workspace = true, optional = true } +serde = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:gamut-png"] +encode = ["dep:gamut-png", "dep:gamut-core", "dep:rawshift-image-metadata"] +serde = ["dep:serde", "rawshift-image-core/serde"] diff --git a/crates/rawshift-image-png/src/lib.rs b/crates/rawshift-image-png/src/lib.rs new file mode 100644 index 0000000..31a0e77 --- /dev/null +++ b/crates/rawshift-image-png/src/lib.rs @@ -0,0 +1,160 @@ +//! PNG format support. +#![forbid(unsafe_code)] + +#[cfg(feature = "decode")] +use rawshift_image_core::FormatError; +use rawshift_image_core::{FormatId, FormatSniffer}; +#[cfg(any(feature = "decode", feature = "encode"))] +use rawshift_image_core::{RawError, RawResult, RgbImage}; + +/// Hostile-input resource limits for PNG decoding. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PngDecodeConfig { + pub max_width: Option, + pub max_height: Option, + pub max_image_bytes: Option, + pub max_metadata_bytes: Option, +} + +/// PNG format marker. +pub struct Png; + +impl FormatSniffer for Png { + const FORMAT: FormatId = FormatId::Png; + fn matches(data: &[u8]) -> bool { + data.starts_with(b"\x89PNG\r\n\x1a\n") + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Png { + type Options = PngDecodeConfig; + type Output = RgbImage; + fn decode(data: &[u8], options: &Self::Options) -> RawResult { + decode(data, options) + } +} + +#[cfg(feature = "decode")] +pub fn decode(data: &[u8], config: &PngDecodeConfig) -> RawResult { + use gamut_png::PngImage; + let decoded = decoder(config).decode(data).map_err(|error| { + RawError::Format(FormatError::ImageDecode { + format: "PNG", + message: error.to_string(), + }) + })?; + let (width, height) = (decoded.header.width, decoded.header.height); + let samples = match decoded.image { + PngImage::Gray8(image) => image + .as_samples() + .iter() + .flat_map(|&v| [scale(v); 3]) + .collect(), + PngImage::Gray16(image) => image.as_samples().iter().flat_map(|&v| [v; 3]).collect(), + PngImage::GrayAlpha8(image) => image + .as_samples() + .chunks_exact(2) + .flat_map(|px| [scale(px[0]); 3]) + .collect(), + PngImage::GrayAlpha16(image) => image + .as_samples() + .chunks_exact(2) + .flat_map(|px| [px[0]; 3]) + .collect(), + PngImage::Rgb8(image) => image.as_samples().iter().map(|&v| scale(v)).collect(), + PngImage::Rgb16(image) => image.into_samples(), + PngImage::Rgba8(image) => image + .as_samples() + .chunks_exact(4) + .flat_map(|px| [scale(px[0]), scale(px[1]), scale(px[2])]) + .collect(), + PngImage::Rgba16(image) => image + .as_samples() + .chunks_exact(4) + .flat_map(|px| [px[0], px[1], px[2]]) + .collect(), + PngImage::Indexed8(image) => { + let palette = decoded.palette.ok_or_else(|| { + RawError::Format(FormatError::ImageDecode { + format: "PNG", + message: "indexed PNG without a palette".to_owned(), + }) + })?; + image + .as_samples() + .iter() + .flat_map(|&index| { + let [r, g, b] = palette.rgb(index).unwrap_or_default(); + [scale(r), scale(g), scale(b)] + }) + .collect() + } + }; + RgbImage::new(width, height, samples) +} + +#[cfg(feature = "decode")] +fn decoder(config: &PngDecodeConfig) -> gamut_png::PngDecoder { + const SPEC_MAX: u32 = i32::MAX as u32; + let mut decoder = gamut_png::PngDecoder::new(); + if config.max_width.is_some() || config.max_height.is_some() { + decoder = decoder.with_max_dimensions( + config.max_width.unwrap_or(SPEC_MAX), + config.max_height.unwrap_or(SPEC_MAX), + ); + } + if let Some(bytes) = config.max_image_bytes { + decoder = decoder.with_max_image_bytes(bytes); + } + if let Some(bytes) = config.max_metadata_bytes { + decoder = decoder.with_max_metadata_bytes(bytes); + } + decoder +} + +#[cfg(feature = "decode")] +fn scale(value: u8) -> u16 { + u16::from(value) * 257 +} + +#[cfg(feature = "encode")] +impl rawshift_image_core::ImageEncoder for Png { + type Options = (); + type Input = RgbImage; + fn encode_to_writer( + input: &Self::Input, + metadata: &rawshift_image_core::ImageMetadata, + _: &Self::Options, + mut writer: W, + ) -> RawResult<()> { + use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb16}; + use rawshift_image_metadata::{exif::ExifBuilder, icc::IccProfile}; + let error = |error: gamut_core::Error| { + RawError::Encode(rawshift_image_core::EncodeError::Encoding { + format: "PNG", + message: error.to_string(), + }) + }; + let dimensions = Dimensions::new(input.width(), input.height()).map_err(error)?; + let image = ImageRef::::new(input.data(), dimensions).map_err(error)?; + let icc = IccProfile::srgb(); + let mut encoder = + gamut_png::PngEncoder::new().with_icc_profile("ICC Profile", icc.as_bytes()); + if let Ok(exif) = ExifBuilder::new(metadata).build_bytes() { + encoder = encoder.with_exif(&exif); + } + if let Some(xmp) = metadata + .xmp + .as_deref() + .and_then(|value| std::str::from_utf8(value).ok()) + { + encoder = encoder.with_xmp(xmp); + } + let mut output = Vec::new(); + encoder.encode_image(image, &mut output).map_err(error)?; + writer.write_all(&output)?; + Ok(()) + } +} diff --git a/crates/rawshift-image-ppm/Cargo.toml b/crates/rawshift-image-ppm/Cargo.toml new file mode 100644 index 0000000..9413991 --- /dev/null +++ b/crates/rawshift-image-ppm/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "rawshift-image-ppm" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "PPM and Netpbm decoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true } +zune-core = { version = "0.5", optional = true } +zune-ppm = { version = "0.5", optional = true } +serde = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:zune-core", "dep:zune-ppm"] +serde = ["dep:serde", "rawshift-image-core/serde"] diff --git a/crates/rawshift-image-ppm/src/lib.rs b/crates/rawshift-image-ppm/src/lib.rs new file mode 100644 index 0000000..c5f3924 --- /dev/null +++ b/crates/rawshift-image-ppm/src/lib.rs @@ -0,0 +1,82 @@ +//! PPM and Netpbm format support. +#![forbid(unsafe_code)] + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ZunePpmDecodeConfig {} + +pub struct Ppm; +impl rawshift_image_core::FormatSniffer for Ppm { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Ppm; + fn matches(data: &[u8]) -> bool { + data.len() >= 3 + && data[0] == b'P' + && matches!(data[1], b'5' | b'6' | b'7' | b'F' | b'f') + && data[2].is_ascii_whitespace() + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Ppm { + type Options = ZunePpmDecodeConfig; + type Output = rawshift_image_core::RgbImage; + fn decode(data: &[u8], _: &Self::Options) -> rawshift_image_core::RawResult { + use rawshift_image_core::{FormatError, RawError}; + use zune_core::{ + bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions, + result::DecodingResult, + }; + let mut decoder = + zune_ppm::PPMDecoder::new_with_options(ZCursor::new(data), DecoderOptions::default()); + let result = decoder.decode().map_err(|error| { + RawError::Format(FormatError::ImageDecode { + format: "PPM", + message: format!("{error:?}"), + }) + })?; + let (width, height) = decoder + .dimensions() + .map(|(w, h)| (w as u32, h as u32)) + .ok_or_else(|| { + RawError::Format(FormatError::ImageDecode { + format: "PPM", + message: "could not read dimensions".to_owned(), + }) + })?; + let color = decoder.colorspace().unwrap_or(ColorSpace::RGB); + let components = color.num_components(); + let samples = match result { + DecodingResult::U8(v) => v.into_iter().map(|x| u16::from(x) * 257).collect(), + DecodingResult::U16(v) => v, + DecodingResult::F32(v) => v + .into_iter() + .map(|x| (x.clamp(0.0, 1.0) * 65535.0) as u16) + .collect(), + _ => { + return Err(RawError::Format(FormatError::ImageDecode { + format: "PPM", + message: "unexpected pixel depth".to_owned(), + })); + } + }; + let rgb = match color { + ColorSpace::RGB => samples, + ColorSpace::RGBA => samples + .chunks_exact(components) + .flat_map(|p| [p[0], p[1], p[2]]) + .collect(), + ColorSpace::Luma => samples.into_iter().flat_map(|v| [v; 3]).collect(), + ColorSpace::LumaA => samples + .chunks_exact(components) + .flat_map(|p| [p[0]; 3]) + .collect(), + _ => { + return Err(RawError::Format(FormatError::ImageDecode { + format: "PPM", + message: format!("unsupported colorspace: {color:?}"), + })); + } + }; + rawshift_image_core::RgbImage::new(width, height, rgb) + } +} diff --git a/crates/rawshift-image-raf/Cargo.toml b/crates/rawshift-image-raf/Cargo.toml new file mode 100644 index 0000000..f1cccc3 --- /dev/null +++ b/crates/rawshift-image-raf/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "rawshift-image-raf" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Fujifilm RAF decoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true, features = ["raf-decode"] } +tracing = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:tracing"] diff --git a/crates/rawshift-image/src/formats/raf.rs b/crates/rawshift-image-raf/src/decoder.rs similarity index 98% rename from crates/rawshift-image/src/formats/raf.rs rename to crates/rawshift-image-raf/src/decoder.rs index c2fc841..64b2c24 100644 --- a/crates/rawshift-image/src/formats/raf.rs +++ b/crates/rawshift-image-raf/src/decoder.rs @@ -14,8 +14,8 @@ use std::io::{Read, Seek, SeekFrom}; -use crate::core::image::{CfaPattern, Dimensions, RawImage, Rect, XTransPattern}; -use crate::error::{FormatError, RawError, RawResult}; +use rawshift_image_core::image::{CfaPattern, Dimensions, RawImage, Rect, XTransPattern}; +use rawshift_image_core::{FormatError, RawError, RawResult}; use tracing::instrument; /// RAF magic bytes at the beginning of every Fujifilm RAF file. @@ -270,9 +270,9 @@ impl RafFile { } } -impl crate::core::ExtractMetadata for RafFile { - fn extract_metadata(&self) -> crate::core::ImageMetadata { - use crate::core::metadata::*; +impl rawshift_image_core::ExtractMetadata for RafFile { + fn extract_metadata(&self) -> rawshift_image_core::ImageMetadata { + use rawshift_image_core::metadata::*; let m = self.metadata.as_ref(); diff --git a/crates/rawshift-image-raf/src/lib.rs b/crates/rawshift-image-raf/src/lib.rs new file mode 100644 index 0000000..172b0b5 --- /dev/null +++ b/crates/rawshift-image-raf/src/lib.rs @@ -0,0 +1,27 @@ +//! Fujifilm RAF format support. +#![forbid(unsafe_code)] + +#[cfg(feature = "decode")] +mod decoder; +#[cfg(feature = "decode")] +pub use decoder::*; + +/// Fujifilm RAF format marker. +pub struct Raf; + +#[cfg(feature = "decode")] +impl rawshift_image_core::FormatSniffer for Raf { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Raf; + fn matches(data: &[u8]) -> bool { + is_raf(data) + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Raf { + type Options = (); + type Output = rawshift_image_core::RawImage; + fn decode(data: &[u8], _: &()) -> rawshift_image_core::RawResult { + RafFile::parse(std::io::Cursor::new(data))?.decode_raw() + } +} diff --git a/crates/rawshift-image-svg/Cargo.toml b/crates/rawshift-image-svg/Cargo.toml new file mode 100644 index 0000000..b30d83f --- /dev/null +++ b/crates/rawshift-image-svg/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rawshift-image-svg" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "SVG rasterization for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true } +resvg = { version = "0.44", optional = true } +serde = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:resvg"] +serde = ["dep:serde", "rawshift-image-core/serde"] diff --git a/crates/rawshift-image-svg/src/lib.rs b/crates/rawshift-image-svg/src/lib.rs new file mode 100644 index 0000000..ccd109a --- /dev/null +++ b/crates/rawshift-image-svg/src/lib.rs @@ -0,0 +1,61 @@ +//! SVG rasterization support. +#![forbid(unsafe_code)] + +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ResvgDecodeConfig { + pub dpi: f32, +} +impl Default for ResvgDecodeConfig { + fn default() -> Self { + Self { dpi: 96.0 } + } +} + +pub struct Svg; +impl rawshift_image_core::FormatSniffer for Svg { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Svg; + fn matches(data: &[u8]) -> bool { + data.windows(4).any(|bytes| bytes == b" rawshift_image_core::RawResult { + use rawshift_image_core::{FormatError, RawError}; + use resvg::{tiny_skia, usvg}; + let options = usvg::Options { + dpi: config.dpi, + ..Default::default() + }; + let tree = usvg::Tree::from_data(data, &options).map_err(|error| { + RawError::Format(FormatError::ImageDecode { + format: "SVG", + message: error.to_string(), + }) + })?; + let size = tree.size().to_int_size(); + let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height()).ok_or_else(|| { + RawError::Format(FormatError::ImageDecode { + format: "SVG", + message: "failed to create pixmap".to_owned(), + }) + })?; + resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); + let samples = pixmap + .data() + .chunks_exact(4) + .flat_map(|p| { + [ + u16::from(p[0]) * 257, + u16::from(p[1]) * 257, + u16::from(p[2]) * 257, + ] + }) + .collect(); + rawshift_image_core::RgbImage::new(size.width(), size.height(), samples) + } +} diff --git a/crates/rawshift-image-tiff/Cargo.toml b/crates/rawshift-image-tiff/Cargo.toml new file mode 100644 index 0000000..5244502 --- /dev/null +++ b/crates/rawshift-image-tiff/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rawshift-image-tiff" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "TIFF decoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true } +tiff = { version = "0.11", optional = true } +serde = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:tiff"] +serde = ["dep:serde", "rawshift-image-core/serde"] diff --git a/crates/rawshift-image-tiff/src/lib.rs b/crates/rawshift-image-tiff/src/lib.rs new file mode 100644 index 0000000..d7a3cc1 --- /dev/null +++ b/crates/rawshift-image-tiff/src/lib.rs @@ -0,0 +1,79 @@ +//! TIFF format support. +#![forbid(unsafe_code)] + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TiffDecodeConfig {} + +pub struct Tiff; + +impl rawshift_image_core::FormatSniffer for Tiff { + const FORMAT: rawshift_image_core::FormatId = rawshift_image_core::FormatId::Tiff; + fn matches(data: &[u8]) -> bool { + data.starts_with(b"II\x2a\0") || data.starts_with(b"MM\0\x2a") + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for Tiff { + type Options = TiffDecodeConfig; + type Output = rawshift_image_core::RgbImage; + fn decode(data: &[u8], _: &Self::Options) -> rawshift_image_core::RawResult { + use rawshift_image_core::{FormatError, RawError}; + use tiff::{ + ColorType, + decoder::{Decoder, DecodingResult}, + }; + let error = |error: tiff::TiffError| { + RawError::Format(FormatError::ImageDecode { + format: "TIFF", + message: error.to_string(), + }) + }; + let mut decoder = Decoder::new(std::io::Cursor::new(data)).map_err(error)?; + let (width, height) = decoder.dimensions().map_err(error)?; + let color = decoder.colortype().map_err(error)?; + let samples = match decoder.read_image().map_err(error)? { + DecodingResult::U8(values) => values.into_iter().map(|v| u16::from(v) * 257).collect(), + DecodingResult::U16(values) => values, + DecodingResult::U32(values) => values.into_iter().map(|v| (v >> 16) as u16).collect(), + DecodingResult::F32(values) => values + .into_iter() + .map(|v| (v.clamp(0.0, 1.0) * 65535.0) as u16) + .collect(), + _ => { + return Err(RawError::Format(FormatError::ImageDecode { + format: "TIFF", + message: "unsupported TIFF sample type".to_owned(), + })); + } + }; + let rgb = match color { + ColorType::RGB(_) => samples, + ColorType::RGBA(_) => samples + .chunks_exact(4) + .flat_map(|p| [p[0], p[1], p[2]]) + .collect(), + ColorType::Gray(_) => samples.into_iter().flat_map(|v| [v; 3]).collect(), + ColorType::GrayA(_) => samples.chunks_exact(2).flat_map(|p| [p[0]; 3]).collect(), + ColorType::CMYK(_) => samples + .chunks_exact(4) + .flat_map(|p| { + let k = 1.0 - f64::from(p[3]) / 65535.0; + [ + ((1.0 - f64::from(p[0]) / 65535.0) * k * 65535.0) as u16, + ((1.0 - f64::from(p[1]) / 65535.0) * k * 65535.0) as u16, + ((1.0 - f64::from(p[2]) / 65535.0) * k * 65535.0) as u16, + ] + }) + .collect(), + _ => { + return Err(RawError::Format(FormatError::ImageDecode { + format: "TIFF", + message: format!("unsupported TIFF color type: {color:?}"), + })); + } + }; + rawshift_image_core::RgbImage::new(width, height, rgb) + } +} diff --git a/crates/rawshift-image-webp/Cargo.toml b/crates/rawshift-image-webp/Cargo.toml new file mode 100644 index 0000000..0fe9b9e --- /dev/null +++ b/crates/rawshift-image-webp/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "rawshift-image-webp" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "WebP decoding and encoding for rawshift" + +[dependencies] +rawshift-image-core = { workspace = true } +rawshift-image-metadata = { workspace = true, optional = true, features = ["exif"] } +gamut-core = { workspace = true, optional = true } +gamut-metadata = { workspace = true, optional = true } +gamut-webp = { workspace = true, optional = true } +gamut-xmp = { workspace = true, optional = true } +serde = { workspace = true, optional = true } + +[features] +default = ["decode"] +decode = ["dep:gamut-core", "dep:gamut-metadata", "dep:gamut-webp", "dep:rawshift-image-metadata"] +encode = ["dep:gamut-core", "dep:gamut-metadata", "dep:gamut-webp", "dep:gamut-xmp", "dep:rawshift-image-metadata"] +serde = ["dep:serde", "rawshift-image-core/serde"] diff --git a/crates/rawshift-image-webp/src/lib.rs b/crates/rawshift-image-webp/src/lib.rs new file mode 100644 index 0000000..69114e8 --- /dev/null +++ b/crates/rawshift-image-webp/src/lib.rs @@ -0,0 +1,159 @@ +//! Pure-Rust WebP decoding, encoding, and metadata support via `gamut-webp`. +#![forbid(unsafe_code)] + +use rawshift_image_core::{FormatId, FormatSniffer}; +#[cfg(any(feature = "decode", feature = "encode"))] +use rawshift_image_core::{RawError, RawResult, RgbImage}; + +/// Configuration for the pure-Rust `gamut-webp` decoder. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct WebpDecodeConfig {} + +/// WebP format marker. +pub struct WebP; + +impl FormatSniffer for WebP { + const FORMAT: FormatId = FormatId::WebP; + + fn matches(data: &[u8]) -> bool { + data.len() >= 12 && data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" + } +} + +#[cfg(feature = "decode")] +impl rawshift_image_core::ImageDecoder for WebP { + type Options = WebpDecodeConfig; + type Output = RgbImage; + + fn decode(data: &[u8], options: &Self::Options) -> RawResult { + decode(data, options) + } +} + +/// Decode a WebP still image into 16-bit RGB samples. +#[cfg(feature = "decode")] +pub fn decode(data: &[u8], _config: &WebpDecodeConfig) -> RawResult { + use gamut_core::{DecodeImage, ImageBuf, Rgb8}; + use gamut_webp::WebpDecoder; + use rawshift_image_core::FormatError; + + let decoded: ImageBuf = WebpDecoder::new().decode_image(data).map_err(|error| { + RawError::Format(FormatError::ImageDecode { + format: "WebP", + message: error.to_string(), + }) + })?; + let dimensions = decoded.dimensions(); + let samples = decoded + .as_samples() + .iter() + .map(|&value| u16::from(value) * 257) + .collect(); + RgbImage::new(dimensions.width, dimensions.height, samples) +} + +#[cfg(feature = "encode")] +impl rawshift_image_core::ImageEncoder for WebP { + type Options = (); + type Input = RgbImage; + + fn encode_to_writer( + input: &Self::Input, + metadata: &rawshift_image_core::ImageMetadata, + _: &Self::Options, + mut writer: W, + ) -> RawResult<()> { + use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; + use gamut_webp::WebpEncoder; + use rawshift_image_core::EncodeError; + use rawshift_image_metadata::{exif::ExifBuilder, icc::IccProfile}; + + let encoding_error = |error: gamut_core::Error| { + RawError::Encode(EncodeError::Encoding { + format: "WebP", + message: error.to_string(), + }) + }; + let dimensions = Dimensions::new(input.width(), input.height()).map_err(encoding_error)?; + let samples: Vec = input + .data() + .iter() + .map(|value| (value >> 8) as u8) + .collect(); + let image = ImageRef::::new(&samples, dimensions).map_err(encoding_error)?; + + let icc = IccProfile::srgb(); + let mut encoder = WebpEncoder::lossy(75).with_icc_profile(icc.as_bytes()); + if let Ok(exif) = ExifBuilder::new(metadata).build_bytes() { + encoder = encoder.with_exif(&exif); + } + if let Some(xmp) = metadata.xmp.as_deref() + && gamut_xmp::XmpMeta::from_packet(xmp).is_ok() + { + encoder = encoder.with_xmp(xmp); + } + + let output = encoder.encode_to_vec(image).map_err(encoding_error)?; + writer.write_all(&output)?; + Ok(()) + } +} + +/// Extract EXIF, ICC, and XMP metadata from a WebP container. +#[cfg(any(feature = "decode", feature = "encode"))] +pub fn read_metadata(data: &[u8]) -> rawshift_image_core::ImageMetadata { + use gamut_metadata::{Metadata, MetadataBlock}; + + let Ok(metadata) = gamut_webp::metadata(data) else { + return rawshift_image_core::ImageMetadata::default(); + }; + let mut blocks = Vec::with_capacity(3); + if let Some(exif) = metadata.exif.as_deref() { + blocks.push(MetadataBlock::Exif(exif)); + } + if let Some(xmp) = metadata.xmp.as_deref() { + blocks.push(MetadataBlock::Xmp(xmp)); + } + if let Some(icc) = metadata.icc.as_deref() { + blocks.push(MetadataBlock::Icc(icc)); + } + let Ok(model) = Metadata::from_blocks(&blocks) else { + return rawshift_image_core::ImageMetadata::default(); + }; + rawshift_image_metadata::bridge::from_gamut(&model) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sniffs_webp_container() { + assert!(WebP::matches(b"RIFF\x04\x00\x00\x00WEBP")); + assert!(!WebP::matches(b"not a WebP file")); + } + + #[cfg(all(feature = "decode", feature = "encode"))] + #[test] + fn encode_decode_round_trip_preserves_dimensions_and_metadata() { + use rawshift_image_core::{ImageDecoder, ImageEncoder, ImageMetadata}; + + let image = RgbImage::new( + 2, + 2, + vec![ + 0xffff, 0, 0, 0, 0xffff, 0, 0, 0, 0xffff, 0xffff, 0xffff, 0xffff, + ], + ) + .expect("valid image"); + let mut encoded = Vec::new(); + WebP::encode_to_writer(&image, &ImageMetadata::default(), &(), &mut encoded) + .expect("encode WebP"); + + let decoded = WebP::decode(&encoded, &WebpDecodeConfig::default()).expect("decode WebP"); + assert_eq!((decoded.width(), decoded.height()), (2, 2)); + assert_eq!(decoded.data().len(), image.data().len()); + assert!(read_metadata(&encoded).icc_profile.is_some()); + } +} diff --git a/crates/rawshift-image/Cargo.toml b/crates/rawshift-image/Cargo.toml index 4d7e70b..96e5e11 100644 --- a/crates/rawshift-image/Cargo.toml +++ b/crates/rawshift-image/Cargo.toml @@ -21,6 +21,27 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] rawshift-core = { workspace = true } +rawshift-image-core = { workspace = true } +rawshift-image-metadata = { workspace = true } +rawshift-image-ifd = { workspace = true, optional = true } +rawshift-image-ljpeg = { workspace = true, optional = true } +rawshift-image-arw = { workspace = true, default-features = false } +rawshift-image-avif = { workspace = true, default-features = false } +rawshift-image-cr2 = { workspace = true, default-features = false } +rawshift-image-cr3 = { workspace = true, default-features = false } +rawshift-image-crw = { workspace = true, default-features = false } +rawshift-image-dng = { workspace = true, default-features = false } +rawshift-image-nef = { workspace = true, default-features = false } +rawshift-image-raf = { workspace = true, default-features = false } +rawshift-image-heic = { workspace = true, default-features = false } +rawshift-image-gif = { workspace = true, default-features = false } +rawshift-image-jpeg = { workspace = true, default-features = false } +rawshift-image-jxl = { workspace = true, default-features = false } +rawshift-image-png = { workspace = true, default-features = false } +rawshift-image-ppm = { workspace = true, default-features = false } +rawshift-image-svg = { workspace = true, default-features = false } +rawshift-image-tiff = { workspace = true, default-features = false } +rawshift-image-webp = { workspace = true, default-features = false } gamut-core = { workspace = true } # gamut-icc backs the sRGB profile construction and validation in # `metadata/icc.rs`, which compiles unconditionally (the `IccProfile` type is @@ -173,30 +194,30 @@ raf = ["raf-decode"] # Decode covers baseline + progressive streams (grayscale/YCbCr/RGB/CMYK/YCCK); # encode writes baseline or progressive 8-bit DCT JPEG. `exif` supplies the # EXIF builder/parser for the APP1 segment gamut-jpeg reads and writes. -jpeg-decode = ["dep:gamut-jpeg", "exif"] -jpeg-encode = ["dep:gamut-jpeg", "exif"] +jpeg-decode = ["dep:gamut-jpeg", "exif", "rawshift-image-jpeg/decode"] +jpeg-encode = ["dep:gamut-jpeg", "exif", "rawshift-image-jpeg/encode"] # PNG is backed directly by gamut-png (no tier-4 layer): like JPEG and the RAW # direction features, each half has a single gamut-backed implementation, so # the format-direction feature itself pulls the dependency. Decode covers # every PNG colour type and bit depth (incl. Adam7 interlace) with # hostile-input resource guards, and surfaces eXIf/iCCP/XMP chunk metadata; # `exif` supplies the parser that maps the eXIf payload into `ImageMetadata`. -png-decode = ["dep:gamut-png", "exif"] +png-decode = ["dep:gamut-png", "exif", "rawshift-image-png/decode"] # Encode: `exif` supplies the ExifBuilder used to serialise the eXIf chunk # payload. -png-encode = ["dep:gamut-png", "exif"] +png-encode = ["dep:gamut-png", "exif", "rawshift-image-png/encode"] # WebP is backed directly by gamut-webp (pure Rust VP8/VP8L plus RIFF # EXIF/XMP/ICC metadata in both directions). -webp-decode = ["dep:gamut-webp", "exif"] -webp-encode = ["dep:gamut-webp", "exif"] +webp-decode = ["dep:gamut-webp", "exif", "rawshift-image-webp/decode"] +webp-encode = ["dep:gamut-webp", "exif", "rawshift-image-webp/encode"] # JXL is backed directly by gamut-jxl (no tier-4 layer): like PNG and # the RAW direction features, each half has a single gamut-backed # implementation, so the format-direction feature itself pulls the dependency. # Decode wraps the pure-Rust jxl-rs decoder; encode wraps the reference libjxl # (statically built and linked by gamut-jxl-sys — cmake + C++ toolchain). # `exif` supplies the ExifBuilder used for the container `Exif` box on encode. -jxl-decode = ["dep:gamut-jxl", "gamut-jxl/decode"] -jxl-encode = ["dep:gamut-jxl", "gamut-jxl/encode", "exif"] +jxl-decode = ["dep:gamut-jxl", "gamut-jxl/decode", "rawshift-image-jxl/decode"] +jxl-encode = ["dep:gamut-jxl", "gamut-jxl/encode", "exif", "rawshift-image-jxl/encode"] gif-decode = ["gif-decode-gif"] tiff-decode = ["tiff-decode-tiff"] # AVIF decode is backed directly by gamut-avif (no tier-4 layer): container @@ -208,7 +229,7 @@ tiff-decode = ["tiff-decode-tiff"] # (visualcommons/gamut#259). `exif` supplies the parser for the container's # Exif item; `gamut-isobmff` surfaces in the ICC-extraction path # (`AvifItem::colour()`). -avif-decode = ["dep:gamut-avif", "dep:gamut-isobmff", "exif"] +avif-decode = ["dep:gamut-avif", "dep:gamut-isobmff", "exif", "rawshift-image-avif/decode"] # AVIF encode is backed directly by gamut-avif (no tier-4 layer): like # PNG and the RAW direction features, it has a single gamut-backed # implementation, so the format-direction feature itself pulls the dependency. @@ -216,28 +237,28 @@ avif-decode = ["dep:gamut-avif", "dep:gamut-isobmff", "exif"] # lossless or lossy; 10/12-bit output is pending visualcommons/gamut#251. # `exif` supplies the ExifBuilder and the `gamut-xmp` validation used when # splicing EXIF/ICC/XMP items into the encoded container. -avif-encode = ["dep:gamut-avif", "exif"] +avif-encode = ["dep:gamut-avif", "exif", "rawshift-image-metadata/avif-encode", "rawshift-image-avif/encode"] # HEIC decode is backed directly by gamut-heic (no tier-4 layer): container # parsing, metadata, and auxiliary-image enumeration always work; pixel decode # additionally needs a hardware HEVC decoder (`hw`/`hw-*`, see below) and # reports `RawError::HwDecoderUnavailable` without one. `exif` supplies the # parser for the container's Exif item. -heic-decode = ["dep:gamut-heic", "dep:gamut-isobmff", "exif"] +heic-decode = ["dep:gamut-heic", "dep:gamut-isobmff", "exif", "rawshift-image-heic/decode"] svg-decode = ["svg-decode-resvg"] ppm-decode = ["ppm-decode-zune"] -arw-decode = ["ifd-parser"] -cr2-decode = ["ifd-parser"] -cr3-decode = ["ifd-parser"] -crw-decode = [] +arw-decode = ["ifd-parser", "dep:rawshift-image-ljpeg", "rawshift-image-arw/decode"] +cr2-decode = ["ifd-parser", "dep:rawshift-image-ljpeg", "rawshift-image-core/cr2-decode", "rawshift-image-cr2/decode"] +cr3-decode = ["ifd-parser", "rawshift-image-core/cr3-decode", "rawshift-image-cr3/decode"] +crw-decode = ["rawshift-image-core/crw-decode", "rawshift-image-crw/decode"] # DNG decode is backed by gamut-dng (raw image, levels, camera profile; JPEG # XL tile decode is built into gamut-dng, so `jxl-decode` is no longer pulled). # `ifd-parser` is additionally pulled because format sniffing # (`RawFile::detect_format`) and the EXIF/GPS/thumbnail tree walk read the # container through gamut-ifd. -dng-decode = ["ifd-parser", "dep:gamut-dng"] -dng-encode = ["dep:gamut-dng"] -nef-decode = ["ifd-parser"] -raf-decode = [] +dng-decode = ["ifd-parser", "dep:gamut-dng", "rawshift-image-dng/decode"] +dng-encode = ["dep:gamut-dng", "rawshift-image-dng/encode"] +nef-decode = ["ifd-parser", "dep:rawshift-image-ljpeg", "rawshift-image-core/nef-decode", "rawshift-image-nef/decode"] +raf-decode = ["rawshift-image-core/raf-decode", "rawshift-image-raf/decode"] # ── Tier 4: implementation features (four retained aliases only) ────────────── # The gamut migrations collapsed this tier for every gamut-backed format — the @@ -255,16 +276,16 @@ raf-decode = [] # Each also enables its tier-3 direction feature, so it can be selected # standalone. When a blocked migration unblocks, its alias dissolves into the # direction feature exactly like the gamut-backed formats above. -gif-decode-gif = ["gif-decode", "dep:gif"] -tiff-decode-tiff = ["tiff-decode", "dep:tiff", "exif"] -svg-decode-resvg = ["svg-decode", "dep:resvg"] -ppm-decode-zune = ["ppm-decode", "dep:zune-ppm", "zune-runtime"] +gif-decode-gif = ["gif-decode", "dep:gif", "rawshift-image-gif/decode"] +tiff-decode-tiff = ["tiff-decode", "dep:tiff", "exif", "rawshift-image-tiff/decode"] +svg-decode-resvg = ["svg-decode", "dep:resvg", "rawshift-image-svg/decode"] +ppm-decode-zune = ["ppm-decode", "dep:zune-ppm", "zune-runtime", "rawshift-image-ppm/decode"] # ── Tier 5: infrastructure / linking features ───────────────────────────────── # gamut-ifd TIFF/IFD structure engine (used by the migrated TIFF-based RAW # decoders and by format detection). -ifd-parser = ["dep:gamut-ifd"] -serde = ["dep:serde", "rawshift-core/serde"] +ifd-parser = ["dep:gamut-ifd", "dep:rawshift-image-ifd", "rawshift-image-metadata/ifd-parser"] +serde = ["dep:serde", "rawshift-core/serde", "rawshift-image-core/serde", "rawshift-image-dng/serde", "rawshift-image-avif/serde", "rawshift-image-gif/serde", "rawshift-image-heic/serde", "rawshift-image-jpeg/serde", "rawshift-image-jxl/serde", "rawshift-image-png/serde", "rawshift-image-ppm/serde", "rawshift-image-svg/serde", "rawshift-image-tiff/serde", "rawshift-image-webp/serde"] # `zune-core` codec primitives (bit depth, colorspace, decoder/encoder option # builders). Pulled by the zune-backed PPM impl feature (`zune-ppm` is a # permanent exception to the gamut migration — see AGENTS.md). @@ -273,7 +294,7 @@ zune-runtime = ["dep:zune-core"] # blob model, `gamut-metadata` + `gamut-xmp` for the unified-model bridge). # Pulled by format impl features that parse embedded EXIF on decode or write it # on encode. -exif = ["dep:gamut-exif", "dep:gamut-metadata", "dep:gamut-xmp"] +exif = ["dep:gamut-exif", "dep:gamut-metadata", "dep:gamut-xmp", "rawshift-image-metadata/exif"] # Hardware still-frame decode (HEVC for HEIC; AV1 for AVIF). These are # **verified** feature flags — see docs/SUPPORT.md for the permanent # target/API matrix: @@ -283,10 +304,10 @@ exif = ["dep:gamut-exif", "dep:gamut-metadata", "dep:gamut-xmp"] # `hw-*` — pin one explicit backend; `compile_error!` on any other target. # Without any of them, `heic`/`avif` are valid container/metadata-only builds # whose pixel decode returns `RawError::HwDecoderUnavailable`. -hw = ["dep:rawshift-hwdec", "rawshift-hwdec/hw"] -hw-videotoolbox = ["hw", "rawshift-hwdec/videotoolbox"] -hw-vaapi = ["hw", "rawshift-hwdec/vaapi"] -hw-mediacodec = ["hw", "rawshift-hwdec/mediacodec"] +hw = ["dep:rawshift-hwdec", "rawshift-hwdec/hw", "rawshift-image-avif/hw", "rawshift-image-heic/hw"] +hw-videotoolbox = ["hw", "rawshift-hwdec/videotoolbox", "rawshift-image-avif/hw-videotoolbox", "rawshift-image-heic/hw-videotoolbox"] +hw-vaapi = ["hw", "rawshift-hwdec/vaapi", "rawshift-image-avif/hw-vaapi", "rawshift-image-heic/hw-vaapi"] +hw-mediacodec = ["hw", "rawshift-hwdec/mediacodec", "rawshift-image-avif/hw-mediacodec", "rawshift-image-heic/hw-mediacodec"] [[bench]] name = "decode" diff --git a/crates/rawshift-image/README.md b/crates/rawshift-image/README.md index a161186..0f47044 100644 --- a/crates/rawshift-image/README.md +++ b/crates/rawshift-image/README.md @@ -10,6 +10,13 @@ depending on `rawshift-image` directly. Depend on this crate directly only when you need fine-grained control — individual formats, alternative codec backends, or an explicit hardware-decode backend pin (`hw-*`). +Format implementations live in independently publishable `rawshift-image-*` +leaf crates. This crate is the compatibility aggregator: its existing features +and APIs delegate to those leaves, while processing, transforms, camera data, +and the end-to-end RAW development pipeline remain here. Leaf crates share the +types and `FormatSniffer`/`ImageDecoder`/`ImageEncoder` contracts from +`rawshift-image-core`. + ## Format Support | Format | Decoding | Encoding | Notes | diff --git a/crates/rawshift-image/src/codecs/arw.rs b/crates/rawshift-image/src/codecs/arw.rs deleted file mode 100644 index aab93f8..0000000 --- a/crates/rawshift-image/src/codecs/arw.rs +++ /dev/null @@ -1 +0,0 @@ -//! Sony ARW specific functionality diff --git a/crates/rawshift-image/src/codecs/mod.rs b/crates/rawshift-image/src/codecs/mod.rs deleted file mode 100644 index 17aa4b2..0000000 --- a/crates/rawshift-image/src/codecs/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[cfg(feature = "arw-decode")] -pub(crate) mod arw; -#[cfg(any(feature = "arw-decode", feature = "cr2-decode", feature = "nef-decode"))] -pub(crate) mod bit_pump; -// Lossless JPEG stays in-repo only for the CR2/NEF/ARW paths; the DNG path -// uses gamut-dng's internal (and public) lossless-JPEG implementation. -#[cfg(any(feature = "arw-decode", feature = "cr2-decode", feature = "nef-decode"))] -pub(crate) mod ljpeg; diff --git a/crates/rawshift-image/src/core/mod.rs b/crates/rawshift-image/src/core/mod.rs index 36f4cc4..dfbcee3 100644 --- a/crates/rawshift-image/src/core/mod.rs +++ b/crates/rawshift-image/src/core/mod.rs @@ -7,13 +7,11 @@ //! validated `ImageBuf`. [`IccProfile`] is additionally surfaced here //! from the internal `metadata` module. -mod rgb_image; - pub use rawshift_core::*; -pub use rgb_image::RgbImage; +pub use rawshift_image_core::RgbImage; // Re-export IccProfile from the internal metadata module so it remains // publicly accessible under `core` as before the workspace split. // Its internals are built on `gamut_icc` (metadata-stack migration, #19); // the wrapper type stays so the container-append API keeps its home. -pub use crate::metadata::icc::IccProfile; +pub use rawshift_image_metadata::icc::IccProfile; diff --git a/crates/rawshift-image/src/error.rs b/crates/rawshift-image/src/error.rs index def1cc2..fb58ead 100644 --- a/crates/rawshift-image/src/error.rs +++ b/crates/rawshift-image/src/error.rs @@ -1,287 +1,3 @@ -//! Error types for RAW image processing. -//! -//! This module defines comprehensive error types for TIFF parsing, -//! format-specific errors, and I/O errors. -//! -//! Errors are organized into categories: -//! - [`ParseError`] — TIFF/binary parse issues -//! - [`FormatError`] — Format-specific decode failures -//! - [`ProcessingError`] — Demosaic, color, tonemap -//! - [`EncodeError`] — Output encoding -//! - [`RawError::Unsupported`] — Feature not implemented +//! Compatibility re-exports for the canonical image error contracts. -use std::io; -use thiserror::Error; - -use crate::core::BitDepth; - -/// Main error type for the rawshift library. -#[derive(Debug, Error)] -pub enum RawError { - /// I/O error during file operations. - #[error("I/O error: {0}")] - Io(#[from] io::Error), - - /// TIFF/binary parse error. - #[error(transparent)] - Parse(#[from] ParseError), - - /// Format-specific decode error. - #[error(transparent)] - Format(#[from] FormatError), - - /// Processing pipeline error. - #[error(transparent)] - Processing(#[from] ProcessingError), - - /// Output encoding error. - #[error(transparent)] - Encode(#[from] EncodeError), - - /// Feature not yet implemented. - #[error("Unsupported: {0}")] - Unsupported(String), - - /// Error surfaced by a gamut primitive (buffer/dimension validation, - /// codec-independent invariants). - /// - /// `context` names the rawshift operation that invoked gamut, since the - /// upstream error alone rarely identifies the call site (structured - /// diagnostic context upstream is visualcommons/gamut#254). - #[error("{context}: {source}")] - Gamut { - /// The rawshift operation that invoked gamut. - context: &'static str, - /// The underlying gamut error. - #[source] - source: gamut_core::Error, - }, - - /// Pixel decode was requested for a hardware-decoded codec (HEVC/HEIC, - /// AV1/AVIF) but no hardware decoder is available — none compiled in - /// (build without `hw`/`hw-*`), the target has no hardware decode API - /// (see `docs/SUPPORT.md`), or the runtime probe failed. - /// - /// Container parsing, metadata, and auxiliary-image enumeration always - /// work regardless; only pixel decode fails with this error. Probe - /// availability up front with `formats::heic_hw_decode_available()` - /// (requires the `heic` feature). - #[error("no hardware decoder available for {codec}: {reason}")] - HwDecoderUnavailable { - /// The codec that needed a hardware decoder (e.g. `"HEVC"`). - codec: &'static str, - /// Why no decoder is available. - reason: String, - }, -} - -impl RawError { - /// Wrap a gamut error with the rawshift operation it occurred in. - pub fn gamut(context: &'static str, source: gamut_core::Error) -> Self { - RawError::Gamut { context, source } - } -} - -/// TIFF and binary parse errors. -#[derive(Debug, Error)] -pub enum ParseError { - /// Invalid TIFF magic number. - #[error("Invalid TIFF magic number: expected {expected}, found {found}")] - InvalidMagic { - /// Expected magic number - expected: u16, - /// Actual magic number found - found: u16, - }, - - /// Invalid byte order marker. - #[error("Invalid byte order marker: 0x{0:04X} (expected 'II' or 'MM')")] - InvalidByteOrder(u16), - - /// Invalid or malformed IFD. - #[error("Invalid IFD at offset {offset}: {reason}")] - InvalidIfd { - /// Offset where the IFD was expected - offset: u64, - /// Description of what's wrong - reason: String, - }, - - /// Required tag not found, identified by its raw 16-bit id. - /// - /// Used by the gamut-ifd-based decoders, which address tags numerically. - #[error("Required tag not found: 0x{0:04X}")] - MissingTag(u16), - - /// Offset exceeds file boundaries. - #[error("Offset out of bounds: offset {offset} + size {size} exceeds file size {file_size}")] - OffsetOutOfBounds { - /// The offset that's out of bounds - offset: u64, - /// Size of data being accessed - size: u64, - /// Total file size - file_size: u64, - }, - - /// Unknown TIFF data type. - #[error("Unknown TIFF data type: {0}")] - UnknownDataType(u16), - - /// Invalid image dimensions. - #[error("Invalid image dimensions: {width}x{height}")] - InvalidDimensions { - /// Image width - width: u32, - /// Image height - height: u32, - }, - - /// Circular reference detected in IFD chain. - #[error("Circular reference detected in IFD chain at offset {0}")] - CircularReference(u64), - - /// Binary parse error (from format-specific binary parsers). - #[error("Binary parse error: {0}")] - BinaryParse(String), -} - -/// Format-specific decode errors. -#[derive(Debug, Error)] -pub enum FormatError { - /// Canon CR2 format error. - #[cfg(feature = "cr2-decode")] - #[error("CR2 error: {0}")] - Cr2(String), - - /// Nikon NEF format error. - #[cfg(feature = "nef-decode")] - #[error("NEF error: {0}")] - Nef(String), - - /// Canon CR3/ISOBMFF format error. - #[cfg(feature = "cr3-decode")] - #[error("CR3 error: {0}")] - Cr3(String), - - /// Fujifilm RAF format error. - #[cfg(feature = "raf-decode")] - #[error("RAF error: {0}")] - Raf(String), - - /// Canon CRW/CIFF format error. - #[cfg(feature = "crw-decode")] - #[error("CRW error: {0}")] - Crw(String), - - /// Standard image format decoding error. - #[error("Image decode error ({format}): {message}")] - ImageDecode { - /// Format name (e.g., "JPEG", "PNG") - format: &'static str, - /// Error description - message: String, - }, - - /// Decompression error. - #[error("Decompression error: {0}")] - Decompression(String), -} - -/// Processing pipeline errors. -#[derive(Debug, Error)] -pub enum ProcessingError { - /// Demosaicing error. - #[error("Demosaic error: {0}")] - Demosaic(String), - - /// Color processing error. - #[error("Color processing error: {0}")] - Color(String), -} - -/// Output encoding errors. -/// -/// `#[non_exhaustive]`: new encoder backends may introduce new error variants -/// without that being a breaking change. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum EncodeError { - /// Generic encoding/export error. - #[error("Encoding error ({format}): {message}")] - Encoding { - /// Format name (e.g., "JPEG", "PNG") - format: &'static str, - /// Error description - message: String, - }, - - /// The selected encoder does not support the requested output bit depth. - #[error("{format} encoder does not support {requested:?} output")] - UnsupportedBitDepth { - /// Format name (e.g., "JPEG", "AVIF") - format: &'static str, - /// The bit depth that was requested but is not supported. - requested: BitDepth, - }, -} - -/// Result type alias using RawError. -pub type RawResult = Result; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_error_display() { - let err = RawError::Parse(ParseError::InvalidMagic { - expected: 42, - found: 0, - }); - let s = format!("{}", err); - assert!(s.contains("Invalid TIFF magic")); - - let err = RawError::Parse(ParseError::MissingTag(0x0100)); - let s = format!("{}", err); - assert!(s.contains("0x0100")); - } - - #[test] - fn test_io_error_conversion() { - let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found"); - let raw_err: RawError = io_err.into(); - assert!(matches!(raw_err, RawError::Io(_))); - } - - #[test] - fn test_parse_error_conversion() { - let parse_err = ParseError::InvalidByteOrder(0x1234); - let raw_err: RawError = parse_err.into(); - assert!(matches!( - raw_err, - RawError::Parse(ParseError::InvalidByteOrder(0x1234)) - )); - } - - #[cfg(feature = "cr2-decode")] - #[test] - fn test_format_error_conversion() { - let fmt_err = FormatError::Cr2("test error".to_string()); - let raw_err: RawError = fmt_err.into(); - assert!(matches!(raw_err, RawError::Format(FormatError::Cr2(_)))); - } - - #[test] - fn test_encode_error_conversion() { - let enc_err = EncodeError::Encoding { - format: "PNG", - message: "test".to_string(), - }; - let raw_err: RawError = enc_err.into(); - assert!(matches!( - raw_err, - RawError::Encode(EncodeError::Encoding { .. }) - )); - } -} +pub use rawshift_image_core::error::*; diff --git a/crates/rawshift-image/src/formats/mod.rs b/crates/rawshift-image/src/formats/mod.rs index 9b8cc8d..e042324 100644 --- a/crates/rawshift-image/src/formats/mod.rs +++ b/crates/rawshift-image/src/formats/mod.rs @@ -8,32 +8,51 @@ //! backend inventory. #[cfg(feature = "arw-decode")] -pub(crate) mod arw; +pub(crate) mod arw { + pub use rawshift_image_arw::*; +} #[cfg(feature = "avif-decode")] -pub(crate) mod avif; +pub(crate) mod avif { + pub use rawshift_image_avif::*; +} #[cfg(feature = "cr2-decode")] -pub(crate) mod cr2; +pub(crate) mod cr2 { + pub use rawshift_image_cr2::*; +} #[cfg(feature = "cr3-decode")] -pub(crate) mod cr3; +pub(crate) mod cr3 { + pub use rawshift_image_cr3::*; +} #[cfg(feature = "crw-decode")] -pub(crate) mod crw; +pub(crate) mod crw { + pub use rawshift_image_crw::*; +} #[cfg(feature = "dng-decode")] -pub(crate) mod dng; +pub(crate) mod dng { + pub use rawshift_image_dng::*; +} #[cfg(feature = "dng-encode")] -pub(crate) mod dng_export; +pub(crate) mod dng_export { + pub use rawshift_image_dng::*; +} mod encode; pub mod export; #[cfg(feature = "heic-decode")] -pub(crate) mod heic; -// Plane-lifting helpers shared by the HEIC and AVIF hardware adapters. -#[cfg(all(feature = "hw", any(feature = "heic-decode", feature = "avif-decode")))] -pub(crate) mod hw_planes; +pub(crate) mod heic { + pub use rawshift_image_heic::*; +} #[cfg(feature = "ifd-parser")] -pub(crate) mod ifd; +pub(crate) mod ifd { + pub use rawshift_image_ifd::*; +} #[cfg(feature = "nef-decode")] -pub(crate) mod nef; +pub(crate) mod nef { + pub use rawshift_image_nef::*; +} #[cfg(feature = "raf-decode")] -pub(crate) mod raf; +pub(crate) mod raf { + pub use rawshift_image_raf::*; +} pub mod registry; pub(crate) mod standard; diff --git a/crates/rawshift-image/src/formats/standard.rs b/crates/rawshift-image/src/formats/standard.rs index 8ab6b8b..ca3d058 100644 --- a/crates/rawshift-image/src/formats/standard.rs +++ b/crates/rawshift-image/src/formats/standard.rs @@ -3,19 +3,6 @@ //! This module provides decoders for common non-RAW image formats that decode //! directly to RGB pixel data stored in an [`RgbImage`]. -// `Cursor` is used only by the reader-based backends (gif / tiff). -#[cfg(any(feature = "gif-decode", feature = "tiff-decode"))] -use std::io::Cursor; - -#[cfg(feature = "zune-runtime")] -use zune_core::bytestream::ZCursor; -#[cfg(feature = "zune-runtime")] -use zune_core::colorspace::ColorSpace; -#[cfg(feature = "zune-runtime")] -use zune_core::options::DecoderOptions; -#[cfg(feature = "zune-runtime")] -use zune_core::result::DecodingResult; - use crate::core::CodecId; use crate::core::{Dimensions, RgbImage}; use crate::error::{FormatError, RawError, RawResult}; @@ -255,397 +242,57 @@ pub fn detect_standard_format(data: &[u8]) -> Option { } } -// ── Helpers ────────────────────────────────────────────────────────────────── - -/// Scale an 8-bit sample to 16-bit by duplicating the byte in both halves. -/// This is equivalent to `v * 257` and ensures that 255 maps to 65535. -/// -/// Unused when only the JXL decoder is compiled in — gamut-jxl scales -/// internally — hence the feature-precise `allow(dead_code)`. -#[inline(always)] -#[cfg_attr( - not(any( - feature = "gif-decode", - feature = "jpeg-decode", - feature = "png-decode", - feature = "webp-decode", - feature = "tiff-decode", - feature = "ppm-decode", - )), - allow(dead_code) -)] -fn u8_to_u16(v: u8) -> u16 { - (v as u16) * 257 +#[cfg(test)] +#[inline] +fn u8_to_u16(value: u8) -> u16 { + u16::from(value) * 257 } // ── GIF ────────────────────────────────────────────────────────────────────── #[cfg(feature = "gif-decode")] fn decode_gif(data: &[u8]) -> RawResult { - use gif::{ColorOutput, DecodeOptions}; - - let mut opts = DecodeOptions::new(); - opts.set_color_output(ColorOutput::RGBA); - let mut decoder = opts.read_info(Cursor::new(data)).map_err(|e| { - RawError::Format(FormatError::ImageDecode { - format: "GIF", - message: e.to_string(), - }) - })?; - - let canvas_width = decoder.width() as u32; - let canvas_height = decoder.height() as u32; - - let frame = decoder - .read_next_frame() - .map_err(|e| { - RawError::Format(FormatError::ImageDecode { - format: "GIF", - message: e.to_string(), - }) - })? - .ok_or_else(|| { - RawError::Format(FormatError::ImageDecode { - format: "GIF", - message: "no frames in GIF".to_string(), - }) - })?; - - let frame_width = frame.width as usize; - let frame_height = frame.height as usize; - let frame_left = frame.left as usize; - let frame_top = frame.top as usize; - - // Allocate a black canvas and composite the frame (RGBA → RGB, drop alpha). - let mut out = vec![0u16; (canvas_width as usize) * (canvas_height as usize) * 3]; - - let buf = &frame.buffer[..]; - // With ColorOutput::RGBA the buffer contains 4 bytes per pixel. - let expected_rgba = frame_width * frame_height * 4; - if buf.len() < expected_rgba { - return Err(RawError::Format(FormatError::ImageDecode { - format: "GIF", - message: format!( - "frame buffer too small: got {} bytes, expected {} ({}x{}x4)", - buf.len(), - expected_rgba, - frame_width, - frame_height, - ), - })); - } - - for row in 0..frame_height { - let canvas_y = frame_top + row; - if canvas_y >= canvas_height as usize { - break; - } - for col in 0..frame_width { - let canvas_x = frame_left + col; - if canvas_x >= canvas_width as usize { - continue; - } - let src = (row * frame_width + col) * 4; - let dst = (canvas_y * canvas_width as usize + canvas_x) * 3; - out[dst] = u8_to_u16(buf[src]); - out[dst + 1] = u8_to_u16(buf[src + 1]); - out[dst + 2] = u8_to_u16(buf[src + 2]); - // Alpha channel (buf[src + 3]) is intentionally dropped. - } - } - - RgbImage::new(canvas_width, canvas_height, out) + use rawshift_image_core::ImageDecoder; + rawshift_image_gif::Gif::decode(data, &Default::default()) } -// ── JPEG ───────────────────────────────────────────────────────────────────── +// ── JPEG ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/// `(v * k) / 255` with correct rounding — the multiplicative K application -/// used for CMYK→RGB (borrowed from stb via zune-jpeg, kept for bit-exact -/// parity with the previous backend). #[cfg(feature = "jpeg-decode")] -#[inline] -fn blinn_8x8(v: u8, k: u8) -> u8 { - let t = i32::from(v) * i32::from(k) + 128; - ((t + (t >> 8)) >> 8) as u8 -} - -#[cfg(feature = "jpeg-decode")] -fn decode_jpeg(data: &[u8], _cfg: &JpegDecodeConfig) -> RawResult { - use gamut_core::{Cmyk8, DecodeImage, ImageBuf, Rgb8}; - use gamut_jpeg::JpegDecoder; - - let jpeg_err = |e: gamut_core::Error| { - RawError::Format(FormatError::ImageDecode { - format: "JPEG", - message: e.to_string(), - }) - }; - - // gamut-jpeg presents four-component (Adobe CMYK/YCCK) streams only as - // `Cmyk8`; everything else — grayscale (replicated), YCbCr, RGB — decodes - // directly as `Rgb8`. The header-only `info` read selects the path. - let info = gamut_jpeg::info(data).map_err(jpeg_err)?; - if info.components == 4 { - let decoded: ImageBuf = JpegDecoder::new().decode_image(data).map_err(jpeg_err)?; - let dims = decoded.dimensions(); - // CMYK→RGB in the Adobe-inverted convention (matching libjpeg and the - // previous zune-jpeg backend): R = C·K/255, G = M·K/255, B = Y·K/255 - // on the stored sample values. - let data_u16: Vec = decoded - .as_samples() - .chunks_exact(4) - .flat_map(|px| { - [ - u8_to_u16(blinn_8x8(px[0], px[3])), - u8_to_u16(blinn_8x8(px[1], px[3])), - u8_to_u16(blinn_8x8(px[2], px[3])), - ] - }) - .collect(); - return RgbImage::new(dims.width, dims.height, data_u16); - } - - let decoded: ImageBuf = JpegDecoder::new().decode_image(data).map_err(jpeg_err)?; - let dims = decoded.dimensions(); - let data_u16: Vec = decoded.as_samples().iter().map(|&v| u8_to_u16(v)).collect(); - RgbImage::new(dims.width, dims.height, data_u16) -} - -// ── PNG ────────────────────────────────────────────────────────────────────── - -/// Builds a `gamut_png::PngDecoder` from a [`PngDecodeConfig`], applying only -/// the guards the caller overrode (a `None` keeps gamut's default). -#[cfg(feature = "png-decode")] -fn build_png_decoder(cfg: &PngDecodeConfig) -> gamut_png::PngDecoder { - // The spec's own dimension bound (§11.2.1): width/height are 1..=2³¹−1. - // Used when only one of the two dimension caps is overridden. - const SPEC_MAX_DIMENSION: u32 = i32::MAX as u32; - - let mut decoder = gamut_png::PngDecoder::new(); - if cfg.max_width.is_some() || cfg.max_height.is_some() { - decoder = decoder.with_max_dimensions( - cfg.max_width.unwrap_or(SPEC_MAX_DIMENSION), - cfg.max_height.unwrap_or(SPEC_MAX_DIMENSION), - ); - } - if let Some(bytes) = cfg.max_image_bytes { - decoder = decoder.with_max_image_bytes(bytes); - } - if let Some(bytes) = cfg.max_metadata_bytes { - decoder = decoder.with_max_metadata_bytes(bytes); - } - decoder +fn decode_jpeg(data: &[u8], cfg: &JpegDecodeConfig) -> RawResult { + rawshift_image_jpeg::decode(data, cfg) } #[cfg(feature = "png-decode")] fn decode_png(data: &[u8], cfg: &PngDecodeConfig) -> RawResult { - use gamut_png::PngImage; - - // gamut-png's rich decode returns the pixels in the file's native layout - // (every colour type and bit depth, palettes carried alongside indices), - // which is then normalised to interleaved RGB u16 here: 16-bit samples - // pass through natively, 8-bit samples scale by 257, greyscale replicates - // across the channels, palettes expand, and alpha/transparency is dropped - // — matching every other standard decoder in this module. The typed - // `DecodeImage` impls are not used because they (correctly) refuse the - // lossy alpha drop. - let decoded = build_png_decoder(cfg).decode(data).map_err(|e| { - RawError::Format(FormatError::ImageDecode { - format: "PNG", - message: e.to_string(), - }) - })?; - - let (w, h) = (decoded.header.width, decoded.header.height); - let data_u16: Vec = match decoded.image { - PngImage::Gray8(img) => img - .as_samples() - .iter() - .flat_map(|&v| [u8_to_u16(v); 3]) - .collect(), - PngImage::Gray16(img) => img.as_samples().iter().flat_map(|&v| [v; 3]).collect(), - PngImage::GrayAlpha8(img) => img - .as_samples() - .chunks_exact(2) - .flat_map(|px| [u8_to_u16(px[0]); 3]) - .collect(), - PngImage::GrayAlpha16(img) => img - .as_samples() - .chunks_exact(2) - .flat_map(|px| [px[0]; 3]) - .collect(), - PngImage::Rgb8(img) => img.as_samples().iter().map(|&v| u8_to_u16(v)).collect(), - PngImage::Rgb16(img) => img.into_samples(), - PngImage::Rgba8(img) => img - .as_samples() - .chunks_exact(4) - .flat_map(|px| [u8_to_u16(px[0]), u8_to_u16(px[1]), u8_to_u16(px[2])]) - .collect(), - PngImage::Rgba16(img) => img - .as_samples() - .chunks_exact(4) - .flat_map(|px| [px[0], px[1], px[2]]) - .collect(), - PngImage::Indexed8(img) => { - // The decoder validated every index against the palette, so the - // lookup cannot fail; a defensive default keeps this panic-free. - // Per-entry tRNS alpha is dropped like every other alpha channel. - let palette = decoded.palette.ok_or_else(|| { - RawError::Format(FormatError::ImageDecode { - format: "PNG", - message: "indexed PNG without a palette".to_string(), - }) - })?; - img.as_samples() - .iter() - .flat_map(|&idx| { - let [r, g, b] = palette.rgb(idx).unwrap_or_default(); - [u8_to_u16(r), u8_to_u16(g), u8_to_u16(b)] - }) - .collect() - } - }; - - RgbImage::new(w, h, data_u16) + rawshift_image_png::decode(data, cfg) } -// ── WebP ───────────────────────────────────────────────────────────────────── +// ── WebP ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── #[cfg(feature = "webp-decode")] fn decode_webp(data: &[u8]) -> RawResult { - use gamut_core::{DecodeImage, ImageBuf, Rgb8}; - use gamut_webp::WebpDecoder; - - let decoded: ImageBuf = WebpDecoder::new().decode_image(data).map_err(|e| { - RawError::Format(FormatError::ImageDecode { - format: "WebP", - message: e.to_string(), - }) - })?; - let dims = decoded.dimensions(); - let data_u16 = decoded - .as_samples() - .iter() - .map(|&value| u8_to_u16(value)) - .collect(); - RgbImage::new(dims.width, dims.height, data_u16) + use rawshift_image_core::ImageDecoder; + rawshift_image_webp::WebP::decode(data, &Default::default()) } // ── JXL ────────────────────────────────────────────────────────────────────── #[cfg(feature = "jxl-decode")] fn decode_jxl(data: &[u8]) -> RawResult { - use gamut_core::{DecodeImage, ImageBuf, Rgb16}; - use gamut_jxl::JxlDecoder; - - // Requesting Rgb16 lets the decoder normalise every stream layout itself: - // grayscale expands to RGB, an alpha channel is dropped, and integer - // samples are scaled to full-range 16-bit (matching `u8_to_u16` for 8-bit - // sources). Animated and premultiplied-alpha streams are rejected upstream. - let decoded: ImageBuf = JxlDecoder::new().decode_image(data).map_err(|e| { - RawError::Format(FormatError::ImageDecode { - format: "JXL", - message: format!("{e}"), - }) - })?; - - let dims = decoded.dimensions(); - RgbImage::new(dims.width, dims.height, decoded.into_samples()) + use rawshift_image_core::ImageDecoder; + rawshift_image_jxl::Jxl::decode(data, &Default::default()) } // ── TIFF ───────────────────────────────────────────────────────────────────── #[cfg(feature = "tiff-decode")] fn decode_tiff(data: &[u8]) -> RawResult { - use tiff::ColorType; - use tiff::decoder::{Decoder, DecodingResult}; - - let cursor = Cursor::new(data); - let mut decoder = Decoder::new(cursor).map_err(|e| { - RawError::Format(FormatError::ImageDecode { - format: "TIFF", - message: format!("{e}"), - }) - })?; - - let (w, h) = decoder.dimensions().map_err(|e| { - RawError::Format(FormatError::ImageDecode { - format: "TIFF", - message: format!("{e}"), - }) - })?; - - let color_type = decoder.colortype().map_err(|e| { - RawError::Format(FormatError::ImageDecode { - format: "TIFF", - message: format!("{e}"), - }) - })?; - - let result = decoder.read_image().map_err(|e| { - RawError::Format(FormatError::ImageDecode { - format: "TIFF", - message: format!("{e}"), - }) - })?; - - // Extract raw samples as u16 values. - let samples_u16: Vec = match result { - DecodingResult::U8(px) => px.iter().map(|&v| u8_to_u16(v)).collect(), - DecodingResult::U16(px) => px, - DecodingResult::U32(px) => px.iter().map(|&v| (v >> 16) as u16).collect(), - DecodingResult::F32(px) => px - .iter() - .map(|&v| (v.clamp(0.0, 1.0) * 65535.0) as u16) - .collect(), - _ => { - return Err(RawError::Format(FormatError::ImageDecode { - format: "TIFF", - message: format!("unsupported TIFF sample type for color type {color_type:?}"), - })); - } - }; - - // Convert to interleaved RGB u16 based on the color type. - let data_u16: Vec = match color_type { - ColorType::RGB(_) => samples_u16, - ColorType::RGBA(_) => samples_u16 - .chunks_exact(4) - .flat_map(|px| [px[0], px[1], px[2]]) - .collect(), - ColorType::Gray(_) => samples_u16.iter().flat_map(|&v| [v, v, v]).collect(), - ColorType::GrayA(_) => samples_u16 - .chunks_exact(2) - .flat_map(|px| [px[0], px[0], px[0]]) - .collect(), - ColorType::CMYK(_) => { - // Simple CMYK→RGB: R = (1-C)*(1-K), G = (1-M)*(1-K), B = (1-Y)*(1-K) - samples_u16 - .chunks_exact(4) - .flat_map(|px| { - let c = px[0] as f64 / 65535.0; - let m = px[1] as f64 / 65535.0; - let y = px[2] as f64 / 65535.0; - let k = px[3] as f64 / 65535.0; - let r = ((1.0 - c) * (1.0 - k) * 65535.0) as u16; - let g = ((1.0 - m) * (1.0 - k) * 65535.0) as u16; - let b = ((1.0 - y) * (1.0 - k) * 65535.0) as u16; - [r, g, b] - }) - .collect() - } - _ => { - return Err(RawError::Format(FormatError::ImageDecode { - format: "TIFF", - message: format!("unsupported TIFF color type: {color_type:?}"), - })); - } - }; - - RgbImage::new(w, h, data_u16) + use rawshift_image_core::ImageDecoder; + rawshift_image_tiff::Tiff::decode(data, &Default::default()) } -// ── AVIF ───────────────────────────────────────────────────────────────────── +// ── AVIF ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── /// Decode an AVIF file: gamut-avif parses the container and drives the /// decode pipeline; the AV1 codestream is decoded by a rawshift-hwdec @@ -686,57 +333,19 @@ fn decode_heic(_data: &[u8]) -> RawResult { #[cfg(feature = "svg-decode")] fn decode_svg(data: &[u8], cfg: &ResvgDecodeConfig) -> RawResult { - use resvg::{tiny_skia, usvg}; - - let options = usvg::Options { - dpi: cfg.dpi, - ..usvg::Options::default() - }; - let tree = usvg::Tree::from_data(data, &options).map_err(|e| { - RawError::Format(FormatError::ImageDecode { - format: "SVG", - message: e.to_string(), - }) - })?; - - let pixmap_size = tree.size().to_int_size(); - let width = pixmap_size.width(); - let height = pixmap_size.height(); - - let mut pixmap = tiny_skia::Pixmap::new(width, height).ok_or_else(|| { - RawError::Format(FormatError::ImageDecode { - format: "SVG", - message: "Failed to create pixmap".to_string(), - }) - })?; - - resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); - - // pixmap contains RGBA u8 data; convert to RGB u16 - let rgba = pixmap.data(); - let data_u16: Vec = rgba - .chunks_exact(4) - .flat_map(|chunk| { - [ - chunk[0] as u16 * 257, // R - chunk[1] as u16 * 257, // G - chunk[2] as u16 * 257, // B - ] - }) - .collect(); - - RgbImage::new(width, height, data_u16) + use rawshift_image_core::ImageDecoder; + rawshift_image_svg::Svg::decode(data, cfg) } #[cfg(not(feature = "svg-decode"))] fn decode_svg(_data: &[u8], _cfg: &ResvgDecodeConfig) -> RawResult { Err(RawError::Format(FormatError::ImageDecode { format: "SVG", - message: "SVG support requires the 'svg' feature flag".to_string(), + message: "SVG support requires the 'svg' feature flag".to_owned(), })) } -// ── APV ────────────────────────────────────────────────────────────────────── +// ── APV ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── fn decode_apv(_data: &[u8]) -> RawResult { // APV (All-intra Predictive Video codec) is an open format developed by Samsung. @@ -752,109 +361,16 @@ fn decode_apv(_data: &[u8]) -> RawResult { // ── PPM ────────────────────────────────────────────────────────────────────── #[cfg(feature = "ppm-decode")] -fn decode_ppm(data: &[u8], _cfg: &ZunePpmDecodeConfig) -> RawResult { - let cursor = ZCursor::new(data); - let mut decoder = zune_ppm::PPMDecoder::new_with_options(cursor, DecoderOptions::default()); - - let result = decoder.decode().map_err(|e| { - RawError::Format(FormatError::ImageDecode { - format: "PPM", - message: format!("{e:?}"), - }) - })?; - - let (w, h) = decoder - .dimensions() - .map(|(w, h)| (w as u32, h as u32)) - .ok_or_else(|| { - RawError::Format(FormatError::ImageDecode { - format: "PPM", - message: "could not read image dimensions after decode".to_string(), - }) - })?; - - let colorspace = decoder.colorspace().unwrap_or(ColorSpace::RGB); - let n = colorspace.num_components(); - - // Convert raw samples to Vec (PFM yields f32 normalised to 0..1). - let samples_u16: Vec = match result { - DecodingResult::U8(px) => px.iter().map(|&v| u8_to_u16(v)).collect(), - DecodingResult::U16(px) => px, - DecodingResult::F32(px) => px - .iter() - .map(|&v| (v.clamp(0.0, 1.0) * 65535.0) as u16) - .collect(), - _ => { - return Err(RawError::Format(FormatError::ImageDecode { - format: "PPM", - message: "unexpected pixel depth in decoded result".to_string(), - })); - } - }; - - // Convert any colorspace to packed RGB u16. - let data_u16: Vec = match colorspace { - ColorSpace::RGB => samples_u16, - ColorSpace::RGBA => samples_u16 - .chunks_exact(n) - .flat_map(|px| [px[0], px[1], px[2]]) - .collect(), - ColorSpace::Luma => samples_u16.iter().flat_map(|&v| [v, v, v]).collect(), - ColorSpace::LumaA => samples_u16 - .chunks_exact(n) - .flat_map(|px| [px[0], px[0], px[0]]) - .collect(), - _ => { - return Err(RawError::Format(FormatError::ImageDecode { - format: "PPM", - message: format!("unsupported PPM colorspace: {colorspace:?}"), - })); - } - }; - - RgbImage::new(w, h, data_u16) +fn decode_ppm(data: &[u8], cfg: &ZunePpmDecodeConfig) -> RawResult { + use rawshift_image_core::ImageDecoder; + rawshift_image_ppm::Ppm::decode(data, cfg) } -// ── Decoder implementation selection ────────────────────────────────────────── +// ── Decoder implementation selection ────────────────────────────────────────────────────────────────────────────────── -/// Per-implementation configuration for the `gamut-png` PNG decoder. -/// -/// Every knob is a hostile-input resource guard; `None` keeps gamut's default. -/// CRC verification of critical chunks and spec conformance are always on — -/// they are not configurable (ancillary chunks with bad CRCs are skipped per -/// PNG §13.1, never errors). -#[derive(Debug, Clone, PartialEq, Eq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct PngDecodeConfig { - /// Reject images wider than this, in pixels, before any allocation. - /// `None` keeps the PNG spec maximum (2³¹ − 1). - pub max_width: Option, - /// Reject images taller than this, in pixels, before any allocation. - /// `None` keeps the PNG spec maximum (2³¹ − 1). - pub max_height: Option, - /// Cap on the decoded sample buffer, in bytes — the guard that bounds - /// peak memory against zlib bombs. `None` keeps gamut's default (64 MiB). - pub max_image_bytes: Option, - /// Cumulative cap on inflated compressed-metadata payloads (iCCP, zTXt, - /// compressed iTXt). Payloads past the budget are skipped, not errors. - /// `None` keeps gamut's default (16 MiB). - pub max_metadata_bytes: Option, -} - -/// Per-implementation configuration for the `resvg` SVG renderer. -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct ResvgDecodeConfig { - /// Dots-per-inch used to resolve physical units (`mm`, `cm`, `in`) in the - /// SVG. Default: `96.0`. - pub dpi: f32, -} +pub use rawshift_image_png::PngDecodeConfig; -impl Default for ResvgDecodeConfig { - fn default() -> Self { - Self { dpi: 96.0 } - } -} +pub use rawshift_image_svg::ResvgDecodeConfig; /// Macro to define an implementation config type that currently exposes no /// tunable parameters. The type is a stable home for future backend-specific @@ -873,14 +389,14 @@ macro_rules! empty_decode_config { }; } -empty_decode_config!(JpegDecodeConfig, "gamut-jpeg"); -empty_decode_config!(WebpDecodeConfig, "gamut-webp"); -empty_decode_config!(JxlDecodeConfig, "gamut-jxl"); -empty_decode_config!(GifDecodeConfig, "gif"); -empty_decode_config!(TiffDecodeConfig, "tiff"); +pub use rawshift_image_gif::GifDecodeConfig; +pub use rawshift_image_jpeg::JpegDecodeConfig; +pub use rawshift_image_jxl::JxlDecodeConfig; +pub use rawshift_image_tiff::TiffDecodeConfig; +pub use rawshift_image_webp::WebpDecodeConfig; empty_decode_config!(AvifDecodeConfig, "gamut-avif + rawshift-hwdec"); empty_decode_config!(HeicDecodeConfig, "gamut-heic + rawshift-hwdec"); -empty_decode_config!(ZunePpmDecodeConfig, "zune-ppm"); +pub use rawshift_image_ppm::ZunePpmDecodeConfig; /// Selects the format a standard image is decoded as, and carries that /// format's decoder configuration. @@ -1592,25 +1108,7 @@ pub fn read_standard_image_metadata( any(feature = "webp-decode", feature = "webp-encode") ))] fn read_webp_metadata(data: &[u8]) -> crate::core::metadata::ImageMetadata { - use gamut_metadata::{Metadata, MetadataBlock}; - - let Ok(meta) = gamut_webp::metadata(data) else { - return crate::core::metadata::ImageMetadata::default(); - }; - let mut blocks = Vec::with_capacity(3); - if let Some(exif) = meta.exif.as_deref() { - blocks.push(MetadataBlock::Exif(exif)); - } - if let Some(xmp) = meta.xmp.as_deref() { - blocks.push(MetadataBlock::Xmp(xmp)); - } - if let Some(icc) = meta.icc.as_deref() { - blocks.push(MetadataBlock::Icc(icc)); - } - let Ok(model) = Metadata::from_blocks(&blocks) else { - return crate::core::metadata::ImageMetadata::default(); - }; - crate::metadata::bridge::from_gamut(&model) + rawshift_image_webp::read_metadata(data) } /// Extract EXIF / ICC / XMP from a JPEG's APP segments via `gamut-jpeg`. diff --git a/crates/rawshift-image/src/lib.rs b/crates/rawshift-image/src/lib.rs index 4513d8f..0d0cf60 100644 --- a/crates/rawshift-image/src/lib.rs +++ b/crates/rawshift-image/src/lib.rs @@ -101,7 +101,6 @@ //! //! See the "Feature Flags" section of the README for the full hierarchy. -pub(crate) mod codecs; pub mod core; pub mod data; pub mod error; diff --git a/crates/rawshift-image/src/metadata/mod.rs b/crates/rawshift-image/src/metadata/mod.rs index 70c42a5..765c67c 100644 --- a/crates/rawshift-image/src/metadata/mod.rs +++ b/crates/rawshift-image/src/metadata/mod.rs @@ -5,12 +5,15 @@ //! images, and the bridge to gamut's unified `Metadata` model. #[cfg(feature = "exif")] -pub mod bridge; +#[allow(unused_imports)] +pub use rawshift_image_metadata::bridge; #[cfg(feature = "exif")] -pub mod exif; -pub mod icc; -pub(crate) mod isobmff; +pub use rawshift_image_metadata::exif; +#[allow(unused_imports)] +pub use rawshift_image_metadata::icc; +#[allow(unused_imports)] +pub(crate) use rawshift_image_metadata::isobmff; // XMP box splicing is only needed by the AVIF encode path — JPEG, PNG, and // JXL embed XMP through their gamut encoders. #[cfg(feature = "avif-encode")] -pub mod xmp; +pub use rawshift_image_metadata::xmp; diff --git a/crates/rawshift-image/src/transforms/mod.rs b/crates/rawshift-image/src/transforms/mod.rs index 8e540d6..d3fbaa2 100644 --- a/crates/rawshift-image/src/transforms/mod.rs +++ b/crates/rawshift-image/src/transforms/mod.rs @@ -36,7 +36,10 @@ pub mod cfa; pub mod color; pub mod denoise; pub mod lens_correction; -pub mod opcodes; +#[cfg(feature = "dng-decode")] +pub mod opcodes { + pub use rawshift_image_dng::opcodes::*; +} pub mod orientation; pub mod simd; pub mod tonemap; diff --git a/docs/V1_DESIGN.md b/docs/V1_DESIGN.md index 7d6076e..38ce991 100644 --- a/docs/V1_DESIGN.md +++ b/docs/V1_DESIGN.md @@ -14,9 +14,11 @@ gamut is consumed as versioned crates.io dependencies managed in the workspace ``` rawshift/ facade — features: image (default), serde, hw, hw-*, full ├── crates/rawshift-core #![forbid(unsafe_code)]; deps: gamut-core, gamut-color, serde(opt) -├── crates/rawshift-image safe except processing/ + transforms/ hot paths; -│ deps: rawshift-core, rawshift-hwdec(opt), gamut codec/metadata crates, -│ gif, resvg, zune-ppm (permanent exceptions), rayon +├── crates/rawshift-image-core shared image/error/capability contracts +├── crates/rawshift-image compatibility aggregator plus processing, transforms, and camera data +├── crates/rawshift-image-* one publishable leaf per format (ARW through WebP) +├── crates/rawshift-image-{metadata,ifd,ljpeg} +│ focused support shared by the format leaves ├── crates/rawshift-hwdec all platform FFI; hardware still-frame decode of HEVC + AV1 │ (VideoToolbox / libva / NDK MediaCodec) └── crates/rawshift-video parked placeholder — excluded from publish set + facade @@ -32,7 +34,7 @@ Modules: `geometry`, `sensor`, `color`, `codec`, `metadata`. | `Size` | deleted → re-export `gamut_core::Dimensions` | | `Point`, `Rect` | kept (`Rect { origin: Point, size: Dimensions }`) | | `RawImage`(+Builder), `CfaPattern`, `XTransPattern`, `white_level_from_bit_depth` | kept unchanged (sensor domain); bridge to `gamut_dng` types lives in rawshift-image | -| `RgbImage` | deleted from core → rawshift-image wrapper over `ImageBuf` | +| `RgbImage` | deleted from rawshift-core → rawshift-image-core wrapper over `ImageBuf` | | `pixel.rs` (`Sample`/`FromF32`/`Rgb`/`Rgba`) | deleted → re-export gamut-core `Sample`/`Pixel` markers; f32 stays transform-internal scratch | | `ColorSpace` | deleted → new `ColorDescription` (CICP primaries+transfer pair; consts `SRGB`, `LINEAR_SRGB` (pipeline working space), `DISPLAY_P3`, `REC2020`, `UNSPECIFIED`; ICC-authoritative spaces such as Adobe RGB map to `UNSPECIFIED` with the preserved ICC profile as the authority); manual serde via CICP code points until gamut derives land | | `BitDepth` | deleted → re-export `gamut_color::BitDepth` (gated on upstream adding `Sixteen`) |