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.
+2 -1
View File
@@ -17,7 +17,8 @@ pub use colour::{Chromaticities, Transfer};
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
pub use settings::{
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
ExportTarget, ImportSettings, LibrarySettings, OutputSharpening, Settings, SizingMode,
ExportTarget, ImportSettings, LibrarySettings, OutputSharpening, ScreenSize, Settings,
SizingMode,
};
pub use time::{
civil_from_unix, civil_from_unix_at, format_date, parse_date, unix_from_civil, Civil,
+181 -1
View File
@@ -642,10 +642,75 @@ pub enum SizingMode {
LongEdge(u32),
/// Pixels on the shorter dimension; aspect preserved.
ShortEdge(u32),
/// TRACES: FR-EXP-3
/// Scaled to fit inside `width × height`; aspect preserved, so the result
/// is smaller than the box on one axis unless the crop already matches it.
///
/// The safe half of the pair: nothing is thrown away and nothing is
/// stretched. Wanted where the box is a ceiling — an upload limit, a
/// mail attachment, a folder of images that must all fit one envelope.
FitBox(u32, u32),
/// TRACES: FR-EXP-3
/// Scaled to cover `width × height` and cut back to exactly those pixels.
///
/// The one mode that guarantees the file's dimensions rather than merely
/// bounding them, which is what a display that refuses anything else
/// needs — a television's art mode, a digital frame, a wallpaper slot. The
/// price is the overhang, taken off the middle: a photograph whose crop is
/// not already the box's shape loses two strips of it.
///
/// Pair it with a crop locked to the same ratio and nothing is lost at
/// all, which is the workflow the two features are meant to be used in.
FillBox(u32, u32),
/// A factor of the source, as a percentage.
Percentage(u32),
}
/// TRACES: FR-EXP-3
/// A display whose panel size is worth having as a button.
///
/// These fill in both numbers of a box mode at once. The list is short and
/// deliberately made of *panels* rather than of platforms: a screen has one
/// exact pixel count for ever, where "what a photo site wants" is a moving
/// target that would rot in this file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScreenSize {
pub label: &'static str,
pub width: u32,
pub height: u32,
}
impl ScreenSize {
/// The panels the settings page offers, in ascending order.
///
/// 4K UHD is the one that prompted the list. A television art mode — the
/// Samsung Frame's among them — accepts one resolution and rejects
/// everything else, so "3840 × 2160, exactly" is a thing a photographer
/// needs to be able to ask for without counting pixels by hand.
pub const ALL: [Self; 4] = [
Self {
label: "Full HD",
width: 1920,
height: 1080,
},
Self {
label: "QHD",
width: 2560,
height: 1440,
},
Self {
label: "4K UHD",
width: 3840,
height: 2160,
},
Self {
label: "5K",
width: 5120,
height: 2880,
},
];
}
impl SizingMode {
/// The mode's name, without its value.
pub fn label(self) -> &'static str {
@@ -653,15 +718,33 @@ impl SizingMode {
Self::Original => "Original",
Self::LongEdge(_) => "Long edge",
Self::ShortEdge(_) => "Short edge",
Self::FitBox(..) => "Fit box",
Self::FillBox(..) => "Fill box",
Self::Percentage(_) => "Percentage",
}
}
/// The number this mode carries, if it takes one.
///
/// For a box mode this is the width; [`Self::height`] is the other half.
pub fn value(self) -> Option<u32> {
match self {
Self::Original => None,
Self::LongEdge(n) | Self::ShortEdge(n) | Self::Percentage(n) => Some(n),
Self::FitBox(w, _) | Self::FillBox(w, _) => Some(w),
}
}
/// TRACES: FR-EXP-3
/// The second number, for the modes that take two.
///
/// Separate from [`Self::value`] rather than folded into it as a pair,
/// because the page needs to ask "is there a second field to draw?" and
/// every other mode's honest answer is no.
pub fn height(self) -> Option<u32> {
match self {
Self::FitBox(_, h) | Self::FillBox(_, h) => Some(h),
_ => None,
}
}
@@ -674,20 +757,53 @@ impl SizingMode {
Self::Original => Self::Original,
Self::LongEdge(_) => Self::LongEdge(value),
Self::ShortEdge(_) => Self::ShortEdge(value),
Self::FitBox(_, h) => Self::FitBox(value, h),
Self::FillBox(_, h) => Self::FillBox(value, h),
Self::Percentage(_) => Self::Percentage(value),
}
}
/// TRACES: FR-EXP-3
/// The same mode carrying `height`, or unchanged where it takes only one
/// number.
pub fn with_height(self, height: u32) -> Self {
match self {
Self::FitBox(w, _) => Self::FitBox(w, height),
Self::FillBox(w, _) => Self::FillBox(w, height),
other => other,
}
}
/// TRACES: FR-EXP-3
/// The same mode carrying both numbers, for the screen presets.
pub fn with_box(self, width: u32, height: u32) -> Self {
self.with_value(width).with_height(height)
}
/// TRACES: FR-EXP-3
/// Whether this mode cuts the overhang away to hit its numbers exactly.
///
/// The one question the export path has to ask about a mode, and the one
/// place the fit/fill distinction means anything: everything else treats
/// the resolved size the same way.
pub fn crops_to_fill(self) -> bool {
matches!(self, Self::FillBox(..))
}
/// The modes in page order, each with a usable starting value.
///
/// Switching to a sized mode has to land on *something*, and these are
/// values a user would plausibly keep: 2048px is a common web long edge,
/// and 100% is the identity, so choosing "Percentage" changes nothing
/// until a number is typed.
pub const CHOICES: [Self; 4] = [
pub const CHOICES: [Self; 6] = [
Self::Original,
Self::LongEdge(2048),
Self::ShortEdge(1600),
// Both box modes open on 4K UHD, which is the panel the presets exist
// for and a size no photographer would call surprising.
Self::FitBox(3840, 2160),
Self::FillBox(3840, 2160),
Self::Percentage(100),
];
@@ -788,6 +904,11 @@ impl Settings {
SizingMode::LongEdge(0) => SizingMode::LongEdge(1),
SizingMode::ShortEdge(0) => SizingMode::ShortEdge(1),
SizingMode::Percentage(0) => SizingMode::Percentage(1),
// Either axis of a box, independently: a hand-edited `[3840, 0]`
// is a box with no area, and the axis that was written is still
// the one the user meant.
SizingMode::FitBox(w, h) => SizingMode::FitBox(w.max(1), h.max(1)),
SizingMode::FillBox(w, h) => SizingMode::FillBox(w.max(1), h.max(1)),
other => other,
};
@@ -867,6 +988,65 @@ pub mod budget {
mod tests {
use super::*;
#[test]
fn every_screen_preset_is_a_size_a_panel_actually_has() {
// Guards a list nobody re-reads against a typo. Each entry is a real
// panel: landscape, and an even number of pixels on both axes.
for s in ScreenSize::ALL {
assert!(s.width > s.height, "{} is not a landscape panel", s.label);
assert_eq!(s.width % 2, 0, "{} has an odd width", s.label);
assert_eq!(s.height % 2, 0, "{} has an odd height", s.label);
assert!(!s.label.is_empty(), "a preset with no name");
}
}
#[test]
fn a_box_mode_carries_both_numbers_and_edits_them_separately() {
// What the page needs: two fields beside one mode chip, each editing
// its own axis without re-deciding the variant or clearing the other.
let mode = SizingMode::FillBox(3840, 2160);
assert_eq!(mode.value(), Some(3840));
assert_eq!(mode.height(), Some(2160));
assert_eq!(mode.with_value(1920), SizingMode::FillBox(1920, 2160));
assert_eq!(mode.with_height(1080), SizingMode::FillBox(3840, 1080));
assert_eq!(mode.with_box(2560, 1440), SizingMode::FillBox(2560, 1440));
// The variant is what decides fit or fill, and setting a number must
// never quietly change it.
assert!(mode.with_box(2560, 1440).crops_to_fill());
assert!(!SizingMode::FitBox(3840, 2160).crops_to_fill());
}
#[test]
fn only_a_box_mode_has_a_second_number() {
// The question the page asks to decide whether to draw a height field
// at all. A "Percentage" mode answering `Some` would draw one.
for mode in [
SizingMode::Original,
SizingMode::LongEdge(2048),
SizingMode::ShortEdge(1600),
SizingMode::Percentage(100),
] {
assert_eq!(mode.height(), None, "{mode:?} claimed a second number");
assert_eq!(mode.with_height(99), mode, "{mode:?} took one anyway");
assert!(!mode.crops_to_fill(), "{mode:?} should not crop");
}
}
#[test]
fn a_box_with_a_zero_axis_is_nudged_rather_than_reset() {
// Same rule as the single-value modes: the user wanted "small", and
// silently restoring the default would ignore that. Each axis is
// repaired on its own, so the one that was written survives.
let mut s = Settings::default();
s.export.sizing = SizingMode::FillBox(3840, 0);
s.sanitise();
assert_eq!(s.export.sizing, SizingMode::FillBox(3840, 1));
s.export.sizing = SizingMode::FitBox(0, 2160);
s.sanitise();
assert_eq!(s.export.sizing, SizingMode::FitBox(1, 2160));
}
#[test]
fn defaults_round_trip_through_json() {
let settings = Settings::default();