Export at an exact resolution, and offer the panels worth naming

Export sizing could bound an image but not fix it. Long edge, short edge
and percentage all preserve the aspect ratio by letting one dimension
fall where it may, which is right for most work and useless against a
display that accepts one resolution and rejects everything else — a
television's art mode, a digital frame, a wallpaper slot.

FR-EXP-3 has always listed both halves of the answer, and this adds them.
**Fit box** scales to fit inside a width and height, so nothing is thrown
away and the result is smaller than the box on one axis unless the crop
already matches it. **Fill box** scales to cover the box and cuts the
overhang off the middle, so the file is exactly the pixels asked for.

Fill is the only mode in the file that discards image data, so two things
about it are worth stating. The overhang comes off symmetrically: the
crop tool is where a photographer decides which part of a frame survives,
and this stage having an opinion of its own would fight it. And locking
the crop to the same ratio leaves nothing here to cut, which is the
workflow the two features are meant to be used in.

With upscaling off and a source too small to cover, a fill box keeps its
*shape* rather than falling back to the source's: exporting a 3:2 file
where 16:9 was asked for is silently wrong in exactly the way the mode
exists to prevent, so the box shrinks instead. The existing rule — clamp,
never fail — is otherwise unchanged.

Four panel sizes are offered as buttons beside the fields. Getting 3840 x
2160 by typing four digits twice is a step at which the mistake is
discovered after the upload rather than before it. They fill in the
numbers and nothing else, in particular not the fit/fill choice: both are
legitimate against a screen, and guessing would discard the edges of a
photograph for a user who wanted them. The list is panels rather than
platforms, because a screen has one exact pixel count for ever where
"what a photo site wants" would rot in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 13:49:20 +02:00
co-authored by Claude Opus 5
parent d9eb8faffd
commit 3bb68cff69
8 changed files with 556 additions and 41 deletions
+9 -1
View File
@@ -179,7 +179,15 @@ pub fn export(
settings.allow_upscaling,
);
let resized = size::resample(frame, width, height);
// TRACES: FR-EXP-3
// One mode promises exact dimensions rather than a bound on them, and it
// is the only place the fit/fill distinction survives: `target_size` has
// already reported what the file will be either way.
let resized = if settings.sizing.crops_to_fill() {
size::resample_filling(frame, width, height)
} else {
size::resample(frame, width, height)
};
// Scaled by how much the image actually shrank: a full-size export needs
// no compensation, and a thumbnail needs a great deal.
+202 -6
View File
@@ -25,8 +25,11 @@ const A: f32 = 3.0;
/// TRACES: FR-EXP-3
/// Resolve the requested sizing against a source, honouring the upscale rule.
///
/// Aspect is preserved in every mode, so only one dimension is ever the
/// requested one.
/// Aspect is preserved in every mode. In all but one that means only a single
/// dimension is ever the requested one; [`SizingMode::FillBox`] is the
/// exception, and it keeps the aspect by *discarding* the overhang rather than
/// by settling for a smaller box — see [`resample_filling`], which does the
/// discarding.
///
/// **Upscaling is refused by clamping, never by failing.** FR-EXP-3 makes
/// upscaling opt-in, and a batch of mixed frames must not abort because one
@@ -45,21 +48,56 @@ pub fn target_size(
SizingMode::Original => (src_w, src_h),
SizingMode::LongEdge(n) => scale_to(src_w, src_h, n, src_w >= src_h),
SizingMode::ShortEdge(n) => scale_to(src_w, src_h, n, src_w < src_h),
// Fit is a ceiling on both axes, so the smaller factor wins and the
// result touches the box on one axis only.
SizingMode::FitBox(bw, bh) => {
scale_by(src_w, src_h, box_factor(src_w, src_h, bw, bh, f64::min))
}
// Fill is the box, exactly. The scale that covers it is the larger
// factor, and the overhang is taken off in `resample_filling` — this
// reports what the file will be, which is the whole reason the mode
// exists.
SizingMode::FillBox(bw, bh) => (bw.max(1), bh.max(1)),
SizingMode::Percentage(p) => {
let f = f64::from(p) / 100.0;
(
((f64::from(src_w) * f).round() as u32).max(1),
((f64::from(src_h) * f).round() as u32).max(1),
)
scale_by(src_w, src_h, f)
}
};
if !allow_upscaling && (w > src_w || h > src_h) {
// A fill box has to keep its shape even when it cannot keep its size:
// the mode's promise is an exact aspect ratio at exact dimensions, and
// falling back to the source's own shape would quietly export a 3:2
// file where a 16:9 one was asked for. So the *box* is scaled down to
// what the source can cover, rather than abandoned.
if let SizingMode::FillBox(bw, bh) = sizing {
let cover = box_factor(src_w, src_h, bw, bh, f64::max);
if cover > 1.0 {
return scale_by(bw.max(1), bh.max(1), 1.0 / cover);
}
}
return (src_w, src_h);
}
(w.max(1), h.max(1))
}
/// TRACES: FR-EXP-3
/// The scale that puts `src` against a `bw × bh` box, `choose` deciding which
/// axis governs: `f64::min` fits inside it, `f64::max` covers it.
fn box_factor(src_w: u32, src_h: u32, bw: u32, bh: u32, choose: fn(f64, f64) -> f64) -> f64 {
let fw = f64::from(bw.max(1)) / f64::from(src_w.max(1));
let fh = f64::from(bh.max(1)) / f64::from(src_h.max(1));
choose(fw, fh)
}
/// Both axes by one factor, never rounding away to nothing.
fn scale_by(w: u32, h: u32, factor: f64) -> (u32, u32) {
(
((f64::from(w) * factor).round() as u32).max(1),
((f64::from(h) * factor).round() as u32).max(1),
)
}
/// Scale so that the chosen edge lands on `n`.
fn scale_to(src_w: u32, src_h: u32, n: u32, width_is_the_edge: bool) -> (u32, u32) {
let n = n.max(1);
@@ -95,6 +133,46 @@ pub fn resample(frame: &Frame, dst_w: u32, dst_h: u32) -> Vec<u8> {
pass(&horizontal, dst_w, frame.height, dst_w, dst_h, false)
}
/// TRACES: FR-EXP-3
/// Resample onto exactly `(dst_w, dst_h)`, covering the box and cutting the
/// overhang off the middle.
///
/// The other half of [`SizingMode::FillBox`]. [`resample`] alone would do the
/// job by *stretching* the frame onto the box, which is the one outcome a
/// photographer would never accept — a 3:2 photograph squeezed onto a 16:9
/// panel is visibly wrong in a way no amount of resolution fixes.
///
/// So the frame is scaled until it covers the box, on whichever axis needs the
/// most, and the surplus is taken symmetrically off the other. Centred rather
/// than anchored: the crop tool is where a photographer decides *which* part
/// of the frame survives, and this stage guessing differently would fight it.
/// Locking the crop to the export's ratio leaves nothing here to cut.
pub fn resample_filling(frame: &Frame, dst_w: u32, dst_h: u32) -> Vec<u8> {
let (dst_w, dst_h) = (dst_w.max(1), dst_h.max(1));
// Rounded *up*, and floored at the destination: a cover scale that rounds
// down leaves the box a pixel short on one axis, and the crop below would
// then read past the end of the buffer.
let cover = box_factor(frame.width, frame.height, dst_w, dst_h, f64::max);
let cw = (((f64::from(frame.width) * cover).ceil()) as u32).max(dst_w);
let ch = (((f64::from(frame.height) * cover).ceil()) as u32).max(dst_h);
let covered = resample(frame, cw, ch);
if cw == dst_w && ch == dst_h {
return covered;
}
let (x0, y0) = ((cw - dst_w) / 2, (ch - dst_h) / 2);
let mut out = vec![0u8; (dst_w as usize) * (dst_h as usize) * 4];
for y in 0..dst_h as usize {
let src = ((y + y0 as usize) * cw as usize + x0 as usize) * 4;
let dst = y * dst_w as usize * 4;
let run = dst_w as usize * 4;
out[dst..dst + run].copy_from_slice(&covered[src..src + run]);
}
out
}
/// One separable pass. `horizontal` picks the axis being resampled.
fn pass(src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32, horizontal: bool) -> Vec<u8> {
let (src_len, dst_len) = if horizontal {
@@ -219,6 +297,124 @@ mod tests {
);
}
#[test]
fn a_fit_box_stays_inside_the_box_and_keeps_its_shape() {
// Fit is a ceiling on both axes, so a 3:2 frame in a 16:9 box comes
// back short of the box's width, never past its height.
assert_eq!(
target_size(6000, 4000, SizingMode::FitBox(3840, 2160), false),
(3240, 2160)
);
// Portrait into the same box: now the height governs nothing and the
// width does.
assert_eq!(
target_size(4000, 6000, SizingMode::FitBox(3840, 2160), false),
(1440, 2160)
);
}
#[test]
fn a_fill_box_is_the_box_exactly() {
// The whole point of the mode. A display that accepts one resolution
// and rejects everything else has to get that resolution whatever the
// photograph's own shape is.
for (w, h) in [(6000u32, 4000u32), (4000, 6000), (5000, 5000)] {
assert_eq!(
target_size(w, h, SizingMode::FillBox(3840, 2160), false),
(3840, 2160),
"{w}x{h} did not fill the box"
);
}
}
#[test]
fn a_fill_box_too_large_for_the_source_keeps_its_shape_not_the_sources() {
// Upscaling off, and the source cannot cover 4K. Falling back to the
// source's own size would export a 3:2 file where 16:9 was asked
// for — silently wrong in exactly the way the mode exists to prevent.
// The box shrinks instead.
let (w, h) = target_size(1600, 1200, SizingMode::FillBox(3840, 2160), false);
assert!(w <= 1600 && h <= 1200, "upscaled to {w}x{h}");
let want = 3840.0 / 2160.0;
assert!(
((w as f64 / h as f64) / want - 1.0).abs() < 0.01,
"{w}x{h} is not the box's shape"
);
}
#[test]
fn a_fill_box_within_the_source_is_honoured_with_upscaling_off() {
// The ordinary case: a 24 MP frame has pixels to spare for a 4K panel,
// so nothing is being enlarged and the clamp must not fire.
assert_eq!(
target_size(6000, 4000, SizingMode::FillBox(3840, 2160), false),
(3840, 2160)
);
}
#[test]
fn a_filled_frame_comes_back_at_exactly_the_box() {
let f = frame(128, 64);
// Wider than the source's 2:1, so the crop comes off the width.
assert_eq!(resample_filling(&f, 40, 40).len(), 40 * 40 * 4);
assert_eq!(resample_filling(&f, 100, 25).len(), 100 * 25 * 4);
// Already the box: no work, and no drift.
assert_eq!(resample_filling(&f, 128, 64), f.rgba);
}
#[test]
fn a_fill_crops_rather_than_stretching() {
// The property that separates fill from handing the box straight to
// `resample`. The test frame ramps red left to right, so a 2:1 source
// squeezed into a square would compress that ramp into the full
// width — where a centre crop keeps its middle, and therefore starts
// and ends well inside the source's own range.
let f = frame(128, 128);
let square = resample(&f, 64, 64);
let filled = resample_filling(&f, 32, 64);
let left = |b: &[u8]| b[0];
let right = |b: &[u8], w: usize| b[(w - 1) * 4];
assert!(
left(&filled) > left(&square),
"a centre crop must start further into the ramp"
);
assert!(
right(&filled, 32) < right(&square, 64),
"a centre crop must end further from the ramp's end"
);
}
#[test]
fn a_fill_takes_the_overhang_evenly_off_both_sides() {
// Centred, not anchored: the crop tool is where a photographer decides
// which part of the frame survives, and this stage must not have an
// opinion of its own.
let f = frame(128, 128);
let filled = resample_filling(&f, 32, 64);
let px = |x: usize| filled[x * 4];
// The ramp is horizontal, so a centred crop is symmetric about the
// frame's own midpoint: the two ends should sit equally far from it.
let mid = i32::from(resample(&f, 128, 128)[64 * 4]);
let lo = i32::from(px(0));
let hi = i32::from(px(31));
assert!(
((mid - lo) - (hi - mid)).abs() < 8,
"not centred: {lo} .. {mid} .. {hi}"
);
}
#[test]
fn a_flat_field_survives_a_fill_unchanged() {
// Same guard as the fit path: any deviation means the cover scale and
// the crop disagree about where the pixels are.
let flat = Frame::new(64, 48, vec![200; 64 * 48 * 4]).unwrap();
for byte in resample_filling(&flat, 30, 30) {
assert_eq!(byte, 200);
}
}
#[test]
fn upscaling_is_refused_by_clamping_rather_than_failing() {
// FR-EXP-3: opt-in, and a batch must not abort over one small frame.