mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-21 22:54:11 -04:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8032bac09a | ||
|
|
01827acd0e | ||
|
|
d8eddfc4db | ||
|
|
1d4b4df84a | ||
|
|
71ae0c43e9 | ||
|
|
c94c4e24f7 | ||
|
|
3706915515 | ||
|
|
705963c8eb | ||
|
|
993e46f26f | ||
|
|
d9728f1aff | ||
|
|
1f34dad82f | ||
|
|
935ac5b44c | ||
|
|
893d5cd575 | ||
|
|
16accc1553 | ||
|
|
a25ddbb5c8 | ||
|
|
436af1aace | ||
|
|
546a92a3e2 | ||
|
|
4146dcf08f | ||
|
|
38076a3750 | ||
|
|
1de3970681 | ||
|
|
d6a8a5a680 | ||
|
|
1de2726b51 | ||
|
|
8338e91043 | ||
|
|
714414e3a8 | ||
|
|
b36f07b999 | ||
|
|
e3af1b82a8 | ||
|
|
000ef5a842 | ||
|
|
7102fc37c7 | ||
|
|
4654d4338c | ||
|
|
a5c604d6cb | ||
|
|
e9ffa50cc2 | ||
|
|
b20aaab920 | ||
|
|
1de4f2d5ce | ||
|
|
60a89db6f8 |
No files matched your search
Generated
+40
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¶ms, 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, ¶ms, bounds, bounds, None, Some(bounds), 64, &ctl), Some(None)));
|
||||
assert_eq!(surface.to_interleaved(bounds), before);
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms, area);
|
||||
(result, start.elapsed().as_secs_f64() * 1000.0)
|
||||
};
|
||||
let after = || {
|
||||
let start = Instant::now();
|
||||
let (result, used_fft) = candidate(surface, ¶ms, 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ¶ms, 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, ¶ms, 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, ¶ms, rect, rect, None, None, 64, &Interrupt::NONE).is_none(),
|
||||
"unsupported FFT input case={case}"
|
||||
);
|
||||
let actual = crate::apply_tiled(&source, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, area, extent, None, 64, Some(extent));
|
||||
assert_eq!(actual.read_region(area), expected);
|
||||
}
|
||||
@@ -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, ¶ms, rect, rect, mask, extent);
|
||||
for tile in [5, 17, 256] {
|
||||
let actual = crate::apply_tiled(&source, ¶ms, 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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] {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user