Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
107 commits
Select commit Hold shift + click to select a range
fc9e589
feat(png): account every byte of a PNG with deconstruct
justin13888 Sep 1, 2026
27359a2
refactor(png): expose the encoder stages behind test-support
justin13888 Sep 1, 2026
92a1474
chore(png): benchmark encode size, bpp and per-stage throughput
justin13888 Sep 1, 2026
78466a1
test(png): pin the output size against libpng at maximum compression
justin13888 Sep 1, 2026
ded5128
feat(png): opt-in cleanup of invisible pixel colour
justin13888 Sep 1, 2026
6b31ab9
fix(png): keep the palette only when it is actually smaller
justin13888 Sep 1, 2026
85beb2f
perf(png): accelerate CRC-32 and the scanline filter loops
justin13888 Sep 1, 2026
fddc749
docs: record the gamut-png efficiency baseline and the benchmarking c…
justin13888 Sep 1, 2026
1cc51fd
test(png): close the mutation gaps in the new efficiency code
justin13888 Sep 1, 2026
f360e51
feat(cli): inspect PNG byte accounting
justin13888 Sep 1, 2026
54eb160
feat(png): reduce binary alpha to a tRNS colour key
justin13888 Sep 1, 2026
1529ab0
docs(png): record the colour key in the axis scorecard
justin13888 Sep 1, 2026
364efa9
feat(png): order the palette, and close the colour-key mutation gaps
justin13888 Sep 1, 2026
cb1c377
docs(png): refresh the efficiency tables after palette ordering
justin13888 Sep 1, 2026
4fa858b
feat(png): entropy and bigram filter heuristics
justin13888 Sep 1, 2026
319c345
fix(png): emit a filtered row when every filter candidate ties
justin13888 Sep 1, 2026
ea4a9e2
refactor(png): read the bigram index as one big-endian pair
justin13888 Sep 1, 2026
73e9c0b
refactor(png): make the colour-key arms total instead of unreachable
justin13888 Sep 1, 2026
0c27e29
test(png): separate palette ordering from discovery order
justin13888 Sep 1, 2026
934a76f
perf(png): index the chunk tally by type
justin13888 Sep 1, 2026
25b1a14
feat(png): clean invisible colour on the 16-bit paths too
justin13888 Sep 1, 2026
dbb0d80
docs: settle the counter rule, the png authority row and two counts
justin13888 Sep 1, 2026
82220cc
fix(png): budget the quantity the decoder budgets
justin13888 Sep 1, 2026
c16ac22
test(png): cover the greyscale colour key
justin13888 Sep 1, 2026
ef73616
test(png): pin the size race a colour key can lose
justin13888 Sep 1, 2026
589261f
test(png): re-pin sub-byte indexed auto-reduce
justin13888 Sep 1, 2026
8dcac02
fix(png-cli): say why the filter scan was skipped
justin13888 Sep 1, 2026
0d680a8
docs(cli): state what inspect's exit code means per format
justin13888 Sep 1, 2026
e858818
refactor(png): delete choose_min_sum_abs
justin13888 Sep 1, 2026
9ca0f19
test(png): derive every size budget from its measurement
justin13888 Sep 1, 2026
8e9f038
fix(png): race the cleaned encoding instead of assuming it wins
justin13888 Sep 1, 2026
448c3f6
docs(png): say which efficiency tables are gated and which only report
justin13888 Sep 2, 2026
c831147
style(png): drop a stray blank line in the size contract
justin13888 Sep 2, 2026
c04b8d4
chore: merge origin/master into feat/224-png-encoder-efficiency
justin13888 Sep 2, 2026
e2c38fb
test(png): pin the cleanup tie-break and the entropy weighting
justin13888 Sep 2, 2026
7593fe5
feat(png): bound the deconstruct walk and name what it actually read
justin13888 Sep 2, 2026
a0bde8e
test(png): pin the walk's ceilings, its saturation and the unread ver…
justin13888 Sep 2, 2026
cd70f78
fix(cli): gate inspect on what it read, and bound the lists it prints
justin13888 Sep 2, 2026
b20f9d4
feat(png): seal FilterStrategy, and clear only the bigram words a row…
justin13888 Sep 2, 2026
5a363fc
refactor(png): drop a palette sort key that cannot change the order
justin13888 Sep 2, 2026
bec1e5b
test(png): pin the greyscale colour key the race declines
justin13888 Sep 2, 2026
423a0e6
docs(png): correct the cost model and the size claim against the encoder
justin13888 Sep 2, 2026
332af8d
docs: record the crc32fast approval for gamut-png
justin13888 Sep 2, 2026
97567f5
test(png): kill the five mutants the new walk code left alive
justin13888 Sep 2, 2026
9d7f770
chore: merge origin/master into feat/224-png-encoder-efficiency
justin13888 Sep 5, 2026
49189a6
fix(png): emit bKGD and sBIT for the colour type actually written
justin13888 Sep 5, 2026
5e2807c
fix(png): bound the filter scan's inflation by the stream that claims it
justin13888 Sep 6, 2026
1851bb0
fix(png): count chunks, not segments, against max_chunks
justin13888 Sep 6, 2026
90ff376
test(png): pin the chunk tally's constant-time lookup structurally
justin13888 Sep 6, 2026
4517679
docs(png): record what the races cost and how the chunks follow them
justin13888 Sep 6, 2026
2c08480
docs(cli): state why inspect's verification gate is PNG-only
justin13888 Sep 6, 2026
eabb0bd
docs(png)!: record FilterStrategy as non-exhaustive
justin13888 Sep 6, 2026
5f8e71b
test(png): a grey sBIT needs all three channels to agree, not two
justin13888 Sep 6, 2026
589df4f
fix(png): resolve a background against the written palette's alpha an…
justin13888 Sep 6, 2026
e1e39b3
test(png): count the tally's lookup probes instead of trusting its shape
justin13888 Sep 6, 2026
fac39dc
docs(png): qualify the bKGD/sBIT contract by axis and state what a bo…
justin13888 Sep 6, 2026
21602ed
feat(png): carry the C2PA manifest store in the caBX chunk
justin13888 Sep 6, 2026
deb28c7
fix(png): race the chunk-free reduction the raw estimate eliminated
justin13888 Sep 6, 2026
97cb438
fix(png): keep the byte-exact encoding when cleanup ties on size
justin13888 Sep 6, 2026
e943666
fix(png): charge the tally's probe counter per entry examined
justin13888 Sep 6, 2026
e8441df
test(png): shrink the distinct-type fixture to the count it needs
justin13888 Sep 6, 2026
cda73e3
feat(png): name the inflation-ratio refusal apart from the byte budget
justin13888 Sep 6, 2026
8a0fc3c
fix(png): count IHDR against the walk's chunk ceiling
justin13888 Sep 6, 2026
57fa923
fix(cli): restore the truncated-list notice for TIFF and DNG
justin13888 Sep 6, 2026
0595ee8
docs(cli): record the gamut inspect exit-code contract in docs/
justin13888 Sep 6, 2026
c59df6f
style(png): rustfmt the chunk-ceiling closure and its boundary test
justin13888 Sep 6, 2026
7f60deb
Merge remote-tracking branch 'origin/feat/224-png-encoder-efficiency'…
justin13888 Sep 9, 2026
8373af0
docs(png): say what moving the chunk ceiling into push actually fixed
justin13888 Sep 9, 2026
cce9939
docs(png): state the tie-break rule once, without appealing to encode…
justin13888 Sep 9, 2026
5ee185b
Merge remote-tracking branch 'origin/feat/224-png-encoder-efficiency'…
justin13888 Sep 9, 2026
cb30ea8
fix(png): end the C2PA store walk with the datastream
justin13888 Sep 9, 2026
0342473
feat(png): fill a reserved C2PA store in place
justin13888 Sep 9, 2026
704fa25
fix(png): never read a caBX after IDAT as the manifest store
justin13888 Sep 9, 2026
a4f9e7f
refactor(png): name what a metadata walk returns
justin13888 Sep 9, 2026
35957e0
docs(png): separate C2PA carriage from what a decode admits
justin13888 Sep 9, 2026
60aec64
test(png): pin the exact fit in the C2PA fill's bounds check
justin13888 Sep 9, 2026
eca33fa
fix(png): reject a C2PA span the chunk's own length contradicts
justin13888 Sep 9, 2026
c49be4c
fix(png): count every ignored C2PA store, and say what is not counted
justin13888 Sep 9, 2026
e1dddb7
refactor(png): read the C2PA chunk header as bytes, not offsets
justin13888 Sep 9, 2026
0a7e665
feat(png): preserve metadata across a re-encode, and refuse the chunk…
justin13888 Sep 10, 2026
d8c2ffd
test(png): pin Latin-1 text, the refused chunk pairs, and what a re-e…
justin13888 Sep 10, 2026
d7868bc
fix(png)!: implement §11.3.3's text clauses and stop refusing the col…
justin13888 Sep 10, 2026
47b42c3
feat(cli): say what metadata a conversion could not carry
justin13888 Sep 10, 2026
0a78c0f
docs(png): record the clauses metadata preservation implements
justin13888 Sep 10, 2026
0314208
test(png): pin what end_carry separates and what a dropped payload is…
justin13888 Sep 10, 2026
cc52efd
fix(png)!: carry an XMP packet's framing, and report what §11.3.3 onl…
justin13888 Sep 10, 2026
5d71425
docs(png): correct what preservation carries and what it only reports
justin13888 Sep 10, 2026
beda774
test(png): pin both readings of an XMP chunk's iTXt framing
justin13888 Sep 10, 2026
64a61f4
docs(png): say whose job the colour-chunk ranking is
justin13888 Sep 10, 2026
9243d2e
docs(png): cite the sections the vendored spec actually numbers
justin13888 Sep 10, 2026
cbf3e02
refactor(png): give the trailing-opaque tRNS trim one owner
justin13888 Sep 10, 2026
1f3ef91
feat(png): clean a caller-supplied palette before writing it
justin13888 Sep 10, 2026
a7737fc
docs(png): record what cleaning a caller's palette buys
justin13888 Sep 10, 2026
7b1cc00
fix(png): trim the trailing opaque alphas without a loop that can hang
justin13888 Sep 10, 2026
a290e3a
feat(png): let a caller set the IDAT optimal-parse limit
justin13888 Sep 10, 2026
b1eb531
feat(png): compose the encoder's size and time knobs into a preset
justin13888 Sep 10, 2026
1453e9b
feat(cli): put the PNG effort ladder on gamut convert
justin13888 Sep 10, 2026
91f5ae7
docs(png): record what each rung of the effort ladder costs and buys
justin13888 Sep 10, 2026
790c19e
style(png): wrap an assert the formatter reflows
justin13888 Sep 10, 2026
7461c26
docs(png): state the ordering the ladder can actually keep
justin13888 Sep 10, 2026
e6d68bc
docs(png): measure what the Small to Smallest step costs time from
justin13888 Sep 10, 2026
89bd8b4
fix(png): bound the top rung's optimal-parse span
justin13888 Sep 10, 2026
a0299d0
refactor(cli): resolve --png-preset through gamut-png's own ladder
justin13888 Sep 10, 2026
35561ea
test(png): print the size table the ladder's documented command promises
justin13888 Sep 10, 2026
3eac9b9
docs(png): measure the parse span where it is live, and where a rung …
justin13888 Sep 10, 2026
c69fde6
docs(png): record the row the top rung's parse span costs bytes on
justin13888 Sep 10, 2026
f39fed1
test(png): pin both ends of the top rung's parse span
justin13888 Sep 10, 2026
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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ Dependency edges (a crate depends on those to its right):
hostile input, ancillary metadata surfaced as raw `MetadataBlock`-ready payloads
(eXIf/iCCP/XMP/text) plus parsed gAMA/cHRM/sRGB/cICP. APNG out of scope (decodes as the
default image). Differential oracle both directions: libpng, which also *generates* the
decoder's conformance fixtures. ← core, deflate (+ `miniz_oxide` for inflate).
decoder's conformance fixtures. ← core, deflate (+ `miniz_oxide` for inflate, and
**maintainer-approved `crc32fast`** for the chunk CRC that every encode pays on its critical
path — hardware CRC-32 on x86-64/aarch64, table fallback elsewhere including wasm32, and it
keeps its `unsafe` to itself, so gamut-png stays `#![deny(unsafe_code)]`).
- **gamut-ifd** — TIFF/IFD container core (byte order, field types, IFD read/write); a
low-level container primitive (sibling to bitstream), shared by `gamut-tiff` and EXIF
metadata. ← core. Optional `bigtiff` feature adds 64-bit BigTIFF. Per-format metadata
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ cargo test --workspace
| `mise run lint-fix` | Lint and auto-fix |
| `mise run check-commits` | Check commits are Conventional Commits |
| `mise run coverage` | Run tests with coverage (min 80%) |
| `mise run bench` | Run performance benchmarks (Divan; see [docs/benchmarking.md](docs/benchmarking.md)) |
| `mise run bench-test` | Run every bench once to prove it still executes (no timings) |
| `mise run check-cross <triple>` | Cross-compile-check the libs for a target (extended CI; master/manual) |
| `mise run check-msrv` | Check the libs compile on the documented MSRV (extended CI; master/manual) |
| `mise run versions` | List every crate's version |
Expand Down
134 changes: 125 additions & 9 deletions crates/gamut-cli/src/commands/convert.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! `gamut convert` — decode an image and re-encode it with a gamut codec.

use std::path::PathBuf;
use std::path::{Path, PathBuf};

use clap::{Args, ValueEnum};
use gamut::avif::AvifEncoder;
Expand All @@ -10,7 +10,7 @@ use gamut::jxl::{
Container as JxlContainer, Distance as JxlDistance, Effort as JxlEffort, JxlEncoder,
ModularMode as JxlModularMode,
};
use gamut::png::{Level as PngLevel, PngEncoder};
use gamut::png::{PngEncoder, Preset as PngCodecPreset};
use gamut::tiff::{Compression as TiffCompression, TiffEncoder};
use gamut::webp::{Effort as WebpEffort, NearLossless as WebpNearLossless, WebpEncoder};

Expand Down Expand Up @@ -63,9 +63,17 @@ pub(crate) struct ConvertArgs {
/// Compress TIFF output with PackBits run-length encoding instead of storing it uncompressed.
#[arg(long)]
packbits: bool,
/// PNG DEFLATE effort: optimal-parse refinement passes at the always-used best compression
/// level (0 = lazy parse only; zopfli's default budget is 15). Omitting it keeps the encoder
/// default (6). Ignored for other output formats.
/// PNG effort preset: one setting for the five knobs that trade encoding time for output size
/// (compression level, DEFLATE effort, filter strategy, optimal-parse span, and lossless
/// colour-type reduction). `small` is the default and is what this command has always done;
/// `smallest` adds the whole-image filter search, which is much slower. Ignored for other
/// output formats.
#[arg(long = "png-preset", value_enum, default_value = "small")]
png_preset: PngPreset,
/// PNG DEFLATE effort: optimal-parse refinement passes, overriding whatever `--png-preset`
/// chose (0 = lazy parse only; zopfli's default budget is 15). Only the best compression level
/// consults it, so it does nothing under `--png-preset fast` or `balanced`. Omitting it keeps
/// the preset's own budget. Ignored for other output formats.
#[arg(long)]
png_effort: Option<u8>,
/// JPEG XL Butteraugli distance for lossy encoding (~1.0 = visually lossless, up to 25.0).
Expand All @@ -86,6 +94,16 @@ pub(crate) struct ConvertArgs {
/// for other output formats.
#[arg(long)]
jxl_container: bool,
/// Drop the input's metadata instead of carrying it into the output. By default a PNG input
/// re-encoded to PNG keeps its EXIF, ICC profile, XMP packet, text annotations and colour
/// chunks; a stripped file is smaller, an unstripped one is colour-accurate, so the default
/// is the one that loses nothing. Anything that cannot be carried — the C2PA manifest store,
/// signed over the bytes of the file it was made for — and anything carried in a shape the
/// PNG specification does not endorse is reported on stderr rather than passed over in
/// silence. Currently applies only to the PNG output path with a PNG input; every other pair
/// drops metadata regardless.
#[arg(long)]
strip_metadata: bool,
}

/// Output container/codec for `gamut convert`.
Expand All @@ -97,7 +115,8 @@ pub(crate) enum OutputFormat {
Webp,
/// TIFF (8-bit RGB; uncompressed, or PackBits with `--packbits`).
Tiff,
/// PNG — lossless; transparency preserved, with automatic lossless colour-type reduction.
/// PNG — lossless; transparency preserved, with automatic lossless colour-type reduction at
/// the default `--png-preset`.
Png,
/// JPEG XL — lossless by default, or lossy at `--jxl-distance`; transparency preserved.
Jxl,
Expand Down Expand Up @@ -130,6 +149,38 @@ pub(crate) enum JxlModular {
Modular,
}

/// PNG effort preset for `--png-preset`, naming the rungs of `gamut_png::Preset`.
///
/// The discriminants **are** the codec's ladder levels, and `to_codec` resolves them through
/// [`PngCodecPreset::from_level`] rather than re-deciding the mapping — the same route
/// `--webp-effort` and `--jxl-effort` take through their own ladders. A named value enum rather
/// than the siblings' bare integer only because clap prints a rung's doc comment in `--help`,
/// which a number cannot.
#[derive(Clone, Copy, ValueEnum)]
#[repr(u8)]
pub(crate) enum PngPreset {
/// Fastest: greedy matching and one fixed filter, accepting a larger file.
Fast = 0,
/// The `gamut-png` library default — the balanced speed/size point.
Balanced = 1,
/// The optimal parse and lossless reduction on one filter heuristic; this command's default.
Small = 2,
/// Adds the whole-image filter search, zopfli's own refinement budget and a wider
/// optimal-parse span. Six times slower than `small` and worth about 3.6% on gamut's corpus,
/// but nothing at all on three of its nine rows: measure your own material.
Smallest = 3,
}

impl PngPreset {
/// Maps the CLI choice onto the codec's [`PngCodecPreset`] rung.
fn to_codec(self) -> PngCodecPreset {
// `the_png_preset_flag_offers_exactly_the_codec_ladder` pins that every discriminant here
// is a level the codec admits, so the fallback is unreachable; it exists because this is
// a CLI and a panic is not an error report.
PngCodecPreset::from_level(self as u8).unwrap_or_default()
}
}

impl JpegSubsampling {
/// Maps the CLI choice onto the codec's [`JpegChroma`] enum.
fn to_codec(self) -> JpegChroma {
Expand Down Expand Up @@ -236,12 +287,40 @@ pub(crate) fn run(args: &ConvertArgs) -> Result<(), CliError> {
bytes = rgba.len(),
"decoded input"
);
let mut encoder = PngEncoder::new()
.with_compression(PngLevel::Best)
.with_auto_reduce(true);
// The preset sets every size/time knob; `--png-effort` still overrides the one it
// names, so it must be applied after.
let mut encoder = PngEncoder::new().with_preset(args.png_preset.to_codec());
if let Some(effort) = args.png_effort {
encoder = encoder.with_effort(effort);
}
// Carry the input's metadata rather than dropping it (issue #483). `png_metadata`
// reads the file from disk a second time; the *walk* is cheap (it skips IDAT by
// length and never inflates a pixel), the second read is not, and it is what the
// convenience of taking a path rather than the already-loaded bytes costs. It yields
// nothing for an input that is not a PNG.
let metadata = (!args.strip_metadata)
.then(|| png_metadata(&args.input))
.flatten();
if let Some(metadata) = &metadata {
tracing::info!(
texts = metadata.texts.len(),
exif = metadata.exif.is_some(),
icc = metadata.icc_profile.is_some(),
xmp = metadata.xmp.is_some(),
"carrying input metadata"
);
encoder = encoder.with_metadata(metadata);
// Say what could not come along, and what came along with a caveat. Silent loss
// is the defect this path exists to remove, and a payload the spec forbids
// carrying is still a payload the caller had.
for notice in encoder.metadata_notices() {
if notice.carried() {
tracing::warn!("input metadata carried with a caveat — {notice}");
} else {
tracing::warn!("input metadata not carried — {notice}");
}
}
}
encoder.encode_image(ImageRef::<Rgba8>::new(&rgba, dims)?, &mut out)?;
(rgba.len(), dims)
}
Expand Down Expand Up @@ -324,6 +403,16 @@ pub(crate) fn run(args: &ConvertArgs) -> Result<(), CliError> {
Ok(())
}

/// The metadata `path` carries, or `None` when it is not a PNG or cannot be read.
///
/// Deliberately total: the input has already been decoded successfully by the time this is
/// called, so an error here means the file is simply not a PNG — a JPEG or WebP input has
/// metadata of its own, but mapping that into PNG chunks is a cross-format job this command does
/// not do yet. Failing to *read* metadata must never fail a conversion whose pixels are fine.
fn png_metadata(path: &Path) -> Option<gamut::png::PngMetadata> {
gamut::png::metadata(&std::fs::read(path).ok()?).ok()
}

/// Picks the output format from `--format`, falling back to the output file's extension.
fn resolve_format(args: &ConvertArgs) -> Result<OutputFormat, CliError> {
if let Some(format) = args.format {
Expand All @@ -346,3 +435,30 @@ fn resolve_format(args: &ConvertArgs) -> Result<OutputFormat, CliError> {
None => Err(CliError::UnsupportedOutput("<none>".to_string())),
}
}

#[cfg(test)]
mod tests {
use clap::ValueEnum;

use super::{PngCodecPreset, PngPreset};

#[test]
fn the_png_preset_flag_offers_exactly_the_codec_ladder() {
// The flag's values and the codec's rungs are two lists that must stay the same list.
// Walking `from_level` from zero is the codec's own enumeration of its ladder, so a rung
// added to `gamut_png::Preset` fails here instead of being silently unreachable from the
// command line — which is what a hand-written arm-per-variant mapping could not catch.
let from_flag: Vec<u8> = PngPreset::value_variants()
.iter()
.map(|preset| preset.to_codec().level())
.collect();
let from_codec: Vec<u8> = (0..=u8::MAX)
.map_while(PngCodecPreset::from_level)
.map(PngCodecPreset::level)
.collect();
assert!(
from_flag == from_codec,
"--png-preset offers levels {from_flag:?} against the codec ladder {from_codec:?}"
);
}
}
Loading
Loading