Compare commits

...
Author SHA1 Message Date
Claude 8032bac09a Merge pr/1459 2026-10-09 01:37:48 +00:00
Claude 01827acd0e Merge pr/1460 2026-10-09 01:37:47 +00:00
Claude d8eddfc4db Merge pr/1470 2026-10-09 01:37:47 +00:00
Claude 1d4b4df84a Merge pr/1471 2026-10-09 01:37:46 +00:00
Claude 71ae0c43e9 Merge pr/1473 2026-10-09 01:37:46 +00:00
Claude c94c4e24f7 Merge pr/1475 2026-10-09 01:37:45 +00:00
Claude 3706915515 Merge pr/1483 2026-10-09 01:37:45 +00:00
Claude 705963c8eb Merge pr/1484 2026-10-09 01:37:44 +00:00
Claude 993e46f26f Merge pr/1491 2026-10-09 01:37:44 +00:00
Claude d9728f1aff Merge pr/1492 2026-10-09 01:37:43 +00:00
Claude 1f34dad82f Merge pr/1495 2026-10-09 01:37:42 +00:00
Claude 935ac5b44c Merge pr/1496 2026-10-09 01:37:42 +00:00
Claude 893d5cd575 Merge pr/1500 2026-10-09 01:37:42 +00:00
Claude 16accc1553 Merge pr/1502 2026-10-09 01:37:41 +00:00
David MannandClaude Opus 5.5 a25ddbb5c8 Selection tools: arrow keys nudge the selection outline (#1428)
With a marquee, lasso, wand or other selection tool active, the arrow
keys now nudge the selection outline 1 px (Shift: 10 px) through
select.transformSelection, one undo step per press; the pixels stay put.
A floating piece (Cmd-dragged pixels) moves instead. Before, the arrows
did nothing unless the Move tool was active.

Also fixes the Move tool's arrow-key modifiers: egui's consume_key
ignores extra Shift/Alt, so the plain arrow matched first and Shift+arrow
nudged 1 px instead of 10 and Alt+arrow did not duplicate. The most
specific modifiers are now tried first.

Dragging inside the selection already moved the outline, with the move
cursor on hover (#686); a regression test now covers the cursor.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 02:01:14 +02:00
Claude Opus 5.5 436af1aace Mesh warp of a gray surface writes the old and warped regions separately (#995)
warp_mesh_gray (select.transformSelection with a warp mesh) cleared the
source and warped regions as one dense buffer over their union, so a mesh
that moves a selection far away allocated a buffer growing with the square
of the distance: about 37 GiB of floats for a 48 px selection moved
100000 px. b70fbd4 fixed the same thing in the homography path
(transform_cmds::warp_gray); this does the same here: clear the old region,
then write the warped one. Surface is tile-sparse, so a distant region
costs only its own tiles.

Moving a 48 px selection 8192 px, peak RSS of a test binary went from
346 MB to 3 MB (0.41 s to under 0.01 s).

The test warps 8- and 32-bit gray selections by ±100000 px and 300 px and
checks the result is the translated block with the old region cleared.
2026-10-08 19:55:56 -04:00
David MannandClaude Opus 5.5 546a92a3e2 Apply the transform when another layer is selected (#1400)
While Free Transform was open (Ctrl+T, or after dragging a Move-tool
transform control), selecting another layer in the Layers panel left the
box and the lifted preview on the old layer. end_if_left only ended the
session when its layer or document went away or the tool changed.

It now also commits the session when the active layer is no longer the
one being transformed, like Photoshop applying the transform on a layer
switch; the next transform (or the Move tool's controls) then starts on
the newly selected layer. Transform Selection is not tied to a layer and
stays open.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 01:51:15 +02:00
harrisonoconnorhover 4146dcf08f Preserve document depth when splitting vector shapes 2026-10-08 19:48:03 -04:00
Claude Opus 5.5 38076a3750 Puppet Warp: pins that stretch the content too far are refused (#1019)
edit.puppetWarp accepted pin destinations that are finite but arbitrarily
far away, and the triangle renderer sizes its tile, bin and lookup
collections from the deformed mesh's bounds: a pin dragged to (2^20, 2^20)
meant 17.6 million tiles (at least 846 MB of bookkeeping) before a pixel
was drawn.

- algo: MAX_WARP_PIXELS (2^30, about 16000 tiles; the Select > Modify
  budget). puppet_warp returns None when the deformed mesh's bounding box
  exceeds it or isn't finite, checked after the solve and before render.
- engine: edit.puppetWarp returns a bad-params error and the edit is
  discarded (layer unchanged, no history step). run_on_layer's and
  deform_selected's warp callbacks now return Result (a LayerFn alias);
  Liquify and Perspective Warp wrap theirs in Ok.
- A stored Puppet Warp smart filter too far to render leaves the smart
  object's pixels as they were.

Tests: algo (far pins refused, a near pin renders) and engine (the issue's
command refused at 8/16/32-bit, with and without a selection, layer and
history unchanged; a rigid move and a 4000 px pin still work; smart object
unchanged).
2026-10-08 19:43:50 -04:00
Jeremy 1de3970681 Canvas: checker moves with the image; pixel grid from 500%, over pixels only
The GPU canvas anchored the checker to the top-left of the canvas viewport,
while the pixel grid sits on document pixel edges, so panning or zooming
slid the checker under the image and out of step with the grid. The checker
is now measured from the document origin; its 8 screen pixel cell is unchanged.

The pixel grid appeared from 800% (1200% on the CPU fallback), was faint and
was drawn across empty pixels. It now shows above 500%, lightens a dark pixel
by a quarter, and is skipped where the pixel is transparent (GPU canvas).

New offscreen render tests in tests/checker_canvas.rs cover cell size at nine
zooms, the phase, the grid threshold and strength, and the grid over empty
checker.
2026-10-09 00:42:17 +01:00
David MannandClaude Opus 5.5 d6a8a5a680 Liquify: Alt reverses Twirl and swaps Pucker/Bloat, as in Photoshop (#1442)
Holding Alt (Option) at pointer-down draws the stroke with the selected
brush's opposite: Twirl Clockwise twirls counterclockwise (and back),
Pucker bloats and Bloat puckers. The selected tool is left unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 01:28:07 +02:00
Claude Opus 5.5 1de2726b51 .pcraft: a tile index outside the i32 pixel range is a corrupt file (#938)
The loader accepted a manifest tile at any i32 index. TileCoord::rect
multiplies the index by the tile size, so an index past ±2²³ loaded fine
and then panicked ("attempt to multiply with overflow", geom/src/lib.rs)
wherever the layer's bounds were asked for: the status bar, Properties,
edit.transform, the compositor. Release builds wrapped to a rectangle
unrelated to the tile.

Loading now refuses a tile whose rectangle doesn't fit in i32 on both edges
(indices -8388608 ..= 8388606) with FormatError::Corrupt, as it refuses a
duplicate tile. In-app editing can't produce such an index; only a damaged
or crafted manifest can.

The test moves a raster layer's tile to just past each end of the range and
to i32::MAX / i32::MIN (refused), and to the extreme valid indices (loaded,
bounds computed). Before the fix the out-of-range bundles loaded, and asking
the layer for its content bounds panicked at the multiply.
2026-10-08 19:17:47 -04:00
UnemarsandClaude Opus 5.5 8338e91043 Camera RAW: a measured Nikon D4 profile (image area, white level, colour matrix, tone curve)
NEF carries no colour description, so D4 frames developed with the camera channels taken as
sRGB primaries and no tone curve (dark, pale), the sensor's optical-black columns showed as a
purple stripe on the right, and clipped highlights turned pink (green saturates first).

crates/raw/src/cameras.rs holds camera profiles measured from the cameras' own output (no
third-party tables), applied to TIFF/EP raws by make, model and sensor size. Sensor gains
tone_curve (like DNG ProfileToneCurve, per channel in linear ProPhoto), folded into the gamma
LUT; other cameras develop exactly as before.

Nikon D4, from 148 uncompressed NEFs of one body (ISO 100–10000):
- image area 4928x3280 at (8, 6): where the camera's own full-size JPEG sits, found by aligning
  the two (2 columns of optical black on the left, 50 + 8 on the right);
- black 0 (the data is black-subtracted in camera: the darkest image pixels reach 0);
- white 15520: green saturates at 15546–15672, red and blue at 15687–16383, so one level just
  below green makes clipped highlights white without moving the white balance;
- ForwardMatrix (white-balanced camera → XYZ D50) and tone curve fitted to the embedded JPEGs
  (Picture Control Standard, Active D-Lighting off; 52 frames, smooth areas). On 15 held-out
  frames PhotoCraft's development is 7.0/255 per channel from the camera's JPEG on average,
  against 34 before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 00:54:18 +02:00
optimalbytee 714414e3a8 Speed up wide Motion Blur with adaptive FFT convolution
Keep the merged upstream row kernel for shorter streaks and unsupported inputs.
Use bounded overlap-save convolution of its merged bilinear coefficients when
channel pairs, parallelism and memory estimates favor FFT. Preserve alpha cutoff
behavior, cancellation, selections and depth/model-neutral filtering.

Validate 303 algorithm tests in debug and release, plus native checks and full
image comparisons. Repeated current-upstream comparisons show 2.31x at 24 MP
and 4.15x at 1.5 MP for angle 30 / distance 1000 on the measured host.
2026-10-08 23:47:07 +01:00
Claude Opus 5.5 b36f07b999 Crop: an explicit rectangle past 300000 px per side is refused (#960)
image.crop took any explicit width and height, so a crop to 100000000 x
100000000 succeeded (the tiles are lazy) and left a canvas that a following
full-canvas flatten, such as image.trim or image.duplicate with mergedOnly,
could only abort on allocating.

An explicit crop now refuses a side over 300000 px with a bad-params error,
before the document changes. That is the limit file.new, Image Size and
Canvas Size already use. Extending the canvas up to it still works.

The test checks 1e8 x 1e8, 300001 x 10 and 10 x 300001 are refused with
the document and history unchanged, and that 300000 x 2 still extends the
canvas. It failed before the fix (the 1e8 crop returned Ok).
2026-10-08 18:46:06 -04:00
Nigel Ihza Firdausy e3af1b82a8 style(psd): apply rustfmt layout to exporter compatibility tests 2026-10-09 05:20:18 +07:00
Nigel Ihza Firdausy 000ef5a842 style(psd): format Photo Filter golden-block regression test 2026-10-09 05:20:13 +07:00
Nigel Ihza Firdausy 7102fc37c7 test(psd): full exporter/importer roundtrip and repair of legacy invalid phfl blocks 2026-10-09 05:18:36 +07:00
Nigel Ihza Firdausy 4654d4338c fix(psd): normalize legacy 18-byte Photo Filter blocks on re-save 2026-10-09 05:18:13 +07:00
Nigel Ihza Firdausy a5c604d6cb test(psd): compare quantized Photo Filter channels with encoding tolerance 2026-10-09 05:17:22 +07:00
Nigel Ihza Firdausy e9ffa50cc2 test(psd): golden phfl length, padding, legacy compatibility and Photo Filter variants 2026-10-09 05:17:10 +07:00
Nigel Ihza Firdausy b20aaab920 fix(psd): emit Photo Filter phfl block with required four-byte alignment (#1455) 2026-10-09 05:16:55 +07:00
harrisonoconnorhover 1de4f2d5ce Use rotated preview dimensions on the GPU canvas 2026-10-08 18:11:51 -04:00
StreetblockandGPT-6 60a89db6f8 Fix PNG text metadata decode budget bypass
Bound decoded PNG text and XMP by one shared max_alloc budget before
growing output, retaining CRC validation and malformed ancillary handling.
Cover compressed, plain, Unicode and post-IDAT metadata with small
synthetic regression fixtures, and document the budget's scope.

Opt Streetblock into public GitHub profile-name credits at their request.

Co-Authored-By: GPT-6 (Codex) <[email protected]>
2026-10-09 00:01:44 +02:00
46 changed files with 3411 additions and 165 deletions

No files matched your search

Generated
+40
View File
@@ -3540,6 +3540,7 @@ dependencies = [
"photocraft-geom",
"photocraft-raster",
"rayon",
"rustfft",
"serde",
"serde_json",
]
@@ -4069,6 +4070,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
[[package]]
name = "primal-check"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
dependencies = [
"num-integer",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -4574,6 +4584,20 @@ dependencies = [
"semver",
]
[[package]]
name = "rustfft"
version = "6.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
dependencies = [
"num-complex",
"num-integer",
"num-traits",
"primal-check",
"strength_reduce",
"transpose",
]
[[package]]
name = "rustix"
version = "0.38.44"
@@ -4993,6 +5017,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
name = "strict-num"
version = "0.1.1"
@@ -5333,6 +5363,16 @@ dependencies = [
"once_cell",
]
[[package]]
name = "transpose"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
dependencies = [
"num-integer",
"strength_reduce",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
+3
View File
@@ -22,6 +22,9 @@ real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.Streetblock]
sync_github_name = true
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
+1
View File
@@ -14,6 +14,7 @@ photocraft-geom = { workspace = true }
photocraft-color = { workspace = true }
photocraft-raster = { workspace = true }
serde = { workspace = true }
rustfft = "6.4.1"
# Parallel tiles on native; wasm builds stay single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+12
View File
@@ -7,6 +7,18 @@ use crate::image::{Edge, Image, premultiply, unpremultiply};
use crate::photo_util::{par_map, par_rows};
use crate::{Ctx, FilterParams, RadialMethod, RadialQuality};
#[cfg(test)]
use self::motion as motion_rows;
pub(crate) mod motion_apply;
#[cfg(all(test, not(target_arch = "wasm32")))]
mod motion_bench;
mod motion_fft;
#[cfg(test)]
mod motion_fft_tests;
#[cfg(test)]
mod motion_tests;
/// Normalized Gaussian kernel with standard deviation `sigma` (radius 3σ).
pub(crate) fn gaussian_kernel(sigma: f32) -> Vec<f32> {
if sigma < 0.05 {
+292
View File
@@ -0,0 +1,292 @@
//! Bounded tile scheduling for wide Motion Blur convolution.
use photocraft_geom::Rect;
use photocraft_raster::{Interrupt, Surface};
use super::motion_fft::{COORDINATE_LIMIT, Plan, ordinary_coordinates, worthwhile};
use crate::{FilterParams, Image};
const MAX_RESIDENT_BYTES: usize = 512 << 20;
const MIN_TILE: i32 = 64;
const MAX_TILE: i32 = 512;
fn valid_rect(rect: Rect) -> bool {
!rect.is_empty() && rect.width() <= COORDINATE_LIMIT as u32 && rect.height() <= COORDINATE_LIMIT as u32 && ordinary_coordinates(rect)
}
fn bytes(rect: Rect, channels: usize) -> Option<usize> {
usize::try_from(rect.width()).ok()?.checked_mul(usize::try_from(rect.height()).ok()?)?.checked_mul(channels)?.checked_mul(std::mem::size_of::<f32>())
}
fn group_size(resident: usize, metadata: usize, worker: usize, output: usize, tiles: usize, workers: usize) -> Option<usize> {
if tiles == 0 || workers == 0 {
return None;
}
let available = MAX_RESIDENT_BYTES.checked_sub(resident)?.checked_sub(metadata)?;
let per_tile = worker.checked_add(output)?;
let memory_workers = available.checked_div(per_tile.max(1))?;
(memory_workers > 0).then_some(memory_workers.min(workers).min(tiles))
}
/// `None` declines the specialization; `Some(None)` propagates cancellation. Input pixels are
/// never edited, including when cancellation discards a partially written output clone.
#[allow(clippy::too_many_arguments)]
pub(crate) fn apply(
surface: &Surface,
params: &FilterParams,
area: Rect,
bounds: Rect,
selection: Option<&Surface>,
extent: Option<Rect>,
tile: i32,
ctl: &Interrupt,
) -> Option<Option<Surface>> {
apply_inner(surface, params, area, bounds, selection, extent, tile, ctl, false)
}
#[allow(clippy::too_many_arguments)]
#[cfg(test)]
pub(super) fn apply_for_bench(
surface: &Surface,
params: &FilterParams,
area: Rect,
bounds: Rect,
selection: Option<&Surface>,
extent: Option<Rect>,
tile: i32,
ctl: &Interrupt,
) -> Option<Option<Surface>> {
apply_inner(surface, params, area, bounds, selection, extent, tile, ctl, true)
}
#[allow(clippy::too_many_arguments)]
fn apply_inner(
surface: &Surface,
params: &FilterParams,
area: Rect,
bounds: Rect,
selection: Option<&Surface>,
extent: Option<Rect>,
tile: i32,
ctl: &Interrupt,
force: bool,
) -> Option<Option<Surface>> {
let FilterParams::MotionBlur { angle, distance } = *params else { return None };
if ctl.cancelled() {
return Some(None);
}
if area.is_empty() {
return Some(Some(surface.clone()));
}
let fmt = surface.format();
let channels = fmt.channels();
if !(1..=5).contains(&channels)
|| !valid_rect(area)
|| !valid_rect(bounds)
|| extent.is_some_and(|rect| !valid_rect(rect))
// read_clamped falls back to raw reads when its window misses the
// extent. A directional and square halo can differ in that case;
// preserve the old boundary policy for out-of-canvas output areas.
|| extent.is_some_and(|rect| area.intersect(&rect) != area)
|| selection.is_some_and(|selection| selection.channels() == 0)
{
return None;
}
let pixels = u64::from(area.width()).checked_mul(u64::from(area.height()))?;
if pixels < 4096 {
return None;
}
let row_tile = usize::try_from(tile).ok()?;
#[cfg(not(target_arch = "wasm32"))]
let workers = rayon::current_num_threads();
#[cfg(target_arch = "wasm32")]
let workers = 1;
let mut tile = tile.clamp(MIN_TILE, MAX_TILE);
while tile > MIN_TILE && (area.width() < tile as u32 || area.height() < tile as u32) {
tile = (tile / 2).max(MIN_TILE);
}
let tile_usize = usize::try_from(tile).ok()?;
if !force && !worthwhile(angle, distance, tile_usize, area, row_tile, channels, workers)? {
return None;
}
let plan = match Plan::new_with(angle, distance, tile_usize, ctl) {
Some(plan) => plan,
None if ctl.cancelled() => return Some(None),
None => return None,
};
if ctl.cancelled() {
return Some(None);
}
let columns = usize::try_from(area.width()).ok()?.div_ceil(tile_usize);
let rows = usize::try_from(area.height()).ok()?.div_ceil(tile_usize);
let count = columns.checked_mul(rows)?;
let metadata_bytes = count.checked_mul(std::mem::size_of::<(Rect, Rect)>())?;
let mut tiles = Vec::new();
tiles.try_reserve_exact(count).ok()?;
let mut largest_source_bytes = 0;
let mut y = area.y0;
while y < area.y1 {
let y1 = y.checked_add(tile)?.min(area.y1);
let mut x = area.x0;
while x < area.x1 {
let x1 = x.checked_add(tile)?.min(area.x1);
let output = Rect::new(x, y, x1, y1);
let source = plan.read_rect(output)?;
if !valid_rect(source) {
return None;
}
largest_source_bytes = largest_source_bytes.max(bytes(source, channels)?);
tiles.push((output, source));
x = x1;
}
y = y1;
}
// read_clamped temporarily holds both its inner read and expanded image. The convolution
// estimate includes its input and scratch, but that decoding peak needs a separate check.
let decode_bytes = largest_source_bytes.checked_mul(if extent.is_some() { 2 } else { 1 })?;
let worker_bytes = plan.worker_bytes(channels, tile_usize)?.max(decode_bytes);
let output_bytes = tile_usize.checked_mul(tile_usize)?.checked_mul(channels)?.checked_mul(std::mem::size_of::<f32>())?;
// A group's results remain live until its last worker finishes. Bounding the complete
// group by each tile's scratch plus output is conservative and avoids forcing one per core.
let group = group_size(plan.resident_bytes(), metadata_bytes, worker_bytes, output_bytes, tiles.len(), workers)?;
let finished = std::sync::atomic::AtomicUsize::new(0);
let run = |&(output, source): &(Rect, Rect)| -> Option<(Rect, Vec<f32>)> {
if ctl.cancelled() {
return None;
}
let src = match extent {
Some(extent) => Image::read_clamped(surface, source, extent),
None => Image::read(surface, source),
};
if ctl.cancelled() {
return None;
}
// Unsupported source values decline the entire specialization. The caller then
// retries its unchanged row pipeline, discarding this clone and any partial writes.
// Do not allocate a row kernel's square window inside the FFT working-set budget.
let mut data = plan.filter(&src, output, fmt.alpha, ctl)?;
if ctl.cancelled() || data.len().checked_mul(std::mem::size_of::<f32>())? != bytes(output, channels)? {
return None;
}
if let Some(selection) = selection {
crate::mix_selection(&mut data, output, selection, &src);
}
if ctl.cancelled() {
return None;
}
let done = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
ctl.progress(0.98 * done as f32 / count as f32);
Some((output, data))
};
let mut out = surface.clone();
for chunk in tiles.chunks(group) {
if ctl.cancelled() {
return Some(None);
}
#[cfg(not(target_arch = "wasm32"))]
let results: Option<Vec<_>> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Option<Vec<_>> = chunk.iter().map(run).collect();
let Some(results) = results else { return if ctl.cancelled() { Some(None) } else { None } };
for (output, data) in results {
if ctl.cancelled() {
return Some(None);
}
out.write_region(output, &data);
}
}
if ctl.cancelled() {
return Some(None);
}
out.prune();
if ctl.cancelled() {
return Some(None);
}
ctl.progress(1.0);
Some(Some(out))
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use photocraft_color::PixelFormat;
use super::*;
#[test]
fn scheduler_bounds_complete_groups_including_sources_and_outputs() {
let mib = 1 << 20;
let group = group_size(100 * mib, mib, 150 * mib, 5 * mib, 100, 12).unwrap();
assert_eq!(group, 2, "memory limits must take precedence over one tile per worker");
assert!(100 * mib + mib + group * (150 * mib + 5 * mib) <= MAX_RESIDENT_BYTES);
assert_eq!(group_size(100 * mib, 0, 450 * mib, 5 * mib, 100, 12), None);
assert_eq!(group_size(MAX_RESIDENT_BYTES, 1, 1, 1, 1, 1), None);
assert_eq!(group_size(0, 0, usize::MAX, 1, 1, 1), None);
assert_eq!(group_size(0, 0, 1, 1, 0, 1), None);
}
#[test]
fn scheduler_preflight_declines_unsupported_geometry_and_small_distances() {
let surface = Surface::new(PixelFormat::GRAY8);
let bounds = Rect::new(0, 0, 128, 128);
let small = FilterParams::MotionBlur { angle: 30.0, distance: 10.0 };
assert!(apply(&surface, &small, bounds, bounds, None, Some(bounds), 64, &Interrupt::NONE).is_none());
let wide = FilterParams::MotionBlur { angle: 30.0, distance: 64.0 };
let oversized = Rect::new(0, 0, i32::MAX, 128);
assert!(apply(&surface, &wide, oversized, bounds, None, Some(bounds), 64, &Interrupt::NONE).is_none());
assert!(apply(&surface, &wide, bounds, bounds, None, Some(Rect::EMPTY), 64, &Interrupt::NONE).is_none());
assert!(apply(&surface, &wide, Rect::new(0, 0, 1, 1), bounds, None, Some(bounds), 512, &Interrupt::NONE).is_none());
let huge = FilterParams::MotionBlur { angle: 30.0, distance: 2000.0 };
assert!(apply(&surface, &huge, bounds, bounds, None, Some(bounds), 512, &Interrupt::NONE).is_none());
assert!(!valid_rect(Rect::new(-COORDINATE_LIMIT, 0, COORDINATE_LIMIT, 1)));
assert!(apply(&surface, &wide, Rect::EMPTY, bounds, None, Some(bounds), 64, &Interrupt::NONE).unwrap().unwrap().tiles().next().is_none());
}
#[test]
fn crossover_keeps_rows_for_measured_losses_and_memory_limited_large_streaks() {
let proxy = Rect::new(0, 0, 1500, 1000);
let full = Rect::new(0, 0, 6000, 4000);
for workers in [1, 2, 4, 12] {
for channels in 1..=5 {
for angle in [0.0, 30.0, 45.0, 90.0] {
for distance in [10.0, 64.0, 128.0, 256.0] {
assert_eq!(worthwhile(angle, distance, 512, proxy, 576, channels, workers), Some(false));
}
}
}
}
for workers in [4, 12] {
assert_eq!(worthwhile(30.0, 1000.0, 512, proxy, 1024, 4, workers), Some(true));
assert_eq!(worthwhile(45.0, 2000.0, 512, proxy, 2048, 4, workers), Some(true));
assert_eq!(worthwhile(30.0, 2000.0, 512, full, 2048, 4, workers), Some(false));
}
assert_eq!(worthwhile(30.0, 1000.0, 512, full, 1024, 4, 12), Some(true));
assert_eq!(worthwhile(30.0, 1000.0, 512, proxy, 1024, 4, 1), Some(false));
assert_eq!(worthwhile(30.0, 1000.0, 64, Rect::new(0, 0, 64, 64), 1024, 4, 12), Some(false));
}
#[test]
fn scheduler_cancellation_discards_all_partial_output() {
let bounds = Rect::new(0, 0, 128, 128);
let mut surface = Surface::new(PixelFormat::GRAY8);
surface.fill_rect(bounds, &[0.4]);
let before = surface.to_interleaved(bounds);
let params = FilterParams::MotionBlur { angle: 0.0, distance: 64.0 };
let cancelled = AtomicBool::new(true);
let cancel = || cancelled.load(Ordering::Relaxed);
assert!(matches!(apply(&surface, &params, bounds, bounds, None, Some(bounds), 64, &Interrupt::cancel_only(&cancel)), Some(None)));
cancelled.store(false, Ordering::Relaxed);
let progress = |fraction: f32| {
if fraction > 0.0 {
cancelled.store(true, Ordering::Relaxed);
}
};
let ctl = Interrupt::new(&cancel, &progress);
assert!(matches!(apply_for_bench(&surface, &params, bounds, bounds, None, Some(bounds), 64, &ctl), Some(None)));
assert_eq!(surface.to_interleaved(bounds), before);
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Opt-in release comparisons against the Motion Blur row kernel merged in upstream PR #902.
#![cfg(not(target_arch = "wasm32"))]
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
use rayon::prelude::*;
use serde_json::json;
use std::time::Instant;
/// Copy of upstream's tiled apply_in pipeline, with its merged row kernel called directly so
/// the experimental FFT dispatch cannot change the baseline. Source reads, automatic tile
/// sizing, Rayon groups, writes, progress and pruning follow the current upstream implementation.
#[allow(clippy::panic)] // Fixed benchmark parameters, never a production entry point.
fn apply_reference(surface: &Surface, params: &FilterParams, area: Rect) -> Surface {
let FilterParams::MotionBlur { angle, distance } = params else { panic!("Motion Blur benchmark parameters required") };
let crate::Halo::Radius(halo) = params.halo_for(area) else { panic!("Motion Blur must declare a radius halo") };
let tile = crate::auto_tile(params, area);
let fmt = surface.format();
let ctx = Ctx { bounds: area, mode: fmt.mode, alpha: fmt.alpha };
let mut out = surface.clone();
let mut tiles = Vec::new();
let mut y = area.y0;
while y < area.y1 {
let mut x = area.x0;
while x < area.x1 {
tiles.push(Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1)));
x += tile;
}
y += tile;
}
let finished = std::sync::atomic::AtomicUsize::new(0);
let total = tiles.len().max(1);
let ctl = Interrupt::NONE;
let run = |t: &Rect| {
if ctl.cancelled() {
return (*t, Vec::new());
}
let src = Image::read_clamped(surface, t.inflate(halo), area);
let data = motion_rows(&src, *t, &ctx, *angle, *distance);
let n = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
ctl.progress(0.98 * n as f32 / total as f32);
(*t, data)
};
let per_tile = (tile.max(1) as usize).pow(2) * fmt.channels() * std::mem::size_of::<f32>();
let group = (crate::RESULT_BUDGET / per_tile.max(1)).max(rayon::current_num_threads()).max(1);
for chunk in tiles.chunks(group) {
let results: Vec<_> = chunk.par_iter().map(run).collect();
for (t, data) in results {
out.write_region(t, &data);
}
}
out.prune();
ctl.progress(1.0);
out
}
fn source(area: Rect, variable_alpha: bool, channels: usize) -> Surface {
let (mode, alpha) = match channels {
1 => (ColorMode::Grayscale, false),
2 => (ColorMode::Grayscale, true),
3 => (ColorMode::Rgb, false),
4 => (ColorMode::Rgb, true),
5 => (ColorMode::Cmyk, true),
_ => (ColorMode::Rgb, true),
};
let mut surface = Surface::new(PixelFormat::new(mode, SampleType::U8, alpha));
let bytes: Vec<u8> = (area.y0..area.y1)
.flat_map(|y| {
(area.x0..area.x1).flat_map(move |x| {
let (r, g, b) = ((x % 256) as u8, ((x * 3 + y) / 11 % 256) as u8, ((x ^ y) % 256) as u8);
let alpha = if variable_alpha { (x ^ (y * 7)) as u8 } else { 255 };
let pixel = match channels {
1 => [r, 0, 0, 0, 0],
2 => [r, alpha, 0, 0, 0],
3 => [r, g, b, 0, 0],
4 => [r, g, b, alpha, 0],
5 => [r, g, b, ((x * 5 + y * 7) % 256) as u8, alpha],
_ => [0; 5],
};
pixel.into_iter().take(channels)
})
})
.collect();
surface.write_interleaved(area, &bytes);
surface
}
fn median(values: &[f64]) -> f64 {
let mut sorted = values.to_vec();
sorted.sort_by(f64::total_cmp);
match sorted.get(sorted.len() / 2) {
Some(&value) if !sorted.len().is_multiple_of(2) => value,
Some(&value) => (value + sorted.get(sorted.len() / 2 - 1).copied().unwrap_or(value)) * 0.5,
None => 0.0,
}
}
#[allow(clippy::panic)] // Invalid benchmark configuration should fail visibly.
fn env_number(name: &str, default: u32) -> u32 {
match std::env::var(name) {
Ok(value) => match value.parse() {
Ok(value) => value,
Err(_) => panic!("invalid benchmark number {name}={value}"),
},
Err(_) => default,
}
}
#[allow(clippy::panic)] // Invalid benchmark configuration should fail visibly.
fn cases() -> Vec<(f32, f32)> {
let spec = std::env::var("PHOTOCRAFT_MOTION_CASES")
.unwrap_or_else(|_| "30:10,30:50,30:64,30:128,30:256,30:500,30:1000,30:2000,0:256,0:1000,0:2000,90:256,90:1000,90:2000,45:256,45:1000,45:2000".into());
spec.split(',')
.map(|case| {
let Some((angle, distance)) = case.trim().split_once(':') else { panic!("expected angle:distance in PHOTOCRAFT_MOTION_CASES") };
let (Ok(angle), Ok(distance)) = (angle.parse::<f32>(), distance.parse::<f32>()) else { panic!("invalid Motion Blur benchmark case {case}") };
assert!(angle.is_finite() && (0.5..=2000.0).contains(&distance), "out-of-range benchmark case {case}");
(angle, distance)
})
.collect()
}
#[allow(clippy::panic)] // A cancellation with Interrupt::NONE is a benchmark failure.
fn candidate(surface: &Surface, params: &FilterParams, area: Rect, forced: bool) -> (Surface, bool) {
if forced {
match super::motion_apply::apply_for_bench(surface, params, area, area, None, Some(area), crate::auto_tile(params, area), &Interrupt::NONE) {
Some(Some(result)) => return (result, true),
Some(None) => panic!("uncancellable forced FFT benchmark was cancelled"),
None => {}
}
}
(crate::apply_in(surface, params, area, area, None, area), false)
}
fn compare(surface: &Surface, area: Rect, angle: f32, distance: f32, runs: usize, forced: bool, variable_alpha: bool) {
let params = FilterParams::MotionBlur { angle, distance };
let channels = surface.channels();
let mut upstream_ms = Vec::with_capacity(runs);
let mut candidate_ms = Vec::with_capacity(runs);
let mut largest_error = 0u8;
let mut changed_samples = 0usize;
let mut squared_error = 0u64;
let mut compared_samples = 0usize;
let mut fft_applied = false;
for run in 0..runs {
let upstream = || {
let start = Instant::now();
let result = apply_reference(surface, &params, area);
(result, start.elapsed().as_secs_f64() * 1000.0)
};
let after = || {
let start = Instant::now();
let (result, used_fft) = candidate(surface, &params, area, forced);
(result, start.elapsed().as_secs_f64() * 1000.0, used_fft)
};
let ((before, before_ms), (after, after_ms, used_fft)) = if run % 2 == 0 {
(upstream(), after())
} else {
let after = after();
(upstream(), after)
};
upstream_ms.push(before_ms);
candidate_ms.push(after_ms);
fft_applied |= used_fft;
let before = before.to_interleaved(area);
let after = after.to_interleaved(area);
assert_eq!(before.len(), after.len());
let mut maximum = 0u8;
let mut changed = 0usize;
let mut squared = 0u64;
for (&before, &after) in before.iter().zip(&after) {
let error = before.abs_diff(after);
maximum = maximum.max(error);
changed += usize::from(error != 0);
squared += u64::from(error).pow(2);
}
assert!(maximum <= 1, "upstream comparison exceeded one U8 level: angle={angle}, distance={distance}, error={maximum}");
largest_error = largest_error.max(maximum);
changed_samples += changed;
squared_error += squared;
compared_samples += before.len();
println!(
"{}",
json!({"event":"motion_upstream_run", "width":area.width(), "height":area.height(), "channels":channels, "angle":angle, "distance":distance, "run":run, "mode":if forced {"forced_fft"} else {"public"}, "fft_applied":if forced {Some(used_fft)} else {None}, "variable_alpha":variable_alpha, "upstream_ms":before_ms, "candidate_ms":after_ms, "max_u8_error":maximum, "changed_samples":changed, "rms_u8_error":(squared as f64 / before.len() as f64).sqrt()})
);
}
println!(
"{}",
json!({"event":"motion_upstream_median", "width":area.width(), "height":area.height(), "channels":channels, "angle":angle, "distance":distance, "runs":runs, "mode":if forced {"forced_fft"} else {"public"}, "fft_applied":if forced {Some(fft_applied)} else {None}, "variable_alpha":variable_alpha, "upstream_median_ms":median(&upstream_ms), "candidate_median_ms":median(&candidate_ms), "speedup":median(&upstream_ms)/median(&candidate_ms), "upstream_runs_ms":upstream_ms, "candidate_runs_ms":candidate_ms, "max_u8_error":largest_error, "changed_samples":changed_samples, "rms_u8_error":(squared_error as f64 / compared_samples as f64).sqrt()})
);
}
/// Reproduce in release with --ignored --nocapture. PHOTOCRAFT_MOTION_WIDTH/HEIGHT default to
/// 1500/1000; PHOTOCRAFT_MOTION_RUNS defaults to 3; PHOTOCRAFT_MOTION_CASES is comma-separated
/// angle:distance pairs. PHOTOCRAFT_MOTION_MODE=public measures final dispatch, otherwise forced
/// FFT is attempted (normal eligibility/memory guards remain). PHOTOCRAFT_MOTION_CHANNELS=1..5
/// selects Gray, Gray-alpha, RGB, RGBA (default), or CMYK-alpha. PHOTOCRAFT_MOTION_VARIABLE_ALPHA=1
/// enables varying alpha in formats that have alpha. Timings include reads, writes and pruning, excluding
/// source construction, output comparison, proxy construction, compositing and GPU upload.
#[test]
#[ignore = "upstream row kernel versus FFT/full-dispatch release calibration"]
fn motion_upstream_performance() {
let width = env_number("PHOTOCRAFT_MOTION_WIDTH", 1500);
let height = env_number("PHOTOCRAFT_MOTION_HEIGHT", 1000);
let runs = env_number("PHOTOCRAFT_MOTION_RUNS", 3) as usize;
let channels = env_number("PHOTOCRAFT_MOTION_CHANNELS", 4) as usize;
assert!((1..=16384).contains(&width) && (1..=16384).contains(&height));
assert!((1..=31).contains(&runs));
assert!((1..=5).contains(&channels));
let area = Rect::new(0, 0, width as i32, height as i32);
let mode = std::env::var("PHOTOCRAFT_MOTION_MODE").unwrap_or_else(|_| "forced_fft".into());
assert!(matches!(mode.as_str(), "public" | "forced_fft"), "PHOTOCRAFT_MOTION_MODE must be public or forced_fft");
let forced = mode == "forced_fft";
let variable_alpha = matches!(channels, 2 | 4 | 5) && std::env::var("PHOTOCRAFT_MOTION_VARIABLE_ALPHA").is_ok_and(|value| value != "0" && value != "false");
println!(
"{}",
json!({"event":"motion_upstream_config", "width":width, "height":height, "channels":channels, "runs":runs, "mode":if forced {"forced_fft"} else {"public"}, "variable_alpha":variable_alpha, "rayon_workers":rayon::current_num_threads(), "os":std::env::consts::OS, "arch":std::env::consts::ARCH, "baseline":"merged upstream PR902 motion_rows plus current upstream tiled pipeline", "timing_scope":"source decode/halo reads, filtering, writes, pruning; excludes construction/comparison/UI"})
);
let surface = source(area, variable_alpha, channels);
for (angle, distance) in cases() {
compare(&surface, area, angle, distance, runs, forced, variable_alpha);
}
}
+515
View File
@@ -0,0 +1,515 @@
//! Overlap-save convolution of the discrete Motion Blur kernel.
//!
//! The spectrum uses the upstream row kernel's merged f32 coefficients, with
//! no intermediate image resampling. Transforms use doubles and keep alpha
//! premultiplied until the final output. The summation order differs from the
//! row kernel, which remains the fallback.
use std::sync::Arc;
use photocraft_geom::Rect;
use photocraft_raster::Interrupt;
use rustfft::{Fft, FftPlanner, num_complex::Complex};
use crate::Image;
const MAX_POINTS: usize = 4 << 20;
const MAX_SIDE: usize = 8192;
const MAX_BYTES: usize = 128 << 20;
const MAX_SAMPLE: f32 = 1e10;
pub(super) const COORDINATE_LIMIT: i32 = 16_384;
type Number = Complex<f64>;
fn buffer<T: Clone>(len: usize, value: T) -> Option<Vec<T>> {
if len.checked_mul(std::mem::size_of::<T>())? > MAX_BYTES {
return None;
}
let mut result = Vec::new();
result.try_reserve_exact(len).ok()?;
result.resize(len, value);
Some(result)
}
fn dimensions(rect: Rect) -> Option<(usize, usize)> {
Some((usize::try_from(i64::from(rect.x1) - i64::from(rect.x0)).ok()?, usize::try_from(i64::from(rect.y1) - i64::from(rect.y0)).ok()?))
}
pub(super) fn ordinary_coordinates(rect: Rect) -> bool {
[rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| (-COORDINATE_LIMIT..=COORDINATE_LIMIT).contains(v))
}
// Smooth lengths avoid the almost fourfold padding of a 512px tile plus a
// short halo to a 1024x1024 power-of-two transform. RustFFT supports these
// mixed-radix lengths efficiently.
fn fft_size(needed: usize) -> Option<usize> {
if needed == 0 || needed > MAX_SIDE {
return None;
}
let mut best = MAX_SIDE;
let mut a = 1usize;
while a <= MAX_SIDE {
let mut b = a;
while b <= MAX_SIDE {
let mut c = b;
while c <= MAX_SIDE {
if c >= needed {
best = best.min(c);
}
c = c.checked_mul(5)?;
}
b = b.checked_mul(3)?;
}
a = a.checked_mul(2)?;
}
Some(best)
}
#[derive(Clone, Copy)]
struct Tap {
x: i32,
y: i32,
weight: f64,
}
fn taps(angle: f32, distance: f32, clip: Option<(Rect, Rect)>) -> Option<Vec<Tap>> {
let distance = distance.abs();
if !angle.is_finite() || !(64.0..=2000.0).contains(&distance) {
return None;
}
let steps = distance.ceil() as i32;
let (sin, cos) = angle.to_radians().sin_cos();
let norm = 1.0 / (steps + 1) as f32;
let capacity = usize::try_from(steps + 1).ok()?.checked_mul(4)?;
let mut merged: Vec<(i32, i32, f32)> = Vec::new();
merged.try_reserve_exact(capacity).ok()?;
let mut slots = std::collections::HashMap::new();
slots.try_reserve(capacity).ok()?;
for i in 0..=steps {
// Match PR #902's f32 geometry, normalized weights and first-seen merge order.
let t = i as f32 / steps as f32 - 0.5;
let (x, y) = (cos * distance * t, -sin * distance * t);
let (ix, iy) = (x.floor() as i32, y.floor() as i32);
let (fx, fy) = (x - ix as f32, y - iy as f32);
for (x, y, weight) in [(ix, iy, (1.0 - fx) * (1.0 - fy)), (ix + 1, iy, fx * (1.0 - fy)), (ix, iy + 1, (1.0 - fx) * fy), (ix + 1, iy + 1, fx * fy)] {
if weight <= 0.0 {
continue;
}
let slot = *slots.entry((x, y)).or_insert_with(|| {
merged.push((x, y, 0.0));
merged.len() - 1
});
merged.get_mut(slot)?.2 += weight * norm;
}
}
let mut result = Vec::new();
result.try_reserve_exact(merged.len()).ok()?;
for (x, y, weight) in merged {
if let Some((source, out)) = clip {
// Omitted reads are transparent; retain the complete kernel's coefficients.
if i64::from(out.x0) + i64::from(x) >= i64::from(source.x1)
|| i64::from(out.x1) - 1 + i64::from(x) < i64::from(source.x0)
|| i64::from(out.y0) + i64::from(y) >= i64::from(source.y1)
|| i64::from(out.y1) - 1 + i64::from(y) < i64::from(source.y0)
{
continue;
}
}
result.push(Tap { x, y, weight: f64::from(weight) });
}
Some(result)
}
/// One pixel of the upstream row kernel, without allocating its premultiplied
/// square window. Coefficients and accumulation retain their original f32 order.
fn sample_row_point(src: &Image, x: i32, y: i32, alpha: bool, taps: &[Tap], ctl: &Interrupt) -> Option<[f32; 5]> {
let (width, height) = dimensions(src.rect)?;
if !(1..=5).contains(&src.ch) || width.checked_mul(height)?.checked_mul(src.ch)? != src.data.len() || !ordinary_coordinates(src.rect) {
return None;
}
let mut result = [0.0f32; 5];
for (i, tap) in taps.iter().enumerate() {
if i % 64 == 0 && ctl.cancelled() {
return None;
}
let (sx, sy) = (x.checked_add(tap.x)?, y.checked_add(tap.y)?);
let a = if alpha { src.get(sx, sy, src.ch - 1) } else { 1.0 };
// Each stored coefficient came directly from f32 and converts back
// exactly. Match the row kernel's weight * premultiplied sample order.
let weight = tap.weight as f32;
for (c, value) in result.iter_mut().take(src.ch).enumerate() {
let sample = src.get(sx, sy, c);
let premultiplied = if alpha && c + 1 < src.ch { sample * a } else { sample };
*value += weight * premultiplied;
}
}
if alpha {
let a = *result.get(src.ch - 1)?;
for value in result.iter_mut().take(src.ch - 1) {
*value = if a > 1e-7 { *value / a } else { 0.0 };
}
}
Some(result)
}
/// Conservative native crossover against the merged row kernel. Wide rows can
/// be faster than transforms unless FFT tiles retain enough parallelism. The
/// release matrix in motion_bench covers both winners and declined cases.
#[allow(clippy::too_many_arguments)]
pub(super) fn worthwhile(angle: f32, distance: f32, tile: usize, area: Rect, row_tile: usize, channels: usize, workers: usize) -> Option<bool> {
if workers < 4 || !(1..=5).contains(&channels) || row_tile == 0 {
return Some(false);
}
let pairs = channels.div_ceil(2);
// Transforming a channel pair costs almost the same as one channel. Stay
// on rows for short streaks, with a higher crossover for odd channel counts.
if !distance.is_finite() || distance.abs() * (channels as f32) < 1500.0 * pairs as f32 {
return Some(false);
}
let taps = taps(angle, distance, None)?;
let (mut min_x, mut max_x, mut min_y, mut max_y) = (0, 0, 0, 0);
for tap in &taps {
min_x = min_x.min(tap.x);
max_x = max_x.max(tap.x);
min_y = min_y.min(tap.y);
max_y = max_y.max(tap.y);
}
let sx = usize::try_from(max_x - min_x).ok()?;
let sy = usize::try_from(max_y - min_y).ok()?;
let width = fft_size(tile.checked_add(sx)?)?;
let height = fft_size(tile.checked_add(sy)?)?;
let points = width.checked_mul(height)?;
if points > MAX_POINTS {
return Some(false);
}
let (ow, oh) = dimensions(area)?;
let tiles = ow.div_ceil(tile).checked_mul(oh.div_ceil(tile))?;
let row_tiles = ow.div_ceil(row_tile).checked_mul(oh.div_ceil(row_tile))?;
let row_workers = workers.min(row_tiles);
// Before creating any FFT plans, conservatively estimate the same complete
// working-set budget as the scheduler. Mixed-radix scratch is bounded here
// by four times the longest side; the real plan supplies its exact size.
let source = tile.checked_add(sx)?.checked_mul(tile.checked_add(sy)?)?.checked_mul(channels)?.checked_mul(4)?;
let result = tile.checked_mul(tile)?.checked_mul(channels)?;
let scratch = width.max(height).checked_mul(4)?;
let worker =
points.checked_mul(2)?.checked_add(scratch)?.checked_mul(16)?.checked_add(source)?.checked_add(result.checked_mul(8)?)?.max(source.checked_mul(2)?);
let resident = points
.checked_mul(16)?
.checked_add(width.checked_add(height)?.checked_mul(256)?)?
.checked_add(taps.capacity().checked_mul(std::mem::size_of::<Tap>())?)?;
let metadata = tiles.checked_mul(std::mem::size_of::<(Rect, Rect)>())?;
let available = (512usize << 20).checked_sub(resident)?.checked_sub(metadata)?;
let memory_workers = available.checked_div(worker.checked_add(result.checked_mul(4)?)?)?;
let fft_workers = workers.min(memory_workers).min(tiles);
// A few large diagonal transforms can lose to well-parallelized rows.
// Admit them only when their task count gives a clear parallelism advantage.
let parallel = fft_workers >= row_workers.checked_mul(2)? || (fft_workers >= 4 && fft_workers.checked_mul(2)? >= row_workers);
let transform_work = points.checked_mul((width.ilog2() + height.ilog2()) as usize)?.checked_mul(tiles)?;
let row_work = ow.checked_mul(oh)?.checked_mul(taps.len())?;
Some(parallel && transform_work < row_work)
}
/// Immutable FFT plans and kernel spectrum, shared by every tile of one apply.
pub(super) struct Plan {
distance: f32,
width: usize,
height: usize,
tile_width: usize,
tile_height: usize,
min_x: i32,
max_x: i32,
min_y: i32,
max_y: i32,
kernel: Vec<Number>,
taps: Vec<Tap>,
row_forward: Arc<dyn Fft<f64>>,
col_forward: Arc<dyn Fft<f64>>,
row_inverse: Arc<dyn Fft<f64>>,
col_inverse: Arc<dyn Fft<f64>>,
scratch_len: usize,
}
impl Plan {
pub(super) fn new_with(angle: f32, distance: f32, tile: usize, ctl: &Interrupt) -> Option<Self> {
Self::build(angle, distance, tile, tile, None, ctl)
}
fn build(angle: f32, distance: f32, tile_width: usize, tile_height: usize, clip: Option<(Rect, Rect)>, ctl: &Interrupt) -> Option<Self> {
if ctl.cancelled() {
return None;
}
if tile_width == 0 || tile_height == 0 || tile_width > 512 || tile_height > 512 {
return None;
}
let taps = taps(angle, distance, clip)?;
let (mut min_x, mut max_x, mut min_y, mut max_y) = (0, 0, 0, 0);
for tap in &taps {
min_x = min_x.min(tap.x);
max_x = max_x.max(tap.x);
min_y = min_y.min(tap.y);
max_y = max_y.max(tap.y);
}
let width = fft_size(tile_width.checked_add(usize::try_from(max_x - min_x).ok()?)?)?;
let height = fft_size(tile_height.checked_add(usize::try_from(max_y - min_y).ok()?)?)?;
let count = width.checked_mul(height)?;
if count > MAX_POINTS {
return None;
}
let mut kernel = buffer(count, Number::default())?;
for tap in &taps {
// Convolution reverses the displacement of a correlation tap.
let x = usize::try_from((-tap.x).rem_euclid(i32::try_from(width).ok()?)).ok()?;
let y = usize::try_from((-tap.y).rem_euclid(i32::try_from(height).ok()?)).ok()?;
kernel.get_mut(y.checked_mul(width)?.checked_add(x)?)?.re += tap.weight;
}
let mut planner = FftPlanner::<f64>::new();
let row_forward = planner.plan_fft_forward(width);
let col_forward = planner.plan_fft_forward(height);
let row_inverse = planner.plan_fft_inverse(width);
let col_inverse = planner.plan_fft_inverse(height);
let scratch_len = [
row_forward.get_inplace_scratch_len(),
col_forward.get_inplace_scratch_len(),
row_inverse.get_inplace_scratch_len(),
col_inverse.get_inplace_scratch_len(),
]
.into_iter()
.max()
.unwrap_or(0);
let mut result = Self {
distance,
width,
height,
tile_width,
tile_height,
min_x,
max_x,
min_y,
max_y,
kernel: Vec::new(),
taps,
row_forward,
col_forward,
row_inverse,
col_inverse,
scratch_len,
};
let mut transposed = buffer(count, Number::default())?;
let mut scratch = buffer(scratch_len, Number::default())?;
result.transform(&mut kernel, &mut transposed, &mut scratch, false, ctl)?;
result.kernel = kernel;
Some(result)
}
pub(super) fn resident_bytes(&self) -> usize {
// Include a conservative allowance for the FFT plans' twiddle tables.
self.kernel.len() * std::mem::size_of::<Number>() + self.taps.capacity() * std::mem::size_of::<Tap>() + (self.width + self.height) * 256
}
pub(super) fn worker_bytes(&self, channels: usize, tile: usize) -> Option<usize> {
let source = tile
.checked_add(usize::try_from(self.max_x - self.min_x).ok()?)?
.checked_mul(tile.checked_add(usize::try_from(self.max_y - self.min_y).ok()?)?)?
.checked_mul(channels)?
.checked_mul(4)?;
self.kernel
.len()
.checked_mul(2)?
.checked_add(self.scratch_len)?
.checked_mul(std::mem::size_of::<Number>())?
.checked_add(source)?
.checked_add(tile.checked_mul(tile)?.checked_mul(channels)?.checked_mul(8)?)
}
pub(super) fn read_rect(&self, out: Rect) -> Option<Rect> {
Some(Rect::new(out.x0.checked_add(self.min_x)?, out.y0.checked_add(self.min_y)?, out.x1.checked_add(self.max_x)?, out.y1.checked_add(self.max_y)?))
}
fn transform(&self, data: &mut [Number], transposed: &mut [Number], scratch: &mut [Number], inverse: bool, ctl: &Interrupt) -> Option<()> {
let (rows, cols) = if inverse { (&self.row_inverse, &self.col_inverse) } else { (&self.row_forward, &self.col_forward) };
// Every row has the planned length, and scratch_len covers all four
// plans. Those invariants satisfy RustFFT's checked process API.
for (i, row) in data.chunks_exact_mut(self.width).enumerate() {
if i % 8 == 0 && ctl.cancelled() {
return None;
}
rows.process_with_scratch(row, scratch);
}
transpose(data, transposed, self.width, self.height, ctl)?;
for (i, row) in transposed.chunks_exact_mut(self.height).enumerate() {
if i % 8 == 0 && ctl.cancelled() {
return None;
}
cols.process_with_scratch(row, scratch);
}
transpose(transposed, data, self.height, self.width, ctl)
}
pub(super) fn filter(&self, src: &Image, out: Rect, alpha: bool, ctl: &Interrupt) -> Option<Vec<f32>> {
let (ow, oh) = dimensions(out)?;
if out.is_empty() {
return Some(Vec::new());
}
let (sw, sh) = dimensions(src.rect)?;
if !(1..=5).contains(&src.ch)
|| sw.checked_mul(sh)?.checked_mul(src.ch)? != src.data.len()
|| ow > self.tile_width
|| oh > self.tile_height
|| !ordinary_coordinates(src.rect)
|| !ordinary_coordinates(out)
{
return None;
}
let mut scales = [0.0f64; 5];
for (i, pixel) in src.data.chunks_exact(src.ch).enumerate() {
if i % 4096 == 0 && ctl.cancelled() {
return None;
}
if pixel.iter().any(|value| !value.is_finite() || value.abs() > MAX_SAMPLE)
|| (alpha && !pixel.last().is_some_and(|value| (0.0..=1.0).contains(value)))
{
return None;
}
let a = if alpha { *pixel.last()? } else { 1.0 };
for (c, &value) in pixel.iter().enumerate() {
let value = if alpha && c + 1 < src.ch { value * a } else { value };
let scale = scales.get_mut(c)?;
*scale = scale.max(f64::from(value.abs()));
}
}
let mut result = buffer(ow.checked_mul(oh)?.checked_mul(src.ch)?, 0.0f64)?;
let read = self.read_rect(out)?;
let used = read.intersect(&src.rect);
if used.is_empty() || scales.iter().take(src.ch).all(|&scale| scale == 0.0) {
return buffer(result.len(), 0.0f32);
}
let count = self.kernel.len();
let mut data = buffer(count, Number::default())?;
let mut transposed = buffer(count, Number::default())?;
let mut scratch = buffer(self.scratch_len, Number::default())?;
let norm = 1.0 / count as f64;
for channel in (0..src.ch).step_by(2) {
if ctl.cancelled() {
return None;
}
data.fill(Number::default());
for y in used.y0..used.y1 {
if (y - used.y0) % 8 == 0 && ctl.cancelled() {
return None;
}
let source_start =
usize::try_from(y - src.rect.y0).ok()?.checked_mul(sw)?.checked_add(usize::try_from(used.x0 - src.rect.x0).ok()?)?.checked_mul(src.ch)?;
let source_len = usize::try_from(used.x1 - used.x0).ok()?.checked_mul(src.ch)?;
let row = src.data.get(source_start..source_start.checked_add(source_len)?)?;
let start = usize::try_from(y - read.y0).ok()?.checked_mul(self.width)?.checked_add(usize::try_from(used.x0 - read.x0).ok()?)?;
let dst = data.get_mut(start..start.checked_add(source_len / src.ch)?)?;
for (pixel, dst) in row.chunks_exact(src.ch).zip(dst) {
let a = if alpha { *pixel.last()? } else { 1.0 };
// Normalize each channel separately before packing a pair.
// Otherwise a bright HDR colour's FFT residual could swamp
// a small alpha channel sharing the imaginary component.
let sample = |c: usize| -> f64 {
match (pixel.get(c), scales.get(c)) {
(Some(&value), Some(&scale)) if scale > 0.0 => f64::from(if alpha && c + 1 < src.ch { value * a } else { value }) / scale,
_ => 0.0,
}
};
*dst = Number::new(sample(channel), sample(channel + 1));
}
}
self.transform(&mut data, &mut transposed, &mut scratch, false, ctl)?;
for (value, kernel) in data.iter_mut().zip(&self.kernel) {
*value *= kernel;
}
self.transform(&mut data, &mut transposed, &mut scratch, true, ctl)?;
let x0 = usize::try_from(-self.min_x).ok()?;
let y0 = usize::try_from(-self.min_y).ok()?;
for (y, dst) in result.chunks_exact_mut(ow * src.ch).enumerate() {
let start = y.checked_add(y0)?.checked_mul(self.width)?.checked_add(x0)?;
let values = data.get(start..start.checked_add(ow)?)?;
for (pixel, value) in dst.chunks_exact_mut(src.ch).zip(values) {
*pixel.get_mut(channel)? = value.re * norm * scales.get(channel)?;
if let Some(next) = pixel.get_mut(channel + 1) {
*next = value.im * norm * scales.get(channel + 1)?;
}
}
}
}
if alpha {
// The row kernel accumulates in f32. Recompute very small alpha
// near its cutoff so a different summation order cannot expose colour.
let uncertainty = f64::from(self.distance.abs().ceil()) * f64::from(f32::EPSILON) * scales.get(src.ch - 1)?;
for (i, pixel) in result.chunks_exact_mut(src.ch).enumerate() {
if i % 16 == 0 && ctl.cancelled() {
return None;
}
let a = pixel.last_mut()?;
// Valid alpha is nonnegative. Remove transform roundoff at
// zero to keep fully transparent regions empty.
if a.abs() < 1e-14 * scales.get(src.ch - 1)? {
*a = 0.0;
}
*a = a.clamp(0.0, 1.0);
let a = *a;
// The row kernel's f32 sum can straddle the cutoff at long distances.
// Keep its branch and straight colour exactly for that narrow
// range, rather than amplifying a tiny alpha difference.
if a > 0.0 && (a <= 1.01e-7 + uncertainty || (0.99e-7..=1.01e-7).contains(&(a as f32))) {
let x = out.x0.checked_add(i32::try_from(i % ow).ok()?)?;
let y = out.y0.checked_add(i32::try_from(i / ow).ok()?)?;
let original = sample_row_point(src, x, y, alpha, &self.taps, ctl)?;
for (dst, value) in pixel.iter_mut().zip(original) {
*dst = f64::from(value);
}
continue;
}
for value in pixel.iter_mut().take(src.ch - 1) {
*value = if a as f32 > 1e-7 { *value / a } else { 0.0 };
}
}
}
let mut output = buffer(result.len(), 0.0f32)?;
for (dst, value) in output.iter_mut().zip(result) {
*dst = value as f32;
}
Some(output)
}
}
#[cfg(test)]
pub(super) fn row_point_for_test(src: &Image, x: i32, y: i32, alpha: bool, angle: f32, distance: f32) -> Option<Vec<f32>> {
let taps = taps(angle, distance, None)?;
let result = sample_row_point(src, x, y, alpha, &taps, &Interrupt::NONE)?;
Some(result.into_iter().take(src.ch).collect())
}
fn transpose(src: &[Number], dst: &mut [Number], width: usize, height: usize, ctl: &Interrupt) -> Option<()> {
// Blocking keeps both row-major matrices in cache while transposing.
for by in (0..height).step_by(32) {
if ctl.cancelled() {
return None;
}
for bx in (0..width).step_by(32) {
for y in by..(by + 32).min(height) {
let start = y.checked_mul(width)?.checked_add(bx)?;
let row = src.get(start..start.checked_add((width - bx).min(32))?)?;
for (x, value) in row.iter().enumerate() {
*dst.get_mut((bx + x).checked_mul(height)?.checked_add(y)?)? = *value;
}
}
}
}
Some(())
}
/// Small-window oracle tests omit taps whose reads must be transparent. The
/// production plan covers the whole kernel and is reused across output tiles.
#[cfg(test)]
pub(super) fn filter(src: &Image, out: Rect, alpha: bool, angle: f32, distance: f32) -> Option<Vec<f32>> {
if out.is_empty() {
return Some(Vec::new());
}
let (width, height) = dimensions(out)?;
Plan::build(angle, distance, width, height, Some((src.rect, out)), &Interrupt::NONE)?.filter(src, out, alpha, &Interrupt::NONE)
}
+538
View File
@@ -0,0 +1,538 @@
//! Numerical and geometric oracles for convolution of the finite motion kernel.
//! The reference independently builds the current upstream merged f32 taps and
//! source premultiplication, then performs convolution and accumulation in f64.
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
use std::collections::BTreeMap;
fn offsets(angle: f32, distance: f32) -> Vec<(f64, f64)> {
let distance = distance.abs();
let steps = distance.ceil() as i32;
let (sin, cos) = angle.to_radians().sin_cos();
(0..=steps)
.map(|i| {
let t = i as f32 / steps as f32 - 0.5;
(f64::from(cos * distance * t), -f64::from(sin * distance * t))
})
.collect()
}
fn merged_taps(angle: f32, distance: f32) -> BTreeMap<(i32, i32), f32> {
let offsets = offsets(angle, distance);
let norm = 1.0 / offsets.len() as f32;
let mut taps = BTreeMap::new();
for (x, y) in offsets {
let (x, y) = (x as f32, y as f32);
let (x0, y0) = (x.floor(), y.floor());
let (ax, ay) = (x - x0, y - y0);
let (x0, y0) = (x0 as i32, y0 as i32);
for (dx, dy, weight) in [(0, 0, (1.0 - ax) * (1.0 - ay)), (1, 0, ax * (1.0 - ay)), (0, 1, (1.0 - ax) * ay), (1, 1, ax * ay)] {
if weight > 0.0 {
*taps.entry((x0 + dx, y0 + dy)).or_insert(0.0) += weight * norm;
}
}
}
taps
}
fn ideal(src: &Image, out: Rect, alpha: bool, angle: f32, distance: f32) -> Vec<f64> {
let taps = merged_taps(angle, distance);
let mut result = Vec::new();
for y in out.y0..out.y1 {
for x in out.x0..out.x1 {
let mut acc = vec![0.0; src.ch];
for (&(dx, dy), &weight) in &taps {
let (sx, sy) = (x + dx, y + dy);
let a = if alpha { src.get(sx, sy, src.ch - 1) } else { 1.0 };
for (c, value) in acc.iter_mut().enumerate() {
let sample = src.get(sx, sy, c);
let premultiplied = if alpha && c + 1 < src.ch { sample * a } else { sample };
*value += f64::from(premultiplied) * f64::from(weight);
}
}
if alpha {
let a = acc[src.ch - 1];
for value in acc.iter_mut().take(src.ch - 1) {
*value = if a as f32 > 1e-7 { *value / a } else { 0.0 };
}
}
result.extend(acc);
}
}
result
}
fn fft(src: &Image, out: Rect, alpha: bool, angle: f32, distance: f32) -> Vec<f32> {
super::motion_fft::filter(src, out, alpha, angle, distance).expect("supported finite motion convolution")
}
fn assert_ideal(actual: &[f32], expected: &[f64], case: &str) {
assert_eq!(actual.len(), expected.len(), "{case}");
for (i, (&actual, &expected)) in actual.iter().zip(expected).enumerate() {
let rounded = expected as f32;
// The public result is f32; allow two rounding units and a small absolute
// FFT residual at exact zero, rather than claiming sub-ULP precision.
let tolerance = 1e-9 + 2.0 * f64::from(f32::EPSILON) * f64::from(rounded.abs());
assert!((f64::from(actual) - f64::from(rounded)).abs() <= tolerance, "{case}, sample {i}: actual={actual}, ideal={expected}, tolerance={tolerance}");
}
}
fn pattern(rect: Rect, ch: usize, alpha: bool) -> Image {
let mut data = Vec::new();
for y in 0..rect.height() as usize {
for x in 0..rect.width() as usize {
for c in 0..ch {
let value = if alpha && c + 1 == ch { [0.0, 0.25, 0.5, 1.0][(x + y * 3) % 4] } else { ((x * 31 + y * 17 + c * 43) % 251) as f32 / 250.0 };
data.push(value);
}
}
}
Image { rect, ch, data }
}
#[test]
fn motion_fft_matches_independent_ideal_for_channels_angles_and_distances() {
let rect = Rect::new(-4, -4, 5, 5);
let out = Rect::new(-1, -1, 2, 2);
for ch in 1..=5 {
for alpha in [false, true] {
let src = pattern(rect, ch, alpha);
for (angle, distance) in [(0.0, 64.0), (90.0, 65.5), (30.0, 127.0), (-45.0, -256.0), (89.75, 2000.0), (37.0, 2000.0)] {
let actual = fft(&src, out, alpha, angle, distance);
assert_ideal(&actual, &ideal(&src, out, alpha, angle, distance), &format!("ch={ch}, alpha={alpha}, angle={angle}, distance={distance}"));
}
}
}
}
fn impulse_kernel(angle: f32, distance: f32) -> BTreeMap<(i32, i32), f64> {
merged_taps(angle, distance).into_iter().map(|((x, y), weight)| ((-x, -y), f64::from(weight))).collect()
}
fn moments(samples: impl Iterator<Item = ((i32, i32), f64)>, angle: f32) -> [f64; 5] {
let angle = f64::from(angle).to_radians();
let (sin, cos) = angle.sin_cos();
let mut result = [0.0; 5];
for ((x, y), weight) in samples {
let (x, y) = (f64::from(x), f64::from(y));
result[0] += weight;
result[1] += x * weight;
result[2] += y * weight;
result[3] += (x * sin + y * cos).powi(2) * weight;
result[4] += (x * cos - y * sin).powi(2) * weight;
}
result
}
#[test]
fn motion_fft_impulse_preserves_mass_centroid_and_directional_width() {
let src = Image { rect: Rect::new(0, 0, 1, 1), ch: 1, data: vec![1.0] };
for distance in [64.0_f32, 127.0] {
let reach = (distance / 2.0).ceil() as i32 + 2;
let out = Rect::new(-reach, -reach, reach + 1, reach + 1);
for angle in [0.0, 30.0, 45.0, 89.75] {
let expected = impulse_kernel(angle, distance);
let actual = fft(&src, out, false, angle, distance);
let width = out.width() as usize;
let samples = actual.iter().enumerate().map(|(i, &v)| ((out.x0 + (i % width) as i32, out.y0 + (i / width) as i32), f64::from(v)));
let actual_moments = moments(samples, angle);
let expected_moments = moments(expected.iter().map(|(&p, &v)| (p, v)), angle);
assert!((actual_moments[0] - 1.0).abs() <= 3e-6, "angle={angle}, distance={distance}, mass={}", actual_moments[0]);
for i in 1..=3 {
assert!(
(actual_moments[i] - expected_moments[i]).abs() <= 2e-5,
"angle={angle}, distance={distance}, moment={i}, actual={}, expected={}",
actual_moments[i],
expected_moments[i]
);
}
assert!((actual_moments[4] - expected_moments[4]).abs() <= 5e-4, "along-motion variance changed: angle={angle}, distance={distance}");
let expected_pixels: Vec<f64> =
(out.y0..out.y1).flat_map(|y| (out.x0..out.x1).map(move |x| (x, y))).map(|p| expected.get(&p).copied().unwrap_or(0.0)).collect();
assert_ideal(&actual, &expected_pixels, &format!("impulse angle={angle}, distance={distance}"));
}
}
}
#[test]
fn motion_fft_sharp_stripes_match_uniform_streak_without_cross_direction_softening() {
let rect = Rect::new(-80, -80, 81, 81);
let out = Rect::new(-2, -2, 3, 3);
for angle in [0.0_f32, 30.0, 45.0] {
let (sin, cos) = f64::from(angle).to_radians().sin_cos();
for perpendicular in [false, true] {
for frequency in [0.125, 0.375] {
let mut src = Image::new(rect, 1);
for (i, value) in src.data.iter_mut().enumerate() {
let (x, y) = (rect.x0 + (i % rect.width() as usize) as i32, rect.y0 + (i / rect.width() as usize) as i32);
let coordinate = if perpendicular { f64::from(x) * sin + f64::from(y) * cos } else { f64::from(x) * cos - f64::from(y) * sin };
*value = (0.5 + 0.5 * (std::f64::consts::TAU * frequency * coordinate).cos()) as f32;
}
let distance = if perpendicular { 127.0 } else { 64.0 };
assert_ideal(
&fft(&src, out, false, angle, distance),
&ideal(&src, out, false, angle, distance),
&format!("stripe angle={angle}, perpendicular={perpendicular}, frequency={frequency}"),
);
}
}
}
}
#[test]
fn motion_fft_premultiplied_alpha_hidden_colour_hdr_and_cutoff_are_preserved() {
let rect = Rect::new(-5, -5, 6, 6);
let out = Rect::new(-1, -1, 2, 2);
let mut src = pattern(rect, 4, true);
let mut hidden = src.clone();
for (pixel, changed) in src.data.as_chunks_mut::<4>().0.iter_mut().zip(hidden.data.as_chunks_mut::<4>().0.iter_mut()) {
for value in pixel.iter_mut().take(3) {
*value = *value * 32.0 - 8.0;
}
changed.copy_from_slice(pixel);
if pixel[3] == 0.0 {
changed[..3].copy_from_slice(&[1e6, 7e5, 9e5]);
}
}
let actual = fft(&src, out, true, 38.0, 67.75);
assert_ideal(&actual, &ideal(&src, out, true, 38.0, 67.75), "HDR premultiplied image");
assert_ideal(&fft(&hidden, out, true, 38.0, 67.75), &actual.iter().map(|&v| f64::from(v)).collect::<Vec<_>>(), "hidden transparent colours do not bleed");
let rect = Rect::new(-40, -40, 41, 41);
for alpha in [0.0_f32, 1e-8, 1e-7, f32::from_bits(1e-7_f32.to_bits() + 1), 0.25, 1.0] {
let src = Image { rect, ch: 4, data: [0.25, 0.5, 0.75, alpha].repeat((rect.width() * rect.height()) as usize) };
if (0.99e-7..=1.01e-7).contains(&alpha) {
// The upstream row f32 sum can move alpha across its discontinuous
// colour cutoff. Near that boundary, retain the upstream row kernel.
let actual = fft(&src, out, true, 30.0, 64.0);
let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true };
let expected = motion_rows(&src, out, &ctx, 30.0, 64.0);
assert_eq!(actual, expected, "near-cutoff alpha={alpha}");
continue;
}
let actual = fft(&src, out, true, 30.0, 64.0);
assert_ideal(&actual, &ideal(&src, out, true, 30.0, 64.0), &format!("constant alpha={alpha}"));
if alpha <= 1e-7 {
assert!(actual.as_chunks::<4>().0.iter().all(|p| p[..3].iter().all(|&v| v == 0.0)), "alpha cutoff={alpha}");
}
}
}
#[test]
fn motion_fft_upstream_summation_error_is_bounded_in_supported_coordinates() {
for origin in [-16_384, 0, 16_375] {
let rect = Rect::new(origin, origin, origin + 9, origin + 9);
let out = Rect::new(origin + 2, origin + 2, origin + 5, origin + 5);
for alpha in [false, true] {
let src = pattern(rect, 4, alpha);
let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha };
for (angle, distance) in [(30.0, 64.0), (89.75, 127.0)] {
let actual = fft(&src, out, alpha, angle, distance);
let expected = motion_rows(&src, out, &ctx, angle, distance);
for (&a, &b) in actual.iter().zip(&expected) {
assert!((a - b).abs() <= 1e-3, "origin={origin}, alpha={alpha}, angle={angle}, distance={distance}, actual={a}, original={b}");
}
}
}
}
}
#[test]
fn motion_fft_large_hdr_colours_do_not_change_small_alpha_or_its_cutoff() {
let rect = Rect::new(-40, -40, 41, 41);
let out = Rect::new(-1, -1, 2, 2);
for alpha in [1e-7_f32, f32::from_bits(1e-7_f32.to_bits() + 1), 0.25] {
let src = Image { rect, ch: 4, data: [1e10, -5e9, 9e9, alpha].repeat((rect.width() * rect.height()) as usize) };
if (0.99e-7..=1.01e-7).contains(&alpha) {
let actual = fft(&src, out, true, 33.0, 64.0);
let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true };
assert_eq!(actual, motion_rows(&src, out, &ctx, 33.0, 64.0), "near-cutoff HDR alpha={alpha}");
continue;
}
let actual = fft(&src, out, true, 33.0, 64.0);
assert_ideal(&actual, &ideal(&src, out, true, 33.0, 64.0), &format!("large HDR, alpha={alpha}"));
if alpha <= 1e-7 {
assert!(actual.as_chunks::<4>().0.iter().all(|p| p[..3].iter().all(|&v| v == 0.0)), "HDR changed alpha cutoff");
}
}
}
#[test]
fn motion_fft_pipeline_supports_depths_models_edges_selections_and_tile_seams() {
let rect = Rect::new(-6, 3, 7, 16);
let mut selection = Surface::new(PixelFormat::GRAY8);
let coverage: Vec<f32> = (0..rect.width() * rect.height()).map(|i| [0.0, 0.25, 0.5, 1.0][i as usize % 4]).collect();
selection.write_region(rect, &coverage);
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let mut source = Surface::new(PixelFormat::new(mode, depth, alpha));
let image = pattern(rect, source.channels(), alpha);
source.write_region(rect, &image.data);
let params = FilterParams::MotionBlur { angle: 31.0, distance: 73.25 };
let crate::Halo::Radius(reach) = params.halo() else { panic!("local motion halo") };
for extent in [None, Some(rect)] {
let src =
if extent.is_some() { Image::read_clamped(&source, rect.inflate(reach), rect) } else { Image::read(&source, rect.inflate(reach)) };
let filtered = fft(&src, rect, alpha, 31.0, 73.25);
for mask in [None, Some(&selection)] {
let mut expected_data = filtered.clone();
if let Some(mask) = mask {
crate::mix_selection(&mut expected_data, rect, mask, &src);
}
let mut expected = source.clone();
expected.write_region(rect, &expected_data);
let expected = expected.read_region(rect.inflate(2));
let tolerance = match depth {
SampleType::U8 => 1.0 / 255.0 + 1e-7,
SampleType::U16 => 1.0 / 65_535.0 + 1e-7,
SampleType::F32 => 1e-6,
};
for tile in [5, 17, 256] {
let actual = crate::apply_tiled(&source, &params, rect, rect, mask, tile, extent).read_region(rect.inflate(2));
assert_eq!(actual.len(), expected.len());
for (i, (&a, &b)) in actual.iter().zip(&expected).enumerate() {
assert!(
(a - b).abs() <= tolerance,
"mode={mode:?}, depth={depth:?}, alpha={alpha}, extent={extent:?}, selection={}, tile={tile}, sample={i}, actual={a}, expected={b}",
mask.is_some()
);
}
}
}
}
}
}
}
}
#[test]
fn motion_fft_rejects_unsupported_values_and_extreme_geometry() {
let rect = Rect::new(-2, -2, 3, 3);
let src = pattern(rect, 4, true);
for (angle, distance) in [(f32::NAN, 64.0), (f32::INFINITY, 64.0), (30.0, f32::NAN), (30.0, f32::INFINITY), (30.0, 2001.0)] {
assert!(super::motion_fft::filter(&src, rect, true, angle, distance).is_none());
}
for alpha in [-0.25, 1.25, f32::NAN, f32::INFINITY] {
let mut image = src.clone();
image.data[3] = alpha;
assert!(super::motion_fft::filter(&image, rect, true, 30.0, 64.0).is_none(), "alpha={alpha}");
}
for sample in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 2e10] {
let mut image = src.clone();
image.data[0] = sample;
assert!(super::motion_fft::filter(&image, rect, true, 30.0, 64.0).is_none(), "sample={sample}");
}
let malformed = Image { rect, ch: 4, data: vec![0.0] };
assert!(super::motion_fft::filter(&malformed, rect, true, 30.0, 64.0).is_none());
for origin in [16_385, i32::MIN] {
let rect = Rect::new(origin, 0, origin + 5, 5);
let image = Image { rect, ch: 1, data: vec![0.0; 25] };
assert!(super::motion_fft::filter(&image, rect, false, 30.0, 64.0).is_none());
}
}
#[test]
fn motion_fft_production_crosses_real_tile_seams_and_leaves_other_pixels_untouched() {
let extent = Rect::new(-180, -145, 173, 144);
let area = Rect::new(-170, -135, 163, 134);
let check = extent.inflate(8);
let params = FilterParams::MotionBlur { angle: 31.0, distance: 73.25 };
let crate::Halo::Radius(reach) = params.halo() else { panic!("local motion halo") };
let mut selection = Surface::new(PixelFormat::GRAY8);
let mut coverage = Vec::new();
for y in extent.y0..extent.y1 {
for x in extent.x0..extent.x1 {
let inside = Rect::new(-51, -31, 53, 34).contains(x, y);
coverage.push(if inside { [0.0, 0.25, 0.5, 0.75, 1.0][(x + y).rem_euclid(5) as usize] } else { 0.0 });
}
}
selection.write_region(extent, &coverage);
for depth in [SampleType::U16, SampleType::F32] {
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true));
source.write_region(extent, &pattern(extent, 4, true).data);
source.fill_rect(Rect::new(-86, -61, -82, -57), &[0.125, 0.25, 0.5, 1.0]);
let original = Image::read(&source, check);
for clamp in [false, true] {
let src = if clamp { Image::read_clamped(&source, area.inflate(reach), extent) } else { Image::read(&source, area.inflate(reach)) };
let filtered = fft(&src, area, true, 31.0, 73.25);
for mask in [None, Some(&selection)] {
let mut expected = source.clone();
let mut data = filtered.clone();
if let Some(mask) = mask {
crate::mix_selection(&mut data, area, mask, &src);
}
expected.write_region(area, &data);
let expected = Image::read(&expected, check);
let tolerance = if depth == SampleType::U16 { 1.0 / 65_535.0 + 1e-7 } else { 1e-6 };
for tile in [64, 128, 256] {
let result = super::motion_apply::apply_for_bench(&source, &params, area, extent, mask, clamp.then_some(extent), tile, &Interrupt::NONE)
.expect("the FFT specialization must actually cross these seams")
.expect("not cancelled");
let result = Image::read(&result, check);
for y in check.y0..check.y1 {
for x in check.x0..check.x1 {
for c in 0..4 {
let actual = result.get(x, y, c);
if !area.contains(x, y) {
assert_eq!(
actual.to_bits(),
original.get(x, y, c).to_bits(),
"write outside area: depth={depth:?}, tile={tile}, pixel=({x},{y}), channel={c}"
);
} else {
let target = expected.get(x, y, c);
assert!(
(actual - target).abs() <= tolerance,
"seam: depth={depth:?}, clamp={clamp}, selection={}, tile={tile}, pixel=({x},{y}), channel={c}, actual={actual}, expected={target}",
mask.is_some()
);
}
}
}
}
}
}
}
}
}
#[test]
fn motion_fft_production_unsupported_inputs_keep_upstream_row_fallback() {
let params = FilterParams::MotionBlur { angle: 31.0, distance: 64.0 };
let crate::Halo::Radius(reach) = params.halo() else { panic!("local motion halo") };
for case in 0..5 {
let origin = if case == 4 { 20_000 } else { -64 };
let rect = Rect::new(origin, -32, origin + 128, 32);
let mut image = pattern(rect, 4, true);
let middle = (32 * rect.width() as usize + 64) * 4;
match case {
0 => image.data[middle + 3] = -0.25,
1 => image.data[middle + 3] = 1.25,
2 => image.data[middle] = f32::NAN,
3 => image.data[middle] = f32::INFINITY,
_ => {}
}
let mut source = Surface::new(PixelFormat::RGBA32F);
source.write_region(rect, &image.data);
let src = Image::read(&source, rect.inflate(reach));
let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true };
let mut expected = source.clone();
expected.write_region(rect, &motion_rows(&src, rect, &ctx, 31.0, 64.0));
let expected = expected.read_region(rect);
assert!(
super::motion_apply::apply_for_bench(&source, &params, rect, rect, None, None, 64, &Interrupt::NONE).is_none(),
"unsupported FFT input case={case}"
);
let actual = crate::apply_tiled(&source, &params, rect, rect, None, 64, None).read_region(rect);
assert_eq!(actual.len(), expected.len());
for (i, (&actual, &expected)) in actual.iter().zip(&expected).enumerate() {
assert!(
actual.to_bits() == expected.to_bits() || (actual.is_nan() && expected.is_nan()),
"fallback case={case}, sample={i}, actual={actual}, expected={expected}"
);
}
}
}
#[test]
fn motion_fft_production_near_alpha_cutoff_retains_upstream_row_summation() {
let extent = Rect::new(-64, -64, 65, 65);
let area = Rect::new(-32, -32, 32, 32);
for alpha in [1e-7_f32, 0.999e-7, 1.001e-7] {
let mut source = Surface::new(PixelFormat::RGBA32F);
source.fill_rect(extent, &[0.25, 0.5, 0.75, alpha]);
for distance in [200.0_f32, 500.0] {
let params = FilterParams::MotionBlur { angle: 0.0, distance };
let crate::Halo::Radius(reach) = params.halo() else { panic!("local motion halo") };
let src = Image::read_clamped(&source, area.inflate(reach), extent);
let ctx = Ctx { bounds: extent, mode: ColorMode::Rgb, alpha: true };
let mut expected = source.clone();
expected.write_region(area, &motion_rows(&src, area, &ctx, 0.0, distance));
let expected = expected.to_interleaved(area);
// FFT uses a more accurate sum, but accuracy alone cannot preserve
// this branch: the upstream rounded alpha chooses whether colour
// survives. Only pixels near 1e-7 use the upstream row kernel.
let actual = super::motion_apply::apply_for_bench(&source, &params, area, extent, None, Some(extent), 64, &Interrupt::NONE)
.expect("the FFT path must exercise its low-alpha fallback")
.expect("not cancelled");
assert_eq!(actual.to_interleaved(area), expected, "near-cutoff alpha={alpha}, distance={distance}");
}
}
}
#[test]
fn motion_fft_low_alpha_at_large_coordinates_retains_upstream_row_cutoff() {
let rect = Rect::new(15_950, 15_998, 16_080, 16_002);
let out = Rect::new(16_000, 16_000, 16_003, 16_001);
let mut src = Image::new(rect, 4);
for (i, pixel) in src.data.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let y = rect.y0 + (i / rect.width() as usize) as i32;
if y == 15_999 {
pixel.copy_from_slice(&[0.25, 0.5, 0.75, 1.0]);
}
}
let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true };
let expected = motion_rows(&src, out, &ctx, 0.0005, 64.0);
let actual = fft(&src, out, true, 0.0005, 64.0);
// The relative upstream kernel does not inherit absolute-coordinate phase
// rounding. Away from its true alpha cutoff, compare numerical accuracy.
assert_ideal(&actual, &expected.into_iter().map(f64::from).collect::<Vec<_>>(), "positive low alpha at translated coordinates");
}
#[test]
fn motion_fft_scalar_row_point_matches_upstream_for_layouts_hdr_and_cutoff() {
let rect = Rect::new(-5, -4, 6, 5);
let out = Rect::new(-1, 0, 2, 1);
for ch in 1..=5 {
for alpha in [false, true] {
let mut src = pattern(rect, ch, alpha);
for pixel in src.data.chunks_exact_mut(ch) {
for value in pixel.iter_mut().take(ch - usize::from(alpha)) {
*value = *value * 32.0 - 8.0;
}
}
let ctx = Ctx { bounds: rect, mode: ColorMode::Multichannel, alpha };
for (angle, distance) in [(30.0, 64.0), (0.0, 256.0), (43.0, 1000.0)] {
// Read the reference's square window once for all three points.
// The scalar path itself never allocates that window.
let expected = motion_rows(&src, out, &ctx, angle, distance);
for (i, x) in (out.x0..out.x1).enumerate() {
let actual = super::motion_fft::row_point_for_test(&src, x, out.y0, alpha, angle, distance).expect("valid scalar row point");
assert_eq!(actual, expected[i * ch..(i + 1) * ch], "ch={ch}, alpha={alpha}, angle={angle}, distance={distance}, x={x}");
}
}
}
}
let rect = Rect::new(-34, -34, 35, 35);
for ch in 1..=5 {
for alpha in [1e-7_f32, f32::from_bits(1e-7_f32.to_bits() + 1), 0.999e-7, 1.001e-7] {
let mut pixel = vec![1e6; ch];
pixel[ch - 1] = alpha;
let src = Image { rect, ch, data: pixel.repeat((rect.width() * rect.height()) as usize) };
let ctx = Ctx { bounds: rect, mode: ColorMode::Multichannel, alpha: true };
let expected = motion_rows(&src, Rect::new(0, 0, 1, 1), &ctx, 31.0, 64.0);
let actual = super::motion_fft::row_point_for_test(&src, 0, 0, true, 31.0, 64.0).expect("valid cutoff scalar point");
assert_eq!(actual, expected, "cutoff ch={ch}, alpha={alpha}");
}
}
}
#[test]
fn motion_fft_output_outside_extent_retains_upstream_boundary_reads() {
let extent = Rect::new(0, 0, 128, 128);
let area = Rect::new(0, 200, 64, 264);
let mut source = Surface::new(PixelFormat::RGBA32F);
source.fill_rect(extent, &[0.25, 0.5, 0.75, 1.0]);
let params = FilterParams::MotionBlur { angle: 0.0, distance: 256.0 };
assert!(super::motion_apply::apply(&source, &params, area, extent, None, Some(extent), 64, &Interrupt::NONE).is_none());
let crate::Halo::Radius(reach) = params.halo() else { panic!("local motion halo") };
let src = Image::read_clamped(&source, area.inflate(reach), extent);
let ctx = Ctx { bounds: extent, mode: ColorMode::Rgb, alpha: true };
let expected = motion_rows(&src, area, &ctx, 0.0, 256.0);
let actual = crate::apply_tiled(&source, &params, area, extent, None, 64, Some(extent));
assert_eq!(actual.read_region(area), expected);
}
+128
View File
@@ -0,0 +1,128 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
fn assert_samples_equal(actual: &[f32], expected: &[f32], case: &str) {
assert_eq!(actual.len(), expected.len(), "{case}");
for (i, (&a, &b)) in actual.iter().zip(expected).enumerate() {
assert!(a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan()), "{case}, sample {i}: {a:?} != {b:?}");
}
}
fn patterned_image(rect: Rect, ch: usize, alpha: bool) -> Image {
let mut data = Vec::new();
for y in 0..rect.height() as usize {
for x in 0..rect.width() as usize {
for c in 0..ch {
let k = x * 37 + y * 13 + c * 29;
let value = if alpha && c + 1 == ch { [0.0, 1.0, 0.125, 0.875][k % 4] } else { (k % 256) as f32 / 255.0 };
data.push(value);
}
}
}
Image { rect, ch, data }
}
#[test]
fn motion_identity_preserves_signed_zero_and_transparent_colours() {
let rect = Rect::new(-1, -1, 2, 2);
let src = Image { rect, ch: 4, data: [-0.0, 8.0, -2.0, 0.0].repeat(9) };
let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true };
for distance in [-0.49, -0.0, 0.0, 0.49] {
assert_samples_equal(&motion(&src, rect, &ctx, 17.0, distance), &src.data, &format!("distance={distance}"));
}
let blurred = motion(&src, rect, &ctx, 31.0, 2.0);
assert!(blurred.iter().all(|value| *value == 0.0));
}
#[test]
fn motion_default_kernel_retains_current_upstream_rows() {
// PR 902 deliberately merges bilinear taps and changes summation order.
// The default kernel follows that implementation at every distance.
for (x0, y0) in [(0, 0), (-17, 31), (16_000, -16_000), (1 << 20, -(1 << 20))] {
let rect = Rect::new(x0, y0, x0 + 11, y0 + 9);
for ch in [1, 2, 3, 4, 5, 6] {
for alpha in [false, true] {
let src = patterned_image(rect, ch, alpha);
let ctx = Ctx { bounds: rect, mode: ColorMode::Multichannel, alpha };
let out = Rect::new(x0 + 2, y0 + 1, x0 + 7, y0 + 6);
for (angle, distance) in [(0.0, 1.0), (90.0, 2.5), (30.0, 10.0), (-45.0, -63.5), (89.75, 64.0), (37.0, 127.0)] {
assert_samples_equal(
&motion(&src, out, &ctx, angle, distance),
&motion_rows(&src, out, &ctx, angle, distance),
&format!("upstream rows: origin=({x0},{y0}), ch={ch}, alpha={alpha}, angle={angle}, distance={distance}"),
);
}
}
}
}
}
fn row_surface(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rect, selection: Option<&Surface>, extent: Option<Rect>) -> Surface {
let FilterParams::MotionBlur { angle, distance } = params else { panic!("motion-only oracle") };
let crate::Halo::Radius(reach) = params.halo() else { panic!("motion uses a local halo") };
let src = match extent {
Some(e) => Image::read_clamped(surface, area.inflate(reach), e),
None => Image::read(surface, area.inflate(reach)),
};
let format = surface.format();
let ctx = Ctx { bounds, mode: format.mode, alpha: format.alpha };
let mut data = motion_rows(&src, area, &ctx, *angle, *distance);
if let Some(selection) = selection {
crate::mix_selection(&mut data, area, selection, &src);
}
let mut result = surface.clone();
result.write_region(area, &data);
result.prune();
result
}
#[test]
fn motion_pipeline_matches_upstream_rows_across_formats_edges_selections_and_tiles() {
let rect = Rect::new(-13, 9, 16, 30);
let mut selection = Surface::new(PixelFormat::GRAY8);
let mut coverage = Vec::new();
for y in 0..rect.height() as usize {
for x in 0..rect.width() as usize {
coverage.push([0.0, 0.25, 0.5, 0.75, 1.0][(x + y * 3) % 5]);
}
}
selection.write_region(rect, &coverage);
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let format = PixelFormat::new(mode, sample, alpha);
let mut source = Surface::new(format);
let mut image = patterned_image(rect, source.channels(), alpha);
if sample == SampleType::F32 {
for pixel in image.data.chunks_exact_mut(image.ch) {
for value in pixel.iter_mut().take(image.ch - usize::from(alpha)) {
*value = *value * 4.0 - 1.0;
}
}
}
source.write_region(rect, &image.data);
for params in [
FilterParams::MotionBlur { angle: 0.0, distance: 2.5 },
FilterParams::MotionBlur { angle: 32.0, distance: 11.0 },
FilterParams::MotionBlur { angle: -90.0, distance: -7.25 },
] {
for extent in [None, Some(rect)] {
for mask in [None, Some(&selection)] {
let expected = row_surface(&source, &params, rect, rect, mask, extent);
for tile in [5, 17, 256] {
let actual = crate::apply_tiled(&source, &params, rect, rect, mask, tile, extent);
assert_eq!(
actual.to_interleaved(rect.inflate(2)),
expected.to_interleaved(rect.inflate(2)),
"mode={mode:?}, sample={sample:?}, alpha={alpha}, params={params:?}, extent={extent:?}, selection={}, tile={tile}",
mask.is_some()
);
}
}
}
}
}
}
}
}
+3
View File
@@ -968,6 +968,9 @@ pub fn apply_tiled_with(
if let Some(boxes) = blur::box_widths(params) {
return apply_box_blur(out, surface, area, extent, selection, &boxes, ctl);
}
if let Some(result) = blur::motion_apply::apply(surface, params, area, bounds, selection, extent, tile, ctl) {
return result;
}
let fmt = surface.format();
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
let halo = params.halo_for(bounds);
+42 -7
View File
@@ -766,15 +766,29 @@ pub fn render(src: &Surface, bounds: Rect, mesh: &PuppetMesh, deformed: &[[f64;
crate::warp::warp_triangles(src, bounds, &verts, &tris, interp)
}
/// Applies a puppet warp to `src` (content inside `bounds`).
pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) -> Surface {
/// The largest area (px) a puppet warp's result may span: 2³⁰, about 16000 tiles. A pin dragged
/// absurdly far stretches the mesh over far more, and the renderer allocates bookkeeping for
/// every tile of it before drawing (#1019).
pub const MAX_WARP_PIXELS: f64 = (1u64 << 30) as f64;
/// Applies a puppet warp to `src` (content inside `bounds`). `None` when the deformed mesh would
/// span more than [`MAX_WARP_PIXELS`].
pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) -> Option<Surface> {
let (solver, v, order) = deform(src, bounds, w, ITERATIONS);
// No triangles (an expansion that shrinks every pixel away): nothing to warp. Rendering the
// empty mesh would drop every pixel of the layer.
if solver.mesh.tris.is_empty() {
return src.clone();
return Some(src.clone());
}
render(src, bounds, &solver.mesh, &v, &order, interp)
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &v {
(x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1]));
}
let area = (x1 - x0 + 1.0) * (y1 - y0 + 1.0);
if area.is_nan() || area > MAX_WARP_PIXELS {
return None;
}
Some(render(src, bounds, &solver.mesh, &v, &order, interp))
}
#[cfg(test)]
@@ -832,7 +846,7 @@ mod tests {
],
] {
let w = PuppetWarp { pins, mode: PuppetMode::Normal, density: PuppetDensity::Normal, expansion: 2.0 };
let out = puppet_warp(&s, b, &w, Interp::Bicubic);
let out = puppet_warp(&s, b, &w, Interp::Bicubic).unwrap();
let worst = s.read_region(b).iter().zip(out.read_region(b)).map(|(a, c)| (a - c).abs()).fold(0.0f32, f32::max);
assert!(worst <= 1.0 / 255.0 + 1e-6, "{st:?}: {worst}");
}
@@ -855,10 +869,31 @@ mod tests {
};
assert!(build_mesh(&s, b, w.density, w.expansion).tris.is_empty());
// Nothing to warp: the pixels stay, rather than being dropped outside an empty mesh.
let out = puppet_warp(&s, b, &w, Interp::Bilinear);
let out = puppet_warp(&s, b, &w, Interp::Bilinear).unwrap();
assert_eq!(out.read_region(b), s.read_region(b));
}
#[test]
fn a_pin_dragged_absurdly_far_is_refused_before_rendering() {
// #1019: the renderer sized its tile bookkeeping by the deformed mesh, so one far pin
// allocated hundreds of MB before drawing a pixel.
let s = blob(SampleType::U8);
let b = s.content_bounds();
let pin = |dst: [f64; 2]| PuppetWarp {
pins: vec![
PuppetPin { src: [28.0, 20.0], dst: [28.0, 20.0], rotate: None, depth: 0 },
PuppetPin { src: [60.0, 62.0], dst, rotate: None, depth: 0 },
],
mode: PuppetMode::Normal,
density: PuppetDensity::Normal,
expansion: 2.0,
};
assert!(puppet_warp(&s, b, &pin([1048576.0, 1048576.0]), Interp::Bilinear).is_none());
assert!(puppet_warp(&s, b, &pin([-1e7, 62.0]), Interp::Bilinear).is_none());
let near = puppet_warp(&s, b, &pin([70.0, 66.0]), Interp::Bilinear).expect("a near pin warps");
assert!(!near.content_bounds().is_empty());
}
#[test]
fn a_single_pin_translates_rigidly() {
let s = blob(SampleType::U8);
@@ -873,7 +908,7 @@ mod tests {
for (d, x) in v.iter().zip(&solver.mesh.verts) {
assert!((d[0] - x[0] - 10.0).abs() < 1e-3 && (d[1] - x[1] + 5.0).abs() < 1e-3, "{d:?} {x:?}");
}
let out = puppet_warp(&s, b, &w, Interp::Bilinear);
let out = puppet_warp(&s, b, &w, Interp::Bilinear).unwrap();
assert_eq!(out.content_bounds(), b.translate(10, -5));
assert_eq!(out.pixel(30, 20), s.pixel(20, 25));
// A fixed rotation turns the whole mesh about the pin.
+21 -2
View File
@@ -311,8 +311,10 @@ pub fn warp_mesh_gray(s: &Surface, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync),
let v = s.read_region(src);
tmp.write_region(src, &v.iter().flat_map(|g| [*g, 1.0]).collect::<Vec<f32>>());
let w = warp_mesh_surface(&tmp, src, f, interp);
let cover = src.union(&w.content_bounds());
out.write_region(cover, &vec![default; cover.width() as usize * cover.height() as usize]);
// Clear the old region and write the warped one separately, as `warp_gray` does: one dense
// `src ∪ warped` buffer grows with the square of how far a mesh moves the content (#995).
// `Surface` is tile-sparse, so a distant region costs only its own tiles.
out.write_region(src, &vec![default; src.width() as usize * src.height() as usize]);
let b = w.content_bounds();
if !b.is_empty() {
let px = w.read_region(b);
@@ -340,6 +342,23 @@ mod tests {
surf
}
#[test]
fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() {
// #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about
// 37 GiB of floats for a 48 px selection moved 100000 px. Same fix as `warp_gray` (b70fbd4).
for sample in [SampleType::U8, SampleType::F32] {
let mut sel = Surface::new(PixelFormat::new(ColorMode::Grayscale, sample, false));
sel.fill_rect(Rect::new(8, 8, 56, 56), &[1.0]);
for d in [100_000.0, -100_000.0, 300.0] {
let out = warp_mesh_gray(&sel, &|x, y| (x + d, y + d), Interp::Bilinear);
let k = d as i32;
assert_eq!(out.content_bounds(), Rect::new(8 + k, 8 + k, 56 + k, 56 + k), "{sample:?} {d}");
assert_eq!(out.sample_channel(30 + k, 30 + k, 0), 1.0, "{sample:?} {d}: moved");
assert_eq!(out.sample_channel(30, 30, 0), 0.0, "{sample:?} {d}: old region cleared");
}
}
}
#[test]
fn identity_warp_is_a_no_op_at_every_depth() {
for s in [SampleType::U8, SampleType::U16, SampleType::F32] {
+2
View File
@@ -154,6 +154,8 @@ the same.
decoding. Header dimensions are checked before the pixel buffer is allocated, and the budget is
also passed to the underlying decoders. A violation returns `CodecError::LimitExceeded`. The
defaults are 262144 px per side, 2^30 pixels and 8 GiB (2 GiB on 32-bit targets such as wasm).
PNG also bounds the aggregate decoded text and XMP (UTF-8 keywords and values) by `max_alloc`,
including chunks after IDAT. This budget is separate from pixels, not a total process-memory cap.
## Tests
+145 -21
View File
@@ -18,8 +18,12 @@ fn err(e: impl std::fmt::Display) -> CodecError {
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let mut decoder = png::Decoder::new_with_limits(Cursor::new(bytes), png::Limits { bytes: limits.alloc_usize() });
let mut input = Cursor::new(bytes);
let mut decoder = png::Decoder::new_with_limits(&mut input, png::Limits { bytes: limits.alloc_usize() });
decoder.set_transformations(png::Transformations::EXPAND);
// png's lazy text get_text() inflates without consulting its allocation limit.
// Read text separately below, without retaining compressed copies in the decoder.
decoder.set_ignore_text_chunk(true);
{
let info = decoder.read_header_info().map_err(map_png_err)?;
let (w, h) = info.size();
@@ -46,9 +50,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
let mut buf = vec![0u8; size];
let out = reader.next_frame(&mut buf).map_err(map_png_err)?;
buf.truncate(out.buffer_size());
// Collect chunks after IDAT (text may live there). Errors here are
// tolerated: pixel data is already complete.
let _ = reader.finish();
// Collect ancillary chunks after IDAT. Tolerate malformed tails once pixels
// are complete, but never swallow an allocation-limit failure.
if let Err(e @ png::DecodingError::LimitsExceeded) = reader.finish() {
return Err(map_png_err(e));
}
if sample == SampleType::U16 {
for c in buf.as_chunks_mut::<2>().0 {
let v = u16::from_be_bytes([c[0], c[1]]);
@@ -66,23 +72,10 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
{
meta.dpi = Some((d.xppu as f32 * METERS_PER_INCH, d.yppu as f32 * METERS_PER_INCH));
}
for t in &info.uncompressed_latin1_text {
meta.text.push((t.keyword.clone(), t.text.clone()));
}
for t in &info.compressed_latin1_text {
if let Ok(s) = t.get_text() {
meta.text.push((t.keyword.clone(), s));
}
}
for t in &info.utf8_text {
if let Ok(s) = t.get_text() {
if t.keyword == XMP_KEYWORD {
meta.xmp = Some(s);
} else {
meta.text.push((t.keyword.clone(), s));
}
}
}
drop(reader);
let end = usize::try_from(input.position()).map_err(|_| err("invalid PNG reader position"))?;
let consumed = bytes.get(..end).ok_or_else(|| err("invalid PNG reader position"))?;
read_text_metadata(consumed, limits.alloc_usize(), &mut meta)?;
img.meta = meta;
if images > 1 {
img.warnings.push(DecodeWarning::MoreFrames { total: u32::try_from(images).ok() });
@@ -97,6 +90,137 @@ fn map_png_err(e: png::DecodingError) -> CodecError {
}
}
fn text_limit() -> CodecError {
CodecError::LimitExceeded("PNG decoded text metadata exceeds max_alloc".into())
}
fn split_nul(bytes: &[u8]) -> Option<(&[u8], &[u8])> {
let at = bytes.iter().position(|&b| b == 0)?;
Some((bytes.get(..at)?, bytes.get(at.checked_add(1)?..)?))
}
fn latin1_len(bytes: &[u8]) -> Option<usize> {
bytes.len().checked_add(bytes.iter().filter(|&&b| b >= 128).count())
}
/// Read only CRC-valid text chunks, including those after IDAT, with one shared
/// budget for decoded UTF-8 keywords and values (including overwritten XMP).
/// Invalid ancillary text stays optional, as it was with png's text decoder.
fn read_text_metadata(bytes: &[u8], mut remaining: usize, meta: &mut Metadata) -> Result<(), CodecError> {
let Some(mut rest) = bytes.get(8..) else { return Ok(()) };
let (mut plain, mut compressed, mut international) = (Vec::new(), Vec::new(), Vec::new());
while let Some((length, tail)) = rest.split_first_chunk::<4>() {
let Some((kind, tail)) = tail.split_first_chunk::<4>() else { break };
let length = u32::from_be_bytes(*length);
// PNG chunk lengths are at most 2^31-1. Broken tails remain tolerated.
if length > i32::MAX as u32 {
break;
}
let Ok(length) = usize::try_from(length) else { break };
let Some(data) = tail.get(..length) else { break };
let Some((checksum, next)) = tail.get(length..).and_then(|t| t.split_first_chunk::<4>()) else { break };
rest = next;
if kind == b"IEND" {
break;
}
if !matches!(kind, b"tEXt" | b"zTXt" | b"iTXt") {
continue;
}
let mut crc = flate2::Crc::new();
crc.update(kind);
crc.update(data);
if crc.sum() != u32::from_be_bytes(*checksum) {
continue;
}
let Some((keyword, value)) = split_nul(data) else { continue };
if keyword.is_empty() || keyword.len() > 79 {
continue;
}
let (value, deflated, latin1) = match kind {
b"tEXt" => (value, false, true),
b"zTXt" => {
let Some((&0, value)) = value.split_first() else { continue };
(value, true, true)
}
_ => {
let Some(([flag, method], value)) = value.split_first_chunk::<2>() else { continue };
if *flag > 1 || (*flag == 1 && *method != 0) {
continue;
}
let Some((language, value)) = split_nul(value) else { continue };
let Some((translated, value)) = split_nul(value) else { continue };
if !language.is_ascii() || std::str::from_utf8(translated).is_err() {
continue;
}
(value, *flag == 1, false)
}
};
let keyword_len = latin1_len(keyword).ok_or_else(text_limit)?;
let limit = remaining.saturating_sub(keyword_len);
let Some(text) = bounded_text(value, deflated, latin1, limit)? else { continue };
remaining = remaining.checked_sub(keyword_len).and_then(|left| left.checked_sub(text.len())).ok_or_else(text_limit)?;
let keyword: String = keyword.iter().map(|&b| char::from(b)).collect();
match kind {
b"tEXt" => plain.push((keyword, text)),
b"zTXt" => compressed.push((keyword, text)),
_ if keyword == XMP_KEYWORD => meta.xmp = Some(text),
_ => international.push((keyword, text)),
}
}
// Preserve the previous decoder's grouping of text entries by chunk type.
meta.text.extend(plain);
meta.text.extend(compressed);
meta.text.extend(international);
Ok(())
}
/// No input-sized allocation until its decoded size fits. Inflation uses a
/// fixed scratch buffer and checks each batch before growing the output.
fn bounded_text(input: &[u8], compressed: bool, latin1: bool, limit: usize) -> Result<Option<String>, CodecError> {
let mut output = Vec::new();
let mut append = |bytes: &[u8]| -> Result<(), CodecError> {
let added = if latin1 { latin1_len(bytes).ok_or_else(text_limit)? } else { bytes.len() };
if output.len().checked_add(added).is_none_or(|len| len > limit) {
return Err(text_limit());
}
output.try_reserve(added).map_err(|_| text_limit())?;
if latin1 {
// Every Latin-1 code point fits in at most two UTF-8 bytes.
let mut utf8 = [0; 2];
for &b in bytes {
output.extend_from_slice(char::from(b).encode_utf8(&mut utf8).as_bytes());
}
} else {
output.extend_from_slice(bytes);
}
Ok(())
};
if compressed {
let mut decoder = flate2::Decompress::new(true);
let mut scratch = [0; 8192];
loop {
let start_in = decoder.total_in();
let start_out = decoder.total_out();
let Some(tail) = usize::try_from(start_in).ok().and_then(|at| input.get(at..)) else { return Ok(None) };
let Ok(status) = decoder.decompress(tail, &mut scratch, flate2::FlushDecompress::None) else { return Ok(None) };
let Some(batch) = usize::try_from(decoder.total_out() - start_out).ok().and_then(|len| scratch.get(..len)) else { return Ok(None) };
append(batch)?;
if status == flate2::Status::StreamEnd {
break;
}
if start_in == decoder.total_in() && start_out == decoder.total_out() {
return Ok(None); // Truncated stream: never keep partial text.
}
}
} else {
if !latin1 && std::str::from_utf8(input).is_err() {
return Ok(None);
}
append(input)?;
}
Ok(String::from_utf8(output).ok())
}
fn is_latin1_keyword(k: &str) -> bool {
!k.is_empty() && k.len() <= 79 && k.chars().all(|c| (' '..='~').contains(&c) || ('\u{a1}'..='\u{ff}').contains(&c))
}
+4 -2
View File
@@ -3,8 +3,8 @@
use crate::error::CodecError;
use crate::image::{ChannelLayout, SampleType};
/// Decompression-bomb guards. All checks happen on header values *before*
/// the pixel buffer is allocated.
/// Decompression-bomb guards. Dimension checks precede pixel allocation;
/// metadata decoding checks its budget before growing decoded output.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
pub max_width: u32,
@@ -13,6 +13,8 @@ pub struct Limits {
pub max_pixels: u64,
/// Maximum bytes for the decoded pixel buffer (and passed on as the
/// allocation budget to underlying decoders).
/// PNG also applies this to the aggregate decoded text/XMP UTF-8 bytes,
/// including keywords, separately from pixels. This is not a total RSS cap.
pub max_alloc: u64,
}
+225
View File
@@ -93,6 +93,231 @@ fn png_bomb_header_rejected_before_alloc() {
assert!(is_limit(decode(&b)));
}
const PNG_TEXT_BUDGET: usize = 64 << 10;
fn png_text_decode(bytes: &[u8], max_alloc: usize) -> Result<Image, CodecError> {
decode_with(bytes, &opts(Limits { max_alloc: max_alloc as u64, ..Limits::default() }))
}
fn zlib(text: &[u8]) -> Vec<u8> {
use std::io::Write;
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
encoder.write_all(text).unwrap();
encoder.finish().unwrap()
}
// zTXt and iTXt payloads supplied here are already compressed.
fn png_text_stream(ty: &[u8; 4], keyword: &[u8], payload: &[u8]) -> Vec<u8> {
let mut data = keyword.to_vec();
data.push(0);
match ty {
b"tEXt" => {}
b"zTXt" => data.push(0),
b"iTXt" => data.extend_from_slice(&[1, 0, 0, 0]),
_ => panic!("unexpected text chunk"),
}
data.extend_from_slice(payload);
png_chunk(ty, &data)
}
fn png_text(ty: &[u8; 4], keyword: &[u8], text: &[u8]) -> Vec<u8> {
if ty == b"tEXt" { png_text_stream(ty, keyword, text) } else { png_text_stream(ty, keyword, &zlib(text)) }
}
fn png_with_text(before_idat: &[Vec<u8>], after_idat: &[Vec<u8>]) -> Vec<u8> {
let mut image = Vec::new();
{
let mut encoder = png::Encoder::new(&mut image, 1, 1);
encoder.set_color(png::ColorType::Grayscale);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&[127]).unwrap();
writer.finish().unwrap();
}
let mut at = 8;
let mut idat = None;
let iend = loop {
let len = u32::from_be_bytes(image[at..at + 4].try_into().unwrap()) as usize;
let ty = &image[at + 4..at + 8];
if ty == b"IDAT" && idat.is_none() {
idat = Some(at);
}
if ty == b"IEND" {
break at;
}
at += len + 12;
};
let idat = idat.unwrap();
let mut output = image[..idat].to_vec();
for chunk in before_idat {
output.extend_from_slice(chunk);
}
output.extend_from_slice(&image[idat..iend]);
for chunk in after_idat {
output.extend_from_slice(chunk);
}
output.extend_from_slice(&image[iend..]);
output
}
#[test]
fn png_compressed_text_bombs_obey_limits_before_and_after_idat() {
let bomb = vec![b'A'; PNG_TEXT_BUDGET * 8];
for ty in [b"zTXt", b"iTXt"] {
for after_idat in [false, true] {
let chunks = [png_text(ty, b"Comment", &bomb)];
let bytes = if after_idat { png_with_text(&[], &chunks) } else { png_with_text(&chunks, &[]) };
assert!(bytes.len() < PNG_TEXT_BUDGET / 2, "fixture must fit comfortably within the compressed-input budget");
assert!(is_limit(png_text_decode(&bytes, PNG_TEXT_BUDGET)), "{ty:?}, after IDAT={after_idat}");
}
}
}
#[test]
fn png_text_chunks_share_an_aggregate_budget() {
for text_len in [(PNG_TEXT_BUDGET - 3) / 3, PNG_TEXT_BUDGET / 3] {
let text = vec![b'A'; text_len];
let before = [png_text(b"tEXt", b"A", &text), png_text(b"zTXt", b"B", &text)];
let after = [png_text(b"iTXt", b"C", &text)];
let result = png_text_decode(&png_with_text(&before, &after), PNG_TEXT_BUDGET);
if 3 * (text_len + 1) <= PNG_TEXT_BUDGET {
let image = result.unwrap();
assert_eq!(image.meta.text.len(), 3);
assert!(image.meta.text.iter().all(|(_, value)| value.len() == text_len));
} else {
assert!(is_limit(result), "chunks fitting individually must still share a budget");
}
}
}
#[test]
fn png_text_accepts_the_exact_utf8_budget() {
for ty in [b"tEXt", b"zTXt", b"iTXt"] {
let text = vec![b'A'; PNG_TEXT_BUDGET - 1];
let bytes = png_with_text(&[png_text(ty, b"K", &text)], &[]);
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.text, vec![("K".into(), String::from_utf8(text).unwrap())]);
assert!(is_limit(png_text_decode(&bytes, PNG_TEXT_BUDGET - 1)), "{ty:?}: keyword and text both count");
}
}
#[test]
fn png_plain_itxt_obeys_the_exact_budget_and_validates_fields() {
let mut payload = b"K\0\0\0en\0Keyword\0".to_vec();
payload.extend_from_slice(&vec![b'A'; PNG_TEXT_BUDGET - 1]);
let bytes = png_with_text(&[], &[png_chunk(b"iTXt", &payload)]);
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.text, vec![("K".into(), "A".repeat(PNG_TEXT_BUDGET - 1))]);
assert!(is_limit(png_text_decode(&bytes, PNG_TEXT_BUDGET - 1)));
let invalid_fields: &[&[u8]] = &[
b"K\0\0\0\0\0\xFF", // Invalid text UTF-8.
b"K\0\0\0\xFF\0\0text", // Non-ASCII language tag.
b"K\0\0\0\0\xFF\0text", // Invalid translated-keyword UTF-8.
b"K\0\x02\0\0\0text", // Unknown compression flag.
b"K\0\x01\x01\0\0text", // Unknown compression method for compressed text.
b"K\0\0", // Missing compression method.
b"K\0\0\0\0text", // Missing translated-keyword separator.
];
for fields in invalid_fields {
let bytes = png_with_text(&[png_chunk(b"iTXt", fields)], &[png_text(b"tEXt", b"Good", b"retained")]);
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.text, vec![("Good".into(), "retained".into())]);
}
}
#[test]
fn png_latin1_expansion_counts_keyword_and_text_utf8_bytes() {
let text_len = (PNG_TEXT_BUDGET - 2) / 2;
for ty in [b"tEXt", b"zTXt", b"iTXt"] {
let text = if ty == b"iTXt" { "é".repeat(text_len).into_bytes() } else { vec![0xE9; text_len] };
let bytes = png_with_text(&[], &[png_text(ty, &[0xE9], &text)]);
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.text, vec![("é".into(), "é".repeat(text_len))]);
assert!(is_limit(png_text_decode(&bytes, PNG_TEXT_BUDGET - 1)), "{ty:?}: Latin-1 expands when stored as UTF-8");
}
}
#[test]
fn png_duplicate_xmp_chunks_each_consume_budget() {
const KEYWORD: &[u8] = b"XML:com.adobe.xmp";
let text_len = (PNG_TEXT_BUDGET - 2 * KEYWORD.len()) / 2;
for extra in [0, 1] {
let first = png_text(b"iTXt", KEYWORD, &vec![b'A'; text_len + extra]);
let last_text = vec![b'B'; text_len];
let last = png_text(b"iTXt", KEYWORD, &last_text);
let result = png_text_decode(&png_with_text(&[first], &[last]), PNG_TEXT_BUDGET);
if extra == 0 {
let image = result.unwrap();
assert_eq!(image.meta.xmp, Some(String::from_utf8(last_text).unwrap()));
assert!(image.meta.text.is_empty());
} else {
assert!(is_limit(result), "replacing an XMP packet must not reset its budget charge");
}
}
}
#[test]
fn png_corrupt_text_is_still_skipped() {
let valid = zlib(b"discard");
let mut bad_header = valid.clone();
bad_header[0] = 0;
let truncated = valid[..valid.len() - 4].to_vec();
for ty in [b"zTXt", b"iTXt"] {
for stream in [&bad_header, &truncated] {
let before = [png_text_stream(ty, b"Bad", stream)];
let after = [png_text(b"tEXt", b"Good", b"retained")];
let image = png_text_decode(&png_with_text(&before, &after), PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.text, vec![("Good".into(), "retained".into())]);
}
}
let invalid_utf8 = png_text(b"iTXt", b"Bad", &[0xFF]);
let image = png_text_decode(&png_with_text(&[], &[invalid_utf8]), PNG_TEXT_BUDGET).unwrap();
assert!(image.meta.text.is_empty());
}
#[test]
fn png_text_with_bad_crc_and_text_after_iend_are_ignored() {
let bomb = vec![b'A'; PNG_TEXT_BUDGET * 8];
for ty in [b"zTXt", b"iTXt"] {
let valid = png_text(ty, b"Comment", &bomb);
let mut bad_crc = valid.clone();
*bad_crc.last_mut().unwrap() ^= 1;
for after_idat in [false, true] {
let bytes = if after_idat { png_with_text(&[], &[bad_crc.clone()]) } else { png_with_text(&[bad_crc.clone()], &[]) };
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert!(image.meta.text.is_empty());
}
let mut bytes = png_with_text(&[], &[]);
bytes.extend_from_slice(&valid);
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert!(image.meta.text.is_empty());
}
}
#[test]
fn png_malformed_chunk_tails_remain_tolerated_after_pixels() {
let base = png_with_text(&[png_text(b"tEXt", b"Good", b"retained")], &[png_chunk(b"vpAg", &[])]);
let iend = base.len() - 12;
let tails: &[&[u8]] = &[&[], b"\x00\x00\x00", b"\x00\x00\x00\x10iTXtK\0\0", b"\xFF\xFF\xFF\xFFiTXt"];
for tail in tails {
let mut bytes = base[..iend].to_vec();
bytes.extend_from_slice(tail);
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.dimensions(), (1, 1));
assert_eq!(image.meta.text, vec![("Good".into(), "retained".into())]);
}
}
#[test]
fn png_invalid_optional_text_does_not_spend_an_exhausted_budget() {
let full = png_text(b"tEXt", b"K", &vec![b'A'; PNG_TEXT_BUDGET - 1]);
let invalid = [png_chunk(b"iTXt", b"Bad\0\0\0\0\0\xFF"), png_text_stream(b"zTXt", b"Bad", b"invalid")];
let image = png_text_decode(&png_with_text(&[full], &invalid), PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.text.len(), 1);
assert_eq!(image.meta.text[0].1.len(), PNG_TEXT_BUDGET - 1);
}
#[test]
fn pnm_bomb_header_rejected() {
assert!(is_limit(decode(b"P6\n200000 200000\n255\n\0\0\0")));
+1 -1
View File
@@ -1245,7 +1245,7 @@ fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option
fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option<Vec<f32>>)> {
let LayerContent::Shape(sh) = &layer.content else { return None };
sh.stroke.as_ref()?;
let (fs, ss) = shape_split::split(sh, cx.canvas)?;
let (fs, ss) = shape_split::split(sh, cx.canvas, cx.depth)?;
Some((surface_to_buffer(&fs, rect), surface_to_buffer(&ss, rect), mask_vals(layer, rect, cx)))
}
+12 -6
View File
@@ -6,7 +6,7 @@ use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Mutex, OnceLock};
use photocraft_color::PixelFormat;
use photocraft_color::{PixelFormat, SampleType};
use photocraft_doc::vector::ShapeLayer;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
@@ -22,14 +22,15 @@ fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
const CAPACITY: usize = 32;
/// (fill only, stroke only) of a stroked shape, rendered over `canvas` (RGBA8, as the CPU
/// compositor's split always was). `None` without a stroke. Where the shape's pixels
/// (fill only, stroke only) of a stroked shape, rendered over `canvas` in RGBA at `depth`.
/// `None` without a stroke. Where the shape's pixels
/// (Photoshop's rendering, `sh.cache`) reach past our fill, the part the fill doesn't explain is
/// stroke: the stroke follows Photoshop's coverage (psd-tools double-stroke-effects).
pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> {
pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surface, Surface)> {
sh.stroke.as_ref()?;
let mut h = std::collections::hash_map::DefaultHasher::new();
(canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h);
depth.hash(&mut h);
format!("{:?}{:?}{:?}{:?}", sh.path, sh.fill, sh.stroke, sh.live).hash(&mut h);
if let Some(c) = &sh.cache {
format!("{:?}{:?}", c.format(), c.default_pixel()).hash(&mut h);
@@ -47,7 +48,7 @@ pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> {
return Some(e.0.clone());
}
}
let fmt = PixelFormat::RGBA8;
let fmt = PixelFormat::RGBA8.with_sample(depth);
let fill_only = ShapeLayer { stroke: None, cache: None, ..sh.clone() };
let stroke_only = ShapeLayer { fill: None, cache: None, ..sh.clone() };
let mut parts = (photocraft_vector::render_shape(&fill_only, fmt, canvas), photocraft_vector::render_shape(&stroke_only, fmt, canvas));
@@ -92,6 +93,7 @@ fn fit_stroke(fill: &Surface, stroke: &mut Surface, cache: &Surface, canvas: Rec
stroke.read_rgba_into(r, &mut s);
cache.read_rgba_into(r, &mut c);
let mut out = Vec::with_capacity(n * 4);
let float = stroke.format().sample == SampleType::F32;
for ((fp, sp), cp) in f.iter().zip(&s).zip(&c) {
let mut p = *sp;
if fp[3] < 1.0 - 1e-3 {
@@ -101,7 +103,11 @@ fn fit_stroke(fill: &Surface, stroke: &mut Surface, cache: &Surface, canvas: Rec
} else if a > 1e-3 {
// Normal over the fill: a·p + f·(1 - a)·fill = pixels (premultiplied).
let k = fp[3] * (1.0 - a);
let un = |i: usize| ((cp[3] * cp[i] - k * fp[i]) / a).clamp(0.0, 1.0);
let un = |i: usize| {
let v = (cp[3] * cp[i] - k * fp[i]) / a;
// Float RGB retains HDR values; integer samples use the normalized range.
if float { v } else { v.clamp(0.0, 1.0) }
};
[un(0), un(1), un(2), a]
} else {
[sp[0], sp[1], sp[2], a]
+59 -5
View File
@@ -36,6 +36,56 @@ fn shape(x0: f64, x1: f64, fill: Fill, stroke: Option<ShapeStroke>) -> Layer {
Layer::new("shape", LayerContent::Shape(sh))
}
fn gradient_shape(width: u32, height: u32) -> ShapeLayer {
let (w, h) = (f64::from(width), f64::from(height));
ShapeLayer {
path: Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)])]),
fill: Some(Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false)),
stroke: Some(ShapeStroke { width: 4.0, ..ShapeStroke::default() }),
live: None,
cache: None,
psd_raw: None,
}
}
#[test]
fn split_stroked_gradient_with_effect_keeps_document_precision() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut d = Document::with_background("t", Size::new(4000, 40), ColorMode::Rgb, depth, Color::WHITE);
let mut sh = gradient_shape(4000, 40);
sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
let mut layer = Layer::new("gradient shape", LayerContent::Shape(sh));
layer.effects.items = vec![stroke(1.0, StrokePosition::Outside, blue())];
d.layers.push(layer);
let row = render(&d, Rect::from_xywh(0, 20, 4000, 1));
let mut levels: Vec<u32> = row.px.iter().map(|p| (p[0] * 65535.0).round() as u32).collect();
levels.sort_unstable();
levels.dedup();
if depth == SampleType::U8 {
assert!(levels.len() <= 256, "8-bit control: {} levels", levels.len());
} else {
assert!(levels.len() > 3000, "{depth:?}: split gradient has only {} levels", levels.len());
}
}
}
#[test]
fn split_cache_keeps_same_shape_separate_at_each_document_depth() {
let canvas = Rect::new(0, 0, 64, 8);
let mut sh = gradient_shape(64, 8);
// Keep the shape and cached tile identities identical while only the document depth changes.
sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA32F, canvas));
let mut layer = Layer::new("same shape", LayerContent::Shape(sh.clone()));
layer.effects.items = vec![stroke(1.0, StrokePosition::Outside, blue())];
for depth in [SampleType::U8, SampleType::U16, SampleType::F32, SampleType::U8] {
let mut d = Document::with_background("t", Size::new(64, 8), ColorMode::Rgb, depth, Color::WHITE);
d.layers.push(layer.clone());
let expected = photocraft_vector::render_shape(&sh, PixelFormat::RGBA8.with_sample(depth), canvas).pixel(17, 4)[0];
let actual = px(&d, 17, 4)[0];
assert!((actual - expected).abs() < 1e-6, "{depth:?}: got {actual}, expected {expected}; split cache must include document depth");
}
}
#[test]
fn outline_share_treats_stroke_and_shape_as_disjoint_areas() {
// Outside the shape the stroke covers the pixel; inside it nothing.
@@ -172,15 +222,19 @@ fn hairline_vector_stroke_keeps_the_files_edge_pixel_colour() {
// pixels are opaque and mostly fill. Our path sits off the pixel grid, so our fill covers only
// part of an edge pixel; fitting the stroke to the file's pixels must keep their colour there,
// not paint the edge in the stroke's full colour. At every depth.
for (sample, fmt) in [(SampleType::U8, PixelFormat::RGBA8), (SampleType::U16, PixelFormat::RGBA16), (SampleType::F32, PixelFormat::RGBA32F)] {
for (sample, fmt, edge, fill) in [
(SampleType::U8, PixelFormat::RGBA8, 0.86, 1.0),
(SampleType::U16, PixelFormat::RGBA16, 0.86, 1.0),
(SampleType::F32, PixelFormat::RGBA32F, 0.86, 1.0),
(SampleType::F32, PixelFormat::RGBA32F, 2.0, 2.0),
] {
let mut d = Document::with_background("t", Size::new(40, 40), ColorMode::Rgb, sample, Color::WHITE);
let edge = 0.86;
let vs = ShapeStroke { width: 0.25, paint: Fill::Solid(Color::rgb(0.48, 0.48, 0.48)), ..ShapeStroke::default() };
let path = Path::new(vec![Subpath::polygon(&[(10.3, 10.3), (29.7, 10.3), (29.7, 29.7), (10.3, 29.7)])]);
let mut cache = photocraft_raster::Surface::new(fmt);
cache.fill_rect(Rect::new(10, 10, 30, 30), &[edge, edge, edge, 1.0]);
cache.fill_rect(Rect::new(11, 11, 29, 29), &[1.0, 1.0, 1.0, 1.0]);
let sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::WHITE)), stroke: Some(vs), live: None, cache: Some(cache), psd_raw: None };
cache.fill_rect(Rect::new(11, 11, 29, 29), &[fill, fill, fill, 1.0]);
let sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(fill, fill, fill))), stroke: Some(vs), live: None, cache: Some(cache), psd_raw: None };
let mut l = Layer::new("shape", LayerContent::Shape(sh));
// Any layer effect sends the shape through the fill/stroke split.
l.effects.items = vec![Effect::default_drop_shadow()];
@@ -190,6 +244,6 @@ fn hairline_vector_stroke_keeps_the_files_edge_pixel_colour() {
assert!(close4(p, [edge, edge, edge, 1.0]), "{sample:?} edge pixel ({x}, {y}): {p:?}");
}
// The interior keeps the fill.
assert!(close4(px(&d, 20, 20), [1.0, 1.0, 1.0, 1.0]), "{sample:?} {:?}", px(&d, 20, 20));
assert!(close4(px(&d, 20, 20), [fill, fill, fill, 1.0]), "{sample:?} {:?}", px(&d, 20, 20));
}
}
+18 -21
View File
@@ -151,11 +151,12 @@ pub fn liquify_surface(surf: &Surface, strokes: &[LiquifyStroke], cell: f64, can
apply_liquify(surf, &field)
}
/// Puppet-warps a surface's content.
pub fn puppet_surface(surf: &Surface, w: &PuppetWarp, interp: Interp) -> Surface {
/// Puppet-warps a surface's content; `None` when the result would span more than
/// [`photocraft_algo::puppet::MAX_WARP_PIXELS`].
pub fn puppet_surface(surf: &Surface, w: &PuppetWarp, interp: Interp) -> Option<Surface> {
let b = surf.content_bounds();
if w.is_identity() || b.is_empty() {
return surf.clone();
return Some(surf.clone());
}
puppet_warp(surf, b, w, interp)
}
@@ -178,7 +179,8 @@ pub fn apply_to_surface(id: &str, params: &Value, surf: &Surface, canvas: Rect)
let (strokes, cell) = liquify_params(id, params, canvas).ok()?;
Some(liquify_surface(surf, &strokes, cell, canvas))
}
PUPPET | PUPPET_SMART => Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params))),
// A stored warp too far to render leaves the smart object as it is (#1019).
PUPPET | PUPPET_SMART => Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone())),
PERSPECTIVE | PERSPECTIVE_SMART => Some(perspective_surface(surf, &perspective_params(id, params).ok()?, interp(params))),
_ => None,
}
@@ -231,16 +233,10 @@ fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> {
/// Shared driver: a smart object records `cmd` as a smart filter; a pixel layer is edited in
/// place by `f(surface, selection)`.
type MaskFn<'a> = Option<&'a dyn Fn(&Surface) -> Surface>;
/// Deforms a layer's pixels (with the selection and canvas); an `Err` leaves the layer as it was.
type LayerFn<'a> = &'a dyn Fn(&Surface, Option<&Surface>, Rect) -> Result<Surface>;
fn run_on_layer(
s: &mut Session,
cmd: &str,
label: &str,
p: &Value,
moves: bool,
f: &dyn Fn(&Surface, Option<&Surface>, Rect) -> Surface,
mask: MaskFn,
) -> Result<Value> {
fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool, f: LayerFn, mask: MaskFn) -> Result<Value> {
let id = target(s, p)?;
let mut params = p.clone();
if let Value::Object(m) = &mut params {
@@ -262,7 +258,7 @@ fn run_on_layer(
let LayerContent::Raster(surf) = &mut l.content else {
return Err(EngineError::Other(format!("{label} needs a pixel layer (rasterize it first)")));
};
*surf = f(surf, selection.as_ref(), canvas);
*surf = f(surf, selection.as_ref(), canvas)?;
// A linked layer mask follows a geometric warp.
if let (Some(mf), Some(m)) = (mask, l.mask.as_mut())
&& m.linked
@@ -295,10 +291,10 @@ fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
false,
&|surf, sel, _| {
let out = apply_liquify(surf, &field);
match sel {
Ok(match sel {
Some(sel) => mix_by_selection(surf, &out, sel),
None => out,
}
})
},
None,
)?;
@@ -310,14 +306,14 @@ fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
/// Lifts the selected pixels (when there is a selection), deforms them with `f` and puts them
/// back over the rest; without a selection deforms the whole surface.
fn deform_selected(surf: &Surface, sel: Option<&Surface>, f: &dyn Fn(&Surface) -> Surface) -> Surface {
fn deform_selected(surf: &Surface, sel: Option<&Surface>, f: &dyn Fn(&Surface) -> Result<Surface>) -> Result<Surface> {
match sel {
Some(sel) => {
let (lifted, mut rest) = crate::transform_cmds::split_selected(surf, sel);
let moved = f(&lifted);
let moved = f(&lifted)?;
crate::transform_cmds::composite_over(&mut rest, &moved);
rest.prune();
rest
Ok(rest)
}
None => {
let fmt = surf.format();
@@ -335,7 +331,8 @@ fn puppet(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
return Ok(json!({"layer": id.0, "changed": false}));
}
let it = interp(p);
run_on_layer(s, cmd, "Puppet Warp", p, true, &|surf, sel, _| deform_selected(surf, sel, &|x| puppet_surface(x, &w, it)), None)
let too_far = || bad(cmd, "the pins stretch the content too far: the result would cover more than 2^30 pixels");
run_on_layer(s, cmd, "Puppet Warp", p, true, &|surf, sel, _| deform_selected(surf, sel, &|x| puppet_surface(x, &w, it).ok_or_else(too_far)), None)
}
fn perspective(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
@@ -357,7 +354,7 @@ fn perspective(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
"Perspective Warp",
p,
true,
&|surf, sel, _| deform_selected(surf, sel, &|x| perspective_surface(x, &planes, it)),
&|surf, sel, _| deform_selected(surf, sel, &|x| Ok(perspective_surface(x, &planes, it))),
Some(&mask_fn),
)?;
r["planes"] = json!(planes);
+36
View File
@@ -134,6 +134,42 @@ fn puppet_with_an_expansion_that_empties_the_mesh_keeps_the_layer() {
assert_eq!(active_surface(&s).read_region(Rect::new(0, 0, 80, 60)), before.read_region(Rect::new(0, 0, 80, 60)));
}
#[test]
fn puppet_refuses_pins_that_stretch_the_content_too_far() {
// #1019: a pin dragged to (2^20, 2^20) stretched the mesh over ~17.6 million tiles, and the
// renderer allocated bookkeeping for every one before drawing anything.
let far = json!({"pins": [{"src": [20, 20], "dst": [20, 20]}, {"src": [50, 30], "dst": [1048576, 1048576]}]});
let full = Rect::new(0, 0, 80, 60);
for depth in [8u64, 16, 32] {
for selected in [false, true] {
let mut s = session(depth);
if selected {
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
}
let (before, past) = (active_surface(&s), s.active().unwrap().history.past_len());
let err = s.execute(PUPPET, far.clone()).unwrap_err();
assert!(err.to_string().contains("too far"), "{depth} {selected}: {err}");
assert_eq!(active_surface(&s).read_region(full), before.read_region(full), "{depth} {selected}: layer unchanged");
assert_eq!(s.active().unwrap().history.past_len(), past, "{depth} {selected}: no history step");
}
// Controls: a rigid move still moves the content, and a pin 4000 px out (well inside the
// budget) still warps.
let mut s = session(depth);
s.execute(PUPPET, json!({"pins": [{"src": [20, 20], "dst": [30, 25]}, {"src": [50, 30], "dst": [60, 35]}], "mode": "rigid"})).unwrap();
assert_eq!(active_surface(&s).content_bounds(), Rect::new(20, 15, 70, 45), "{depth}");
let mut s = session(depth);
s.execute(PUPPET, json!({"pins": [{"src": [20, 20], "dst": [20, 20]}, {"src": [50, 30], "dst": [4050, 4030]}]})).unwrap();
assert!(active_surface(&s).content_bounds().x1 > 1000, "{depth}");
}
// A smart object stores the filter; a warp too far to render leaves its pixels as they were.
let mut s = session(8);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let before = active_surface(&s);
s.execute(PUPPET_SMART, far).unwrap();
assert_eq!(smart(&s).smart_filters.len(), 1);
assert_eq!(active_surface(&s), before);
}
#[test]
fn puppet_two_pins_bend_the_block() {
let mut s = session(8);
+26
View File
@@ -280,6 +280,9 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "width": nw, "height": nh, "offset": [dx, dy] }))
}
/// The largest side an explicit crop may give the canvas (the `file.new` and Canvas Size limit).
const MAX_CROP_SIDE: i32 = 300_000;
/// Image → Crop (to the selection bounds).
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let delete = p.get("deleteCroppedPixels").and_then(Value::as_bool).unwrap_or(true);
@@ -292,6 +295,12 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
crate::commands::int_i32("image.crop", p, "height")?,
) {
(Some(x), Some(y), Some(w), Some(h)) if w > 0 && h > 0 => {
// The new canvas size: refuse what File › New, Image Size and Canvas Size won't make,
// before anything changes. Unbounded, a following full-canvas flatten (Trim, Duplicate
// Merged) aborted on allocation (#960).
if w > MAX_CROP_SIDE || h > MAX_CROP_SIDE {
return Err(bad("image.crop", format!("{w} x {h} exceeds the {MAX_CROP_SIDE} px limit per side")));
}
// A far edge past i32::MAX used to saturate, silently cropping less than asked (#959).
let end = |o: i32, len: i32, ko: &str, kl: &str| {
o.checked_add(len)
@@ -672,6 +681,23 @@ mod tests {
assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 5), vec![1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn crop_refuses_a_canvas_side_past_the_new_document_limit() {
// #960: an unbounded explicit crop left a canvas that a later Trim or Duplicate Merged
// could only abort on allocating.
for (w, h) in [(100_000_000, 100_000_000), (300_001, 10), (10, 300_001)] {
let mut s = session();
let (size, past) = (doc(&s).size, s.active().unwrap().history.past_len());
let err = s.execute("image.crop", json!({"x": 0, "y": 0, "width": w, "height": h})).unwrap_err();
assert!(err.to_string().contains("300000 px limit"), "{w} x {h}: {err}");
assert_eq!((doc(&s).size, s.active().unwrap().history.past_len()), (size, past), "{w} x {h}: nothing changed");
}
// At the limit it still extends the canvas (the tiles stay lazy).
let mut s = session();
s.execute("image.crop", json!({"x": 0, "y": 0, "width": 300_000, "height": 2})).unwrap();
assert_eq!(doc(&s).size, Size::new(300_000, 2));
}
#[test]
fn trim_based_on_top_left_colour() {
let mut s = session();
+6
View File
@@ -310,7 +310,13 @@ impl Loader<'_> {
swap_to_le(&mut dp, f.sample);
let mut s = Surface::with_default(f, &decode_pixel(&f, &dp));
let len = tile_len(&f);
// Both edges of a tile's rectangle must fit in i32 (`TileCoord::rect` multiplies by the
// tile size): -8388608 ..= 8388606. A damaged manifest can name any index (#938).
let fits = |i: i32| i.checked_mul(TILE_SIZE).is_some() && i.checked_add(1).and_then(|e| e.checked_mul(TILE_SIZE)).is_some();
for t in &m.tiles {
if !fits(t.tx) || !fits(t.ty) {
return Err(FormatError::corrupt(format!("tile ({}, {}) is outside the coordinate range", t.tx, t.ty)));
}
let c = TileCoord::new(t.tx, t.ty);
if s.tile(c).is_some() {
return Err(FormatError::corrupt(format!("duplicate tile ({}, {})", t.tx, t.ty)));
+42
View File
@@ -329,3 +329,45 @@ fn sample_cached() -> &'static [u8] {
static S: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
S.get_or_init(sample)
}
/// Moves the first tile of the first top-level layer that has pixels to `(tx, ty)`; returns that
/// layer's index.
fn move_first_tile(v: &mut serde_json::Value, tx: i64, ty: i64) -> Option<usize> {
let layers = v["document"]["layers"].as_array_mut()?;
let (i, tile) = layers.iter_mut().enumerate().find_map(|(i, l)| l["content"]["surface"]["tiles"].get_mut(0).map(|t| (i, t)))?;
tile["tx"] = tx.into();
tile["ty"] = ty.into();
Some(i)
}
#[test]
fn tile_coordinates_outside_the_i32_pixel_range_are_rejected() {
// #938: a tile index whose rectangle overflows i32 loaded fine and panicked later, in
// `TileCoord::rect`, wherever the layer's bounds were asked for (wrapping in release).
let with_tile_at = |tx: i64, ty: i64| {
let layer = std::cell::Cell::new(None);
let b = rebuild(&sample(), |n, d| {
Some(if n == "manifest.json" {
let mut v: serde_json::Value = serde_json::from_slice(&d).unwrap();
layer.set(move_first_tile(&mut v, tx, ty));
serde_json::to_vec(&v).unwrap()
} else {
d
})
});
(b, layer.get().expect("the sample has a raster layer with a tile"))
};
let (lo, hi) = (i64::from(i32::MIN / 256), i64::from(i32::MAX / 256 - 1));
for (tx, ty) in [(hi + 1, 0), (0, hi + 1), (lo - 1, 0), (0, lo - 1), (i64::from(i32::MAX), 0), (0, i64::from(i32::MIN))] {
let err = load_from_bytes(&with_tile_at(tx, ty).0).unwrap_err();
assert!(err.to_string().contains("outside the coordinate range"), "({tx}, {ty}): {err}");
}
// The extreme valid indices still load, and the layer's bounds can be computed.
for (tx, ty) in [(hi, hi), (lo, lo), (hi, lo)] {
let (b, i) = with_tile_at(tx, ty);
let doc = load_from_bytes(&b).unwrap_or_else(|e| panic!("({tx}, {ty}): {e}"));
let s = doc.layers[i].surface().expect("raster layer");
assert!(s.tiles().any(|(c, _)| (i64::from(c.tx), i64::from(c.ty)) == (tx, ty)), "({tx}, {ty})");
assert!(!s.content_bounds().is_empty());
}
}
+2 -2
View File
@@ -641,7 +641,7 @@ impl<'a> Planner<'a> {
if let LayerContent::Shape(sh) = &layer.content
&& !visible_clipped.is_empty()
&& let Some((fill, stroke)) = photocraft_compose::shape_split::split(sh, self.cx.canvas)
&& let Some((fill, stroke)) = photocraft_compose::shape_split::split(sh, self.cx.canvas, self.cx.depth)
{
// The vector stroke goes above the clipped layers: the fill is the clipping base,
// the stroke is laid over the clipped result, then the masks apply to both
@@ -912,7 +912,7 @@ impl<'a> Planner<'a> {
// A stroked shape's vector stroke goes above its clipped layers and interior effects
// (compose::split_parts): the fill and the stroke unmasked, the masks applied after.
let split = match &layer.content {
LayerContent::Shape(sh) if sh.stroke.is_some() => photocraft_compose::shape_split::split(sh, canvas),
LayerContent::Shape(sh) if sh.stroke.is_some() => photocraft_compose::shape_split::split(sh, canvas, self.cx.depth),
_ => None,
};
let (mut content, vstroke) = match split {
+56 -51
View File
@@ -1056,40 +1056,42 @@ fn stroke_effects_on_filled_and_stroked_shapes() {
// clipped layers and a mask.
let Some(mut g) = gpu() else { return };
use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, StrokeAlign, Subpath};
for (fill_kind, vector_stroke, masked) in [(0, false, false), (1, false, true), (0, true, false), (1, true, true)] {
let mut d = fx_doc(90, 70, SampleType::U8);
let path = Path::new(vec![Subpath::polygon(&[(14.3, 12.6), (70.2, 18.1), (60.7, 58.4), (24.9, 50.2)])]);
let mut clear = Color::rgb(0.9, 0.3, 0.1);
clear.alpha = 0.0;
let fill = if fill_kind == 0 {
Fill::Solid(Color::rgb(0.3, 0.6, 0.9))
} else {
Fill::gradient(vec![(0.0, Color::rgb(0.9, 0.3, 0.1)), (1.0, clear)], 20.0, 1.0, GradientStyle::Linear, false)
};
let stroke_v = vector_stroke.then(|| ShapeStroke {
width: 3.0,
align: StrokeAlign::Inside,
paint: Fill::Solid(Color::rgb(0.1, 0.8, 0.2)),
..ShapeStroke::default()
});
let mut sh = ShapeLayer { path, fill: Some(fill), stroke: stroke_v, live: None, cache: None, psd_raw: None };
sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
let mut l = Layer::new("shape", LayerContent::Shape(sh));
if masked {
l.mask = Some(mask(Rect::new(0, 0, 90, 70), 7, 0.6));
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for (fill_kind, vector_stroke, masked) in [(0, false, false), (1, false, true), (0, true, false), (1, true, true)] {
let mut d = fx_doc(90, 70, depth);
let path = Path::new(vec![Subpath::polygon(&[(14.3, 12.6), (70.2, 18.1), (60.7, 58.4), (24.9, 50.2)])]);
let mut clear = Color::rgb(0.9, 0.3, 0.1);
clear.alpha = 0.0;
let fill = if fill_kind == 0 {
Fill::Solid(Color::rgb(0.3, 0.6, 0.9))
} else {
Fill::gradient(vec![(0.0, Color::rgb(0.9, 0.3, 0.1)), (1.0, clear)], 20.0, 1.0, GradientStyle::Linear, false)
};
let stroke_v = vector_stroke.then(|| ShapeStroke {
width: 3.0,
align: StrokeAlign::Inside,
paint: Fill::Solid(Color::rgb(0.1, 0.8, 0.2)),
..ShapeStroke::default()
});
let mut sh = ShapeLayer { path, fill: Some(fill), stroke: stroke_v, live: None, cache: None, psd_raw: None };
sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
let mut l = Layer::new("shape", LayerContent::Shape(sh));
if masked {
l.mask = Some(mask(Rect::new(0, 0, 90, 70), 7, 0.6));
}
l.effects.items = vec![
Effect::Stroke(stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)))),
Effect::Stroke(stroke(5.0, StrokePosition::Outside, FxPaint::Gradient(gradient()))),
Effect::Stroke(stroke(3.0, StrokePosition::Inside, FxPaint::Gradient(gradient()))),
Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Multiply, 0.7), color: Color::rgb(0.2, 0.2, 0.9) },
Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 120.0, 4.0, 5.0, 0.0)),
];
d.layers.push(l);
let mut c = noise_layer("clip", d.pixel_format(), Rect::new(30, 0, 60, 70), 9, 0.5);
c.clipped = true;
d.layers.push(c);
fx_check(&mut g, &d, &format!("{depth:?} fill {fill_kind} vector stroke {vector_stroke} masked {masked}"));
}
l.effects.items = vec![
Effect::Stroke(stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)))),
Effect::Stroke(stroke(5.0, StrokePosition::Outside, FxPaint::Gradient(gradient()))),
Effect::Stroke(stroke(3.0, StrokePosition::Inside, FxPaint::Gradient(gradient()))),
Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Multiply, 0.7), color: Color::rgb(0.2, 0.2, 0.9) },
Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 120.0, 4.0, 5.0, 0.0)),
];
d.layers.push(l);
let mut c = noise_layer("clip", PixelFormat::RGBA8, Rect::new(30, 0, 60, 70), 9, 0.5);
c.clipped = true;
d.layers.push(c);
fx_check(&mut g, &d, &format!("fill {fill_kind} vector stroke {vector_stroke} masked {masked}"));
}
}
@@ -1246,25 +1248,28 @@ fn blend_mode_extremes() {
fn stroked_shapes_with_clipped_layers() {
let Some(mut g) = gpu() else { return };
use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, Subpath};
for (blend, masked) in [(BlendMode::Normal, false), (BlendMode::Multiply, true)] {
let mut d = base_doc(80, 64);
let path = Path::new(vec![Subpath::polygon(&[(10.3, 8.6), (66.2, 12.1), (58.7, 54.4), (16.9, 48.2)])]);
let stroke = ShapeStroke { width: 5.0, paint: Fill::Solid(Color::rgb(0.9, 0.9, 0.1)), ..Default::default() };
let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.3, 0.8))), stroke: Some(stroke), live: None, cache: None, psd_raw: None };
sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
let mut l = Layer::new("shape", LayerContent::Shape(sh));
l.blend = blend;
l.opacity = 0.9;
if masked {
l.mask = Some(mask(Rect::new(0, 0, 80, 64), 41, 1.0));
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for (blend, masked) in [(BlendMode::Normal, false), (BlendMode::Multiply, true)] {
let mut d = Document::new("t", Size::new(80, 64), ColorMode::Rgb, depth);
d.layers.push(noise_layer("bg", d.pixel_format(), Rect::from_xywh(0, 0, 80, 64), 1, 0.3));
let path = Path::new(vec![Subpath::polygon(&[(10.3, 8.6), (66.2, 12.1), (58.7, 54.4), (16.9, 48.2)])]);
let stroke = ShapeStroke { width: 5.0, paint: Fill::Solid(Color::rgb(0.9, 0.9, 0.1)), ..Default::default() };
let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.3, 0.8))), stroke: Some(stroke), live: None, cache: None, psd_raw: None };
sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
let mut l = Layer::new("shape", LayerContent::Shape(sh));
l.blend = blend;
l.opacity = 0.9;
if masked {
l.mask = Some(mask(Rect::new(0, 0, 80, 64), 41, 1.0));
}
let mut c = noise_layer("clip", d.pixel_format(), Rect::new(0, 0, 80, 64), 42, 0.5);
c.clipped = true;
c.blend = BlendMode::Screen;
d.layers.extend([l, c]);
let st = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, &d, d.bounds());
assert!(st.is_ok(), "planned on the GPU");
check(&mut g, &d, &format!("{depth:?} stroked shape + clipped {blend:?} masked {masked}"));
}
let mut c = noise_layer("clip", PixelFormat::RGBA8, Rect::new(0, 0, 80, 64), 42, 0.5);
c.clipped = true;
c.blend = BlendMode::Screen;
d.layers.extend([l, c]);
let st = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, &d, d.bounds());
assert!(st.is_ok(), "planned on the GPU");
check(&mut g, &d, &format!("stroked shape + clipped {blend:?} masked {masked}"));
}
}
+29 -2
View File
@@ -676,7 +676,11 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec<u8>)> {
put16(&mut v, 0);
v.extend_from_slice(&((density.clamp(0.0, 1.0) * 100.0).round() as u32).to_be_bytes());
v.push(u8::from(*preserve_luminosity));
v.push(0);
// Photoshop requires the 17-byte Photo Filter payload after the version to be
// padded to a 4-byte boundary INSIDE the tagged block's declared length.
// A single pad byte made an 18-byte `phfl` block that Photoshop could not open
// even though PhotoCraft and other tolerant readers accepted it (#1455).
v.extend_from_slice(&[0, 0, 0]);
return vec![(*b"phfl", v)];
}
Adjustment::ChannelMixer { matrix, monochrome } => {
@@ -923,12 +927,35 @@ mod tests {
let mut v2 = i16s(&[2, 0]);
v2.extend([0xff, 0xff, 0x80, 0x00, 0, 0, 0, 0]);
v2.extend(40u32.to_be_bytes());
v2.extend([1, 0]);
v2.extend([1, 0, 0, 0]);
assert_eq!(v2.len(), 20, "Photo Filter must occupy 20 bytes");
assert_eq!(&v2[17..], &[0, 0, 0], "padding is included inside the block length");
let a = parse(b"phfl", &v2, None, Channels::Rgb);
let Adjustment::PhotoFilter { color, density, preserve_luminosity: true } = a else { panic!("{a:?}") };
assert_eq!(color, [1.0, 32768.0 / 65535.0, 0.0]);
assert_eq!(density, 0.4);
assert_eq!(write(&a)[0].1, v2, "version 2 RGB is written back byte-exact");
// Older PhotoCraft files used only one padding byte (18 bytes); keep importing them,
// but never produce that Photoshop-incompatible length again.
assert_eq!(parse(b"phfl", &v2[..18], None, Channels::Rgb), a);
for preserve_luminosity in [false, true] {
for density in [0.0, 0.14, 1.0] {
let adjustment = Adjustment::PhotoFilter { color: [0.8, 0.4, 0.2], density, preserve_luminosity };
let blocks = write(&adjustment);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].0, *b"phfl");
assert_eq!(blocks[0].1.len(), 20);
assert_eq!(&blocks[0].1[17..], &[0, 0, 0]);
let Adjustment::PhotoFilter { color, density: read_density, preserve_luminosity: read_preserve } =
parse(b"phfl", &blocks[0].1, None, Channels::Rgb)
else {
panic!("written phfl must parse")
};
assert!(color.iter().zip([0.8, 0.4, 0.2]).all(|(got, expected)| (got - expected).abs() <= 1.0 / 65535.0));
assert!((read_density - density).abs() < 0.00001);
assert_eq!(read_preserve, preserve_luminosity);
}
}
// Version 2, Lab colour structure: L 50, a 0, b 0 is mid gray.
let mut lab = i16s(&[2, 7, 5000, 0, 0, 0]);
lab.extend(25u32.to_be_bytes());
+46 -1
View File
@@ -437,7 +437,13 @@ impl Ex {
_ => adjust_map::Channels::Other,
};
let cged = raw.iter().find(|(k, _)| k == b"CgEd").map(|(_, d)| d.clone());
let keep = raw.iter().any(|(k, d)| adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a);
let keep = raw.iter().any(|(k, d)| {
// A previously saved PhotoCraft v2 Photo Filter block may be 18 bytes.
// It parses here, but Photoshop requires four-byte alignment, so repair
// that legacy record on re-save rather than preserving the broken bytes.
let legacy_phfl = k == b"phfl" && d.starts_with(&[0, 2]) && d.len() % 4 != 0;
!legacy_phfl && adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a
});
if !keep {
raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd");
let w = adjust_map::write(a);
@@ -1369,6 +1375,45 @@ mod tests {
Document::new("size estimate", photocraft_geom::Size::new(width, height), ColorMode::Rgb, SampleType::U8)
}
#[test]
fn photo_filter_psd_is_four_byte_aligned_and_round_trips() {
let color = [60000u16, 30000, 0].map(|x| x as f32 / 65535.0);
let adjustment = photocraft_doc::Adjustment::PhotoFilter { color, density: 0.14, preserve_luminosity: true };
let mut doc =
Document::with_background("Photo Filter PSD", photocraft_geom::Size::new(32, 24), ColorMode::Rgb, SampleType::U8, photocraft_doc::Color::WHITE);
let mut layer = Layer::new("Warming Filter", LayerContent::Adjustment(adjustment.clone()));
doc.layers.push(layer.clone());
let assert_phfl = |file: &PsdFile| {
let records = &file.layer_info.as_ref().unwrap().layers;
let data =
records.iter().flat_map(|r| r.blocks.iter()).find(|b| b.key == *b"phfl").map(|b| b.data.as_slice()).expect("Photo Filter adjustment data");
assert_eq!(data.len(), 20, "PSD layer block length includes four-byte padding");
assert_eq!(&data[0..2], &[0, 2], "version 2 RGB Photo Filter");
assert_eq!(&data[17..], &[0, 0, 0], "three padding bytes after the 17-byte payload");
};
let exported = document_to_psd(&doc);
assert_phfl(&exported);
let encoded = exported.to_bytes().unwrap();
let parsed = PsdFile::from_bytes(&encoded).unwrap();
assert_phfl(&parsed);
let (restored, _) = crate::psd_import::psd_to_document(&parsed);
assert!(restored.layers.iter().any(|l| l.content == LayerContent::Adjustment(adjustment.clone())));
// Importing a legacy 18-byte block must still work; saving it again should repair it
// even when the adjustment values have not changed (normally raw blocks are reused).
let mut legacy = crate::adjust_map::write(&adjustment)[0].1.clone();
legacy.truncate(18);
layer.psd_blocks = vec![(*b"phfl", std::sync::Arc::new(legacy))];
doc.layers.pop();
doc.layers.push(layer);
let repaired = document_to_psd(&doc);
assert_phfl(&repaired);
let repaired_bytes = repaired.to_bytes().unwrap();
assert_phfl(&PsdFile::from_bytes(&repaired_bytes).unwrap());
}
#[test]
fn size_limit_boundary_keeps_limit_and_switches_above_it() {
assert!(!exceeds_psd_size_limit(Some(PSD_MAX_FILE_SIZE)));
+167
View File
@@ -0,0 +1,167 @@
//! Camera profiles measured by PhotoCraft, for raw formats that carry no colour description of
//! their own (DNG does; Nikon NEF does not).
//!
//! Clean-room: every number here comes from our own measurements of the camera's output, never
//! from another converter's tables. A profile gives the image area inside the sensor data (the
//! camera's own JPEG crop), the saturation level, a ForwardMatrix (white-balanced camera → XYZ D50,
//! DNG semantics) and a default tone curve (like DNG ProfileToneCurve).
//!
//! **Nikon D4** (148 uncompressed NEFs from one body, 2023–2026, ISO 100–10000): the sensor data is
//! 4992 × 3292; the camera's full-size JPEG (4928 × 3280) sits at (8, 6), found by aligning the two
//! (2 px of optical black on the left, 50 + 8 on the right show as a purple stripe otherwise). The
//! data is already black-subtracted (the darkest image pixels reach 0). Green saturates at
//! 15546–15672 while red and blue reach 15687–16383, so one level of 15520 makes clipped
//! highlights white without shifting the white balance. Matrix and curve were fitted to the
//! camera's embedded JPEGs (Picture Control Standard, Active D-Lighting off; 52 frames, smooth
//! areas, 4 × 4 means): on 15 held-out frames the development is 7.2/255 per channel off the
//! camera's JPEG on average, against 34 without a profile.
use crate::color::{Calibration, ColorInfo, IDENTITY, Mat3};
use crate::sensor::{Rect, Sensor};
const D4_FORWARD: Mat3 = [[0.644598, 0.0703598, 0.249262], [0.342249, 0.518929, 0.138822], [0.0568265, -0.269568, 1.03795]];
const D4_COLOR: Mat3 = [[0.895936, -0.218092, -0.185988], [-1.09736, 2.06898, -0.0131889], [-0.26977, 0.393884, 0.687829]];
const D4_TONE: [[f32; 2]; 31] = [
[0.005524272, 0.00285028],
[0.006569503, 0.00357083],
[0.0078125, 0.00447912],
[0.009290681, 0.00605813],
[0.01104854, 0.00841445],
[0.01313901, 0.011536],
[0.015625, 0.0165399],
[0.01858136, 0.0230234],
[0.02209709, 0.0319205],
[0.02627801, 0.0434858],
[0.03125, 0.0582138],
[0.03716272, 0.0771963],
[0.04419417, 0.0993894],
[0.05255603, 0.125058],
[0.0625, 0.157207],
[0.07432544, 0.194929],
[0.08838835, 0.236109],
[0.1051121, 0.279155],
[0.125, 0.329365],
[0.1486509, 0.388405],
[0.1767767, 0.457944],
[0.2102241, 0.52456],
[0.25, 0.598051],
[0.2973018, 0.668794],
[0.3535534, 0.749954],
[0.4204482, 0.815443],
[0.5, 0.870167],
[0.5946036, 0.907869],
[std::f32::consts::FRAC_1_SQRT_2, 0.939351],
[0.8408964, 0.970567],
[1.0, 1.0],
];
struct Profile {
make: &'static str,
model: &'static str,
/// Sensor data size the profile was measured on.
size: (usize, usize),
/// The image area: x, y, width, height.
crop: (usize, usize, usize, usize),
white: f32,
/// The black level is known to be 0 (the data is black-subtracted in camera).
black_zero: bool,
forward: Mat3,
color: Mat3,
tone: &'static [[f32; 2]],
}
const PROFILES: &[Profile] = &[Profile {
make: "NIKON CORPORATION",
model: "NIKON D4",
size: (4992, 3292),
crop: (8, 6, 4928, 3280),
white: 15520.0,
black_zero: true,
forward: D4_FORWARD,
color: D4_COLOR,
tone: &D4_TONE,
}];
/// Applies the measured profile of the camera that made `s`, when there is one.
pub(crate) fn apply(s: &mut Sensor) {
let (Some(make), Some(model)) = (s.make.as_deref(), s.model.as_deref()) else { return };
let Some(p) = PROFILES.iter().find(|p| make.trim() == p.make && model.trim() == p.model && (s.width, s.height) == p.size) else { return };
let (x, y, w, h) = p.crop;
s.crop = Rect::new(x, y, w, h);
s.white = [p.white; 3];
if p.black_zero {
s.warnings.retain(|w| !w.contains("black level not recorded"));
}
s.color = ColorInfo {
// D50 daylight: the matrices are consistent at the camera's daylight neutral.
calibrations: vec![Calibration { temperature: 5003.0, color_matrix: p.color, forward_matrix: Some(p.forward), camera_calibration: IDENTITY }],
..ColorInfo::default()
};
s.tone_curve = p.tone.to_vec();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RawFormat;
use crate::color::apply as mat_apply;
use crate::sensor::BlackLevels;
fn nef(model: &str, width: usize, height: usize) -> Sensor {
Sensor {
format: RawFormat::Nef,
make: Some("NIKON CORPORATION".into()),
model: Some(model.into()),
width,
height,
samples: 1,
data: Vec::new(),
cfa: None,
linearization: None,
black: BlackLevels::uniform(0.0),
white: [16383.0; 3],
active: Rect::new(0, 0, width, height),
crop: Rect::new(0, 0, width, height),
color: ColorInfo::default(),
camera_wb: Some([2.0, 1.0, 1.4]),
orientation: 1,
baseline_exposure: 0.0,
gain_maps: Vec::new(),
tone_curve: Vec::new(),
warnings: vec!["NEF: black level not recorded in a documented tag; assumed 0".into()],
}
}
#[test]
fn a_d4_nef_gets_its_crop_levels_matrix_and_curve() {
let mut s = nef("NIKON D4", 4992, 3292);
apply(&mut s);
assert_eq!(s.crop, Rect::new(8, 6, 4928, 3280));
assert_eq!(s.white, [15520.0; 3]);
assert_eq!(s.color.calibrations.len(), 1);
assert_eq!(s.color.calibrations[0].forward_matrix, Some(D4_FORWARD));
assert_eq!(s.tone_curve.len(), D4_TONE.len());
assert!(s.warnings.is_empty(), "the D4's black level is known: {:?}", s.warnings);
// The profile gives a colour conversion for the as-shot white balance.
assert!(s.color.balanced_to_xyz_d50([0.5, 1.0, 0.7]).is_some());
}
#[test]
fn other_cameras_and_sizes_are_left_alone() {
for (model, w, h) in [("NIKON D4S", 4992, 3292), ("NIKON D4", 2496, 1646), ("NIKON D800", 7424, 4924)] {
let mut s = nef(model, w, h);
apply(&mut s);
assert_eq!(s.crop, Rect::new(0, 0, w, h), "{model} {w}x{h}");
assert!(s.tone_curve.is_empty() && s.color.calibrations.is_empty() && s.white == [16383.0; 3]);
assert_eq!(s.warnings.len(), 1);
}
}
#[test]
fn d4_forward_matrix_maps_neutral_to_d50_and_curve_reaches_white() {
let n = mat_apply(&D4_FORWARD, [1.0; 3]);
assert!((n[0] - 0.9642).abs() < 1e-3 && (n[1] - 1.0).abs() < 1e-3 && (n[2] - 0.8252).abs() < 1e-3, "{n:?}");
assert!(D4_TONE.windows(2).all(|w| w[1][0] > w[0][0] && w[1][1] >= w[0][1]), "increasing");
assert_eq!(D4_TONE.last().map(|p| (p[0], p[1])), Some((1.0, 1.0)));
}
}
+1
View File
@@ -285,6 +285,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result<Sensor> {
orientation: t.tag_uint(&ifd0, tag::ORIENTATION).map(|o| o as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1),
baseline_exposure: 0.0,
gain_maps: Vec::new(),
tone_curve: Vec::new(),
warnings,
})
}
+47 -3
View File
@@ -274,9 +274,9 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result<Developed> {
}
}
// Camera → ProPhoto, exposure, gamma 1.8, 16 bits.
// Camera → ProPhoto, exposure, the profile's tone curve, gamma 1.8, 16 bits.
let mut out = vec![0u16; w * h * 3];
let lut = GammaLut::new();
let lut = GammaLut::with_curve(&s.tone_curve);
par::chunks_mut(&mut out, band * w * 3, |b, chunk| {
let start = b * band * w * 3;
let src = &rgb[start..start + chunk.len()];
@@ -347,8 +347,20 @@ struct GammaLut {
const GAMMA_STEPS: usize = 4096;
impl GammaLut {
#[cfg(test)]
fn new() -> Self {
GammaLut { table: (0..=GAMMA_STEPS + 1).map(|i| ((i as f32 / GAMMA_STEPS as f32).min(1.0)).powf(2.0 / 1.8) * 65535.0).collect() }
Self::with_curve(&[])
}
/// The gamma-1.8 encoding after a tone curve (see [`Sensor::tone_curve`]; empty: none).
fn with_curve(curve: &[[f32; 2]]) -> Self {
let table = (0..=GAMMA_STEPS + 1)
.map(|i| {
let s = (i as f32 / GAMMA_STEPS as f32).min(1.0);
tone(curve, s * s).clamp(0.0, 1.0).powf(1.0 / 1.8) * 65535.0
})
.collect();
GammaLut { table }
}
#[inline]
@@ -362,6 +374,22 @@ impl GammaLut {
}
}
/// `curve` at linear `v`: linear interpolation between the points in log₂ of the input, a line
/// through 0 below the first point, the last output beyond the last.
fn tone(curve: &[[f32; 2]], v: f32) -> f32 {
let (Some(first), Some(last)) = (curve.first(), curve.last()) else { return v };
if v <= first[0] {
return if first[0] > 0.0 { v * first[1] / first[0] } else { first[1] };
}
if v >= last[0] {
return last[1];
}
let i = curve.partition_point(|p| p[0] <= v).clamp(1, curve.len() - 1);
let ([x0, y0], [x1, y1]) = (curve[i - 1], curve[i]);
let f = (v.log2() - x0.log2()) / (x1.log2() - x0.log2());
y0 + (y1 - y0) * f
}
/// Applies a TIFF orientation (1–8) to interleaved RGB.
pub(crate) fn orient(src: Vec<u16>, w: usize, h: usize, o: u16) -> (Vec<u16>, usize, usize) {
if !(2..=8).contains(&o) {
@@ -409,6 +437,22 @@ mod tests {
assert_eq!(lut.encode(f32::NAN), 0);
}
#[test]
fn tone_curve_is_folded_into_the_gamma_table() {
let code = |v: f32| v.powf(1.0 / 1.8) * 65535.0;
let lut = GammaLut::with_curve(&[[0.25, 0.5], [1.0, 1.0]]);
let near = |got: u16, want: f32| (f32::from(got) - want).abs() <= 2.0;
// On a point, between points (linear in log2 of the input), on the line through 0 below the first.
assert!(near(lut.encode(0.25), code(0.5)));
assert!(near(lut.encode(0.5), code(0.75)), "{} vs {}", lut.encode(0.5), code(0.75));
assert!(near(lut.encode(0.125), code(0.25)));
assert_eq!(lut.encode(0.0), 0);
assert_eq!(lut.encode(1.0), 65535);
assert!((0..1000).map(|i| lut.encode(i as f32 / 999.0)).collect::<Vec<_>>().windows(2).all(|w| w[1] >= w[0]));
// No curve: the plain gamma 1.8.
assert!(near(GammaLut::with_curve(&[]).encode(0.25), code(0.25)));
}
#[test]
fn orientations() {
// 3×2 image, pixel value = index.
+1
View File
@@ -250,6 +250,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result<Sensor> {
orientation: t.tag_uint(&ifd0, tag::ORIENTATION).map(|o| o as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1),
baseline_exposure: if baseline_exposure.is_finite() { baseline_exposure.clamp(-10.0, 10.0) } else { 0.0 },
gain_maps,
tone_curve: Vec::new(),
warnings,
})
}
+4
View File
@@ -17,6 +17,9 @@
//! orientation.
//! * [`embedded_preview`] finds the camera's full-size JPEG preview, for
//! formats whose sensor data is not decoded yet.
//! * Raws that carry no colour description (NEF) take a camera profile we
//! measured from the camera's own output when there is one (`cameras.rs`:
//! image area, levels, ForwardMatrix, tone curve; the Nikon D4 so far).
//!
//! Decoded today: DNG (uncompressed and lossless-JPEG, strips and tiles, CFA
//! and LinearRaw), CR2 (lossless JPEG with Canon slices), uncompressed or
@@ -31,6 +34,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod cameras;
mod color;
mod cr2;
mod demosaic;
+1
View File
@@ -149,6 +149,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result<Sensor> {
orientation: t.tag_uint(&raw, tag::ORIENTATION).map(|o| o as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1),
baseline_exposure: 0.0,
gain_maps: Vec::new(),
tone_curve: Vec::new(),
warnings,
})
}
+1
View File
@@ -169,6 +169,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result<Sensor> {
orientation: t.tag_uint(&ifd, tag::ORIENTATION).map(|o| o as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1),
baseline_exposure: 0.0,
gain_maps: Vec::new(),
tone_curve: Vec::new(),
warnings,
})
}
+4
View File
@@ -153,6 +153,10 @@ pub struct Sensor {
pub baseline_exposure: f64,
/// DNG OpcodeList2 gain maps (lens shading), applied to the normalized data.
pub gain_maps: Vec<crate::opcodes::GainMap>,
/// The camera profile's default tone curve (like DNG ProfileToneCurve): increasing `[input,
/// output]` pairs in linear ProPhoto, applied per channel after the colour conversion and
/// exposure. Below the first point the curve is a line through 0; empty: no curve.
pub tone_curve: Vec<[f32; 2]>,
pub warnings: Vec<String>,
}
+5 -2
View File
@@ -193,7 +193,7 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result<Sen
}
}
let ifd0 = t.ifd_at(t.first_ifd, 0);
Ok(Sensor {
let mut sensor = Sensor {
format,
make,
model,
@@ -212,8 +212,11 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result<Sen
orientation: ifd0.and_then(|i| t.tag_uint(&i, tag::ORIENTATION)).map(|o| o as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1),
baseline_exposure: 0.0,
gain_maps: Vec::new(),
tone_curve: Vec::new(),
warnings,
})
};
crate::cameras::apply(&mut sensor);
Ok(sensor)
}
#[cfg(test)]
+20 -13
View File
@@ -986,7 +986,7 @@ fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx:
}
/// Live preview for an open filter dialog: run the filter on the proxy and upload it.
fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64)> {
fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64, [u32; 2])> {
if crate::adjust_preview::on_layer(app, idx) {
return None;
}
@@ -1017,7 +1017,9 @@ fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u6
}
app.filter_preview = Some(crate::filter_dialog::FilterPreview { doc: doc_id, revision, hash, k, result });
}
app.filter_preview.as_ref().filter(|p| p.result.is_some()).map(|p| (p.k, key))
let preview = app.filter_preview.as_ref()?;
let result = preview.result.as_ref()?;
Some((preview.k, key, [result.size.width, result.size.height]))
}
/// The document pixels a (non-rotated) view shows, with a margin for filtering.
@@ -1029,8 +1031,8 @@ fn visible_doc_rect(xf: &ViewXform) -> DRect {
/// Zoomed-out Image › Adjustments preview on a large document: the wgpu compositor renders the
/// reduced preview document (`adjust_preview::gpu_proxy`) into its own texture, over the target's
/// area after the first frame. Returns (factor, gpu key) to draw.
fn ensure_adjust_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option<(u32, u64)> {
/// area after the first frame. Returns (factor, gpu key, dimensions) to draw.
fn ensure_adjust_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option<(u32, u64, [u32; 2])> {
let frame = crate::adjust_preview::gpu_proxy(app, idx, zoom)?;
let key = frame.doc.id.0;
let size = [frame.doc.size.width, frame.doc.size.height];
@@ -1042,12 +1044,12 @@ fn ensure_adjust_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option
app.perf.record(if r.kind.starts_with("gpu") { "gpu-adjust-proxy" } else { "adjust-proxy" }, r.px, r.composite_ms, r.upload_ms);
crate::adjust_preview::proxy_drawn(app, &frame);
}
Some((frame.k, key))
Some((frame.k, key, size))
}
/// If a live adjustment preview is active on a large document, composite it on the proxy and upload
/// it under its own GPU key. Returns (factor, gpu key) when the proxy should be drawn.
fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64)> {
/// it under its own GPU key. Returns (factor, gpu key, dimensions) when the proxy should be drawn.
fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64, [u32; 2])> {
let (layer, params) = app.live_adjust.clone()?;
let (doc_id, revision, doc) = {
let st = app.session.documents().get(idx)?;
@@ -1082,7 +1084,7 @@ fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64
app.perf.record("proxy", p.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1);
app.proxy_uploaded = Some((doc_id, hash));
}
Some((k, key))
Some((k, key, [proxy.size.width, proxy.size.height]))
}
/// A CPU composite as the GPU canvas texture stores it (sRGB-encoded for linear documents).
@@ -1830,16 +1832,21 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// A flipped view draws through the CPU path (the GPU canvas shader has no mirroring).
if app.gpu.is_some()
&& !flip
&& let Some((k, key)) = ensure_adjust_proxy(app, idx, view.zoom * ctx.pixels_per_point())
&& let Some((k, key, preview_size)) = ensure_adjust_proxy(app, idx, view.zoom * ctx.pixels_per_point())
.or_else(|| ensure_filter_preview(app, idx))
.or_else(|| ensure_proxy_preview(app, idx))
{
on_gpu = true;
let original_size = [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)];
let params = crate::gpu_canvas::ViewParams {
doc: key,
doc_size: [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)],
doc_size: preview_size,
zoom: view.zoom * k as f32,
center: [view.center[0] / k as f32, view.center[1] / k as f32],
// A size-changing preview grows around the old image center; keep the view's pan.
center: [
view.center[0] / k as f32 + (preview_size[0] as f32 - original_size[0] as f32) / 2.0,
view.center[1] / k as f32 + (preview_size[1] as f32 - original_size[1] as f32) / 2.0,
],
shadow: {
let t = crate::theme::Tokens::get(&ctx);
!t.bevel && !t.pro && drop_shadow
@@ -1926,11 +1933,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
// Pixel grid at high zoom (the GPU path draws its own).
if !on_gpu && pixel_grid && view.zoom >= 12.0 {
if !on_gpu && pixel_grid && view.zoom > 5.0 {
let vis = img_rect.intersect(rect);
let a = xf.to_doc(vis.min);
let b = xf.to_doc(vis.max);
let grid = Stroke::new(1.0, Color32::from_white_alpha(40));
let grid = Stroke::new(1.0, Color32::from_white_alpha(64));
for x in (a[0].floor() as i32)..=(b[0].ceil() as i32) {
let sx = xf.to_screen(x as f32, 0.0).x;
painter.line_segment([pos2(sx, vis.top()), pos2(sx, vis.bottom())], grid);
+15 -10
View File
@@ -13,10 +13,11 @@
//!
//! Per frame the shader draws, in *device pixels*:
//! - a soft analytic drop shadow around the document on the pasteboard (erf-blurred box),
//! - a screen-space transparency checkerboard (8 pt squares, grays 255/204) under the document,
//! - a transparency checkerboard (8 pt squares, grays 255/204, the same size at every zoom)
//! under the document, anchored to the document's top-left corner so it moves with the image,
//! - the document itself, sampled trilinearly when zoomed out, bilinearly between 1× and 2×, and
//! with an anti-aliased "sharp nearest" filter at integer scales and ≥ 2×,
//! - a one-device-pixel pixel grid at zoom ≥ 8.
//! - a one-device-pixel pixel grid above 500% zoom, over pixels with content only.
//!
//! GPU resources live in the renderer's `callback_resources` type map; the per-frame callback only
//! carries plain view parameters, so it is `Send + Sync` on every target (including wasm).
@@ -1594,7 +1595,8 @@ impl CanvasCallback {
let p = &self.params;
let (origin, scale) = self.placement(ppp);
let (mode, lod) = filter_mode(scale);
let grid = if p.pixel_grid && p.zoom >= 8.0 { 0.16 } else { 0.0 };
// The pixel grid shows above 500% and lightens a dark pixel by about a quarter.
let grid = if p.pixel_grid && p.zoom > 5.0 { 0.25 } else { 0.0 };
let square = if style.checker_square > 0.0 { (style.checker_square * ppp).round().max(1.0) } else { 0.0 };
let (l, d, g) = (style.checker_light, style.checker_dark, style.gamut_color);
// 32-bit preview: linear-light gain 2^exposure (0 = off) and 1 / gamma.
@@ -1615,8 +1617,8 @@ impl CanvasCallback {
lod,
grid,
square,
(self.rect.min.x * ppp).round(),
(self.rect.min.y * ppp).round(),
0.0,
0.0,
p.display as f32,
if out_linear { 1.0 } else { 0.0 },
l[0],
@@ -1720,7 +1722,7 @@ struct View {
a: vec4<f32>, // screen_w, screen_h, scale (device px per doc px), pixels_per_point
b: vec4<f32>, // doc origin x, y (device px), doc w, h (doc px)
c: vec4<f32>, // filter mode, lod, grid alpha, checker square (device px)
d: vec4<f32>, // checker anchor x, y (device px), display (0 none, 1 LUT, 2 LUT + gamut), output linear
d: vec4<f32>, // unused x, y, display (0 none, 1 LUT, 2 LUT + gamut), output linear
e: vec4<f32>, // checker light rgb, gamut warning opacity
f: vec4<f32>, // checker dark rgb, 32-bit preview gain (2^exposure; 0 = off)
g: vec4<f32>, // gamut warning rgb, 32-bit preview 1 / gamma
@@ -1806,11 +1808,13 @@ fn vs_tile(@builtin(vertex_index) vi: u32) -> VOut {
return VOut(px_to_clip(p));
}
// Cells are screen-sized (view.c.w device px) but anchored to the document's top-left corner, so
// the pattern moves with the image as it is panned or zoomed.
fn checker(p: vec2<f32>) -> vec3<f32> {
if (view.c.w <= 0.0) {
return vec3(1.0);
}
let c = floor((p - view.d.xy) / view.c.w);
let c = floor((p - view.b.xy) / view.c.w);
let odd = fract((c.x + c.y) * 0.5) > 0.25;
return select(view.e.xyz, view.f.xyz, odd);
}
@@ -1854,12 +1858,13 @@ fn fs_tile(in: VOut) -> @location(0) vec4<f32> {
col = vec4(shown * col.a, col.a);
}
var rgb = col.rgb + checker(p) * (1.0 - col.a);
let grid = view.c.z;
// The pixel grid is drawn over pixels with content, not over empty checker.
let grid = view.c.z * col.a;
if (grid > 0.0) {
let e = fract(d) * scale;
if (e.x < 1.0 || e.y < 1.0) {
let l = dot(rgb, vec3(0.299, 0.587, 0.114));
rgb = mix(rgb, select(vec3(1.0), vec3(0.0), l > 0.55), grid);
let on_light = dot(rgb, vec3(0.299, 0.587, 0.114)) > 0.55;
rgb = mix(rgb, select(vec3(1.0), vec3(0.0), on_light), select(grid, grid * 0.64, on_light));
}
}
if (view.d.w > 0.5) {
+47 -9
View File
@@ -172,10 +172,10 @@ impl LiquifyDialog {
})
}
fn template(&self) -> LiquifyStroke {
fn template(&self, tool: LiquifyTool) -> LiquifyStroke {
let o = &self.opts;
LiquifyStroke {
tool: o.tool,
tool,
size: f64::from(o.size),
density: f64::from(o.density),
pressure: f64::from(o.pressure),
@@ -193,9 +193,11 @@ impl LiquifyDialog {
self.mask_dirty |= mask;
}
fn begin(&mut self, p: [f64; 3], now: f64) {
/// Starts a stroke; `alt` draws with the selected tool's opposite (see [`alt_tool`]).
fn begin(&mut self, p: [f64; 3], now: f64, alt: bool) {
self.redo.clear();
let mut s = self.template();
let tool = if alt { alt_tool(self.opts.tool) } else { self.opts.tool };
let mut s = self.template(tool);
s.points.push(p.to_vec());
let t0 = crate::gpu_canvas::now_ms();
let d = self.field.stroke_begin(&s, p);
@@ -236,8 +238,7 @@ impl LiquifyDialog {
pts.push(first);
}
self.redo.clear();
let mut s = self.template();
s.tool = LiquifyTool::LassoMask;
let mut s = self.template(LiquifyTool::LassoMask);
s.amount = Some(if subtract { 0.0 } else { 1.0 });
s.points = pts.into_iter().map(|p| p.to_vec()).collect();
let d = self.field.apply_stroke(&s);
@@ -441,8 +442,21 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result<Value, String> {
Ok(d.describe())
}
/// Pointer in document coordinates (from the preview or the control channel). Alt = subtract
/// from the freeze mask (lasso only); Shift adds, same as no modifier.
/// Photoshop's Alt (Option) variant of a Liquify brush: the twirls reverse direction and Pucker
/// and Bloat swap. Other tools are unchanged.
fn alt_tool(t: LiquifyTool) -> LiquifyTool {
match t {
LiquifyTool::TwirlCw => LiquifyTool::TwirlCcw,
LiquifyTool::TwirlCcw => LiquifyTool::TwirlCw,
LiquifyTool::Pucker => LiquifyTool::Bloat,
LiquifyTool::Bloat => LiquifyTool::Pucker,
t => t,
}
}
/// Pointer in document coordinates (from the preview or the control channel). Alt at
/// pointer-down subtracts from the freeze mask (lasso), reverses a twirl and swaps Pucker/Bloat;
/// Shift adds to the mask, same as no modifier.
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) {
let now = crate::gpu_canvas::now_ms();
let Some(d) = app.distort.liquify.as_mut() else { return };
@@ -451,7 +465,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) {
if d.opts.tool == LiquifyTool::LassoMask {
d.lasso = Some((mods.alt, vec![[x, y]]));
} else {
d.begin([x, y, f64::from(pressure)], now);
d.begin([x, y, f64::from(pressure)], now, mods.alt);
}
}
ToolEvent::Move { x, y, pressure } => {
@@ -1053,6 +1067,30 @@ mod tests {
assert_eq!(app.distort.liquify.as_ref().unwrap().redo.len(), 0, "a new lasso clears redo");
}
#[test]
fn alt_reverses_twirl_and_swaps_pucker_and_bloat() {
let ctx = egui::Context::default();
let mut app = app_with_layer();
open(&mut app, &ctx).unwrap();
let dab = |app: &mut PhotocraftApp, m: egui::Modifiers| {
pointer(app, ToolEvent::Down { x: 50.0, y: 40.0, pressure: 1.0 }, m);
pointer(app, ToolEvent::Up { x: 50.0, y: 40.0 }, m);
app.distort.liquify.as_ref().unwrap().strokes.last().unwrap().tool
};
for (tool, plain, alt) in [
("twirlCw", LiquifyTool::TwirlCw, LiquifyTool::TwirlCcw),
("twirlCcw", LiquifyTool::TwirlCcw, LiquifyTool::TwirlCw),
("pucker", LiquifyTool::Pucker, LiquifyTool::Bloat),
("bloat", LiquifyTool::Bloat, LiquifyTool::Pucker),
("forwardWarp", LiquifyTool::ForwardWarp, LiquifyTool::ForwardWarp),
] {
control(&mut app, &json!({"tool": tool})).unwrap();
assert_eq!(dab(&mut app, egui::Modifiers::NONE), plain, "{tool}");
assert_eq!(dab(&mut app, egui::Modifiers::ALT), alt, "⌥ {tool}");
assert_eq!(app.distort.liquify.as_ref().unwrap().opts.tool, plain, "⌥ leaves the selected tool alone");
}
}
#[test]
fn liquify_redo_stack_is_bounded() {
let ctx = egui::Context::default();
+58
View File
@@ -291,6 +291,64 @@ fn mouse_drag_inside_the_selection_moves_it() {
assert_eq!(selection(&h), Rect::new(120, 90, 220, 170), "moved by (20, 10)");
}
/// #1428: hovering inside the ants with a marquee shows the move cursor (a press there drags the
/// outline); outside it is the marquee's own cursor.
#[test]
fn hovering_inside_the_selection_shows_the_move_cursor() {
for tool in [Tool::RectMarquee, Tool::EllipseMarquee] {
let mut h = harness(tool);
press_at(&mut h, 100.0, 80.0, Modifiers::NONE);
release_at(&mut h, 200.0, 160.0, Modifiers::NONE);
move_to(&mut h, 150.0, 120.0);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "{tool:?} inside");
move_to(&mut h, 300.0, 250.0);
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "{tool:?} outside");
}
}
/// #1428: with a marquee, the arrow keys nudge the selection outline 1 px, ⇧ 10 px (Photoshop);
/// each press is one undoable step.
#[test]
fn arrow_keys_nudge_the_selection_outline() {
use egui::Key;
for tool in [Tool::RectMarquee, Tool::EllipseMarquee] {
let mut h = harness(tool);
press_at(&mut h, 100.0, 80.0, Modifiers::NONE);
release_at(&mut h, 200.0, 160.0, Modifiers::NONE);
let drawn = selection(&h);
assert!(!drawn.is_empty(), "{tool:?} drew");
let shifted = |dx: i32, dy: i32| Rect::new(drawn.x0 + dx, drawn.y0 + dy, drawn.x1 + dx, drawn.y1 + dy);
let steps = h.state().session.active().unwrap().history.past_len();
h.key_press(Key::ArrowRight);
h.run_steps(1);
assert_eq!(selection(&h), shifted(1, 0), "{tool:?} right 1 px");
h.key_press(Key::ArrowUp);
h.run_steps(1);
assert_eq!(selection(&h), shifted(1, -1), "{tool:?} up 1 px");
h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowDown);
h.run_steps(1);
assert_eq!(selection(&h), shifted(1, 9), "{tool:?} shift-down 10 px");
h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft);
h.run_steps(1);
assert_eq!(selection(&h), shifted(-9, 9), "{tool:?} shift-left 10 px");
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 4, "one step per press");
h.state_mut().session.undo();
assert_eq!(selection(&h), shifted(1, 9), "{tool:?} undo takes back one nudge");
}
}
/// Arrow keys with a marquee but no selection change nothing and report no error.
#[test]
fn arrow_keys_without_a_selection_do_nothing() {
let mut h = harness(Tool::RectMarquee);
let steps = h.state().session.active().unwrap().history.past_len();
h.key_press(egui::Key::ArrowRight);
h.run_steps(1);
assert_eq!(selection(&h), Rect::EMPTY);
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps);
assert!(!h.state().ui.status_error);
}
/// ⌘⌥-drag copies the selected pixels instead of cutting them: the original stays.
#[test]
fn cmd_alt_drag_floats_a_copy() {
+84 -2
View File
@@ -170,12 +170,13 @@ pub fn abandon(app: &mut PhotocraftApp) {
/// ⌥ duplicates the layers first. Returns true when a key was used.
pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
if app.ui.tool != Tool::Move && app.ui.transform.is_none() {
return false;
return selection_arrow_keys(app, ctx);
}
use egui::{Key, Modifiers};
let keys = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
for (key, ux, uy) in keys {
for mods in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT] {
// Most specific first: `consume_key` ignores extra ⇧ / ⌥ (`matches_logically`).
for mods in [Modifiers::SHIFT | Modifiers::ALT, Modifiers::SHIFT, Modifiers::ALT, Modifiers::NONE] {
if ctx.input_mut(|i| i.consume_key(mods, key)) {
let k = if mods.shift { 10.0 } else { 1.0 };
nudge(app, ux * k, uy * k, mods.alt);
@@ -212,6 +213,38 @@ pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) {
}
}
/// Arrow keys with a selection tool (#1428), as in Photoshop: nudge the selection outline 1 px
/// (⇧ 10 px) through `select.transformSelection`, one history step per press; the pixels stay
/// put. A floating piece (⌘-dragged pixels) moves instead, like a plain drag on it. Not while a
/// drag, a polygon or a Magnetic Lasso border is in progress. Returns true when a key was used.
fn selection_arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
if !crate::tool_feedback::is_selection_tool(app.ui.tool) || app.drag.is_some() || !app.ui.polygon.is_empty() || app.ui.magnetic.active() {
return false;
}
let floating = app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some());
if !floating && !app.session.is_enabled("select.transformSelection") {
return false;
}
use egui::{Key, Modifiers};
let keys = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
for (key, ux, uy) in keys {
// ⇧ first: `consume_key` ignores an extra ⇧.
for mods in [Modifiers::SHIFT, Modifiers::NONE] {
if ctx.input_mut(|i| i.consume_key(mods, key)) {
let k = if mods.shift { 10.0 } else { 1.0 };
let (dx, dy) = (ux * k, uy * k);
let id = if floating { "select.float" } else { "select.transformSelection" };
if let Err(e) = app.run(id, json!({"dx": dx, "dy": dy})) {
app.ui.status = e;
app.ui.status_error = true;
}
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
@@ -354,4 +387,53 @@ mod tests {
nudge(&mut app, 0.0, 10.0, false);
assert_eq!(app.ui.transform.as_ref().unwrap().quad[0], [q0[0][0], q0[0][1] + 10.0]);
}
/// One key press through `arrow_keys`, as egui delivers it.
fn press(app: &mut PhotocraftApp, key: egui::Key, mods: egui::Modifiers) -> bool {
let ctx = egui::Context::default();
ctx.input_mut(|i| {
i.modifiers = mods;
i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: mods });
});
arrow_keys(app, &ctx)
}
/// Real key events: ⇧ nudges 10 px and ⌥ duplicates first (egui ignores extra ⇧ / ⌥ when
/// matching, so the plain arrow must not be tried first).
#[test]
fn arrow_key_modifiers_reach_the_move_tool() {
let mut app = app_with_layer();
let id = app.session.active().unwrap().active_layer.unwrap();
let n0 = app.session.active().unwrap().doc.layers.len();
assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE));
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(9, 8, 25, 24));
assert!(press(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT));
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(9, 18, 25, 34));
assert!(press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::ALT));
let st = app.session.active().unwrap();
assert_eq!(st.doc.layers.len(), n0 + 1, "⌥ duplicated first");
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(9, 18, 25, 34), "the original stays");
}
/// #1428: with a selection tool the arrows nudge the selection outline (⇧ 10 px), one step
/// each; the layer stays put. Without a selection they are left alone.
#[test]
fn arrow_keys_nudge_the_selection_with_a_selection_tool() {
let mut app = app_with_layer();
let id = app.session.active().unwrap().active_layer.unwrap();
app.ui.tool = Tool::Lasso;
assert!(!press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE), "no selection: not used");
app.session.execute("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 10})).unwrap();
let sel = |app: &PhotocraftApp| app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds();
let h0 = app.session.active().unwrap().history.past_len();
assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE));
assert!(press(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT));
assert_eq!(sel(&app), photocraft_geom::Rect::new(5, 14, 15, 24));
assert_eq!(app.session.active().unwrap().history.past_len(), h0 + 2);
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24), "pixels stay put");
// Not while a polygon is being drawn.
app.ui.tool = Tool::PolygonLasso;
app.ui.polygon = vec![[1.0, 1.0]];
assert!(!press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE));
}
}
+59 -5
View File
@@ -559,14 +559,18 @@ pub fn commit(app: &mut PhotocraftApp) {
}
/// A transform whose layer or document went away (undo, close) ends silently; picking another tool
/// applies it. Checked every frame and before each pointer event, so a press with a tool chosen
/// just before it (`ui.pointer`'s `tool`) goes to that tool.
/// or selecting another layer (Layers panel, ⌘-click, the control channel) applies it, so the box
/// never stays on a layer that is no longer the active one (#1400). Checked every frame and before
/// each pointer event, so a press with a tool chosen just before it (`ui.pointer`'s `tool`) goes to
/// that tool.
pub fn end_if_left(app: &mut PhotocraftApp) {
let Some(t) = &app.ui.transform else { return };
if app.session.active().is_none_or(|st| app.transform_preview.as_ref().is_some_and(|pv| pv.doc.id != st.doc.id) || st.doc.layer(LayerId(t.layer)).is_none())
{
let Some(st) = app.session.active() else { return cancel(app) };
if app.transform_preview.as_ref().is_some_and(|pv| pv.doc.id != st.doc.id) || st.doc.layer(LayerId(t.layer)).is_none() {
cancel(app);
} else if app.transform_preview.as_ref().is_some_and(|pv| pv.tool != app.ui.tool) {
} else if app.transform_preview.as_ref().is_some_and(|pv| pv.tool != app.ui.tool)
|| (!t.selection && st.active_layer.is_some_and(|active| active.0 != t.layer))
{
commit(app);
}
}
@@ -2075,6 +2079,56 @@ mod tests {
assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(), photocraft_geom::Rect::new(28, 8, 44, 24));
}
/// #1400: selecting another layer while transforming applies the box to the layer it was on,
/// and the next transform starts on the newly selected layer.
#[test]
fn selecting_another_layer_applies_the_transform_and_the_next_one_follows() {
let ctx = egui::Context::default();
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
let first = app.session.active().unwrap().active_layer.unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.session
.edit("paint", |doc, a| {
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(40, 40, 56, 60), &[0.0, 0.0, 1.0, 1.0]);
Ok(())
})
.unwrap();
let second = app.session.active().unwrap().active_layer.unwrap();
app.run("layer.select", json!({"layer": first.0})).unwrap();
app.ui.tool = Tool::Move;
crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap();
if let Some(t) = app.ui.transform.as_mut() {
t.quad = t.quad.map(|[x, y]| [x + 20.0, y]);
}
let steps = app.session.active().unwrap().history.past_len();
app.run("layer.select", json!({"layer": second.0})).unwrap();
end_if_left(&mut app);
assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "the box left the old layer");
let st = app.session.active().unwrap();
assert_eq!(st.active_layer, Some(second));
assert_eq!(st.history.past_len(), steps + 1, "applied as one step");
let bounds = |id: LayerId| st.doc.layer(id).unwrap().surface().unwrap().content_bounds();
assert_eq!(bounds(first), photocraft_geom::Rect::new(28, 8, 44, 24), "applied to the layer it was on");
assert_eq!(bounds(second), photocraft_geom::Rect::new(40, 40, 56, 60), "the new layer is untouched");
crate::menus::invoke(&mut app, &ctx, "edit.freeTransform", json!({})).unwrap();
let t = app.ui.transform.as_ref().unwrap();
assert_eq!((t.layer, t.rect), (second.0, [40.0, 40.0, 56.0, 60.0]), "the next box is on the selected layer");
}
/// Transform Selection moves the selection outline, not a layer: selecting a layer keeps it open.
#[test]
fn selecting_another_layer_keeps_a_transform_selection_open() {
let ctx = egui::Context::default();
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
let first = app.session.active().unwrap().active_layer.unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("select.all", json!({})).unwrap();
begin_selection(&mut app, &ctx).unwrap();
app.run("layer.select", json!({"layer": first.0})).unwrap();
end_if_left(&mut app);
assert!(app.ui.transform.as_ref().is_some_and(|t| t.selection));
}
/// A control-channel stroke that picks another tool applies the box first, then paints.
#[test]
fn a_pointer_request_with_another_tool_applies_the_transform_first() {
@@ -0,0 +1,163 @@
//! #1436: a size-changing Arbitrary rotation preview must remain visible on the real GPU canvas.
//! Synthetic blue pixels are measured outside the dialog, so its controls cannot pass the test.
use egui_kittest::kittest::Queryable;
use photocraft_ui_egui::PhotocraftApp;
use photocraft_ui_egui::control::{ControlRequest, Outcome, handle};
use serde_json::{Value, json};
use std::sync::Arc;
type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
fn harness() -> Option<Harness> {
let built = std::panic::catch_unwind(|| {
egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
app.set_wgpu(rs.clone());
}
app
})
});
match built {
Ok(h) => Some(h),
Err(_) => {
eprintln!("skipping: no GPU adapter");
None
}
}
}
fn control(h: &mut Harness, method: &str, params: Value) -> Value {
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new(method, params);
let Outcome::Done(value) = handle(h.state_mut(), &ctx, &req) else { panic!("{method} did not finish") };
assert_eq!(value["ok"], true, "{method}: {value}");
h.run_steps(4);
value["result"].clone()
}
fn setup(h: &mut Harness, width: u32, height: u32, zoom: f32, center: [f32; 2]) {
while h.state().session.active().is_some() {
control(h, "engine.execute", json!({"command":"file.close","params":{"discard":true}}));
}
control(h, "engine.execute", json!({"command":"prefs.set","params":{"path":"interface.language","value":"en"}}));
control(h, "engine.execute", json!({"command":"file.new","params":{"width":width,"height":height,"background":"white"}}));
control(h, "engine.execute", json!({"command":"edit.fill","params":{"color":"#2266dd"}}));
control(h, "ui.set", json!({"zoom":zoom,"center":center}));
assert!(h.state().perf.gpu, "the fixture must use the GPU canvas");
}
fn open(h: &mut Harness) -> u64 {
control(h, "ui.menu.invoke", json!({"id":"image.rotation.arbitrary"}))["dialog"].as_u64().expect("rotation dialog")
}
fn angle(h: &mut Harness, id: u64, value: f64) {
control(h, "ui.dialog.set", json!({"dialog":id,"field":"angle","value":value}));
}
/// Count blue canvas pixels and their vertical extent, excluding every modal's actual rectangle.
fn blue(h: &mut Harness, name: &str) -> (usize, u32, u32) {
h.run_steps(3);
let rect = h.state().last_canvas_rect.shrink(3.0);
let modals: Vec<_> =
h.state().ui.dialogs.iter().map(|d| h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", d.id)))).expect("laid-out dialog").expand(4.0)).collect();
let img = h.render().expect("render");
if let Some(dir) = std::env::var_os("PHOTOCRAFT_ROTATION_EVIDENCE_DIR") {
std::fs::create_dir_all(&dir).expect("evidence directory");
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).expect("save UI frame");
}
let (mut count, mut top, mut bottom) = (0, u32::MAX, 0);
for y in rect.top().max(0.0) as u32..(rect.bottom() as u32).min(img.height()) {
for x in rect.left().max(0.0) as u32..(rect.right() as u32).min(img.width()) {
if modals.iter().any(|r| r.contains(egui::pos2(x as f32, y as f32))) {
continue;
}
let p = img.get_pixel(x, y).0;
if p[0] < 80 && (60..160).contains(&p[1]) && p[2] > 170 {
count += 1;
top = top.min(y);
bottom = bottom.max(y);
}
}
}
(count, top, bottom)
}
#[test]
fn size_changing_rotation_preview_is_visible_and_preserves_the_panned_center() {
let Some(mut h) = harness() else { return };
h.run_steps(4);
for (width, height, zoom, center, degrees, factor) in [
(400, 240, 1.0, [160.0, 90.0], 45.0, 1),
(400, 240, 1.0, [160.0, 90.0], -45.0, 1),
(400, 240, 1.0, [160.0, 90.0], 90.0, 1),
(3000, 1800, 0.12, [1350.0, 825.0], 45.0, 2),
] {
setup(&mut h, width, height, zoom, center);
assert_eq!(photocraft_ui_egui::proxy::factor(&h.state().session.active().unwrap().doc), factor);
let id = open(&mut h);
let source = blue(&mut h, &format!("{width}x{height}-{degrees}-zero")).0;
assert!(source > 5_000, "the unrotated blue image must be visible outside the dialog");
angle(&mut h, id, degrees);
h.get_by_label("Preview").click();
h.run_steps(4);
assert_eq!(h.state().ui.dialogs[0].fields["__preview"], false);
assert!(blue(&mut h, &format!("{width}x{height}-{degrees}-off")).0 > 5_000, "Preview off shows the original image");
h.get_by_label("Preview").click();
h.run_steps(4);
assert_eq!(h.state().ui.dialogs[0].fields["__preview"], true);
let (count, top, bottom) = blue(&mut h, &format!("{width}x{height}-{degrees}-on"));
assert!(count > 5_000, "{width}x{height}, {degrees}°, factor {factor}: rotated blue pixels disappeared ({count} remain)");
let rect = photocraft_ui_egui::rulers::content_rect(h.state(), h.state().last_canvas_rect);
let expected_y = rect.center().y + (height as f32 / 2.0 - center[1]) * zoom;
let actual_y = (top + bottom) as f32 / 2.0;
assert!((actual_y - expected_y).abs() < 3.0, "{degrees}°: preview moved the panned image center from {expected_y} to {actual_y}");
h.get_by_label("Cancel").click();
h.run_steps(4);
}
}
#[test]
fn preview_toggle_and_cancel_keep_the_document_while_ok_applies_rotation() {
let Some(mut h) = harness() else { return };
h.run_steps(4);
setup(&mut h, 400, 240, 1.0, [200.0, 120.0]);
let st = h.state().session.active().unwrap();
let (doc, revision, history) = (st.doc.clone(), st.revision, st.history.entries());
let id = open(&mut h);
assert!(blue(&mut h, "control-zero-on").0 > 5_000);
h.get_by_label("Preview").click();
h.run_steps(4);
assert_eq!(h.state().ui.dialogs[0].fields["__preview"], false);
assert!(blue(&mut h, "control-zero-off").0 > 5_000);
h.get_by_label("Preview").click();
h.run_steps(4);
assert_eq!(h.state().ui.dialogs[0].fields["__preview"], true);
assert!(blue(&mut h, "control-zero-restored").0 > 5_000);
h.get_by_label("Preview").click();
h.run_steps(4);
angle(&mut h, id, 45.0);
h.get_by_label("Cancel").click();
h.run_steps(4);
let st = h.state().session.active().unwrap();
assert!(Arc::ptr_eq(&st.doc, &doc), "Cancel must keep the committed document");
assert_eq!(st.revision, revision);
assert_eq!(st.history.entries(), history);
assert!(blue(&mut h, "control-cancel").0 > 5_000);
let id = open(&mut h);
h.get_by_label("Preview").click();
h.run_steps(4);
angle(&mut h, id, 45.0);
h.get_by_label("OK").click();
h.run_steps(4);
assert!(h.state().ui.dialogs.is_empty());
let st = h.state().session.active().unwrap();
assert_eq!((st.doc.size.width, st.doc.size.height), (453, 453));
assert_eq!(st.history.entries().len(), history.len() + 1);
assert!(st.revision > revision);
assert_eq!(h.state().ui.views[0].center, [226.5, 226.5]);
assert!(blue(&mut h, "control-ok").0 > 5_000, "the applied rotation must be visible");
}
+193
View File
@@ -0,0 +1,193 @@
//! The transparency checker is made of 8 screen pixel cells (the Medium preference) at every zoom,
//! anchored to the document's top-left corner so the pattern moves with the image. The pixel grid shows above 500% zoom,
//! over pixels with content only, never over empty checker.
//!
//! These tests render the real app offscreen and read pixels back. They skip when no GPU adapter
//! is available.
use photocraft_ui_egui::PhotocraftApp;
use photocraft_ui_egui::control::{ControlRequest, handle};
use serde_json::{Value, json};
type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
const LIGHT: u8 = 255;
const DARK: u8 = 204;
fn harness() -> Option<Harness> {
let built = std::panic::catch_unwind(|| {
egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 800.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
app.set_wgpu(rs.clone());
}
app
})
});
match built {
Ok(h) => Some(h),
Err(_) => {
eprintln!("skipping: no GPU adapter");
None
}
}
}
fn control(h: &mut Harness, method: &str, params: Value) {
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new(method, params);
handle(h.state_mut(), &ctx, &req);
h.run_steps(4);
}
/// True for the neutral light greys the default checker (and its pixel-grid darkening) paints.
fn is_checker_grey(p: [u8; 4]) -> bool {
p[0] == p[1] && p[1] == p[2] && p[0] >= 160
}
/// Longest run of `true` in `flags`, as `(start, len)`.
fn longest_run(flags: impl Iterator<Item = bool>) -> (usize, usize) {
let (mut best, mut start, mut len) = ((0, 0), 0, 0);
for (i, f) in flags.enumerate() {
if f {
if len == 0 {
start = i;
}
len += 1;
if len > best.1 {
best = (start, len);
}
} else {
len = 0;
}
}
best
}
/// A rendered screen.
struct Shot {
w: u32,
px: Vec<u8>,
}
impl Shot {
fn at(&self, x: u32, y: u32) -> [u8; 4] {
let i = ((y * self.w + x) * 4) as usize;
[self.px[i], self.px[i + 1], self.px[i + 2], self.px[i + 3]]
}
}
/// Opens a `doc` px square document at `zoom` (transparent, or filled black when `black`), renders
/// it with the pixel grid on or off, and returns the screenshot with the document's left and top
/// screen edge and its width in screen pixels.
fn render(h: &mut Harness, zoom: f32, doc: u32, black: bool, grid: bool) -> (Shot, u32, u32, u32) {
{
let app = h.state_mut();
while app.session.active().is_some() {
app.run("file.close", json!({"discard": true})).expect("close");
}
let background = if black { "white" } else { "transparent" };
app.run("file.new", json!({"width": doc, "height": doc, "background": background})).expect("new");
if black {
app.run("edit.fill", json!({"color": "#000000"})).expect("fill");
}
app.sync_views();
app.ui.view.show.pixel_grid = grid;
}
control(h, "ui.set", json!({"zoom": zoom, "center": [doc as f32 / 2.0, doc as f32 / 2.0]}));
h.run_steps(3);
assert!(h.state().perf.gpu, "zoom {zoom}: expected the GPU canvas");
let rendered = h.render().expect("render");
let (width, height) = (rendered.width(), rendered.height());
let img = Shot { w: width, px: rendered.into_raw() };
let r = h.state().last_canvas_rect;
let (cx, cy) = (r.center().x as u32, r.center().y as u32);
// Black fill is 0 (64 on a grid line), the pasteboard is 39.
let on_doc = |p: [u8; 4]| if black { p[0] < 12 || (58..=70).contains(&p[0]) } else { is_checker_grey(p) };
let (x0, w) = longest_run((0..width).map(|x| on_doc(img.at(x, cy))));
let (y0, hgt) = longest_run((0..height).map(|y| on_doc(img.at(cx, y))));
let doc_px = (doc as f32 * zoom).round() as i64;
assert!((w as i64 - doc_px).abs() <= 1 && (hgt as i64 - doc_px).abs() <= 1, "zoom {zoom}: found a {w}x{hgt} document, want {doc_px}");
(img, x0 as u32, y0 as u32, w as u32)
}
#[test]
fn checker_cells_are_8_screen_pixels_at_every_zoom() {
let Some(mut h) = harness() else { return };
h.run_steps(4);
// The 8 px cell never changes with the zoom.
for (zoom, doc) in [(0.5, 64), (1.0, 64), (1.1, 64), (2.0, 32), (3.3, 32), (5.5, 32), (8.0, 32), (13.0, 32), (32.0, 12)] {
let (img, x0, y0, _) = render(&mut h, zoom, doc, false, false);
let n = ((doc as f32 * zoom) / 8.0) as u32;
assert!(n >= 2, "zoom {zoom}: test needs at least two cells");
for row in 0..n.min(4) {
for col in 0..n.min(6) {
let (sx, sy) = (x0 + col * 8 + 4, y0 + row * 8 + 4);
let got = img.at(sx, sy);
let want = if (col + row) % 2 == 0 { LIGHT } else { DARK };
assert!(
got[0] == want && got[1] == want && got[2] == want,
"zoom {zoom}, cell ({col}, {row}) centre ({sx}, {sy}): got {got:?}, want grey {want}"
);
}
}
// Crisp: every pixel of the row is exactly one of the two greys, no blended edges.
let y = y0 + 4;
for x in x0..x0 + (n * 8).min(48) {
let v = img.at(x, y)[0];
assert!(v == LIGHT || v == DARK, "zoom {zoom}: blended pixel {v} at ({x}, {y})");
}
}
}
#[test]
fn checker_phase_moves_with_the_image() {
let Some(mut h) = harness() else { return };
h.run_steps(4);
// The document origin moves four screen pixels between these two views, and the checker
// phase moves with it: the same screen position flips from a light to a dark cell.
let mut sample_at = None;
for (zoom, expected) in [(1.0, LIGHT), (1.25, DARK)] {
let (img, x0, y0, _) = render(&mut h, zoom, 32, false, false);
let (sx, sy) = *sample_at.get_or_insert((x0 + 6, y0 + 2));
let px = img.at(sx, sy);
assert!(px[0] == expected, "zoom {zoom}: phase at fixed screen position ({sx}, {sy}) is {px:?}, want {expected}");
}
}
/// Screen columns inside the document (from `x0`) whose pixel is lighter than the black fill.
fn lit_columns(img: &Shot, x0: u32, y: u32, len: u32) -> Vec<u32> {
(0..len).filter(|i| img.at(x0 + i, y)[0] > 25).collect()
}
#[test]
fn pixel_grid_shows_above_500_percent_and_lightens_a_dark_pixel_by_about_a_quarter() {
let Some(mut h) = harness() else { return };
h.run_steps(4);
let (flat, x0, y0, _) = render(&mut h, 5.0, 48, true, true);
assert!(lit_columns(&flat, x0, y0 + 20, 100).is_empty(), "no grid at 500%");
for zoom in [6.0f32, 8.0, 12.0] {
let (img, x0, y0, _) = render(&mut h, zoom, 48, true, true);
let lit = lit_columns(&img, x0, y0 + (zoom * 3.5) as u32, (zoom * 10.0) as u32);
assert_eq!(lit.len(), 10, "zoom {zoom}: grid columns {lit:?}");
assert!(lit.windows(2).all(|p| p[1] - p[0] == zoom as u32), "zoom {zoom}: spacing of {lit:?}");
let v = img.at(x0 + lit[2], y0 + (zoom * 3.5) as u32)[0];
assert!((60..=68).contains(&v), "zoom {zoom}: a grid line over black is {v}, want about 64 (a quarter of the way to white)");
}
}
#[test]
fn pixel_grid_is_not_drawn_over_empty_checker() {
let Some(mut h) = harness() else { return };
h.run_steps(4);
for zoom in [8.0f32, 16.0] {
let (img, x0, y0, _) = render(&mut h, zoom, 24, false, true);
for y in y0..y0 + 64 {
for x in x0..x0 + 64 {
let v = img.at(x, y)[0];
assert!(v == LIGHT || v == DARK, "zoom {zoom}: grid or blend {v} over empty checker at ({x}, {y})");
}
}
}
}
+13
View File
@@ -82,6 +82,19 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
| Platforms | macOS (notarized), Windows, Linux (AppImage/deb/rpm/Flatpak bundle), web | medium-high | Flathub later (#173); Windows signing material pending. |
| Localisation | 2026-10-07: 10 UI languages; menu, `tl!`, blend mode, preference and brush-section coverage enforced by tests; live switching and scoped Preferences previews | medium | Engine errors/status messages still partly English; CJK web fonts, browser-locale detection, and RTL remain open. |
2026-10-08: Motion Blur adds adaptive FFT convolution for wide streaks while retaining the
row kernel merged in [#902](https://github.com/storytold/photocraft/pull/902) for shorter
streaks and unsupported cases. Native release medians of three paired runs on an Intel
i7-9750H with 12 Rayon workers, synthetic RGBA8 at 30° / distance 1000: 24 MP
22.594 → 9.785 s (2.31×), and 1.5 MP
2.455 → 0.592 s (4.15×).
Full-image comparisons differ by at most one U8 code level; distance 64/256 retain the
byte-identical row output. These measure decode/halo/filter/write/prune, excluding UI proxy
creation, compositing and upload; the UI remains synchronous. FFT work is scheduled within
a conservative 512 MiB working-set estimate, excluding stored Surface tiles and row fallback
memory. The crossover is a heuristic; images dominated by near-cutoff alpha can need extra
scalar work. These measurements do not establish Photoshop filter parity.
2026-10-08: the tools update above is checked against the current toolbar groups in
[`panels.rs`](../crates/ui-egui/src/panels.rs), the Pencil and tool-cycle tests in
[`pencil_tests.rs`](../crates/ui-egui/src/pencil_tests.rs), Patch's live-preview test in