Skip to content

Commit 054bfc7

Browse files
authored
Merge pull request #344 from tinyhumansai/png-optimize-feature
feat(harness): make PNG re-compression an opt-in png-optimize feature
2 parents 635a17f + 951858f commit 054bfc7

6 files changed

Lines changed: 48 additions & 2 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ jobs:
107107
features: "--no-default-features --features tools"
108108
- name: multimodal
109109
features: "--no-default-features --features multimodal"
110+
- name: png-optimize
111+
features: "--no-default-features --features png-optimize"
110112
steps:
111113
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
112114
with:

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ TinyAgents is a Cargo workspace, not one crate. Depend on the pieces you need:
2929
middleware, structured output, streaming, usage/cost accounting, retries,
3030
caching, and a Claude Code CLI model adapter with stream-json,
3131
session, authentication, and MCP endpoint support. Features: `sqlite`,
32-
`tools`, `multimodal`, `tracing`.
32+
`tools`, `multimodal`, `png-optimize` (opt-in PNG re-compression), `tracing`.
3333
- **`tinyagents-graph`** — a LangGraph-style durable, typed state graph:
3434
`START`/`END`, nodes, conditional edges, `Send` fanout, reducers/channels,
3535
checkpoints, interrupts, subgraphs, and time travel. Features: `sqlite`,

‎crates/tinyagents-harness/Cargo.toml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ storage-drivers = ["dep:tinystoragedrivers-core"]
5858
# still spell out the old name keep compiling.
5959
tools = ["builtin-tools"]
6060
builtin-tools = ["dep:chrono-tz"]
61-
multimodal = ["dep:flate2", "dep:reqwest", "dep:tar", "dep:zip", "dep:oxipng"]
61+
multimodal = ["dep:flate2", "dep:reqwest", "dep:tar", "dep:zip"]
62+
# Lossless PNG re-compression (`multimodal::optimize_png_lossless`) through
63+
# `oxipng`. Off by default: without it the helper returns `None` and callers
64+
# keep the original bytes, and `oxipng`/`libdeflater` (a C build) are not linked.
65+
png-optimize = ["multimodal", "dep:oxipng"]
6266
# Image and video generation tools over tinyinference-image / -video.
6367
media = ["dep:tinyinference-image", "dep:tinyinference-video"]
6468
# Gates the Claude Code CLI and Claude Agent SDK provider adapters, which are

‎crates/tinyagents-harness/src/multimodal/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ PNG derivatives use `optimize_png_lossless`: it changes only IDAT compression
145145
and filtering, preserving pixel format, hidden RGB, interlacing and every other
146146
chunk byte-for-byte. Animated, malformed and oversized inputs are skipped; the
147147
caller retains the original and decides where to store the optional smaller copy.
148+
The optimizer (`oxipng`) is behind the opt-in `png-optimize` feature; without it
149+
`optimize_png_lossless` always returns `None`, i.e. the original is kept as is.
148150

149151
ZIP admission conservatively rejects footer signature bytes in central-directory
150152
metadata or archive comments, even where ZIP permits those bytes. This prevents

‎crates/tinyagents-harness/src/multimodal/png.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@
88
/// color metadata) remain byte-for-byte unchanged. The input is never modified.
99
/// Work is bounded to 32 MiB of input and 16 million pixels; hosts may enforce
1010
/// tighter upload limits. This helper neither decodes nor changes other formats.
11+
///
12+
/// Requires the `png-optimize` feature. Without it this always returns `None`
13+
/// ("no saving"), so callers keep the original bytes and any size limit they
14+
/// apply to the original is enforced exactly as when optimization fails.
15+
#[cfg(not(feature = "png-optimize"))]
16+
pub fn optimize_png_lossless(_bytes: &[u8]) -> Option<Vec<u8>> {
17+
None
18+
}
19+
20+
#[cfg(feature = "png-optimize")]
1121
pub fn optimize_png_lossless(bytes: &[u8]) -> Option<Vec<u8>> {
1222
if bytes.len() > 32 * 1024 * 1024 || bytes.get(..8)? != b"\x89PNG\r\n\x1a\n" {
1323
return None;
@@ -40,6 +50,7 @@ pub fn optimize_png_lossless(bytes: &[u8]) -> Option<Vec<u8>> {
4050
Some(optimized)
4151
}
4252

53+
#[cfg(feature = "png-optimize")]
4354
// Even if an optimizer starts rewriting a metadata chunk in a future release,
4455
// the derivative is admitted only when every non-IDAT chunk stays identical.
4556
fn unchanged_chunks(bytes: &[u8]) -> Option<Vec<&[u8]>> {

‎crates/tinyagents-harness/src/multimodal/png_tests.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use super::*;
22

3+
#[cfg(feature = "png-optimize")]
34
fn fixture() -> Vec<u8> {
45
let mut out = Vec::new();
56
{
@@ -23,6 +24,7 @@ fn fixture() -> Vec<u8> {
2324
out
2425
}
2526

27+
#[cfg(feature = "png-optimize")]
2628
fn decoded(bytes: &[u8]) -> Vec<u8> {
2729
let mut reader = ::png::Decoder::new(std::io::Cursor::new(bytes))
2830
.read_info()
@@ -33,6 +35,7 @@ fn decoded(bytes: &[u8]) -> Vec<u8> {
3335
pixels
3436
}
3537

38+
#[cfg(feature = "png-optimize")]
3639
#[test]
3740
fn png_derivative_is_smaller_preserves_hidden_rgb_and_metadata_and_original() {
3841
let original = fixture();
@@ -55,6 +58,7 @@ fn png_derivative_is_smaller_preserves_hidden_rgb_and_metadata_and_original() {
5558
assert!(optimize_png_lossless(&optimized).is_none());
5659
}
5760

61+
#[cfg(feature = "png-optimize")]
5862
#[test]
5963
fn animation_malformed_non_png_and_excessive_dimensions_are_skipped() {
6064
assert!(optimize_png_lossless(b"jpeg").is_none());
@@ -77,3 +81,26 @@ fn animation_malformed_non_png_and_excessive_dimensions_are_skipped() {
7781
giant[16..20].copy_from_slice(&u32::MAX.to_be_bytes());
7882
assert!(optimize_png_lossless(&giant).is_none());
7983
}
84+
85+
#[test]
86+
fn non_png_input_is_skipped_in_every_configuration() {
87+
assert!(optimize_png_lossless(b"jpeg").is_none());
88+
assert!(optimize_png_lossless(b"\x89PNG\r\n\x1a\n").is_none());
89+
}
90+
91+
// Without `png-optimize` the helper is a passthrough signal: `None` even for a
92+
// valid, compressible PNG, so callers keep (and size-check) the original.
93+
#[cfg(not(feature = "png-optimize"))]
94+
#[test]
95+
fn valid_png_is_passed_through_without_the_feature() {
96+
let mut out = Vec::new();
97+
{
98+
let mut encoder = ::png::Encoder::new(&mut out, 64, 64);
99+
encoder.set_color(::png::ColorType::Rgba);
100+
encoder.set_depth(::png::BitDepth::Eight);
101+
encoder.set_compression(::png::Compression::Fast);
102+
let mut writer = encoder.write_header().unwrap();
103+
writer.write_image_data(&vec![0u8; 64 * 64 * 4]).unwrap();
104+
}
105+
assert!(optimize_png_lossless(&out).is_none());
106+
}

0 commit comments

Comments
 (0)