Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ name: CI
on:
push:
branches: [master]
# No branch filter: a pull request targeting any branch runs CI. Stacked PRs
# (one feature branch based on another, retargeted to master once the parent
# lands) would otherwise get no checks at all — not pending ones, none — and
# sit unverified for as long as the parent is open.
pull_request:
branches: [master]

env:
CARGO_TERM_COLOR: always
Expand Down Expand Up @@ -258,6 +261,43 @@ jobs:
name: coverage-report
path: coverage.json

# The demonstration GUI (examples/gallery). A standalone package outside the
# workspace, so no other job builds it: its gamut-cmm git dependency pulls the
# gamut repository's aom/dav1d submodules, a ~1.4 GB checkout that a workspace
# member would impose on every job that resolves the workspace.
#
# Builds and unit-tests only. The window needs a display server, but the
# round-trip logic it drives does not — `--headless` runs the same matrix, and
# the unit tests cover decode/encode/compare and the colour transform.
gallery:
name: Gallery (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.92.0"
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: examples/gallery
# libjxl (via gamut-jxl, pulled by rawshift-image/full) builds from
# source, hence cmake/clang. Nothing is installed for windowing: the
# gallery selects iced's `x11` + `wayland` features, and the latter
# brings `winit/wayland-dlopen`, so libwayland is loaded at runtime
# rather than linked. Dropping those features sends the build into
# `wayland-sys`, which needs libwayland-dev via pkg-config.
- name: Install build dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y cmake clang libclang-dev
- run: cargo fmt --manifest-path examples/gallery/Cargo.toml -- --check
- run: cargo clippy --manifest-path examples/gallery/Cargo.toml --all-targets -- -D warnings
- run: cargo test --manifest-path examples/gallery/Cargo.toml

msrv:
name: MSRV (1.92)
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
/target
# examples/gallery is a standalone package outside the workspace, so it has a
# build directory of its own that the anchored rule above does not cover.
/examples/gallery/target

.DS_Store

Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ dependencies are not permitted because they prevent publishing rawshift.
link to the gamut issue.
- Permanent exceptions (stay on current deps; do not migrate, do not file
upstream issues): GIF (`gif`), SVG (`resvg`), PPM (`zune-ppm`).
- One scoped carve-out to the no-git-dependencies rule: `examples/gallery`, the
demonstration GUI, takes `gamut-cmm` from gamut `master`. The rule exists
because git dependencies prevent publishing; that does not reach a crate
which is `publish = false` **and** outside the workspace, so no published
crate's dependency tree contains it. The carve-out is limited to that one
package and expires when gamut-cmm reaches crates.io. Do not widen it: the
workspace `[workspace.dependencies]` gamut pins stay crates.io-only.
- Supported compilation targets and hardware decode APIs are fixed in
`docs/SUPPORT.md` (with justifications for exclusions) — do not add or
remove targets/APIs; they were decided once at v1.
Expand Down
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,55 @@ from the published [gamut](https://github.com/visualcommons/gamut) crates.
dependency is a registry package. 0.x source compatibility is a non-goal (see
`docs/V1_DESIGN.md`).

### Added

#### Colour management

- `rawshift-image-core::color_resolve` maps what a container declares — a CICP
code-point pair, an embedded ICC profile, or neither — onto a
`ColorDescription`. Surfaced from `rawshift_image::core` as `resolve_color`,
`ContainerColor`, and `is_convertible_to_srgb`.
- `probe_avif_color` / `probe_heic_color`: the primary item's declared colour
space from container parsing alone, with no hardware decoder involved.

#### Examples

- `examples/gallery`, a cross-platform GUI (iced) demonstrating the decode and
encode paths end to end: sources are re-encoded through every selected format
with every parameter exposed, written to the temp directory, decoded back,
and shown side by side with PSNR / max-delta / bit-exact verdicts. Run it
with `just gallery`, or `just gallery-headless` for the same matrix without a
window. It is a standalone package outside the workspace and is never
published; see its README for why, and for the scoped `gamut-cmm` git
carve-out recorded in `AGENTS.md`.

### Changed

All entries below are **breaking**, grouped by area.

#### Colour handling on decode and encode

- **Decode now reports the container's real colour space.** `decode_standard_image`
and `decode_standard_image_with` previously forced every result to
`ColorDescription::SRGB`. PNG, JPEG, WebP, AVIF, and HEIC now tag from the
`cICP` chunk / `colr` box / embedded profile they already parse; the formats
with no colour path keep the sRGB default. A profile with no faithful CICP
expression (Adobe RGB, ProPhoto RGB) resolves to `UNSPECIFIED`, which is what
that value documents, with the profile preserved in
`ImageMetadata::icc_profile`. Callers that assumed decode always yields
`SRGB` must handle `DISPLAY_P3`, `REC2020`, `LINEAR_SRGB`, and `UNSPECIFIED`.
- **`ImageProbe::color_space` reads the file** for JPEG, WebP, AVIF, and HEIC
instead of returning a hardcoded `SRGB`. PNG reports `UNSPECIFIED` — its
`iCCP` chunk is DEFLATE-compressed and gamut-png exposes ancillary chunks
only from a full decode, which a header-only probe must not do
([gamut#379](https://github.com/visualcommons/gamut/issues/379)).
- **Encode preserves the source ICC profile.** Every `embed_icc` path wrote a
synthesised sRGB profile, discarding the source's. An image tagged
`UNSPECIFIED` now carries its own profile through to the output. Images
tagged with a named space still get sRGB, which is what keeps RAW export
correct — `RawFile::process` tags its output `SRGB` while the file's metadata
may hold a camera profile describing the sensor, not the developed image.

#### Package boundaries

- Image formats now live in 17 independently publishable
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ members = [
"crates/rawshift-image-webp",
"crates/rawshift-video",
]
# `examples/gallery` is deliberately NOT a member. It is a `publish = false`
# demonstration GUI whose only git dependency (gamut-cmm, see its manifest)
# drags in the gamut repository's `aom`/`dav1d` submodules — a ~1.4 GB
# checkout. A workspace member would impose that fetch on every job that
# resolves the workspace, including ones excluding it by name, because Cargo
# still reads every member manifest to build the resolve graph. Standalone, it
# is built only by `just gallery*` and the dedicated `gallery` CI job.
exclude = ["examples/gallery"]

[workspace.package]
version = "0.1.1"
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ The key priorities in order are:

<!-- TODO: Add docs on the specific features, etc. on the docs.rs page -->

### Seeing it work

`crates/rawshift-image/examples/` holds small CLI examples (decode, encode,
develop a RAW, dump metadata, inspect an AVIF/HEIC container).

For an end-to-end view there is a GUI, [`examples/gallery`](examples/gallery):
pick images and each is decoded, re-encoded through every selected output
format with every parameter exposed, written to the temp directory, decoded
back, and shown side by side against the source with PSNR / max-delta /
bit-exact verdicts. It also reports which hardware decode backend is active.

```sh
just gallery # the window
just gallery-headless path/to/*.jpg # the same matrix, no window
```

It is a standalone package outside the workspace and is never published — see
its README for why, and for the one scoped `gamut-cmm` git carve-out to the
upstream-first policy.

## Format Support

Rawshift targets both still image and video formats. Image decoding is the
Expand Down
62 changes: 61 additions & 1 deletion crates/rawshift-image-avif/src/decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,19 @@ impl AvifFile {
}
}

/// The colour space the container declared for one item.
///
/// A `colr` box holds either CICP code points (`nclx`) or an ICC profile;
/// AVIF permits a `colr` per item, so each decoded item is tagged from its
/// own box, falling back to the primary item's when it has none — a grid
/// tile or auxiliary typically inherits the primary's colour.
#[cfg(feature = "hw")]
fn item_color(&self, id: u32) -> rawshift_image_core::ColorDescription {
let own = self.image.item(id).as_ref().and_then(declared_color);
let color = own.or_else(|| declared_color(&self.image.primary_item()));
rawshift_image_core::resolve_color(color.unwrap_or_default())
}

/// Decode one item through the hardware AV1 decoder.
#[cfg(feature = "hw")]
fn decode_item(&self, id: u32) -> RawResult<RgbImage> {
Expand All @@ -189,7 +202,11 @@ impl AvifFile {
};
let mut adapter = hw::HwAv1Adapter::new(decoder);
match self.image.decode_item_rgba8(id, &mut adapter) {
Ok(rgba) => rawshift_image_core::hw_planes::rgba8_to_rgb_image(&rgba),
Ok(rgba) => {
let mut image = rawshift_image_core::hw_planes::rgba8_to_rgb_image(&rgba)?;
image.set_color(self.item_color(id));
Ok(image)
}
Err(source) => Err(adapter.into_raw_error(source)),
}
}
Expand All @@ -205,6 +222,49 @@ impl AvifFile {
}
}

/// What one item's `colr` box declares about its colour space.
///
/// A `colr` holds either CICP code points (`nclx`) or an ICC profile. The
/// returned borrow is of the file data, not of `item`, which is why this is a
/// free function with an explicit lifetime rather than a closure.
fn declared_color<'a>(
item: &gamut_avif::AvifItem<'a>,
) -> Option<rawshift_image_core::ContainerColor<'a>> {
match item.colour()? {
ColourInformation::Nclx(nclx) => Some(rawshift_image_core::ContainerColor {
cicp: Some((nclx.colour_primaries, nclx.transfer_characteristics)),
icc: None,
}),
ColourInformation::RestrictedIcc(bytes) | ColourInformation::UnrestrictedIcc(bytes) => {
Some(rawshift_image_core::ContainerColor {
cicp: None,
icc: Some(bytes.as_slice()),
})
}
_ => None,
}
}

/// The colour space a AVIF file declares for its primary item, without
/// decoding pixels.
///
/// Parses the container only — no hardware decoder is involved, so this works
/// in every build. Returns [`ColorDescription::SRGB`] when the file carries no
/// `colr` box, and [`ColorDescription::UNSPECIFIED`] when it cannot be parsed
/// at all or carries a profile with no CICP expression.
///
/// [`ColorDescription::SRGB`]: rawshift_image_core::ColorDescription::SRGB
/// [`ColorDescription::UNSPECIFIED`]: rawshift_image_core::ColorDescription::UNSPECIFIED
#[must_use]
pub fn probe_avif_color(data: &[u8]) -> rawshift_image_core::ColorDescription {
match AvifFile::open(data.to_vec()) {
Ok(file) => rawshift_image_core::resolve_color(
declared_color(&file.image.primary_item()).unwrap_or_default(),
),
Err(_) => rawshift_image_core::ColorDescription::UNSPECIFIED,
}
}

/// Whether AVIF pixel decode can work in this build on this machine: a
/// hardware AV1 decoder is compiled in (`hw`/`hw-*` feature) **and** usable
/// at runtime.
Expand Down
2 changes: 2 additions & 0 deletions crates/rawshift-image-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ categories = ["multimedia::images"]
[dependencies]
rawshift-core = { workspace = true }
gamut-core = { workspace = true }
gamut-color = { workspace = true }
gamut-icc = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true, optional = true }
rawshift-hwdec = { workspace = true, optional = true }
Expand Down
Loading
Loading