Skip to content
Merged
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
2 changes: 1 addition & 1 deletion config/derivations.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
},
"regenerate": "bash tools/photos/extract-photo-metadata.sh \"$SOOC_DIR\" && bun tools/photos/build-histogram-index.ts",
"recorded": {
"inputs": "4f8e62b9dd95c62238aea1ee278bd4b3e7e8b1a926c9bbc295585ccf4470bb28",
"inputs": "025a02d09757cb99791760ce11cddf1fc5d636942d254124409ad3f12e9973ba",
"count": 261
}
},
Expand Down
4 changes: 2 additions & 2 deletions config/derivations.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -1551,8 +1551,8 @@
"public/i/XT509996.47144221.avif": "471442213dffcaf2ad49d282c3a2c6058f555c6a57bdd1de0fa7fe3d2af84612",
"public/i/XT509996.7c90b12d.jpg": "7c90b12dc70fc92e8f5e836e0b28248f7f07929b2df5607bf8a601b2a4b1b7c4",
"tools/photos/shell-data.ts": "9f579625d015d884ac8cae51ee6075df9993b3b658f894cadbd168cde05b29e1",
"tools/photos/zenc/Cargo.lock": "f8461ff344258f61c9ca715e93c3f12a549be5cc0d7dd89364de27cb143e5897",
"tools/photos/zenc/Cargo.lock": "f7a8fc33f3c9da7a2f287dbf239758d567a9ab6dc87fd4a20870b77b7f01b207",
"tools/photos/zenc/src/histogram.rs": "59e46d9f6e65905da2f6019714963cfc0d2d0cd40b518f5f57c5a14377b6612a",
"tools/photos/zenc/src/pixels.rs": "4ba5b98e36b3013260e2368f15a4a25adf7d3bce8dba4b326accab66a5dbd786"
"tools/photos/zenc/src/pixels.rs": "0185c8a11c7155d01993669a2e2dfb603e1ce72561d27f15ad8d32117b1ed2ab"
}
}
2 changes: 1 addition & 1 deletion tools/photos/zenc/Cargo.lock

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

9 changes: 8 additions & 1 deletion tools/photos/zenc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,14 @@ zenjpeg = { version = "0.8.4", features = ["trellis"] }
# transfer curves) and the histogram bake over all 165 stems came back
# identical from the binary built either way, and the same A/B is the gate for
# moving this rev.
halflight = { git = "https://github.com/oddharsh/halflight.git", rev = "80e9f344f669f85cbf4e85d9337b11405691f7ba" }
#
# Moved 2026-09-22 to 871487e for resample_oriented, which square now calls so
# no upright copy of a full frame is built. `bun run zenc:bench` is that A/B
# now: all 24 square outputs and 258 histograms byte-identical, and the
# histograms regenerated from this build matched the committed file. Pin the
# rev to halflight's branch commit and merge that PR with a merge commit (not a
# squash), as #1 was, so this sha stays reachable from halflight's main.
halflight = { git = "https://github.com/oddharsh/halflight.git", rev = "871487e15be16dae0da39ddd882d94d188a5dc5f" }
# Input decode only. `tiff` was added on 2026-08-25 for the Instagram export,
# which needs a LOSSLESS decode of a HEIF source and cannot use JPEG for it.
# Measured on a 7728x5152 HIF: `sips -s format png` takes 6222ms because it
Expand Down
9 changes: 6 additions & 3 deletions tools/photos/zenc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,12 @@ whole rows for the flips and walks 64px tiles for the four transposes, which
took the decode-to-resample floor from 731/774/859 ms to 572/577/601 ms at
orientations 3/6/8. On the bench's corpus, rotated sources run 1.09-1.17x
faster and use 5-9.5% less CPU, each at p = 0.0006, with every output
byte-identical. What orientation still costs over upright is the copy into a
fresh full-frame buffer. Removing it means reading through the orientation
inside halflight's resample, which is a halflight API change and its own A/B.
byte-identical. The second pass removed the copy itself: `square` now hands the
STORED frame to halflight's `resample_oriented`, which reads it upright inside
the resample and is bitwise equal to orienting first. On one 26 MP JPEG the
floor reads 491 ms upright and 491/445/461 ms at orientations 3/6/8, so a
rotated photo now costs nothing extra, and the axis-swapping ones are cheaper
than upright. `resize` and `frame` still orient with the tiled copy.

## Remaining work

Expand Down
2 changes: 1 addition & 1 deletion tools/photos/zenc/examples/heif-memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ fn run(args: &[String]) -> Result<(), String> {
let root = Path::new(&args[2]);
std::fs::create_dir_all(root).map_err(|e| e.to_string())?;
for size in [600, 400, 200] {
let tier = square::tier(&src, size, halflight::Filter::Box);
let tier = square::tier(&src, pixels::Orientation::Upright, size, halflight::Filter::Box);
avif::write(&tier, &root.join(format!("{size}.avif")))?;
if size == 600 {
let jpg = jpeg::encode(&tier, 84, zenjpeg::encoder::ChromaSubsampling::Quarter)?;
Expand Down
2 changes: 1 addition & 1 deletion tools/photos/zenc/examples/memory-encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ fn run(args: &[String]) -> Result<(), String> {
let src = pixels::orient(pixels::load_linear(&args[1], pixels::TransferOption::Auto)?, orientation);
std::fs::create_dir_all(root).map_err(|e| e.to_string())?;
for size in [600, 400, 200] {
let tier = square::tier(&src, size, halflight::Filter::Box);
let tier = square::tier(&src, pixels::Orientation::Upright, size, halflight::Filter::Box);
avif::write(&tier, &root.join(format!("{size}.avif")))?;
if size == 600 {
let encoded = jpeg::encode(&tier, 84, ChromaSubsampling::Quarter)?;
Expand Down
38 changes: 33 additions & 5 deletions tools/photos/zenc/src/pixels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@ pub enum Orientation {
Rotate270,
}

impl Orientation {
/// Whether the upright frame's width is the stored frame's height.
pub fn swaps_axes(self) -> bool {
self as u8 >= 5
}

/// The same transform in halflight's terms. Both enums number the eight
/// cases as EXIF does and map a stored pixel identically, which zenc:bench
/// gates on real photos byte for byte.
fn halflight(self) -> halflight::Orientation {
halflight::Orientation::from_exif(self as u8).expect("Orientation admits exactly EXIF 1-8")
}
}

impl TryFrom<u8> for Orientation {
type Error = &'static str;
fn try_from(value: u8) -> Result<Self, Self::Error> {
Expand Down Expand Up @@ -310,11 +324,11 @@ pub fn orient(f: Frame, orientation: Orientation) -> Frame {
// orientation 6 774 -> 577 ms (tiled transpose)
// orientation 8 859 -> 601 ms (tiled transpose)
//
// What is left over upright is the copy into a fresh full-frame buffer.
// Removing that means reading through the orientation inside halflight's
// resample, which is a halflight API change. Output is byte-identical,
// since this moves the same values to the same places, and zenc:bench gates
// that on real photos.
// The ingest no longer calls this: `square` reads the stored frame upright
// inside the resample (scale_oriented, halflight::resample_oriented), which
// skips the copy this still makes. `resize` and `frame` use it. Output is
// byte-identical, since this moves the same values to the same places, and
// zenc:bench gates that on real photos.
//
// The channel count is a const generic so each pixel copy compiles to a
// fixed-size move.
Expand Down Expand Up @@ -389,6 +403,20 @@ pub fn scale(f: &Frame, dw: u32, dh: u32, filter: Filter) -> Frame {
}
}

/// Resample a STORED frame to an exact UPRIGHT size, reading it through the
/// orientation inside the resample rather than orienting a full-frame copy
/// first. halflight holds this bitwise equal to `scale(&orient(f, o), ..)`.
pub fn scale_oriented(f: &Frame, o: Orientation, dw: u32, dh: u32, filter: Filter) -> Frame {
let ch = if f.gray { 1 } else { 3 };
Frame {
w: dw,
h: dh,
gray: f.gray,
transfer: f.transfer,
data: halflight::resample_oriented(&f.data, f.w as usize, f.h as usize, ch, o.halflight(), dw as usize, dh as usize, filter),
}
}

/// Quantize once after resampling; the returned pixels can feed several encoders.
pub fn encoded(f: &Frame) -> DynamicImage {
let t = f.transfer;
Expand Down
22 changes: 13 additions & 9 deletions tools/photos/zenc/src/square.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,23 @@
// `--transfer` names the SOURCE's curve (srgb, or g22 for the Monochrom's
// Gray Gamma 2.2) and is used for decode and encode both, so unaveraged values
// pass through exactly.
use crate::pixels::{crop, load_linear, orient, parse_transfer, encoded, scale, Frame, Orientation, TransferOption};
use crate::pixels::{crop, load_linear, parse_transfer, encoded, scale_oriented, Frame, Orientation, TransferOption};
use halflight::Filter;
use std::path::Path;

/// The production geometry, shared with the benchmark front end.
pub fn tier(src: &Frame, size: u32, filter: Filter) -> image::DynamicImage {
let (w, h) = (src.w, src.h);
/// The production geometry, shared with the benchmark front end. `src` is the
/// STORED frame and `orientation` brings it upright inside the resample, so no
/// upright copy of the full frame is ever built. Geometry is computed on the
/// upright size, exactly as it was when this took an oriented frame.
pub fn tier(src: &Frame, orientation: Orientation, size: u32, filter: Filter) -> image::DynamicImage {
let (w, h) = if orientation.swaps_axes() { (src.h, src.w) } else { (src.w, src.h) };
let (nw, nh) = if w <= h {
(size, (h as u64 * size as u64).div_ceil(w as u64) as u32)
} else {
((w as u64 * size as u64).div_ceil(h as u64) as u32, size)
};
let (cx, cy) = ((nw.saturating_sub(size)) / 2, (nh.saturating_sub(size)) / 2);
let scaled = scale(src, nw, nh, filter);
let scaled = scale_oriented(src, orientation, nw, nh, filter);
encoded(&crop(&scaled, cx, cy, size.min(nw), size.min(nh)))
}

Expand Down Expand Up @@ -114,13 +117,14 @@ pub fn run(args: &[String]) -> i32 {
Ok(f) => f,
Err(e) => return err(&e),
};
// Orient BEFORE the resample, so the crop math sees the frame the viewer
// will. For orientation 1 the decoded buffer moves without copying.
let src = orient(src, exif);
// The orientation is applied INSIDE each tier's resample (tier() and
// halflight::resample_oriented), so the crop math still sees the frame the
// viewer will and no upright copy of the full frame is built. That copy was
// a 311 MB f32 buffer for a 26 MP photo, rebuilt once per ingest.
for output in outputs {
// Short edge lands on `size`. sips reaches the same crop from the long
// edge, which is one more piece of arithmetic to get wrong.
let pixels = tier(&src, output.size, filter);
let pixels = tier(&src, exif, output.size, filter);
if let Some(path) = output.png {
if let Err(e) = pixels.save(path) { return err(&format!("cannot write {path}: {e}")); }
}
Expand Down
Loading