Add folder scan with format selection; validate A3 on a real library
Library setup as the user described it: pick a folder, choose which RAW
types to look for, scan recursively.
dr-types::FormatFilter the tick-box selection, seeing through VFS
placeholder suffixes so a dehydrated CR2 still
matches as a CR2
dr-sync::scan recursive walk, Depth:1 per directory, pruning
unchanged subtrees where the backend propagates
directory ETags
Verified against nextcloud.tourolle.paris (34.0.2) on a real library:
browse root 32 entries, 98ms
scan PhotosRaw 17,185 RAW files in 334 directories, 34.1s
(7,836 CR2 + 9,349 DNG)
range read 262KB of a 21.5MB DNG in 119ms — 1.22% of the file,
and enough to read "Canon EOS 6D | ISO 100"
That last line is assumption A3 validated on real data. Cataloguing this
library by whole-file fetch would move roughly 370GB; the range path
moves a few MB.
Pruning is capability-gated rather than assumed: with per-entry ETags a
probe costs a request and proves nothing about children, so it is skipped
entirely. A test asserts zero probes in that case.
Still unresolved: /core/preview returns 400 for every parameter
combination tried, including on a JPEG the server reports as having a
preview. Not a request-shape bug — it fails identically bare. Recorded
rather than worked around; ARCH §6.7 already treats server previews as
opportunistic, so nothing depends on it.
This commit is contained in:
@@ -0,0 +1,943 @@
|
||||
//! Framing — crop, straighten, rotate, flip (FR-DEV-3, ARCH §5.2).
|
||||
//!
|
||||
//! # Why this is not an `Operation`, and not a `Warp` either
|
||||
//!
|
||||
//! An [`crate::operation::Operation`] is a function from colour to colour. By
|
||||
//! the time one runs, the colour has been sampled and the question framing
|
||||
//! asks — *which* source pixel does this output pixel come from — has already
|
||||
//! been answered. And a crop changes the output's dimensions and aspect
|
||||
//! ratio, which no colour fragment can express.
|
||||
//!
|
||||
//! [`crate::warp::Warp`] is closer: it also rewrites coordinates before the
|
||||
//! fetch. But a warp is a *correction to the optics* — distortion and CA are
|
||||
//! properties of the lens, defined about the optical axis, over the whole
|
||||
//! frame the lens projected. Framing is a decision about *composition*, made
|
||||
//! afterwards. The order matters and is not a preference:
|
||||
//!
|
||||
//! ```text
|
||||
//! output pixel → framing → warp (lens) → sample → colour ops → output
|
||||
//! ```
|
||||
//!
|
||||
//! Reading forward, the lens is corrected on the full frame and the crop
|
||||
//! then selects from the corrected result. Correcting distortion on an
|
||||
//! already-cropped frame would place the optical centre in the wrong spot and
|
||||
//! bend the image about a point the lens never saw.
|
||||
//!
|
||||
//! So framing runs **first** in the coordinate chain, and hands the warp
|
||||
//! chain exactly the space it documents: normalised, centred, `r == 1` at the
|
||||
//! corner. Neither stage needs to know the other exists.
|
||||
//!
|
||||
//! # Why sampling changes with the angle
|
||||
//!
|
||||
//! At 90° steps and flips, output pixels land exactly on source pixels, so
|
||||
//! the map is a permutation and an integer `textureLoad` is both correct and
|
||||
//! lossless. At any other angle it is not, and nearest-neighbour sampling
|
||||
//! makes a straightened horizon visibly stair-step — the commonest use of
|
||||
//! this stage, and where the artefact is most obvious. Free angles therefore
|
||||
//! need interpolation, which is what [`Framing::needs_interpolation`] tells
|
||||
//! the composer. Paying for it only when the angle demands it keeps the
|
||||
//! common case exact rather than merely close.
|
||||
|
||||
use std::f32::consts::PI;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
|
||||
use crate::operation::Affects;
|
||||
|
||||
pub const ID: OpId = OpId("framing");
|
||||
|
||||
pub const ANGLE: ParamId = ParamId("angle");
|
||||
pub const ROTATION: ParamId = ParamId("rotation");
|
||||
pub const FLIP_H: ParamId = ParamId("flip_h");
|
||||
pub const FLIP_V: ParamId = ParamId("flip_v");
|
||||
pub const CROP_X: ParamId = ParamId("crop_x");
|
||||
pub const CROP_Y: ParamId = ParamId("crop_y");
|
||||
pub const CROP_W: ParamId = ParamId("crop_w");
|
||||
pub const CROP_H: ParamId = ParamId("crop_h");
|
||||
|
||||
/// Widest straightening the control offers, in degrees either way.
|
||||
///
|
||||
/// Straightening a horizon is a small correction; a gross reorientation is
|
||||
/// what the 90° steps are for. Bounding it keeps the slider's travel where
|
||||
/// the edits actually are.
|
||||
pub const MAX_STRAIGHTEN: f32 = 45.0;
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.framing"),
|
||||
params: &[
|
||||
// Straightening. Degrees rather than a normalised amount because a
|
||||
// photographer reading "-1.4°" off a horizon knows what it means.
|
||||
ParamDescriptor::scalar(
|
||||
"angle",
|
||||
"param.angle",
|
||||
-MAX_STRAIGHTEN,
|
||||
MAX_STRAIGHTEN,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
// Quarter turns, 0..3. Separate from `angle` because these are exact
|
||||
// and lossless, and because reorienting a frame is a different
|
||||
// gesture from nudging a horizon.
|
||||
ParamDescriptor::scalar(
|
||||
"rotation",
|
||||
"param.rotation",
|
||||
0.0,
|
||||
3.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::switch("flip_h", "param.flip_h"),
|
||||
ParamDescriptor::switch("flip_v", "param.flip_v"),
|
||||
// The crop rect, in fractions of the source. Normalised rather than
|
||||
// in pixels so a crop survives being applied to a proxy, a full
|
||||
// resolution render, or an export at another size — the same reason
|
||||
// the viewport renders at display resolution (FR-DSP-1).
|
||||
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
|
||||
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
|
||||
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
||||
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
|
||||
],
|
||||
};
|
||||
|
||||
/// A normalised crop rectangle, in fractions of the source image.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CropRect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl Default for CropRect {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CropRect {
|
||||
/// Smallest crop the rect may be reduced to, as a fraction of the source.
|
||||
///
|
||||
/// A zero-extent crop produces a zero-sized output texture, which is a
|
||||
/// device error rather than a visibly silly image. Bounding it here means
|
||||
/// no caller has to defend against it.
|
||||
pub const MIN_EXTENT: f32 = 0.01;
|
||||
|
||||
/// Whether this rect selects the whole image.
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.x == 0.0 && self.y == 0.0 && self.width == 1.0 && self.height == 1.0
|
||||
}
|
||||
|
||||
/// Clamp into the unit square, keeping the rect non-degenerate.
|
||||
///
|
||||
/// The origin is clamped first and the extent fitted to what remains, so
|
||||
/// a rect dragged past an edge slides rather than inverting.
|
||||
pub fn normalised(self) -> Self {
|
||||
let x = finite(self.x, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
|
||||
let y = finite(self.y, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
|
||||
let width = finite(self.width, 1.0).clamp(Self::MIN_EXTENT, 1.0 - x);
|
||||
let height = finite(self.height, 1.0).clamp(Self::MIN_EXTENT, 1.0 - y);
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace a non-finite value with a fallback.
|
||||
///
|
||||
/// A NaN reaching the crop rect would propagate into the output *dimensions*,
|
||||
/// not merely the pixels — `NaN as u32` is 0, and a zero-sized texture is a
|
||||
/// device error. The same defence as `ParamDescriptor::clamp`, one level up.
|
||||
fn finite(v: f32, fallback: f32) -> f32 {
|
||||
if v.is_finite() {
|
||||
v
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Crop, straighten, rotation and flips for one image.
|
||||
///
|
||||
/// Holds no GPU state: like the rest of the graph this is CPU-side, so a lost
|
||||
/// device is recovered by re-composing rather than by re-deriving the edit
|
||||
/// (ARCH §6.10).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Framing {
|
||||
/// Straightening, in degrees. Positive rotates the image clockwise.
|
||||
angle: f32,
|
||||
/// Quarter turns clockwise, 0..=3.
|
||||
quarter_turns: u8,
|
||||
flip_h: bool,
|
||||
flip_v: bool,
|
||||
crop: CropRect,
|
||||
}
|
||||
|
||||
impl Default for Framing {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
angle: 0.0,
|
||||
quarter_turns: 0,
|
||||
flip_h: false,
|
||||
flip_v: false,
|
||||
crop: CropRect::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Framing {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
}
|
||||
|
||||
/// What this stage affects, for invalidation scoping (FR-DEV-3d).
|
||||
pub fn affects(&self) -> Affects {
|
||||
Affects::Geometry
|
||||
}
|
||||
|
||||
pub fn crop(&self) -> CropRect {
|
||||
self.crop
|
||||
}
|
||||
|
||||
pub fn set_crop(&mut self, rect: CropRect) {
|
||||
self.crop = rect.normalised();
|
||||
}
|
||||
|
||||
pub fn angle(&self) -> f32 {
|
||||
self.angle
|
||||
}
|
||||
|
||||
pub fn quarter_turns(&self) -> u8 {
|
||||
self.quarter_turns
|
||||
}
|
||||
|
||||
pub fn flips(&self) -> (bool, bool) {
|
||||
(self.flip_h, self.flip_v)
|
||||
}
|
||||
|
||||
/// Add quarter turns, wrapping. The rotate-left/right buttons.
|
||||
pub fn rotate_quarters(&mut self, turns: i32) {
|
||||
self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8;
|
||||
}
|
||||
|
||||
/// Whether this stage currently changes the image.
|
||||
///
|
||||
/// The same contract the operations honour: neutral framing contributes
|
||||
/// nothing to the generated shader, so an uncropped image reads its
|
||||
/// pixels through the identity map exactly as it did before this existed.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.angle != 0.0
|
||||
|| self.quarter_turns != 0
|
||||
|| self.flip_h
|
||||
|| self.flip_v
|
||||
|| !self.crop.is_full()
|
||||
}
|
||||
|
||||
/// Whether the axes are swapped — a 90° or 270° turn.
|
||||
fn swaps_axes(&self) -> bool {
|
||||
self.quarter_turns % 2 == 1
|
||||
}
|
||||
|
||||
/// Whether the map puts output pixels between source pixels.
|
||||
///
|
||||
/// False for quarter turns and flips, which are permutations with an
|
||||
/// exact answer. True once a free angle is involved. The composer reads
|
||||
/// this to decide between an integer load and a filtered sample — and a
|
||||
/// warp being active forces interpolation regardless, which is the
|
||||
/// composer's call to make rather than this stage's.
|
||||
pub fn needs_interpolation(&self) -> bool {
|
||||
self.angle != 0.0
|
||||
}
|
||||
|
||||
pub fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
ANGLE => self.angle = finite(value, 0.0),
|
||||
// Descriptor-clamped to 0..3, so the cast cannot wrap.
|
||||
ROTATION => self.quarter_turns = finite(value, 0.0).round().clamp(0.0, 3.0) as u8,
|
||||
FLIP_H => self.flip_h = value != 0.0,
|
||||
FLIP_V => self.flip_v = value != 0.0,
|
||||
CROP_X => {
|
||||
self.crop = CropRect {
|
||||
x: value,
|
||||
..self.crop
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
CROP_Y => {
|
||||
self.crop = CropRect {
|
||||
y: value,
|
||||
..self.crop
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
CROP_W => {
|
||||
self.crop = CropRect {
|
||||
width: value,
|
||||
..self.crop
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
CROP_H => {
|
||||
self.crop = CropRect {
|
||||
height: value,
|
||||
..self.crop
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
_ => log::warn!("framing: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
ANGLE => self.angle,
|
||||
ROTATION => f32::from(self.quarter_turns),
|
||||
FLIP_H => f32::from(u8::from(self.flip_h)),
|
||||
FLIP_V => f32::from(u8::from(self.flip_v)),
|
||||
CROP_X => self.crop.x,
|
||||
CROP_Y => self.crop.y,
|
||||
CROP_W => self.crop.width,
|
||||
CROP_H => self.crop.height,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
/// The output size this framing produces from a source of `(w, h)`.
|
||||
///
|
||||
/// The rendered aspect ratio follows from here, which is why this is the
|
||||
/// one piece of framing both the UI and the GPU pass need before any
|
||||
/// pixel is shaded: the output texture is allocated from it.
|
||||
///
|
||||
/// A free angle does **not** change the output size. The rotated image is
|
||||
/// sampled into the crop rect as it stands, so straightening a horizon
|
||||
/// leaves the frame where the user put it and may pull in undefined area
|
||||
/// at the corners — see [`Self::max_inscribed_crop`] for the rect that
|
||||
/// avoids that.
|
||||
pub fn output_size(&self, width: u32, height: u32) -> (u32, u32) {
|
||||
let (w, h) = if self.swaps_axes() {
|
||||
(height, width)
|
||||
} else {
|
||||
(width, height)
|
||||
};
|
||||
// Round rather than truncate: half of a 101px axis should be 51, and
|
||||
// truncation biases every crop smaller.
|
||||
let cw = ((w as f32 * self.crop.width).round() as u32).max(1);
|
||||
let ch = ((h as f32 * self.crop.height).round() as u32).max(1);
|
||||
(cw, ch)
|
||||
}
|
||||
|
||||
/// The largest centred crop, at the current angle, containing no
|
||||
/// undefined area.
|
||||
///
|
||||
/// Rotating a rectangle inside its own bounds exposes the corners: there
|
||||
/// is no source pixel there, and the shader renders it black. This is the
|
||||
/// rect that avoids it — what a "straighten and auto-crop" gesture would
|
||||
/// apply, and what the crop overlay should offer as its bound.
|
||||
///
|
||||
/// The standard largest-inscribed-rectangle result for a rotated
|
||||
/// rectangle of the same aspect ratio.
|
||||
pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect {
|
||||
if self.angle == 0.0 || width == 0 || height == 0 {
|
||||
return CropRect::default();
|
||||
}
|
||||
|
||||
let (w, h) = if self.swaps_axes() {
|
||||
(height as f32, width as f32)
|
||||
} else {
|
||||
(width as f32, height as f32)
|
||||
};
|
||||
|
||||
let a = (self.angle * PI / 180.0).abs();
|
||||
let (sin, cos) = (a.sin(), a.cos());
|
||||
|
||||
// Longer and shorter side, so the two cases below stay symmetric.
|
||||
let (long, short) = if w >= h { (w, h) } else { (h, w) };
|
||||
|
||||
let (bw, bh) = if short <= 2.0 * sin * cos * long || (sin - cos).abs() < 1e-6 {
|
||||
// Half-constrained: the shorter side alone limits the rectangle.
|
||||
let half = 0.5 * short;
|
||||
if w >= h {
|
||||
(half / sin, half / cos)
|
||||
} else {
|
||||
(half / cos, half / sin)
|
||||
}
|
||||
} else {
|
||||
// Fully constrained by both sides.
|
||||
let cos2 = cos * cos - sin * sin;
|
||||
((w * cos - h * sin) / cos2, (h * cos - w * sin) / cos2)
|
||||
};
|
||||
|
||||
// Back to fractions of the (possibly axis-swapped) frame, centred.
|
||||
let fw = (bw / w).clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
let fh = (bh / h).clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
CropRect {
|
||||
x: (1.0 - fw) * 0.5,
|
||||
y: (1.0 - fh) * 0.5,
|
||||
width: fw,
|
||||
height: fh,
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
|
||||
/// Uniform values the generated prologue reads.
|
||||
///
|
||||
/// A fixed-size block in a fixed slot, like the camera matrix: the
|
||||
/// prologue is emitted whether or not any operation is active, so its
|
||||
/// uniforms cannot be positioned by the op loop.
|
||||
///
|
||||
/// The angle reaches the shader as sin/cos rather than degrees — a trig
|
||||
/// call per pixel would recover a value constant across the dispatch.
|
||||
pub fn uniforms(&self) -> [f32; FRAMING_UNIFORM_FIELDS] {
|
||||
let rad = self.angle * PI / 180.0;
|
||||
[
|
||||
self.crop.x,
|
||||
self.crop.y,
|
||||
self.crop.width,
|
||||
self.crop.height,
|
||||
rad.sin(),
|
||||
rad.cos(),
|
||||
0.0,
|
||||
0.0,
|
||||
]
|
||||
}
|
||||
|
||||
/// The WGSL mapping an output pixel to a **normalised centred** source
|
||||
/// position, ready for the warp chain.
|
||||
///
|
||||
/// Leaves the result in `p`: centre `(0, 0)`, `r == 1` at the corner —
|
||||
/// exactly the space [`crate::warp`] documents, so lens correction
|
||||
/// composes on top of this without either stage naming the other.
|
||||
///
|
||||
/// `aspect` is left in scope alongside it, since the warp chain and the
|
||||
/// sampler both need it to return to texture coordinates.
|
||||
pub fn wgsl_prologue(&self) -> String {
|
||||
let mut s = String::new();
|
||||
|
||||
s.push_str(
|
||||
" // ---- framing ----
|
||||
// Output pixel -> source position, in the normalised centred space the
|
||||
// warp chain expects: the centre is (0, 0) and the radius is 1 at the
|
||||
// corner. Working here rather than in pixels is what makes the map
|
||||
// independent of the resolution being rendered at.
|
||||
let src_dims = textureDimensions(source);
|
||||
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
||||
var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
||||
",
|
||||
);
|
||||
|
||||
if !self.is_active() {
|
||||
// Neutral framing still has to produce `p`, since the warp chain
|
||||
// and the sampler read it either way. It is only the crop,
|
||||
// rotation and flip steps that vanish.
|
||||
s.push_str(
|
||||
"
|
||||
// Framing is neutral: the whole frame, unrotated.
|
||||
var p = (uv - vec2<f32>(0.5)) * aspect;
|
||||
",
|
||||
);
|
||||
return s;
|
||||
}
|
||||
|
||||
s.push_str(
|
||||
"
|
||||
// Into the crop rect.
|
||||
uv = u.crop_rect.xy + uv * u.crop_rect.zw;
|
||||
var p = (uv - vec2<f32>(0.5)) * aspect;
|
||||
",
|
||||
);
|
||||
|
||||
if self.angle != 0.0 {
|
||||
// Done in the aspect-corrected space, which is the whole reason
|
||||
// `p` is scaled by `aspect` above: a rotation applied to raw 0..1
|
||||
// coordinates on a non-square image shears it rather than
|
||||
// turning it, and that reads as a rendering fault.
|
||||
s.push_str(
|
||||
"
|
||||
// Straighten, about the frame centre.
|
||||
p = vec2<f32>(
|
||||
p.x * u.framing_angle.y - p.y * u.framing_angle.x,
|
||||
p.x * u.framing_angle.x + p.y * u.framing_angle.y,
|
||||
);
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
if self.quarter_turns != 0 {
|
||||
// An exact coordinate permutation rather than a rotation through
|
||||
// the matrix above, which would resample a transform that has an
|
||||
// exact answer. Applied to `p`, so the aspect scaling has to be
|
||||
// undone and reapplied across the swap.
|
||||
let permutation = match self.quarter_turns {
|
||||
1 => " p = vec2<f32>(p.y * aspect.x, -p.x / aspect.x);",
|
||||
2 => " p = -p;",
|
||||
_ => " p = vec2<f32>(-p.y * aspect.x, p.x / aspect.x);",
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
"
|
||||
// {}° clockwise — an exact permutation, so nothing is resampled.
|
||||
{permutation}
|
||||
",
|
||||
u32::from(self.quarter_turns) * 90
|
||||
);
|
||||
}
|
||||
|
||||
if self.flip_h {
|
||||
s.push_str(" p.x = -p.x;\n");
|
||||
}
|
||||
if self.flip_v {
|
||||
s.push_str(" p.y = -p.y;\n");
|
||||
}
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
/// Identifies this framing's *structure* — which branches the prologue
|
||||
/// generates, not the values it reads.
|
||||
///
|
||||
/// Deliberately coarse, for the reason the operation hash is: dragging
|
||||
/// the crop handles or the straighten slider must reuse the compiled
|
||||
/// pipeline and upload uniforms only. Only the presence of each
|
||||
/// transform, never its magnitude, may enter this.
|
||||
pub fn structure_key(&self) -> u64 {
|
||||
u64::from(!self.crop.is_full())
|
||||
| u64::from(self.angle != 0.0) << 1
|
||||
| u64::from(self.flip_h) << 2
|
||||
| u64::from(self.flip_v) << 3
|
||||
| u64::from(self.quarter_turns) << 4
|
||||
}
|
||||
}
|
||||
|
||||
/// Floats the framing block occupies in the generated uniform struct.
|
||||
///
|
||||
/// Two `vec4`s: the crop rect, and the angle's sin/cos with padding.
|
||||
pub const FRAMING_UNIFORM_FIELDS: usize = 8;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_fresh_framing_is_neutral() {
|
||||
// The invariant behind "opening an image shows the image".
|
||||
let f = Framing::new();
|
||||
assert!(!f.is_active());
|
||||
assert!(!f.needs_interpolation());
|
||||
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_framing_still_produces_a_position_for_the_warp_chain() {
|
||||
// The prologue always defines `p` and `aspect`, active or not — the
|
||||
// warp chain and the sampler read them either way, so a neutral
|
||||
// framing that skipped them would fail to compile rather than
|
||||
// rendering an unframed image.
|
||||
let src = Framing::new().wgsl_prologue();
|
||||
assert!(src.contains("var p ="), "{src}");
|
||||
assert!(src.contains("let aspect ="), "{src}");
|
||||
// ...but none of the transform steps.
|
||||
assert!(!src.contains("crop_rect"));
|
||||
assert!(!src.contains("framing_angle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_active_framing_reads_the_crop_rect() {
|
||||
let mut f = Framing::new();
|
||||
f.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
assert!(f.wgsl_prologue().contains("u.crop_rect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cropping_changes_the_output_size() {
|
||||
let mut f = Framing::new();
|
||||
f.set_crop(CropRect {
|
||||
x: 0.25,
|
||||
y: 0.25,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
assert!(f.is_active());
|
||||
assert_eq!(f.output_size(1000, 800), (500, 400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_swaps_the_output_axes() {
|
||||
// What makes a landscape frame come out portrait: the output is
|
||||
// genuinely taller than it is wide.
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(1);
|
||||
assert_eq!(f.output_size(6000, 4000), (4000, 6000));
|
||||
|
||||
f.rotate_quarters(1);
|
||||
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_applies_within_the_rotated_frame() {
|
||||
// Half of a rotated frame must be half of the *rotated* dimensions,
|
||||
// or a crop drawn on screen after a rotation lands somewhere else.
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(1);
|
||||
f.set_crop(CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 0.5,
|
||||
height: 1.0,
|
||||
});
|
||||
assert_eq!(f.output_size(6000, 4000), (2000, 6000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarter_turns_wrap_in_both_directions() {
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(-1);
|
||||
assert_eq!(f.quarter_turns(), 3);
|
||||
f.rotate_quarters(1);
|
||||
assert_eq!(f.quarter_turns(), 0);
|
||||
f.rotate_quarters(7);
|
||||
assert_eq!(f.quarter_turns(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_cannot_be_driven_degenerate() {
|
||||
// A zero-extent crop produces a zero-sized texture, which is a device
|
||||
// error rather than a visibly silly image.
|
||||
let mut f = Framing::new();
|
||||
f.set_crop(CropRect {
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
});
|
||||
let (w, h) = f.output_size(1000, 1000);
|
||||
assert!(w >= 1 && h >= 1);
|
||||
assert!(f.crop().width >= CropRect::MIN_EXTENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_pushed_past_the_edge_stays_inside() {
|
||||
let mut f = Framing::new();
|
||||
f.set_crop(CropRect {
|
||||
x: 0.8,
|
||||
y: 0.9,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
let c = f.crop();
|
||||
assert!(c.x + c.width <= 1.0 + 1e-6, "{c:?} extends past the edge");
|
||||
assert!(c.y + c.height <= 1.0 + 1e-6, "{c:?} extends past the edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nan_crop_falls_back_rather_than_producing_a_zero_texture() {
|
||||
// Worse than a wrong image: `NaN as u32` is 0, and a zero-sized
|
||||
// texture is a device error.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(CROP_W, f32::NAN);
|
||||
f.set_param(CROP_X, f32::INFINITY);
|
||||
let (w, h) = f.output_size(1000, 1000);
|
||||
assert!(w >= 1 && h >= 1);
|
||||
assert!(f.crop().width.is_finite() && f.crop().x.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_size_rounds_rather_than_truncating() {
|
||||
// Truncation biases every crop smaller; half of 101 should be 51.
|
||||
let mut f = Framing::new();
|
||||
f.set_crop(CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
assert_eq!(f.output_size(101, 101), (51, 51));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarter_turns_and_flips_need_no_interpolation() {
|
||||
// Why 90° steps are handled apart from the free angle: they have an
|
||||
// exact answer and must not be resampled.
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(1);
|
||||
f.set_param(FLIP_H, 1.0);
|
||||
assert!(f.is_active());
|
||||
assert!(!f.needs_interpolation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_free_angle_needs_interpolation() {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, 1.5);
|
||||
assert!(f.needs_interpolation());
|
||||
assert!(f.wgsl_prologue().contains("u.framing_angle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_corrects_for_aspect_across_the_swap() {
|
||||
// `p` is scaled by the source aspect, so a permutation that exchanges
|
||||
// the axes has to undo and reapply it. Without that a 90° turn on a
|
||||
// 3:2 frame comes out stretched.
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(1);
|
||||
assert!(f.wgsl_prologue().contains("aspect.x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_structure_key_ignores_magnitudes() {
|
||||
// What the pipeline cache depends on: dragging the straighten slider
|
||||
// or the crop handles must not recompile.
|
||||
let mut a = Framing::new();
|
||||
a.set_param(ANGLE, 1.0);
|
||||
let mut b = Framing::new();
|
||||
b.set_param(ANGLE, 4.0);
|
||||
assert_eq!(a.structure_key(), b.structure_key());
|
||||
assert_eq!(a.wgsl_prologue(), b.wgsl_prologue());
|
||||
assert_ne!(a.uniforms(), b.uniforms());
|
||||
|
||||
let mut c = Framing::new();
|
||||
c.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
let mut d = Framing::new();
|
||||
d.set_crop(CropRect {
|
||||
x: 0.2,
|
||||
y: 0.2,
|
||||
width: 0.4,
|
||||
height: 0.4,
|
||||
});
|
||||
assert_eq!(c.structure_key(), d.structure_key());
|
||||
assert_eq!(c.wgsl_prologue(), d.wgsl_prologue());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_transforms_take_different_structure_keys() {
|
||||
// The other half of the cache contract: framing that generates
|
||||
// different code must not reuse another's pipeline.
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
seen.insert(Framing::new().structure_key());
|
||||
|
||||
let mut cropped = Framing::new();
|
||||
cropped.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
assert!(seen.insert(cropped.structure_key()));
|
||||
|
||||
let mut angled = Framing::new();
|
||||
angled.set_param(ANGLE, 2.0);
|
||||
assert!(seen.insert(angled.structure_key()));
|
||||
|
||||
let mut flipped = Framing::new();
|
||||
flipped.set_param(FLIP_H, 1.0);
|
||||
assert!(seen.insert(flipped.structure_key()));
|
||||
|
||||
for turns in 1..=3 {
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(turns);
|
||||
assert!(seen.insert(f.structure_key()), "{turns} quarter turns");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_round_trip() {
|
||||
let mut f = Framing::new();
|
||||
for (id, v) in [
|
||||
(ANGLE, 2.5),
|
||||
(ROTATION, 2.0),
|
||||
(FLIP_H, 1.0),
|
||||
(FLIP_V, 1.0),
|
||||
(CROP_X, 0.1),
|
||||
(CROP_Y, 0.2),
|
||||
(CROP_W, 0.5),
|
||||
(CROP_H, 0.4),
|
||||
] {
|
||||
f.set_param(id, v);
|
||||
assert_eq!(f.param(id), v, "{id} did not round-trip");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_default_leaves_the_stage_neutral() {
|
||||
// The same contract the operations honour, checked against the
|
||||
// descriptor rather than a literal.
|
||||
let mut f = Framing::new();
|
||||
for p in DESCRIPTOR.params {
|
||||
f.set_param(p.id, p.default);
|
||||
}
|
||||
assert!(!f.is_active(), "descriptor defaults must be neutral");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_default_is_within_its_declared_range() {
|
||||
for p in DESCRIPTOR.params {
|
||||
assert_eq!(p.clamp(p.default), p.default, "{} is out of range", p.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_parameter_is_declared_twice() {
|
||||
let mut ids: Vec<&str> = DESCRIPTOR.params.iter().map(|p| p.id.0).collect();
|
||||
let before = ids.len();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(before, ids.len(), "framing has a duplicate parameter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_returns_to_neutral() {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, 3.0);
|
||||
f.rotate_quarters(1);
|
||||
f.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.3,
|
||||
});
|
||||
assert!(f.is_active());
|
||||
|
||||
f.reset();
|
||||
assert!(!f.is_active());
|
||||
assert_eq!(f.wgsl_prologue(), Framing::new().wgsl_prologue());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniforms_carry_the_angle_as_sin_and_cos() {
|
||||
// The shader never sees degrees: converting here keeps a trig call
|
||||
// out of every pixel.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, 90.0);
|
||||
let u = f.uniforms();
|
||||
assert!(
|
||||
(u[4] - 1.0).abs() < 1e-6,
|
||||
"sin(90°) should be 1, got {}",
|
||||
u[4]
|
||||
);
|
||||
assert!(u[5].abs() < 1e-6, "cos(90°) should be 0, got {}", u[5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniforms_are_always_finite() {
|
||||
// One NaN in the uniform block blanks every pixel.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, f32::NAN);
|
||||
f.set_param(CROP_W, f32::NAN);
|
||||
assert!(
|
||||
f.uniforms().iter().all(|v| v.is_finite()),
|
||||
"{:?}",
|
||||
f.uniforms()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_uniform_block_is_vec4_aligned() {
|
||||
// Emitted as whole `vec4`s; a size not divisible by four would
|
||||
// misalign every operation uniform that follows it.
|
||||
assert_eq!(FRAMING_UNIFORM_FIELDS % 4, 0);
|
||||
assert_eq!(Framing::new().uniforms().len(), FRAMING_UNIFORM_FIELDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inscribed_crop_of_an_unrotated_image_is_the_whole_frame() {
|
||||
assert!(Framing::new().max_inscribed_crop(6000, 4000).is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inscribed_crop_shrinks_as_the_angle_grows() {
|
||||
// Straightening further must cut in further; anything else leaves
|
||||
// undefined corners inside the frame.
|
||||
let mut small = Framing::new();
|
||||
small.set_param(ANGLE, 2.0);
|
||||
let mut large = Framing::new();
|
||||
large.set_param(ANGLE, 10.0);
|
||||
|
||||
let a = small.max_inscribed_crop(6000, 4000);
|
||||
let b = large.max_inscribed_crop(6000, 4000);
|
||||
assert!(a.width > b.width, "{} should exceed {}", a.width, b.width);
|
||||
assert!(a.width < 1.0, "a rotated frame cannot keep its full width");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inscribed_crop_is_centred_and_inside_the_frame() {
|
||||
for angle in [1.0f32, 5.0, 15.0, 30.0, 45.0, -7.5] {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, angle);
|
||||
for (w, h) in [(6000u32, 4000u32), (4000, 6000), (3000, 3000)] {
|
||||
let c = f.max_inscribed_crop(w, h);
|
||||
assert!(
|
||||
c.width > 0.0 && c.height > 0.0,
|
||||
"{angle}° on {w}x{h}: {c:?} is degenerate"
|
||||
);
|
||||
assert!(
|
||||
c.x + c.width <= 1.0 + 1e-4 && c.y + c.height <= 1.0 + 1e-4,
|
||||
"{angle}° on {w}x{h}: {c:?} extends past the frame"
|
||||
);
|
||||
assert!(
|
||||
((c.x + c.width * 0.5) - 0.5).abs() < 1e-4,
|
||||
"{angle}° on {w}x{h}: {c:?} is not centred"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inscribed_crop_contains_no_undefined_area() {
|
||||
// The property the derivation exists for, checked directly: every
|
||||
// corner of the inscribed rect, mapped through the same transform the
|
||||
// shader applies, must land inside the source.
|
||||
for angle in [1.0f32, 5.0, 15.0, 30.0, 45.0, -12.0] {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, angle);
|
||||
let (w, h) = (6000.0f32, 4000.0f32);
|
||||
let c = f.max_inscribed_crop(6000, 4000);
|
||||
|
||||
let rad = angle * PI / 180.0;
|
||||
let (sn, cs) = (rad.sin(), rad.cos());
|
||||
let aspect = w / h;
|
||||
|
||||
for (fx, fy) in [
|
||||
(c.x, c.y),
|
||||
(c.x + c.width, c.y),
|
||||
(c.x, c.y + c.height),
|
||||
(c.x + c.width, c.y + c.height),
|
||||
] {
|
||||
let (px, py) = ((fx - 0.5) * aspect, fy - 0.5);
|
||||
let (rx, ry) = (px * cs - py * sn, px * sn + py * cs);
|
||||
let (ux, uy) = (rx / aspect + 0.5, ry + 0.5);
|
||||
assert!(
|
||||
(-1e-3..=1.0 + 1e-3).contains(&ux) && (-1e-3..=1.0 + 1e-3).contains(&uy),
|
||||
"{angle}°: corner ({fx}, {fy}) maps to ({ux}, {uy}), outside the source"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user