Merge current main, retain full-sweep and new benchmark inputs

This commit is contained in:
FredApps
2026-10-30 03:05:00 +01:00
189 changed files with 29550 additions and 1807 deletions
+177 -1
View File
@@ -15,6 +15,22 @@ on:
description: "Run every testkit sweep case (runtime comparison and integration validation)"
type: boolean
default: false
color_range:
description: "Compare cropped Color Range on 24 MP (Linux, exact outputs)"
type: boolean
default: false
selection_combining:
description: "Compare direct GRAY8 selection combining on 36 MP (Linux, exact outputs)"
type: boolean
default: false
brush_tip:
description: "Compare cached round-tip rasterization on 24 MP"
type: boolean
default: false
brush_tip_base:
description: "Baseline commit for the round-tip comparison"
type: string
default: "main"
selection_morphology:
description: "Compare selection morphology on 36 MP (Linux, exact outputs)"
type: boolean
@@ -26,6 +42,10 @@ on:
fuzz_seconds:
description: "Seconds per fuzz target"
default: "600"
dry_mixer:
description: "Compare the live Mixer Brush against the origin/main merge base on 24 MP (Linux)"
type: boolean
default: false
# The token is only used to check out the code: no job writes to the repository.
permissions:
@@ -430,6 +450,36 @@ jobs:
name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }}
path: crates/${{ matrix.target.crate }}/fuzz/artifacts
color-range-performance:
if: inputs.color_range == true
runs-on: ubuntu-latest
timeout-minutes: 45
env:
RAYON_NUM_THREADS: "4"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install Linux build dependencies
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: color-range-perf
- name: Compare exact outputs and release timings on 24 MP
run: |
set -o pipefail
cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture | tee color-range-perf.txt
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: color-range-performance
path: color-range-perf.txt
selection-morphology:
if: inputs.selection_morphology == true
runs-on: ubuntu-latest
@@ -465,7 +515,8 @@ jobs:
path: selection-morphology-perf.txt
selection-morphology-quick-perf:
if: inputs.selection_morphology == true
if: inputs.selection_morphology == true || inputs.color_range == true || inputs.selection_combining == true || inputs.brush_tip == true || inputs.dry_mixer == true
name: quick performance suite
# Match perf-nightly.yml: Linux llvmpipe lacks the float render targets needed by
# bevel_bench, so its required GPU rows cannot be measured there.
runs-on: macos-15
@@ -487,3 +538,128 @@ jobs:
path: |
target/perf/*.json
target/perf/summary.md
selection-combining:
if: inputs.selection_combining == true
runs-on: ubuntu-latest
timeout-minutes: 30
env:
RAYON_NUM_THREADS: "4"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: selection-combining-perf
- name: Compare exact outputs and release timings on 36 MP
run: |
set -o pipefail
cargo test --release -p photocraft-algo --lib gray8_combining_release_comparison -- --ignored --nocapture | tee selection-combining-perf.txt
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: selection-combining-performance
path: selection-combining-perf.txt
brush-tip:
if: inputs.brush_tip == true
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
RAYON_NUM_THREADS: "4"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
path: after
- uses: actions/checkout@v7
with:
persist-credentials: false
ref: ${{ inputs.brush_tip_base }}
path: before
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: |
before -> target
after -> target
key: brush-tip-release
- name: Build the same benchmark against both versions
run: |
cp after/crates/paint/examples/big_dab_bench.rs before/crates/paint/examples/big_dab_bench.rs
cargo build --release --locked --manifest-path before/Cargo.toml -p photocraft-paint --example big_dab_bench
cargo build --release --locked --manifest-path after/Cargo.toml -p photocraft-paint --example big_dab_bench
- name: Compare round and transformed tips on 24 MP
run: |
set -o pipefail
for size in 200 2000; do
for angle in 0 0.7; do
for version in before after; do
echo "$version: size=$size angle=$angle" | tee -a brush-tip.txt
"$version/target/release/examples/big_dab_bench" "$size" 0.37 "$angle" | tee -a brush-tip.txt
done
done
done
- uses: actions/upload-artifact@v7
if: always()
with:
name: brush-tip-performance
path: brush-tip.txt
dry-mixer:
if: inputs.dry_mixer == true
runs-on: ubuntu-latest
timeout-minutes: 90
env:
RAYON_NUM_THREADS: "4"
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Pin baseline to the merge base with origin/main
id: baseline
run: |
git remote add upstream https://github.com/storytold/photocraft.git
git fetch --no-tags upstream main
base=$(git merge-base HEAD upstream/main)
echo "sha=$base" >> "$GITHUB_OUTPUT"
git worktree add --detach "$RUNNER_TEMP/photocraft-baseline" "$base"
mkdir -p "$RUNNER_TEMP/photocraft-baseline/crates/engine/examples"
cp crates/engine/examples/bench_dry_mixer.rs "$RUNNER_TEMP/photocraft-baseline/crates/engine/examples/bench_dry_mixer.rs"
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- name: Install Linux build dependencies
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
- name: Benchmark baseline and candidate on this runner
run: |
set -o pipefail
echo "baseline=${{ steps.baseline.outputs.sha }}" | tee dry-mixer-perf.txt
echo "candidate=${GITHUB_SHA}" | tee -a dry-mixer-perf.txt
CARGO_TARGET_DIR="$RUNNER_TEMP/target-baseline" cargo run --release --manifest-path "$RUNNER_TEMP/photocraft-baseline/Cargo.toml" -p photocraft-engine --example bench_dry_mixer | tee baseline-mixer.txt
CARGO_TARGET_DIR="$RUNNER_TEMP/target-candidate" cargo run --release -p photocraft-engine --example bench_dry_mixer | tee candidate-mixer.txt
cat baseline-mixer.txt candidate-mixer.txt >> dry-mixer-perf.txt
python3 - <<'PY'
import re
def fingerprints(path):
rows = open(path).readlines()
result = {}
for row in rows:
match = re.search(r'wet=\s*(\d+)% sample_all=\s*(\w+)\s+size=\s*(\d+).*blake3=(\w+)', row)
if match:
result[match.group(1, 2, 3)] = match.group(4)
return result
before, after = fingerprints('baseline-mixer.txt'), fingerprints('candidate-mixer.txt')
assert len(before) == 12 and before == after, 'baseline/candidate mixer output or state differs'
PY
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: dry-mixer-performance
path: dry-mixer-perf.txt
+4
View File
@@ -27,6 +27,10 @@ next to them.
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sv.tsv` | Swedish UI translations | @kler, @davvarn and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish UI translations | @Janiz-zzz, @metuuu and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/da.tsv` | Danish UI translations | @pechnikov2026-star and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål UI translations | @nikolainyegaard, @Bolla76 and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
Generated
+2
View File
@@ -5023,6 +5023,7 @@ dependencies = [
name = "photocraft-algo"
version = "0.17.0"
dependencies = [
"criterion",
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
@@ -5295,6 +5296,7 @@ dependencies = [
"photocraft-geom",
"photocraft-raster",
"rayon",
"rustc-hash 2.1.3",
"serde",
"serde_json",
]
+1
View File
@@ -67,6 +67,7 @@ log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
rustc-hash = "2.1.3"
criterion = { version = "0.8", default-features = false }
getrandom = "0.3"
# Monotonic clock on native and wasm (screenshot deadlines: a wall clock can jump).
+4 -1
View File
@@ -83,7 +83,10 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
h.session.actions.list = set.clone();
h.open(input)?;
for (id, p) in &actions {
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
if photocraft_engine::actions_cmds::shell_view_command(id)
|| photocraft_engine::actions_cmds::shell_save_command(id)
|| photocraft_engine::actions_cmds::noop_step(&h.session, id, p)
{
continue;
}
let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?;
+5
View File
@@ -93,6 +93,11 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) ->
}
let cmds = command_list(a)?;
for (id, params) in cmds {
// A step that is a silent no-op in action playback (Deselect with nothing selected, an Arrange that cannot move the layer) is one in a script too (#8802).
if photocraft_engine::actions_cmds::noop_step(&h.session, &id, &params) {
print_json(out, &json!({"command": id, "result": null}), JsonStyle::Compact)?;
continue;
}
let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?;
if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(CliError::NestedFailure { id, message });
+75
View File
@@ -1421,3 +1421,78 @@ fn control_token_env_vars_are_the_flags_fallbacks() {
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", ""));
assert!(err.contains("photocraft-cli serving on") && err.contains("photocraft-cli control token: "), "{err}");
}
/// #8802: Deselect with nothing selected is skipped in a `run`, as action playback does (#6744),
/// so a script that deselects after Reveal Selection still finishes and saves.
#[test]
fn run_skips_deselect_with_nothing_selected() {
let folder = tmp("deselect-run");
let native = folder.join("out.pcraft").to_string_lossy().to_string();
let Invoke { code, stdout, stderr } = invoke(&[
"run",
"--new",
r#"{"width":16,"height":16}"#,
"--cmd",
"select.rect",
"--params",
r#"{"x":0,"y":0,"width":8,"height":16}"#,
"--cmd",
"layer.new.layer",
"--cmd",
"layer.layerMask.revealSelection",
"--cmd",
"select.deselect",
"--cmd",
"layer.new.layer",
"--params",
r#"{"name":"after"}"#,
"--out",
&native,
]);
assert_eq!(code, 0, "{stderr}");
let results: Vec<Value> = stdout.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(results.len(), 5, "every command reports a result: {stdout}");
assert_eq!(results[3], json!({"command": "select.deselect", "result": null}));
let Invoke { code, stdout, stderr } = invoke(&["info", &native, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(info["layers"][0]["name"], "after", "the steps after Deselect ran: {info}");
// Deselect with a selection still deselects: Inverse afterwards finds nothing to invert.
let Invoke { code, stderr, .. } = invoke(&[
"run",
"--new",
r#"{"width":16,"height":16}"#,
"--cmd",
"select.all",
"--cmd",
"select.deselect",
"--cmd",
"select.inverse",
"--out",
&native,
]);
assert_eq!(code, 1, "{stderr}");
assert!(stderr.contains("select.inverse") && stderr.contains("no selection"), "{stderr}");
let _ = std::fs::remove_dir_all(&folder);
}
/// #8802: a batched action that starts with Deselect plays on files with nothing selected, as the
/// same action does in the Actions panel (#6744).
#[test]
fn batch_skips_deselect_with_nothing_selected() {
let dir = tmp("deselect-batch");
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
assert_eq!(code, 0, "{stderr}");
std::fs::write(dir.join("steps.json"), json!([["select.deselect", {}], ["image.imageRotation.90cw", {}]]).to_string()).unwrap();
let Invoke { code, stdout, stderr } = invoke(&["batch", "--actions", &path("steps.json"), "--in", &path("in"), "--out", &path("out")]);
assert_eq!(code, 0, "{stdout}{stderr}");
assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}");
let Invoke { code, stdout, stderr } = invoke(&["info", &path("out/a.png"), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "the step after Deselect ran: {info}");
let _ = std::fs::remove_dir_all(&dir);
}
+5
View File
@@ -23,3 +23,8 @@ rayon = { workspace = true }
[dev-dependencies]
photocraft-testkit = { workspace = true }
serde_json = { workspace = true }
criterion = { workspace = true }
[[bench]]
name = "blur"
harness = false
+33
View File
@@ -0,0 +1,33 @@
//! `cargo bench -p photocraft-algo`
use criterion::{Criterion, criterion_group, criterion_main};
use photocraft_algo::{FilterParams, apply};
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
fn textured(n: u32) -> Surface {
let r = Rect::new(0, 0, n as i32, n as i32);
let mut s = Surface::new(PixelFormat::RGBA8);
let bytes: Vec<u8> = (0..n).flat_map(|y| (0..n).flat_map(move |x| [(x ^ y) as u8, (x / 3) as u8, (y * 7) as u8, 255])).collect();
s.write_interleaved(r, &bytes);
s
}
/// Gaussian Blur's direct-kernel path (radius up to 4; larger radii use box passes), including
/// the variance-matched kernels below radius 1 (#8544).
fn gaussian_direct(c: &mut Criterion) {
let n = 2048;
let s = textured(n);
let area = Rect::new(0, 0, n as i32, n as i32);
for radius in [0.3f32, 0.8, 3.0] {
let p = FilterParams::GaussianBlur { radius };
c.bench_function(&format!("gaussian_blur_{radius}px_2048"), |b| b.iter(|| apply(&s, &p, area, area, None)));
}
}
criterion_group! {
name = benches;
config = Criterion::default().sample_size(10);
targets = gaussian_direct
}
criterion_main!(benches);
+18
View File
@@ -122,6 +122,24 @@ fn main() {
};
wideangle::apply(&s, r, &p, Interp::Bicubic)
});
// The mesh solve alone, on the dialog's 800 x 533 proxy.
let proxy = Rect::new(0, 0, 800, 533);
let solve_case = |constraints: Vec<Constraint>| {
let p = WideAngle { model: WideModel::Fisheye, focal_length: 12.0, constraints, ..Default::default() };
wideangle::solve(&p, proxy)
};
time("wide angle solve: 800x533 proxy, H + V", &only, || {
solve_case(vec![
Constraint { a: [60.0, 80.0], b: [740.0, 80.0], orientation: Orientation::Horizontal },
Constraint { a: [100.0, 60.0], b: [100.0, 470.0], orientation: Orientation::Vertical },
])
});
time("wide angle solve: 800x533 proxy, 2 free", &only, || {
solve_case(vec![
Constraint { a: [60.0, 80.0], b: [740.0, 95.0], orientation: Orientation::Free },
Constraint { a: [100.0, 60.0], b: [110.0, 470.0], orientation: Orientation::Free },
])
});
time("hdr: merge 3 exposures + local adaptation", &only, || {
let shots: Vec<Vec<[f32; 4]>> =
[0.5f32, 1.0, 2.0].iter().map(|k| px.iter().map(|q| [(q[0] * k).min(1.0), (q[1] * k).min(1.0), (q[2] * k).min(1.0), 1.0]).collect()).collect();
+38 -1
View File
@@ -31,6 +31,43 @@ pub(crate) fn gaussian_kernel(sigma: f32) -> Vec<f32> {
k.into_iter().map(|v| v / s).collect()
}
/// Gaussian Blur's kernel for `radius` (its standard deviation). Sampling the curve at whole
/// pixels leaves a small radius almost no blur: at 0.3 the neighbour weight is about 0.004, a
/// tenth of the spread asked for (#8544). Below 1 the sampling width is instead chosen so the
/// kernel has exactly the variance `radius²`; from 1 up the sampled kernel is within about 0.4%
/// of it (its 3σ truncation), so larger radii are unchanged and the blur grows smoothly from 0.05.
pub(crate) fn gaussian_blur_kernel(radius: f32) -> Vec<f32> {
if !(0.05..1.0).contains(&radius) {
return gaussian_kernel(radius);
}
let variance = |k: &[f32]| {
let r = (k.len() / 2) as f32;
k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::<f32>()
};
// Sampled at `width` with one fixed half-width for every radius below 1: its variance then
// rises steadily with `width` (the search can't settle on a step where taps are added), and
// the blur grows without the dips a width chosen per radius made at 1/3 and 2/3. Three is the
// half-width `gaussian_kernel` uses at radius 1, so the hand-over is smooth too.
let half = 3;
let sampled = |width: f32| {
let k: Vec<f32> = (-half..=half).map(|i| (-(i * i) as f32 / (2.0 * width * width)).exp()).collect();
let s: f32 = k.iter().sum();
k.into_iter().map(|v| v / s).collect::<Vec<f32>>()
};
let target = radius * radius;
// The sampled kernel's variance only falls short of sigma², so the width lies above `radius`.
let (mut lo, mut hi) = (radius, radius + 1.0);
for _ in 0..40 {
let mid = 0.5 * (lo + hi);
if variance(&sampled(mid)) < target {
lo = mid;
} else {
hi = mid;
}
}
sampled(0.5 * (lo + hi))
}
/// Separable convolution of `src` (premultiplied internally) for `out`.
/// `src` must cover `out` grown by the kernel radii.
pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bool) -> Vec<f32> {
@@ -111,7 +148,7 @@ pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f3
match box_widths(&FilterParams::GaussianBlur { radius }) {
Some(boxes) => box_passes(src, out, ctx.alpha, &boxes),
None => {
let k = gaussian_kernel(radius);
let k = gaussian_blur_kernel(radius);
conv_sep(src, out, &k, &k, ctx.alpha)
}
}
+70
View File
@@ -2,6 +2,76 @@ use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
/// #8544, rendered: Gaussian Blur at 0.1–0.3 px through the filter itself (the direct-kernel
/// path) visibly softens a hard edge, more with each step, as the kernel predicts.
#[test]
fn small_gaussian_radii_soften_a_rendered_edge() {
let canvas = Rect::new(0, 0, 20, 4);
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(canvas, &[0.0, 0.0, 0.0, 1.0]);
s.fill_rect(Rect::new(10, 0, 20, 4), &[1.0, 1.0, 1.0, 1.0]);
let mut last = 0.0f32;
for radius in [0.1f32, 0.2, 0.3] {
let p = FilterParams::GaussianBlur { radius };
assert!(box_widths(&p).is_none(), "the direct-kernel path");
let out = crate::apply(&s, &p, canvas, canvas, None);
// The last black pixel takes the white side's share of the kernel.
let got = out.pixel(9, 1)[0];
let k = gaussian_blur_kernel(radius);
let want: f32 = k[k.len() / 2 + 1..].iter().sum();
assert!((got - want).abs() <= 1.0 / 255.0, "radius {radius}: {got} vs {want}");
assert!(got >= 1.0 / 255.0 && got > last, "radius {radius}: {got} after {last}");
last = got;
}
}
/// #8544: Gaussian Blur spreads a hard edge for every radius from 0.1, growing smoothly
/// with the radius (no dead zone up to 0.3, then a jump); from 1 up the kernel is unchanged.
#[test]
fn small_gaussian_radii_blur_smoothly() {
let spread = |k: &[f32]| {
let r = (k.len() / 2) as f32;
k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::<f32>()
};
let mut last = 0.0f32;
for step in 1..=20 {
let radius = step as f32 * 0.05;
let k = gaussian_blur_kernel(radius);
assert!((k.iter().sum::<f32>() - 1.0).abs() < 1e-5, "normalised at {radius}");
if radius < 1.0 {
assert!((spread(&k) - radius * radius).abs() < 1e-4, "variance at {radius}: {}", spread(&k));
}
// A hard 0 → 1 edge: the first pixel past it takes the kernel's tail beyond the centre.
let c = k.len() / 2;
let edge: f32 = k[c + 1..].iter().sum();
assert!(edge > last, "radius {radius}: edge {edge} after {last}");
if radius > 0.1 {
assert!(edge - last < 0.05, "radius {radius}: jump {} from {last}", edge - last);
}
last = edge;
}
assert!(gaussian_blur_kernel(0.1).len() >= 3, "0.1 blurs");
// No step where the variance-matched range hands over to plain sampling.
let edge = |r: f32| {
let k = gaussian_blur_kernel(r);
k[k.len() / 2 + 1..].iter().sum::<f32>()
};
assert!((edge(0.999) - edge(1.0)).abs() < 0.005, "{} vs {}", edge(0.999), edge(1.0));
// No dip where a per-radius width used to add taps (1/3 and 2/3).
for pair in [(0.33f32, 0.34f32), (0.66, 0.67)] {
assert!(edge(pair.1) > edge(pair.0), "edge falls from {} to {}", pair.0, pair.1);
}
// Exact variance right up to the hand-over, past where `gaussian_kernel` changes width.
for radius in [0.98f32, 0.995, 0.998, 0.9995] {
let k = gaussian_blur_kernel(radius);
assert!((spread(&k) - radius * radius).abs() < 2e-5, "variance at {radius}: {}", spread(&k));
}
for radius in [1.0, 1.5, 3.0, 4.0] {
assert_eq!(gaussian_blur_kernel(radius), gaussian_kernel(radius), "{radius}");
}
assert_eq!(gaussian_blur_kernel(0.0), vec![1.0]);
}
#[test]
fn radial_blur_samples_large_arcs_beyond_64_intervals() {
let bounds = Rect::new(0, 0, 1000, 1000);
+225 -52
View File
@@ -62,68 +62,213 @@ impl Normal {
}
}
/// A sparse least-squares problem `min Σ w²(row·x − rhs)²`, solved by conjugate gradients on the
/// normal equations (CGLS). Used for mesh warps with thousands of unknowns.
#[derive(Default)]
pub(crate) struct SparseLs {
pub n: usize,
rows: Vec<(Vec<(usize, f64)>, f64)>,
/// A compressed sparse row matrix (symmetric, used by CG).
pub(crate) struct Csr {
pub rows: Vec<usize>,
pub cols: Vec<usize>,
pub vals: Vec<f64>,
pub diag: Vec<f64>,
}
impl SparseLs {
pub fn new(n: usize) -> Self {
SparseLs { n, rows: Vec::new() }
}
pub fn add(&mut self, row: Vec<(usize, f64)>, rhs: f64, w: f64) {
self.rows.push((row.into_iter().map(|(i, c)| (i, c * w)).collect(), rhs * w));
}
/// CGLS from `x0`; at most `iters` iterations.
pub fn solve(&self, x0: &[f64], iters: usize) -> Vec<f64> {
let n = self.n;
let mut x = x0.to_vec();
let ax = |x: &[f64]| -> Vec<f64> { self.rows.iter().map(|(r, _)| r.iter().map(|&(i, c)| c * x[i]).sum()).collect() };
let atv = |v: &[f64]| -> Vec<f64> {
let mut out = vec![0.0; n];
for ((r, _), vi) in self.rows.iter().zip(v) {
for &(i, c) in r {
out[i] += c * vi;
impl Csr {
pub fn from_triplets(n: usize, mut t: Vec<(usize, usize, f64)>) -> Csr {
t.sort_by_key(|a| (a.0, a.1));
let mut rows = vec![0usize; n + 1];
let mut cols = Vec::with_capacity(t.len());
let mut vals: Vec<f64> = Vec::with_capacity(t.len());
let mut last = (usize::MAX, usize::MAX);
for (i, j, v) in t {
if (i, j) == last {
*vals.last_mut().unwrap_or(&mut 0.0) += v;
continue;
}
last = (i, j);
cols.push(j);
vals.push(v);
rows[i + 1] = cols.len();
}
for i in 0..n {
rows[i + 1] = rows[i + 1].max(rows[i]);
}
let mut diag = vec![1.0; n];
for (i, d) in diag.iter_mut().enumerate() {
for k in rows[i]..rows[i + 1] {
if cols[k] == i && vals[k] > 0.0 {
*d = vals[k];
}
}
out
};
let mut r: Vec<f64> = self.rows.iter().zip(ax(&x)).map(|((_, b), a)| b - a).collect();
let mut s = atv(&r);
let mut p = s.clone();
let mut gamma: f64 = s.iter().map(|v| v * v).sum();
let g0 = gamma.max(1e-30);
for _ in 0..iters {
if gamma <= g0 * 1e-20 {
}
Csr { rows, cols, vals, diag }
}
pub fn mul(&self, x: &[f64], out: &mut [f64]) {
for (i, o) in out.iter_mut().enumerate() {
let mut s = 0.0;
for k in self.rows[i]..self.rows[i + 1] {
s += self.vals[k] * x[self.cols[k]];
}
*o = s;
}
}
/// Jacobi-preconditioned conjugate gradients, warm-started from `x`.
pub fn solve(&self, b: &[f64], x: &mut [f64]) {
let n = b.len();
let mut r = vec![0.0; n];
self.mul(x, &mut r);
for i in 0..n {
r[i] = b[i] - r[i];
}
let bn: f64 = b.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30);
let mut z: Vec<f64> = r.iter().zip(&self.diag).map(|(r, d)| r / d).collect();
let mut p = z.clone();
let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let mut ap = vec![0.0; n];
for _ in 0..(4 * n).clamp(50, 2000) {
if r.iter().map(|v| v * v).sum::<f64>().sqrt() <= 1e-12 * bn {
break;
}
let q = ax(&p);
let qq: f64 = q.iter().map(|v| v * v).sum();
if qq <= 1e-300 {
self.mul(&p, &mut ap);
let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
if pap.abs() < 1e-300 {
break;
}
let alpha = gamma / qq;
let alpha = rz / pap;
for i in 0..n {
x[i] += alpha * p[i];
r[i] -= alpha * ap[i];
}
for (ri, qi) in r.iter_mut().zip(&q) {
*ri -= alpha * qi;
}
s = atv(&r);
let g1: f64 = s.iter().map(|v| v * v).sum();
let beta = g1 / gamma;
gamma = g1;
for i in 0..n {
p[i] = s[i] + beta * p[i];
z[i] = r[i] / self.diag[i];
}
let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let beta = rz2 / rz;
rz = rz2;
for i in 0..n {
p[i] = z[i] + beta * p[i];
}
}
x
}
}
/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee
/// order, so the system's bandwidth is about one breadth-first front): factored once per pin
/// set, then each global step is two triangular solves.
pub(crate) struct BandChol {
n: usize,
p: usize,
/// Row `i` holds `L[i][i - p ..= i]`.
l: Vec<f64>,
}
impl BandChol {
/// `None` when the band would be too large (or the matrix isn't positive definite).
pub fn new(a: &Csr) -> Option<BandChol> {
let n = a.diag.len();
let mut p = 0;
for i in 0..n {
for k in a.rows[i]..a.rows[i + 1] {
p = p.max(i.abs_diff(a.cols[k]));
}
}
if n.saturating_mul(p + 1) > 40_000_000 {
return None;
}
let w = p + 1;
let mut l = vec![0.0f64; n * w];
for i in 0..n {
for k in a.rows[i]..a.rows[i + 1] {
let j = a.cols[k];
if j <= i {
l[i * w + (j + p - i)] = a.vals[k];
}
}
}
for i in 0..n {
let lo = i.saturating_sub(p);
for j in lo..=i {
let jlo = j.saturating_sub(p).max(lo);
let mut s = l[i * w + (j + p - i)];
for k in jlo..j {
s -= l[i * w + (k + p - i)] * l[j * w + (k + p - j)];
}
if i == j {
if s <= 0.0 || !s.is_finite() {
return None;
}
l[i * w + p] = s.sqrt();
} else {
l[i * w + (j + p - i)] = s / l[j * w + p];
}
}
}
Some(BandChol { n, p, l })
}
#[allow(clippy::needless_range_loop)] // band index arithmetic reads clearer with indices
pub fn solve(&self, b: &[f64], x: &mut [f64]) {
let (n, p, w) = (self.n, self.p, self.p + 1);
let mut y = b.to_vec();
for i in 0..n {
let mut s = y[i];
for k in i.saturating_sub(p)..i {
s -= self.l[i * w + (k + p - i)] * y[k];
}
y[i] = s / self.l[i * w + p];
}
for i in (0..n).rev() {
let mut s = y[i];
for k in i + 1..(i + p + 1).min(n) {
s -= self.l[k * w + (i + p - k)] * x[k];
}
x[i] = s / self.l[i * w + p];
}
}
}
/// Preconditioned conjugate gradients on the SPD system `A x = b`, warm-started from `x`.
/// `a(v, out)` writes `A·v` and `m(r, out)` writes `M⁻¹·r`. Stops when `‖r‖ ≤ tol·‖b‖`, when the
/// system stops being usable (non-finite or non-positive curvature), or after `max_iter`
/// iterations. Returns the iterations used.
pub(crate) fn pcg(a: impl Fn(&[f64], &mut [f64]), m: impl Fn(&[f64], &mut [f64]), b: &[f64], x: &mut [f64], tol: f64, max_iter: usize) -> usize {
let dot = |u: &[f64], v: &[f64]| -> f64 { u.iter().zip(v).map(|(p, q)| p * q).sum() };
let n = b.len().min(x.len());
let mut r = vec![0.0; n];
a(x, &mut r);
for (ri, bi) in r.iter_mut().zip(b) {
*ri = bi - *ri;
}
let stop = tol * dot(b, b).sqrt().max(1e-30);
let mut z = vec![0.0; n];
m(&r, &mut z);
let mut p = z.clone();
let mut rz = dot(&r, &z);
let mut ap = vec![0.0; n];
for it in 0..max_iter {
if dot(&r, &r).sqrt() <= stop || rz <= 1e-300 || !rz.is_finite() {
return it;
}
a(&p, &mut ap);
let pap = dot(&p, &ap);
if pap <= 1e-300 || !pap.is_finite() {
return it;
}
let alpha = rz / pap;
for i in 0..n {
x[i] += alpha * p[i];
r[i] -= alpha * ap[i];
}
m(&r, &mut z);
let rz2 = dot(&r, &z);
let beta = rz2 / rz;
rz = rz2;
for i in 0..n {
p[i] = z[i] + beta * p[i];
}
}
max_iter
}
/// Runs `f(y, row)` for every row of a `w × h × ch` buffer, in parallel on native targets.
pub(crate) fn par_rows<T: Send + Sync>(buf: &mut [T], w: usize, ch: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
let stride = (w * ch).max(1);
@@ -251,22 +396,50 @@ mod tests {
}
#[test]
fn dense_and_sparse_solvers_agree() {
fn dense_solver_fits_a_line_and_rejects_singular_systems() {
// Overdetermined line fit y = 2x + 1.
let mut ne = Normal::new(2);
let mut sp = SparseLs::new(2);
for x in 0..10 {
let y = 2.0 * x as f64 + 1.0;
ne.add(&[(0, x as f64), (1, 1.0)], y, 1.0);
sp.add(vec![(0, x as f64), (1, 1.0)], y, 1.0);
ne.add(&[(0, x as f64), (1, 1.0)], 2.0 * x as f64 + 1.0, 1.0);
}
let a = ne.solve().unwrap();
let b = sp.solve(&[0.0, 0.0], 50);
assert!((a[0] - 2.0).abs() < 1e-9 && (a[1] - 1.0).abs() < 1e-9);
assert!((b[0] - 2.0).abs() < 1e-6 && (b[1] - 1.0).abs() < 1e-6, "{b:?}");
assert!(solve_dense(2, vec![1.0, 2.0, 2.0, 4.0], vec![1.0, 2.0]).is_none());
}
/// A tridiagonal SPD system: identity preconditioning needs many iterations, the exact
/// banded Cholesky preconditioner one or two; both match the dense solve.
#[test]
fn pcg_matches_the_dense_solve_and_an_exact_preconditioner_converges_at_once() {
let n = 40;
let mut trip = Vec::new();
for i in 0..n {
trip.push((i, i, 2.5));
if i + 1 < n {
trip.push((i, i + 1, -1.0));
trip.push((i + 1, i, -1.0));
}
}
let k = Csr::from_triplets(n, trip);
let mut dense = vec![0.0; n * n];
for i in 0..n {
for e in k.rows[i]..k.rows[i + 1] {
dense[i * n + k.cols[e]] = k.vals[e];
}
}
let b: Vec<f64> = (0..n).map(|i| (i as f64 * 0.7).sin()).collect();
let want = solve_dense(n, dense, b.clone()).unwrap();
let mul = |v: &[f64], out: &mut [f64]| k.mul(v, out);
let mut x = vec![0.0; n];
let slow = pcg(mul, |r, z| z.copy_from_slice(r), &b, &mut x, 1e-12, 500);
assert!(x.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-8), "identity-preconditioned result differs");
let chol = BandChol::new(&k).unwrap();
let mut y = vec![0.0; n];
let fast = pcg(mul, |r, z| chol.solve(r, z), &b, &mut y, 1e-12, 500);
assert!(y.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-8), "cholesky-preconditioned result differs");
assert!(fast <= 2 && slow > fast, "iterations: exact {fast}, identity {slow}");
}
#[test]
fn srgb_round_trip_and_sampling() {
for v in [0.0f32, 0.01, 0.2, 0.5, 1.0] {
+1 -164
View File
@@ -26,6 +26,7 @@ use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
use crate::photo_util::{BandChol, Csr};
use crate::transform::Interp;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -744,170 +745,6 @@ fn band_order(pos: &[[f64; 2]], tris: &[[usize; 3]]) -> Vec<usize> {
order
}
/// A compressed sparse row matrix (symmetric, used by CG).
struct Csr {
rows: Vec<usize>,
cols: Vec<usize>,
vals: Vec<f64>,
diag: Vec<f64>,
}
impl Csr {
fn from_triplets(n: usize, mut t: Vec<(usize, usize, f64)>) -> Csr {
t.sort_by_key(|a| (a.0, a.1));
let mut rows = vec![0usize; n + 1];
let mut cols = Vec::with_capacity(t.len());
let mut vals: Vec<f64> = Vec::with_capacity(t.len());
let mut last = (usize::MAX, usize::MAX);
for (i, j, v) in t {
if (i, j) == last {
*vals.last_mut().unwrap_or(&mut 0.0) += v;
continue;
}
last = (i, j);
cols.push(j);
vals.push(v);
rows[i + 1] = cols.len();
}
for i in 0..n {
rows[i + 1] = rows[i + 1].max(rows[i]);
}
let mut diag = vec![1.0; n];
for (i, d) in diag.iter_mut().enumerate() {
for k in rows[i]..rows[i + 1] {
if cols[k] == i && vals[k] > 0.0 {
*d = vals[k];
}
}
}
Csr { rows, cols, vals, diag }
}
fn mul(&self, x: &[f64], out: &mut [f64]) {
for (i, o) in out.iter_mut().enumerate() {
let mut s = 0.0;
for k in self.rows[i]..self.rows[i + 1] {
s += self.vals[k] * x[self.cols[k]];
}
*o = s;
}
}
/// Jacobi-preconditioned conjugate gradients, warm-started from `x`.
fn solve(&self, b: &[f64], x: &mut [f64]) {
let n = b.len();
let mut r = vec![0.0; n];
self.mul(x, &mut r);
for i in 0..n {
r[i] = b[i] - r[i];
}
let bn: f64 = b.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30);
let mut z: Vec<f64> = r.iter().zip(&self.diag).map(|(r, d)| r / d).collect();
let mut p = z.clone();
let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let mut ap = vec![0.0; n];
for _ in 0..(4 * n).clamp(50, 2000) {
if r.iter().map(|v| v * v).sum::<f64>().sqrt() <= 1e-12 * bn {
break;
}
self.mul(&p, &mut ap);
let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
if pap.abs() < 1e-300 {
break;
}
let alpha = rz / pap;
for i in 0..n {
x[i] += alpha * p[i];
r[i] -= alpha * ap[i];
}
for i in 0..n {
z[i] = r[i] / self.diag[i];
}
let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let beta = rz2 / rz;
rz = rz2;
for i in 0..n {
p[i] = z[i] + beta * p[i];
}
}
}
}
/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee
/// order, so the system's bandwidth is about one breadth-first front): factored once per pin
/// set, then each global step is two triangular solves.
struct BandChol {
n: usize,
p: usize,
/// Row `i` holds `L[i][i - p ..= i]`.
l: Vec<f64>,
}
impl BandChol {
/// `None` when the band would be too large (or the matrix isn't positive definite).
fn new(a: &Csr) -> Option<BandChol> {
let n = a.diag.len();
let mut p = 0;
for i in 0..n {
for k in a.rows[i]..a.rows[i + 1] {
p = p.max(i.abs_diff(a.cols[k]));
}
}
if n.saturating_mul(p + 1) > 40_000_000 {
return None;
}
let w = p + 1;
let mut l = vec![0.0f64; n * w];
for i in 0..n {
for k in a.rows[i]..a.rows[i + 1] {
let j = a.cols[k];
if j <= i {
l[i * w + (j + p - i)] = a.vals[k];
}
}
}
for i in 0..n {
let lo = i.saturating_sub(p);
for j in lo..=i {
let jlo = j.saturating_sub(p).max(lo);
let mut s = l[i * w + (j + p - i)];
for k in jlo..j {
s -= l[i * w + (k + p - i)] * l[j * w + (k + p - j)];
}
if i == j {
if s <= 0.0 || !s.is_finite() {
return None;
}
l[i * w + p] = s.sqrt();
} else {
l[i * w + (j + p - i)] = s / l[j * w + p];
}
}
}
Some(BandChol { n, p, l })
}
#[allow(clippy::needless_range_loop)] // band index arithmetic reads clearer with indices
fn solve(&self, b: &[f64], x: &mut [f64]) {
let (n, p, w) = (self.n, self.p, self.p + 1);
let mut y = b.to_vec();
for i in 0..n {
let mut s = y[i];
for k in i.saturating_sub(p)..i {
s -= self.l[i * w + (k + p - i)] * y[k];
}
y[i] = s / self.l[i * w + p];
}
for i in (0..n).rev() {
let mut s = y[i];
for k in i + 1..(i + p + 1).min(n) {
s -= self.l[k * w + (i + p - k)] * x[k];
}
x[i] = s / self.l[i * w + p];
}
}
}
/// A pin bound to the mesh: barycentric weights on a triangle's vertices.
#[derive(Clone, Debug)]
struct Bound {
+154 -50
View File
@@ -639,53 +639,162 @@ fn spot(shape: HalftoneShape, u: f32, v: f32) -> f32 {
/// Converts gray values (0..=1, row-major `w` wide) to 0/1 in place.
pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
let h = gray.len().checked_div(w).unwrap_or(0);
match method {
BitmapMethod::Threshold => gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }),
BitmapMethod::Pattern => {
for (i, v) in gray.iter_mut().enumerate() {
*v = if *v > bayer8(i % w, i / w) + 0.5 { 1.0 } else { 0.0 };
BitmapStrips::new(w, method).convert(gray);
}
/// Image › Mode › Bitmap fed in strips of whole rows, top to bottom, so a huge image never needs a
/// full-size gray plane (#8477): [`BitmapStrips::convert`] turns each strip to 0/1 in place,
/// carrying error diffusion's error into the next strip. The result is the same however the image
/// is cut into strips.
pub struct BitmapStrips<'a> {
w: usize,
kind: StripKind<'a>,
/// Image row of the next strip's first row.
y: usize,
}
enum StripKind<'a> {
Threshold,
Pattern,
/// Floyd–Steinberg: the error each pixel of the last converted row passes down.
Diffusion {
errs: Vec<f32>,
},
/// The spot function's values over a cell, sorted (its CDF ranks coverage by tone).
Halftone {
cell: f32,
sin: f32,
cos: f32,
shape: HalftoneShape,
table: Vec<f32>,
},
/// Custom Pattern's threshold map, `mw` x `mh`, tiled from the top-left corner.
Custom {
map: &'a [f32],
mw: usize,
mh: usize,
},
}
impl<'a> BitmapStrips<'a> {
pub fn new(w: usize, method: BitmapMethod) -> Self {
let kind = match method {
BitmapMethod::Threshold => StripKind::Threshold,
BitmapMethod::Pattern => StripKind::Pattern,
BitmapMethod::Diffusion => StripKind::Diffusion { errs: Vec::new() },
BitmapMethod::Halftone { cell, angle, shape } => {
let (sin, cos) = angle.to_radians().sin_cos();
// Rank the spot function over the cell so coverage is proportional to the tone.
let mut table: Vec<f32> =
(0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect();
table.sort_by(f32::total_cmp);
StripKind::Halftone { cell: cell.max(2.0), sin, cos, shape, table }
}
};
Self { w, kind, y: 0 }
}
/// Bitmap's Custom Pattern: each gray value turns white where it reaches the threshold `map`
/// (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the image's top-left corner,
/// black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%.
pub fn custom(w: usize, map: &'a [f32], mw: usize, mh: usize) -> Self {
let fits = mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len());
let kind = if fits { StripKind::Custom { map, mw, mh } } else { StripKind::Threshold };
Self { w, kind, y: 0 }
}
/// Converts the next rows (gray 0..=1, `w` wide; a short last row is allowed) to 0/1 in place.
pub fn convert(&mut self, gray: &mut [f32]) {
let (w, y0) = (self.w, self.y);
if w == 0 {
// No rows to lay a pattern or diffuse along: a 50% threshold.
gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 });
return;
}
BitmapMethod::Diffusion => {
let mut buf: Vec<f32> = gray.to_vec();
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let old = buf[i];
let new = if old >= 0.5 { 1.0 } else { 0.0 };
let e = old - new;
gray[i] = new;
if x + 1 < w {
buf[i + 1] += e * 7.0 / 16.0;
}
if y + 1 < h {
if x > 0 {
buf[i + w - 1] += e * 3.0 / 16.0;
}
buf[i + w] += e * 5.0 / 16.0;
if x + 1 < w {
buf[i + w + 1] += e / 16.0;
}
}
self.y = y0.saturating_add(gray.len().div_ceil(w));
// Every method but diffusion is pointwise: rows convert in parallel.
let par = gray.len() >= 1 << 16;
match &mut self.kind {
StripKind::Threshold => crate::par::chunks_mut(gray, w, par, |_, row| {
row.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 });
}),
StripKind::Pattern => crate::par::chunks_mut(gray, w, par, |r, row| {
for (x, v) in row.iter_mut().enumerate() {
*v = if *v > bayer8(x, y0 + r) + 0.5 { 1.0 } else { 0.0 };
}
}),
StripKind::Diffusion { errs } => {
// The previous strip's last row passes its error down to this strip's first.
let first = w.min(gray.len());
pass_down(errs, gray.get_mut(..first).unwrap_or_default());
let mut rest = gray;
while !rest.is_empty() {
let n = w.min(rest.len());
let (row, tail) = std::mem::take(&mut rest).split_at_mut(n);
diffuse_row(row, errs);
let below = w.min(tail.len());
pass_down(errs, tail.get_mut(..below).unwrap_or_default());
rest = tail;
}
}
}
BitmapMethod::Halftone { cell, angle, shape } => {
let cell = cell.max(2.0);
let (s, c) = angle.to_radians().sin_cos();
// Rank the spot function over the cell so coverage is proportional to the tone.
let mut table: Vec<f32> = (0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect();
table.sort_by(f32::total_cmp);
let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32;
for (i, v) in gray.iter_mut().enumerate() {
let (x, y) = ((i % w) as f32 + 0.5, (i / w) as f32 + 0.5);
let (rx, ry) = (x * c + y * s, -x * s + y * c);
let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0;
let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0;
// Ink (black) covers where the spot function exceeds the gray level.
*v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 };
StripKind::Halftone { cell, sin, cos, shape, table } => {
let (cell, s, c, shape, table) = (*cell, *sin, *cos, *shape, &*table);
let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32;
crate::par::chunks_mut(gray, w, par, |r, row| {
let y = (y0 + r) as f32 + 0.5;
for (x, v) in row.iter_mut().enumerate() {
let x = x as f32 + 0.5;
let (rx, ry) = (x * c + y * s, -x * s + y * c);
let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0;
let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0;
// Ink (black) covers where the spot function exceeds the gray level.
*v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 };
}
});
}
StripKind::Custom { map, mw, mh } => {
let (map, mw, mh) = (&**map, *mw, *mh);
crate::par::chunks_mut(gray, w, par, |r, row| {
let base = (y0 + r) % mh * mw;
for (x, v) in row.iter_mut().enumerate() {
let t = map.get(base + x % mw).copied().unwrap_or(0.5);
*v = if *v >= t { 1.0 } else { 0.0 };
}
});
}
}
}
}
/// One Floyd–Steinberg row, in place: each pixel takes its error from the left (7/16), and
/// `errs` gets the error it passes down to the next row ([`pass_down`]).
fn diffuse_row(row: &mut [f32], errs: &mut Vec<f32>) {
errs.clear();
for x in 0..row.len() {
let old = row[x];
let new = if old >= 0.5 { 1.0 } else { 0.0 };
let e = old - new;
row[x] = new;
if let Some(next) = row.get_mut(x + 1) {
*next += e * 7.0 / 16.0;
}
errs.push(e);
}
}
/// Adds the error `errs` of a row to the row below: 1/16 from the pixel up-left, 5/16 from the
/// one above, 3/16 from the one up-right, in that order (the order the row's pixels were
/// converted in), so the result is the same whether or not the rows are in the same strip.
fn pass_down(errs: &[f32], below: &mut [f32]) {
for (t, v) in below.iter_mut().enumerate() {
if let Some(e) = t.checked_sub(1).and_then(|l| errs.get(l)) {
*v += e / 16.0;
}
if let Some(e) = errs.get(t) {
*v += e * 5.0 / 16.0;
}
if let Some(e) = errs.get(t + 1) {
*v += e * 3.0 / 16.0;
}
}
}
@@ -719,15 +828,10 @@ pub fn custom_pattern_thresholds(lightness: &[f32]) -> Vec<f32> {
out
}
/// Bitmap's Custom Pattern: each gray value (0..=1, row-major `w` wide) turns white where it
/// reaches the threshold `map` (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the
/// image's top-left corner, black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%.
/// Bitmap's Custom Pattern over a whole image (gray 0..=1, row-major `w` wide), see
/// [`BitmapStrips::custom`]. An image `w` of 0 thresholds at 50%.
pub fn to_bitmap_custom(gray: &mut [f32], w: usize, map: &[f32], mw: usize, mh: usize) {
let fits = w > 0 && mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len());
for (i, v) in gray.iter_mut().enumerate() {
let t = if fits { map.get((i / w) % mh * mw + (i % w) % mw).copied().unwrap_or(0.5) } else { 0.5 };
*v = if *v >= t { 1.0 } else { 0.0 };
}
BitmapStrips::custom(w, map, mw, mh).convert(gray);
}
#[cfg(test)]
+107
View File
@@ -440,3 +440,110 @@ fn custom_pattern_tiles_from_the_top_left() {
to_bitmap_custom(&mut g, 0, &map, 2, 1);
assert_eq!(g, [1.0]);
}
/// `gray` converted by `conv` in strips of `rows` rows each, cycling through `rows`.
fn in_strips(gray: &[f32], w: usize, rows: &[usize], mut conv: BitmapStrips<'_>) -> Vec<f32> {
let mut out = gray.to_vec();
let mut rest = out.as_mut_slice();
for r in rows.iter().cycle() {
if rest.is_empty() {
break;
}
let (strip, tail) = rest.split_at_mut((r * w).min(rest.len()));
conv.convert(strip);
rest = tail;
}
out
}
#[test]
fn bitmap_strips_match_the_whole_image_however_it_is_cut() {
// #8477: strip boundaries must preserve diffusion state and keep patterns/screens anchored.
let (w, h) = (37, 29);
let gray: Vec<f32> = (0..w * h).map(|i| ((i % w) as f32 / w as f32 * 0.7 + (i / w) as f32 / h as f32 * 0.3).fract()).collect();
let map = custom_pattern_thresholds(&[0.1, 0.9, 0.4, 0.6, 0.2, 0.8]);
let methods = [
BitmapMethod::Threshold,
BitmapMethod::Pattern,
BitmapMethod::Diffusion,
BitmapMethod::Halftone { cell: 5.5, angle: 30.0, shape: HalftoneShape::Ellipse },
];
for rows in [&[1][..], &[3], &[5, 2, 7], &[h]] {
for method in methods {
let mut whole = gray.clone();
to_bitmap(&mut whole, w, method);
assert!(whole.iter().all(|v| *v == 0.0 || *v == 1.0), "{method:?}");
assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, method)), whole, "{method:?} in strips of {rows:?}");
}
let mut whole = gray.clone();
to_bitmap_custom(&mut whole, w, &map, 3, 2);
assert_eq!(in_strips(&gray, w, rows, BitmapStrips::custom(w, &map, 3, 2)), whole, "custom in strips of {rows:?}");
}
}
#[test]
fn bitmap_strips_match_the_whole_image_with_a_ragged_last_row() {
// A short final row still receives the error from the row above, including in its own strip.
let w = 37;
let gray: Vec<f32> = (0..w * 5 + 11).map(|i| ((i % w) as f32 / w as f32 * 0.7 + (i / w) as f32 * 0.13).fract()).collect();
for method in [BitmapMethod::Diffusion, BitmapMethod::Threshold, BitmapMethod::Pattern] {
let mut whole = gray.clone();
to_bitmap(&mut whole, w, method);
for rows in [&[1][..], &[5], &[2, 3], &[5, 1]] {
assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, method)), whole, "{method:?} in strips of {rows:?}");
}
}
}
/// Floyd–Steinberg over the whole image as `to_bitmap` did before strips (#8477).
fn diffusion_reference(gray: &mut [f32], w: usize) {
let h = gray.len() / w;
let mut buf = gray.to_vec();
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let old = buf[i];
let new = if old >= 0.5 { 1.0 } else { 0.0 };
let error = old - new;
gray[i] = new;
if x + 1 < w {
buf[i + 1] += error * 7.0 / 16.0;
}
if y + 1 < h {
if x > 0 {
buf[i + w - 1] += error * 3.0 / 16.0;
}
buf[i + w] += error * 5.0 / 16.0;
if x + 1 < w {
buf[i + w + 1] += error / 16.0;
}
}
}
}
}
#[test]
fn bitmap_diffusion_in_strips_is_bit_identical_to_one_pass() {
// Keep error accumulation order identical; one rounding change can flip many later pixels.
let (w, h) = (173, 61);
let gray: Vec<f32> = (0..w * h).map(|i| ((i % w) as f32 * 0.013 + (i / w) as f32 * 0.007 + ((i * 7919) % 101) as f32 * 0.001).fract()).collect();
let mut expected = gray.clone();
diffusion_reference(&mut expected, w);
for rows in [&[1][..], &[4, 9], &[h]] {
assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, BitmapMethod::Diffusion)), expected, "strips of {rows:?}");
}
}
#[test]
fn bitmap_strips_handle_empty_and_ragged_input() {
// A zero width thresholds at 50%, and a short final row is converted too.
for method in [BitmapMethod::Pattern, BitmapMethod::Diffusion, BitmapMethod::Halftone { cell: 4.0, angle: 0.0, shape: HalftoneShape::Round }] {
let mut gray = vec![0.4, 0.6];
to_bitmap(&mut gray, 0, method);
assert_eq!(gray, [0.0, 1.0], "{method:?}");
let mut gray = vec![0.3; 7];
to_bitmap(&mut gray, 3, method);
assert!(gray.iter().all(|v| *v == 0.0 || *v == 1.0), "{method:?}: {gray:?}");
to_bitmap(&mut [], 3, method);
}
}
+21 -4
View File
@@ -252,14 +252,28 @@ pub fn grid_cut_cancellable(
weight: impl Fn(f32) -> f32,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel)
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel, false)
}
/// [`grid_cut_cancellable`] solved with Boykov–Kolmogorov instead of push/relabel: faster when
/// the data costs come from colour models (GrabCut and the band re-cut), where terminal links
/// are few and paths short. The minimum cut value is the same.
pub fn grid_cut_bk_cancellable(
img: &RgbImage,
cost_fg: &[f32],
cost_bg: &[f32],
fixed: &[u8],
weight: impl Fn(f32) -> f32,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel, true)
}
mod edge_starts;
// The const parameter also runs the previous full scan in before/after workflow tests.
fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec<bool> {
grid_cut_impl::<OPTIMIZED>(img, cost_fg, cost_bg, fixed, weight, &|| false).unwrap_or_default()
grid_cut_impl::<OPTIMIZED>(img, cost_fg, cost_bg, fixed, weight, &|| false, false).unwrap_or_default()
}
fn grid_cut_impl<const OPTIMIZED: bool>(
@@ -269,6 +283,7 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
fixed: &[u8],
weight: impl Fn(f32) -> f32,
cancel: &dyn Fn() -> bool,
bk: bool,
) -> Option<Vec<bool>> {
let (w, h) = (img.w as i32, img.h as i32);
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Some(Vec::new()) };
@@ -336,7 +351,9 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
if cancel() {
return None;
}
if OPTIMIZED {
if OPTIMIZED && bk {
g.maxflow_bk_with(cancel)?;
} else if OPTIMIZED {
g.maxflow_with(cancel)?;
} else {
g.maxflow_bk();
@@ -733,7 +750,7 @@ fn refine_band(
}
let beta = contrast_beta(&img);
// `grid_cut`'s weight, with the cut stopping on cancel.
let cut = grid_cut_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let cut = grid_cut_bk_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let (cw, ch) = (t.width() as usize, t.height() as usize);
let mut core = Vec::with_capacity(cw * ch);
for yy in 0..ch {
+2 -2
View File
@@ -10,7 +10,7 @@ use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt};
use super::gmm::{DEFAULT_REG, Gmm};
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_cancellable, subsample};
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_bk_cancellable, subsample};
/// Definite background.
pub const BG: u8 = 0;
@@ -67,7 +67,7 @@ pub fn grabcut_with(img: &RgbImage, trimap: &mut [u8], iters: usize, ctl: &Inter
};
let (cf, cb) = data_costs(img, &fg, &bg);
// `grid_cut`'s weight, with the cut stopping on cancel.
let cut = grid_cut_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let cut = grid_cut_bk_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let mut changed = 0usize;
for (t, fgnd) in trimap.iter_mut().zip(&cut) {
if *t == PR_BG || *t == PR_FG {
+60 -1
View File
@@ -140,11 +140,22 @@ impl Graph {
self.bk(cancel)
}
/// [`Graph::maxflow_with`] that always runs Boykov–Kolmogorov. It is the faster solver on
/// the 8-connected colour-model grids of GrabCut (about 2.3x on a 107k-pixel working image);
/// push/relabel wins on Quick Selection's many area-cost terminals, which keep
/// [`Graph::maxflow_with`].
pub fn maxflow_bk_with(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
self.bk(cancel)
}
pub(super) fn maxflow_bk(&mut self) -> f64 {
self.bk(&never).unwrap_or(self.flow)
}
fn bk(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
if cancel() {
return None;
}
let n = self.node_count();
let mut queue: VecDeque<u32> = VecDeque::new();
let mut orphans: VecDeque<u32> = VecDeque::new();
@@ -219,7 +230,7 @@ impl Graph {
self.adopt(o as usize, time, &mut queue, &mut orphans);
}
}
Some(self.flow)
if cancel() { None } else { Some(self.flow) }
}
fn augment(&mut self, m: usize, orphans: &mut VecDeque<u32>) {
@@ -408,3 +419,51 @@ impl Graph {
s
}
}
#[cfg(test)]
mod cancellation_tests {
use super::{CANCEL_EVERY, Graph};
use std::cell::Cell;
#[test]
fn bk_checks_cancellation_on_short_runs() {
let mut graph = Graph::with_capacity(2, 0);
graph.add_tweights(0, 1.0, 0.0);
graph.add_tweights(1, 0.0, 1.0);
assert!(graph.maxflow_bk_with(&|| true).is_none());
let calls = Cell::new(0);
assert!(
graph
.maxflow_bk_with(&|| {
calls.set(calls.get() + 1);
calls.get() == 2
})
.is_none()
);
assert_eq!(calls.get(), 2, "short run must check before returning");
}
#[test]
fn bk_checks_cancellation_during_a_long_run() {
let mut graph = Graph::with_capacity(CANCEL_EVERY as usize + 1, 0);
for i in 0..graph.node_count() {
graph.add_tweights(i, 1.0, 0.0);
}
let calls = Cell::new(0);
assert!(
graph
.maxflow_bk_with(&|| {
calls.set(calls.get() + 1);
calls.get() == 2
})
.is_none()
);
// The first check is at entry; more than CANCEL_EVERY active nodes ensure the
// second is the periodic poll, before the solver can finish.
assert_eq!(calls.get(), 2);
// Without the periodic poll the final check would be the second call. The poll stops the
// run with the last queued node unprocessed, still marked active.
assert!(graph.active[CANCEL_EVERY as usize], "periodic cancel must stop before the last queued node is processed");
}
}
+2 -2
View File
@@ -265,7 +265,7 @@ fn cut_levels<const OPTIMIZED: bool>(
let (w, h) = (img.w, img.h);
let zeros = vec![0.0f32; w * h];
if w * h <= direct_px || w < 8 || h < 8 {
return super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel);
return super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel, false);
}
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let coarse_img = img.downsample(2);
@@ -310,7 +310,7 @@ fn cut_levels<const OPTIMIZED: bool>(
}
})
.collect();
super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel)
super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel, false)
}
/// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel.
+143
View File
@@ -288,6 +288,65 @@ fn grabcut_trimap_on_disc() {
assert!(iou > 0.95, "IoU {iou}");
}
#[test]
fn grabcut_small_disc_mask_is_pinned() {
// The mask GrabCut produced with the push/relabel solver, before the switch to BK. Min-cut
// ties may break differently between solvers, so any drift here is a visible change.
let (img, _) = disc_scene(32, 24, 16.0, 12.0, 8.0, 3);
let mut tri = vec![grabcut::BG; img.w * img.h];
for y in 2..22 {
for x in 6..26 {
tri[y * img.w + x] = grabcut::PR_FG;
}
}
grabcut::grabcut(&img, &mut tri, 8).unwrap();
let got: Vec<String> = tri.chunks(img.w).map(|r| r.iter().map(|t| if *t == grabcut::PR_FG || *t == grabcut::FG { '#' } else { '.' }).collect()).collect();
let want = [
"................................",
"................................",
"................................",
"................................",
".............######.............",
"...........##########...........",
"..........############..........",
".........##############.........",
".........##############.........",
"........################........",
"........################........",
"........################........",
"........################........",
"........################........",
"........################........",
".........##############.........",
".........##############.........",
"..........############..........",
"...........##########...........",
".............######.............",
"................................",
"................................",
"................................",
"................................",
];
assert_eq!(got, want);
}
#[test]
fn grabcut_large_disc_mask_is_pinned() {
// 5168 free nodes with terminal links both ways, so the pre-BK solver was push/relabel here.
// The count and FNV-1a hash are of the mask that push/relabel produced at the merge base.
let (img, _) = disc_scene(96, 80, 48.0, 40.0, 26.0, 3);
let mut tri = vec![grabcut::BG; img.w * img.h];
for y in 6..74 {
for x in 10..86 {
tri[y * img.w + x] = grabcut::PR_FG;
}
}
grabcut::grabcut(&img, &mut tri, 8).unwrap();
let fg: Vec<bool> = tri.iter().map(|t| *t == grabcut::PR_FG || *t == grabcut::FG).collect();
let hash = fg.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3));
assert_eq!((fg.iter().filter(|b| **b).count(), hash), (2128, 0x62fd_c64a_0ee4_4e75));
}
#[test]
fn object_select_disc_full_resolution() {
let (img, truth) = disc_scene(200, 160, 100.0, 80.0, 50.0, 2);
@@ -1068,3 +1127,87 @@ fn hole_fill_without_specks_fills_object_coloured_holes_only() {
assert!(out[25 * w + 25], "a speck of the object's colour fills");
assert!(!out[10 * w + 10], "a speck of the background's colour stays open");
}
/// Energy of a labelling: data costs plus the contrast weights of separated 8-neighbours.
fn grid_energy(img: &RgbImage, cf: &[f32], cb: &[f32], cut: &[bool], weight: impl Fn(f32) -> f32) -> f64 {
let (w, h) = (img.w as i32, img.h as i32);
let mut e = 0.0f64;
for y in 0..h {
for x in 0..w {
let i = (y * w + x) as usize;
e += f64::from(if cut[i] { cf[i] } else { cb[i] });
for (dx, dy, len) in NB {
let (nx, ny) = (x + dx, y + dy);
if nx >= 0 && nx < w && ny < h {
let j = (ny * w + nx) as usize;
if cut[i] != cut[j] {
e += f64::from(weight(d2(img.px[i], img.px[j])) / len);
}
}
}
}
}
e
}
#[test]
fn bk_and_default_grid_cut_tie_reaches_the_same_energy() {
// Hard background down the left edge, hard foreground down the right, uniform pixels and equal
// data costs: a vertical cut at any column separates the borders at the same energy, so only
// the solvers' tie-breaking decides the labelling.
let (w, h) = (120usize, 100usize);
let img = RgbImage::from_fn(w, h, |_, _| [0.5, 0.5, 0.5]);
let (cf, cb) = (vec![1.0f32; w * h], vec![1.0f32; w * h]);
let fixed: Vec<u8> = (0..w * h)
.map(|i| match i % w {
0 => HARD_BG,
x if x == w - 1 => HARD_FG,
_ => FREE,
})
.collect();
let weight = |_: f32| 2.0;
// Two distinct candidate labellings (vertical cuts at different columns) tie at the minimum.
let split = |col: usize| -> Vec<bool> { (0..w * h).map(|i| i % w >= col).collect() };
let (e30, e90) = (grid_energy(&img, &cf, &cb, &split(30), weight), grid_energy(&img, &cf, &cb, &split(90), weight));
assert!((e30 - e90).abs() <= 1e-6 * e30.abs().max(1.0), "candidates must tie: {e30} vs {e90}");
assert_ne!(split(30), split(90));
let a = grid_cut_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("default cut");
let b = grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("bk cut");
let (ea, eb) = (grid_energy(&img, &cf, &cb, &a, weight), grid_energy(&img, &cf, &cb, &b, weight));
assert!((ea - e30).abs() <= 1e-3 * e30.abs().max(1.0), "default {ea} is not the tied minimum {e30}");
assert!((eb - e30).abs() <= 1e-3 * e30.abs().max(1.0), "bk {eb} is not the tied minimum {e30}");
// The solvers may pick different minimum cuts here; only the energy is guaranteed.
assert!(a.iter().any(|&l| l) && b.iter().any(|&l| l), "both label the foreground border");
}
#[test]
fn bk_and_default_grid_cut_reach_the_same_minimum_energy() {
// Large enough (>= 4096 free nodes, both terminals) that the default solver is push/relabel.
let (w, h) = (120usize, 100usize);
let mut rng = Rng::new(7);
let img = RgbImage::from_fn(w, h, |x, y| {
let inside = (x as f32 - 60.0).powi(2) + (y as f32 - 50.0).powi(2) < 1600.0;
let base = if inside { [0.8, 0.3, 0.2] } else { [0.2, 0.4, 0.7] };
base.map(|v| v + 0.08 * rng.normal())
});
let cf: Vec<f32> = img.px.iter().map(|p| 4.0 * d2(*p, [0.8, 0.3, 0.2])).collect();
let cb: Vec<f32> = img.px.iter().map(|p| 4.0 * d2(*p, [0.2, 0.4, 0.7])).collect();
let fixed: Vec<u8> = (0..w * h)
.map(|i| {
if i % w == 0 {
HARD_BG
} else if i == (50 * w + 60) {
HARD_FG
} else {
FREE
}
})
.collect();
let weight = |d: f32| 50.0 * (-8.0 * d).exp();
let a = grid_cut_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("default cut");
let b = grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("bk cut");
let (ea, eb) = (grid_energy(&img, &cf, &cb, &a, weight), grid_energy(&img, &cf, &cb, &b, weight));
assert!((ea - eb).abs() <= 1e-3 * ea.abs().max(1.0), "default {ea} vs bk {eb}");
assert_eq!(a, b, "same labelling, not just the same energy");
assert!(grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| true).is_none(), "BK honours cancel");
}
+18 -1
View File
@@ -134,18 +134,30 @@ pub fn mask_to_surface(m: &[f32], area: Rect) -> Surface {
surface_from_masks(area, m, None, |_, v| v)
}
mod combine_mask;
pub use combine_mask::combine_band;
/// Combines `new` with `old` by `mode`. Returns `None` when nothing is selected.
pub fn combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
if area.is_empty() {
return None;
}
// Replace already writes straight from the input mask to tiles; it needs no old mask.
if mode == SelectionMode::Replace || (old.is_none() && mode == SelectionMode::Add) {
let s = mask_to_surface(new, area);
return (s.tile_count() > 0).then_some(s);
}
let old = old?;
if old.format() == PixelFormat::GRAY8 {
return combine_mask::gray8(Some(old), new, area, mode, false);
}
// Quantize to the 8-bit grid selections are stored on, so combining a mask with itself is
// exact. Below half an 8-bit step is nothing.
let q = |v: f32| f32::from(coverage8(v)) / 255.0;
let s = match mode {
SelectionMode::Replace => surface_from_masks(area, new, None, |_, v| v),
_ => {
let prev = mask_from_surface(old, area);
let prev = mask_from_surface(Some(old), area);
match mode {
SelectionMode::Add => surface_from_masks(area, new, Some(&prev), |p, v| p.max(q(v))),
SelectionMode::Subtract => surface_from_masks(area, new, Some(&prev), |p, v| (p - q(v)).max(0.0)),
@@ -381,6 +393,9 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti
let empty = Region { bbox: Rect::EMPTY, mask: Vec::new() };
let new = new.unwrap_or(&empty);
let b = new.bbox;
if old.is_none_or(|s| s.format() == PixelFormat::GRAY8) {
return combine_mask::gray8_bytes(old, &new.mask, b, mode);
}
let prev_u8 = |s: &Surface| -> Vec<u8> {
let mut v = Vec::new();
s.read_region_into(b, &mut v);
@@ -761,11 +776,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
}
mod distance;
mod inverse;
mod lasso;
mod marquee;
mod morphology;
pub use distance::edt;
pub use inverse::inverse_surface;
pub(crate) use lasso::clip_segment;
pub use lasso::{MAX_PAINTED_TILES, polygon_edge_tiles, polygon_surface};
pub use marquee::{combine_sparse, combine_surfaces, ellipse_surface};
+112
View File
@@ -0,0 +1,112 @@
//! Float mask inputs meet stored GRAY8 selections at the tile boundary. No dense copy of
//! the previous selection or of the combined result is needed.
use super::{SelectionMode, combine, coverage8};
use crate::photo_util::par_map;
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::{Surface, Tile};
use std::sync::Arc;
/// Combines a mask covering only `band` (zero elsewhere). Add and Subtract retain the old
/// selection outside it, including pixels sharing a tile with the band's edges. Replace
/// and Intersect return only the selection inside the band. Missing mask samples read zero.
pub fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, mode: SelectionMode) -> Option<Surface> {
let keep = matches!(mode, SelectionMode::Add | SelectionMode::Subtract);
if !keep || old.is_none() {
return combine(old, mask, band, mode);
}
if band.is_empty() {
return old.cloned();
}
if old.is_none_or(|s| s.format() == PixelFormat::GRAY8) {
return gray8(old, mask, band, mode, true);
}
// Preserve the format and conversion behavior of unusual selection surfaces. The normal
// GRAY8 path above never materializes these band-sized buffers.
let inside = combine(old, mask, band, mode);
let mut out = old.cloned()?;
let values = inside.map_or_else(|| vec![0.0; band.width() as usize * band.height() as usize], |s| s.read_region(band));
if out.channels() == 1 {
out.write_region(band, &values);
} else {
// A selection uses its first channel as coverage. Broadcast it when writing back to
// an unusual multichannel surface, rather than passing a short buffer to write_region.
let pixels: Vec<f32> = values.iter().flat_map(|v| std::iter::repeat_n(*v, out.channels())).collect();
out.write_region(band, &pixels);
}
out.prune();
(out.tile_count() > 0).then_some(out)
}
pub(super) fn gray8(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode, keep_outside: bool) -> Option<Surface> {
build(old, area, mode, keep_outside, |i| coverage8(mask.get(i).copied().unwrap_or(0.0)))
}
pub(super) fn gray8_bytes(old: Option<&Surface>, mask: &[u8], area: Rect, mode: SelectionMode) -> Option<Surface> {
let keep = matches!(mode, SelectionMode::Add | SelectionMode::Subtract);
if area.is_empty() {
return if keep { old.cloned().filter(|s| !s.content_bounds().is_empty()) } else { None };
}
build(old, area, mode, keep, |i| mask.get(i).copied().unwrap_or(0))
}
fn build(old: Option<&Surface>, area: Rect, mode: SelectionMode, keep_outside: bool, sample: impl Fn(usize) -> u8 + Sync) -> Option<Surface> {
let mut out = if keep_outside { old.cloned().unwrap_or_else(|| Surface::new(PixelFormat::GRAY8)) } else { Surface::new(PixelFormat::GRAY8) };
let default = old.and_then(|s| s.default_bytes().first()).copied().unwrap_or(0);
let out_default = out.default_bytes().first().copied().unwrap_or(0);
let (blank, full) = (out.solid_tile(&[0.0]), out.solid_tile(&[1.0]));
let untouched = out.solid_tile(&[f32::from(out_default) / 255.0]);
let coords: Vec<TileCoord> = area.tiles().collect();
let width = area.width() as usize;
let ts = TILE_SIZE as usize;
let built = par_map(coords.len(), |k| {
let tc = *coords.get(k)?;
let rect = tc.rect();
let cut = rect.intersect(&area);
let previous = old.and_then(|s| s.tile(tc));
let mut tile = Tile::clone(if keep_outside { previous.unwrap_or(&untouched) } else { &blank });
for y in cut.y0..cut.y1 {
let offset = y.abs_diff(rect.y0) as usize * ts + cut.x0.abs_diff(rect.x0) as usize;
let len = cut.width() as usize;
let Some(dst) = tile.bytes_mut().get_mut(offset..offset + len) else { continue };
let src = previous.and_then(|t| t.bytes().get(offset..offset + len));
let start = (y.abs_diff(area.y0) as usize).checked_mul(width).and_then(|i| i.checked_add(cut.x0.abs_diff(area.x0) as usize));
for (i, d) in dst.iter_mut().enumerate() {
let new = start.and_then(|j| j.checked_add(i)).map(&sample).unwrap_or(0);
let prev = src.and_then(|s| s.get(i)).copied().unwrap_or(default);
*d = match mode {
SelectionMode::Replace => new,
SelectionMode::Add => prev.max(new),
SelectionMode::Subtract => prev.saturating_sub(new),
SelectionMode::Intersect => prev.min(new),
};
}
}
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), b| (lo.min(*b), hi.max(*b)));
let tile = if lo == out_default && hi == out_default {
None
} else if previous.is_some_and(|p| p.bytes() == tile.bytes()) {
previous.cloned()
} else if lo == u8::MAX {
Some(Arc::clone(&full))
} else {
Some(Arc::new(tile))
};
Some((tc, tile))
});
for (tc, tile) in built.into_iter().flatten() {
match tile {
Some(tile) => out.put_tiles([(tc, tile)]),
None if keep_outside && out.tile(tc).is_some() => drop(out.take_tiles(tc.rect())),
None => {}
}
}
if keep_outside {
out.prune();
}
(out.tile_count() > 0).then_some(out)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,179 @@
use super::*;
use crate::selection::{Region, combine_region, mask_from_surface, surface_from_masks};
use photocraft_color::{ColorMode, SampleType};
const MODES: [SelectionMode; 4] = [SelectionMode::Replace, SelectionMode::Add, SelectionMode::Subtract, SelectionMode::Intersect];
// The pre-refactor float formulas, including quantization before the binary operation.
fn reference(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
if area.is_empty() {
return None;
}
if mode == SelectionMode::Replace {
let s = surface_from_masks(area, mask, None, |_, v| v);
return (s.tile_count() > 0).then_some(s);
}
let prev = mask_from_surface(old, area);
let s = surface_from_masks(area, mask, Some(&prev), |p, v| {
let q = f32::from(coverage8(v)) / 255.0;
match mode {
SelectionMode::Replace => v,
SelectionMode::Add => p.max(q),
SelectionMode::Subtract => (p - q).max(0.0),
SelectionMode::Intersect => p.min(q),
}
});
(s.tile_count() > 0).then_some(s)
}
fn reference_band(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
let inside = reference(old, mask, area, mode);
let Some(old) = old.filter(|_| matches!(mode, SelectionMode::Add | SelectionMode::Subtract)) else { return inside };
if area.is_empty() {
return Some(old.clone());
}
let values = inside.map_or_else(|| vec![0.0; area.width() as usize * area.height() as usize], |s| s.read_region(area));
let mut out = old.clone();
out.write_region(area, &values);
out.prune();
(out.tile_count() > 0).then_some(out)
}
#[test]
fn every_byte_pair_matches_float_combining() {
let area = Rect::new(0, 0, 256, 256);
let prev: Vec<f32> = (0..65536).map(|i| (i / 256) as f32 / 255.0).collect();
let mask: Vec<f32> = (0..65536).map(|i| (i % 256) as f32 / 255.0).collect();
let old = crate::selection::mask_to_surface(&prev, area);
for mode in MODES {
assert_eq!(combine(Some(&old), &mask, area, mode), reference(Some(&old), &mask, area, mode), "{mode:?}");
}
}
#[test]
fn cropped_bands_match_dense_writeback_with_defaults_and_short_masks() {
let values = [f32::NAN, f32::NEG_INFINITY, -0.2, 0.0, 0.49 / 255.0, 0.5 / 255.0, 0.51 / 255.0, 0.5, 1.0, 1.2, f32::INFINITY];
for area in [Rect::new(-259, -2, 259, 3), Rect::new(255, 255, 258, 259), Rect::new(0, 0, 512, 256), Rect::EMPTY] {
let n = area.width() as usize * area.height() as usize;
let mask: Vec<f32> = (0..n).map(|i| values[i % values.len()]).collect();
for default in [0.0, 0.5, 1.0] {
let mut old = Surface::with_default(PixelFormat::GRAY8, &[default]);
old.fill_rect(Rect::new(-300, -20, 300, 270), &[0.75]);
old.write_pixel(1024, 1024, &[1.0]);
old.write_pixel(-257, 0, &[0.0]);
let snapshot = old.clone();
for prev in [None, Some(&old)] {
for len in [0, n / 2, n] {
for mode in MODES {
let mask = &mask[..len];
assert_eq!(combine(prev, mask, area, mode), reference(prev, mask, area, mode), "{area:?} {mode:?} {default} {len}");
assert_eq!(combine_band(prev, mask, area, mode), reference_band(prev, mask, area, mode), "band {area:?} {mode:?} {default} {len}");
}
}
}
assert_eq!(old, snapshot, "combining must leave undo snapshots unchanged");
}
}
}
#[test]
fn unusual_selection_formats_keep_their_float_behavior() {
let area = Rect::new(-2, -1, 3, 2);
for sample in SampleType::ALL {
for color in [ColorMode::Grayscale, ColorMode::Rgb] {
let mut old = Surface::with_default(PixelFormat::new(color, sample, false), &vec![0.123; if color == ColorMode::Rgb { 3 } else { 1 }]);
old.write_pixel(0, 0, &vec![0.754; old.channels()]);
for mode in MODES {
let mask = [0.5; 15];
assert_eq!(combine(Some(&old), &mask, area, mode), reference(Some(&old), &mask, area, mode));
if color == ColorMode::Grayscale {
assert_eq!(combine_band(Some(&old), &mask, area, mode), reference_band(Some(&old), &mask, area, mode));
} else {
let out = combine_band(Some(&old), &mask, area, mode).unwrap();
let inside = reference(Some(&old), &mask, area, mode).unwrap();
let expected = inside.pixel(0, 0)[0];
let encoded = Surface::with_default(out.format(), &vec![expected; out.channels()]);
assert_eq!(out.pixel(0, 0), encoded.pixel(0, 0));
if matches!(mode, SelectionMode::Add | SelectionMode::Subtract) {
assert_eq!(out.pixel(20, 20), old.pixel(20, 20));
}
}
}
}
}
}
#[test]
fn region_bytes_match_float_bands() {
let bbox = Rect::new(-257, 255, 258, 258);
let mask: Vec<u8> = (0..bbox.width() * bbox.height()).map(|i| (i % 256) as u8).collect();
let floats: Vec<f32> = mask.iter().map(|b| f32::from(*b) / 255.0).collect();
let region = Region { bbox, mask };
let mut old = Surface::new(PixelFormat::GRAY8);
old.fill_rect(Rect::new(-300, 250, 300, 270), &[0.75]);
for prev in [None, Some(&old)] {
for mode in MODES {
assert_eq!(combine_region(prev, Some(&region), mode), reference_band(prev, &floats, bbox, mode));
assert_eq!(combine_region(prev, None, mode), reference_band(prev, &[], Rect::EMPTY, mode));
}
}
}
#[test]
fn tiles_stay_shared_or_are_pruned() {
let area = Rect::new(0, 0, 768, 256);
let mut old = Surface::new(PixelFormat::GRAY8);
old.fill_rect(area, &[1.0]);
let tc = TileCoord::new(0, 0);
let unchanged = combine(Some(&old), &[], area, SelectionMode::Add).unwrap();
assert!(Arc::ptr_eq(unchanged.tile(tc).unwrap(), old.tile(tc).unwrap()));
let band = Rect::new(257, 1, 260, 3);
let changed = combine_band(Some(&old), &[1.0; 6], band, SelectionMode::Subtract).unwrap();
assert!(Arc::ptr_eq(changed.tile(tc).unwrap(), old.tile(tc).unwrap()));
assert_eq!(changed.pixel(256, 1), vec![1.0]);
assert_eq!(changed.pixel(257, 1), vec![0.0]);
assert_eq!(changed.pixel(260, 1), vec![1.0]);
let full = combine(None, &vec![1.0; 768 * 256], area, SelectionMode::Replace).unwrap();
assert!(Arc::ptr_eq(full.tile(tc).unwrap(), full.tile(TileCoord::new(1, 0)).unwrap()));
assert!(combine_band(Some(&old), &vec![1.0; 768 * 256], area, SelectionMode::Subtract).is_none());
assert!(combine(None, &[], area, SelectionMode::Replace).is_none());
}
/// Run only in release on CI: compares the former implementation, including dense reads and
/// band writeback, against the byte path with identical 36 MP inputs and exact output checks.
#[test]
#[ignore = "36 MP release performance comparison; run on GitHub Actions"]
fn gray8_combining_release_comparison() {
use std::{hint::black_box, time::Instant};
let area = Rect::new(0, 0, 6000, 6000);
let mut old = Surface::new(PixelFormat::GRAY8);
old.fill_rect(Rect::new(100, 100, 5900, 5900), &[0.75]);
println!("36 MP; median of three release runs including allocation; exact output checks; previous dense mask alone = 144000000 bytes");
for (case, size) in [("sparse", 50), ("dense", 4000), ("full", 6000)] {
let mut mask = vec![0.0; 6000 * 6000];
let start = (6000 - size) / 2;
for row in mask.as_chunks_mut::<6000>().0.iter_mut().skip(start).take(size) {
row[start..start + size].fill(0.5);
}
for mode in MODES {
for band in [false, true] {
let baseline = if band { reference_band } else { reference };
let direct = if band { combine_band } else { combine };
assert_eq!(direct(Some(&old), &mask, area, mode), baseline(Some(&old), &mask, area, mode));
let mut medians = [0.0; 2];
for (median, op) in medians.iter_mut().zip([baseline, direct]) {
let mut runs = [0.0; 3];
for ms in &mut runs {
let start = Instant::now();
let out = black_box(op(Some(black_box(&old)), black_box(&mask), area, mode));
*ms = start.elapsed().as_secs_f64() * 1000.0;
drop(out);
}
runs.sort_by(f64::total_cmp);
*median = runs[1];
}
println!("{case} {mode:?} band={band}: old={:.2}ms new={:.2}ms speedup={:.2}x exact=true", medians[0], medians[1], medians[0] / medians[1]);
}
}
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Tile-wise selection inversion, with shared solid tiles and no canvas-sized buffers.
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use photocraft_color::{PixelFormat, read_sample};
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::{Surface, Tile};
use crate::photo_util::par_map;
use super::coverage8;
/// Invert coverage within `area`, returning a zero-default GRAY8 selection with nothing
/// outside it. Missing tiles become shared solid tiles; selected solid tiles disappear.
/// Each distinct interior source tile is inverted only once, preserving sharing and snapshots.
/// Selections are GRAY8; other formats use their first channel, quantized to selection coverage.
pub fn inverse_surface(source: &Surface, area: Rect) -> Surface {
let mut out = Surface::new(PixelFormat::GRAY8);
if area.is_empty() {
return out;
}
let (blank, full) = (out.solid_tile(&[0.0]), out.solid_tile(&[1.0]));
let default = coverage8(1.0 - source.default_pixel().first().copied().unwrap_or(0.0));
let missing = match default {
0 => None,
255 => Some(Arc::clone(&full)),
v => Some(out.solid_tile(&[f32::from(v) / 255.0])),
};
// Solid fills share one payload across many coordinates. Cache by payload, rather than
// scanning/copying the same 64 KiB for every tile. OnceLock allows parallel tile workers.
let cache: HashMap<_, OnceLock<Option<Arc<Tile>>>> =
source.tiles().filter(|(c, _)| area.contains_rect(&c.rect())).map(|(_, t)| (Arc::as_ptr(t) as usize, OnceLock::new())).collect();
let format = source.format();
let value = |t: &Tile, i: usize| {
if format == PixelFormat::GRAY8 {
t.bytes().get(i).map_or(default, |b| 255 - b)
} else {
let bpp = format.bytes_per_pixel();
t.bytes().get(i * bpp..(i + 1) * bpp).map_or(default, |pixel| coverage8(1.0 - read_sample(pixel, format.sample, 0)))
}
};
let coords: Vec<TileCoord> = area.tiles().collect();
let built = par_map(coords.len(), |k| {
let tc = *coords.get(k)?;
let rect = tc.rect();
let cut = rect.intersect(&area);
let input = source.tile(tc);
// At i32::MAX the coordinate rectangle is saturated and omits the tile's last
// column/row, so it must still take the clipped path.
if cut == rect && cut.width() == TILE_SIZE as u32 && cut.height() == TILE_SIZE as u32 {
let Some(input) = input else { return missing.clone().map(|t| (tc, t)) };
let cell = cache.get(&(Arc::as_ptr(input) as usize))?;
let tile = cell.get_or_init(|| {
if format == PixelFormat::GRAY8 {
if input.bytes() == full.bytes() {
return None;
}
if input.bytes() == blank.bytes() {
return Some(Arc::clone(&full));
}
let mut tile = Tile::clone(input);
for b in tile.bytes_mut() {
*b = 255 - *b;
}
return Some(Arc::new(tile));
}
let mut tile = Tile::clone(&blank);
for (i, b) in tile.bytes_mut().iter_mut().enumerate() {
*b = value(input, i);
}
canonical(tile, &blank, &full)
});
return tile.clone().map(|t| (tc, t));
}
// Boundary tiles start at zero so no inverted coverage leaks past the canvas.
let mut tile = Tile::clone(&blank);
let (x0, x1) = ((cut.x0 - rect.x0) as usize, (cut.x1 - rect.x0) as usize);
for y in (cut.y0 - rect.y0) as usize..(cut.y1 - rect.y0) as usize {
let start = y * TILE_SIZE as usize + x0;
if let Some(row) = tile.bytes_mut().get_mut(start..start + x1 - x0) {
for (i, b) in (start..).zip(row) {
*b = input.map_or(default, |t| value(t, i));
}
}
}
canonical(tile, &blank, &full).map(|t| (tc, t))
});
out.put_tiles(built.into_iter().flatten());
out
}
fn canonical(tile: Tile, blank: &Arc<Tile>, full: &Arc<Tile>) -> Option<Arc<Tile>> {
if tile.bytes() == blank.bytes() {
None
} else if tile.bytes() == full.bytes() {
Some(Arc::clone(full))
} else {
Some(Arc::new(tile))
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,85 @@
use super::*;
use photocraft_color::{ColorMode, SampleType};
fn reference(source: &Surface, area: Rect) -> Surface {
let data: Vec<f32> = source.read_region(area).chunks_exact(source.channels()).map(|p| 1.0 - p[0]).collect();
let mut out = Surface::new(PixelFormat::GRAY8);
out.write_region(area, &data);
out.prune();
out
}
#[test]
fn inverse_matches_float_reference_and_clips_boundary_tiles() {
// Negative, misaligned and empty bounds, including stored pixels beyond the canvas.
for area in [Rect::new(-17, -11, 530, 270), Rect::new(0, 0, 512, 256), Rect::new(3, 5, 4, 6), Rect::EMPTY] {
for sample in SampleType::ALL {
for default in [0.0, 0.25, 1.0] {
let fmt = PixelFormat::new(ColorMode::Grayscale, sample, false);
let mut source = Surface::with_default(fmt, &[default]);
source.fill_rect(Rect::new(-100, -100, 260, 120), &[1.0]);
// Every encoded GRAY8 value, including feathered coverage at tile seams.
let values: Vec<f32> = (0..256).map(|v| v as f32 / 255.0).collect();
source.write_region(Rect::new(240, 125, 496, 126), &values);
source.write_pixel(750, 280, &[0.7]);
let got = inverse_surface(&source, area);
let want = reference(&source, area);
let window = Rect::new(-30, -20, 800, 290);
assert_eq!(got.to_interleaved(window), want.to_interleaved(window), "{sample:?} {default} {area:?}");
assert_eq!(got.default_bytes(), &[0]);
assert_eq!(got.tile_count(), want.tile_count(), "zero tiles must be pruned");
}
}
}
}
#[test]
fn inverse_preserves_sparse_storage_and_shared_payloads() {
let area = Rect::new(0, 0, 2048, 1024);
let empty = Surface::new(PixelFormat::GRAY8);
let selected = inverse_surface(&empty, area);
assert_eq!(selected.tile_count(), 32);
let full = selected.tile(TileCoord::new(0, 0)).unwrap();
assert!(selected.tiles().all(|(_, t)| Arc::ptr_eq(t, full)), "missing tiles share one solid payload");
let cleared = inverse_surface(&selected, area);
assert_eq!(cleared.tile_count(), 0, "fully selected tiles disappear");
let mut mixed = Surface::new(PixelFormat::GRAY8);
mixed.write_pixel(10, 11, &[0.25]);
let payload = mixed.tile(TileCoord::new(0, 0)).unwrap().clone();
mixed.put_tiles(area.tiles().map(|c| (c, payload.clone())));
let snapshot = mixed.clone();
let inverted = inverse_surface(&mixed, area);
let result = inverted.tile(TileCoord::new(0, 0)).unwrap();
assert!(inverted.tiles().all(|(_, t)| Arc::ptr_eq(t, result)), "shared mixed tiles are inverted once");
assert!(!Arc::ptr_eq(result, &payload));
assert_eq!(mixed.to_interleaved(area), snapshot.to_interleaved(area));
assert_eq!(inverse_surface(&inverted, area).to_interleaved(area), mixed.to_interleaved(area));
}
#[test]
fn inverse_of_unpruned_zero_tiles_is_shared_full() {
let mut source = Surface::new(PixelFormat::GRAY8);
let area = Rect::new(0, 0, 768, 256);
source.fill_rect(Rect::new(0, 0, 256, 256), &[0.0]);
source.fill_rect(Rect::new(256, 0, 512, 256), &[1.0]);
let out = inverse_surface(&source, area);
assert_eq!(out.tile_count(), 2);
assert!(out.tile(TileCoord::new(1, 0)).is_none());
assert!(Arc::ptr_eq(out.tile(TileCoord::new(0, 0)).unwrap(), out.tile(TileCoord::new(2, 0)).unwrap()));
}
#[test]
fn inverse_clips_at_coordinate_limits_and_uses_first_channel() {
for area in [Rect::new(i32::MAX - 255, 0, i32::MAX, 256), Rect::new(0, i32::MAX - 255, 256, i32::MAX), Rect::new(i32::MIN, -1, i32::MIN + 3, 2)] {
let source = Surface::new(PixelFormat::GRAY8);
let out = inverse_surface(&source, area);
assert_eq!(out.to_interleaved(area), reference(&source, area).to_interleaved(area));
assert_eq!(out.sample_channel(area.x1, area.y0, 0), 0.0);
assert_eq!(out.sample_channel(area.x0, area.y1, 0), 0.0);
}
let area = Rect::new(0, 0, 513, 260);
let mut source = Surface::with_default(PixelFormat::RGBA32F, &[0.25, 0.5, 0.75, 1.0]);
source.fill_rect(Rect::new(10, 10, 400, 200), &[0.75, 1.0, 0.0, 0.5]);
assert_eq!(inverse_surface(&source, area).to_interleaved(area), reference(&source, area).to_interleaved(area));
}
+382 -83
View File
@@ -22,7 +22,7 @@ use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
use crate::photo_util::SparseLs;
use crate::photo_util::{BandChol, Csr, par_rows, pcg, solve_dense};
use crate::transform::Interp;
/// Lens projection of the source image.
@@ -234,95 +234,293 @@ impl WideMesh {
}
}
/// Solves the content-preserving warp for `p` over `frame`.
pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh {
let cam = Camera::new(p, frame);
let (fw, fh) = (frame.width().max(1) as f64, frame.height().max(1) as f64);
let cells = 48.0;
let cell = fw.max(fh) / cells;
let nx = ((fw / cell).round() as usize).clamp(2, 96);
let ny = ((fh / cell).round() as usize).clamp(2, 96);
let (cw, ch) = (fw / nx as f64, fh / ny as f64);
let w1 = nx + 1;
let nv = w1 * (ny + 1);
let src: Vec<[f64; 2]> = (0..nv).map(|k| [frame.x0 as f64 + (k % w1) as f64 * cw, frame.y0 as f64 + (k / w1) as f64 * ch]).collect();
let curves: Vec<Vec<[f64; 2]>> = p.constraints.iter().map(|c| cam.arc(c.a, c.b, 24)).collect();
let mut x: Vec<f64> = src.iter().flat_map(|s| [s[0], s[1]]).collect();
// Bilinear weights of a source point in the grid.
let weights = |q: [f64; 2]| -> [(usize, f64); 4] {
let gx = ((q[0] - frame.x0 as f64) / cw).clamp(0.0, nx as f64 - 1e-9);
let gy = ((q[1] - frame.y0 as f64) / ch).clamp(0.0, ny as f64 - 1e-9);
/// Weight of the line rows (applied squared in the normal equations).
const LINE_WEIGHT: f64 = 10.0;
/// Weight of the weak anchoring to the source (it fixes the global similarity).
const ANCHOR_WEIGHT: f64 = 0.02;
/// Grid cells along the longer side of the frame.
const GRID_CELLS: f64 = 48.0;
/// A sparse row `Σ coef·x[idx]`.
type Row = Vec<(usize, f64)>;
/// A solver for `(K + LᵀL) x = rhs` given the grid, the line rows `L` and a warm start.
type LinSolve<'a> = dyn FnMut(&Grid, &[Row], &mut Vec<f64>) + 'a;
/// The warp's grid over a frame; the unknowns are the output positions of its vertices,
/// interleaved `[x0, y0, x1, y1, …]`.
struct Grid {
frame: Rect,
nx: usize,
ny: usize,
cw: f64,
ch: f64,
w1: usize,
nv: usize,
/// Source position of each vertex.
src: Vec<[f64; 2]>,
}
impl Grid {
fn new(frame: Rect, cells: f64) -> Grid {
let (fw, fh) = (frame.width().max(1) as f64, frame.height().max(1) as f64);
let cell = fw.max(fh) / cells;
let nx = ((fw / cell).round() as usize).clamp(2, 96);
let ny = ((fh / cell).round() as usize).clamp(2, 96);
let (cw, ch) = (fw / nx as f64, fh / ny as f64);
let w1 = nx + 1;
let nv = w1 * (ny + 1);
let src = (0..nv).map(|k| [frame.x0 as f64 + (k % w1) as f64 * cw, frame.y0 as f64 + (k / w1) as f64 * ch]).collect();
Grid { frame, nx, ny, cw, ch, w1, nv, src }
}
/// Bilinear weights of a source point in the grid.
fn weights(&self, q: [f64; 2]) -> [(usize, f64); 4] {
let gx = ((q[0] - self.frame.x0 as f64) / self.cw).clamp(0.0, self.nx as f64 - 1e-9);
let gy = ((q[1] - self.frame.y0 as f64) / self.ch).clamp(0.0, self.ny as f64 - 1e-9);
let (i, j) = (gx.floor() as usize, gy.floor() as usize);
let (tx, ty) = (gx - i as f64, gy - j as f64);
let a = j * w1 + i;
[(a, (1.0 - tx) * (1.0 - ty)), (a + 1, tx * (1.0 - ty)), (a + w1, (1.0 - tx) * ty), (a + w1 + 1, tx * ty)]
};
let tris = {
let mut t = Vec::new();
for j in 0..ny {
for i in 0..nx {
let a = j * w1 + i;
t.push([a, a + 1, a + w1 + 1]);
t.push([a, a + w1 + 1, a + w1]);
// Both diagonals so the shape term is symmetric.
t.push([a, a + 1, a + w1]);
t.push([a + 1, a + w1 + 1, a + w1]);
let a = j * self.w1 + i;
[(a, (1.0 - tx) * (1.0 - ty)), (a + 1, tx * (1.0 - ty)), (a + self.w1, (1.0 - tx) * ty), (a + self.w1 + 1, tx * ty)]
}
/// The anchoring right-hand side `ANCHOR_WEIGHT² · src`, interleaved.
fn anchor_rhs(&self) -> Vec<f64> {
self.src.iter().flat_map(|s| [ANCHOR_WEIGHT * ANCHOR_WEIGHT * s[0], ANCHOR_WEIGHT * ANCHOR_WEIGHT * s[1]]).collect()
}
}
/// The two shape rows (x then y) that pin vertex `i0` to `v1 + u (v2 − v1) + v R90 (v2 − v1)`,
/// with `(u, v)` taken from the source positions. Vertex ids are whatever the caller numbers its
/// unknowns by; the rows' right-hand sides are zero.
fn shape_rows(i: [usize; 3], p: [[f64; 2]; 3]) -> [[(usize, f64); 5]; 2] {
let (e, d) = ([p[2][0] - p[1][0], p[2][1] - p[1][1]], [p[0][0] - p[1][0], p[0][1] - p[1][1]]);
let l2 = e[0] * e[0] + e[1] * e[1];
let u = (d[0] * e[0] + d[1] * e[1]) / l2;
let v = (d[0] * -e[1] + d[1] * e[0]) / l2;
let (i0, i1, i2) = (i[0], i[1], i[2]);
[
// x: v0x − v1x − u (v2x − v1x) + v (v2y − v1y) = 0
[(2 * i0, 1.0), (2 * i1, -1.0 + u), (2 * i2, -u), (2 * i2 + 1, v), (2 * i1 + 1, -v)],
// y: v0y − v1y − u (v2y − v1y) − v (v2x − v1x) = 0
[(2 * i0 + 1, 1.0), (2 * i1 + 1, -1.0 + u), (2 * i2 + 1, -u), (2 * i2, -v), (2 * i1, v)],
]
}
/// The four triangles of a cell over its corners `[a, a + 1, a + w1, a + w1 + 1]` (as 0..4);
/// both diagonals are used so the shape term is symmetric.
const CELL_TRIS: [[usize; 3]; 4] = [[0, 1, 3], [0, 3, 2], [0, 1, 2], [1, 3, 2]];
/// Every shape row of the grid (with global vertex ids), for reference solves.
#[cfg(test)]
fn all_shape_rows(g: &Grid) -> Vec<Row> {
let mut out = Vec::new();
for j in 0..g.ny {
for i in 0..g.nx {
let a = j * g.w1 + i;
let corner = [a, a + 1, a + g.w1, a + g.w1 + 1];
for t in CELL_TRIS {
for rot in 0..3 {
let ids = [corner[t[rot]], corner[t[(rot + 1) % 3]], corner[t[(rot + 2) % 3]]];
for row in shape_rows(ids, ids.map(|k| g.src[k])) {
out.push(row.to_vec());
}
}
}
}
t
};
}
out
}
/// The line rows (already multiplied by `LINE_WEIGHT`): every sample of a constraint's curve is
/// tied to the curve's first point along the constraint's normal.
fn line_rows(g: &Grid, curves: &[Vec<[f64; 2]>], active: &[usize], normals: &[[f64; 2]], p: &WideAngle) -> Vec<Row> {
let mut rows = Vec::new();
for (ci, &k) in active.iter().enumerate() {
let Some(c) = curves.get(k) else { continue };
let n = match p.constraints.get(k).map(|c| c.orientation) {
Some(Orientation::Horizontal) => [0.0, 1.0],
Some(Orientation::Vertical) => [1.0, 0.0],
_ => normals.get(ci).copied().unwrap_or([0.0, 1.0]),
};
let Some(first) = c.first() else { continue };
let w0 = g.weights(*first);
for q in c.iter().skip(1) {
let mut row: Row = Vec::with_capacity(16);
for &(i, w) in &g.weights(*q) {
row.push((2 * i, LINE_WEIGHT * n[0] * w));
row.push((2 * i + 1, LINE_WEIGHT * n[1] * w));
}
for &(i, w) in &w0 {
row.push((2 * i, -LINE_WEIGHT * n[0] * w));
row.push((2 * i + 1, -LINE_WEIGHT * n[1] * w));
}
rows.push(row);
}
}
rows
}
/// Above this many line rows the low-rank update's dense `rows × rows` system gets too big and
/// the solve falls back to conjugate gradients.
const MAX_UPDATE_ROWS: usize = 256;
/// The part of the normal equations that doesn't depend on the constraints: the shape and
/// anchor terms `K = Σ rᵀr + ANCHOR_WEIGHT²·I`, assembled and factored once per solve.
struct StaticSystem {
k: Csr,
chol: Option<BandChol>,
rhs: Vec<f64>,
/// `K⁻¹ · rhs`, the solution without any line rows.
base: Vec<f64>,
}
impl StaticSystem {
fn new(g: &Grid) -> StaticSystem {
let n = 2 * g.nv;
let mut trip: Vec<(usize, usize, f64)> = Vec::with_capacity(g.nx * g.ny * 64 + n);
for j in 0..g.ny {
for i in 0..g.nx {
let a = j * g.w1 + i;
let corner = [a, a + 1, a + g.w1, a + g.w1 + 1];
// The cell's 24 shape rows only involve its own 8 unknowns: accumulate their
// 8 × 8 normal block (unknown `2·c + axis` is corner `c`'s coordinate).
let mut m = [[0.0f64; 8]; 8];
for t in CELL_TRIS {
for rot in 0..3 {
let local = [t[rot], t[(rot + 1) % 3], t[(rot + 2) % 3]];
for row in shape_rows(local, local.map(|c| g.src[corner[c]])) {
for &(si, ca) in &row {
for &(sj, cb) in &row {
m[si][sj] += ca * cb;
}
}
}
}
}
for (si, mi) in m.iter().enumerate() {
for (sj, &v) in mi.iter().enumerate() {
trip.push((2 * corner[si / 2] + si % 2, 2 * corner[sj / 2] + sj % 2, v));
}
}
}
}
for d in 0..n {
trip.push((d, d, ANCHOR_WEIGHT * ANCHOR_WEIGHT));
}
let k = Csr::from_triplets(n, trip);
let chol = BandChol::new(&k);
let rhs = g.anchor_rhs();
let mut base = vec![0.0; n];
if let Some(c) = &chol {
c.solve(&rhs, &mut base);
}
StaticSystem { k, chol, rhs, base }
}
/// Solves `(K + LᵀL) x = rhs` for the line rows `L = lines`. With `K`'s factor this is the
/// exact low-rank update `x = K⁻¹b − K⁻¹Lᵀ (I + L K⁻¹ Lᵀ)⁻¹ L K⁻¹b` (one parallel solve per
/// line row). Without it, or with very many rows, conjugate gradients warm-started from `x`.
/// A solve that goes non-finite leaves `x` as it was.
fn solve(&self, lines: &[Row], x: &mut Vec<f64>) {
let n = self.rhs.len();
let updated = match &self.chol {
Some(chol) if lines.len() <= MAX_UPDATE_ROWS => self.solve_update(chol, lines),
_ => None,
};
let solved = match updated {
Some(v) => v,
None => {
let mut v = x.clone();
self.solve_cg(lines, &mut v);
v
}
};
if solved.len() == n && solved.iter().all(|v| v.is_finite()) {
*x = solved;
}
}
fn solve_update(&self, chol: &BandChol, lines: &[Row]) -> Option<Vec<f64>> {
let (n, m) = (self.rhs.len(), lines.len());
if m == 0 {
return Some(self.base.clone());
}
// Column `j` of `K⁻¹Lᵀ` is `K⁻¹` applied to line row `j`.
let mut cols = vec![0.0; n * m];
par_rows(&mut cols, n, 1, |j, col| {
let mut dense = vec![0.0; n];
for &(i, c) in lines.get(j).into_iter().flatten() {
if let Some(d) = dense.get_mut(i) {
*d += c;
}
}
chol.solve(&dense, col);
});
let dot_row = |row: &Row, v: &[f64]| -> f64 { row.iter().map(|&(i, c)| c * v.get(i).copied().unwrap_or(0.0)).sum() };
// S = I + L K⁻¹ Lᵀ, and t = L K⁻¹ b.
let mut s = vec![0.0; m * m];
for (a, row) in lines.iter().enumerate() {
for (b, col) in cols.chunks(n).enumerate() {
s[a * m + b] = dot_row(row, col) + if a == b { 1.0 } else { 0.0 };
}
}
let t: Vec<f64> = lines.iter().map(|row| dot_row(row, &self.base)).collect();
let z = solve_dense(m, s, t)?;
let mut x = self.base.clone();
for (zj, col) in z.iter().zip(cols.chunks(n)) {
for (xi, ci) in x.iter_mut().zip(col) {
*xi -= zj * ci;
}
}
Some(x)
}
fn solve_cg(&self, lines: &[Row], x: &mut [f64]) {
let op = |v: &[f64], out: &mut [f64]| {
self.k.mul(v, out);
for row in lines {
let t: f64 = row.iter().map(|&(i, c)| c * v.get(i).copied().unwrap_or(0.0)).sum();
for &(i, c) in row {
if let Some(o) = out.get_mut(i) {
*o += c * t;
}
}
}
};
match &self.chol {
Some(chol) => {
pcg(op, |r, z| chol.solve(r, z), &self.rhs, x, 1e-8, 500);
}
// The band was too wide to factor: Jacobi-preconditioned, and many more iterations.
None => {
let diag = &self.k.diag;
pcg(op, |r, z| z.iter_mut().zip(r.iter().zip(diag)).for_each(|(z, (r, d))| *z = r / d), &self.rhs, x, 1e-8, 4000);
}
}
}
}
/// Solves the content-preserving warp for `p` over `frame`.
pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh {
let mut system: Option<StaticSystem> = None;
solve_with(p, frame, GRID_CELLS, &mut |g, lines, x| system.get_or_insert_with(|| StaticSystem::new(g)).solve(lines, x))
}
/// [`solve`] with the grid resolution and the linear solver for `(K + LᵀL) x = rhs` (given the
/// current line rows and a warm start) chosen by the caller.
fn solve_with(p: &WideAngle, frame: Rect, cells: f64, linsolve: &mut LinSolve<'_>) -> WideMesh {
let cam = Camera::new(p, frame);
let g = Grid::new(frame, cells);
let (nx, ny, nv, src) = (g.nx, g.ny, g.nv, &g.src);
let curves: Vec<Vec<[f64; 2]>> = p.constraints.iter().map(|c| cam.arc(c.a, c.b, 24)).collect();
let mut x: Vec<f64> = src.iter().flat_map(|s| [s[0], s[1]]).collect();
let active: Vec<usize> = (0..curves.len()).filter(|&k| curves[k].len() >= 3).collect();
if !active.is_empty() {
let mut normals: Vec<[f64; 2]> = active.iter().map(|_| [0.0, 1.0]).collect();
for round in 0..4 {
let mut ls = SparseLs::new(2 * nv);
// Shape: v0 = v1 + u (v2 − v1) + v R90 (v2 − v1), with (u, v) from the source.
for t in &tris {
for rot in 0..3 {
let (i0, i1, i2) = (t[rot], t[(rot + 1) % 3], t[(rot + 2) % 3]);
let (p0, p1, p2) = (src[i0], src[i1], src[i2]);
let e = [p2[0] - p1[0], p2[1] - p1[1]];
let l2 = e[0] * e[0] + e[1] * e[1];
let d = [p0[0] - p1[0], p0[1] - p1[1]];
let u = (d[0] * e[0] + d[1] * e[1]) / l2;
let v = (d[0] * -e[1] + d[1] * e[0]) / l2;
// x: v0x − v1x − u (v2x − v1x) + v (v2y − v1y) = 0
ls.add(vec![(2 * i0, 1.0), (2 * i1, -1.0 + u), (2 * i2, -u), (2 * i2 + 1, v), (2 * i1 + 1, -v)], 0.0, 1.0);
// y: v0y − v1y − u (v2y − v1y) − v (v2x − v1x) = 0
ls.add(vec![(2 * i0 + 1, 1.0), (2 * i1 + 1, -1.0 + u), (2 * i2 + 1, -u), (2 * i2, -v), (2 * i1, v)], 0.0, 1.0);
}
}
// Weak anchoring to the source (fixes the global similarity).
for (k, s) in src.iter().enumerate() {
ls.add(vec![(2 * k, 1.0)], s[0], 0.02);
ls.add(vec![(2 * k + 1, 1.0)], s[1], 0.02);
}
// Lines.
for (ci, &k) in active.iter().enumerate() {
let c = &curves[k];
let n = match p.constraints[k].orientation {
Orientation::Horizontal => [0.0, 1.0],
Orientation::Vertical => [1.0, 0.0],
Orientation::Free => normals[ci],
};
let w0 = weights(c[0]);
for q in &c[1..] {
let wq = weights(*q);
let mut row: Vec<(usize, f64)> = Vec::with_capacity(16);
for &(i, w) in &wq {
row.push((2 * i, n[0] * w));
row.push((2 * i + 1, n[1] * w));
}
for &(i, w) in &w0 {
row.push((2 * i, -n[0] * w));
row.push((2 * i + 1, -n[1] * w));
}
ls.add(row, 0.0, 10.0);
}
}
x = ls.solve(&x, 400 + 200 * round);
linsolve(&g, &line_rows(&g, &curves, &active, &normals, p), &mut x);
// Re-estimate free normals from the current end points.
let pos = |q: [f64; 2]| -> [f64; 2] { weights(q).iter().fold([0.0, 0.0], |acc, &(i, w)| [acc[0] + w * x[2 * i], acc[1] + w * x[2 * i + 1]]) };
let pos = |q: [f64; 2]| -> [f64; 2] { g.weights(q).iter().fold([0.0, 0.0], |acc, &(i, w)| [acc[0] + w * x[2 * i], acc[1] + w * x[2 * i + 1]]) };
let mut changed = false;
for (ci, &k) in active.iter().enumerate() {
let c = &curves[k];
@@ -350,7 +548,7 @@ pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh {
let mut mesh = WideMesh { nx, ny, verts, curves, residual: 0.0 };
// Straightness residual: distance of curve samples from the chord of their mapped ends.
let mut worst = 0.0f64;
for (k, cv) in mesh.curves.iter().enumerate() {
for cv in mesh.curves.iter() {
if cv.len() < 3 {
continue;
}
@@ -361,7 +559,6 @@ pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh {
for q in &pts {
worst = worst.max(((q[0] - a[0]) * d[1] - (q[1] - a[1]) * d[0]).abs() / l);
}
let _ = k;
}
mesh.residual = worst;
mesh
@@ -433,6 +630,108 @@ mod tests {
assert!((c[0] - 300.0).abs() < 80.0 && (c[1] - 250.0).abs() < 40.0, "{c:?}");
}
/// The exact solution of the same normal equations (dense, small grids only).
fn dense_linsolve(g: &Grid, lines: &[Row], x: &mut Vec<f64>) {
use crate::photo_util::Normal;
let mut ne = Normal::new(2 * g.nv);
for row in all_shape_rows(g) {
ne.add(&row, 0.0, 1.0);
}
for k in 0..2 * g.nv {
ne.add(&[(k, 1.0)], g.src[k / 2][k % 2], ANCHOR_WEIGHT);
}
for row in lines {
ne.add(row, 0.0, 1.0);
}
*x = ne.solve().expect("the anchored system is positive definite");
}
#[test]
fn direct_solve_matches_the_dense_reference() {
let frame = Rect::new(0, 0, 240, 160);
for constraints in [
vec![
Constraint { a: [30.0, 40.0], b: [210.0, 40.0], orientation: Orientation::Horizontal },
Constraint { a: [40.0, 30.0], b: [45.0, 130.0], orientation: Orientation::Free },
],
vec![
Constraint { a: [20.0, 120.0], b: [220.0, 110.0], orientation: Orientation::Free },
Constraint { a: [200.0, 20.0], b: [205.0, 140.0], orientation: Orientation::Vertical },
],
] {
let p = WideAngle { model: WideModel::Fisheye, focal_length: 8.0, constraints, ..Default::default() };
let mut system: Option<StaticSystem> = None;
let fast = solve_with(&p, frame, 12.0, &mut |g, lines, x| system.get_or_insert_with(|| StaticSystem::new(g)).solve(lines, x));
let exact = solve_with(&p, frame, 12.0, &mut dense_linsolve);
assert_eq!(fast.verts.len(), exact.verts.len());
let worst = fast.verts.iter().zip(&exact.verts).map(|(a, b)| (a.0[0] - b.0[0]).abs().max((a.0[1] - b.0[1]).abs())).fold(0.0, f64::max);
assert!(worst < 0.05, "worst vertex difference {worst} px");
assert!(fast.residual <= exact.residual + 0.05, "residual {} vs exact {}", fast.residual, exact.residual);
}
}
/// More line rows than the low-rank update takes falls back to conjugate gradients, which
/// still agrees with the exact solution.
#[test]
fn many_constraints_use_the_conjugate_gradient_fallback() {
let frame = Rect::new(0, 0, 240, 160);
let constraints: Vec<Constraint> = (0..12)
.map(|k| Constraint {
a: [20.0 + 4.0 * k as f64, 15.0 + 10.0 * k as f64],
b: [210.0 - 3.0 * k as f64, 18.0 + 10.0 * k as f64],
orientation: Orientation::Horizontal,
})
.collect();
let p = WideAngle { model: WideModel::Fisheye, focal_length: 8.0, constraints, ..Default::default() };
let mut rows = 0;
let mut system: Option<StaticSystem> = None;
let fast = solve_with(&p, frame, 12.0, &mut |g, lines, x| {
rows = lines.len();
system.get_or_insert_with(|| StaticSystem::new(g)).solve(lines, x)
});
assert!(rows > MAX_UPDATE_ROWS, "{rows} line rows");
let exact = solve_with(&p, frame, 12.0, &mut dense_linsolve);
let worst = fast.verts.iter().zip(&exact.verts).map(|(a, b)| (a.0[0] - b.0[0]).abs().max((a.0[1] - b.0[1]).abs())).fold(0.0, f64::max);
assert!(worst < 0.05, "worst vertex difference {worst} px");
}
/// Without a banded factor (band too wide) the Jacobi-preconditioned fallback still solves.
#[test]
fn unfactored_system_falls_back_to_jacobi_cg() {
let frame = Rect::new(0, 0, 240, 160);
let p = WideAngle {
model: WideModel::Fisheye,
focal_length: 8.0,
constraints: vec![Constraint { a: [30.0, 40.0], b: [210.0, 40.0], orientation: Orientation::Horizontal }],
..Default::default()
};
let mut system: Option<StaticSystem> = None;
let jacobi = solve_with(&p, frame, 12.0, &mut |g, lines, x| {
let sys = system.get_or_insert_with(|| {
let mut sys = StaticSystem::new(g);
sys.chol = None;
sys
});
sys.solve(lines, x)
});
let exact = solve_with(&p, frame, 12.0, &mut dense_linsolve);
let worst = jacobi.verts.iter().zip(&exact.verts).map(|(a, b)| (a.0[0] - b.0[0]).abs().max((a.0[1] - b.0[1]).abs())).fold(0.0, f64::max);
assert!(worst < 0.1, "worst vertex difference {worst} px");
}
#[test]
fn hostile_frames_and_constraints_solve_to_finite_meshes() {
let wild = Constraint { a: [-1e6, 5e5], b: [1e6, -5e5], orientation: Orientation::Free };
let same = Constraint { a: [3.0, 3.0], b: [3.0, 3.0], orientation: Orientation::Free };
let nan = Constraint { a: [f64::NAN, 1.0], b: [2.0, f64::INFINITY], orientation: Orientation::Horizontal };
for frame in [Rect::new(0, 0, 1, 1), Rect::new(0, 0, 2, 200), Rect::new(0, 0, 400, 3), Rect::new(-50, -20, 90, 60)] {
let p =
WideAngle { model: WideModel::Fisheye, focal_length: 8.0, constraints: vec![wild.clone(), same.clone(), nan.clone()], ..Default::default() };
let mesh = solve(&p, frame);
assert!(mesh.verts.iter().all(|(o, s)| o.iter().chain(s).all(|v| v.is_finite())), "{frame:?}");
}
}
#[test]
fn no_constraints_is_identity_and_renders() {
use photocraft_color::PixelFormat;
+405 -101
View File
@@ -306,42 +306,73 @@ fn is_latin1_keyword(k: &str) -> bool {
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
let (w, h) = img.dimensions();
let color = match img.layout() {
let bpp = img.layout().channels() * img.sample_type().bytes();
// Big-endian sample bytes.
let mut data = std::borrow::Cow::Borrowed(img.data());
if img.sample_type() == SampleType::U16 {
to_big_endian(data.to_mut());
}
let mut out = Vec::new();
{
let mut writer = start(&mut out, (w, h), (img.layout(), img.sample_type()), img.icc.as_ref(), &img.meta, opts)?;
if opts.png_interlaced {
let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?;
write_idat(&mut writer, &idat, MAX_IDAT)?;
} else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, bpp, opts.png_compression) {
write_idat(&mut writer, &idat, MAX_IDAT)?;
} else {
writer.write_image_data(&data).map_err(|e| CodecError::encode(F, e))?;
}
writer.finish().map_err(|e| CodecError::encode(F, e))?;
}
Ok(out)
}
/// 16-bit native-endian samples to PNG's big-endian order, in place.
fn to_big_endian(data: &mut [u8]) {
for c in data.as_chunks_mut::<2>().0 {
let v = u16::from_ne_bytes([c[0], c[1]]);
c.copy_from_slice(&v.to_be_bytes());
}
}
/// Writes the signature and every chunk ahead of the image data for a `w` × `h` PNG of
/// `layout` and `sample` (already the encode plan's), with its profile and metadata.
fn start<W: Write>(
out: W,
(w, h): (u32, u32),
(layout, sample): (ChannelLayout, SampleType),
icc: Option<&Vec<u8>>,
meta: &Metadata,
opts: &EncodeOptions,
) -> Result<png::Writer<W>, CodecError> {
let color = match layout {
ChannelLayout::Gray => png::ColorType::Grayscale,
ChannelLayout::GrayA => png::ColorType::GrayscaleAlpha,
ChannelLayout::Rgb => png::ColorType::Rgb,
ChannelLayout::Rgba => png::ColorType::Rgba,
l => return Err(CodecError::encode(F, format!("unsupported layout {l:?}"))),
};
let depth = match img.sample_type() {
let depth = match sample {
SampleType::U8 => png::BitDepth::Eight,
SampleType::U16 => png::BitDepth::Sixteen,
s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))),
};
// Big-endian sample bytes.
let mut data = std::borrow::Cow::Borrowed(img.data());
if depth == png::BitDepth::Sixteen {
for c in data.to_mut().as_chunks_mut::<2>().0 {
let v = u16::from_ne_bytes([c[0], c[1]]);
c.copy_from_slice(&v.to_be_bytes());
}
}
let mut info = png::Info::with_size(w, h);
info.color_type = color;
info.bit_depth = depth;
info.interlaced = opts.png_interlaced;
if opts.embed_icc
&& let Some(icc) = &img.icc
&& let Some(icc) = icc
{
info.icc_profile = Some(icc.clone().into());
}
if opts.embed_metadata {
if let Some(exif) = &img.meta.exif {
if let Some(exif) = &meta.exif {
// The pixels are written as they are shown: never let a viewer rotate them again.
info.exif_metadata = Some(crate::resolution::export_exif(exif, img.meta.dpi).into_owned().into());
info.exif_metadata = Some(crate::resolution::export_exif(exif, meta.dpi).into_owned().into());
}
if let Some((x, y)) = img.meta.dpi
if let Some((x, y)) = meta.dpi
&& x > 0.0
&& y > 0.0
{
@@ -349,51 +380,150 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
Some(png::PixelDimensions { xppu: (x / METERS_PER_INCH).round() as u32, yppu: (y / METERS_PER_INCH).round() as u32, unit: png::Unit::Meter });
}
}
let mut out = Vec::new();
{
let mut encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
encoder.set_compression(match opts.png_compression {
PngCompression::None => png::Compression::NoCompression,
PngCompression::Fast => png::Compression::Fast,
PngCompression::Default => png::Compression::Balanced,
PngCompression::Best => png::Compression::High,
});
if opts.embed_metadata {
for (k, v) in &img.meta.text {
let latin1_text = v.chars().all(|c| (c as u32) < 256);
let res = if is_latin1_keyword(k) && latin1_text {
encoder.add_text_chunk(k.clone(), v.clone())
} else if is_latin1_keyword(k) {
encoder.add_itxt_chunk(k.clone(), v.clone())
} else {
continue;
};
res.map_err(|e| CodecError::encode(F, e))?;
}
if let Some(xmp) = &img.meta.xmp {
encoder
.add_itxt_chunk(XMP_KEYWORD.into(), crate::resolution::export_xmp(xmp, img.meta.dpi).into_owned())
.map_err(|e| CodecError::encode(F, e))?;
}
let mut encoder = png::Encoder::with_info(out, info).map_err(|e| CodecError::encode(F, e))?;
encoder.set_compression(match opts.png_compression {
PngCompression::None => png::Compression::NoCompression,
PngCompression::Fast => png::Compression::Fast,
PngCompression::Default => png::Compression::Balanced,
PngCompression::Best => png::Compression::High,
});
if opts.embed_metadata {
for (k, v) in &meta.text {
let latin1_text = v.chars().all(|c| (c as u32) < 256);
let res = if is_latin1_keyword(k) && latin1_text {
encoder.add_text_chunk(k.clone(), v.clone())
} else if is_latin1_keyword(k) {
encoder.add_itxt_chunk(k.clone(), v.clone())
} else {
continue;
};
res.map_err(|e| CodecError::encode(F, e))?;
}
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
if let Some(stereo) = opts.png_stereo {
// Ancillary chunk before IDAT, as the spec recommends for `sTER`.
writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?;
if let Some(xmp) = &meta.xmp {
encoder.add_itxt_chunk(XMP_KEYWORD.into(), crate::resolution::export_xmp(xmp, meta.dpi).into_owned()).map_err(|e| CodecError::encode(F, e))?;
}
if opts.png_interlaced {
let bpp = img.layout().channels() * img.sample_type().bytes();
let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?;
writer.write_chunk(png::chunk::IDAT, &idat).map_err(|e| CodecError::encode(F, e))?;
} else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, img.layout().channels() * img.sample_type().bytes(), opts.png_compression) {
writer.write_chunk(png::chunk::IDAT, &idat).map_err(|e| CodecError::encode(F, e))?;
} else {
writer.write_image_data(&data).map_err(|e| CodecError::encode(F, e))?;
}
writer.finish().map_err(|e| CodecError::encode(F, e))?;
}
Ok(out)
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
if let Some(stereo) = opts.png_stereo {
// Ancillary chunk before IDAT, as the spec recommends for `sTER`.
writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?;
}
Ok(writer)
}
/// Largest IDAT chunk written. A chunk holds at most 2^31 - 1 bytes, so a zlib stream past that
/// (a multi-gigapixel image) is split over several IDAT chunks, which readers join back.
const MAX_IDAT: usize = 1 << 30;
/// `zlib` as IDAT chunks of at most `max` bytes.
fn write_idat<W: Write>(writer: &mut png::Writer<W>, zlib: &[u8], max: usize) -> Result<(), CodecError> {
for part in zlib.chunks(max.max(1)) {
writer.write_chunk(png::chunk::IDAT, part).map_err(|e| CodecError::encode(F, e))?;
}
Ok(())
}
/// A PNG encoded a band of rows at a time, for images too large to hold whole (a multi-gigapixel
/// print): each band is converted to the encode plan, filtered and deflated on all cores as it
/// arrives and written as IDAT chunks, so only about one band is ever held. The deflate bands are
/// [`parallel_idat`]'s, so the zlib stream is the one [`crate::encode`] writes for a large image.
/// Never interlaced (Adam7 needs the whole image).
pub struct PngRows<W: Write> {
writer: png::Writer<W>,
plan: Plan,
width: u32,
height: u32,
/// Rows received so far.
rows: u32,
level: PngCompression,
/// Converted, big-endian rows that don't fill a deflate band yet.
pending: Vec<u8>,
/// The last row deflated: the row above `pending` for the filters.
prev: Vec<u8>,
adler: u32,
/// Whether the zlib header is written.
started: bool,
}
impl<W: Write> PngRows<W> {
/// Writes the header of a `first.width()` × `height` PNG to `out`. The layout, sample type,
/// profile and metadata come from `first`, the first band (not written yet: [`Self::push`]
/// it like the others), through the same plan as [`crate::encode`].
pub fn new(out: W, first: &Image, height: u32, opts: &EncodeOptions) -> Result<Self, CodecError> {
if opts.png_interlaced {
return Err(CodecError::unsupported(F, "an interlaced PNG can't be written a band of rows at a time"));
}
let width = first.width();
if width == 0 || height == 0 {
return Err(CodecError::InvalidImage("cannot encode an empty image".into()));
}
let plan = crate::fidelity::plan(first, F, opts);
let writer = start(out, (width, height), (plan.layout, plan.sample), first.icc.as_ref(), &first.meta, opts)?;
Ok(Self { writer, plan, width, height, rows: 0, level: opts.png_compression, pending: Vec::new(), prev: Vec::new(), adler: 1, started: false })
}
/// Encodes the next rows of the image: `band` is as wide as the image, in any layout and
/// sample type (converted like the first band).
pub fn push(&mut self, band: &Image) -> Result<(), CodecError> {
if band.width() != self.width {
return Err(CodecError::InvalidImage(format!("a {} px wide band for a {} px wide image", band.width(), self.width)));
}
let rows = self.rows.checked_add(band.height()).filter(|&r| r <= self.height);
let Some(rows) = rows else {
return Err(CodecError::InvalidImage(format!("more than the image's {} rows", self.height)));
};
let img = band.converted(self.plan.layout, self.plan.sample);
let at = self.pending.len();
self.pending.try_reserve(img.data().len()).map_err(|_| CodecError::encode(F, "not enough memory for a band of rows"))?;
self.pending.extend_from_slice(img.data());
if self.plan.sample == SampleType::U16 {
to_big_endian(self.pending.get_mut(at..).unwrap_or_default());
}
self.rows = rows;
self.deflate_pending()
}
/// Deflates the whole bands held, and the rest once every row is in.
fn deflate_pending(&mut self) -> Result<(), CodecError> {
let bpp = self.plan.layout.channels() * self.plan.sample.bytes();
let stride = self.width as usize * bpp;
let per_band = band_rows(stride);
let held = self.pending.len() / stride.max(1);
let finish = self.rows == self.height;
let take = if finish { held } else { held / per_band * per_band };
if take == 0 {
return Ok(());
}
let rows = self.pending.get(..take * stride).unwrap_or_default();
let prev = (!self.prev.is_empty()).then_some(self.prev.as_slice());
let parts = deflate_rows(rows, prev, stride, bpp, per_band, deflate_level(self.level), finish, encode_threads())
.ok_or_else(|| CodecError::encode(F, "a band of rows could not be compressed"))?;
let mut zlib = Vec::with_capacity(parts.iter().map(|p| p.z.len()).sum::<usize>() + 6);
if !self.started {
zlib.extend_from_slice(&zlib_header(self.level));
self.started = true;
}
for p in &parts {
zlib.extend_from_slice(&p.z);
self.adler = adler32_combine(self.adler, p.adler, p.len);
}
if finish {
zlib.extend_from_slice(&self.adler.to_be_bytes());
}
write_idat(&mut self.writer, &zlib, MAX_IDAT)?;
self.prev.clear();
self.prev.extend_from_slice(rows.get(rows.len() - stride..).unwrap_or_default());
self.pending.drain(..take * stride);
Ok(())
}
/// Ends the file. Fails when fewer rows than the image's height were pushed.
pub fn finish(self) -> Result<(), CodecError> {
if self.rows != self.height || !self.pending.is_empty() {
return Err(CodecError::InvalidImage(format!("{} of the image's {} rows were written", self.rows, self.height)));
}
self.writer.finish().map_err(|e| CodecError::encode(F, e))
}
}
/// Build the zlib stream for an Adam7-interlaced image (filter type 0).
@@ -417,13 +547,7 @@ fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression
y += dy;
}
}
let lvl = match level {
PngCompression::None => flate2::Compression::none(),
PngCompression::Fast => FAST,
PngCompression::Default => flate2::Compression::default(),
PngCompression::Best => flate2::Compression::best(),
};
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), lvl);
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), deflate_level(level));
z.write_all(&raw).map_err(|e| CodecError::encode(F, e))?;
z.finish().map_err(|e| CodecError::encode(F, e))
}
@@ -447,44 +571,108 @@ fn parallel_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompress
if cfg!(target_arch = "wasm32") || data.len() < PARALLEL_MIN_BYTES || level == PngCompression::None || w == 0 || h == 0 {
return None;
}
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32);
banded_idat(data, w, h, bpp, level, threads)
banded_idat(data, w, h, bpp, level, encode_threads())
}
/// Filtered bytes per deflate band. The bands depend only on the image, never on the number of
/// threads, so the same image and options give the same bytes whatever the machine's core count.
const BAND_BYTES: usize = 512 << 10;
/// The deflate effort for `level`.
fn deflate_level(level: PngCompression) -> flate2::Compression {
match level {
PngCompression::None => flate2::Compression::none(),
PngCompression::Fast => FAST,
PngCompression::Default => flate2::Compression::default(),
PngCompression::Best => flate2::Compression::best(),
}
}
/// The zlib stream header: deflate, 32 K window; FLEVEL only advertises the effort.
fn zlib_header(level: PngCompression) -> [u8; 2] {
match level {
PngCompression::None | PngCompression::Fast => [0x78, 0x01],
PngCompression::Best => [0x78, 0xDA],
PngCompression::Default => [0x78, 0x9C],
}
}
/// Rows per deflate band for rows of `stride` bytes.
fn band_rows(stride: usize) -> usize {
(BAND_BYTES / stride.saturating_add(1)).max(1)
}
/// Worker threads for the banded encoders: one on wasm (no threads), else the machine's cores.
fn encode_threads() -> usize {
if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32) }
}
/// [`parallel_idat`] on `threads` worker threads.
fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression, threads: usize) -> Option<Vec<u8>> {
let stride = w * bpp;
let band_rows = (BAND_BYTES / stride.saturating_add(1)).max(1);
let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows).map(|y| (y, (y + band_rows).min(h))).collect();
let lvl = match level {
PngCompression::Fast => FAST,
PngCompression::Best => flate2::Compression::best(),
_ => flate2::Compression::default(),
let parts = deflate_rows(data.get(..h * stride)?, None, stride, bpp, band_rows(stride), deflate_level(level), true, threads)?;
let mut out = Vec::with_capacity(parts.iter().map(|p| p.z.len()).sum::<usize>() + 6);
out.extend_from_slice(&zlib_header(level));
let mut adler = 1u32;
for p in &parts {
out.extend_from_slice(&p.z);
adler = adler32_combine(adler, p.adler, p.len);
}
out.extend_from_slice(&adler.to_be_bytes());
Some(out)
}
/// One band of rows filtered and deflated: the raw deflate bytes, and the Adler-32 and length of
/// the filtered bytes they hold.
struct Deflated {
z: Vec<u8>,
adler: u32,
len: usize,
}
/// `rows` (whole rows of `stride` bytes; `prev` is the row above the first, if any) filtered and
/// deflated in independent bands of `band_rows` rows on up to `threads` workers (inline for one).
/// The last band finishes the deflate stream when `finish`; every other band ends on a sync flush.
/// `None` when a band did not deflate in one call ([`deflate_band`]).
#[allow(clippy::too_many_arguments)]
fn deflate_rows(
rows: &[u8],
prev: Option<&[u8]>,
stride: usize,
bpp: usize,
band_rows: usize,
lvl: flate2::Compression,
finish: bool,
threads: usize,
) -> Option<Vec<Deflated>> {
let h = rows.len() / stride.max(1);
let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows.max(1)).map(|y| (y, (y + band_rows).min(h))).collect();
let band = |&(y0, y1): &(usize, usize)| -> Option<Deflated> {
let mut filtered = Vec::with_capacity((y1 - y0) * (stride + 1));
let mut scratch = vec![0u8; stride];
for y in y0..y1 {
let row = rows.get(y * stride..(y + 1) * stride)?;
let above = if y > 0 { rows.get((y - 1) * stride..y * stride) } else { prev };
filter_row(row, above, bpp, &mut scratch, &mut filtered);
}
let adler = adler32(1, &filtered);
let z = deflate_band(&filtered, lvl, finish && y1 == h)?;
Some(Deflated { z, adler, len: filtered.len() })
};
if threads <= 1 || bands.len() <= 1 {
return bands.iter().map(band).collect();
}
let next = std::sync::atomic::AtomicUsize::new(0);
type Band = (usize, Vec<u8>, u32, usize);
let mut parts: Vec<Band> = std::thread::scope(|sc| {
let workers: Vec<_> = (0..threads.min(bands.len()).max(1))
let mut parts: Vec<(usize, Deflated)> = std::thread::scope(|sc| {
let workers: Vec<_> = (0..threads.min(bands.len()))
.map(|_| {
sc.spawn(|| {
let mut out: Vec<Band> = Vec::new();
let mut out = Vec::new();
loop {
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let Some(&(y0, y1)) = bands.get(i) else { break };
let mut filtered = Vec::with_capacity((y1 - y0) * (stride + 1));
let mut scratch = vec![0u8; stride];
for y in y0..y1 {
let row = &data[y * stride..(y + 1) * stride];
let prev = (y > 0).then(|| &data[(y - 1) * stride..y * stride]);
filter_row(row, prev, bpp, &mut scratch, &mut filtered);
}
let adler = adler32(1, &filtered);
let Some(z) = deflate_band(&filtered, lvl, y1 == h) else { return Vec::new() };
out.push((i, z, adler, filtered.len()));
let Some(b) = bands.get(i) else { break };
let Some(d) = band(b) else { return Vec::new() };
out.push((i, d));
}
out
})
@@ -496,20 +684,7 @@ fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompressio
return None;
}
parts.sort_by_key(|p| p.0);
let mut out = Vec::with_capacity(parts.iter().map(|p| p.1.len()).sum::<usize>() + 6);
// zlib header: deflate, 32 K window; FLEVEL only advertises the effort.
out.extend_from_slice(match level {
PngCompression::Fast => &[0x78, 0x5E],
PngCompression::Best => &[0x78, 0xDA],
_ => &[0x78, 0x9C],
});
let mut adler = 1u32;
for (_, z, a, len) in &parts {
out.extend_from_slice(z);
adler = adler32_combine(adler, *a, *len);
}
out.extend_from_slice(&adler.to_be_bytes());
Some(out)
Some(parts.into_iter().map(|p| p.1).collect())
}
/// Raw deflate of one band: ends on a sync flush, or finishes the stream for the `last` band.
@@ -978,3 +1153,132 @@ mod parallel_tests {
}
}
}
#[cfg(test)]
mod streamed_tests {
use super::*;
/// (bytes ahead of the first IDAT, the IDAT payloads joined, how many IDAT chunks).
fn split(png: &[u8]) -> (Vec<u8>, Vec<u8>, usize) {
let (mut at, mut head, mut zlib, mut count) = (8, None, Vec::new(), 0);
while at + 12 <= png.len() {
let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize;
if &png[at + 4..at + 8] == b"IDAT" {
head.get_or_insert_with(|| png[..at].to_vec());
zlib.extend_from_slice(&png[at + 8..at + 8 + len]);
count += 1;
}
at += 12 + len;
}
(head.unwrap(), zlib, count)
}
fn noise(n: usize, seed: u32) -> Vec<u8> {
(0..n).map(|i| ((i / 3) as u32 ^ seed).wrapping_mul(2_654_435_761).rotate_left(i as u32 % 7) as u8 / 3 + (i % 97) as u8).collect()
}
/// `img` pushed in bands of `heights` rows (the last one takes the rest).
fn streamed(img: &Image, heights: &[u32], opts: &EncodeOptions) -> Vec<u8> {
let (w, h) = img.dimensions();
let row = img.data().len() / h as usize;
let mut bands = Vec::new();
let mut y = 0;
for &n in heights.iter().chain(std::iter::once(&u32::MAX)) {
let n = n.min(h - y);
if n == 0 {
continue;
}
let data = img.data()[y as usize * row..(y + n) as usize * row].to_vec();
bands.push(Image::from_raw(w, n, img.layout(), img.sample_type(), data).unwrap().with_icc(img.icc.clone()).with_meta(img.meta.clone()));
y += n;
}
let mut out = Vec::new();
let mut rows = PngRows::new(&mut out, &bands[0], h, opts).unwrap();
for b in &bands {
rows.push(b).unwrap();
}
rows.finish().unwrap();
out
}
/// Bands of any height give the zlib stream, header chunks and pixels the whole-image encoder
/// gives, at 8 and 16 bits, with a profile, resolution, text and XMP.
#[test]
fn streamed_rows_match_the_whole_image_encoder() {
for (layout, sample, ch, bytes) in [(ChannelLayout::Rgba, SampleType::U8, 4usize, 1usize), (ChannelLayout::Rgb, SampleType::U16, 3, 2)] {
let (w, h) = (1201u32, 1013u32);
let meta = Metadata {
dpi: Some((300.0, 300.0)),
text: vec![("Title".into(), "Tent".into())],
xmp: Some("<x:xmpmeta xmlns:x='adobe:ns:meta/'/>".into()),
..Default::default()
};
let img = Image::from_raw(w, h, layout, sample, noise(w as usize * h as usize * ch * bytes, 7))
.unwrap()
.with_icc(Some((0..600u32).map(|i| (i % 251) as u8).collect()))
.with_meta(meta);
for level in [PngCompression::Fast, PngCompression::Default] {
let opts = EncodeOptions { png_compression: level, ..Default::default() };
let whole = crate::encode(&img, Format::Png, &opts).unwrap();
let (head, zlib, _) = split(&whole);
for heights in [&[1u32, 77, 300][..], &[256, 256, 256], &[1013]] {
let png = streamed(&img, heights, &opts);
let (h2, z2, _) = split(&png);
assert!(h2 == head, "{layout:?} {level:?} {heights:?}: header chunks differ");
assert!(z2 == zlib, "{layout:?} {level:?} {heights:?}: zlib stream differs");
assert_eq!(decode(&png, &Limits::default()).unwrap().data(), img.data());
}
}
}
}
/// Small images, no compression and bands in another sample type (converted by the plan).
#[test]
fn small_uncompressed_and_converted_bands_round_trip() {
let img = Image::from_raw(3, 5, ChannelLayout::GrayA, SampleType::U8, noise(30, 3)).unwrap();
for level in [PngCompression::None, PngCompression::Best] {
let opts = EncodeOptions { png_compression: level, ..Default::default() };
let png = streamed(&img, &[2, 1], &opts);
assert_eq!(decode(&png, &Limits::default()).unwrap().data(), img.data(), "{level:?}");
}
// Float bands are written with 16 bits, as `encode` writes them.
let f = img.convert(ChannelLayout::GrayA, SampleType::F32);
let png = streamed(&f, &[4], &EncodeOptions::default());
let back = decode(&png, &Limits::default()).unwrap();
assert_eq!(back.sample_type(), SampleType::U16);
assert_eq!(back.convert(ChannelLayout::GrayA, SampleType::U8).data(), img.data());
}
/// A zlib stream longer than one chunk may hold is split over IDAT chunks that read back.
#[test]
fn long_streams_split_over_several_idat_chunks() {
let img = Image::from_raw(64, 40, ChannelLayout::Rgb, SampleType::U8, noise(64 * 40 * 3, 9)).unwrap();
let whole = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
let (_, zlib, _) = split(&whole);
let mut out = Vec::new();
let mut w = start(&mut out, (64, 40), (ChannelLayout::Rgb, SampleType::U8), None, &Metadata::default(), &EncodeOptions::default()).unwrap();
write_idat(&mut w, &zlib, 100).unwrap();
w.finish().unwrap();
let (_, joined, count) = split(&out);
assert_eq!(count, zlib.len().div_ceil(100));
assert!(joined == zlib);
assert_eq!(decode(&out, &Limits::default()).unwrap().data(), img.data());
}
/// Bands that don't fit the image, missing rows and interlacing are errors, not panics.
#[test]
fn bands_that_dont_fit_are_errors() {
let band = Image::from_raw(4, 2, ChannelLayout::Rgb, SampleType::U8, vec![0; 24]).unwrap();
let opts = EncodeOptions::default();
assert!(PngRows::new(Vec::new(), &band, 0, &opts).is_err());
assert!(PngRows::new(Vec::new(), &band, 3, &EncodeOptions { png_interlaced: true, ..Default::default() }).is_err());
let mut rows = PngRows::new(Vec::new(), &band, 3, &opts).unwrap();
rows.push(&band).unwrap();
// Two more rows than the image holds.
assert!(rows.push(&band).is_err());
let narrow = Image::from_raw(3, 1, ChannelLayout::Rgb, SampleType::U8, vec![0; 9]).unwrap();
assert!(rows.push(&narrow).is_err());
// Two of three rows.
assert!(rows.finish().is_err());
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ pub mod web;
pub use crate::cicp::Cicp;
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo};
pub use crate::codecs::png::{PngPalette, PngRows, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo};
pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian};
pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind};
pub use crate::error::CodecError;
+2 -2
View File
@@ -31,8 +31,8 @@ pub use color::{
use tone::LUT_SIZE;
pub(crate) use tone::apply_tone;
pub use tone::{
TONE_SLOPE_SPAN, contour_curve_lut, curve_lut, curve_lut_mode, curve_map_lut, float_tone_extrapolates, float_tone_identity, levels, levels_float,
levels_gamma, levels_q, modern_brightness, modern_contrast, modern_lut, posterize, posterize_in, tone_luts, tone_luts_depth, tone_luts_q,
TONE_SLOPE_SPAN, contour_curve_lut, curve_lut, curve_lut_mode, curve_map_lut, curve_samples, float_tone_extrapolates, float_tone_identity, levels,
levels_float, levels_gamma, levels_q, modern_brightness, modern_contrast, modern_lut, posterize, posterize_in, tone_luts, tone_luts_depth, tone_luts_q,
};
use tone::{lut, map_rgb};
+11 -3
View File
@@ -402,11 +402,19 @@ pub fn curve_map_lut(points: &[CurvePoint]) -> Vec<f32> {
/// overshoot between points, as Photoshop's does), constant outside the
/// first/last point, clamped to 0..=1, sampled into a LUT.
pub fn curve_lut(points: &[CurvePoint]) -> Vec<f32> {
curve_samples::<LUT_SIZE>(points)
}
/// The same natural spline as [`curve_lut`], sampled directly at `N` evenly spaced inputs
/// including both endpoints. UI graphs use a smaller table without building the rendering LUT.
/// Zero samples returns an empty table; one sample evaluates at input zero.
pub fn curve_samples<const N: usize>(points: &[CurvePoint]) -> Vec<f32> {
let last = N.saturating_sub(1).max(1) as f32;
let mut pts: Vec<(f32, f32)> = points.iter().map(|p| (p.input, p.output)).collect();
pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
if pts.len() < 2 {
return (0..LUT_SIZE).map(|i| i as f32 / (LUT_SIZE - 1) as f32).collect();
return (0..N).map(|i| i as f32 / last).collect();
}
let n = pts.len();
// Second derivatives of the natural spline (tridiagonal solve).
@@ -430,9 +438,9 @@ pub fn curve_lut(points: &[CurvePoint]) -> Vec<f32> {
m2[i] = d[i] - c[i] * m2[i + 1];
}
}
(0..LUT_SIZE)
(0..N)
.map(|k| {
let xv = k as f32 / (LUT_SIZE - 1) as f32;
let xv = k as f32 / last;
if xv <= pts[0].0 {
return pts[0].1.clamp(0.0, 1.0);
}
+1 -2
View File
@@ -108,8 +108,7 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
|| (),
|_, y, row, _| {
for (x, o) in row.iter_mut().enumerate() {
let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5);
let p = tile.sample(u, v);
let p = tile.sample_area(&place, f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5);
let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3];
*o += k * if t.invert { 1.0 - l } else { l };
}
+1 -2
View File
@@ -339,8 +339,7 @@ pub(super) fn paint_pattern(
m,
|i| {
let (x, y) = (f64::from(big.x0 + (i % w) as i32) + 0.5, f64::from(big.y0 + (i / w) as i32) + 0.5);
let (u, v) = place.map(x, y);
tile.sample(u, v)
tile.sample_area(&place, x, y)
},
blend,
opacity,
+357 -48
View File
@@ -3,19 +3,32 @@
//! A pattern tiles the plane from an origin (the layer's frame when linked, else the canvas
//! origin, plus the phase), scaled and rotated about that origin. Samples are bilinear with
//! wrap-around on premultiplied colour, so integer placements at 100 % reproduce the tile
//! exactly and scaled ones stay seamless.
//! exactly and scaled ones stay seamless. Below 100 % each document pixel averages an
//! `n × n` grid of bilinear taps over its footprint in the tile (see [`pixel_taps`]), so a
//! minified tile is box-filtered instead of aliased; at 100 % and above it is a single tap.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType};
pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20;
/// What one rendering call may keep of its patterns' working copies: every pattern it paints with
/// stays converted for the call (four of the largest 8-bit ones on desktop), so none is converted
/// once per output tile. A ceiling, not an allocation: only the patterns a call paints with are
/// converted. Past it a pattern is converted under one lock and shared while in use (see
/// [`PreparedPatterns::get`]). The web build keeps a quarter of that.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const PREPARED_PATTERN_BYTES: usize = 1 << 30;
/// See the desktop value above.
#[cfg(target_arch = "wasm32")]
pub(crate) const PREPARED_PATTERN_BYTES: usize = 256 << 20;
const MAX_PREPARED_PATTERNS: usize = 64;
/// Absolute cap on a single pattern's RGBA conversion on the uncached path. A pattern this
/// large cannot be cached, so re-expanding it for every output tile would be a DoS; above the
/// cap it is skipped instead. Independent of the cache budget so small patterns still convert.
const MAX_UNCACHED_PATTERN_BYTES: usize = 256 << 20;
/// Absolute cap on a single pattern's working copy on the uncached path: the document's own cap
/// on what paints (`Pattern::is_paintable`). A pattern above it is skipped, not converted.
const MAX_UNCACHED_PATTERN_BYTES: usize = photocraft_doc::pattern::MAX_WORKING_BYTES as usize;
// A call keeps at least two of the largest, or a second large fill is converted for every tile.
const _: () = assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64);
type PreparedSlot = Arc<OnceLock<Option<Arc<Tile>>>>;
struct PreparedState {
@@ -23,6 +36,15 @@ struct PreparedState {
reserved_bytes: usize,
}
/// Patterns past the call's budget: converted one at a time under this lock, and shared by the
/// workers using one at the same moment (a `Weak` per pattern), so the extra copies in memory are
/// the patterns in use, not one per worker.
#[derive(Default)]
struct OverBudget {
live: Mutex<HashMap<usize, Weak<Tile>>>,
warned: AtomicBool,
}
/// Demand-prepared pixels owned by one rendering call, never by the document.
pub(crate) struct PreparedPatterns<'a> {
patterns: &'a [Pattern],
@@ -30,11 +52,18 @@ pub(crate) struct PreparedPatterns<'a> {
/// The colour mode the patterns are painted into (see [`pattern_rgba`]).
mode: ColorMode,
state: Mutex<PreparedState>,
over_budget: OverBudget,
}
impl<'a> PreparedPatterns<'a> {
pub(crate) fn new(patterns: &'a [Pattern], budget: usize) -> Self {
Self { patterns, budget, mode: ColorMode::Rgb, state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }) }
Self {
patterns,
budget,
mode: ColorMode::Rgb,
state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }),
over_budget: OverBudget::default(),
}
}
/// Patterns painted into a document of colour mode `mode`.
@@ -52,10 +81,7 @@ impl<'a> PreparedPatterns<'a> {
if !p.is_paintable() {
return None;
}
let bytes = usize::try_from(p.width)
.ok()
.and_then(|w| usize::try_from(p.height).ok().and_then(|h| w.checked_mul(h)))
.and_then(|pixels| pixels.checked_mul(std::mem::size_of::<[f32; 4]>()));
let bytes = usize::try_from(p.working_bytes()).ok();
// The borrowed immutable slice pins these addresses for the complete call.
let key = std::ptr::from_ref(p) as usize;
let slot = {
@@ -82,14 +108,40 @@ impl<'a> PreparedPatterns<'a> {
// Run on the requesting worker to preserve its active CMYK profile.
slot.get_or_init(|| Tile::new_in(p, self.mode).map(Arc::new)).clone()
}
// Uncached path: a pattern whose full conversion would exceed an absolute cap is
// not materialized at all. Otherwise every output tile would re-expand the entire
// sparse pattern into RGBA floats (and could do so concurrently), exhausting CPU and
// memory even though cache admission already refused to retain it. The cap is
// independent of the (possibly zero) cache budget, so small patterns still convert
// on demand when nothing is cached.
// Uncached path: a pattern whose working copy would exceed an absolute cap is not
// materialized at all. One past the call's budget is converted under one lock, and
// shared while some worker still holds it, so workers painting tiles at the same
// time do not each convert their own copy (CPU and memory), and it says so once.
// The cap is independent of the (possibly zero) cache budget, so small patterns
// still convert on demand when nothing is cached.
None if bytes.is_none_or(|b| b > MAX_UNCACHED_PATTERN_BYTES) => None,
None => Tile::new_in(p, self.mode).map(Arc::new),
None => {
// The lock is held through the conversion, so conversions of different
// over-budget patterns run one at a time. That is the point (one extra copy
// being built at once, not one per worker) and fine for a fallback: patterns
// that fit the budget never come here.
let mut live = self.over_budget.live.lock().unwrap_or_else(|e| e.into_inner());
if let Some(shared) = live.get(&key).and_then(Weak::upgrade) {
return Some(shared);
}
if !self.over_budget.warned.swap(true, Ordering::Relaxed) {
// Written, not printed: a warning must never panic, even with no stderr.
#[cfg(not(target_arch = "wasm32"))]
{
use std::io::Write as _;
let _ = writeln!(
std::io::stderr(),
"photocraft: pattern \"{}\" is past this render's pattern memory ({} MiB) and is converted again as tiles need it",
p.display_name(),
self.budget >> 20
);
}
}
let tile = Tile::new_in(p, self.mode).map(Arc::new)?;
live.retain(|_, w| w.strong_count() > 0);
live.insert(key, Arc::downgrade(&tile));
Some(tile)
}
}
}
}
@@ -101,29 +153,80 @@ impl<'a> PreparedPatterns<'a> {
/// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't
/// [paintable](Pattern::is_paintable).
pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> {
let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else {
return Vec::new();
};
let mut px = vec![[0.0f32; 4]; n];
p.surface.read_rgba_into(p.rect(), &mut px);
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
if paints_in && p.surface.format().mode != mode {
let fmt = PixelFormat::new(mode, SampleType::F32, true);
let mut v = [0.0f32; 5];
for q in &mut px {
let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v);
*q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default());
}
let mut px = Vec::new();
if p.is_paintable() && pattern_rgba_rows(p, mode, 0, p.height, &mut px).is_none() {
px = Vec::new();
}
px
}
/// A pattern converted once to straight-alpha RGBA for sampling.
/// Rows `top..bottom` of [`pattern_rgba`] (of a paintable pattern), replacing `out`: a large
/// pattern is read a band at a time instead of as one copy at 16 bytes per pixel. `None` when the
/// memory for the rows cannot be had (on the web a failed allocation aborts the tab, so it is
/// asked for, not assumed).
pub fn pattern_rgba_rows(p: &Pattern, mode: ColorMode, top: u32, bottom: u32, out: &mut Vec<[f32; 4]>) -> Option<()> {
let bottom = bottom.min(p.height);
let top = top.min(bottom);
out.clear();
let pixels = (p.width as usize).checked_mul((bottom - top) as usize)?;
out.try_reserve_exact(pixels).ok()?;
out.resize(pixels, [0.0f32; 4]);
p.surface.read_rgba_into(Rect::new(0, top as i32, p.width as i32, bottom as i32), out);
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
if paints_in && p.surface.format().mode != mode {
let fmt = PixelFormat::new(mode, SampleType::F32, true);
let mut v = [0.0f32; 5];
for q in out.iter_mut() {
let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v);
*q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default());
}
}
Some(())
}
/// The pixels of a paintable pattern as they paint in a document of colour mode `mode`, rounded
/// to the pattern's own sample grid by `quantize`, read a band of rows at a time: no full-size
/// `f32` copy. A pattern with its own colours comes through exactly (its samples are on that
/// grid); one converted to a grey or CMYK document is rounded to the grid it was drawn on. `None`
/// when the memory cannot be had.
fn quantized_rgba<T>(p: &Pattern, mode: ColorMode, quantize: impl Fn(f32) -> T) -> Option<Vec<[T; 4]>> {
let mut out = Vec::new();
out.try_reserve_exact((p.width as usize).checked_mul(p.height as usize)?).ok()?;
let mut band = Vec::new();
for top in (0..p.height).step_by(photocraft_doc::pattern::BAND_ROWS as usize) {
pattern_rgba_rows(p, mode, top, top + photocraft_doc::pattern::BAND_ROWS, &mut band)?;
out.extend(band.iter().map(|c| [quantize(c[0]), quantize(c[1]), quantize(c[2]), quantize(c[3])]));
}
Some(out)
}
/// `u8 / 255` as `f32`, for every `u8`: the value an 8-bit sample reads as.
const UNIT8: [f32; 256] = {
let mut table = [0.0f32; 256];
let mut i = 0;
while i < 256 {
table[i] = i as f32 / 255.0;
i += 1;
}
table
};
/// A pattern's pixels as the sampler holds them.
enum Texels {
/// Premultiplied `f32` RGBA, 16 bytes per pixel: any pattern that fits.
Premultiplied(Vec<[f32; 4]>),
/// Straight RGBA8, 4 bytes per pixel, premultiplied as each tap reads it (see [`UNIT8`]): a
/// large 8-bit pattern, with the values the expanded copy would hold.
Rgba8(Vec<[u8; 4]>),
/// Straight RGBA16, 8 bytes per pixel, premultiplied as each tap reads it: a large 16-bit one.
Rgba16(Vec<[u16; 4]>),
}
/// A pattern converted once for sampling.
pub struct Tile {
w: usize,
h: usize,
/// Premultiplied RGBA.
px: Vec<[f32; 4]>,
texels: Texels,
}
impl Tile {
@@ -137,26 +240,83 @@ impl Tile {
if !p.is_paintable() {
return None;
}
Tile::build(p, mode, p.pixel_count().saturating_mul(16) > photocraft_doc::pattern::MAX_WORKING_BYTES)
}
/// [`Tile::new_in`], held at the pattern's own depth when `compact` and it has a smaller form.
fn build(p: &Pattern, mode: ColorMode, compact: bool) -> Option<Tile> {
let (w, h) = (p.width as usize, p.height as usize);
let mut px = pattern_rgba(p, mode);
for q in &mut px {
for c in 0..3 {
q[c] *= q[3];
let texels = match p.surface.format().sample {
// A large pattern is held at its own depth (`Pattern::working_bytes`).
SampleType::U8 if compact => Texels::Rgba8(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)?),
SampleType::U16 if compact => Texels::Rgba16(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16)?),
_ => {
let mut px = pattern_rgba(p, mode);
if px.len() as u64 != p.pixel_count() {
return None;
}
for q in &mut px {
for c in 0..3 {
q[c] *= q[3];
}
}
Texels::Premultiplied(px)
}
}
Some(Tile { w, h, px })
};
Some(Tile { w, h, texels })
}
#[inline]
fn at(&self, x: i64, y: i64) -> [f32; 4] {
let xi = x.rem_euclid(self.w as i64) as usize;
let yi = y.rem_euclid(self.h as i64) as usize;
self.px[yi * self.w + xi]
let i = yi * self.w + xi;
match &self.texels {
Texels::Premultiplied(px) => px.get(i).copied().unwrap_or_default(),
Texels::Rgba8(px) => {
let [r, g, b, a] = px.get(i).copied().unwrap_or_default();
let a = UNIT8[usize::from(a)];
[UNIT8[usize::from(r)] * a, UNIT8[usize::from(g)] * a, UNIT8[usize::from(b)] * a, a]
}
Texels::Rgba16(px) => {
let [r, g, b, a] = px.get(i).copied().unwrap_or_default();
let unit = |v: u16| f32::from(v) / 65535.0;
let a = unit(a);
[unit(r) * a, unit(g) * a, unit(b) * a, a]
}
}
}
/// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`).
/// Returns straight alpha.
pub fn sample(&self, u: f64, v: f64) -> [f32; 4] {
unpremultiply(self.sample_premul(u, v))
}
/// The pattern at the centre `(x, y)` of a document pixel, filtered over the pixel's
/// footprint in the tile when the placement shrinks it. Returns straight alpha.
pub fn sample_area(&self, place: &Placement, x: f64, y: f64) -> [f32; 4] {
let n = pixel_taps(place.inv_scale);
if n == 1 {
let (u, v) = place.map(x, y);
return self.sample(u, v);
}
let mut acc = [0.0f32; 4];
for j in 0..n {
for i in 0..n {
let (u, v) = place.map(x + tap_offset(i, n), y + tap_offset(j, n));
let q = self.sample_premul(u, v);
for c in 0..4 {
acc[c] += q[c];
}
}
}
let k = 1.0 / (n * n) as f32;
unpremultiply([acc[0] * k, acc[1] * k, acc[2] * k, acc[3] * k])
}
/// Bilinear, wrapping sample, premultiplied.
fn sample_premul(&self, u: f64, v: f64) -> [f32; 4] {
let (u, v) = (u - 0.5, v - 0.5);
let (x0, y0) = (u.floor(), v.floor());
let (fx, fy) = ((u - x0) as f32, (v - y0) as f32);
@@ -177,13 +337,32 @@ impl Tile {
}
}
}
if acc[3] <= 0.0 {
return [0.0; 4];
}
[acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)]
acc
}
}
/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap).
const MAX_TAPS: usize = 8;
/// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers
/// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above.
fn pixel_taps(inv_scale: f64) -> usize {
let s = inv_scale as f32;
if s.is_finite() && s > 1.0 { (s.ceil() as usize).min(MAX_TAPS) } else { 1 }
}
/// Offset in document pixels of tap `i` of `n`, centred on the pixel.
fn tap_offset(i: usize, n: usize) -> f64 {
(i as f64 + 0.5) / n as f64 - 0.5
}
fn unpremultiply(acc: [f32; 4]) -> [f32; 4] {
if acc[3] <= 0.0 {
return [0.0; 4];
}
[acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)]
}
/// Where a pattern sits: document point → tile coordinates.
#[derive(Clone, Copy, Debug)]
pub struct Placement {
@@ -234,8 +413,7 @@ pub fn render(tile: &Tile, place: &Placement, rect: Rect) -> Vec<[f32; 4]> {
let mut out = Vec::with_capacity(rect.width() as usize * rect.height() as usize);
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let (u, v) = place.map(f64::from(x) + 0.5, f64::from(y) + 0.5);
out.push(tile.sample(u, v));
out.push(tile.sample_area(place, f64::from(x) + 0.5, f64::from(y) + 0.5));
}
}
out
@@ -282,6 +460,114 @@ mod tests {
assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none());
}
/// Review of #8902: what `Pattern::is_paintable` admits, the compositor expands. A pattern that
/// fits is expanded as before; a large one is held at its own depth, so Define Pattern's
/// 8000 x 8000 fits at 8 bits. (Only the size is declared: the patterns themselves are empty.)
#[test]
fn the_most_paintable_pixels_still_expand() {
let sparse = |name: &str, (width, height): (u32, u32), format: PixelFormat| Pattern {
id: name.into(),
name: name.into(),
width,
height,
surface: Surface::new(format),
};
let float = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
let list = [
sparse("max8", (8000, 8000), PixelFormat::RGBA8),
sparse("max16", (5792, 5792), PixelFormat::RGBA16),
sparse("max32", (4096, 4096), float),
sparse("over8", (9000, 8000), PixelFormat::RGBA8),
sparse("over16", (8000, 8000), PixelFormat::RGBA16),
sparse("over32", (4097, 4096), float),
];
assert_eq!(list.each_ref().map(|p| p.is_paintable()), [true, true, true, false, false, false]);
let prepared = PreparedPatterns::new(&list, PREPARED_PATTERN_BYTES);
let held = |name: &str| prepared.get(name, "").map(|t| (matches!(t.texels, Texels::Rgba8(_)), matches!(t.texels, Texels::Rgba16(_))));
assert_eq!(held("max8"), Some((true, false)), "an 8-bit 8000 x 8000 pattern expands to 4 bytes a pixel");
assert_eq!(held("max16"), Some((false, true)), "a 16-bit one to 8");
for name in ["over8", "over16", "over32"] {
assert!(prepared.get(name, "").is_none(), "{name} is skipped, not allocated");
}
assert!(PreparedPatterns::new(&list, 0).get("max8", "").is_some(), "uncached too, at the cap");
}
/// A render that paints with two large patterns (a pattern fill and a pattern overlay, say)
/// converts each once, not once per output tile: both stay in the call's budget. A third that
/// does not fit is converted when asked for (correct, just not kept).
#[test]
fn two_large_patterns_are_each_converted_once_per_call() {
let sparse = |name: &str| Pattern { id: name.into(), name: name.into(), width: 5000, height: 5000, surface: Surface::new(PixelFormat::RGBA8) };
let list = [sparse("a"), sparse("b"), sparse("c")];
assert!(list.iter().all(|p| p.working_bytes() == 100_000_000));
let prepared = PreparedPatterns::new(&list, 250_000_000);
let same = |name: &str| {
let (first, second) = (prepared.get(name, "").unwrap(), prepared.get(name, "").unwrap());
Arc::ptr_eq(&first, &second)
};
assert!(same("a"), "the first is kept");
assert!(same("b"), "and the second");
// A third past the budget is converted under one lock and shared while a worker holds it,
// so concurrent tiles use one copy; it is converted again once nobody holds it.
assert!(same("c"), "shared while in use");
let held = prepared.get("c", "").unwrap();
let other = prepared.get("c", "").unwrap();
assert!(Arc::ptr_eq(&held, &other), "two workers at once get one copy");
drop((held, other));
assert!(prepared.get("c", "").is_some(), "and it converts again after");
// The real budget holds two of the largest paintable ones.
assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64);
}
/// A large pattern's compact copy samples exactly as the expanded one: every 8-bit and 16-bit
/// value survives the round trip through `f32`, and a tap reads the same premultiplied numbers.
#[test]
fn the_compact_copies_read_the_same_as_the_expanded_one() {
for v in 0..=255u8 {
assert_eq!(((f32::from(v) / 255.0) * 255.0 + 0.5) as u8, v);
assert_eq!(UNIT8[usize::from(v)], f32::from(v) / 255.0);
}
for v in 0..=u16::MAX {
assert_eq!(((f32::from(v) / 65535.0) * 65535.0 + 0.5) as u16, v);
}
for (format, grid) in [(PixelFormat::RGBA8, 255.0f32), (PixelFormat::RGBA16, 65535.0)] {
let mut s = Surface::new(format);
for y in 0..16i32 {
for x in 0..16i32 {
let level = |v: i32| (v as f32 / 255.0 * grid).round() / grid;
s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[level(x * 17), level(y * 17), level((x + y) * 8), level(255 - x * 13 - y * 3)]);
}
}
let p = Pattern::new("ramp", s, 16, 16);
let (expanded, compact) = (Tile::build(&p, ColorMode::Rgb, false).unwrap(), Tile::build(&p, ColorMode::Rgb, true).unwrap());
assert!(matches!(expanded.texels, Texels::Premultiplied(_)));
assert!(matches!((&compact.texels, grid as u32), (Texels::Rgba8(_), 255) | (Texels::Rgba16(_), 65535)));
for y in 0..16 {
for x in 0..16 {
assert_eq!(compact.at(x, y), expanded.at(x, y), "{grid} ({x}, {y})");
}
}
// And rendered, scaled, rotated and shifted across the wrap.
let place = Placement::new(Rect::new(3, 5, 40, 40), true, (2.0, 1.0), 0.6, 20.0);
assert_eq!(render(&compact, &place, Rect::new(-9, -7, 30, 25)), render(&expanded, &place, Rect::new(-9, -7, 30, 25)));
}
}
/// Memory for the rows is asked for, not assumed: a pattern whose rows cannot be had (here a
/// declared size past what any allocation could be) gives `None`, never an aborted allocation.
#[test]
fn rows_that_cannot_be_allocated_give_none() {
let huge = Pattern { id: "huge".into(), name: "huge".into(), width: u32::MAX, height: u32::MAX, surface: Surface::new(PixelFormat::RGBA8) };
let mut out = Vec::new();
assert!(pattern_rgba_rows(&huge, ColorMode::Rgb, 0, u32::MAX, &mut out).is_none());
assert!(quantized_rgba(&huge, ColorMode::Rgb, |v| v as u8).is_none());
assert!(pattern_rgba(&huge, ColorMode::Rgb).is_empty());
assert!(Tile::new(&huge).is_none());
let small = Pattern { id: "small".into(), name: "small".into(), width: 3, height: 2, surface: Surface::new(PixelFormat::RGBA8) };
assert_eq!(pattern_rgba_rows(&small, ColorMode::Rgb, 0, 2, &mut out), Some(()));
assert_eq!(out.len(), 6);
}
#[test]
fn link_phase_scale_and_angle_move_the_origin() {
let t = Tile::new(&checker()).unwrap();
@@ -299,6 +585,29 @@ mod tests {
assert!((u - -3.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9, "{u},{v}");
}
#[test]
fn minified_pattern_is_filtered_not_aliased() {
// 1-px dark / light stripes (mean 0.5), shown at 25 %: every pixel covers 4 tile pixels.
// The 0.125 px phase puts each pixel centre on a light tile pixel's centre, so a single
// bilinear tap would return pure white.
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]);
for x in (0..8).step_by(2) {
s.fill_rect(Rect::new(x, 0, x + 1, 8), &[0.0, 0.0, 0.0, 1.0]);
}
let t = Tile::new(&Pattern::new("stripes", s, 8, 8)).unwrap();
let place = Placement::new(Rect::EMPTY, false, (0.125, 0.0), 0.25, 0.0);
let (u, _) = place.map(0.5, 0.5);
assert_eq!(t.sample(u, 0.5)[0], 1.0, "the single tap lands on a light stripe");
for p in render(&t, &place, Rect::new(0, 0, 8, 1)) {
assert!((p[0] - 0.5).abs() < 0.05, "{p:?}");
}
// 100 % stays exact.
let one = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 1.0, 0.0);
let row = render(&t, &one, Rect::new(0, 0, 2, 1));
assert_eq!((row[0][0], row[1][0]), (0.0, 1.0));
}
#[test]
fn sampling_at_hostile_phases_does_not_overflow() {
let patterns = [checker()];
+26
View File
@@ -834,6 +834,32 @@ fn threshold_posterize_levels_curves() {
assert!(s[s.len() / 4] < 0.25 && s[3 * s.len() / 4] > 0.75);
}
#[test]
fn curve_samples_preserve_spline_and_endpoint_behavior() {
use photocraft_doc::adjust::CurvePoint;
let points = [CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.5, output: 1.0 }, CurvePoint { input: 1.0, output: 0.0 }];
// Analytic natural spline: at x=1/4 and 3/4 the output is 11/16.
assert_eq!(adjust::curve_samples::<5>(&points), vec![0.0, 0.6875, 1.0, 0.6875, 0.0]);
let low = adjust::curve_samples::<256>(&points);
let full = adjust::curve_lut(&points);
assert_eq!(low.len(), 256);
assert_eq!(full.len(), 4096);
// Both grids share these inputs exactly (17/255 == 273/4095).
for k in 0..=15 {
assert_eq!(low[k * 17], full[k * 273]);
}
let unsorted = [CurvePoint { input: 0.75, output: 0.8 }, CurvePoint { input: 0.25, output: 0.2 }];
let table = adjust::curve_samples::<5>(&unsorted);
for (actual, expected) in table.iter().zip([0.2, 0.2, 0.5, 0.8, 0.8]) {
assert!((actual - expected).abs() < 1e-6);
}
assert_eq!(adjust::curve_samples::<3>(&[]), vec![0.0, 0.5, 1.0]);
assert_eq!(adjust::curve_samples::<3>(&points[..1]), vec![0.0, 0.5, 1.0]);
assert!(adjust::curve_samples::<0>(&points).is_empty());
assert_eq!(adjust::curve_samples::<1>(&points), vec![0.0]);
}
#[test]
fn hue_saturation_roundtrips_and_desaturates() {
let c = [0.8, 0.3, 0.1];
+7 -2
View File
@@ -1015,12 +1015,17 @@ impl Document {
/// Pixel format for new raster layers in this document.
pub fn pixel_format(&self) -> PixelFormat {
let mode = match self.mode {
Self::pixel_format_for(self.mode, self.depth)
}
/// [`Self::pixel_format`] of a document in `mode` at `depth`.
pub fn pixel_format_for(mode: ColorMode, depth: SampleType) -> PixelFormat {
let mode = match mode {
ColorMode::Indexed | ColorMode::Multichannel => ColorMode::Rgb,
ColorMode::Bitmap | ColorMode::Duotone => ColorMode::Grayscale,
m => m,
};
PixelFormat::new(mode, self.depth, true)
PixelFormat::new(mode, depth, true)
}
pub fn bounds(&self) -> Rect {
+137 -9
View File
@@ -2,16 +2,30 @@
//! and brush textures. Pure data; rendering lives in `photocraft-compose`, the PSD mapping
//! (`Patt`/`Pat2`/`Pat3` global blocks) in `photocraft-io`.
use photocraft_color::SampleType;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 4000).
/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 8000).
pub const MAX_EDGE: u32 = 30_000;
/// Most pixels a pattern may have to be painted. Renderers expand a pattern into a 16-byte-per-pixel
/// working copy, so this keeps that copy at 1 GiB; larger patterns paint nothing.
/// Most pixels a pattern may have to be painted (Define Pattern's 8000 × 8000 fits). Larger
/// patterns paint nothing.
pub const MAX_PAINT_PIXELS: u64 = 1 << 26;
/// Most bytes of working copy the CPU compositor holds for one pattern (`photocraft-compose`
/// checks the two agree at build time). See [`Pattern::working_bytes`]. A browser tab has far less
/// memory than a desktop process, and a failed allocation there aborts the tab, so the web build
/// holds half as much: an 8-bit pattern paints up to 33,554,432 px (about 5792²) there.
#[cfg(not(target_arch = "wasm32"))]
pub const MAX_WORKING_BYTES: u64 = 256 << 20;
/// See the desktop value above.
#[cfg(target_arch = "wasm32")]
pub const MAX_WORKING_BYTES: u64 = 128 << 20;
/// Rows of a pattern read at a time where a whole-tile `f32` copy would cost 16 bytes per pixel.
pub const BAND_ROWS: u32 = 256;
/// One pattern tile. Pixels cover `0..width × 0..height` of `surface`, in any pixel format
/// (depth and colour model are the pattern's own, as in Photoshop).
#[derive(Clone, Debug, PartialEq)]
@@ -36,13 +50,18 @@ impl Pattern {
/// defining a pattern is deterministic and replayable).
pub fn new(name: impl Into<String>, surface: Surface, width: u32, height: u32) -> Pattern {
let name = name.into();
let r = Rect::new(0, 0, width as i32, height as i32);
let px = surface.read_region(r);
let mut h1 = fnv(0xcbf2_9ce4_8422_2325, name.as_bytes());
h1 = fnv(h1, &width.to_le_bytes());
h1 = fnv(h1, &height.to_le_bytes());
for v in &px {
h1 = fnv(h1, &v.to_bits().to_le_bytes());
// The samples row by row, a band at a time: the same sequence as one read of the whole
// tile, without a full-size `f32` copy of a large pattern.
let mut band = Vec::new();
for top in (0..height).step_by(BAND_ROWS as usize) {
let bottom = (top + BAND_ROWS).min(height);
surface.read_region_into(Rect::new(0, top as i32, width as i32, bottom as i32), &mut band);
for v in &band {
h1 = fnv(h1, &v.to_bits().to_le_bytes());
}
}
let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", surface.format()).as_bytes());
let id = format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff);
@@ -70,9 +89,61 @@ impl Pattern {
u64::from(self.width) * u64::from(self.height)
}
/// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`]).
/// Bytes of working copy the compositor holds to paint this pattern. One that fits is expanded
/// to premultiplied `f32` RGBA (16 per pixel). A pattern too large for that is held at its own
/// depth instead, straight RGBA at 4 bytes per pixel for 8 bits and 8 for 16 bits: exactly its
/// values, in a quarter or half the memory. A float one has no smaller exact form.
pub fn working_bytes(&self) -> u64 {
working_bytes_for(self.pixel_count(), self.surface.format().sample)
}
/// Why this pattern paints nothing, for the person or agent who chose it; `None` when it
/// paints. A pattern too large for the compositor's working copy renders as transparent, so
/// every command that picks a pattern, and the import that brought it in, says so.
pub fn paint_problem(&self) -> Option<String> {
if self.is_paintable() {
return None;
}
if self.is_empty() {
return Some("it is empty".into());
}
let sample = self.surface.format().sample;
Some(format!(
"it is {} × {} px at {} bits per channel, and a pattern of that depth paints up to {} px in all",
self.width,
self.height,
sample.bytes() * 8,
max_paint_pixels(sample)
))
}
/// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`] and
/// [`MAX_WORKING_BYTES`]).
pub fn is_paintable(&self) -> bool {
!self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS
!self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS && self.working_bytes() <= MAX_WORKING_BYTES
}
}
/// [`Pattern::working_bytes`] of a pattern of `pixels` pixels with `sample` samples.
pub fn working_bytes_for(pixels: u64, sample: SampleType) -> u64 {
let expanded = pixels.saturating_mul(16);
if expanded <= MAX_WORKING_BYTES {
return expanded;
}
match sample {
SampleType::U8 => pixels.saturating_mul(4),
SampleType::U16 => pixels.saturating_mul(8),
SampleType::F32 => expanded,
}
}
/// Most pixels a pattern with `sample` samples may have and still paint: [`MAX_PAINT_PIXELS`] for
/// 8 bits, fewer where the working copy is larger per pixel.
pub fn max_paint_pixels(sample: SampleType) -> u64 {
match sample {
SampleType::U8 => MAX_PAINT_PIXELS,
SampleType::U16 => MAX_PAINT_PIXELS.min(MAX_WORKING_BYTES / 8),
SampleType::F32 => MAX_PAINT_PIXELS.min(MAX_WORKING_BYTES / 16),
}
}
@@ -118,4 +189,61 @@ mod tests {
assert!(find(&list, "nope", "water").is_some());
assert!(find(&list, "nope", "").is_none());
}
/// The id is a hash of every sample in row-major order; reading a tall pattern a band of rows
/// at a time must give the id one whole read gave, or saved documents would lose their
/// pattern links.
#[test]
fn the_id_of_a_tall_pattern_matches_a_single_read_of_it() {
let (w, h) = (7u32, BAND_ROWS * 2 + 37);
let mut s = Surface::new(PixelFormat::RGBA8);
for y in 0..h as i32 {
for x in 0..w as i32 {
s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[(x % 5) as f32 / 4.0, (y % 7) as f32 / 6.0, ((x + y) % 3) as f32 / 2.0, 1.0]);
}
}
let name = "tall";
let mut h1 = fnv(0xcbf2_9ce4_8422_2325, name.as_bytes());
h1 = fnv(h1, &w.to_le_bytes());
h1 = fnv(h1, &h.to_le_bytes());
for v in s.read_region(Rect::new(0, 0, w as i32, h as i32)) {
h1 = fnv(h1, &v.to_bits().to_le_bytes());
}
let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", s.format()).as_bytes());
let expected =
format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff);
assert_eq!(Pattern::new(name, s, w, h).id, expected);
}
#[test]
fn working_bytes_keep_a_pattern_exact_in_less_memory() {
let sparse =
|format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) };
let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true);
// What fits is expanded as before.
assert_eq!(sparse(PixelFormat::RGBA8, 10, 10).working_bytes(), 1600);
assert_eq!(sparse(float, 4096, 4096).working_bytes(), MAX_WORKING_BYTES);
// Past that, a pattern is held at its own depth.
assert_eq!(sparse(PixelFormat::RGBA8, 8000, 8000).working_bytes(), 256_000_000);
assert_eq!(sparse(PixelFormat::RGBA16, 5000, 5000).working_bytes(), 200_000_000);
assert!(sparse(PixelFormat::RGBA8, 8000, 8000).is_paintable());
assert!(sparse(PixelFormat::RGBA16, 5792, 5792).is_paintable());
assert!(!sparse(PixelFormat::RGBA16, 8000, 8000).is_paintable(), "512 MB at 16 bits");
assert!(!sparse(float, 4097, 4096).is_paintable(), "float has no smaller exact form");
assert!(!sparse(PixelFormat::RGBA8, 9000, 8000).is_paintable());
assert!(!sparse(PixelFormat::RGBA8, 0, 8).is_paintable());
}
#[test]
fn a_pattern_that_paints_nothing_says_why() {
let sparse =
|format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) };
let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true);
assert_eq!(sparse(PixelFormat::RGBA8, 8000, 8000).paint_problem(), None);
assert_eq!(sparse(PixelFormat::RGBA8, 0, 4).paint_problem().as_deref(), Some("it is empty"));
let reason = sparse(float, 5000, 5000).paint_problem().expect("float 5000 x 5000 paints nothing");
assert!(reason.contains("5000 × 5000") && reason.contains("32 bits") && reason.contains("16777216"), "{reason}");
let reason = sparse(PixelFormat::RGBA16, 8000, 8000).paint_problem().expect("16-bit 8000 x 8000 paints nothing");
assert!(reason.contains("16 bits") && reason.contains("33554432"), "{reason}");
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Release benchmark for the live Mixer Brush on a 24 MP document.
//! Setup and checksum are outside the timed command. Run with
//! `cargo run --release -p photocraft-engine --example bench_dry_mixer`.
use std::time::Instant;
use photocraft_engine::{EngineError, Session};
use photocraft_geom::Rect;
use serde_json::json;
const W: i32 = 6000;
const H: i32 = 4000;
const SIZES: [u32; 3] = [32, 128, 512];
const REPEATS: usize = 4;
fn fingerprint(session: &Session) -> Result<String, Box<dyn std::error::Error>> {
let state = session.active().ok_or("no active document")?;
let id = state.active_layer.ok_or("no active layer")?;
let surface = state.doc.layer(id).and_then(|layer| layer.surface()).ok_or("no raster surface")?;
let pixels = surface.read_region(Rect::new(0, 0, W, H));
let mut hash = blake3::Hasher::new();
for channel in pixels {
hash.update(&channel.to_le_bytes());
}
hash.update(&serde_json::to_vec(&session.tools.mixer)?);
Ok(hash.finalize().to_hex().to_string())
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
for wet in [0, 100] {
for sample_all in [false, true] {
for size in SIZES {
let mut session = Session::new();
session.execute("file.new", json!({"width": W, "height": H, "depth": 8, "mode": "rgb"}))?;
session.execute("layer.new.layer", json!({}))?;
session.edit("benchmark source", |doc, active| {
let id = active.ok_or_else(|| EngineError::Other("no source layer".into()))?;
let surface = doc.layer_mut(id).and_then(|layer| layer.surface_mut()).ok_or_else(|| EngineError::Other("no source pixels".into()))?;
for x in (0..W).step_by(64) {
let color = if x % 128 == 0 { [0.9, 0.25, 0.08, 1.0] } else { [0.08, 0.35, 0.9, 1.0] };
surface.fill_rect(Rect::new(x, 0, (x + 64).min(W), H), &color);
}
Ok(())
})?;
session.execute("layer.new.layer", json!({}))?;
let params = json!({
"points": [[1200, 2000], [4800, 2000]],
"size": size,
"spacing": 0.25,
"wet": wet,
"load": 50,
"mix": 50,
"flow": 100,
"sampleAllLayers": sample_all,
"cleanAfterStroke": true
});
let mut timings = Vec::with_capacity(REPEATS);
let mut output = String::new();
for repeat in 0..REPEATS {
let start = Instant::now();
session.execute("paint.mixerBrush", params.clone())?;
let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
if repeat > 0 {
timings.push(elapsed_ms);
}
output = fingerprint(&session)?;
session.execute("edit.undo", json!({}))?;
}
timings.sort_by(f64::total_cmp);
println!(
"wet={wet:3}% sample_all={sample_all:5} size={size:3} median_ms={:.2} min_ms={:.2} max_ms={:.2} blake3={output}",
timings[1], timings[0], timings[2]
);
}
}
}
Ok(())
}
fn main() {
if let Err(error) = run() {
eprintln!("{error}");
std::process::exit(1);
}
}
@@ -0,0 +1,109 @@
//! End-to-end timing of `select.inverse` on a 6000×4000 document.
//!
//! ```sh
//! cargo run --release -p photocraft-engine --example bench_selection_inverse -- [--quick] [--reps 11]
//! ```
//!
//! Three rows: inverting a rectangular marquee, an elliptical marquee (central 2/3 of the
//! canvas) and `select.all`. Setup is never timed — only the `execute("select.inverse")`
//! call between `Instant`s. After the timed op (before deselect) the resulting selection
//! surface is inspected for storage diagnostics: unique tile payloads (distinct `Arc`
//! allocations behind the coverage surface) and allocated tile count.
use std::collections::HashSet;
use std::time::Instant;
use photocraft_engine::Session;
use serde_json::{Value, json};
type Res = Result<(), Box<dyn std::error::Error>>;
fn arg<'a>(args: &'a [String], name: &str) -> Option<&'a String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1))
}
/// Nearest-rank percentile of a pre-sorted slice.
fn pct(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
let idx = ((p / 100.0) * n as f64).ceil() as usize;
sorted.get(idx.saturating_sub(1).min(n.saturating_sub(1))).copied().unwrap_or(0.0)
}
/// Selection setup for one row; each previous sample already deselected.
fn setup(s: &mut Session, w: i32, h: i32, ellipse: Option<bool>) -> Result<Value, Box<dyn std::error::Error>> {
let params = match ellipse {
Some(ellipse) => {
// Central 2/3 of the canvas.
let x = w / 6;
let y = h / 6;
json!({
"x": x,
"y": y,
"width": w.saturating_mul(2) / 3,
"height": h.saturating_mul(2) / 3,
"ellipse": ellipse,
})
}
None => json!({}), // select.all
};
let id = if ellipse.is_some() { "select.rect" } else { "select.all" };
let v = s.execute(id, params)?;
Ok(v)
}
/// Storage diagnostics of the current selection surface: distinct tile payloads and tiles held.
fn diagnostics(s: &Session) -> Result<(usize, usize), Box<dyn std::error::Error>> {
let Some(state) = s.active() else {
return Err("no active document after select.inverse".into());
};
let Some(sel) = state.doc.selection.as_ref() else {
return Err("no selection after select.inverse".into());
};
let mut payloads: HashSet<usize> = HashSet::new();
let mut count = 0usize;
for (_, tile) in sel.tiles() {
payloads.insert(std::sync::Arc::as_ptr(tile) as usize);
count += 1;
}
Ok((payloads.len(), count))
}
/// One measured sample: setup, time ONLY `select.inverse`, print diagnostics, deselect.
fn sample(s: &mut Session, w: i32, h: i32, ellipse: Option<bool>) -> Result<(f64, usize, usize), Box<dyn std::error::Error>> {
setup(s, w, h, ellipse)?;
let t = Instant::now();
s.execute("select.inverse", json!({}))?;
let ms = t.elapsed().as_secs_f64() * 1000.0;
let (payloads, tiles) = diagnostics(s)?;
s.execute("select.deselect", json!({}))?;
Ok((ms, payloads, tiles))
}
fn main() -> Res {
let args: Vec<String> = std::env::args().collect();
let (w, h) = if args.iter().any(|a| a == "--quick") { (1200, 800) } else { (6000, 4000) };
let reps = arg(&args, "--reps").and_then(|v| v.parse::<usize>().ok()).unwrap_or(11).clamp(1, 100);
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "background": "transparent"}))?;
let rows: [(&str, Option<bool>); 3] = [("Inverse rectangular", Some(false)), ("Inverse ellipse", Some(true)), ("Inverse all", None)];
for (label, ellipse) in rows {
// Warm-up: one untimed sample to page in allocations and caches.
sample(&mut s, w, h, ellipse)?;
let mut times: Vec<f64> = Vec::with_capacity(reps);
for _ in 0..reps {
let (ms, payloads, tiles) = sample(&mut s, w, h, ellipse)?;
times.push(ms);
println!("sample: {ms:8.2} ms (selection tiles: {payloads} unique payloads, {tiles} allocated)");
}
times.sort_by(|a, b| a.total_cmp(b));
println!(
"{label:<22} ({w}×{h}, {reps} reps) p50 {p50:8.2} ms p95 {p95:8.2} ms max {max:8.2} ms",
p50 = pct(&times, 50.0),
p95 = pct(&times, 95.0),
max = pct(&times, 100.0),
);
}
Ok(())
}
+38 -25
View File
@@ -2,7 +2,7 @@
use photocraft_color::{BlendMode, Color, ColorMode, SampleType};
use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size};
use photocraft_geom::Rect;
use photocraft_geom::{Rect, TILE_SIZE};
use photocraft_raster::Surface;
use serde_json::{Map, Value, json};
@@ -168,7 +168,7 @@ pub(crate) use layer_params::{NEW_LAYER_PATH, explicit_layer, layer_listing, lay
use ops::{arrange, merge_group, set_mask, set_mask_and_deselect};
pub(crate) use ops::{layer_copy, translate_layer};
use params::{bad, set_clipped};
pub(crate) use params::{check_f32_values, color_param, int, int_i32, parse_hex, u32_id, u32_id_param};
pub(crate) use params::{check_f32_values, cmyk_param, color_param, int, int_i32, parse_hex, u32_id, u32_id_param};
pub fn command_specs() -> &'static [CommandSpec] {
&registry().specs
@@ -365,12 +365,7 @@ fn build() -> Vec<CommandSpec> {
cmd!("select.inverse", "Inverse", ["Select"], Some("Cmd+Shift+I"), "{}", has_selection, |s, _| {
s.edit("Inverse", |doc, _| {
let old = doc.selection.take().unwrap_or_else(|| Surface::new(photocraft_color::PixelFormat::GRAY8));
let b = doc.bounds();
let data: Vec<f32> = old.read_region(b).into_iter().map(|v| 1.0 - v).collect();
let mut m = Surface::new(photocraft_color::PixelFormat::GRAY8);
m.write_region(b, &data);
m.prune();
doc.selection = Some(m);
doc.selection = Some(photocraft_algo::selection::inverse_surface(&old, doc.bounds()));
Ok(())
})?;
Ok(Value::Null)
@@ -419,15 +414,34 @@ fn build() -> Vec<CommandSpec> {
}
let old = doc.selection.take();
let combined = match (mode.as_str(), old) {
("add", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a.max(b)),
("subtract", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a * (1.0 - b)),
("add", Some(o)) => sel::combine_sparse(&o, &shape, area, |a, b| a.max(b)),
("subtract", Some(o)) => sel::combine_sparse(&o, &shape, area, |a, b| a * (1.0 - b)),
("intersect", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a.min(b)),
// With no existing selection, neither operation can select new pixels.
("subtract" | "intersect", None) => Surface::new(photocraft_color::PixelFormat::GRAY8),
_ => shape,
};
// Nothing selected on the canvas (e.g. dragged entirely outside it) deselects.
doc.selection = (!combined.content_bounds().is_empty()).then_some(combined);
// Sparse Add/Subtract keeps untouched pixels outside the canvas. Only pixels
// inside the document count when deciding whether a selection remains.
let has_on_canvas_pixels = (combined.default_bytes().first().copied().unwrap_or(0) != 0 && {
let canvas_tiles = u64::from(area.width().div_ceil(TILE_SIZE as u32)) * u64::from(area.height().div_ceil(TILE_SIZE as u32));
let stored = combined.tiles().filter(|(coord, _)| !coord.rect().intersect(&area).is_empty()).count() as u64;
stored < canvas_tiles
}) || combined.tiles().any(|(coord, tile)| {
let tile_rect = coord.rect();
let overlap = tile_rect.intersect(&area);
if overlap.is_empty() {
return false;
}
let x0 = (overlap.x0 - tile_rect.x0) as usize;
let x1 = (overlap.x1 - tile_rect.x0) as usize;
(overlap.y0 - tile_rect.y0..overlap.y1 - tile_rect.y0).any(|y| {
let row_start = y as usize * TILE_SIZE as usize;
let start = row_start + x0;
tile.bytes().get(start..row_start + x1).is_some_and(|row| row.iter().any(|&v| v != 0))
})
});
doc.selection = has_on_canvas_pixels.then_some(combined);
Ok(())
})?;
Ok(Value::Null)
@@ -778,13 +792,22 @@ fn build() -> Vec<CommandSpec> {
"Solid Color…",
["Layer", "New Fill Layer"],
None,
r##"{"color":"#rrggbb"=foreground,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##,
r##"{"color":"#rrggbb"=foreground,"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document's fill holds exactly these; default the foreground's CMYK pick),"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##,
has_doc,
|s, p| {
let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?;
// The inks the colour was picked as, so CMYK 50/50/50/100 stays 50/50/50/100 instead
// of what its sRGB stand-in converts back to through the document profile (#8568).
let inks = match cmyk_param("layer.newFillLayer.solidColor", p, "cmyk")? {
Some(k) => Some(k),
None => s.tools.picked_cmyk([c[0], c[1], c[2]]),
};
let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
let id = s.edit("New Color Fill Layer", |doc, active| {
let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode);
let fill = match inks {
Some(k) if doc.mode == ColorMode::Cmyk => Fill::Solid(Color { mode: ColorMode::Cmyk, c: k, alpha: c[3] }),
_ => Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode),
};
let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill));
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?;
let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto);
@@ -923,17 +946,7 @@ fn build() -> Vec<CommandSpec> {
r##"{"foreground":"#rrggbb"|[r,g,b,a?]?,"background":same?,"foregroundCmyk":[c,m,y,k]? (inks 0..1 the foreground was picked as: a CMYK document is filled with exactly these),"backgroundCmyk":same?}"##,
always,
|s, p| {
let cmyk = |key: &str| -> Result<Option<[f32; 4]>> {
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
let inks: Option<Vec<f32>> = v
.as_array()
.filter(|a| a.len() == 4)
.and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32)).collect());
match inks.as_deref() {
Some(&[c, m, y, k]) => Ok(Some([c, m, y, k])),
_ => Err(bad("tools.setColors", format!("`{key}` must be [c, m, y, k] numbers 0..1"))),
}
};
let cmyk = |key: &str| cmyk_param("tools.setColors", p, key);
let (fg_cmyk, bg_cmyk) = (cmyk("foregroundCmyk")?, cmyk("backgroundCmyk")?);
let foreground = color_param("tools.setColors", p, "foreground", s.tools.foreground)?;
let background = color_param("tools.setColors", p, "background", s.tools.background)?;
+2 -2
View File
@@ -369,8 +369,8 @@ fn stop(s: &mut Session, _p: &Value) -> Result<Value> {
/// A step Photoshop plays as a silent no-op although its menu item is greyed: Deselect with an
/// open document and nothing selected (actions often start with it to clear any selection, #6744),
/// and an Arrange that can't move the layer, such as Bring to Front on a new adjustment layer that
/// is already on top (#8453).
fn noop_step(s: &Session, id: &str, params: &Value) -> bool {
/// is already on top (#8453). The CLI's `run` and `batch` scripts skip these too (#8802).
pub fn noop_step(s: &Session, id: &str, params: &Value) -> bool {
let Some(d) = s.active() else { return false };
let dir = match id {
"select.deselect" => return d.doc.selection.is_none(),
+17 -2
View File
@@ -291,12 +291,16 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS
return Ok(l.bounds);
}
let sel = doc.selection.clone();
let table = crate::mode_cmds::paint_color_table(doc, p);
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
let mut brush = brush;
erase_locked(&mut brush, lock, bg);
if auto_erase {
// A stroke in the table's colour starts on what the foreground painted as.
let fg = table.as_ref().map_or(fg, |t| crate::mode_cmds::table_color(t, fg));
apply_auto_erase(&mut brush, surf, pts.first(), fg, bg);
}
let unpainted = table.as_ref().map(|_| surf.clone());
let damage = if let Some(axis) = &symmetry {
let reflected = axis.reflected_passes(&pts);
let copies: Vec<_> = reflected
@@ -321,6 +325,9 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS
} else {
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
};
if let (Some(table), Some(pre)) = (&table, &unpainted) {
crate::mode_cmds::keep_in_color_table(table, pre, surf, damage);
}
Ok(damage)
})
})?;
@@ -600,9 +607,11 @@ impl LiveStroke {
let base = (st.doc.id, st.revision);
let mut doc = (*st.doc).clone();
let sel = doc.selection.clone();
let table = crate::mode_cmds::paint_color_table(&doc, p);
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?;
erase_locked(&mut brush, lock, s.tools.background);
if pencil && flag(p, "autoErase", false) {
let fg = table.as_ref().map_or(fg, |t| crate::mode_cmds::table_color(t, fg));
apply_auto_erase(&mut brush, surf, pts.first(), fg, s.tools.background);
}
let renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
@@ -676,7 +685,7 @@ impl LiveStroke {
check_coords(pts, &self.cmd)?;
self.points.extend_from_slice(pts);
let cmyk = self.cmyk.clone();
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
let dmg = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || -> Result<Rect> {
self.renderer.push(pts);
if let Some(symmetry) = &self.symmetry {
for ((mirror, distinct), reflected) in self.mirrors.iter_mut().zip(&mut self.mirror_distinct).zip(symmetry.reflected_passes(pts)) {
@@ -722,7 +731,13 @@ impl LiveStroke {
dmg = dmg.union(&self.tail);
}
Ok(dmg)
})
})?;
// The preview shows the pixels the commit keeps (see `stroke_with`).
if let Some(table) = crate::mode_cmds::paint_color_table(&self.doc, &self.params) {
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
crate::mode_cmds::keep_in_color_table(&table, &self.pre, surf, dmg);
}
Ok(dmg)
}
}
@@ -89,7 +89,7 @@ impl Core {
}
// Render the visible document including the active layer exactly once, before
// deposition. Masks, transforms, blend modes and adjustments use the compositor.
let sample = self.settings.sample_all_layers.then(|| {
let sample = (self.settings.sample_all_layers && self.settings.samples_canvas(dab)).then(|| {
let buf = photocraft_compose::render(&self.doc, r);
let fmt = self.pre.format();
let mut sample = Region::new(r, fmt.channels());
@@ -70,6 +70,29 @@ fn mixer_live_empty_layer_exact_commit_undo_source_preservation_and_chunking() {
}
}
/// A dry live stroke commits exactly its preview for either sampling setting and supports undo.
#[test]
fn dry_mixer_live_matches_commit_with_and_without_all_layer_sampling() {
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
for sample_all in [false, true] {
let mut s = fixture(fmt);
let before = target(&s);
let p =
json!({"points":[[40,48],[80,48],[120,48]],"size":24,"wet":0,"sampleAllLayers":sample_all,"cleanAfterStroke":false,"loadAfterStroke":false});
let live = LiveMixer::begin(&s, &p).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let bounds = s.active().unwrap().doc.bounds();
let preview = live.doc.layer(id).unwrap().surface().unwrap().read_region(bounds);
s.execute("paint.mixerBrush", p).unwrap();
assert_eq!(preview, target(&s).read_region(bounds));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(before.read_region(bounds), target(&s).read_region(bounds));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(preview, target(&s).read_region(bounds));
}
}
}
/// Current-layer mixing matches the frozen kernel; dropping a preview and cleaning the load paint nothing.
#[test]
fn mixer_live_current_layer_matches_reference_and_cancels() {
+10 -1
View File
@@ -61,6 +61,11 @@ pub(super) fn purge(s: &mut Session, what: &str) -> Result<Value> {
freed = freed.saturating_add(stages);
items.push("smart filter cache");
}
let sources = crate::smart_cmds::purge_source_cache();
if sources > 0 {
freed = freed.saturating_add(sources);
items.push("smart object source cache");
}
let strokes = photocraft_compose::shape_split::purge_cache();
if strokes > 0 {
freed = freed.saturating_add(strokes);
@@ -86,7 +91,11 @@ pub(super) fn can_purge_histories(s: &Session) -> std::result::Result<(), String
pub(super) fn can_purge_all(s: &Session) -> std::result::Result<(), String> {
can_purge_histories(s).or_else(|_| can_purge_clipboard(s)).or_else(|_| {
if photocraft_compose::effect_cache_bytes() > 0 || photocraft_compose::masks::cache_bytes() > 0 || crate::smart_cmds::stack_cache_bytes() > 0 {
if photocraft_compose::effect_cache_bytes() > 0
|| photocraft_compose::masks::cache_bytes() > 0
|| crate::smart_cmds::stack_cache_bytes() > 0
|| crate::smart_cmds::source_cache_bytes() > 0
{
Ok(())
} else {
Err("nothing to purge".into())
@@ -1,4 +1,4 @@
use super::damage::layer_bounds;
use super::damage::align_bounds;
use super::moves::{move_layers, with_links};
use super::selection::{selected, top_level};
use super::{Document, EngineError, LayerId, Rect, Result, Session, Value, bad, json};
@@ -44,7 +44,7 @@ impl Edge {
/// Selected top-level layers that have content bounds, bottom-to-top.
fn bounded_selection(doc: &Document, sel: &[LayerId]) -> Vec<(LayerId, Rect)> {
top_level(doc, sel).into_iter().filter_map(|id| Some((id, layer_bounds(doc.layer(id)?)?))).collect()
top_level(doc, sel).into_iter().filter_map(|id| Some((id, align_bounds(doc.layer(id)?, doc.bounds(), doc.pixel_format().sample)?))).collect()
}
pub(super) fn align(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
@@ -1,3 +1,5 @@
use photocraft_color::{PixelFormat, SampleType};
use super::{Document, Layer, LayerContent, Rect, Session, VideoData};
/// Calls `f` with the rect of every tile that differs between two surfaces of the same format and
@@ -580,8 +582,9 @@ pub(crate) fn note_insert(s: &mut Session, before: &Document) {
st.last_damage = step_damage(before, &st.doc);
}
/// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their
/// rendered pixels), or the union of a group's children. Fill and adjustment layers have none.
/// Content bounds of a layer's pixels as stored (type and shape layers: their cached render), or
/// the union of a group's children. Fill and adjustment layers have none. Cheap: Layers list,
/// artboards, copy and slices call it for every layer.
pub fn layer_bounds(l: &Layer) -> Option<Rect> {
let r = match &l.content {
LayerContent::Group(g) => g.children.iter().filter_map(layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)),
@@ -589,3 +592,68 @@ pub fn layer_bounds(l: &Layer) -> Option<Rect> {
};
(!r.is_empty()).then_some(r)
}
/// The bounds Align and Distribute move by, in a document of bounds `canvas`: [`layer_bounds`],
/// except that a shape layer is measured over everything it draws, beyond the canvas too (its
/// cache is clipped to the canvas, #8660), by rendering it again. Only those commands pay for that
/// render, once per click; `canvas` is the frame a shape's gradients are laid out in, so an opacity
/// stop measures as it renders, and `sample` is the document's sample type (see
/// [`shape_pixel_bounds`]).
pub(crate) fn align_bounds(l: &Layer, canvas: Rect, sample: SampleType) -> Option<Rect> {
let r = match &l.content {
LayerContent::Group(g) => g.children.iter().filter_map(|c| align_bounds(c, canvas, sample)).fold(Rect::EMPTY, |a, b| a.union(&b)),
LayerContent::Shape(sh) => shape_pixel_bounds(sh, canvas, sample).unwrap_or_else(|| {
let cached = l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY);
// A fill that shows everywhere covers the canvas, rendered yet or not.
if cached.is_empty() && unbounded_fill(sh) { canvas } else { cached }
}),
_ => return layer_bounds(l),
};
(!r.is_empty()).then_some(r)
}
/// Whether the shape's fill shows everywhere (an inverted path, or below 100 % density), so it has
/// no extent of its own.
fn unbounded_fill(sh: &photocraft_doc::ShapeLayer) -> bool {
sh.fill.is_some() && (sh.path.inverted || (sh.density.is_finite() && sh.density < 1.0))
}
/// Largest area [`shape_pixel_bounds`] renders to find a shape's extent; beyond it the path and
/// stroke bounds stand in.
const SHAPE_BOUNDS_MAX_PX: u64 = 64 << 20;
/// Pixels [`shape_pixel_bounds`] renders at a time.
const SHAPE_BOUNDS_BAND_PX: u64 = 1 << 20;
/// The pixels a shape layer draws, wherever they fall. Its cache is rendered clipped to the
/// canvas, so a shape partly off the canvas measured from it looked smaller and Align moved it
/// by the wrong amount, a little more on each click (#8660). The shape is rendered again over
/// everything it can draw (its path, stroke and feather), unclipped, and measured from that. A fill
/// that shows everywhere (an inverted path, or below 100 % density) has no extent of its own and
/// keeps the canvas-clipped measure (`None`).
pub(super) fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Rect, sample: SampleType) -> Option<Rect> {
if unbounded_fill(sh) {
return None;
}
let reach = photocraft_compose::shape_bounds(sh)?.inflate(1);
if u64::from(reach.width()) * u64::from(reach.height()) > SHAPE_BOUNDS_MAX_PX {
return photocraft_compose::hard_shape_bounds(sh);
}
// Gradients lay out in the document's frame (as the cached render does); the feather and the
// clip cover the shape's whole reach, beyond the canvas too.
let shape = photocraft_vector::CompiledShape::with_frames(sh, photocraft_vector::DEFAULT_TOLERANCE, reach, canvas);
// Alpha at the document's own precision, as the document stores it: an 8-bit render drops the
// faint stroke of a 16-bit or float document (the opaque body then aligns by a different
// extent than the document draws), and a float one would find alpha an 8-bit document never
// stores. A band of rows at a time keeps the working copy a few MB however large the shape.
let format = PixelFormat::new(photocraft_color::ColorMode::Grayscale, sample, true);
let band_rows = i32::try_from((SHAPE_BOUNDS_BAND_PX / u64::from(reach.width().max(1))).max(1)).unwrap_or(i32::MAX);
let mut bounds = Rect::EMPTY;
let mut top = reach.y0;
while top < reach.y1 {
let bottom = top.saturating_add(band_rows).min(reach.y1);
bounds = bounds.union(&shape.render(format, Rect::new(reach.x0, top, reach.x1, bottom)).content_bounds());
top = bottom;
}
(!bounds.is_empty()).then_some(bounds)
}
@@ -242,16 +242,18 @@ fn fill_layers_without_default_masks_keep_selection_and_explicit_path_masks() {
assert!(mask.is_none() && !vector, "{cmd}: no selection, no path: no mask at all");
}
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap();
for (cmd, params) in KINDS {
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap();
let (mask, vector) = create(&mut s, cmd, params, None);
let mask = mask.unwrap_or_else(|| panic!("{cmd}: the selection still becomes the layer mask"));
assert!(!vector, "{cmd}");
assert!(mask.surface.pixel(6, 6)[0] > 0.99, "{cmd}: inside the selection reveals");
assert!(mask.surface.pixel(20, 12)[0] < 0.01, "{cmd}: outside the selection hides");
assert!(s.active().unwrap().doc.selection.is_none(), "{cmd}: the selection went into the mask (#8801)");
}
// An explicit path wins over the selection, as with the option on.
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap();
for selected in [true, false] {
if !selected {
s.execute("select.deselect", json!({})).unwrap();
@@ -1,3 +1,6 @@
use photocraft_color::SampleType;
use super::damage::{align_bounds, shape_pixel_bounds};
use super::*;
#[test]
@@ -636,6 +639,86 @@ fn align_each_edge() {
}
}
/// #8660: a shape partly off the canvas aligns by everything it draws, not the part on the
/// canvas, so one click aligns the layers and a second moves nothing.
#[test]
fn align_uses_a_shape_layers_full_extent_off_the_canvas() {
let mut s = session(8);
let drawn = |s: &Session, id: LayerId| align_bounds(doc(s).layer(id).unwrap(), doc(s).bounds(), SampleType::U8).unwrap();
let shape = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 10, 40, 20], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap());
let measured = align_bounds(doc(&s).layer(shape).unwrap(), doc(&s).bounds(), SampleType::U8);
assert_eq!(measured, Some(Rect::new(-20, 10, 20, 30)), "Align: the shape's own extent, not the canvas-clipped render");
// Everything else keeps the cheap measure of the cached render (no re-render per layer).
assert_eq!(bounds(&s, shape), Rect::new(0, 10, 20, 30), "layer_bounds: the cache, clipped to the canvas");
let pixels = rect_layer(&mut s, Rect::new(30, 40, 60, 50));
select_all(&mut s, &[shape, pixels]);
s.execute("layer.align.leftEdges", json!({})).unwrap();
assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50));
assert_eq!(drawn(&s, shape), Rect::new(-20, 10, 20, 30));
s.execute("layer.align.leftEdges", json!({})).unwrap();
assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50), "a second click moves nothing");
// Centred: the union is -20..20 wide on the shape's side and 30 px for the pixel layer.
s.execute("layer.align.horizontalCenters", json!({})).unwrap();
assert_eq!(drawn(&s, shape), Rect::new(-20, 10, 20, 30));
assert_eq!(bounds(&s, pixels), Rect::new(-15, 40, 15, 50));
// A feathered shape is measured by its soft edge too, which reaches past the path.
let soft = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 50, 40, 20], "fill": "#00ff00"})).unwrap()["layer"].as_u64().unwrap());
s.edit("feather", |doc, _| {
if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) {
sh.feather = 4.0;
}
Ok(())
})
.unwrap();
// Measured as Align does, in the document's frame: the feather off the canvas still counts.
let b = drawn(&s, soft);
assert!(b.x0 < -20 && b.x1 > 20 && b.y0 < 50 && b.y1 > 70, "{b:?}");
// A fill that shows everywhere keeps the canvas-clipped measure.
s.edit("invert", |doc, _| {
if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) {
sh.feather = 0.0;
sh.path.inverted = true;
}
Ok(())
})
.unwrap();
let LayerContent::Shape(sh) = &doc(&s).layer(soft).unwrap().content else { panic!("shape") };
assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds(), SampleType::U8), None);
// Without a cached render yet, such a fill still measures as the canvas, not as empty.
let mut uncached = doc(&s).layer(soft).unwrap().clone();
if let LayerContent::Shape(sh) = &mut uncached.content {
sh.cache = None;
}
assert_eq!(align_bounds(&uncached, doc(&s).bounds(), SampleType::U8), Some(doc(&s).bounds()));
}
/// Review of #8779: shape extents are measured at the document's own alpha precision. A faint
/// stroke (0.001 opacity) is stored by a 16-bit or float document, so it counts in the extent
/// and Align moves the opaque body as the document draws it; an 8-bit document stores it as
/// nothing, so it does not widen the measure.
#[test]
fn align_bounds_follow_the_documents_alpha_precision() {
for (depth, sample, expected) in [
(8, SampleType::U8, Rect::new(60, 60, 120, 120)),
(16, SampleType::U16, Rect::new(50, 50, 130, 130)),
(32, SampleType::F32, Rect::new(50, 50, 130, 130)),
] {
let mut s = session(depth);
let id = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [60, 60, 60, 60], "fill": "#000000"})).unwrap()["layer"].as_u64().unwrap());
s.edit("faint stroke", |doc, _| {
if let Some(LayerContent::Shape(sh)) = doc.layer_mut(id).map(|l| &mut l.content) {
sh.stroke = Some(photocraft_doc::ShapeStroke { width: 20.0, opacity: 0.001, ..Default::default() });
}
Ok(())
})
.unwrap();
assert_eq!(doc(&s).pixel_format().sample, sample);
let LayerContent::Shape(sh) = &doc(&s).layer(id).unwrap().content else { panic!("shape") };
assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds(), sample), Some(expected), "depth {depth}");
assert_eq!(align_bounds(doc(&s).layer(id).unwrap(), doc(&s).bounds(), sample), Some(expected), "depth {depth}");
}
}
#[test]
fn align_single_layer_to_selection() {
for depth in [8, 16, 32] {
+12 -7
View File
@@ -63,7 +63,7 @@ pub fn blend_from_str(s: &str) -> Option<BlendMode> {
/// The active selection as the mask of a layer being created, or None without a selection.
/// Photoshop masks a new adjustment or fill layer with the selection, so the adjustment acts on
/// the selected area only (#1250); the selection itself stays, as Reveal Selection leaves it.
/// the selected area only (#1250). [`mask_new_layer`] then drops the selection (#8801).
pub(crate) fn selection_mask(doc: &Document) -> Result<Option<LayerMask>> {
doc.selection.as_ref().map(|sel| mask_at_depth(LayerMask { surface: sel.clone(), ..LayerMask::reveal_all() }, doc.depth)).transpose()
}
@@ -88,9 +88,10 @@ pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool
/// Mask a fill or adjustment layer being created, as Photoshop does: the path selected in the
/// Paths panel (`"path"`: `"work"`, a saved path's name or a path object) becomes its vector
/// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its
/// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768).
/// A path that isn't there is an error.
pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> {
/// layer mask ([`selection_mask`]) and is dropped, as Reveal Selection drops it (#5251, #8801);
/// without either, a white mask reveals the whole layer (#1768). A path that isn't there is an
/// error.
pub(crate) fn mask_new_layer(doc: &mut Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> {
let path = match p.get("path") {
None | Some(Value::Null) => None,
Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?),
@@ -101,9 +102,13 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str
};
match path {
Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)),
None => {
l.mask = selection_mask(doc)?.or_else(|| default_mask.then(LayerMask::reveal_all)).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?;
}
None => match selection_mask(doc)? {
Some(mask) => {
l.mask = Some(mask);
doc.selection = None;
}
None => l.mask = default_mask.then(LayerMask::reveal_all).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?,
},
}
Ok(())
}
+106 -23
View File
@@ -12,7 +12,7 @@ use photocraft_algo::transform::{Homography, Interp};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::adjust::CurvePoint;
use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size};
use photocraft_geom::Affine;
use photocraft_geom::{Affine, Rect};
use photocraft_raster::Surface;
use serde_json::{Value, json};
@@ -191,9 +191,7 @@ fn composite(doc: &Document, opaque: bool) -> Vec<[f32; 4]> {
/// Replace the document's layers by one 8-bit layer holding `px` in `mode`'s storage format.
fn single_layer(doc: &mut Document, active: &mut Option<LayerId>, mode: ColorMode, px: &[[f32; 4]], name: &str, background: bool) {
doc.mode = mode;
doc.depth = SampleType::U8;
let fmt = doc.pixel_format();
let fmt = storage_format(mode);
// Reuse one format-sized pixel buffer instead of allocating a Vec for every pixel.
let mut pixel = vec![0.0; fmt.channels()];
let mut data = Vec::new();
@@ -201,14 +199,28 @@ fn single_layer(doc: &mut Document, active: &mut Option<LayerId>, mode: ColorMod
photocraft_raster::from_rgba_into(&fmt, *q, &mut pixel);
data.extend_from_slice(&pixel);
}
let mut l = Layer::raster(name, fmt);
let mut surf = Surface::new(fmt);
surf.write_region(doc.bounds(), &data);
single_layer_surface(doc, active, mode, surf, name, background);
}
/// The pixel format of an 8-bit document in `mode`.
fn storage_format(mode: ColorMode) -> PixelFormat {
Document::pixel_format_for(mode, SampleType::U8)
}
/// [`single_layer`] with the layer's pixels already in a surface of [`storage_format`]`(mode)`.
fn single_layer_surface(doc: &mut Document, active: &mut Option<LayerId>, mode: ColorMode, pixels: Surface, name: &str, background: bool) {
doc.mode = mode;
doc.depth = SampleType::U8;
let mut l = Layer::raster(name, doc.pixel_format());
if background {
l.locks.transparency = true;
l.locks.position = true;
}
// A new raster layer always has pixels.
if let Some(surf) = l.surface_mut() {
surf.write_region(doc.bounds(), &data);
*surf = pixels;
}
*active = Some(l.id);
doc.layers = vec![l];
@@ -411,14 +423,70 @@ fn color_table(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// The colour table painting on `p`'s target must stay inside: an Indexed Color document's,
/// when the target is layer pixels (masks and channels are gray, not indexed).
pub(crate) fn paint_color_table(doc: &Document, p: &Value) -> Option<ColorTable> {
let pixels = crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels;
doc.color_table.clone().filter(|_| pixels && doc.mode == ColorMode::Indexed)
}
/// The colour `c` paints as in a document with `table` (see [`keep_in_color_table`]).
pub(crate) fn table_color(table: &ColorTable, c: [f32; 4]) -> [f32; 4] {
let mut px = [c];
if !table.colors.is_empty() {
quantize::quantize(&mut px, 1, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from));
}
let [c] = px;
c
}
/// Map the pixels of `r` that the stroke changed (they differ from `pre`) to their nearest
/// colour-table entry, as the Indexed Color conversion does with no dither, so painting never
/// leaves the table (#8669). One tile at a time: a long stroke's bounds can span the canvas.
pub(crate) fn keep_in_color_table(table: &ColorTable, pre: &Surface, surf: &mut Surface, r: Rect) {
if table.colors.is_empty() {
return;
}
let fmt = surf.format();
let n = fmt.channels();
let mut enc = [0.0f32; 8];
for tc in r.tiles() {
let t = tc.rect().intersect(&r);
if t.is_empty() || !surf.has_tiles_in(t) {
continue;
}
let (before, mut data) = (pre.read_region(t), surf.read_region(t));
let (mut changed, mut px) = (Vec::new(), Vec::new());
for (i, (q, b)) in data.chunks_exact(n).zip(before.chunks_exact(n)).enumerate() {
if q != b {
changed.push(i);
px.push(photocraft_raster::to_rgba(&fmt, q));
}
}
if px.is_empty() {
continue;
}
let w = px.len();
quantize::quantize(&mut px, w, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from));
let mut mapped = changed.into_iter().zip(px).peekable();
for (i, q) in data.chunks_exact_mut(n).enumerate() {
if let Some((_, c)) = mapped.next_if(|(j, _)| *j == i) {
photocraft_raster::from_rgba_into(&fmt, c, &mut enc);
q.iter_mut().zip(enc).for_each(|(d, v)| *d = v);
}
}
surf.write_region(t, &data);
}
}
// ---------- Bitmap ----------
/// Bitmap's Output resolution range (ppi): what a new document may have.
const BITMAP_RESOLUTION: std::ops::RangeInclusive<f64> = 1.0..=crate::document_preset_cmds::MAX_RESOLUTION;
/// Bytes per pixel of the Bitmap conversion's full-canvas working buffers at their peak (see
/// `bitmap`), on top of the document itself.
const BITMAP_WORK_BYTES_PER_PX: u64 = 40;
/// Bytes per pixel the Bitmap conversion holds besides the document: its 8-bit gray + alpha
/// result. The composite is converted in bands of [`photocraft_compose::BAND_PIXELS`] (#8477).
const BITMAP_WORK_BYTES_PER_PX: u64 = 2;
/// Bitmap's Custom Pattern: a pattern's threshold map (see
/// [`quantize::custom_pattern_thresholds`]) with its width and height.
@@ -463,12 +531,9 @@ fn bitmap(s: &mut Session, p: &Value) -> Result<Value> {
};
(side(ow)?, side(oh)?)
};
if (nw, nh) != (ow, oh) {
// Besides the resampled document, the conversion holds an RGBA f32 composite (16 B/px; 32
// while it is laid over white), the gray plane (4) and the RGBA f32 output (16) at the new
// size: budget them all before allocating, not just the resample.
crate::image_cmds::check_working_budget(CMD, &d.doc, nw, nh, BITMAP_WORK_BYTES_PER_PX, ", or a lower Output resolution")?;
}
// Besides the (resampled) document, the conversion holds its result at the new size: budget
// both before allocating.
crate::image_cmds::check_working_budget(CMD, &d.doc, nw, nh, BITMAP_WORK_BYTES_PER_PX, ", or a lower Output resolution")?;
let dpi = output as f32;
let mut custom = None;
let method = match str_or(p, "method", "diffusion") {
@@ -504,15 +569,33 @@ fn bitmap(s: &mut Session, p: &Value) -> Result<Value> {
crate::image_cmds::resample_doc(doc, nw, nh, photocraft_algo::resample::Resample::Bicubic);
}
doc.resolution_dpi = dpi;
let px = composite(doc, true);
let mut gray: Vec<f32> = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]])).collect();
let w = doc.size.width as usize;
match &custom {
Some((map, mw, mh)) => quantize::to_bitmap_custom(&mut gray, w, map, *mw, *mh),
None => quantize::to_bitmap(&mut gray, w, method),
}
let out: Vec<[f32; 4]> = gray.iter().map(|g| [*g, *g, *g, 1.0]).collect();
single_layer(doc, active, ColorMode::Bitmap, &out, "Background", true);
let mut strips = match &custom {
Some((map, mw, mh)) => quantize::BitmapStrips::custom(w, map, *mw, *mh),
None => quantize::BitmapStrips::new(w, method),
};
// Band by band, top to bottom: a full-canvas f32 composite of a 30000 px square image is
// 14 GB, and allocating it (and its copies) aborted the app (#8477).
let fmt = storage_format(ColorMode::Bitmap);
let n = fmt.channels();
let mut out = Surface::new(fmt);
let (mut gray, mut data) = (Vec::new(), Vec::new());
let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> std::result::Result<(), std::convert::Infallible> {
gray.clear();
gray.extend(band.px.iter().map(|q| {
// Over white, as the flattened image.
let a = q[3];
photocraft_color::convert::rgb_to_gray([q[0] * a + (1.0 - a), q[1] * a + (1.0 - a), q[2] * a + (1.0 - a)])
}));
strips.convert(&mut gray);
data.resize(gray.len() * n, 0.0);
for (g, px) in gray.iter().zip(data.chunks_exact_mut(n)) {
photocraft_raster::from_rgba_into(&fmt, [*g, *g, *g, 1.0], px);
}
out.write_region(band.rect, &data);
Ok(())
});
single_layer_surface(doc, active, ColorMode::Bitmap, out, "Background", true);
Ok(())
})?;
let size = s.active().ok_or(EngineError::NoDocument)?.doc.size;
+198 -6
View File
@@ -525,15 +525,13 @@ fn bitmap_rejects_bad_resolutions_and_patterns_without_editing_the_document() {
#[test]
fn bitmap_output_resolution_budgets_the_whole_conversion_not_just_the_resample() {
// 100 x 100 px at 1 ppi to 200 ppi is 20000 x 20000 px: 400 MP. Resampling an 8/16-bit gray
// document to it fits the resample budget (under 2 GB), but the conversion's f32 working
// buffers at that size (~40 B/px, 16 GB) don't: it is refused before anything is allocated.
// 100 x 100 px at 1 ppi to 1000 ppi is 100000 x 100000 px: 10 gigapixels. The resampled
// document and the conversion's 8-bit result at that size are refused before anything is
// allocated.
for depth in [8, 16] {
let mut s = ramp(100, 100, depth, 1.0);
let d = &s.active().unwrap().doc;
assert!(crate::image_cmds::check_resample_budget("t", d, 20_000, 20_000, "").is_ok(), "{depth}");
let before = s.active().unwrap().history.past_len();
let e = s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 200})).unwrap_err().to_string();
let e = s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 1000})).unwrap_err().to_string();
assert!(e.contains("megapixels") && e.contains("Output resolution"), "{depth}: {e}");
assert_eq!(s.active().unwrap().history.past_len(), before, "{depth}");
let d = doc(&s);
@@ -554,6 +552,108 @@ fn bitmap_output_resolution_budgets_the_whole_conversion_not_just_the_resample()
}
}
#[test]
fn bitmap_budgets_a_conversion_at_the_document_resolution_too() {
// #8477: without a resample nothing was budgeted, and a huge document's full-size f32
// working buffers aborted the app. 60000 x 60000 px (3.6 gigapixels, a plain white
// background shares one tile) needs more than the 8 GiB budget at any depth: refused.
let mut s = Session::new();
s.execute("file.new", json!({"width": 60_000, "height": 60_000, "mode": "grayscale"})).unwrap();
let before = s.active().unwrap().history.past_len();
let e = s.execute("image.mode.bitmap", json!({"method": "threshold"})).unwrap_err().to_string();
assert!(e.contains("megapixels"), "{e}");
assert_eq!(s.active().unwrap().history.past_len(), before);
assert_eq!(doc(&s).mode, ColorMode::Grayscale);
}
/// The Bitmap conversion of `d` the pre-#8477 way, from one full-canvas composite over white.
fn bitmap_oracle(d: &Document, method: Option<BitmapMethod>, custom: Option<(&[f32], usize, usize)>) -> Vec<f32> {
let px = photocraft_compose::flatten(d).over_background([1.0; 3]).px;
let mut gray: Vec<f32> = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]])).collect();
let w = d.size.width as usize;
match (method, custom) {
(_, Some((map, mw, mh))) => quantize::to_bitmap_custom(&mut gray, w, map, mw, mh),
(Some(m), None) => quantize::to_bitmap(&mut gray, w, m),
(None, None) => {}
}
gray
}
fn bitmap_pixels(d: &Document) -> Vec<f32> {
let surf = d.layers[0].surface().unwrap();
let mut out = Vec::new();
for y in 0..d.size.height as i32 {
for x in 0..d.size.width as i32 {
let p = surf.pixel(x, y);
assert_eq!(p[1], 1.0, "({x}, {y}) is opaque");
out.push(p[0]);
}
}
out
}
#[test]
fn bitmap_in_bands_matches_one_full_canvas_conversion() {
// #8477: the composite is converted in bands (32768 px wide: 256 rows each, so 300 rows are
// two). Diffusion carries its error across the band edge, and pattern, halftone and custom
// pattern stay anchored to the image's rows: the result equals one full-canvas conversion.
let (w, h) = (32_768u32, 300u32);
let mut s = session(w, h, 8, "grayscale");
paint(&mut s, |x, y| {
let v = ((x % 509) as f32 / 509.0 * 0.6 + (y as f32 / 300.0) * 0.4).fract();
[v, v, v, 1.0]
});
add_pattern(&mut s, "Cells", 3, 2, &[10, 200, 90, 250, 150, 40]);
let cases = [
("threshold", None, Some(BitmapMethod::Threshold)),
("pattern", None, Some(BitmapMethod::Pattern)),
("diffusion", None, Some(BitmapMethod::Diffusion)),
("halftone", Some(53.0), None),
("customPattern", None, None),
];
for (method, freq, m) in cases {
let before = doc(&s).clone();
let mut p = json!({"method": method});
if let Some(f) = freq {
p["frequency"] = json!(f);
p["angle"] = json!(30);
p["shape"] = json!("diamond");
}
if method == "customPattern" {
p["pattern"] = json!("Cells");
}
s.execute("image.mode.bitmap", p.clone()).unwrap();
let want = match method {
"halftone" => {
let cell = (before.resolution_dpi / 53.0).max(2.0);
bitmap_oracle(&before, Some(BitmapMethod::Halftone { cell, angle: 30.0, shape: HalftoneShape::Diamond }), None)
}
"customPattern" => {
let (map, mw, mh) = custom_pattern_map(&s, "t", &p).unwrap();
bitmap_oracle(&before, None, Some((&map, mw, mh)))
}
_ => bitmap_oracle(&before, m, None),
};
// Grayscale stores 0/1 the way `from_rgba` turns the old RGBA output into gray.
let want: Vec<f32> = want.iter().map(|g| photocraft_raster::from_rgba(&storage_format(ColorMode::Bitmap), [*g, *g, *g, 1.0])[0]).collect();
let got = bitmap_pixels(doc(&s));
let diff = got.iter().zip(&want).filter(|(a, b)| (**a - **b).abs() > 1e-6).count();
assert_eq!(diff, 0, "{method}: {diff} of {} pixels differ", got.len());
s.execute("edit.undo", json!({})).unwrap();
}
}
#[test]
fn bitmap_lays_a_transparent_composite_over_white() {
// Transparent pixels flatten over white before the conversion: an empty canvas is white,
// 25%-opaque black is light (75% gray) and thresholds to white, 75%-opaque black to black.
let mut s = Session::new();
s.execute("file.new", json!({"width": 3, "height": 1, "mode": "grayscale", "background": "transparent"})).unwrap();
paint(&mut s, |x, _| [0.0, 0.0, 0.0, [0.0, 0.25, 0.75][x as usize]]);
s.execute("image.mode.bitmap", json!({"method": "threshold"})).unwrap();
assert_eq!(bitmap_pixels(doc(&s)), [1.0, 1.0, 0.0]);
}
#[test]
fn duotone_inks_and_display() {
let mut s = session(16, 8, 8, "gray");
@@ -749,3 +849,95 @@ fn color_table_rejects_an_invalid_transparent_index_without_editing() {
s.execute("image.mode.colorTable", json!({"transparent": 0})).unwrap();
assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, Some(0));
}
/// A white 16×16 document in Indexed Color with the two-entry black/white table (#8669's repro).
fn black_white_indexed(transparency: bool) -> Session {
let mut s = Session::new();
let background = if transparency { "transparent" } else { "white" };
s.execute("file.new", json!({"width": 16, "height": 16, "background": background})).unwrap();
paint(&mut s, |x, _| {
if x < 2 {
[0.0; 4]
} else if x < 4 {
[0.0, 0.0, 0.0, 1.0]
} else {
[1.0; 4]
}
});
s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "transparency": transparency, "dither": "none"})).unwrap();
s
}
#[test]
fn brush_stroke_stays_in_indexed_color_table() {
let mut s = black_white_indexed(false);
assert_eq!(doc(&s).color_table.as_ref().unwrap().colors, vec![[0, 0, 0], [255, 255, 255]]);
s.execute("paint.stroke", json!({"points": [[8, 8, 1]], "size": 8, "color": "#ff0000", "opacity": 1, "flow": 1})).unwrap();
// Red's nearest entry is black.
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [0.0, 0.0, 0.0, 1.0]);
// Soft edges and partial opacity blend, and the blends map back onto the table too.
s.execute("paint.stroke", json!({"points": [[4, 12], [14, 12]], "size": 6, "hardness": 0.0, "color": "#808080", "opacity": 0.5})).unwrap();
let table = doc(&s).color_table.clone().unwrap();
assert!(colours(doc(&s)).iter().all(|c| table.colors.contains(c)), "off-table pixels: {:?}", colours(doc(&s)));
assert_eq!(doc(&s).mode, ColorMode::Indexed);
}
#[test]
fn brush_stroke_keeps_indexed_transparency_binary() {
let mut s = black_white_indexed(true);
let table = doc(&s).color_table.clone().unwrap();
assert!(table.transparent.is_some());
s.execute("paint.stroke", json!({"points": [[0, 8], [15, 8]], "size": 8, "hardness": 0.0, "color": "#ff0000", "opacity": 0.7})).unwrap();
let d = doc(&s);
let surf = d.layers[0].surface().unwrap();
for (x, y) in (0..16).flat_map(|y| (0..16).map(move |x| (x, y))) {
let a = surf.rgba(x, y)[3];
assert!(a == 0.0 || a == 1.0, "alpha {a} at ({x},{y})");
}
assert!(colours(d).iter().all(|c| table.colors.contains(c)), "off-table pixels: {:?}", colours(d));
}
#[test]
fn live_brush_preview_matches_indexed_commit() {
let mut s = black_white_indexed(false);
let params = json!({"points": [[4, 8], [12, 8]], "size": 7, "hardness": 0.0, "color": "#ff0000", "opacity": 0.6, "smoothing": 0.0, "seed": 1});
let preview = crate::brush_cmds::LiveStroke::begin(&s, &params).unwrap();
let table = doc(&s).color_table.clone().unwrap();
assert!(colours(&preview.doc).iter().all(|c| table.colors.contains(c)), "preview off the table: {:?}", colours(&preview.doc));
s.execute("paint.stroke", params).unwrap();
let (live, committed) = (preview.doc.layers[0].surface().unwrap(), doc(&s).layers[0].surface().unwrap());
for (x, y) in (0..16).flat_map(|y| (0..16).map(move |x| (x, y))) {
assert_eq!(live.rgba(x, y), committed.rgba(x, y), "preview/commit at ({x},{y})");
}
}
#[test]
fn indexed_brush_stroke_leaves_pixels_it_did_not_paint() {
let mut s = black_white_indexed(false);
// An off-table pixel inside the stroke's bounds but away from its dabs (Paint Bucket can
// still leave one) is not the stroke's to remap.
s.edit("setup", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let px = photocraft_raster::from_rgba(&surf.format(), [1.0, 0.0, 0.0, 1.0]);
surf.write_region(Rect::new(13, 2, 14, 3), &px);
Ok(())
})
.unwrap();
s.execute("paint.stroke", json!({"points": [[2, 2], [14, 14]], "size": 2, "hardness": 1.0, "color": "#ff0000", "smoothing": 0.0})).unwrap();
let surf = doc(&s).layers[0].surface().unwrap();
assert_eq!(surf.rgba(13, 2), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(surf.rgba(8, 8), [0.0, 0.0, 0.0, 1.0]);
}
#[test]
fn pencil_auto_erase_knows_the_indexed_foreground() {
let mut s = black_white_indexed(false);
let pencil = json!({"points": [[8, 8]], "size": 3, "color": "#ff0000", "autoErase": true});
s.execute("paint.pencil", pencil.clone()).unwrap();
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [0.0, 0.0, 0.0, 1.0]);
// Red painted as black, so a stroke starting there auto-erases to the (white) background.
let preview = crate::brush_cmds::LiveStroke::begin_with(&s, "paint.pencil", &pencil).unwrap();
assert_eq!(preview.doc.layers[0].surface().unwrap().rgba(8, 8), [1.0, 1.0, 1.0, 1.0]);
s.execute("paint.pencil", pencil).unwrap();
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [1.0, 1.0, 1.0, 1.0]);
}
+13
View File
@@ -69,6 +69,19 @@ pub(crate) fn check_f32_values(cmd: &str, value: &Value) -> Result<()> {
check(cmd, value, 0)
}
/// `p[key]` as CMYK inks 0..1 (`[c, m, y, k]`), the values a colour was picked as, or `None`
/// when absent or null. A CMYK document stores exactly these instead of what the colour's sRGB
/// stand-in converts back to (#4196, #8568).
pub(crate) fn cmyk_param(cmd: &str, p: &Value, key: &str) -> Result<Option<[f32; 4]>> {
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
let inks: Option<Vec<f32>> =
v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32)).collect());
match inks.as_deref() {
Some(&[c, m, y, k]) => Ok(Some([c, m, y, k])),
_ => Err(bad(cmd, format!("`{key}` must be [c, m, y, k] numbers 0..1"))),
}
}
pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> Result<[f32; 4]> {
Ok(match p.get(key) {
Some(Value::Array(a)) if a.len() >= 3 => {
+211 -28
View File
@@ -149,7 +149,17 @@ pub fn resolve(s: &Session, key: &str) -> Option<Pattern> {
pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result<Pattern> {
let key = p.get("pattern").and_then(Value::as_str).unwrap_or("");
resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)")))
let pat = resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)")))?;
paintable(cmd, pat)
}
/// `pat`, or the reason it would paint nothing: a pattern too large for the compositor renders as
/// transparent, so a command that would use one says so up front.
fn paintable(cmd: &str, pat: Pattern) -> Result<Pattern> {
match pat.paint_problem() {
Some(why) => Err(bad(cmd, format!("pattern \"{}\" would paint nothing: {why}", pat.display_name()))),
None => Ok(pat),
}
}
/// Copies `pat` into the document's patterns unless it is already there (by id).
@@ -168,6 +178,7 @@ pub fn resolve_effect(s: &Session, fx: photocraft_doc::Effect) -> Result<(photoc
Effect::PatternOverlay { common, name, id, scale, angle, link, phase } => {
let key = if id.is_empty() { name } else { id };
let pat = resolve(s, &key).ok_or_else(|| bad("layer.layerStyle.patternOverlay", format!("no pattern \"{key}\" (see pattern.list)")))?;
let pat = paintable("layer.layerStyle.patternOverlay", pat)?;
Ok((Effect::PatternOverlay { common, name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase }, Some(pat)))
}
other => Ok((other, None)),
@@ -180,6 +191,7 @@ fn info(p: &Pattern, in_doc: bool, in_library: bool) -> Value {
"id": p.id, "name": p.display_name(), "storedName": p.name, "width": p.width, "height": p.height,
"mode": format!("{:?}", f.mode), "depth": format!("{:?}", f.sample), "alpha": f.alpha,
"inDocument": in_doc, "inLibrary": in_library,
"paintable": p.is_paintable(), "paintProblem": p.paint_problem(),
})
}
@@ -190,19 +202,45 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(Value::Array(out))
}
/// The visible composite over `r` as a surface in the document's pixel format (with alpha).
fn composite_surface(doc: &Document, r: Rect) -> Surface {
let buf = photocraft_compose::render(doc, r);
/// Pixels composited at a time by [`composite_surface`]: a band's render and conversion buffers
/// stay a few tens of MB however large the pattern.
const BAND_PIXELS: u32 = 1 << 20;
/// The visible composite over `r` as a surface in the document's pixel format (with alpha), and
/// whether every pixel of it is opaque.
fn composite_surface(doc: &Document, r: Rect) -> (Surface, bool) {
composite_surface_in_bands(doc, r, (BAND_PIXELS / r.width().max(1)).max(1))
}
/// [`composite_surface`], `band_rows` rows at a time. Rendering and converting one band at a
/// time, and noting opacity as it goes, keeps an 8000 × 8000 pattern (64 MP, about 1 GB per full
/// `f32` copy) from holding several full-size copies at once. Opacity is judged on the stored
/// samples, as the surface quantizes them: an alpha of 0.999 in an 8-bit document is stored as 255.
fn composite_surface_in_bands(doc: &Document, r: Rect, band_rows: u32) -> (Surface, bool) {
let df = doc.pixel_format();
let fmt = PixelFormat::new(df.mode, df.sample, true);
let n = fmt.channels();
let mut px = vec![0.0f32; buf.px.len() * n];
for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) {
photocraft_raster::from_rgba_into(&fmt, *p, o);
}
let (width, height) = (r.width(), r.height());
let mut s = Surface::new(fmt);
s.write_region(Rect::new(0, 0, r.width() as i32, r.height() as i32), &px);
s
let mut opaque = true;
let mut px = Vec::new();
let mut top = 0;
while top < height {
let rows = band_rows.min(height - top);
let buf = photocraft_compose::render(doc, Rect::new(r.x0, r.y0 + top as i32, r.x1, r.y0 + (top + rows) as i32));
px.clear();
px.resize(buf.px.len() * n, 0.0f32);
for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) {
photocraft_raster::from_rgba_into(&fmt, *p, o);
}
let band = Rect::new(0, top as i32, width as i32, (top + rows) as i32);
s.write_region(band, &px);
if opaque {
opaque = s.read_region(band).chunks_exact(n).all(|q| q[n - 1] >= 1.0);
}
top += rows;
}
(s, opaque)
}
/// The name Define Pattern gives a pattern without one: the active document's name without its
@@ -219,6 +257,11 @@ pub fn default_name(s: &Session) -> String {
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or(base)
}
/// Longest edge Define Pattern accepts. Its area is limited too, to what the compositor holds
/// (`Pattern::working_bytes`): 8000 x 8000 at 8 bits, and fewer pixels at 16 bits and float,
/// where the working copy is larger per pixel. What is accepted always paints.
const MAX_DEFINE_EDGE: u32 = 8000;
fn define(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.definePattern";
let st = s.active().ok_or(EngineError::NoDocument)?;
@@ -234,14 +277,20 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
if r.is_empty() {
return Err(bad(cmd, "the area is empty"));
}
if r.width() > 4000 || r.height() > 4000 {
return Err(bad(cmd, "patterns are limited to 4000 × 4000 px"));
if r.width() > MAX_DEFINE_EDGE || r.height() > MAX_DEFINE_EDGE {
return Err(bad(cmd, "patterns are limited to 8000 × 8000 px"));
}
let mut surf = composite_surface(doc, r);
// A pattern the compositor could not hold would define but paint nothing: 8 bits go to the
// edge limit, 16-bit documents to 33,554,432 px in all, float to 16,777,216 (4096 × 4096).
let sample = doc.pixel_format().sample;
let max_px = photocraft_doc::pattern::max_paint_pixels(sample);
if u64::from(r.width()) * u64::from(r.height()) > max_px {
return Err(bad(cmd, format!("patterns in a {}-bit document are limited to {max_px} px in all", sample.bytes() * 8)));
}
let (mut surf, opaque) = composite_surface(doc, r);
// Opaque patterns drop their alpha channel (as Photoshop does).
let fmt = surf.format();
let px = surf.read_region(Rect::new(0, 0, r.width() as i32, r.height() as i32));
if px.chunks_exact(fmt.channels()).all(|q| q[fmt.channels() - 1] >= 1.0) {
if opaque {
let fmt = surf.format();
surf = surf.convert(PixelFormat::new(fmt.mode, fmt.sample, false));
}
let name = match p.get("name").and_then(Value::as_str) {
@@ -369,15 +418,15 @@ fn brush_texture(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("brush.texturePattern", "the pattern is empty or too large to paint"));
};
// Brush textures are grey: luminance of the composite over white.
let mut px = vec![[0.0f32; 4]; n];
pat.surface.read_rgba_into(pat.rect(), &mut px);
let lum: Vec<f32> = px
.iter()
.map(|q| {
// Read a band of rows at a time: a whole-tile RGBA copy would cost 16 bytes a pixel.
let mut lum: Vec<f32> = Vec::new();
lum.try_reserve_exact(n).map_err(|_| bad("brush.texturePattern", "not enough memory for the pattern"))?;
for_each_band(&pat, pat.width, pat.height, |band| {
lum.extend(band.iter().map(|q| {
let l = 0.299 * q[0] + 0.587 * q[1] + 0.114 * q[2];
l * q[3] + (1.0 - q[3])
})
.collect();
}));
});
let tile = photocraft_paint::GrayTile::from_f32(pat.width, pat.height, &lum);
let t = &mut s.tools.brush.texture;
t.pattern = photocraft_paint::Pattern::Tile(tile);
@@ -393,13 +442,25 @@ pub fn average_color(p: &Pattern) -> Color {
// A pattern too large to paint is averaged over its paintable top-left corner.
let side = photocraft_doc::pattern::MAX_PAINT_PIXELS.isqrt() as u32;
let (w, h) = (p.width.min(side), p.height.min(side));
let mut px = vec![[0.0f32; 4]; w as usize * h as usize];
p.surface.read_rgba_into(Rect::new(0, 0, w as i32, h as i32), &mut px);
let n = px.len().max(1) as f32;
let s = px.iter().fold([0.0f32; 3], |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]]);
let mut s = [0.0f32; 3];
for_each_band(p, w, h, |band| s = band.iter().fold(s, |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]]));
let n = (u64::from(w) * u64::from(h)).max(1) as f32;
Color::rgb(s[0] / n, s[1] / n, s[2] / n)
}
/// Calls `f` with the straight RGBA pixels of the top-left `w × h` corner of `p`, a band of rows
/// at a time in row-major order.
fn for_each_band(p: &Pattern, w: u32, h: u32, mut f: impl FnMut(&[[f32; 4]])) {
let mut band = Vec::new();
for top in (0..h).step_by(photocraft_doc::pattern::BAND_ROWS as usize) {
let bottom = (top + photocraft_doc::pattern::BAND_ROWS).min(h);
band.clear();
band.resize(w as usize * (bottom - top) as usize, [0.0f32; 4]);
p.surface.read_rgba_into(Rect::new(0, top as i32, w as i32, bottom as i32), &mut band);
f(&band);
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec {
@@ -551,6 +612,128 @@ mod tests {
}
}
/// Review of the 8000 px cap: compositing a band of rows at a time gives the pixels and the
/// opacity of one pass, at every depth, whatever the band height.
#[test]
fn banded_compositing_matches_a_single_pass() {
for depth in [8u64, 16, 32] {
for transparent in [false, true] {
let mut s = Session::new();
let background = if transparent { "transparent" } else { "white" };
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": background})).unwrap();
s.execute("select.rect", json!({"x": 3, "y": 4, "width": 20, "height": 13})).unwrap();
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
let doc = &s.active().unwrap().doc;
let area = Rect::new(2, 1, 38, 29);
let all = Rect::new(0, 0, 36, 28);
let (whole, whole_opaque) = composite_surface_in_bands(doc, area, 28);
for band_rows in [1, 5, 27] {
let (banded, opaque) = composite_surface_in_bands(doc, area, band_rows);
assert_eq!(opaque, whole_opaque, "depth {depth}, transparent {transparent}, bands of {band_rows}");
assert_eq!(banded.read_region(all), whole.read_region(all), "depth {depth}, transparent {transparent}, bands of {band_rows}");
}
assert_eq!(whole_opaque, !transparent);
}
}
}
/// Review of the banded composite: opacity is the stored samples', so an alpha an 8-bit
/// document stores as 255 is opaque and a float document's 0.999 is not.
#[test]
fn alpha_that_stores_as_opaque_counts_as_opaque() {
for (depth, opaque) in [(8u64, true), (32, false)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth, "background": "transparent"})).unwrap();
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
s.execute("layer.setProps", json!({"opacity": 0.999})).unwrap();
let (_, all_opaque) = composite_surface_in_bands(&s.active().unwrap().doc, Rect::new(0, 0, 8, 8), 3);
assert_eq!(all_opaque, opaque, "depth {depth}");
}
}
/// Review of #8902: what Define Pattern accepts, the compositor paints. Areas over
/// 4096 x 4096, past the compositor's whole-copy cap, define and fill at 8 and 16 bits (held
/// at the pattern's own depth); a depth that has no smaller exact form is refused up front
/// with its limit, instead of defining a pattern that paints nothing.
#[test]
fn defined_patterns_over_4096_squared_paint_or_are_refused() {
for depth in [8, 16] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": depth})).unwrap();
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
s.execute("edit.definePattern", json!({"name": "Big"})).unwrap();
assert!(s.patterns.items.iter().any(|p| p.name == "Big" && p.is_paintable()), "depth {depth}");
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap();
s.execute("layer.newFillLayer.pattern", json!({"pattern": "Big"})).unwrap();
let [r, g, b, a] = pixel(&s, 5, 5);
assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "depth {depth}: {:?}", [r, g, b, a]);
}
let mut s = Session::new();
s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": 32})).unwrap();
let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string();
assert!(e.contains("32-bit") && e.contains("16777216"), "{e}");
assert!(s.patterns.items.iter().all(|p| p.name != "Pattern"), "nothing was defined");
let mut s = Session::new();
s.execute("file.new", json!({"width": 6000, "height": 5600, "depth": 16})).unwrap();
let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string();
assert!(e.contains("16-bit") && e.contains("33554432"), "{e}");
}
/// A pattern of the largest area Define Pattern accepts at 8 bits is paintable: held at 4
/// bytes a pixel, 256 MB, as the compositor's cap allows, and it renders.
#[test]
fn an_8000_by_8000_pattern_is_defined_and_paints() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8000, "height": 8000, "depth": 8})).unwrap();
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
s.execute("edit.definePattern", json!({"name": "Largest"})).unwrap();
let p = s.patterns.items.iter().find(|p| p.name == "Largest").unwrap();
assert_eq!((p.width, p.height), (8000, 8000));
assert!(p.is_paintable() && p.working_bytes() == 256_000_000);
s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap();
s.execute("layer.newFillLayer.pattern", json!({"pattern": "Largest"})).unwrap();
let [r, g, b, a] = pixel(&s, 5, 5);
assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "{:?}", [r, g, b, a]);
}
/// Review of #8902: a pattern too large to paint is named by every command that would use it
/// and by pattern.list, instead of rendering as transparency with no message.
#[test]
fn a_pattern_that_would_paint_nothing_is_reported() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap();
// A 32-bit 5000 x 5000 pattern (declared only: 400 MB as a working copy, past the cap).
let surface = photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA32F);
s.patterns.items.push(Pattern { id: "huge".into(), name: "Huge".into(), width: 5000, height: 5000, surface });
let list = s.execute("pattern.list", json!({})).unwrap();
let huge = list.as_array().unwrap().iter().find(|p| p["name"] == "Huge").unwrap().clone();
assert_eq!(huge["paintable"], json!(false));
assert!(huge["paintProblem"].as_str().unwrap().contains("32 bits"), "{huge}");
for (id, params) in [
("layer.newFillLayer.pattern", json!({"pattern": "Huge"})),
("layer.layerStyle.patternOverlay", json!({"pattern": "Huge"})),
("edit.fill", json!({"contents": "pattern", "pattern": "Huge"})),
("brush.texturePattern", json!({"pattern": "Huge"})),
] {
let e = s.execute(id, params).expect_err(id).to_string();
assert!(e.contains("Huge") && e.contains("would paint nothing"), "{id}: {e}");
}
let e = s.execute("layer.newFillLayer.pattern", json!({"pattern": "Huge"})).unwrap_err().to_string();
assert!(e.contains("would paint nothing") && e.contains("5000 × 5000"), "{e}");
assert!(list.as_array().unwrap().iter().any(|p| p["name"] != "Huge" && p["paintable"] == json!(true)), "built-in patterns paint");
}
#[test]
fn define_pattern_accepts_edges_over_4000_up_to_8000() {
for (w, ok) in [(4100, true), (8000, true), (8001, false)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": 2, "depth": 8})).unwrap();
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
let r = s.execute("edit.definePattern", json!({}));
assert_eq!(r.is_ok(), ok, "width {w}: {r:?}");
}
}
#[test]
fn pattern_fill_layer_renders_at_every_depth_and_undoes() {
for depth in [8u64, 16, 32] {
@@ -642,6 +825,6 @@ mod tests {
let huge = Pattern { id: "huge".into(), name: "Huge".into(), width: 65_536, height: 65_536, surface: Surface::new(PixelFormat::RGBA8) };
s.patterns.items.push(huge);
let e = s.execute("brush.texturePattern", json!({"pattern": "huge"})).unwrap_err();
assert!(e.to_string().contains("too large"), "{e}");
assert!(e.to_string().contains("would paint nothing") && e.to_string().contains("65536 × 65536"), "{e}");
}
}
+87 -3
View File
@@ -250,8 +250,8 @@ enum SpoolError {
/// Sends the job and says where it went and through what. A named print server takes the IPP
/// route. Otherwise: the app's spooler when it is preferred; else `craft-print` (`lp`, or the
/// Windows spooler) when it is there; else CUPS over IPP; and the app's spooler when that had
/// no route either. A route that exists but fails (an unknown printer, a refused job) is an
/// Windows spooler) when it is there, or the app's spooler when CUPS lists no printer for `lp`;
/// else CUPS over IPP; and the app's spooler when that had no route either. A route that exists but fails (an unknown printer, a refused job) is an
/// error, never a fall-through.
fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -> Result<(String, String)> {
let app = |sp: &Spooler| (sp.run)(job).map(|m| (m, sp.name.to_string())).map_err(other);
@@ -261,7 +261,19 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -
}
if let Some(name) = system_spooler() {
require_windows_pdf()?;
return with_spooler(|| craft_print::submit(&job.pdf, lp)).map(|m| (m, name.to_string())).map_err(other);
// lpstat is CUPS, not a Windows driver, so it runs outside SPOOLER. A listing that
// can't finish in time can't tell, so lp gets the job as it did before the check.
let route = match name {
"lp" => lp_printers(LP_LISTING_TIMEOUT).map_or_else(|| Some(lp.clone()), |list| lp_route(lp, &list)),
_ => Some(lp.clone()),
};
return match (route, spooler) {
(Some(lp), _) => with_spooler(|| craft_print::submit(&job.pdf, &lp)).map(|m| (m, name.to_string())).map_err(other),
// CUPS lists no printer. IPP would ask the same CUPS, or (it ignores client.conf)
// another server whose printer the user never chose, so only the portal is left.
(None, Some(sp)) => app(sp),
(None, None) => Err(other("no printers found; set one up in the system's printer settings, or print to PDF")),
};
}
}
match cups_spool(job) {
@@ -274,6 +286,78 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -
}
}
/// How long a print job waits for CUPS' printer list (`lpstat`, whose `-e` browses the network).
const LP_LISTING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// The most a printer listing may print; a longer one counts as no answer.
const LP_LISTING_CAP: u64 = 1 << 20;
/// CUPS' printers as `craft_print::printers` lists them, or `None` when that can't be told: an
/// `lpstat` can't start, outlasts `timeout`, prints more than `LP_LISTING_CAP`, or `lpstat -e`
/// fails (CUPS unreachable, or listing forbidden by policy). `lpstat -p -d`'s own status is not
/// a failure: it exits 1 whenever there are no queues, as on a host with only driverless
/// printers. Each `lpstat` is killed and reaped at the deadline, so a hung one never outlives
/// the job.
fn lp_printers(timeout: std::time::Duration) -> Option<Vec<craft_print::Printer>> {
let deadline = std::time::Instant::now() + timeout;
let (_, queues) = output_by(craft_print::lpstat_command(), deadline)?;
// `lpstat -e`, as craft-print runs it (its builder is private): the driverless printers.
let mut driverless = std::process::Command::new("lpstat");
driverless.arg("-e").env("LC_ALL", "C").env("LANG", "C").env("SOFTWARE", "Craft");
let (listed, available) = output_by(driverless, deadline)?;
listed.then(|| craft_print::printers_parsed(&queues, Some(&available)))
}
/// Whether `command` succeeded, and its standard output; `None` when it can't start, prints more
/// than `LP_LISTING_CAP`, or is still running at `deadline`, when it is killed and reaped. The
/// output is read on its own thread, so a full pipe can't stall the wait, and that thread ends
/// when the process does.
fn output_by(mut command: std::process::Command, deadline: std::time::Instant) -> Option<(bool, String)> {
use std::io::Read as _;
use std::process::Stdio;
let mut child = command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
let mut stdout = child.stdout.take()?;
let (sender, receiver) = std::sync::mpsc::channel();
let reader = std::thread::Builder::new().name("print-listing".into()).spawn(move || {
let mut out = Vec::new();
// One byte over the cap tells an oversized listing apart; stopping closes the pipe.
let _ = sender.send((&mut stdout).take(LP_LISTING_CAP + 1).read_to_end(&mut out).map(|_| out));
});
let status = reader.ok().and_then(|_| {
loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)),
_ => break None,
}
}
});
let Some(status) = status else {
let _ = child.kill();
let _ = child.wait();
return None;
};
let out = receiver.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())).ok()?.ok()?;
let fits = u64::try_from(out.len()).is_ok_and(|n| n <= LP_LISTING_CAP);
fits.then(|| (status.success(), String::from_utf8_lossy(&out).into_owned()))
}
/// The job for `lp`, or `None` when no printer was asked for and CUPS lists none: `lp` has
/// nowhere to send it, and the portal still may (a Flatpak bundles `lp`). A printer asked for by
/// name always goes to `lp`, which knows it even where CUPS won't list it, and the portal could
/// not honour the name. With no printer asked for, no default and one printer, that printer, as
/// `resolve_printer` picks for IPP.
fn lp_route(lp: &craft_print::Job, printers: &[craft_print::Printer]) -> Option<craft_print::Job> {
let mut lp = lp.clone();
if lp.printer.is_none()
&& !printers.iter().any(|p| p.default)
&& let [only] = printers
{
lp.printer = Some(only.name.clone());
}
(lp.printer.is_some() || !printers.is_empty()).then_some(lp)
}
/// The spooler `craft-print` can talk to, as the job's `backend` name: `lp` when CUPS' command
/// is on the path, the Windows spooler on Windows. `None` in a sandbox without `lp` and in the
/// browser.
+52
View File
@@ -678,6 +678,14 @@ fn spool_transport_streams_pdf_and_propagates_refusal() {
b"#!/bin/sh\nif [ \"$2\" = Refuse ]; then echo 'queue refused' >&2; exit 1; fi\nprintf '%s\\n' \"$@\" > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/args\"\n/bin/cat > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/received.pdf\"\necho 'request id is Office-42 (0 file(s))'\n",
).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
// `lp` takes a job only when CUPS lists a printer (`lp_route`), so the fake CUPS lists its queues.
let lpstat = dir.path().join("lpstat");
std::fs::write(
&lpstat,
b"#!/bin/sh\nif [ \"$1\" = -e ]; then printf 'Office\\nRefuse\\n'; else printf 'printer Office is idle.\\nprinter Refuse is idle.\\n'; fi\n",
)
.unwrap();
std::fs::set_permissions(&lpstat, std::fs::Permissions::from_mode(0o700)).unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "cmd::print_cmds::tests::spool_transport_streams_pdf_and_propagates_refusal", "--nocapture"])
.env(CHILD, dir.path())
@@ -972,6 +980,50 @@ fn a_preferred_app_spooler_takes_the_job() {
assert!(std::path::Path::new(&out).exists());
}
#[cfg(unix)]
#[test]
fn a_printer_listing_past_its_deadline_is_killed_and_reaped() {
let soon = || std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut echo = std::process::Command::new("sh");
echo.args(["-c", "echo printer Office is idle."]);
assert_eq!(output_by(echo, soon()), Some((true, "printer Office is idle.\n".to_string())));
let mut failed = std::process::Command::new("sh");
failed.args(["-c", "exit 1"]);
assert_eq!(output_by(failed, soon()), Some((false, String::new())), "a failed listing is told apart from an empty one");
let mut flood = std::process::Command::new("sh");
flood.args(["-c", "head -c 2000000 /dev/zero"]);
assert_eq!(output_by(flood, soon()), None, "an oversized listing is no answer");
// A hung lpstat: given up at the deadline, and killed, so nothing is left waiting on it.
let dir = tempfile::tempdir().unwrap();
let pid_file = dir.path().join("pid");
let mut hung = std::process::Command::new("sh");
hung.arg("-c").arg(format!("echo $$ > '{}'; exec sleep 30", pid_file.display()));
let started = std::time::Instant::now();
assert_eq!(output_by(hung, started + std::time::Duration::from_millis(300)), None);
assert!(started.elapsed() < std::time::Duration::from_secs(5), "the deadline must cut the wait short");
let pid = std::fs::read_to_string(&pid_file).unwrap();
// `kill -0` fails for a process that no longer exists, on every Unix.
let alive = std::process::Command::new("kill").args(["-0", pid.trim()]).stderr(std::process::Stdio::null()).status().unwrap().success();
assert!(!alive, "the hung process must be killed and reaped");
assert_eq!(output_by(std::process::Command::new("/nonexistent/lpstat"), soon()), None);
}
#[test]
fn lp_takes_the_job_only_when_cups_lists_a_printer_and_picks_the_only_one() {
let p = |name: &str, default: bool| craft_print::Printer { name: name.into(), default, two_sided: None, description: String::new() };
let job = craft_print::Job::default();
// No printers: no lp route, so the portal still gets the job.
assert!(lp_route(&job, &[]).is_none());
assert_eq!(lp_route(&job, &[p("Office", false)]).and_then(|j| j.printer), Some("Office".into()));
assert_eq!(lp_route(&job, &[p("Office", false), p("Lab", true)]).and_then(|j| j.printer), None, "lp picks the default itself");
assert_eq!(lp_route(&job, &[p("Office", false), p("Lab", false)]).and_then(|j| j.printer), None);
let asked = craft_print::Job { printer: Some("Lab".into()), ..Default::default() };
assert_eq!(lp_route(&asked, &[p("Office", false)]).and_then(|j| j.printer), Some("Lab".into()));
// A named printer goes to lp even when CUPS lists none: listing can be forbidden while
// printing is not, and the portal can't honour a name.
assert_eq!(lp_route(&asked, &[]).and_then(|j| j.printer), Some("Lab".into()));
}
#[cfg(not(target_os = "windows"))]
mod cups {
use super::*;
+41 -51
View File
@@ -13,6 +13,11 @@ use crate::{EngineError, Result, Session};
mod modify_window;
#[cfg(test)]
mod color_range_performance;
#[cfg(test)]
mod color_range_window_tests;
fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
@@ -45,15 +50,18 @@ fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionM
}
/// RGBA pixels the tools sample: the composite, or the active layer.
fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> {
fn sample_pixels(s: &Session, all_layers: bool, window: Option<Rect>) -> Result<(Rect, Vec<[f32; 4]>)> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let area = d.doc.bounds();
let area = window.map_or(d.doc.bounds(), |r| r.intersect(&d.doc.bounds()));
// Match the decoders' raster budget before either the layer or composite path allocates.
let len = area.size().area();
let max_bytes: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };
if len > max_bytes / std::mem::size_of::<[f32; 4]>() as u64 {
return Err(EngineError::Other("the canvas is too large to sample for a selection".into()));
}
if area.is_empty() {
return Ok((area, Vec::new()));
}
let layer = d.active_layer.and_then(|id| d.doc.layer(id)).and_then(|l| l.surface());
match (all_layers, layer) {
(false, Some(surf)) => {
@@ -149,31 +157,6 @@ fn parse_color(p: &Value) -> [f32; 3] {
}
}
/// Eyedropper samples from `key` (`[[x, y], …]` in document pixels): the colour under each point
/// and where it was picked (area-local, for Localized Color Clusters).
fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &impl Fn(String) -> EngineError) -> Result<Vec<sel::RangeSample>> {
let Some(pts) = p.get(key) else { return Ok(Vec::new()) };
let pts = pts.as_array().ok_or_else(|| bad(format!("`{key}` must be [[x, y], …]")))?;
let w = area.width() as usize;
let mut out = Vec::with_capacity(pts.len().min(4096));
for pt in pts {
let xy = pt.as_array().and_then(|a| match a.as_slice() {
[x, y] => Some((x.as_f64()?, y.as_f64()?)),
_ => None,
});
let Some((x, y)) = xy.filter(|(x, y)| x.is_finite() && y.is_finite()) else {
return Err(bad(format!("bad point {pt} in `{key}` (want [x, y])")));
};
let (xi, yi) = (x.floor() as i32, y.floor() as i32);
let q = if area.contains(xi, yi) { px.get((yi - area.y0) as usize * w + (xi - area.x0) as usize) } else { None };
let Some(&[r, g, b, _]) = q else {
return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas")));
};
out.push(sel::RangeSample { color: [r, g, b], at: Some(((xi - area.x0) as f32, (yi - area.y0) as f32)) });
}
Ok(out)
}
/// Composite RGB (the compositor's output profile for the document) → CIE Lab D50.
fn lab_transform(s: &Session) -> Result<std::sync::Arc<photocraft_cms::Transform>> {
let d = s.active().ok_or(EngineError::NoDocument)?;
@@ -346,6 +329,23 @@ impl ColorRangeSamples {
}
}
/// Replace refines the existing selection; no output outside its nonzero bounds survives.
fn color_range_area(doc: &Document, m: SelectionMode) -> Rect {
let canvas = doc.bounds();
let Some(selection) = doc.selection.as_ref().filter(|_| m == SelectionMode::Replace) else { return canvas };
// content_bounds describes differences from the default, not implicit selected pixels.
if selection.default_pixel().first().is_some_and(|v| *v != 0.0) {
return canvas;
}
// content_bounds orders raw tile coordinates by their differences. Reject coordinates
// outside the pixel-addressable range before those differences can overflow.
let valid = (i32::MIN / photocraft_geom::TILE_SIZE)..=(i32::MAX / photocraft_geom::TILE_SIZE);
if selection.tiles().any(|(tile, _)| !valid.contains(&tile.tx) || !valid.contains(&tile.ty)) {
return canvas;
}
selection.content_bounds().intersect(&canvas)
}
fn color_range(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "select.colorRange";
let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg };
@@ -357,17 +357,16 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> {
if b(p, "detectFaces", false) {
return Err(bad("detectFaces: face detection isn't available yet; select without it".into()));
}
let area = color_range_area(&s.active().ok_or(EngineError::NoDocument)?.doc, selection_mode);
let (area, mut mask) = match preset {
"sampledColors" => {
let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;
let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?;
let w = area.width() as usize;
// The dialog's eyedroppers: colours picked on the image, at their positions.
// Like Photoshop's, they may sample anywhere on the canvas: an active selection only
// limits the result (scope_color_range), not where colours are picked (#5244).
let plus = eyedropper_samples(p, "points", area, &px, &bad)?;
let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?;
let query = ColorRangeSamples::with_samples(s, p, area, &plus, &minus)?;
let mask = query.coverage(&px, w, 1.0, [0.0, 0.0]);
let query = ColorRangeSamples::new(s, p)?;
let mask = query.coverage(&px, w, 1.0, [area.x0 as f32, area.y0 as f32]);
(area, mask)
}
"reds" | "yellows" | "greens" | "cyans" | "blues" | "magentas" => {
@@ -379,11 +378,11 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> {
"blues" => 240.0,
_ => 300.0,
};
let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;
let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?;
(area, sel::hue_range(&px, center))
}
"skinTones" => {
let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;
let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?;
(area, sel::skin_tones(&px, fuzz(40.0).min(200.0)))
}
"highlights" | "midtones" | "shadows" => {
@@ -406,7 +405,7 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|(l, h)| l <= h)
.ok_or_else(|| bad(format!("tonalRange: want [low, high] levels 0..255 (got {v})")))?,
};
let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;
let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?;
(area, sel::tone_range(&px, lo, hi, fuzz(20.0).min(100.0) / 100.0 * 255.0))
}
"outOfGamut" => {
@@ -458,16 +457,16 @@ fn modify(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
if canvas.size().area() > MAX_MODIFY_PIXELS as u64 {
return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: "the selection is too large to modify".into() });
}
// Feather reaches ⌈3σ⌉ (σ = r, as in Photoshop); the others reach at most ceil(r).
let reach = if op == "feather" { sel::feather_reach(r) } else { r.ceil() as usize };
// Keep both mask conversion and tiled replacement within the affected region. The
// zero halo makes internal window edges inert; nonzero edges can only be canvas edges,
// so the existing Apply effect at canvas bounds padding still has the same meaning.
let area = if matches!(op, "expand" | "contract" | "border") { modify_window::area(doc, r) } else { canvas };
let area = modify_window::area(doc, reach as f32);
let m = sel::mask_from_surface(doc.selection.as_ref(), area);
let (w, h) = (area.width() as usize, area.height() as usize);
// Feather reaches ⌈3σ⌉ (σ = r, as in Photoshop); the others reach r. A margin past that
// keeps the canvas edge out of reach. A selection clear of the canvas edge needs none: both
// readings agree there.
let reach = if op == "feather" { sel::feather_reach(r) } else { r.ceil() as usize };
// A margin past the reach keeps the padded edge out of reach. A selection clear of the
// canvas edge needs none: both readings agree there.
let pad = if touches_edge(&m, w, h) { reach.saturating_add(2) } else { 0 };
let grown = |n: usize| pad.checked_mul(2).and_then(|p| n.checked_add(p));
let (Some(pw), Some(ph)) = (grown(w), grown(h)) else {
@@ -545,7 +544,7 @@ fn crop_mask(m: &[f32], pw: usize, pad: usize, w: usize, h: usize) -> Vec<f32> {
/// the selected pixels' colour range widened by the tolerance.
fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result<Value> {
let tol = f(p, "tolerance", 32.0) / 255.0;
let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false))?;
let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false), None)?;
let (_, m) = current_mask(&s.active().ok_or(EngineError::NoDocument)?.doc);
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
for (q, v) in px.iter().zip(&m) {
@@ -656,16 +655,7 @@ fn polygon_rows(pts: &[(f32, f32)], area: Rect) -> Rect {
/// whole-canvas combine gives: Replace and Intersect are empty outside the band, and Add and
/// Subtract keep the old selection there.
pub(crate) fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, m: SelectionMode) -> Option<Surface> {
let inside = sel::combine(old, mask, band, m);
let Some(old) = old.filter(|_| matches!(m, SelectionMode::Add | SelectionMode::Subtract)) else { return inside };
if band.is_empty() {
return Some(old.clone());
}
let values = inside.map_or_else(|| vec![0.0; band.width() as usize * band.height() as usize], |w| w.read_region(band));
let mut s = old.clone();
s.write_region(band, &values);
s.prune();
(s.tile_count() > 0).then_some(s)
sel::combine_band(old, mask, band, m)
}
/// Keep dense feather masks local to the drawn shape. Clip before inflating: pixels outside
@@ -0,0 +1,70 @@
//! Opt-in release comparison; full-canvas buffers belong on a CI runner.
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Layer, LayerContent, Size};
use std::time::Instant;
fn reference(s: &mut Session, p: &Value) {
let (canvas, px) = sample_pixels(s, true, None).unwrap();
// Previous path: eyedropper colour came from the full composite already in memory.
let [r, g, b, _] = px[1500 * canvas.width() as usize + 2000];
let point = sel::RangeSample { color: [r, g, b], at: Some((2000.0, 1500.0)) };
let query = ColorRangeSamples::with_samples(s, p, canvas, &[point], &[]).unwrap();
let mut mask = query.coverage(&px, canvas.width() as usize, 1.0, [0.0, 0.0]);
drop(px);
scope_color_range(&mut mask, &s.active().unwrap().doc, canvas, SelectionMode::Replace);
set_selection(s, "Color Range", canvas, mask, SelectionMode::Replace).unwrap();
}
#[test]
#[ignore = "24 MP release comparison: run on GitHub Actions"]
fn color_range_release_comparison() {
let mut doc = Document::new("Color Range performance", Size::new(6000, 4000), ColorMode::Rgb, SampleType::U8);
let mut pixels = Surface::with_default(PixelFormat::RGBA8, &[0.65, 0.3, 0.2, 1.0]);
pixels.fill_rect(Rect::new(3000, 2000, 3600, 2400), &[0.2, 0.3, 0.65, 1.0]);
doc.layers.push(Layer::new("image", LayerContent::Raster(pixels)));
let p = json!({"points": [[2000, 1500]], "fuzziness": 40, "localized": true, "range": 80});
// Small, 1%, 4%, and full selections: include dense fallback cost in the comparison.
for bounds in [Rect::new(2975, 1975, 3025, 2025), Rect::new(2700, 1800, 3300, 2200), Rect::new(2400, 1600, 3600, 2400), doc.bounds()] {
let mut selection = Surface::new(PixelFormat::GRAY8);
selection.fill_rect(bounds, &[128.0 / 255.0]);
doc.selection = Some(selection);
let mut old_times = Vec::new();
let mut new_times = Vec::new();
let mut expected = None;
// One warmup pair, followed by three alternating measured pairs.
for round in 0..4 {
for old in if round % 2 == 0 { [true, false] } else { [false, true] } {
let mut session = Session::new();
session.add_document(doc.clone(), None);
let now = Instant::now();
if old {
reference(&mut session, &p);
} else {
session.execute("select.colorRange", p.clone()).unwrap();
}
let elapsed = now.elapsed().as_secs_f64() * 1000.0;
if round > 0 {
if old { old_times.push(elapsed) } else { new_times.push(elapsed) }
}
// Outside timing, compare every stored sample over the whole canvas.
let actual = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds());
if let Some(ref expected) = expected {
assert_eq!(&actual, expected);
} else {
expected = Some(actual);
}
}
}
old_times.sort_by(f64::total_cmp);
new_times.sort_by(f64::total_cmp);
let (old, new) = (old_times[1], new_times[1]);
println!(
"24 MP Color Range {}x{}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x rgba_bytes={} exact=true",
bounds.width(),
bounds.height(),
old / new,
bounds.size().area() * 16
);
}
}
@@ -0,0 +1,211 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Size;
use serde_json::json;
fn fixture(depth: SampleType, p3: bool) -> Session {
let mut s = Session::new();
let mut doc = Document::with_background("color range", Size::new(64, 48), ColorMode::Rgb, depth, photocraft_doc::Color::WHITE);
if p3 {
doc.icc_profile = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes());
} else {
doc.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes());
}
let bg = doc.layers[0].surface_mut().unwrap();
bg.fill_rect(Rect::new(0, 0, 64, 48), &[0.92, 0.92, 0.92, 1.0]);
bg.fill_rect(Rect::new(7, 8, 22, 23), &[0.85, 0.12, 0.08, 1.0]);
bg.fill_rect(Rect::new(35, 8, 50, 23), &[0.12, 0.72, 0.18, 1.0]);
bg.fill_rect(Rect::new(7, 29, 22, 44), &[0.12, 0.20, 0.86, 1.0]);
bg.fill_rect(Rect::new(35, 29, 50, 44), &[0.72, 0.48, 0.31, 1.0]);
s.add_document(doc, None);
s
}
fn coverage_at(s: &Session, x: i32, y: i32) -> f32 {
s.active().and_then(|d| d.doc.selection.as_ref()).map_or(0.0, |m| m.sample_channel(x, y, 0))
}
fn full_sampled_reference(s: &Session, params: &Value) -> Vec<f32> {
let doc = &s.active().unwrap().doc;
let canvas = doc.bounds();
let (_, px) = sample_pixels(s, b(params, "sampleAllLayers", true), None).unwrap();
// Reproduce the previous command: read eyedropper colours from the full composite.
let samples = |key: &str| -> Vec<sel::RangeSample> {
params
.get(key)
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|point| {
let x = point[0].as_f64().unwrap().floor() as usize;
let y = point[1].as_f64().unwrap().floor() as usize;
let pixel = px[y * canvas.width() as usize + x];
sel::RangeSample { color: [pixel[0], pixel[1], pixel[2]], at: Some((x as f32, y as f32)) }
})
.collect()
};
let query = ColorRangeSamples::with_samples(s, params, canvas, &samples("points"), &samples("subtractPoints")).unwrap();
let mut mask = query.coverage(&px, canvas.width() as usize, 1.0, [canvas.x0 as f32, canvas.y0 as f32]);
if b(params, "invert", false) {
mask.iter_mut().for_each(|v| *v = 1.0 - *v);
}
scope_color_range(&mut mask, doc, canvas, mode(params));
sel::mask_from_surface(sel::combine(doc.selection.as_ref(), &mask, canvas, mode(params)).as_ref(), canvas)
}
#[test]
fn sampled_color_cropped_results_match_full_canvas_for_depths_profiles_and_sample_options() {
for (depth, p3) in [SampleType::U8, SampleType::U16, SampleType::F32].into_iter().flat_map(|depth| [false, true].map(|p3| (depth, p3))) {
for params in [
json!({"points": [[40, 15]], "fuzziness": 12}),
json!({"points": [[40, 15]], "subtractPoints": [[10, 15]], "order": ["-", "+"], "fuzziness": 18}),
json!({"points": [[40, 15]], "localized": true, "range": 80, "invert": true}),
json!({"points": [[10, 15]], "localized": true, "range": 25}),
json!({"points": [[40, 15]], "sampleAllLayers": false, "fuzziness": 0}),
] {
let mut s = fixture(depth, p3);
s.execute("select.rect", json!({"x": 3, "y": 5, "width": 22, "height": 25})).unwrap();
let expected = full_sampled_reference(&s, &params);
s.execute("select.colorRange", params.clone()).unwrap();
let actual = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), s.active().unwrap().doc.bounds());
assert_eq!(actual, expected, "depth={depth:?} p3={p3} params={params}");
}
}
}
#[test]
fn range_presets_are_scoped_and_non_replace_modes_keep_full_canvas_semantics() {
for preset in ["reds", "yellows", "greens", "cyans", "blues", "magentas", "highlights", "midtones", "shadows", "skinTones"] {
let mut s = fixture(SampleType::U8, false);
s.execute("select.rect", json!({"x": 2, "y": 4, "width": 24, "height": 25})).unwrap();
s.execute("select.colorRange", json!({"select": preset})).unwrap();
assert_eq!(coverage_at(&s, 42, 15), 0.0, "preset {preset} escaped selection");
}
let mut s = fixture(SampleType::U8, false);
s.execute("select.rect", json!({"x": 2, "y": 4, "width": 24, "height": 25})).unwrap();
s.execute("select.colorRange", json!({"points": [[40, 15]], "fuzziness": 0, "mode": "add"})).unwrap();
assert_eq!(coverage_at(&s, 42, 15), 1.0);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(coverage_at(&s, 42, 15), 0.0);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(coverage_at(&s, 42, 15), 1.0);
}
#[test]
fn window_defaults_and_hostile_tiles_fall_back_and_empty_selection_is_safe() {
let canvas = Rect::new(0, 0, 64, 48);
let mut doc = Document::new("window", Size::new(64, 48), ColorMode::Rgb, SampleType::U8);
doc.selection = Some(Surface::with_default(PixelFormat::GRAY8, &[1.0]));
assert_eq!(color_range_area(&doc, SelectionMode::Replace), canvas);
doc.selection = Some(Surface::new(PixelFormat::GRAY8));
assert_eq!(color_range_area(&doc, SelectionMode::Replace), Rect::new(0, 0, 0, 0));
use photocraft_geom::TileCoord;
use std::sync::Arc;
let mut hostile = Surface::new(PixelFormat::GRAY8);
let tile = hostile.solid_tile(&[1.0]);
hostile.put_tiles([(TileCoord::new(i32::MIN, i32::MIN), Arc::clone(&tile)), (TileCoord::new(i32::MAX, i32::MAX), tile)]);
doc.selection = Some(hostile);
assert_eq!(color_range_area(&doc, SelectionMode::Replace), canvas);
assert_eq!(color_range_area(&doc, SelectionMode::Add), canvas);
doc.selection = Some(Surface::new(PixelFormat::GRAY8));
let mut s = Session::new();
s.add_document(doc, None);
assert!(sample_pixels(&s, true, Some(color_range_area(&s.active().unwrap().doc, SelectionMode::Replace))).unwrap().1.is_empty());
let err = s.execute("select.colorRange", json!({"select": "shadows", "tonalRange": -1})).unwrap_err();
assert!(err.to_string().contains("tonalRange"));
for params in [json!({"points": [[64, 0]]}), json!({"points": "invalid"}), json!({"localized": true}), json!({"order": ["+"]})] {
assert!(s.execute("select.colorRange", params).is_err());
}
s.execute("select.colorRange", json!({"points": [[1, 1]], "invert": true})).unwrap();
assert!(s.active().unwrap().doc.selection.is_none());
}
#[test]
fn sparse_large_selection_samples_only_its_clipped_window() {
let mut doc = Document::new("sparse", Size::new(6000, 4000), ColorMode::Rgb, SampleType::U8);
let selection = sel::mask_to_surface(&vec![1.0; 24 * 20], Rect::new(2990, 1990, 3014, 2010));
doc.selection = Some(selection);
let mut s = Session::new();
s.add_document(doc, None);
let win = color_range_area(&s.active().unwrap().doc, SelectionMode::Replace);
assert_eq!(win, Rect::new(2990, 1990, 3014, 2010));
let (area, pixels) = sample_pixels(&s, true, Some(win)).unwrap();
assert_eq!(pixels.len(), area.size().area() as usize);
s.execute("select.colorRange", json!({"select": "highlights"})).unwrap();
}
#[test]
fn cropped_range_matches_full_canvas_combinations_with_soft_holes_and_clipped_masks() {
let canvas = Rect::new(0, 0, 64, 48);
for case in 0..6 {
for selection_mode in ["replace", "add", "subtract", "intersect"] {
for preset in ["sampledColors", "reds", "greens", "blues", "highlights", "midtones", "shadows", "skinTones"] {
for invert in [false, true] {
let mut s = fixture(SampleType::U16, true);
let mut selection = match case {
0 => Surface::new(PixelFormat::GRAY8),
1 => Surface::with_default(PixelFormat::GRAY8, &[128.0 / 255.0]),
_ => sel::mask_to_surface(&vec![128.0 / 255.0; 30 * 30], Rect::new(-5, 5, 25, 35)),
};
if case == 3 {
selection.fill_rect(Rect::new(8, 10, 15, 20), &[0.0]);
}
if case == 5 {
selection = Surface::new(PixelFormat::GRAY8);
}
if case == 4 {
selection = sel::mask_to_surface(&[1.0; 4], Rect::new(-8, -8, -6, -6));
}
let original = (case != 0).then_some(selection);
let mut prior = s.active().unwrap().doc.as_ref().clone();
prior.selection = original;
let before = sel::mask_from_surface(prior.selection.as_ref(), canvas);
let mut unselected = prior.clone();
unselected.selection = None;
let mut full = Session::new();
full.add_document(unselected, None);
let params = json!({"select": preset, "points": [[40, 15]], "fuzziness": 40, "invert": invert});
full.execute("select.colorRange", params.clone()).unwrap();
let mut mask = sel::mask_from_surface(full.active().unwrap().doc.selection.as_ref(), canvas);
let m = SelectionMode::parse(selection_mode);
scope_color_range(&mut mask, &prior, canvas, m);
let expected = sel::combine(prior.selection.as_ref(), &mask, canvas, m);
s.edit("prior selection", |doc, _| {
doc.selection = prior.selection.clone();
Ok(())
})
.unwrap();
let mut params = params;
params["mode"] = json!(selection_mode);
s.execute("select.colorRange", params).unwrap();
let actual = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas);
assert_eq!(actual, sel::mask_from_surface(expected.as_ref(), canvas), "case {case}, {selection_mode}, {preset}, invert {invert}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas), actual);
}
}
}
}
}
#[test]
fn cropped_samples_match_full_composite_with_translucent_layers_and_active_layer_sampling() {
use photocraft_doc::{Layer, LayerContent};
for all_layers in [false, true] {
let mut s = fixture(SampleType::F32, true);
s.edit("overlay", |doc, _| {
let mut pixels = Surface::new(PixelFormat::RGBA32F);
pixels.fill_rect(Rect::new(0, 0, 64, 48), &[0.3, 0.1, 0.6, 0.35]);
doc.layers.push(Layer::new("overlay", LayerContent::Raster(pixels)));
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 5, "y": 6, "width": 20, "height": 30})).unwrap();
let params = json!({"points": [[40, 15], [10, 15]], "subtractPoints": [[40, 35]], "sampleAllLayers": all_layers, "localized": true, "range": 70});
let expected = full_sampled_reference(&s, &params);
s.execute("select.colorRange", params).unwrap();
assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), s.active().unwrap().doc.bounds()), expected);
}
}
@@ -17,13 +17,17 @@ fn full_canvas_reference(doc: &Document, op: &str, radius: f32, at_bounds: bool)
let canvas = doc.bounds();
let m = sel::mask_from_surface(doc.selection.as_ref(), canvas);
let (w, h) = (canvas.width() as usize, canvas.height() as usize);
let pad = if touches_edge(&m, w, h) { radius.ceil() as usize + 2 } else { 0 };
let reach = if op == "feather" { sel::feather_reach(radius) } else { radius.ceil() as usize };
let pad = if touches_edge(&m, w, h) { reach + 2 } else { 0 };
let (pw, ph) = (w + 2 * pad, h + 2 * pad);
let padded = if pad == 0 { m } else { pad_mask(&m, w, h, pad, op == "border" || at_bounds) };
let out = match op {
"expand" => sel::expand(&padded, pw, ph, radius),
"contract" => sel::contract(&padded, pw, ph, radius),
_ => sel::border(&padded, pw, ph, radius),
"border" => sel::border(&padded, pw, ph, radius),
"smooth" => sel::smooth(&padded, pw, ph, radius),
"feather" => sel::feather(&padded, pw, ph, radius),
_ => panic!("unknown modify operation: {op}"),
};
let out = if pad == 0 { out } else { crop_mask(&out, pw, pad, w, h) };
sel::combine(None, &out, canvas, SelectionMode::Replace)
@@ -36,9 +40,11 @@ fn sparse_36mp_command_only_materializes_the_selection_halo() {
let doc = document(6000, 6000, selection);
assert_eq!(area(&doc, 5.0), Rect::new(2994, 2994, 3056, 3056));
assert_eq!(area(&doc, 5.0).size().area(), 62 * 62);
let mut session = Session::new();
session.add_document(doc, None);
for op in ["expand", "contract", "border"] {
assert_eq!(area(&doc, sel::feather_reach(5.0) as f32), Rect::new(2984, 2984, 3066, 3066));
assert_eq!(area(&doc, sel::feather_reach(5.0) as f32).size().area(), 82 * 82);
for op in ["expand", "contract", "border", "smooth", "feather"] {
let mut session = Session::new();
session.add_document(doc.clone(), None);
session.execute(&format!("select.modify.{op}"), json!({"radius": 5})).unwrap();
assert!(session.active().unwrap().doc.selection.is_some());
}
@@ -71,8 +77,11 @@ fn cropped_commands_match_full_canvas_reads_padding_and_writeback() {
selection.write_pixel(-10, -10, &[1.0]);
}
let doc = document(75, 61, selection);
for radius in [1.0, 5.0, 9.5] {
for op in ["expand", "contract", "border"] {
for radius in [0.1, 1.0, 1.5, 5.0, 9.5, 25.0] {
for op in ["expand", "contract", "border", "smooth", "feather"] {
if radius < 1.0 && op != "feather" {
continue;
}
for at_bounds in [false, true] {
let expected = full_canvas_reference(&doc, op, radius, at_bounds);
let mut session = Session::new();
@@ -91,21 +100,79 @@ fn cropped_commands_match_full_canvas_reads_padding_and_writeback() {
}
}
#[test]
fn cropped_blurs_preserve_soft_masks_at_every_depth() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let format = PixelFormat { sample: depth, ..PixelFormat::GRAY8 };
for default in [0.0, 0.25] {
let mut selection = Surface::with_default(format, &[default]);
for y in 21..39 {
for x in 28..47 {
selection.write_pixel(x, y, &[((x * 13 + y * 7) % 17) as f32 / 16.0]);
}
}
let mut doc = Document::new("soft modify", Size::new(83, 67), ColorMode::Rgb, depth);
doc.selection = Some(selection);
for op in ["smooth", "feather"] {
for radius in [1.0, 2.5, 9.5] {
for at_bounds in [false, true] {
let expected = full_canvas_reference(&doc, op, radius, at_bounds);
let mut session = Session::new();
session.add_document(doc.clone(), None);
session.execute(&format!("select.modify.{op}"), json!({"radius": radius, "applyAtCanvasBounds": at_bounds})).unwrap();
assert_eq!(
sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()),
sel::mask_from_surface(expected.as_ref(), doc.bounds()),
"{depth:?} default {default} {op} radius {radius} bounds {at_bounds}"
);
}
}
}
}
}
}
#[test]
fn cropped_blurs_preserve_single_pixel_and_thin_canvases() {
for (w, h) in [(1, 1), (1, 19), (23, 1)] {
let mut selection = Surface::new(PixelFormat::GRAY8);
selection.write_pixel(w as i32 / 2, h as i32 / 2, &[1.0]);
let doc = document(w, h, selection);
for op in ["smooth", "feather"] {
for radius in [1.0, 9.5] {
for at_bounds in [false, true] {
let expected = full_canvas_reference(&doc, op, radius, at_bounds);
let mut session = Session::new();
session.add_document(doc.clone(), None);
session.execute(&format!("select.modify.{op}"), json!({"radius": radius, "applyAtCanvasBounds": at_bounds})).unwrap();
assert_eq!(
sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()),
sel::mask_from_surface(expected.as_ref(), doc.bounds()),
"{w}x{h} {op} radius {radius} bounds {at_bounds}"
);
}
}
}
}
}
#[test]
fn cropped_replacement_keeps_undo_and_redo() {
let canvas = Rect::new(0, 0, 75, 61);
let selection = sel::mask_to_surface(&vec![1.0; 10 * 10], Rect::new(30, 25, 40, 35));
let doc = document(75, 61, selection);
let before = sel::mask_from_surface(doc.selection.as_ref(), canvas);
let mut session = Session::new();
session.add_document(doc, None);
session.execute("select.modify.expand", json!({"radius": 5})).unwrap();
let after = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas);
assert_ne!(before, after);
session.execute("edit.undo", json!({})).unwrap();
assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), before);
session.execute("edit.redo", json!({})).unwrap();
assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), after);
for op in ["expand", "smooth", "feather"] {
let mut session = Session::new();
session.add_document(doc.clone(), None);
session.execute(&format!("select.modify.{op}"), json!({"radius": 5})).unwrap();
let after = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas);
assert_ne!(before, after, "{op}");
session.execute("edit.undo", json!({})).unwrap();
assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), before, "{op}");
session.execute("edit.redo", json!({})).unwrap();
assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), after, "{op}");
}
}
/// Isolate command-window savings: both paths use the optimized algorithm kernels, while
@@ -114,43 +181,46 @@ fn cropped_replacement_keeps_undo_and_redo() {
#[ignore = "36 MP release benchmark; run on GitHub Actions"]
fn sparse_modify_release_comparison() {
use std::time::Instant;
let selection = sel::mask_to_surface(&vec![1.0; 50 * 50], Rect::new(3000, 3000, 3050, 3050));
let doc = document(6000, 6000, selection);
for radius in [3.0, 50.0] {
for op in ["expand", "contract", "border"] {
let reference = full_canvas_reference(&doc, op, radius, false);
let expected = sel::mask_from_surface(reference.as_ref(), doc.bounds());
let mut old_times = Vec::new();
let mut new_times = Vec::new();
for round in 0..3 {
for old in if round % 2 == 0 { [true, false] } else { [false, true] } {
let mut session = Session::new();
session.add_document(doc.clone(), None);
let now = Instant::now();
if old {
let result = full_canvas_reference(&doc, op, radius, false);
session
.edit("Modify selection", |doc, _| {
doc.selection = result;
Ok(())
})
.unwrap();
} else {
session.execute(&format!("select.modify.{op}"), json!({"radius": radius})).unwrap();
for (kind, bounds) in [("sparse", Rect::new(3000, 3000, 3050, 3050)), ("dense", Rect::new(1000, 1000, 5000, 5000)), ("full", Rect::new(0, 0, 6000, 6000))] {
let selection = sel::mask_to_surface(&vec![1.0; bounds.size().area() as usize], bounds);
let doc = document(6000, 6000, selection);
let ops: &[&str] = if kind == "sparse" { &["expand", "contract", "border", "smooth", "feather"] } else { &["smooth", "feather"] };
for radius in [3.0, 50.0] {
for &op in ops {
let reference = full_canvas_reference(&doc, op, radius, false);
let expected = sel::mask_from_surface(reference.as_ref(), doc.bounds());
let mut old_times = Vec::new();
let mut new_times = Vec::new();
for round in 0..3 {
for old in if round % 2 == 0 { [true, false] } else { [false, true] } {
let mut session = Session::new();
session.add_document(doc.clone(), None);
let now = Instant::now();
if old {
let result = full_canvas_reference(&doc, op, radius, false);
session
.edit("Modify selection", |doc, _| {
doc.selection = result;
Ok(())
})
.unwrap();
} else {
session.execute(&format!("select.modify.{op}"), json!({"radius": radius})).unwrap();
}
let elapsed = now.elapsed().as_secs_f64() * 1000.0;
if old {
old_times.push(elapsed)
} else {
new_times.push(elapsed)
}
assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()), expected);
}
let elapsed = now.elapsed().as_secs_f64() * 1000.0;
if old {
old_times.push(elapsed)
} else {
new_times.push(elapsed)
}
assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()), expected);
}
old_times.sort_by(f64::total_cmp);
new_times.sort_by(f64::total_cmp);
let (old, new) = (old_times[1], new_times[1]);
println!("36 MP {kind} command {op} r={radius}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x exact=true", old / new);
}
old_times.sort_by(f64::total_cmp);
new_times.sort_by(f64::total_cmp);
let (old, new) = (old_times[1], new_times[1]);
println!("36 MP command {op} r={radius}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x exact=true", old / new);
}
}
}
@@ -165,7 +235,7 @@ fn corrupt_tile_coordinates_do_not_panic_while_finding_the_window() {
let doc = document(75, 61, selection);
// Without the guard, content_bounds overflows while sorting these coordinates.
assert_eq!(area(&doc, 5.0), doc.bounds());
for op in ["expand", "contract", "border"] {
for op in ["expand", "contract", "border", "smooth", "feather"] {
let mut session = Session::new();
session.add_document(doc.clone(), None);
session.execute(&format!("select.modify.{op}"), json!({"radius": 5})).unwrap();
@@ -60,7 +60,7 @@ fn selection_sampling_refuses_oversized_canvases_before_reading_pixels() {
None,
);
for all_layers in [false, true] {
assert!(sample_pixels(&s, all_layers).unwrap_err().to_string().contains("too large to sample"));
assert!(sample_pixels(&s, all_layers, None).unwrap_err().to_string().contains("too large to sample"));
}
assert!(!s.active().unwrap().history.can_undo());
}
@@ -275,7 +275,7 @@ fn color_range_prepared_query_samples_visible_layers_not_the_active_layer() {
clear_selection(&mut s);
let p = json!({"points": [[7, 7]], "sampleAllLayers": all_layers, "fuzziness": 0});
let query = ColorRangeSamples::new(&s, &p).unwrap();
let (area, px) = sample_pixels(&s, all_layers).unwrap();
let (area, px) = sample_pixels(&s, all_layers, None).unwrap();
let shown = query.coverage(&px, area.width() as usize, 1.0, [0.0, 0.0]);
s.execute("select.colorRange", p).unwrap();
assert_eq!(shown[7 * 40 + 7], coverage(&s, 7, 7));
+1 -1
View File
@@ -149,7 +149,7 @@ pub(crate) use rendering::source_box;
pub use rendering::{apply_smart_filters, purge_stack_cache, refresh, refresh_layer, render, render_below_filter, stack_cache_bytes, stack_image};
#[cfg(test)]
use rendering::{blend_surfaces, place, render_in};
pub use source::{SourceImage, source_bytes, source_decode_count, source_image, svg_source_image};
pub use source::{SourceImage, purge_source_cache, source_bytes, source_cache_bytes, source_decode_count, source_image, svg_source_image};
use source::{authorize_source, authorized_source_bytes};
pub(crate) use source::{restore_unrendered, take_caches};
@@ -177,6 +177,11 @@ pub(crate) fn on_close(s: &mut Session, index: usize) {
let _ = commit_child(s, index);
}
s.smart_links.retain(|l| l.child != id && l.parent != id);
// Decoded sources are shared by content hash across documents, so the closing document's
// can't be told apart: drop them all. Other documents decode again on their next re-render.
if s.docs.get(index).is_some_and(|st| st.doc.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)))) {
super::purge_source_cache();
}
}
pub(super) fn is_smart_child(s: &Session) -> std::result::Result<(), String> {
@@ -1,5 +1,5 @@
use super::filters::smart;
use super::source::{SourceImage, cache_get, cache_key, cache_put, source_bytes, source_image, source_surface, svg_source_image, vector_scale};
use super::source::{SourceImage, cache_get, cache_key, cache_put, source_bytes, source_image, source_surface_from_buffer, svg_source_image, vector_scale};
use super::{
Affine, Arc, BlendMode, Document, EngineError, Homography, JobCtx, Layer, LayerContent, LayerId, PixelFormat, Rect, Result, SmartFilter, SmartObject,
SmartSource, Stage, StageKey, Surface, Value, decode_source, other, stack_cache,
@@ -47,7 +47,7 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc
photocraft_doc::StackMode::Variance => Stat::Variance,
};
let buf = photocraft_compose::Buffer { rect: bounds, px: photocraft_algo::stack::combine(&frames, stat) };
let img = SourceImage { surface: Arc::new(source_surface(&doc, &buf, fmt)?), bounds };
let img = SourceImage { surface: Arc::new(source_surface_from_buffer(&doc, &buf, fmt)?), bounds };
cache_put(key, img.clone());
Ok(img)
}
+158 -11
View File
@@ -1,3 +1,7 @@
use std::sync::PoisonError;
use photocraft_geom::TILE_SIZE;
use super::{
Arc, ColorMode, Document, Layer, LayerContent, LayerId, Metadata, Mutex, PixelFormat, Rect, Result, SampleType, Session, SmartObject, SmartSource, Surface,
base_name, decode_source, json, other,
@@ -72,22 +76,35 @@ struct CacheEntry {
key: CacheKey,
image: SourceImage,
decodes: u32,
/// The surface's real tile bytes, counted once when it is cached.
bytes: usize,
}
/// Decoded source composites by content hash, most recent last. Re-filtering or re-transforming
/// a smart object never decodes its contents again.
static SOURCE_CACHE: Mutex<Vec<CacheEntry>> = Mutex::new(Vec::new());
#[cfg(not(target_arch = "wasm32"))]
const CACHE_ENTRIES: usize = 32;
#[cfg(target_arch = "wasm32")]
const CACHE_ENTRIES: usize = 4;
/// Accounted tile bytes; a source larger than this on its own isn't kept.
#[cfg(not(target_arch = "wasm32"))]
const CACHE_BYTES: usize = 1 << 30;
#[cfg(target_arch = "wasm32")]
const CACHE_BYTES: usize = 384 << 20;
fn cache() -> std::sync::MutexGuard<'static, Vec<CacheEntry>> {
SOURCE_CACHE.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(super) fn cache_key(bytes: &[u8], fmt: PixelFormat) -> CacheKey {
(*blake3::hash(bytes).as_bytes(), fmt)
}
pub(super) fn cache_get(key: &CacheKey) -> Option<SourceImage> {
let mut c = SOURCE_CACHE.lock().ok()?;
let mut c = cache();
let i = c.iter().position(|e| e.key == *key)?;
let e = c.remove(i);
let img = e.image.clone();
@@ -96,14 +113,22 @@ pub(super) fn cache_get(key: &CacheKey) -> Option<SourceImage> {
}
pub(super) fn cache_put(key: CacheKey, image: SourceImage) {
let Ok(mut c) = SOURCE_CACHE.lock() else { return };
cache_put_within(key, image, CACHE_BYTES);
}
/// [`cache_put`] with `limit` as the byte budget.
fn cache_put_within(key: CacheKey, image: SourceImage, limit: usize) {
let bytes = super::stack_cache::surface_bytes(&image.surface);
let mut c = cache();
let decodes = match c.iter().position(|e| e.key == key) {
Some(i) => c.remove(i).decodes + 1,
None => 1,
};
c.push(CacheEntry { key, image, decodes });
let size = |e: &CacheEntry| e.image.bounds.width() as usize * e.image.bounds.height() as usize * e.key.1.channels() * 4;
while c.len() > CACHE_ENTRIES || (c.len() > 1 && c.iter().map(size).sum::<usize>() > CACHE_BYTES) {
if bytes > limit {
return;
}
c.push(CacheEntry { key, image, decodes, bytes });
while c.len() > CACHE_ENTRIES || c.iter().map(|e| e.bytes).sum::<usize>() > limit {
c.remove(0);
}
}
@@ -111,7 +136,21 @@ pub(super) fn cache_put(key: CacheKey, image: SourceImage) {
/// How many times these contents were decoded at this pixel format (0 = never / evicted).
pub fn source_decode_count(bytes: &[u8], fmt: PixelFormat) -> u32 {
let key = cache_key(bytes, fmt);
SOURCE_CACHE.lock().ok().and_then(|c| c.iter().find(|e| e.key == key).map(|e| e.decodes)).unwrap_or(0)
cache().iter().find(|e| e.key == key).map(|e| e.decodes).unwrap_or(0)
}
/// Bytes held by cached source composites.
pub fn source_cache_bytes() -> usize {
cache().iter().map(|e| e.bytes).sum()
}
/// Drop every cached source composite (Edit › Purge › All, closing a document with smart
/// objects). Returns the bytes released.
pub fn purge_source_cache() -> usize {
let mut c = cache();
let freed = c.iter().map(|e| e.bytes).sum();
c.clear();
freed
}
pub(crate) fn take_caches(layers: &mut [Layer]) -> Vec<(LayerId, Surface)> {
@@ -155,15 +194,82 @@ pub(super) fn buffer_to_surface(buf: &photocraft_compose::Buffer, fmt: PixelForm
s
}
pub(super) fn source_surface(src: &Document, buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Result<Surface> {
/// A source's pixels in `fmt` from a flattened `buf` (the stack-mode combine, which has no
/// document to composite).
pub(super) fn source_surface_from_buffer(src: &Document, buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Result<Surface> {
let raster = |f| buffer_to_surface(buf, f);
convert_source(src, fmt, raster)
}
/// `src` composited straight into `fmt` in blocks, without the full-size RGBA buffer
/// [`photocraft_compose::flatten`] allocates (16 bytes a pixel on top of the surface).
pub(super) fn flattened_source(src: &Document, fmt: PixelFormat) -> Result<Surface> {
let b = src.bounds();
if u64::from(b.width()) * u64::from(b.height()) > photocraft_compose::MAX_RENDER_PIXELS {
return Err(other(format!("the smart object's {}×{} contents are too large to render", b.width(), b.height())));
}
// The banded compositor composites CMYK in inks and Lab in L, a and b: not the same pixels as
// the RGBA buffer, so those modes keep the buffer path.
let native = |m| matches!(m, ColorMode::Cmyk | ColorMode::Lab);
if native(src.mode) || native(fmt.mode) {
return source_surface_from_buffer(src, &photocraft_compose::flatten(src), fmt);
}
convert_source(src, fmt, |f| composite_in_blocks(src, f))
}
/// Pixels per block of [`composite_in_blocks`]: the compositor's own bands are 8 Mpx, which for
/// most sources is the whole image again (a band and its f32 conversion are 32 bytes a pixel).
const BLOCK_PIXELS: u64 = 1 << 20;
/// [`photocraft_compose::flatten_to_surface`] of `src`, composited a block at a time so the float
/// composite and its conversion never cover more than [`BLOCK_PIXELS`]. Blocks are whole tiles,
/// so each block's tiles move into the result as they are: nothing is allocated outside the
/// block, and nothing is copied.
fn composite_in_blocks(src: &Document, fmt: PixelFormat) -> Surface {
let b = src.bounds();
let (bw, bh) = block_size(b.width());
let mut out = Surface::new(fmt);
// On the tile grid even if the bounds aren't, so no tile is shared by two blocks.
let mut y = b.y0 - b.y0.rem_euclid(TILE_SIZE);
while y < b.y1 {
let y1 = y.saturating_add(bh);
let mut x = b.x0 - b.x0.rem_euclid(TILE_SIZE);
while x < b.x1 {
let x1 = x.saturating_add(bw);
let block = Rect::new(x, y, x1, y1).intersect(&b);
if !block.is_empty() {
let mut part = photocraft_compose::composite_to_surface(src, block, fmt, None);
out.put_tiles(part.take_tiles(block));
}
x = x1;
}
y = y1;
}
out.prune();
out
}
/// Width and height of a block of [`composite_in_blocks`] for a `width`-pixel source: whole tiles,
/// the full width (up to [`BLOCK_PIXELS`] over one row of tiles) and as many rows of tiles as fit.
fn block_size(width: u32) -> (i32, i32) {
let tile = TILE_SIZE.unsigned_abs() as u64;
let across = u64::from(width.max(1)).div_ceil(tile) * tile;
let bw = across.min((BLOCK_PIXELS / tile / tile).max(1) * tile);
let bh = (BLOCK_PIXELS / bw / tile).max(1) * tile;
(i32::try_from(bw).unwrap_or(TILE_SIZE), i32::try_from(bh).unwrap_or(TILE_SIZE))
}
/// Runs the gray and 32-bit conversions a source needs to reach `fmt`; `raster` yields the
/// source's composite at a pixel format.
fn convert_source(src: &Document, fmt: PixelFormat, raster: impl FnOnce(PixelFormat) -> Surface) -> Result<Surface> {
let gray = fmt.mode == ColorMode::Grayscale && src.pixel_format().mode != ColorMode::Grayscale;
let linear = fmt.sample == SampleType::F32 && src.depth != SampleType::F32;
if !gray && !linear {
return Ok(buffer_to_surface(buf, fmt));
return Ok(raster(fmt));
}
let mut doc = Document::new(src.name.clone(), src.size, src.mode, src.depth);
doc.icc_profile = src.icc_profile.clone();
doc.layers = vec![Layer::new("source", LayerContent::Raster(buffer_to_surface(buf, src.pixel_format())))];
doc.layers = vec![Layer::new("source", LayerContent::Raster(raster(src.pixel_format())))];
if gray {
let gray_profile = crate::color_cmds::working_profile(ColorMode::Grayscale);
crate::color_cmds::convert_document(&mut doc, &gray_profile, photocraft_cms::Intent::RelativeColorimetric, true)?;
@@ -213,8 +319,49 @@ pub fn source_image(file_name: &str, bytes: &[u8], fmt: PixelFormat) -> Result<S
return Ok(img);
}
let doc = decode_source(file_name, bytes)?;
let buf = photocraft_compose::flatten(&doc);
let img = SourceImage { surface: Arc::new(source_surface(&doc, &buf, fmt)?), bounds: doc.bounds() };
let img = SourceImage { surface: Arc::new(flattened_source(&doc, fmt)?), bounds: doc.bounds() };
cache_put(key, img.clone());
Ok(img)
}
#[cfg(test)]
mod tests {
use super::*;
fn image(side: i32) -> SourceImage {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, side, side), &[0.2, 0.4, 0.6, 1.0]);
SourceImage { surface: Arc::new(s), bounds: Rect::new(0, 0, side, side) }
}
#[test]
fn a_source_larger_than_the_limit_is_returned_but_not_cached() {
let key = cache_key(b"oversize", PixelFormat::RGBA8);
let img = image(256);
let bytes = super::super::stack_cache::surface_bytes(&img.surface);
cache_put_within(key, img.clone(), bytes - 1);
assert!(cache_get(&key).is_none(), "{bytes} bytes against a limit of {}", bytes - 1);
cache_put_within(key, img, bytes);
assert!(cache_get(&key).is_some());
}
#[test]
fn blocks_are_whole_tiles_within_their_pixel_budget_however_wide_the_source() {
for width in [1u32, 255, 4096, 5000, 300_000, u32::MAX] {
let (bw, bh) = block_size(width);
assert!(bw > 0 && bh > 0 && bw % TILE_SIZE == 0 && bh % TILE_SIZE == 0, "{width}: {bw}×{bh}");
assert!(u64::from(bw.unsigned_abs()) * u64::from(bh.unsigned_abs()) <= BLOCK_PIXELS, "{width}: {bw}×{bh}");
}
assert_eq!(block_size(4096), (4096, 256));
assert_eq!(block_size(1000), (1024, 1024));
assert_eq!(block_size(300_000), (4096, 256));
}
#[test]
fn accounting_counts_the_real_tile_bytes() {
let img = image(256);
let bytes = super::super::stack_cache::surface_bytes(&img.surface);
// 8-bit RGBA is 4 bytes a pixel (plus the default pixel), not the 16 of an f32 estimate.
assert!((256 * 256 * 4..256 * 256 * 5).contains(&bytes), "{bytes}");
}
}
@@ -30,7 +30,10 @@ struct Entry {
static STAGES: Mutex<Vec<Entry>> = Mutex::new(Vec::new());
const ENTRIES: usize = 64;
/// Accounted tile bytes; a stage larger than this on its own isn't kept.
#[cfg(not(target_arch = "wasm32"))]
pub const BUDGET: usize = 768 << 20;
#[cfg(target_arch = "wasm32")]
pub const BUDGET: usize = 256 << 20;
/// The first stage: what placing `bytes` (the source contents) through the smart object's
/// transform, warp or perspective gives at pixel format `fmt` on `canvas`.
@@ -88,7 +91,8 @@ pub fn purge() -> usize {
freed
}
fn surface_bytes(s: &Surface) -> usize {
/// Bytes a surface holds: its default pixel and every tile.
pub(super) fn surface_bytes(s: &Surface) -> usize {
// Tiles shared with the document (or another stage) are charged anyway: the cache is what
// keeps them alive once the document moves on.
s.tiles().fold(s.default_bytes().len(), |n, (_, t)| n.saturating_add(t.bytes().len()))
@@ -2,6 +2,7 @@ use super::*;
mod filter_mask_paint;
mod selection;
mod source_flatten;
mod stack_cache;
const DEPTHS: [u64; 3] = [8, 16, 32];
@@ -0,0 +1,133 @@
use super::*;
use crate::cmd::smart_cmds::source::{flattened_source, source_surface_from_buffer};
/// A source document of `mode`/`depth` with a textured, partly transparent block on a layer.
fn source(mode: ColorMode, depth: SampleType) -> Document {
let mut doc = Document::new("source", Size::new(W as u32, H as u32), mode, depth);
doc.layers = vec![Layer::new("block", LayerContent::Raster(block(&doc.pixel_format(), Rect::new(4, 3, 50, 40))))];
doc
}
fn block(fmt: &PixelFormat, r: Rect) -> Surface {
let mut s = Surface::new(*fmt);
let mut data = Vec::new();
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let v = ((x * 7 + y * 13) % 23) as f32 / 22.0;
let a = if (x + y) % 5 == 0 { 0.5 } else { 1.0 };
data.extend(photocraft_raster::from_rgba(fmt, [v, 1.0 - v, ((x * y) % 7) as f32 / 6.0, a]));
}
}
s.write_region(r, &data);
s
}
/// Everything the smart object cache would hold, bit for bit.
fn pixels(s: &Surface, bounds: Rect) -> Vec<u32> {
s.read_region(bounds).iter().map(|v| v.to_bits()).collect()
}
/// The composite through the full-size RGBA buffer, the way sources were rendered before.
fn through_buffer(doc: &Document, fmt: PixelFormat) -> Surface {
source_surface_from_buffer(doc, &photocraft_compose::flatten(doc), fmt).unwrap()
}
fn same(label: &str, doc: &Document, fmt: PixelFormat) {
let (old, new) = (through_buffer(doc, fmt), flattened_source(doc, fmt).unwrap());
assert_eq!(new.format(), old.format(), "{label}: format");
assert!(pixels(&old, doc.bounds()) == pixels(&new, doc.bounds()), "{label}: pixels differ");
}
#[test]
fn rgb_sources_match_at_every_depth() {
for depth in SampleType::ALL {
let doc = source(ColorMode::Rgb, depth);
same(&format!("rgb {depth:?}"), &doc, doc.pixel_format());
}
}
#[test]
fn a_gray_source_in_an_rgb_document_and_back_matches() {
let gray = source(ColorMode::Grayscale, SampleType::U8);
same("gray into rgb", &gray, PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: true });
let rgb = source(ColorMode::Rgb, SampleType::U8);
same("rgb into gray", &rgb, PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::U8, alpha: true });
}
#[test]
fn an_8_bit_source_in_a_32_bit_document_matches() {
let doc = source(ColorMode::Rgb, SampleType::U8);
same("8 into 32", &doc, PixelFormat { mode: ColorMode::Rgb, sample: SampleType::F32, alpha: true });
}
/// The banded compositor composites CMYK in inks and Lab in L, a and b, and lands up to 16 and 1
/// steps (8-bit) off the RGBA buffer on visible pixels, so these sources keep the buffer path.
#[test]
fn a_cmyk_source_matches() {
let doc = source(ColorMode::Cmyk, SampleType::U8);
same("cmyk", &doc, doc.pixel_format());
}
#[test]
fn a_lab_source_matches() {
let doc = source(ColorMode::Lab, SampleType::U8);
same("lab", &doc, doc.pixel_format());
}
#[test]
fn a_layered_source_with_a_mask_blend_mode_and_effect_matches() {
for depth in [SampleType::U8, SampleType::F32] {
let mut doc = source(ColorMode::Rgb, depth);
let fmt = doc.pixel_format();
let mut top = Layer::new("top", LayerContent::Raster(block(&fmt, Rect::new(20, 10, 60, 46))));
top.blend = BlendMode::Multiply;
top.opacity = 0.7;
let mut mask = LayerMask::reveal_all();
mask.surface.fill_rect(Rect::new(30, 0, 64, 48), &[0.25]);
top.mask = Some(mask);
doc.layers.push(top);
let mut shadowed = Layer::new("shadowed", LayerContent::Raster(block(&fmt, Rect::new(8, 20, 24, 36))));
shadowed.effects.items.push(photocraft_doc::Effect::default_drop_shadow());
shadowed.effects.enabled = true;
doc.layers.push(shadowed);
same(&format!("layered {depth:?}"), &doc, fmt);
}
}
#[test]
fn a_source_past_the_render_limit_is_refused_not_emptied() {
let doc = Document::new("huge", Size::new(60_000, 60_000), ColorMode::Rgb, SampleType::U8);
assert!(flattened_source(&doc, doc.pixel_format()).is_err());
}
#[test]
fn an_rgb_source_in_a_cmyk_or_lab_document_matches() {
let doc = source(ColorMode::Rgb, SampleType::U8);
for mode in [ColorMode::Cmyk, ColorMode::Lab] {
same(&format!("rgb into {mode:?}"), &doc, PixelFormat { mode, sample: SampleType::U8, alpha: true });
}
}
/// Blocks are 256 rows of the full width at 4096 px, and split at x = 4096 at 5000 px: the shadow
/// and mask cross the block edges both ways.
#[test]
fn effects_across_block_edges_match() {
for (width, depth) in [(4096, SampleType::U8), (4096, SampleType::F32), (5000, SampleType::U8)] {
let mut doc = Document::new("wide", Size::new(width, 700), ColorMode::Rgb, depth);
let fmt = doc.pixel_format();
let x0 = if width > 4096 { 3800 } else { 100 };
let mut base = Layer::new("base", LayerContent::Raster(block(&fmt, Rect::new(x0, 200, x0 + 800, 560))));
let mut glow = photocraft_doc::Effect::default_drop_shadow();
if let photocraft_doc::Effect::DropShadow(d) = &mut glow {
d.size = 40.0;
d.distance = 30.0;
}
base.effects.items.push(glow);
base.effects.enabled = true;
let mut mask = LayerMask::reveal_all();
mask.surface.fill_rect(Rect::new(0, 240, width as i32, 270), &[0.3]);
base.mask = Some(mask);
doc.layers = vec![base];
same(&format!("blocks {width} {depth:?}"), &doc, fmt);
}
}
+50 -3
View File
@@ -4,7 +4,7 @@ use photocraft_color::{Color, ColorMode};
use photocraft_doc::{Fill, LayerContent};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, check_pixels_unlocked, layer_param};
use crate::commands::{CommandSpec, check_pixels_unlocked, cmyk_param, layer_param};
use crate::{EngineError, Result, Session};
pub const SET: &str = "layer.fill.solidColor.set";
@@ -57,7 +57,13 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
let id = layer_param(s, p)?;
let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let LayerContent::Fill(Fill::Solid(original)) = layer.content else { return Err(bad("the target must be a Solid Color Fill layer")) };
let color = color_param(p.get("color").ok_or_else(|| bad("color is required"))?, original, st.doc.mode)?;
let mut color = color_param(p.get("color").ok_or_else(|| bad("color is required"))?, original, st.doc.mode)?;
// The inks the colour was picked as: a CMYK document keeps them exactly (#8568).
if let Some(k) = cmyk_param(SET, p, "cmyk")?
&& st.doc.mode == ColorMode::Cmyk
{
color = Color { mode: ColorMode::Cmyk, c: k, alpha: color.alpha };
}
if color != original {
check_pixels_unlocked(&st.doc, id)?;
if st.doc.effective_locks(id).transparency && color.alpha != original.alpha {
@@ -78,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Solid Color…",
menu: &[],
shortcut: None,
params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32}}; RGB values without alpha preserve it; stored Color preserves its model and precision"##,
params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32},"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document keeps exactly these)}; RGB values without alpha preserve it; stored Color preserves its model and precision"##,
enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()),
run: set,
journal: true,
@@ -89,6 +95,47 @@ pub fn specs() -> Vec<CommandSpec> {
mod tests {
use super::*;
/// #8568: CMYK 50/50/50/100 entered for a Solid Color fill in a CMYK document stays exactly
/// those inks, on creation and on edit, instead of what its sRGB stand-in converts back to
/// through the document profile. RGB documents ignore the inks.
#[test]
fn cmyk_inks_survive_solid_color_fills_in_a_cmyk_document() {
let inks = [0.5, 0.5, 0.5, 1.0];
let hex = |k: [f32; 4]| {
let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(k).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8);
format!("#{r:02x}{g:02x}{b:02x}")
};
let stored = |s: &Session, id: u64| match s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content {
LayerContent::Fill(Fill::Solid(c)) => c,
_ => panic!("not a solid fill"),
};
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("image.mode.cmyk", json!({})).unwrap();
let id = s.execute("layer.newFillLayer.solidColor", json!({"color": hex(inks), "cmyk": inks})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(stored(&s, id), Color { mode: ColorMode::Cmyk, c: inks, alpha: 1.0 });
// The sRGB stand-in alone does drift: that is what the inks prevent.
let drifted = s.execute("layer.newFillLayer.solidColor", json!({"color": hex(inks)})).unwrap()["layer"].as_u64().unwrap();
assert_ne!(stored(&s, drifted).c, inks);
// Editing keeps the new inks too.
let edit = [0.2, 0.3, 0.4, 0.5];
s.execute(SET, json!({"layer": id, "color": hex(edit), "cmyk": edit})).unwrap();
assert_eq!(stored(&s, id), Color { mode: ColorMode::Cmyk, c: edit, alpha: 1.0 });
// A foreground picked as CMYK seeds a new fill with its inks, as fills do (#4196).
s.execute("tools.setColors", json!({"foreground": hex(edit), "foregroundCmyk": edit})).unwrap();
let seeded = s.execute("layer.newFillLayer.solidColor", json!({})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(stored(&s, seeded).c, edit);
for bad in [json!([0.1, 0.2, 0.3]), json!("50,50,50,100"), json!([0.1, 0.2, 0.3, f64::NAN])] {
assert!(s.execute("layer.newFillLayer.solidColor", json!({"cmyk": bad})).is_err(), "{bad}");
assert!(s.execute(SET, json!({"layer": id, "color": "#000000", "cmyk": bad})).is_err(), "{bad}");
}
// An RGB document takes the colour, not the inks.
let mut rgb = Session::new();
rgb.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let id = rgb.execute("layer.newFillLayer.solidColor", json!({"color": "#336699", "cmyk": inks})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(stored(&rgb, id).mode, ColorMode::Rgb);
}
#[test]
fn edit_preserves_layer_and_masks_at_all_depths_and_undo_redo() {
for depth in [8, 16, 32] {
@@ -345,7 +345,6 @@ fn cached_gradient_fill() -> Session {
let mut s = session();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.edit("cache", |doc, active| {
let (fmt, canvas) = (doc.pixel_format(), doc.bounds());
let l = doc.layer_mut(active.unwrap()).unwrap();
+1 -1
View File
@@ -67,7 +67,7 @@ impl DocState {
}
/// Pixel bytes of the document, the tiles only its history holds and the tiles only its
/// History panel snapshots hold.
/// History panel snapshots hold; embedded smart object contents count too.
pub fn memory_bytes(&self) -> usize {
let snaps: Vec<&Document> = self.snapshots.iter().map(|s| &*s.doc).collect();
self.history.pixel_bytes(&self.doc).saturating_add(self.history.extra_bytes(&self.doc, &snaps))
+44 -1
View File
@@ -49,6 +49,18 @@ pub struct KysSet {
pub windows: bool,
}
/// The set name in the root `filename` attribute (`$$$/.../{name}={name}`). The name may itself
/// contain `/` or `=`, so split on the key prefix and the doubled name, not on the last separator.
fn filename_to_name(f: &str) -> &str {
let Some(rest) = f.strip_prefix("$$$/") else { return f.rsplit(['=', '/']).next().unwrap_or(f).trim() };
let rest = rest.strip_prefix("FileName/Presets/KeyboardCustomization/").unwrap_or(rest);
let mid = rest.len() / 2;
if rest.len() % 2 == 1 && rest.is_char_boundary(mid) && rest.as_bytes()[mid] == b'=' && rest[..mid] == rest[mid + 1..] {
return rest[..mid].trim();
}
rest.split_once('=').map_or(rest, |(_, n)| n).trim()
}
/// Parse a `.kys` file (or Photoshop's live `Keyboard Shortcuts.psp`, the same XML). Rows
/// without a name or a shortcut are skipped.
pub fn parse(text: &str) -> Result<KysSet, String> {
@@ -61,7 +73,7 @@ pub fn parse(text: &str) -> Result<KysSet, String> {
if root.tag_name().name() != "photoshop-keyboard-shortcuts" {
return Err("not a keyboard shortcut set (.kys): no <photoshop-keyboard-shortcuts> root".into());
}
let name = root.attribute("filename").map(|f| f.rsplit(['=', '/']).next().unwrap_or(f).trim()).filter(|n| !n.is_empty()).map(str::to_string);
let name = root.attribute("filename").map(filename_to_name).filter(|n| !n.is_empty()).map(str::to_string);
let mut set = KysSet { name, ..Default::default() };
for node in root.children().filter(|n| n.is_element()) {
match node.tag_name().name() {
@@ -119,6 +131,7 @@ pub fn to_ours(set: &KysSet, sc: &str) -> Option<String> {
}
let (mods, key) = match sc.strip_suffix("++") {
Some(m) => (m, "+"),
None if sc == "+" => ("", "+"),
None => sc.rsplit_once('+').unwrap_or(("", sc)),
};
let mods: Vec<&str> = mods
@@ -262,6 +275,7 @@ pub fn photoshop_key(sc: &str) -> Option<String> {
// The key is the last part; `Cmd++` ends in an empty part, so take it from the string.
let (mods, key) = match n.strip_suffix("++") {
Some(m) => (m, "+"),
None if n == "+" => ("", "+"),
None => n.rsplit_once('+').unwrap_or(("", n.as_str())),
};
let has = |m: &str| mods.split('+').any(|x| x == m);
@@ -309,6 +323,13 @@ pub fn write(name: &str, entries: &[Entry]) -> String {
mod tests {
use super::*;
#[test]
fn set_name_with_separators_round_trips() {
for n in ["Retouch 1/2", "a=b", "x/y=z"] {
assert_eq!(parse(&write(n, &[])).unwrap().name.as_deref(), Some(n));
}
}
const SET: &str = "\u{feff}<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<photoshop-keyboard-shortcuts version=\"4\" filename=\"$$$/FileName/Presets/KeyboardCustomization/texcuts=texcuts\">
\t<command kind=\"static\" name=\"Levels...\" id=\"1801\"><shortcut>Cmd+L</shortcut></command>
@@ -323,6 +344,28 @@ mod tests {
Candidate { id: id.into(), label: label.into(), default: default.map(str::to_string) }
}
#[test]
fn a_bare_plus_key_is_kept_when_saving() {
assert_eq!(photoshop_key("+").as_deref(), Some("+"));
assert_eq!(photoshop_key("Cmd++").as_deref(), Some("Cmd++"));
let e = Entry { id: "tools.zoom".into(), label: "Zoom Tool".into(), tool: true, keys: vec!["+".into()] };
assert!(write("Mine", &[e]).contains(">+</tool>"));
}
#[test]
fn a_bare_plus_command_survives_a_windows_set_with_a_ctrl_row() {
let xml = "<photoshop-keyboard-shortcuts>\
<command name=\"Copy\"><shortcut>Ctrl+C</shortcut></command>\
<command name=\"Zoom In\"><shortcut>+</shortcut></command>\
</photoshop-keyboard-shortcuts>";
let set = parse(xml).unwrap();
assert!(set.windows);
assert_eq!(to_ours(&set, "+").as_deref(), Some("+"));
let p = plan([c("edit.copy", "Copy", Some("Cmd+C")), c("view.zoomIn", "Zoom In", None)], &set);
assert_eq!(p.set.get("view.zoomIn").map(String::as_str), Some("+"));
assert!(p.unreadable.is_empty());
}
#[test]
fn rows_match_by_label_and_the_default_holder_wins_a_shared_label() {
let set = parse(SET).unwrap();
+18 -2
View File
@@ -562,6 +562,16 @@ impl Default for Export {
/// [`Performance::memory_usage_mb`]'s default.
pub const DEFAULT_MEMORY_USAGE_MB: u32 = 8192;
/// The most Memory Usage the web build honours, in MB: a wasm module's linear memory tops out at
/// 4 GiB, so a larger allowance (the desktop default is 8 GiB) would never trim anything.
const WASM_MEMORY_USAGE_CAP_MB: u32 = 1536;
/// The Memory Usage a build honours for a `stored` preference: capped on the web build (`web`).
/// Separate from the target check so native tests exercise both.
fn honoured_memory_usage_mb(stored: u32, web: bool) -> u32 {
if web { stored.min(WASM_MEMORY_USAGE_CAP_MB) } else { stored }
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Performance {
@@ -617,10 +627,16 @@ impl Performance {
self.rendering_mode.unwrap_or_else(|| if !self.use_gpu || self.gpu_backend == GpuBackend::Cpu { RenderingMode::Cpu } else { RenderingMode::Auto })
}
/// [`Performance::memory_usage_mb`] as the build can honour it: capped on the web, where the
/// stored preference (a saved 8192 still loads) is larger than the module's address space.
pub fn effective_memory_usage_mb(&self) -> u32 {
honoured_memory_usage_mb(self.memory_usage_mb, cfg!(target_arch = "wasm32"))
}
/// Pixel memory a document and its History may hold (Memory Usage), in bytes: beyond it
/// the oldest history states are dropped.
pub fn history_budget_bytes(&self) -> usize {
(self.memory_usage_mb as usize).saturating_mul(1 << 20)
(self.effective_memory_usage_mb() as usize).saturating_mul(1 << 20)
}
}
@@ -1314,7 +1330,7 @@ impl Session {
st.trim_history();
}
photocraft_compose::set_effect_cache_budget(budget << 20);
crate::transform_cmds::set_budget(u64::from(self.prefs().performance.memory_usage_mb) << 20);
crate::transform_cmds::set_budget(u64::from(self.prefs().performance.effective_memory_usage_mb()) << 20);
// Without waiting for the system font scan: this runs on the UI thread at launch.
photocraft_text::set_shared_missing_glyph_protection(self.prefs().type_.missing_glyph_protection());
crate::plugin_cmds::sync_prefs(self);
+32
View File
@@ -1033,3 +1033,35 @@ fn a_wrongly_typed_section_or_colour_setting_resets_only_that_part() {
assert!(s.load_prefs_json(&deep).is_ok());
assert!(s.load_prefs_json(r#"{"interface": {"theme": 3, "uiScale": [1]}, "workspaces": {"x": 1}, "menus": 1}"#).is_ok());
}
/// A saved Memory Usage is kept as stored, and means the same on desktop; only the web build
/// caps what it honours.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn the_history_budget_is_the_stored_memory_usage_on_native() {
for mb in [0u32, 1, 1536, 8192, 65536, u32::MAX] {
let p = Performance { memory_usage_mb: mb, ..Performance::default() };
assert_eq!(p.effective_memory_usage_mb(), mb);
assert_eq!(p.history_budget_bytes(), (mb as usize) << 20, "{mb} MB");
}
let mut s = Session::new();
s.load_prefs_json(r#"{"performance": {"memoryUsageMb": 8192}}"#).unwrap();
assert_eq!(s.prefs().performance.memory_usage_mb, 8192);
assert_eq!(s.prefs().performance.history_budget_bytes(), 8192 << 20);
}
/// The web build honours Memory Usage up to its cap: below and at it as stored, above it the cap.
#[test]
fn the_web_build_caps_memory_usage() {
for (stored, honoured) in [
(0, 0),
(1024, 1024),
(WASM_MEMORY_USAGE_CAP_MB, WASM_MEMORY_USAGE_CAP_MB),
(WASM_MEMORY_USAGE_CAP_MB + 1, WASM_MEMORY_USAGE_CAP_MB),
(8192, WASM_MEMORY_USAGE_CAP_MB),
(u32::MAX, WASM_MEMORY_USAGE_CAP_MB),
] {
assert_eq!(honoured_memory_usage_mb(stored, true), honoured, "web, {stored} MB");
assert_eq!(honoured_memory_usage_mb(stored, false), stored, "desktop, {stored} MB");
}
}
+90 -2
View File
@@ -265,8 +265,9 @@ fn new_adjustment_and_fill_layers_are_masked_by_the_selection() {
assert_eq!(mask.value(10, 10), 1.0, "{id} at {depth}-bit: the mask hides the selected area");
assert_eq!(mask.value(2, 2), 0.0, "{id} at {depth}-bit: the mask reveals outside the selection");
assert!(mask.linked && mask.enabled, "{id}");
// The selection stays, as after Layer › Layer Mask › Reveal Selection.
assert!(d.doc.selection.is_some(), "{id} dropped the selection");
// The selection went into the mask, so Photoshop drops it, as Layer › Layer Mask ›
// Reveal Selection does (#5251, #8801).
assert!(d.doc.selection.is_none(), "{id} at {depth}-bit kept the selection");
// The white canvas is untouched outside the selection.
assert_eq!(px(&mut s, 2, 2), vec![1.0, 1.0, 1.0, 1.0], "{id} at {depth}-bit changed pixels outside the selection");
if blackens {
@@ -274,6 +275,29 @@ fn new_adjustment_and_fill_layers_are_masked_by_the_selection() {
}
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active().unwrap().doc.layer(layer).is_none(), "{id}: undo keeps the layer");
let sel = s.active().unwrap().doc.selection.as_ref().unwrap_or_else(|| panic!("{id}: undo does not bring the selection back"));
assert_eq!(sel.content_bounds(), photocraft_geom::Rect::new(8, 8, 24, 24), "{id}: undo restores the same selection");
}
}
}
#[test]
fn an_edit_after_a_new_fill_layer_on_a_selection_reaches_the_whole_layer() {
// #8801: the selection that masked a new fill or adjustment layer is gone, so the next
// pixel edit on another layer is not limited to it.
for (id, params) in [("layer.newFillLayer.solidColor", json!({"color": "#000000"})), ("layer.newAdjustmentLayer.invert", json!({}))] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 20})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#3366cc"})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
s.execute(id, params).unwrap();
s.execute("layer.setProps", json!({"visible": false})).unwrap();
s.execute("layer.selectBottom", json!({})).unwrap();
s.execute("layer.duplicate", json!({})).unwrap();
s.execute("image.adjustments.desaturate", json!({})).unwrap();
for x in [5, 35] {
let p = px(&mut s, x, 10);
assert!((p[0] - p[1]).abs() < 1e-3 && (p[1] - p[2]).abs() < 1e-3, "{id}: ({x}, 10) is not grey: {p:?}");
}
}
}
@@ -351,6 +375,7 @@ fn new_fill_and_adjustment_layers_take_the_active_path_as_their_vector_mask() {
let want = if path_name == "work" { d.doc.work_path.clone().unwrap() } else { d.doc.paths[0].path.clone() };
assert_eq!(vm.path, want, "{id}: the vector mask is the path");
assert!(l.mask.is_none(), "{id}: the path wins over the selection");
assert!(d.doc.selection.is_some(), "{id}: an unused selection stays");
for (x, y) in [(2, 2), (42, 32), (30, 30)] {
assert_eq!(px(&mut s, x, y), vec![1.0, 1.0, 1.0, 1.0], "{id} ({path_name}) at {depth}-bit changed pixels outside the path at ({x}, {y})");
}
@@ -433,6 +458,34 @@ fn marquee_dragged_past_the_canvas_stops_at_its_edge() {
assert_eq!(bounds(&mut s), Value::Null);
}
#[test]
fn subtract_clears_selection_when_only_disjoint_off_canvas_pixels_remain() {
use photocraft_geom::Rect;
use photocraft_raster::Surface;
let mut s = session_with_doc(); // 64 × 48
let mut selection = Surface::new(photocraft_color::PixelFormat::GRAY8);
// Simulate a moved sparse selection: one remnant on either side of the canvas and one
// in-canvas region that the subtract marquee will remove. The off-canvas bounds straddle
// the canvas, so testing only their bounding-box intersection would incorrectly keep it.
selection.fill_rect(Rect::new(-4, 10, 0, 14), &[1.0]);
selection.fill_rect(Rect::new(20, 10, 24, 14), &[1.0]);
selection.fill_rect(Rect::new(64, 10, 68, 14), &[1.0]);
Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(selection);
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 64, "height": 48, "mode": "subtract"})).unwrap();
assert!(s.active().unwrap().doc.selection.is_none());
}
#[test]
fn subtract_clears_selection_with_nonzero_default_when_canvas_is_empty() {
use photocraft_raster::Surface;
let mut s = session_with_doc();
Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0]));
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 64, "height": 48, "mode": "subtract"})).unwrap();
assert!(s.active().unwrap().doc.selection.is_none());
}
#[test]
fn marquee_steps_are_named_after_their_tool() {
// #513: the elliptical marquee shares `select.rect` but records its own step name.
@@ -506,6 +559,41 @@ fn undo_and_redo_restore_the_targeted_layers() {
assert_eq!(target(&s), copies);
}
#[test]
fn selection_inverse_preserves_coverage_history_and_empty_selection() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 530, "height": 270, "depth": depth})).unwrap();
s.execute("select.rect", json!({"x": 250, "y": 10, "width": 30, "height": 30, "ellipse": true})).unwrap();
let before = s.active().unwrap().doc.clone();
let area = before.bounds();
let original = before.selection.as_ref().unwrap().to_interleaved(area);
s.execute("select.inverse", json!({})).unwrap();
let after = s.active().unwrap();
let inverted = after.doc.selection.as_ref().unwrap();
assert_eq!(inverted.to_interleaved(area), original.iter().map(|b| 255 - b).collect::<Vec<_>>());
assert_eq!(inverted.sample_channel(530, 269, 0), 0.0);
assert_eq!(inverted.sample_channel(529, 270, 0), 0.0);
assert_eq!(after.last_damage, Some(photocraft_geom::Rect::EMPTY));
assert_eq!(before.selection.as_ref().unwrap().to_interleaved(area), original);
assert!(s.undo());
assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().to_interleaved(area), original);
assert!(s.redo());
s.execute("select.inverse", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().to_interleaved(area), original);
s.execute("select.all", json!({})).unwrap();
s.execute("select.inverse", json!({})).unwrap();
let empty = s.active().unwrap().doc.selection.as_ref().unwrap();
assert_eq!(empty.tile_count(), 0);
// Keep Some(empty), as before: the next Inverse selects the whole canvas again.
s.execute("select.inverse", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), area);
}
let mut s = Session::new();
assert!(s.execute("select.inverse", json!({})).is_err());
}
#[test]
fn selection_modes() {
let mut s = session_with_doc();
@@ -91,3 +91,42 @@ fn subtracting_from_an_existing_selection_preserves_other_pixels_and_history() {
assert_eq!(coverage(&session, 12, 12), removed);
}
}
#[test]
fn marquee_add_subtract_match_dense_combination_across_tiles() {
use photocraft_algo::selection as sel;
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
// A partial edge tile, a shape crossing a tile boundary, and an untouched distant tile.
let area = Rect::from_xywh(0, 0, 777, 263);
let r = Rect::from_xywh(247, -5, 24, 32);
for depth in [8, 16, 32] {
for ellipse in [false, true] {
for mode in ["add", "subtract"] {
let mut session = Session::new();
session.execute("file.new", json!({"width": 777, "height": 263, "depth": depth})).unwrap();
let mut old = Surface::new(PixelFormat::GRAY8);
old.fill_rect(Rect::from_xywh(240, 0, 40, 40), &[0.6]);
old.fill_rect(Rect::from_xywh(768, 256, 9, 7), &[0.4]);
std::sync::Arc::make_mut(&mut session.active_mut().unwrap().doc).selection = Some(old.clone());
let shape = if ellipse {
sel::ellipse_surface(r, area, true)
} else {
let mut shape = Surface::new(PixelFormat::GRAY8);
shape.fill_rect(r.intersect(&area), &[1.0]);
shape
};
let expected = sel::combine_surfaces(&old, &shape, area, |a, b| if mode == "add" { a.max(b) } else { a * (1.0 - b) });
session.execute("select.rect", json!({"x": 247, "y": -5, "width": 24, "height": 32, "ellipse": ellipse, "mode": mode})).unwrap();
let pixels = |session: &Session| session.active().unwrap().doc.selection.as_ref().unwrap().read_region(area);
assert_eq!(pixels(&session), expected.read_region(area), "depth={depth}, ellipse={ellipse}, mode={mode}");
assert!(session.undo());
assert_eq!(pixels(&session), old.read_region(area));
assert!(session.redo());
assert_eq!(pixels(&session), expected.read_region(area));
}
}
}
}
@@ -0,0 +1,34 @@
//! Cross-PR acceptance checks for the merged parity batch: each test pins one fix through the
//! public command surface, so a bad merge between the PRs shows up here.
use photocraft_engine::Session;
use serde_json::json;
fn session(width: u32, height: u32) -> Session {
let mut session = Session::new();
session.execute("file.new", json!({"width": width, "height": height})).unwrap();
session
}
#[test]
fn a_new_fill_layer_consumes_the_selection_it_took_as_mask() {
let mut s = session(32, 24);
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 8})).unwrap();
s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap();
assert!(s.active().unwrap().doc.selection.is_none());
}
#[test]
fn define_pattern_accepts_a_canvas_wider_than_the_old_4000_px_cap() {
let mut s = session(4500, 8);
s.execute("edit.definePattern", json!({"name": "Wide"})).unwrap();
let mut s = session(8001, 8);
assert!(s.execute("edit.definePattern", json!({"name": "TooWide"})).is_err());
}
#[test]
fn gaussian_blur_accepts_radii_below_one_without_failing() {
for radius in [0.1, 0.3, 0.7, 1.0] {
let mut s = session(24, 16);
s.execute("filter.blur.gaussianBlur", json!({"radius": radius})).unwrap();
}
}
+6 -8
View File
@@ -45,12 +45,10 @@ fn newly_created_layer_masks_follow_document_depth() {
assert_eq!(m.value(20, 20), white);
if selected {
assert_eq!(m.value(0, 0), 0.0);
if command == "layer.layerMask.revealSelection" {
// The selection became the mask: Photoshop drops it, as Hide Selection does.
assert!(d.doc.selection.is_none(), "{command}: the selection is dropped");
} else {
assert_eq!(d.doc.selection, before.selection, "the selection is unchanged");
}
// The selection became the mask: Photoshop drops it, as Hide Selection does
// (#5251; new fill and adjustment layers too, #8801).
assert!(d.doc.selection.is_none(), "{command}: the selection is dropped");
assert!(before.selection.is_some());
} else {
assert_eq!(m.surface.tile_count(), 0, "uniform masks stay sparse");
assert_eq!(m.value(-300_000, 300_000), white);
@@ -72,7 +70,6 @@ fn soft_mask_edits_match_after_native_and_psd_reopen() {
let mut s = session(depth, "rgb");
s.execute("select.rect", json!({"x": 8, "y": 6, "width": 40, "height": 32, "antiAlias": false})).unwrap();
s.execute("layer.newFillLayer.solidColor", json!({"color": "#000000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
let before = s.active().unwrap().doc.clone();
let native = photocraft_format::save_to_bytes(&before, &Default::default()).unwrap();
let psd = photocraft_io::export(&before, "psd", &Default::default()).unwrap();
@@ -138,7 +135,8 @@ fn mask_creation_preserves_selection_coverage_and_existing_masks() {
before.selection.as_ref().unwrap().read_region(before.bounds()),
"{depth}-bit coverage is unchanged"
);
let kept = if command == "layer.layerMask.revealSelection" { None } else { before.selection.clone() };
// The selection went into the mask, except for the Gradient tool's fill layer.
let kept = if command == "gradient.fill.create" { before.selection.clone() } else { None };
assert_eq!(after.selection, kept, "{command}");
assert_eq!(after.layers[0].mask, before.layers[0].mask, "creating a mask does not requantize an existing mask");
}
+1
View File
@@ -37,6 +37,7 @@ mod guide_layout_limit;
mod guide_layout_preview;
mod hdr_gamma_direction;
mod huge_doc_flatten;
mod integration_batch;
mod jpeg_exif_resolution;
mod keyboard_shortcut_bulk;
mod kra;
@@ -101,7 +101,6 @@ fn merging_a_masked_fill_layer_keeps_content_bounds_in_every_mode() {
let (mut s, a, _) = scene(mode, depth);
s.execute("select.rect", json!({"x": B[0], "y": B[1], "width": B[2], "height": B[3]})).unwrap();
let fill = id_of(&s.execute("layer.newFillLayer.solidColor", json!({"color": "#3060c0"})).unwrap());
s.execute("select.deselect", json!({})).unwrap();
(s, a, fill)
};
for &(mode, depth) in CASES {
+92
View File
@@ -0,0 +1,92 @@
//! #10030: the decoded-source cache counts the bytes it holds, and Purge All and closing a
//! document with smart objects release them. One integration-test process keeps the global
//! cache isolated from parallel unit tests; the tests here take turns on it.
use std::sync::Mutex;
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType};
use photocraft_engine::{Session, file_cmds, smart_cmds};
use serde_json::json;
static TURN: Mutex<()> = Mutex::new(());
const SIDE: u32 = 256;
fn png() -> Vec<u8> {
let px = (0..SIDE * SIDE).flat_map(|i| [(i % SIDE) as u8, (i / SIDE) as u8, ((i * 7) % 251) as u8, 255]);
let img = Image::from_raw(SIDE, SIDE, ChannelLayout::Rgba, SampleType::U8, px.collect()).unwrap();
photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap()
}
/// Every cache `edit.purge.all` clears, emptied.
fn empty_caches() {
smart_cmds::purge_source_cache();
smart_cmds::purge_stack_cache();
photocraft_compose::purge_effect_cache();
photocraft_compose::masks::purge_cache();
photocraft_compose::shape_split::purge_cache();
}
fn session_with_smart_object(bytes: &[u8]) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": SIDE, "height": SIDE})).unwrap();
file_cmds::place_bytes(&mut s, "art.png", bytes.to_vec(), None, &json!({})).unwrap();
s
}
fn decode(s: &Session, bytes: &[u8]) {
smart_cmds::source_image("art.png", bytes, s.active().unwrap().doc.pixel_format()).unwrap();
}
#[test]
fn the_cache_counts_real_tile_bytes() {
let _turn = TURN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
empty_caches();
let bytes = png();
let s = session_with_smart_object(&bytes);
decode(&s, &bytes);
let held = smart_cmds::source_cache_bytes();
let px = (SIDE * SIDE) as usize;
assert!(held >= 4 * px && held < 5 * px, "{held} bytes for {px} 8-bit RGBA pixels");
assert_eq!(smart_cmds::purge_source_cache(), held);
assert_eq!(smart_cmds::source_cache_bytes(), 0);
}
#[test]
fn purge_all_reports_and_empties_the_source_cache() {
let _turn = TURN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
empty_caches();
let bytes = png();
let mut s = session_with_smart_object(&bytes);
// Nothing else holds anything to purge: only the source cache enables Purge All.
s.execute("edit.purge.histories", json!({})).unwrap();
s.clipboard = None;
assert!(!s.is_enabled("edit.purge.all"));
decode(&s, &bytes);
assert!(smart_cmds::source_cache_bytes() > 0);
assert!(s.is_enabled("edit.purge.all"));
let purged = s.execute("edit.purge.all", json!({})).unwrap();
assert!(purged["purged"].as_array().unwrap().contains(&json!("smart object source cache")), "{purged}");
assert_eq!(smart_cmds::source_cache_bytes(), 0);
assert!(!s.is_enabled("edit.purge.all"));
}
#[test]
fn closing_a_document_with_a_smart_object_empties_the_source_cache() {
let _turn = TURN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
empty_caches();
let bytes = png();
let mut s = session_with_smart_object(&bytes);
decode(&s, &bytes);
assert!(smart_cmds::source_cache_bytes() > 0);
s.execute("file.close", json!({})).unwrap();
assert_eq!(smart_cmds::source_cache_bytes(), 0);
// A document without smart objects leaves it alone.
let mut plain = Session::new();
plain.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let s = session_with_smart_object(&bytes);
decode(&s, &bytes);
plain.execute("file.close", json!({})).unwrap();
assert!(smart_cmds::source_cache_bytes() > 0);
empty_caches();
}
+50
View File
@@ -0,0 +1,50 @@
//! #10030: decoding a smart object's source (what its first re-render does) composites it in
//! tile-aligned blocks, so the peak memory growth stays a small multiple of the source's pixels, not the 32
//! bytes a pixel the full-size RGBA buffer and its f32 copy took on top of the decoded document.
//!
//! The one test here runs in its own process: it reads the process's peak RSS (`VmHWM`, Linux
//! only) and nothing else may allocate meanwhile.
#![cfg(target_os = "linux")]
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType};
use photocraft_engine::{Session, smart_cmds};
use serde_json::json;
fn peak_rss_bytes() -> Option<u64> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let line = status.lines().find(|l| l.starts_with("VmHWM:"))?;
let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
Some(kib * 1024)
}
const SIDE: u32 = 4096;
fn png() -> Vec<u8> {
let px = (0..SIDE * SIDE).flat_map(|i| {
let (x, y) = (i % SIDE, i / SIDE);
[(x * 5 + y) as u8, ((y * 7) ^ x) as u8, ((x * y) % 251) as u8, 255 - ((x / 64 + y / 64) % 4 * 40) as u8]
});
let img = Image::from_raw(SIDE, SIDE, ChannelLayout::Rgba, SampleType::U8, px.collect()).unwrap();
photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap()
}
#[test]
fn decoding_a_smart_object_source_stays_within_16_bytes_a_source_pixel() {
let bytes = png();
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let fmt = s.active().unwrap().doc.pixel_format();
// Writing 5 resets the peak RSS to the current RSS; skip where the kernel doesn't allow it.
if std::fs::write("/proc/self/clear_refs", "5").is_err() {
return;
}
let Some(before) = peak_rss_bytes() else { return };
let img = smart_cmds::source_image("big.png", &bytes, fmt).unwrap();
let grown = peak_rss_bytes().unwrap().saturating_sub(before);
assert_eq!(img.bounds.width(), SIDE);
let limit = 16 * u64::from(SIDE) * u64::from(SIDE);
eprintln!("peak RSS growth {} MiB for {} px ({:.1} B/px)", grown >> 20, SIDE * SIDE, grown as f64 / f64::from(SIDE * SIDE));
assert!(grown < limit, "peak RSS grew by {grown} bytes, over {limit} (16 B/px)");
}
+11 -4
View File
@@ -34,19 +34,26 @@ fn background_save_outcomes_report_failure_then_recovery() {
let root = temp_dir("retry-failed-write");
let blocked = root.join("recovery");
std::fs::write(&blocked, b"not a directory").unwrap();
let saver = Autosaver::new(&blocked, "doc");
// Not dropped on a failure: dropping joins the worker, which waits for a write in progress,
// so a stuck write would hold the test past its own bound until nextest kills it.
let saver = std::mem::ManuallyDrop::new(Autosaver::new(&blocked, "doc"));
let doc = Arc::new(rich_doc(ColorMode::Rgb, SampleType::U8));
saver.request_checked(doc.clone(), 7, None, SaveOptions::default()).unwrap();
// The bound only turns a worker that never reports into a failure instead of a hang. A real
// write flushes every tile to disk and took over 30 s on a busy Windows CI runner (normally
// 2-3 s), so it is generous, and still under the 10 minutes after which nextest's `ci`
// profile kills a test.
const PATIENCE: std::time::Duration = std::time::Duration::from_secs(5 * 60);
let receive = |saver: &Autosaver| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let deadline = std::time::Instant::now() + PATIENCE;
while std::time::Instant::now() < deadline {
if let Some(outcome) = saver.take_completed().into_iter().next() {
return outcome;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("autosave worker did not report its completed write within 30 seconds");
panic!("autosave worker did not report its completed write within {PATIENCE:?}");
};
let (revision, result) = receive(&saver);
assert_eq!(revision, 7);
@@ -60,7 +67,7 @@ fn background_save_outcomes_report_failure_then_recovery() {
result.unwrap();
assert_eq!(list_recovery(&blocked).len(), 1);
assert_eq!(recover(&list_recovery(&blocked)[0]).unwrap(), *doc);
drop(saver);
drop(std::mem::ManuallyDrop::into_inner(saver));
std::fs::remove_dir_all(root).unwrap();
}
+26 -9
View File
@@ -1116,15 +1116,10 @@ fn wrap(i: i32, n: i32) -> i32 {
return ((i % n) + n) % n;
}
// compose::pattern::Tile::sample through Placement::map. p3 = (origin, cos, sin),
// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA.
fn pattern_sample(d: vec2<i32>) -> vec4<f32> {
let dx = f32(d.x) + 0.5 - op.p3.x;
let dy = f32(d.y) + 0.5 - op.p3.y;
let c = op.p3.z;
let s = op.p3.w;
let u = (dx * c - dy * s) * op.p4.x - 0.5;
let v = (dx * s + dy * c) * op.p4.x - 0.5;
// One bilinear, wrapping, premultiplied tap at tile coordinates (u, v) (Tile::sample_premul).
fn pattern_tap(u0: f32, v0: f32) -> vec4<f32> {
let u = u0 - 0.5;
let v = v0 - 0.5;
let x0 = floor(u);
let y0 = floor(v);
let fx = u - x0;
@@ -1143,6 +1138,28 @@ fn pattern_sample(d: vec2<i32>) -> vec4<f32> {
acc += textureLoad(pat_tex, vec2(wrap(ix + i, tw), wrap(iy + j, th)), 0) * (wx[i] * wy[j]);
}
}
return acc;
}
// compose::pattern::Tile::sample_area through Placement::map. p3 = (origin, cos, sin),
// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. Below 100 % a pixel
// averages n x n taps over its footprint (n = ceil(1 / scale), at most 8), like the CPU.
fn pattern_sample(d: vec2<i32>) -> vec4<f32> {
var n = 1;
if (op.p4.x > 1.0) { n = i32(min(ceil(op.p4.x), 8.0)); }
let c = op.p3.z;
let s = op.p3.w;
var acc = vec4(0.0);
for (var j = 0; j < n; j++) {
for (var i = 0; i < n; i++) {
let ox = (f32(i) + 0.5) / f32(n) - 0.5;
let oy = (f32(j) + 0.5) / f32(n) - 0.5;
let dx = f32(d.x) + 0.5 + ox - op.p3.x;
let dy = f32(d.y) + 0.5 + oy - op.p3.y;
acc += pattern_tap((dx * c - dy * s) * op.p4.x, (dx * s + dy * c) * op.p4.x);
}
}
acc = acc / f32(n * n);
if (acc.a <= 0.0) { return vec4(0.0); }
return vec4(acc.rgb / acc.a, min(acc.a, 1.0));
}
+3
View File
@@ -33,6 +33,9 @@ pub const RESIDENT_BUDGET: u64 = 4 << 30;
/// GPU memory pattern textures may hold before the least recently painted are dropped (those the
/// current frame paints with always stay).
pub const PATTERN_BUDGET: u64 = 256 << 20;
/// The most a single pattern's RGBA32F texture may take. A pattern over it paints on the CPU, which
/// holds a large 8-bit pattern at 4 bytes a pixel instead of 16 (`Pattern::working_bytes`).
pub(crate) const MAX_PATTERN_TEXTURE_BYTES: u64 = 256 << 20;
/// A pattern texture no frame has painted with for this many frames is dropped.
pub const PATTERN_IDLE_FRAMES: u64 = 240;
+18 -5
View File
@@ -1,5 +1,5 @@
use super::batch::tile_grid;
use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAP16, MAP32, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET};
use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAP16, MAP32, MAX_PATTERN_TEXTURE_BYTES, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET};
use super::kit::{Kit, SHADER, tex_entry, uniform_entry};
use super::paging::{cell_rect, field_reaches, first_error, grid_rects};
use super::textures::{FxEntry, Resident, Tex, TexKind};
@@ -386,19 +386,32 @@ impl Compositor {
if s.lut.as_ref().is_some_and(|l| l.len() > 4096) {
return Err(Unsupported(format!("effect blur on `{}` too wide for the GPU path", f.layer.name)));
}
if let Some(pat) = &s.pattern {
self.check_pattern(pat)?;
}
}
}
}
for pass in &p.passes {
if let Some(pat) = pass.pattern
&& (pat.width > self.device_max || pat.height > self.device_max)
{
return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name)));
if let Some(pat) = pass.pattern {
self.check_pattern(pat)?;
}
}
Ok(())
}
/// A pattern's texture must fit the device and the per-pattern cap; a larger one renders on
/// the CPU compositor.
fn check_pattern(&self, pat: &photocraft_doc::Pattern) -> Result<(), Unsupported> {
if pat.width > self.device_max || pat.height > self.device_max {
return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name)));
}
if u64::from(pat.width) * u64::from(pat.height) * 16 > MAX_PATTERN_TEXTURE_BYTES {
return Err(Unsupported(format!("pattern `{}` too large for a GPU texture; the CPU path paints it", pat.name)));
}
Ok(())
}
/// Drop GPU textures of a closed document.
pub fn forget_doc(&mut self, doc: DocId) {
self.residents.retain(|_, r| r.doc != doc);
+22 -1
View File
@@ -2633,6 +2633,27 @@ fn closed_documents_release_their_pattern_textures() {
check(&mut g, &a, "the pattern is uploaded again after being forgotten");
}
/// A pattern whose RGBA32F texture would pass the per-pattern cap (Define Pattern accepts 8-bit
/// ones up to 8000 x 8000) is declined by the GPU path and painted by the CPU compositor, which
/// holds a large 8-bit pattern at a quarter of the texture's size.
#[test]
fn a_pattern_too_large_for_a_gpu_texture_falls_back_to_the_cpu() {
let Some(mut g) = gpu() else { return };
let mut d = Document::new("big pattern", Size::new(8, 6), ColorMode::Rgb, SampleType::U8);
// 100 MB as RGBA8, 400 MB as an RGBA32F texture.
let surface = photocraft_raster::Surface::with_default(PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]);
let pat = Pattern { id: "big".into(), name: "big".into(), width: 5000, height: 5000, surface };
assert!(pat.is_paintable());
d.layers.push(Layer::new(
"pattern",
LayerContent::Fill(Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }),
));
d.patterns.push(pat);
assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).is_err(), "the GPU path declines it");
assert_eq!(g.comp.pattern_cache_bytes(), 0, "nothing was uploaded");
assert!(photocraft_compose::flatten(&d).px.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0]), "the CPU compositor paints it");
}
/// #4971: pattern textures are bounded while their documents stay open, by a byte budget and
/// by age. What the current frame paints with is never dropped.
#[test]
@@ -2712,7 +2733,7 @@ fn pattern_cache_tracks_source_format_and_cow_tiles() {
fn pattern_fill_layers() {
let Some(mut g) = gpu() else { return };
let pat = checker_pattern();
for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false)] {
for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false), (true, 0.3, 10.0, false)] {
let mut d = base_doc(70, 50);
d.patterns.push(pat.clone());
let fill = Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase: (3.0, -2.0) };
+8 -2
View File
@@ -69,9 +69,15 @@ const BLEND_LONG_NAMES: [(BlendMode, &str); 17] = [
(BlendMode::Luminosity, "luminosity"),
];
/// A blend-mode enum from either its four-character code or its long string ID.
/// A blend-mode enum from its four-character code, its long string ID, or the layer record's key:
/// Photoshop 2026 writes `vLit`, `lddg`, `lbrn`… as an effect's `Md ` for some modes (#8567).
pub(crate) fn blend_from_id(v: &[u8]) -> Option<BlendMode> {
BLEND_NAMES.iter().chain(&BLEND_LONG_NAMES).find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m)
BLEND_NAMES
.iter()
.chain(&BLEND_LONG_NAMES)
.find(|(_, n)| n.as_bytes() == v)
.map(|(m, _)| *m)
.or_else(|| <[u8; 4]>::try_from(v).ok().and_then(BlendMode::from_psd_key))
}
fn blend_of(d: &Descriptor, key: &str, default: BlendMode) -> BlendMode {
+29
View File
@@ -198,6 +198,35 @@ fn blend_names_are_unique() {
}
}
#[test]
fn layer_record_blend_keys_parse() {
for (key, mode) in [
(b"vLit", BlendMode::VividLight),
(b"lLit", BlendMode::LinearLight),
(b"pLit", BlendMode::PinLight),
(b"hMix", BlendMode::HardMix),
(b"lbrn", BlendMode::LinearBurn),
(b"lddg", BlendMode::LinearDodge),
(b"dkCl", BlendMode::DarkerColor),
(b"lgCl", BlendMode::LighterColor),
(b"fsub", BlendMode::Subtract),
(b"fdiv", BlendMode::Divide),
] {
assert_eq!(blend_from_id(key), Some(mode), "{}", String::from_utf8_lossy(key));
}
assert_eq!(blend_from_id(b"zzzz"), None);
// The issue's Color Overlay: Vivid Light stored as `vLit`, not left as Normal.
let key = |v: &str| Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(v) };
let d = Descriptor::new("null")
.with("masterFXSwitch", Value::Boolean(true))
.with("SoFi", Value::Descriptor(Descriptor::new("SoFi").with("enab", Value::Boolean(true)).with("Md ", key("vLit"))));
let mut data = 0u32.to_be_bytes().to_vec();
data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes());
let (_, fx) = parse_lfx2(&data).unwrap();
let modes: Vec<BlendMode> = fx.iter().filter_map(|e| if let Effect::ColorOverlay { common, .. } = e { Some(common.blend) } else { None }).collect();
assert_eq!(modes, vec![BlendMode::VividLight]);
}
#[test]
fn long_string_blend_ids_parse() {
// Some writers store effect modes as long string IDs (`BlnM` `multiply`, `colorDodge`)
+483 -163
View File
@@ -334,7 +334,16 @@ pub(crate) fn image_surface(img: &Image, target_layout: ChannelLayout, csample:
/// (keeping CMYK / depth exactly) instead of going through the compositor.
pub(crate) fn single_layer(doc: &Document) -> Option<&Surface> {
let [l] = &doc.layers[..] else { return None };
let ok = l.visible
match &l.content {
LayerContent::Raster(s) if plain_layer(l, doc.mode) && s.format() == doc.pixel_format() => Some(s),
_ => None,
}
}
/// A visible, unmasked, normal, fully opaque layer with no effects or blending options: on its
/// own, its pixels are the composite.
fn plain_layer(l: &Layer, mode: ColorMode) -> bool {
l.visible
&& l.opacity >= 1.0
&& l.fill_opacity >= 1.0
&& l.mask.is_none()
@@ -342,12 +351,8 @@ pub(crate) fn single_layer(doc: &Document) -> Option<&Surface> {
&& l.effects.items.is_empty()
&& l.effects.psd_raw.is_none()
&& matches!(l.blend, BlendMode::Normal | BlendMode::PassThrough)
&& photocraft_compose::channel_weights(l, doc.mode).is_none()
&& !photocraft_compose::blend_if_active(l, doc.mode);
match (&l.content, ok) {
(LayerContent::Raster(s), true) if s.format() == doc.pixel_format() => Some(s),
_ => None,
}
&& photocraft_compose::channel_weights(l, mode).is_none()
&& !photocraft_compose::blend_if_active(l, mode)
}
pub(crate) fn layout_for(mode: ColorMode, alpha: bool) -> ChannelLayout {
@@ -378,45 +383,64 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec<String>) -> Result<I
/// `cmyk_target` / `lab_target`: the format stores CMYK / Lab pixels.
fn document_to_image_for(doc: &Document, warnings: &mut Vec<String>, cmyk_target: bool, lab_target: bool) -> Result<Image, IoError> {
let src = FlatSource::new(doc, warnings, cmyk_target, lab_target)?;
let (w, h) = (doc.size.width, doc.size.height);
let canvas = doc.bounds();
let fmt = doc.pixel_format();
let n = (w as usize) * (h as usize);
// A Lab document keeps its pixels only for a format that stores Lab (TIFF); for the others it
// goes through the sRGB composite, which flattens nothing when there is a single plain layer.
let single = single_layer(doc);
let native = single.filter(|_| fmt.mode != ColorMode::Lab || lab_target);
let mut icc = doc.icc_profile.as_ref().map(|i| i.to_vec());
let img = if let Some(s) = native {
// Native path: keep model and depth. The surface's encoded samples are the codec's raw
// native-endian samples, copied a band of rows at a time (alpha dropped when opaque).
let ch = fmt.channels();
let opaque = opaque_surface(s, canvas);
let layout = layout_for(fmt.mode, !opaque);
let bps = fmt.sample.bytes();
let keep = if opaque { (ch - 1) * bps } else { ch * bps };
let mut data = try_buffer(n, keep)?;
let rows = (BAND_BYTES / (w as usize * ch * bps).max(1)).max(1) as i32;
let mut y = 0;
while y < h as i32 {
let y1 = y.saturating_add(rows).min(h as i32);
let band = s.to_interleaved(Rect::new(0, y, w as i32, y1));
if opaque {
for px in band.chunks_exact(ch * bps) {
data.extend_from_slice(&px[..keep]);
}
} else {
data.extend_from_slice(&band);
}
y = y1;
// The single layer's alpha is dropped up front when it is opaque. A composite is rendered
// with alpha, dropped afterwards, in place, when every pixel turned out opaque.
let alpha = src.native.is_none_or(|s| !opaque_surface(s, doc.bounds()));
let bytes = src.sample.bytes();
let mut data = try_buffer((w as usize) * (h as usize), src.layout(alpha).channels() * bytes)?;
let mut opaque = true;
src.bands(alpha, |_, band, translucent| {
opaque &= !translucent;
data.extend_from_slice(&band);
Ok(())
})?;
let mut layout = src.layout(alpha);
if src.native.is_none() && opaque {
drop_alpha(&mut data, layout.color_channels() * bytes, bytes);
layout = src.layout(false);
}
Ok(Image::from_raw(w, h, layout, src.sample, data)?.with_icc(src.icc.clone()).with_meta(src.meta()))
}
/// How a document becomes flat codec pixels, a band of rows at a time: [`document_to_image`]
/// gathers the bands into one image, a streamed PNG export ([`export_png_streamed`]) encodes each
/// band as it is rendered.
struct FlatSource<'a> {
doc: &'a Document,
/// The single plain layer, written in its own model and depth ([`single_layer`]).
native: Option<&'a Surface>,
/// The model of the pixels: the document's for the single layer, else CMYK, gray or RGB.
mode: ColorMode,
sample: CSample,
/// A CMYK composite mixed on ink values by the native CMYK compositor, so opaque pixels keep
/// their exact inks ([`photocraft_compose::cmyk::renders_inks`]).
inks: bool,
/// The CMYK profile any other CMYK composite is converted back through, for a CMYK target.
space: Option<Arc<photocraft_color::convert::CmykSpace>>,
icc: Option<Vec<u8>>,
/// Rows per band; 0 picks them from the size (tests ask for small bands).
rows: i32,
}
impl<'a> FlatSource<'a> {
/// `cmyk_target` / `lab_target`: the format stores CMYK / Lab pixels.
fn new(doc: &'a Document, warnings: &mut Vec<String>, cmyk_target: bool, lab_target: bool) -> Result<Self, IoError> {
let fmt = doc.pixel_format();
let sample = csample(fmt.sample);
// A Lab document keeps its pixels only for a format that stores Lab (TIFF); for the others it
// goes through the sRGB composite, which flattens nothing when there is a single plain layer.
let single = single_layer(doc);
let native = single.filter(|_| fmt.mode != ColorMode::Lab || lab_target);
let mut icc = doc.icc_profile.as_ref().map(|i| i.to_vec());
if native.is_some() {
return Ok(Self { doc, native, mode: fmt.mode, sample, inks: false, space: None, icc, rows: 0 });
}
Image::from_raw(w, h, layout, csample(fmt.sample), data)?
} else {
if single.is_none() {
let count = doc.layer_count();
warnings.push(format!("{count} layer(s) flattened; layers, masks and blend modes are not kept"));
}
let cs = csample(fmt.sample);
if fmt.mode == ColorMode::Cmyk && cmyk_target {
// A CMYK target is written as CMYK with the document's own profile (#2220), which it
// therefore needs; RGB-only targets keep the compositor's own handling (built-in
@@ -435,130 +459,141 @@ fn document_to_image_for(doc: &Document, warnings: &mut Vec<String>, cmyk_target
if icc.is_none() {
icc = Some(profile.to_bytes().to_vec());
}
let (data, opaque) = cmyk_composite(doc, canvas, cs, n, warnings)?;
Image::from_raw(w, h, layout_for(ColorMode::Cmyk, !opaque), cs, data)?
} else {
let gray = fmt.mode == ColorMode::Grayscale;
if matches!(fmt.mode, ColorMode::Cmyk | ColorMode::Lab) {
// RGB-only targets use the sRGB composite directly, without separating CMYK; the
// compositor renders Lab documents in sRGB. Either way the file is tagged sRGB.
warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode));
icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec());
let inks = photocraft_compose::cmyk::renders_inks(doc);
let space = if inks {
None
} else {
// Through RGB and back: the colour model is kept, exact ink values are not.
warnings.push("CMYK composite converted back through the document's CMYK profile; ink values can differ from the layers'".to_string());
photocraft_compose::cmyk_space(doc)
};
return Ok(Self { doc, native: None, mode: ColorMode::Cmyk, sample, inks, space, icc, rows: 0 });
}
if matches!(fmt.mode, ColorMode::Cmyk | ColorMode::Lab) {
// RGB-only targets use the sRGB composite directly, without separating CMYK; the
// compositor renders Lab documents in sRGB. Either way the file is tagged sRGB.
warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode));
icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec());
}
let mode = if fmt.mode == ColorMode::Grayscale { ColorMode::Grayscale } else { ColorMode::Rgb };
Ok(Self { doc, native: None, mode, sample, inks: false, space: None, icc, rows: 0 })
}
fn layout(&self, alpha: bool) -> ChannelLayout {
layout_for(self.mode, alpha)
}
/// The document's EXIF, XMP, text and resolution.
fn meta(&self) -> codecs::Metadata {
let doc = self.doc;
codecs::Metadata {
exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()),
xmp: doc.metadata.xmp.as_deref().map(str::to_owned),
text: doc.metadata.text.clone(),
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
..Default::default()
}
}
/// Every row of the document, top to bottom, as bands of interleaved native-endian samples
/// in [`Self::layout`]`(alpha)`: `sink(rows, samples, translucent)`, where `translucent` says
/// whether a composite band held a pixel that isn't fully opaque (its alpha is dropped all
/// the same when `alpha` is false). A single layer's band is never translucent without
/// `alpha`: drop it only when [`opaque_surface`] says so.
fn bands(&self, alpha: bool, mut sink: impl FnMut(u32, Vec<u8>, bool) -> Result<(), IoError>) -> Result<(), IoError> {
let doc = self.doc;
let (w, h) = (doc.size.width, doc.size.height);
let fmt = doc.pixel_format();
if let Some(s) = self.native {
// Native path: keep model and depth. The surface's encoded samples are the codec's raw
// native-endian samples, copied a band of rows at a time (alpha dropped when opaque).
let ch = fmt.channels();
let bps = fmt.sample.bytes();
let keep = (ch - 1) * bps;
let rows = if self.rows > 0 { self.rows } else { (BAND_BYTES / (w as usize * ch * bps).max(1)).max(1) as i32 };
let mut y = 0;
while y < h as i32 {
let y1 = y.saturating_add(rows).min(h as i32);
let mut band = s.to_interleaved(Rect::new(0, y, w as i32, y1));
if !alpha {
compact_alpha(&mut band, keep, bps);
}
sink((y1 - y) as u32, band, alpha)?;
y = y1;
}
let colors = if gray { 1 } else { 3 };
// Rendered and quantised in bands (no full-size float composite), with alpha; the
// alpha is dropped afterwards, in place, when every pixel turned out opaque.
let mut data = try_buffer(n, (colors + 1) * cs.bytes())?;
let mut opaque = true;
let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> {
opaque &= band.px.iter().all(|p| p[3] >= 1.0);
return Ok(());
}
let cs = self.sample;
let put = move |out: &mut Vec<u8>, v: f32| {
let v = if v.is_nan() { 0.0 } else { v };
match cs {
CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8),
CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()),
CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()),
}
};
let colors = self.layout(false).channels();
// Bands of quantised samples with alpha; the alpha is dropped here when not wanted.
let mut emit = |rows: u32, parts: Vec<Vec<u8>>, translucent: bool| -> Result<(), IoError> {
let mut data = Vec::with_capacity(parts.iter().map(Vec::len).sum());
for part in parts {
data.extend_from_slice(&part);
}
if !alpha {
compact_alpha(&mut data, colors * cs.bytes(), cs.bytes());
}
sink(rows, data, translucent)
};
if self.inks {
// Mixed on ink values, rendered and quantised in bands.
let rendered = photocraft_compose::cmyk::render_cmyk_bands(doc, doc.bounds(), self.rows, |band| -> Result<(), IoError> {
let translucent = band.px.iter().any(|p| p[4] < 1.0 || p[4].is_nan());
let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| {
let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes());
let mut put = |v: f32| {
let v = if v.is_nan() { 0.0 } else { v };
match cs {
CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8),
CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()),
CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()),
let mut out = Vec::with_capacity(range.len() * 5 * cs.bytes());
for p in band.px.get(range).unwrap_or_default() {
for v in p {
put(&mut out, *v);
}
};
for p in &band.px[range] {
if gray {
put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]]));
} else {
put(p[0]);
put(p[1]);
put(p[2]);
}
put(p[3]);
}
out
});
for part in parts {
data.extend_from_slice(&part);
}
Ok(())
emit(band.rect.height(), parts, translucent)
});
if opaque {
drop_alpha(&mut data, colors * cs.bytes(), cs.bytes());
}
let layout = layout_for(if gray { ColorMode::Grayscale } else { ColorMode::Rgb }, !opaque);
Image::from_raw(w, h, layout, cs, data)?
return rendered.unwrap_or_else(|| Err(IoError::Unsupported("the CMYK compositor can't mix this document on its inks".into())));
}
};
let meta = codecs::Metadata {
exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()),
xmp: doc.metadata.xmp.as_deref().map(str::to_owned),
text: doc.metadata.text.clone(),
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
..Default::default()
};
Ok(img.with_icc(icc).with_meta(meta))
}
/// A CMYK document's composite as interleaved CMYK + alpha samples (`cs`), with whether every
/// pixel came out opaque (the caller then drops the alpha). Stacks the native CMYK compositor
/// handles are mixed on their ink values, so opaque pixels keep their exact inks; any other
/// document is composited in RGB and converted back through its own CMYK profile, as PSD
/// export's merged image is. Rendered and quantised in bands on every core.
fn cmyk_composite(doc: &Document, canvas: Rect, cs: CSample, n: usize, warnings: &mut Vec<String>) -> Result<(Vec<u8>, bool), IoError> {
let mut data = try_buffer(n, 5 * cs.bytes())?;
let mut opaque = true;
let put = |out: &mut Vec<u8>, v: f32| {
let v = if v.is_finite() { v } else { 0.0 };
match cs {
CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8),
CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()),
CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()),
}
};
let native = photocraft_compose::cmyk::render_cmyk_bands(doc, canvas, 0, |band| -> Result<(), ()> {
opaque &= band.px.iter().all(|p| p[4] >= 1.0);
let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| {
let mut out = Vec::with_capacity(range.len() * 5 * cs.bytes());
for p in band.px.get(range).unwrap_or_default() {
for v in p {
put(&mut out, *v);
}
}
out
});
for part in parts {
data.extend_from_slice(&part);
}
Ok(())
});
if native.is_none() {
// Through RGB and back: the colour model is kept, exact ink values are not.
warnings.push("CMYK composite converted back through the document's CMYK profile; ink values can differ from the layers'".to_string());
let space = photocraft_compose::cmyk_space(doc);
let to = PixelFormat::new(ColorMode::Cmyk, SampleType::F32, true);
let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> {
opaque &= band.px.iter().all(|p| p[3] >= 1.0);
let cmyk = self.mode == ColorMode::Cmyk;
let gray = self.mode == ColorMode::Grayscale;
let space = &self.space;
// Rendered and quantised in bands (no full-size float composite), with alpha.
photocraft_compose::render_bands(doc, doc.bounds(), self.rows, |band| -> Result<(), IoError> {
let translucent = band.px.iter().any(|p| p[3] < 1.0 || p[3].is_nan());
let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| {
// The composite came through the document's CMYK profile: convert back through it.
// A CMYK composite came through the document's CMYK profile: convert back through it.
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
let mut out = Vec::with_capacity(range.len() * 5 * cs.bytes());
let mut v = [0.0f32; 5];
let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes());
let mut converted = [0.0; 5];
for p in band.px.get(range).unwrap_or_default() {
photocraft_raster::from_rgba_into(&to, *p, &mut v);
for x in v {
put(&mut out, x);
if cmyk {
let channels = photocraft_raster::from_rgba_into(&fmt, *p, &mut converted);
for value in converted.iter().take(channels) {
put(&mut out, *value);
}
continue;
} else if gray {
put(&mut out, photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]]));
} else {
put(&mut out, p[0]);
put(&mut out, p[1]);
put(&mut out, p[2]);
}
put(&mut out, p[3]);
}
out
})
});
for part in parts {
data.extend_from_slice(&part);
}
Ok(())
});
emit(band.rect.height(), parts, translucent)
})
}
if opaque {
drop_alpha(&mut data, 4 * cs.bytes(), cs.bytes());
}
Ok((data, opaque))
}
/// An empty buffer with room for `pixels × bytes_per_pixel` bytes, or an error (not an abort)
@@ -570,15 +605,21 @@ pub(crate) fn try_buffer(pixels: usize, bytes_per_pixel: usize) -> Result<Vec<u8
Ok(v)
}
/// Remove the trailing `alpha` bytes of every `color + alpha`-byte pixel, in place.
/// Remove the trailing `alpha` bytes of every `color + alpha`-byte pixel, in place, and give the
/// freed memory back.
fn drop_alpha(data: &mut Vec<u8>, color: usize, alpha: usize) {
compact_alpha(data, color, alpha);
data.shrink_to_fit();
}
/// [`drop_alpha`] keeping the allocation (a band about to be handed on).
fn compact_alpha(data: &mut Vec<u8>, color: usize, alpha: usize) {
let stride = color + alpha;
let n = data.len() / stride;
for i in 0..n {
data.copy_within(i * stride..i * stride + color, i * color);
}
data.truncate(n * color);
data.shrink_to_fit();
}
/// Whether every pixel of `s` over `r` is fully opaque (read from the tiles, no copy).
@@ -620,23 +661,47 @@ pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Resu
/// [`export_flat`] reporting a coarse step through `ctl` once the composite is rendered; the
/// encoder does the rest (#3389).
pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result<ExportResult, IoError> {
use photocraft_cms::{Builtin, Intent};
if let Some(r) = export_mode_specific(doc, format, opts)? {
return Ok(r);
}
if format == Format::Png && streams_png(doc) {
return export_png_streamed(doc, opts, ctl, 0);
}
let mut warnings = Vec::new();
let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk());
let lab_target = format.caps().layouts.iter().any(|layout| layout.is_lab());
let mut img = document_to_image_for(doc, &mut warnings, cmyk_target, lab_target)?;
ctl.check().map_err(|_| IoError::Cancelled)?;
ctl.progress(composite_share(format));
if opts.xmp == XmpEmbed::None {
// Export As's Metadata: None: the packet lists the text of every type layer and one id
// per placed document (#647).
img.meta.xmp = None;
// Free-form descriptions can contain the same sensitive text as an XMP packet.
img.meta.text.clear();
strip_xmp(&mut img.meta, opts);
let mut img = prepare_flat(img, format, &mut warnings)?;
if format == Format::Tga {
img = tga_image(doc, img, opts.tga_bits, &mut warnings)?;
}
if format == Format::Dds {
img = dds_image(doc, img, &mut warnings)?;
}
if format == Format::Tiff {
warnings.extend(crate::tiff_layers::stamp_flat_channels(doc, &mut img));
}
encode_image(&img, format, opts, warnings)
}
/// Export As's Metadata: None drops the XMP packet, which lists the text of every type layer and
/// one id per placed document (#647), and the free-form descriptions, which can hold the same
/// sensitive text.
fn strip_xmp(meta: &mut codecs::Metadata, opts: &ExportOptions) {
if opts.xmp == XmpEmbed::None {
meta.xmp = None;
meta.text.clear();
}
}
/// `img` made storable as `format`: matted over white without alpha support, CMYK converted for a
/// format without CMYK, colours converted to what an untagged file means. Pixel by pixel, so a
/// band of rows gets what the whole image would (and the same warnings).
fn prepare_flat(mut img: Image, format: Format, warnings: &mut Vec<String>) -> Result<Image, IoError> {
use photocraft_cms::{Builtin, Intent};
if img.layout().has_alpha() && !format.caps().alpha {
// Flattened over white, as saving a transparent document without transparency does.
img = matte_over_white(&img)?;
@@ -666,16 +731,120 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti
warnings.push(format!("colours converted to sGray; {format:?} can't embed the document's colour profile"));
}
}
if format == Format::Tga {
img = tga_image(doc, img, opts.tga_bits, &mut warnings)?;
Ok(img)
}
/// Decoded pixel bytes past which a PNG export is written a band of rows at a time
/// ([`export_png_streamed`]) instead of from one image buffer (about 90 MP of 8-bit RGB).
const STREAM_PNG_BYTES: u64 = 256 << 20;
/// Whether a PNG export of `doc` streams: its pixels would take more than [`STREAM_PNG_BYTES`].
fn streams_png(doc: &Document) -> bool {
let fmt = doc.pixel_format();
doc.size.area().saturating_mul((fmt.channels() * fmt.sample.bytes()) as u64) > STREAM_PNG_BYTES
}
/// A PNG export rendered, converted and encoded a band of rows at a time, so no full-size image
/// is held: the only way a multi-gigapixel print (a 117504 × 22464 px banner is 7.9 GB of 8-bit
/// RGB) can be written as PNG (#8456). The pixels are the ones [`export_flat`] writes for a
/// smaller document, with these differences: Interlaced is turned off (Adam7 needs the whole
/// image), and a composite whose bottom layer isn't an opaque pixel layer keeps its alpha even
/// when every pixel turns out opaque (that is only known once the last row is written).
///
/// `rows`: rows per band, 0 to size them to the document.
fn export_png_streamed(doc: &Document, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt, rows: i32) -> Result<ExportResult, IoError> {
// A wrong guess (a knockout or a blend mode the guess doesn't foresee) shows in a band and
// the export starts again with alpha.
let opaque = match single_layer(doc) {
Some(s) => opaque_surface(s, doc.bounds()),
None => opaque_bottom(doc),
};
// Progress never goes back: a second pass reports nothing new until it passes the first.
let mut progress = 0.0;
match stream_png(doc, opts, ctl, !opaque, rows, &mut progress)? {
Some(r) => Ok(r),
None => stream_png(doc, opts, ctl, true, rows, &mut progress)?.ok_or_else(|| IoError::Unsupported("PNG export lost its alpha channel".into())),
}
if format == Format::Dds {
img = dds_image(doc, img, &mut warnings)?;
}
/// Whether the bottom layer is a plain pixel layer opaque over the whole canvas (a Background
/// layer): the layers above it add coverage, so the composite is (most likely) opaque too.
fn opaque_bottom(doc: &Document) -> bool {
doc.layers.first().is_some_and(|l| plain_layer(l, doc.mode) && matches!(&l.content, LayerContent::Raster(s) if opaque_surface(s, doc.bounds())))
}
/// [`export_png_streamed`] with or without `alpha`, in bands of `rows` rows (0: sized to the
/// document). `None` when a translucent pixel shows up without `alpha`. `progress` is the most
/// reported so far, by this pass or an earlier one.
fn stream_png(
doc: &Document,
opts: &ExportOptions,
ctl: &photocraft_raster::Interrupt,
alpha: bool,
rows: i32,
progress: &mut f32,
) -> Result<Option<ExportResult>, IoError> {
let format = Format::Png;
let (w, h) = (doc.size.width, doc.size.height);
let mut warnings = Vec::new();
let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk());
let lab_target = format.caps().layouts.iter().any(|layout| layout.is_lab());
let src = FlatSource { rows, ..FlatSource::new(doc, &mut warnings, cmyk_target, lab_target)? };
let mut encode = opts.encode.clone();
let mut late = Vec::new();
if encode.png_interlaced {
encode.png_interlaced = false;
late.push(format!("written without interlacing: a {w} × {h} px PNG is written a band of rows at a time"));
}
if format == Format::Tiff {
warnings.extend(crate::tiff_layers::stamp_flat_channels(doc, &mut img));
// One blank row through the same steps as the bands gives the file's layout, profile and
// metadata (and their warnings) before anything is rendered.
let mut meta = src.meta();
strip_xmp(&mut meta, opts);
let row = try_buffer(w as usize, src.layout(alpha).channels() * src.sample.bytes()).map(|mut v| {
v.resize(v.capacity(), 0);
v
})?;
let first = prepare_flat(Image::from_raw(w, 1, src.layout(alpha), src.sample, row)?.with_icc(src.icc.clone()).with_meta(meta), format, &mut warnings)?;
let mut fidelity: Vec<String> = Vec::new();
let mut note = |img: &Image| -> Result<(), IoError> {
for f in codecs::fidelity_warnings_with(img, format, &encode) {
if f.is_fatal() {
return Err(IoError::Unsupported(f.to_string()));
}
let f = f.to_string();
if !fidelity.contains(&f) {
fidelity.push(f);
}
}
Ok(())
};
note(&first)?;
let mut bytes = Vec::new();
let mut png = codecs::PngRows::new(&mut bytes, &first, h, &encode)?;
let (mut done, mut translucent) = (0u64, false);
let streamed = src.bands(alpha, |rows, data, see_through| {
if see_through && !alpha {
translucent = true;
return Err(IoError::Cancelled);
}
ctl.check().map_err(|_| IoError::Cancelled)?;
let mut band_warnings = Vec::new();
let band = prepare_flat(Image::from_raw(w, rows, src.layout(alpha), src.sample, data)?.with_icc(src.icc.clone()), format, &mut band_warnings)?;
note(&band)?;
png.push(&band)?;
done += u64::from(rows);
*progress = progress.max(done as f32 / h as f32);
ctl.progress(*progress);
Ok(())
});
if translucent {
return Ok(None);
}
encode_image(&img, format, opts, warnings)
streamed?;
png.finish()?;
warnings.extend(fidelity);
warnings.extend(late);
Ok(Some(ExportResult { bytes, warnings }))
}
/// What Targa Options offers first, as Photoshop does: 32 bits per pixel when the document has an
@@ -1065,3 +1234,154 @@ mod cmyk_band_tests {
assert_eq!(first[3], 255);
}
}
#[cfg(test)]
mod streamed_png_tests {
use super::*;
use photocraft_raster::Interrupt;
fn layer(doc: &Document, fills: &[(Rect, &[f32])]) -> Layer {
let mut s = Surface::new(doc.pixel_format());
for (r, px) in fills {
s.fill_rect(*r, px);
}
Layer::new("Layer", LayerContent::Raster(s))
}
fn decoded(bytes: &[u8]) -> Image {
codecs::decode(bytes).unwrap()
}
/// The bytes ahead of the first IDAT: signature, header, profile and metadata chunks.
fn head(png: &[u8]) -> &[u8] {
&png[..png.windows(4).position(|w| w == b"IDAT").unwrap() - 4]
}
/// Streamed in bands of `rows`, `doc` gives the pixels, warnings and header chunks of the
/// whole-image export.
fn same_as_whole(doc: &Document, rows: i32, opts: &ExportOptions) -> ExportResult {
let whole = export_flat(doc, Format::Png, opts).unwrap();
let streamed = export_png_streamed(doc, opts, &Interrupt::NONE, rows).unwrap();
let (a, b) = (decoded(&whole.bytes), decoded(&streamed.bytes));
assert_eq!((b.dimensions(), b.layout(), b.sample_type()), (a.dimensions(), a.layout(), a.sample_type()));
assert!(a.data() == b.data(), "{}: pixels differ", doc.name);
assert_eq!(streamed.warnings, whole.warnings);
assert!(head(&streamed.bytes) == head(&whole.bytes), "{}: header chunks differ", doc.name);
streamed
}
#[test]
fn a_composite_over_an_opaque_bottom_layer_streams_as_rgb() {
let mut doc = Document::new("composite", Size::new(300, 700), ColorMode::Rgb, SampleType::U8);
doc.resolution_dpi = 300.0;
doc.metadata.xmp = Some("<x:xmpmeta xmlns:x='adobe:ns:meta/'/>".into());
doc.metadata.text = vec![("Title".into(), "Tent".into())];
let bottom = layer(&doc, &[(doc.bounds(), &[0.9, 0.5, 0.1, 1.0]), (Rect::new(0, 300, 300, 420), &[0.1, 0.2, 0.8, 1.0])]);
let mut top = layer(&doc, &[(Rect::new(40, 100, 260, 650), &[0.2, 0.7, 0.3, 0.5])]);
top.opacity = 0.7;
top.blend = BlendMode::Multiply;
doc.layers = vec![bottom, top];
assert!(opaque_bottom(&doc));
for xmp in [XmpEmbed::All, XmpEmbed::None] {
let r = same_as_whole(&doc, 256, &ExportOptions { xmp, ..Default::default() });
assert_eq!(decoded(&r.bytes).layout(), ChannelLayout::Rgb);
}
}
#[test]
fn single_layers_stream_in_their_own_model_and_depth() {
// Transparent RGBA, opaque CMYK (written as sRGB through its profile), 16-bit gray and
// float RGB out of range (written with 16 bits, clipped, with the warning).
let mut rgba = Document::new("rgba", Size::new(130, 333), ColorMode::Rgb, SampleType::U8);
rgba.layers = vec![layer(&rgba, &[(Rect::new(10, 20, 120, 300), &[0.3, 0.6, 0.9, 0.4])])];
let mut cmyk = Document::new("cmyk", Size::new(97, 211), ColorMode::Cmyk, SampleType::U8);
cmyk.icc_profile = Some(photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes());
cmyk.layers = vec![layer(&cmyk, &[(cmyk.bounds(), &[0.3, 0.6, 0.1, 0.2, 1.0]), (Rect::new(0, 50, 97, 99), &[0.0, 0.9, 0.9, 0.0, 1.0])])];
let mut gray = Document::new("gray", Size::new(64, 190), ColorMode::Grayscale, SampleType::U16);
gray.layers = vec![layer(&gray, &[(gray.bounds(), &[0.25, 1.0]), (Rect::new(0, 0, 64, 77), &[0.6123, 1.0])])];
let mut hdr = Document::new("hdr", Size::new(50, 120), ColorMode::Rgb, SampleType::F32);
hdr.layers = vec![layer(&hdr, &[(hdr.bounds(), &[1.5, 0.25, -0.1, 1.0])])];
for doc in [&rgba, &cmyk, &gray, &hdr] {
assert!(single_layer(doc).is_some(), "{}", doc.name);
same_as_whole(doc, 37, &ExportOptions::default());
}
let r = same_as_whole(&hdr, 37, &ExportOptions::default());
assert!(r.warnings.iter().any(|w| w.contains("clip")), "{:?}", r.warnings);
}
/// Without an opaque bottom layer the composite keeps its alpha, even when every pixel is
/// opaque (the whole-image export finds that out and drops it).
#[test]
fn a_composite_without_an_opaque_bottom_layer_keeps_its_alpha() {
let mut doc = Document::new("masked", Size::new(80, 300), ColorMode::Rgb, SampleType::U8);
let mut bottom = layer(&doc, &[(doc.bounds(), &[0.2, 0.4, 0.6, 1.0])]);
bottom.mask = Some(photocraft_doc::LayerMask {
surface: Surface::with_default(PixelFormat::GRAY8, &[1.0]),
enabled: true,
linked: true,
density: 1.0,
feather: 0.0,
});
doc.layers = vec![bottom, layer(&doc, &[(Rect::new(0, 0, 40, 40), &[1.0, 0.0, 0.0, 1.0])])];
assert!(!opaque_bottom(&doc));
let whole = decoded(&export_flat(&doc, Format::Png, &ExportOptions::default()).unwrap().bytes);
let streamed = decoded(&export_png_streamed(&doc, &ExportOptions::default(), &Interrupt::NONE, 256).unwrap().bytes);
assert_eq!((whole.layout(), streamed.layout()), (ChannelLayout::Rgb, ChannelLayout::Rgba));
assert!(whole.convert(ChannelLayout::Rgba, CSample::U8).data() == streamed.data());
}
/// A knockout through an opaque bottom layer makes the guess wrong: the export starts again
/// with alpha and keeps the hole, and its progress never goes back.
#[test]
fn a_wrong_opacity_guess_starts_again_with_alpha() {
// The hole is in the fourth band: three are written before the guess turns out wrong.
let mut doc = Document::new("knockout", Size::new(64, 1000), ColorMode::Rgb, SampleType::U8);
let bottom = layer(&doc, &[(doc.bounds(), &[0.2, 0.4, 0.6, 1.0])]);
let mut hole = layer(&doc, &[(Rect::new(8, 800, 56, 960), &[1.0, 1.0, 1.0, 1.0])]);
hole.fill_opacity = 0.0;
hole.advanced.knockout = photocraft_doc::Knockout::Deep;
doc.layers = vec![bottom, hole];
assert!(opaque_bottom(&doc));
assert!(stream_png(&doc, &ExportOptions::default(), &Interrupt::NONE, false, 256, &mut 0.0).unwrap().is_none());
let r = same_as_whole(&doc, 256, &ExportOptions::default());
assert_eq!(decoded(&r.bytes).layout(), ChannelLayout::Rgba);
let seen = std::sync::Mutex::new(Vec::new());
let report = |f: f32| seen.lock().unwrap().push(f);
let never = || false;
export_png_streamed(&doc, &ExportOptions::default(), &Interrupt::new(&never, &report), 256).unwrap();
let seen = seen.into_inner().unwrap();
assert!(seen.windows(2).all(|w| w[0] <= w[1]), "progress went back: {seen:?}");
assert_eq!(seen.last(), Some(&1.0));
}
#[test]
fn interlacing_is_turned_off_with_a_warning() {
let mut doc = Document::new("interlaced", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
doc.layers = vec![layer(&doc, &[(doc.bounds(), &[0.5, 0.5, 0.5, 1.0])])];
let mut opts = ExportOptions::default();
opts.encode.png_interlaced = true;
let r = export_png_streamed(&doc, &opts, &Interrupt::NONE, 7).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("without interlacing")), "{:?}", r.warnings);
let whole = decoded(&export_flat(&doc, Format::Png, &opts).unwrap().bytes);
assert!(decoded(&r.bytes).data() == whole.data());
}
#[test]
fn a_cancelled_stream_stops_with_cancelled() {
let mut doc = Document::new("cancel", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
doc.layers = vec![layer(&doc, &[(doc.bounds(), &[0.5, 0.5, 0.5, 1.0])])];
let yes = || true;
let r = export_png_streamed(&doc, &ExportOptions::default(), &Interrupt::cancel_only(&yes), 7);
assert!(matches!(r, Err(IoError::Cancelled)), "{:?}", r.map(|_| ()));
}
#[test]
fn only_large_documents_stream() {
let doc = |w, h, depth| Document::new("size", Size::new(w, h), ColorMode::Rgb, depth);
assert!(!streams_png(&doc(6000, 4000, SampleType::U8)));
assert!(streams_png(&doc(10_000, 8000, SampleType::U8)));
assert!(streams_png(&doc(8000, 6000, SampleType::U16)));
assert!(streams_png(&doc(117_504, 22_464, SampleType::U8)));
}
}
+29
View File
@@ -110,6 +110,12 @@ pub fn to_psd(p: &Pattern) -> PsdPattern {
}
}
/// A warning for each of `patterns` too large to paint: such a pattern fills with transparency,
/// so the file says so on opening.
pub fn unpaintable_warnings(patterns: &[Pattern]) -> Vec<String> {
patterns.iter().filter_map(|p| Some(format!("pattern \"{}\" would paint nothing: {}", p.display_name(), p.paint_problem()?))).collect()
}
/// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). All
/// blocks share one memory budget, so repeating a block can't multiply it.
pub fn from_global_blocks(doc: &Document) -> Vec<Pattern> {
@@ -278,3 +284,26 @@ mod tests {
assert_eq!(export_global_blocks(&back), back.metadata.psd_global_blocks);
}
}
#[cfg(test)]
mod unpaintable_tests {
use super::*;
use photocraft_doc::PixelFormat;
use photocraft_raster::Surface;
#[test]
fn a_pattern_too_large_to_paint_is_named_on_opening() {
let sparse = |name: &str, format: PixelFormat, side: u32| Pattern {
id: name.into(),
name: name.into(),
width: side,
height: side,
surface: Surface::new(format),
};
let patterns =
[sparse("Small", PixelFormat::RGBA8, 64), sparse("Huge float", PixelFormat::RGBA32F, 5000), sparse("Largest 8-bit", PixelFormat::RGBA8, 8000)];
let warnings = unpaintable_warnings(&patterns);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("Huge float") && warnings[0].contains("would paint nothing") && warnings[0].contains("32 bits"), "{warnings:?}");
}
}
+1
View File
@@ -1122,6 +1122,7 @@ fn read_initial_metadata(file: &PsdFile, global: &[PsdGlobalBlock], doc: &mut Do
));
}
doc.patterns = crate::pattern_map::from_global_blocks(doc);
warnings.extend(crate::pattern_map::unpaintable_warnings(&doc.patterns));
doc.notes = crate::annotations_map::notes_from_blocks(doc);
if crate::annotations_map::anno_unreadable(doc) {
warnings.push("the notes (Anno block) could not be read; they are kept unchanged on PSD save unless a note is added".into());
+13
View File
@@ -154,6 +154,19 @@ fn halftone_pattern_unknown_nested_class_stays_verbatim() {
}
}
/// Keeps a Gallery stack opaque when its own descriptor holds a setting beside the effect list,
/// which a save would otherwise drop.
#[test]
fn halftone_pattern_unknown_outer_setting_stays_verbatim() {
let valid = native_effect("ScrD", 2, 5);
let gallery = Descriptor::new(GALLERY_CLASS).with("GEfs", Value::List(vec![Value::Descriptor(valid)])).with("futureSetting", Value::Integer(7));
let item = native_item(gallery);
let parsed = Descriptor::from_bytes(&item.to_bytes()).unwrap();
let filter = filter_from_item(&parsed);
assert_eq!(filter.command, UNSUPPORTED_FILTER);
assert_eq!(item_for_filter(&filter).unwrap().to_bytes(), item.to_bytes());
}
/// Rejects unsupported export values instead of silently changing their meaning.
#[test]
fn halftone_pattern_invalid_export_settings_are_reported() {
+5 -1
View File
@@ -710,7 +710,11 @@ fn gallery_params(item: &Descriptor, f: &Descriptor) -> Option<J> {
let mut params = Map::new();
if g.key == "halftonePattern" {
// Do not turn an unfamiliar shape or malformed setting into the engine's defaults:
// keeping the whole item opaque preserves it through a subsequent save.
// keeping the whole item opaque preserves it through a subsequent save. That covers
// the stack's own descriptor too: a key beside `GEfs` would not be written back.
if f.get("GEfs").is_some() && !(f.class_id.is(GALLERY_CLASS) && only_keys(f, &["GEfs"])) {
return None;
}
if !e.class_id.is(GALLERY_CLASS)
|| !only_keys(e, &["GEfk", "HlSz", "Cntr", "ScrT", "GELv"])
|| !matches!(e.get("GEfk"), Some(Value::Enumerated { type_id, .. }) if type_id.is("GEft"))
+16 -3
View File
@@ -199,14 +199,22 @@ type Reopened = (String, Option<i32>, Vec<String>);
/// `TextIndex`, bottom first), with a type layer "Hi" made the way the Type tool makes one
/// (`build_tysh`, no index of its own) inserted at `at`, saved and reopened: each layer's
/// index and font features (but `calt`, on by default and written on as Photoshop does), and
/// "Hi"'s style run in the saved `Txt2`.
/// "Hi"'s style run in the saved `Txt2`. The kept layers are styled as their objects are (font
/// and size too), so their objects are saved unchanged, which this checks.
fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (Vec<Reopened>, E) {
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
for (text, index) in kept {
let mut t = layer(text, &[(text.len(), Kerning::Metrics, 0.0)]);
// `photoshop_txt2` writes MyriadPro-Regular at 12 pt (the document is at 72 dpi).
t.runs[0].style.size_pt = 12.0;
t.runs[0].style.postscript_name = Some("MyriadPro-Regular".into());
t.sync_summary();
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None);
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, *index).unwrap()));
let raw = photocraft_text::psd::set_text_index(&photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None), *index).unwrap();
// As opening the file does: a kept layer's style holds what its `Txt2` object says (#8415:
// a layer whose style disagrees with its object is saved as a style edit).
photocraft_text::psd::apply_txt2(&mut t, &raw, &photocraft_text::psd::parse_txt2(&photoshop_txt2(objects)).unwrap());
t.psd_raw = Some(std::sync::Arc::new(raw));
doc.layers.push(Layer::new(*text, LayerContent::Text(t)));
}
doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(objects)))];
@@ -228,6 +236,11 @@ fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) ->
(l.name.clone(), index, t.char_runs().iter().flat_map(|r| r.style.features.iter().filter(|f| f.tag != "calt").map(|f| f.tag.clone())).collect())
})
.collect();
let source = photocraft_text::psd::parse_txt2(&photoshop_txt2(objects)).unwrap();
let object = |txt2: &E, i: i32| txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(usize::try_from(i).ok()?)).cloned();
for (text, index) in kept {
assert_eq!(object(&txt2, *index), object(&source, *index), "{text}: the kept layer's object is saved unchanged");
}
let hi = usize::try_from(layers.iter().find(|l| l.0 == "Hi").and_then(|l| l.1).unwrap()).unwrap();
let run = txt2.path(&["1", "1"]).and_then(E::as_array).unwrap()[hi].path(&["0", "6", "0"]).and_then(E::as_array).unwrap()[0].clone();
(layers, run)
+62
View File
@@ -315,3 +315,65 @@ fn created_text_layer_roundtrips_through_psd() {
assert_eq!(bt.transform, t.transform);
assert!(bt.cache.as_ref().is_some_and(|c| c.content_bounds().width() > 10));
}
/// Review of #8888: the older Photoshop's text objects (`/DocumentObjects /TextObjects`) in the
/// real psd-tools `text.psd`. Saving the type layer unedited leaves them (and the font list they
/// index) exactly as the file has them; editing only the size changes the size of the legacy style
/// run and nothing else in it, and the object's text, paragraph runs and view are untouched.
#[cfg(feature = "corpus")]
#[test]
fn corpus_text_psd_legacy_objects_are_edited_in_place() {
use photocraft_text::engine_data::Value as E;
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/text.psd");
assert!(path.is_file(), "{} is missing: run `cargo xtask corpus --all`", path.display());
let bytes = std::fs::read(&path).unwrap();
let doc = photocraft_io::import("text.psd", &bytes).unwrap().document;
let txt2 = doc.metadata.psd_global_blocks.iter().find(|b| &b.1 == b"Txt2").map(|b| b.2.clone()).expect("text.psd has a Txt2 block");
let mut all = Vec::new();
walk(&doc.layers, &mut all);
let layer = all.iter().find_map(|l| if let LayerContent::Text(t) = &l.content { Some(t.clone()) } else { None }).expect("a type layer");
let before = photocraft_text::psd::parse_txt2(&txt2).expect("the file's Txt2 parses");
let legacy = |block: &E| block.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(<[E]>::first).cloned().expect("the legacy object");
let features = |entry: &E| {
entry
.path(&["Model", "StyleRun", "RunArray"])
.and_then(E::as_array)
.and_then(<[E]>::first)
.and_then(|r| r.path(&["RunData", "StyleSheet", "Features"]))
.cloned()
};
let items = |value: &E| if let E::Dict(items) = value { items.clone() } else { Vec::new() };
// Unedited: the legacy object and the font list are as the file has them.
let unedited = photocraft_text::psd::parse_txt2(&photocraft_text::psd::build_txt2_at(&[(0, &layer)], Some(&txt2), doc.resolution_dpi)).unwrap();
assert!(legacy(&unedited) == legacy(&before), "an unedited layer leaves the legacy text object alone");
assert!(unedited.path(&["DocumentResources", "FontSet"]) == before.path(&["DocumentResources", "FontSet"]), "and the font list it indexes");
// Edited: only the size.
let mut bigger = layer.clone();
for run in &mut bigger.runs {
run.style.size_pt += 7.0;
}
let edited = photocraft_text::psd::parse_txt2(&photocraft_text::psd::build_txt2_at(&[(0, &bigger)], Some(&txt2), doc.resolution_dpi)).unwrap();
let (was, now) = (features(&legacy(&before)).expect("features"), features(&legacy(&edited)).expect("features"));
let size = |f: &E| f.get("FontSize").and_then(E::as_f64).expect("FontSize");
assert!((size(&now) - size(&was) - 7.0).abs() < 1e-6, "the legacy size follows the edit: {} -> {}", size(&was), size(&now));
let rest = |f: &E| items(f).into_iter().filter(|(k, _)| k != "FontSize").collect::<Vec<_>>();
let changed: Vec<String> =
rest(&was).iter().zip(rest(&now).iter()).filter(|(a, b)| a != b).map(|(a, b)| format!("{}: {:?} -> {:?}", a.0, a.1, b.1)).collect();
assert!(rest(&was) == rest(&now), "every other style key of the run is as the file has it; changed: {changed:?}");
let others = |entry: &E, key: &str| entry.path(&["Model", key]).cloned();
for key in ["Text", "ParagraphRun", "KernRun"] {
assert!(others(&legacy(&edited), key) == others(&legacy(&before), key), "{key} is untouched");
}
assert!(legacy(&edited).get("View") == legacy(&before).get("View"), "the view is untouched");
let lengths = |entry: &E| {
entry
.path(&["Model", "StyleRun", "RunArray"])
.and_then(E::as_array)
.map(|rs| rs.iter().map(|r| r.get("Length").and_then(E::as_i64)).collect::<Vec<_>>())
};
assert!(lengths(&legacy(&edited)) == lengths(&legacy(&before)), "the runs still cover the text");
}
+68
View File
@@ -0,0 +1,68 @@
//! PNG export of a multi-gigapixel print (#8456): a 136 ft × 26 ft banner at 72 ppi is
//! 117504 × 22464 px (2.64 GP, 7.9 GB of 8-bit RGB), past the 2^30 px a whole-image export could
//! hold. It is written a band of rows at a time, its zlib stream split over IDAT chunks.
use std::io::Read;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, LayerContent};
use photocraft_geom::{Rect, Size};
use photocraft_io::{ExportOptions, export};
use photocraft_raster::Surface;
/// The IHDR fields and the IDAT payloads of `png`.
fn chunks(png: &[u8]) -> ((u32, u32, u8, u8), Vec<&[u8]>) {
let (mut at, mut ihdr, mut idat) = (8, None, Vec::new());
while at + 12 <= png.len() {
let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize;
let data = &png[at + 8..at + 8 + len];
match &png[at + 4..at + 8] {
b"IHDR" => ihdr = Some((u32::from_be_bytes(data[0..4].try_into().unwrap()), u32::from_be_bytes(data[4..8].try_into().unwrap()), data[8], data[9])),
b"IDAT" => idat.push(data),
_ => {}
}
at += 12 + len;
}
(ihdr.unwrap(), idat)
}
/// Run with `cargo test --release -p photocraft-io --test large_png_export -- --ignored`; with
/// `COMPOSITE=1` a translucent layer on top makes the export composite the banner.
/// Measured 2026-10-22 (20 threads): 12.3 s and 0.49 GB peak RSS, composited 29 s and 1.2 GB; the
/// whole-image export took 12.9 s and 8.0 GB, composited 43 s and 10.3 GB.
#[test]
#[ignore = "encodes 7.9 GB of pixels; run in release"]
fn a_banner_over_two_gigapixels_exports_as_png() {
const W: u32 = 117_504;
const H: u32 = 22_464;
let mut doc = Document::new("Tent banner", Size::new(W, H), ColorMode::Rgb, SampleType::U8);
let mut s = Surface::new(doc.pixel_format());
s.fill_rect(doc.bounds(), &[1.0, 1.0, 1.0, 1.0]);
s.fill_rect(Rect::new(0, 15_000, W as i32, H as i32), &[0.1, 0.45, 0.8, 1.0]);
s.fill_rect(Rect::new(40_000, 2_000, 77_504, 14_000), &[0.55, 0.05, 0.08, 1.0]);
doc.layers.push(Layer::new("Background", LayerContent::Raster(s)));
if std::env::var_os("COMPOSITE").is_some() {
let mut t = Surface::new(doc.pixel_format());
t.fill_rect(Rect::new(10_000, 5_000, 100_000, 20_000), &[0.9, 0.7, 0.1, 0.6]);
doc.layers.push(Layer::new("Text", LayerContent::Raster(t)));
}
let started = std::time::Instant::now();
let r = export(&doc, "banner.png", &ExportOptions::default()).unwrap();
eprintln!("exported {} MB in {:.1} s; warnings {:?}", r.bytes.len() >> 20, started.elapsed().as_secs_f64(), r.warnings);
let ((w, h, depth, color), idat) = chunks(&r.bytes);
assert_eq!((w, h, depth, color), (W, H, 8, 2), "8-bit RGB");
// The zlib stream inflates (Adler-32 checked) to a filter byte and W RGB pixels per row.
let joined = idat.iter().flat_map(|c| c.iter().copied()).collect::<Vec<u8>>();
let mut z = flate2::read::ZlibDecoder::new(joined.as_slice());
let (mut buf, mut total) = (vec![0u8; 1 << 20], 0u64);
loop {
let n = z.read(&mut buf).unwrap();
if n == 0 {
break;
}
total += n as u64;
}
assert_eq!(total, u64::from(H) * (1 + u64::from(W) * 3));
}
+26 -25
View File
@@ -6,7 +6,8 @@
//! same approach Photoshop's History panel exposes to users (one state per step).
//!
//! Memory is bounded by `max_states` plus an approximate byte budget. The current document's
//! tiles and tiles only history holds count against it, including selection masks.
//! tiles and tiles only history holds count against it, including selection masks, and so do the
//! embedded contents of smart objects (shared `Arc` bytes, counted once however many states hold them).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -48,8 +49,8 @@ pub struct History {
/// Lazily initialized when a byte budget is used; unlimited histories pay no counting cost.
tiles: Option<tiles::Tiles>,
pub max_states: usize,
/// Pixel memory budget in bytes for the current document plus the tiles only history holds
/// (0 = unlimited). [`History::trim`] drops the oldest states beyond it.
/// Pixel memory budget in bytes for the current document plus the tiles (and embedded smart
/// object contents) only history holds (0 = unlimited). [`History::trim`] drops the oldest states beyond it.
pub max_bytes: usize,
/// Label of the step that produced the current document.
current_label: String,
@@ -534,8 +535,8 @@ impl History {
self.held_states().map(|s| tile_bytes(&s.doc, &mut seen)).sum()
}
/// Pixel bytes of `current` plus the tiles only history holds (what [`History::trim`]
/// bounds by [`History::max_bytes`]).
/// Pixel bytes of `current` plus the tiles only history holds, embedded smart object contents
/// included (what [`History::trim`] bounds by [`History::max_bytes`]).
pub fn pixel_bytes(&self, current: &Document) -> usize {
if let Some(tiles) = &self.tiles {
return usize::try_from(tiles.bytes_with(current)).unwrap_or(usize::MAX);
@@ -571,24 +572,25 @@ impl History {
}
/// Keep pixel memory within [`History::max_bytes`]: the current document's tiles plus the
/// tiles only history holds (newest states first). The oldest undo states that don't fit are
/// dropped; the most recent one is always kept so the last step can be undone. The History
/// Brush's source state ([`History::set_brush_source`]) is never dropped: its unique tiles are
/// counted first, and the newest states fill what remains. Returns how many states were
/// dropped.
/// tiles only history holds (newest states first), both with embedded smart object contents.
/// The oldest undo states that don't fit are dropped; the most recent one is always kept so
/// the last step can be undone. The History Brush's source state ([`History::set_brush_source`])
/// is never dropped: its unique tiles are counted first, and the newest states fill what
/// remains. Returns how many states were dropped.
pub fn trim(&mut self, current: &Document) -> usize {
self.trim_with(current, &[])
}
/// [`History::trim`] counting the tiles only `pinned` documents hold (History panel
/// snapshots, which outlive the states) against the budget too, so they can't take memory
/// beyond it unseen (#4530). The most recent state is still always kept.
/// [`History::trim`] counting the tiles (and embedded smart object contents) only `pinned`
/// documents hold (History panel snapshots, which outlive the states) against the budget too,
/// so they can't take memory beyond it unseen (#4530). The most recent state is still always
/// kept.
pub fn trim_with(&mut self, current: &Document, pinned: &[&Document]) -> usize {
// While Clear History can be undone, the cleared states are what history holds: the
// oldest of them go first, as the list's would.
let (undo, redo) = match &mut self.cleared {
Some(c) => (&mut c.undo, &c.redo),
None => (&mut self.undo, &self.redo),
// oldest of them go first, as the list's would. The source set before it stays pinned.
let (undo, redo, brush_source) = match &mut self.cleared {
Some(c) => (&mut c.undo, &c.redo, c.brush_source),
None => (&mut self.undo, &self.redo, self.brush_source),
};
if self.max_bytes == 0 || undo.len() <= 1 {
return 0;
@@ -622,7 +624,7 @@ impl History {
}
// The History Brush's source is kept whatever its age, so its own tiles come first; the
// newest states then fill what remains of the budget (the most recent one always).
let source = self.brush_source.and_then(|id| undo.iter().position(|s| s.id == id));
let source = brush_source.and_then(|id| undo.iter().position(|s| s.id == id));
if let Some(state) = source.and_then(|i| undo.get(i)) {
total = total.saturating_add(tile_bytes(&state.doc, &mut seen));
}
@@ -632,7 +634,7 @@ impl History {
continue;
}
total = total.saturating_add(tile_bytes(&s.doc, &mut seen));
if total > self.max_bytes && keep >= 1 {
if total > self.max_bytes && (keep >= 1 || source == Some(undo.len() - 1)) {
break;
}
keep += 1;
@@ -666,14 +668,13 @@ fn merge_ids(into: &mut Vec<LayerId>, from: &[LayerId]) {
}
}
/// Bytes of the pixel tiles of `doc` (layers, masks, fill caches, smart filter masks, alpha channels, selection) not already in `seen`.
/// Bytes of the pixel tiles of `doc` (layers, masks, fill caches, smart filter masks, alpha channels, selection) and the
/// embedded contents of its smart objects not already in `seen`.
fn tile_bytes(doc: &Document, seen: &mut HashSet<usize>) -> usize {
let mut n = 0usize;
tiles::surfaces(doc, |surface| {
for (_, tile) in surface.tiles() {
if seen.insert(Arc::as_ptr(tile) as usize) {
n = n.saturating_add(tile.bytes().len());
}
tiles::blocks(doc, |ptr, len| {
if seen.insert(ptr) {
n = n.saturating_add(len);
}
});
n
+151
View File
@@ -197,6 +197,53 @@ fn clear_states_resets_the_brush_source_until_undo() {
assert_eq!((h.brush_source(), h.row_doc(1, &cur).unwrap().name.as_str()), (Some(1), "a"));
}
/// The byte budget keeps the source set before Clear History among the cleared states, so
/// Undo Clear History brings it back as the History Brush's source.
#[test]
fn byte_budget_keeps_the_brush_source_of_the_cleared_states() {
let mut h = History::new(50);
let mut cur = Arc::new(base());
for i in 0..5 {
edit(&mut h, &mut cur, &format!("paint {i}"), |d| {
let id = d.layers[0].id;
d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
});
}
assert!(h.set_brush_source(1));
let source = h.state(1).unwrap();
assert!(h.clear_states());
h.max_bytes = 1;
assert_eq!(h.trim(&cur), 3);
assert_counter(&h, &cur);
assert!(h.is_cleared() && h.past_len() == 0, "the list stays cleared");
cur = h.undo(cur).unwrap().0;
assert_eq!(h.entries(), ["paint 0", "paint 3", "paint 4"], "the source is kept with the last step");
assert_eq!(h.brush_source(), Some(0));
assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source), "the History Brush paints from the same state");
}
/// When the History Brush's source is the newest past state, it is the state the byte budget
/// always keeps, and no older state is kept beside it.
#[test]
fn byte_budget_counts_a_newest_brush_source_as_the_kept_state() {
let mut h = History::new(50);
let mut cur = Arc::new(base());
for i in 0..5 {
edit(&mut h, &mut cur, &format!("paint {i}"), |d| {
let id = d.layers[0].id;
d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
});
}
assert!(h.set_brush_source(4));
let source = h.state(4).unwrap();
h.max_bytes = 1;
assert_eq!(h.trim(&cur), 4);
assert_counter(&h, &cur);
assert_eq!(h.past_len(), 1);
assert_eq!(h.brush_source(), Some(0));
assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source));
}
#[test]
fn new_edit_clears_redo() {
let mut h = History::default();
@@ -655,6 +702,110 @@ fn fill_caches_and_smart_filter_masks_count_toward_history_bytes() {
assert_counter(&h, &cur);
}
const TILE: usize = 256 * 256 * 4;
const SOURCE: usize = 1_000_000;
fn embedded(len: usize) -> photocraft_doc::SmartSource {
photocraft_doc::SmartSource::Embedded { file_name: "source.pcraft".into(), bytes: Arc::new(vec![7; len]) }
}
/// `base()` with one smart object holding `source`; returns the document and the layer's id.
fn smart_document(source: photocraft_doc::SmartSource) -> (Document, photocraft_doc::LayerId) {
use photocraft_doc::{Affine, LayerContent, SmartObject};
let mut doc = base();
let layer = Layer::new("Smart", LayerContent::Smart(SmartObject::new(source, Affine::IDENTITY, None)));
let id = layer.id;
doc.insert_above(None, layer);
(doc, id)
}
fn replace_source(doc: &mut Document, id: photocraft_doc::LayerId, source: photocraft_doc::SmartSource) {
if let Some(photocraft_doc::LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) {
sm.source = source;
}
}
#[test]
fn embedded_smart_object_contents_count_toward_history_bytes() {
let (with, _) = smart_document(embedded(SOURCE));
let without = base();
let mut h = History::default();
assert_eq!(h.pixel_bytes(&with), h.pixel_bytes(&without) + SOURCE, "uncounted history walks the document");
assert_eq!(h.pixel_bytes(&without), TILE);
// With a budget the counter keeps the figure incrementally.
let mut cur = Arc::new(with.clone());
h.max_bytes = usize::MAX;
edit(&mut h, &mut cur, "Rename", |d| d.name = "renamed".into());
edit(&mut h, &mut cur, "Rename again", |d| d.name = "renamed again".into());
h.trim(&cur);
assert_eq!(h.pixel_bytes(&cur), TILE + SOURCE);
assert_counter(&h, &cur);
// A linked source holds no bytes of its own.
let (linked, _) = smart_document(photocraft_doc::SmartSource::Linked { path: "a.png".into() });
assert_eq!(History::default().pixel_bytes(&linked), TILE);
}
#[test]
fn states_sharing_one_embedded_source_count_it_once() {
let (doc, _) = smart_document(embedded(SOURCE));
let mut cur = Arc::new(doc);
let mut h = History { max_bytes: usize::MAX, ..History::default() };
for i in 0..10 {
edit(&mut h, &mut cur, &format!("Rename {i}"), |d| d.name = format!("renamed {i}"));
}
h.trim(&cur);
assert_eq!(h.past_len(), 10);
assert_eq!(h.pixel_bytes(&cur), TILE + SOURCE);
assert_eq!(h.unique_bytes(&cur), 0);
assert_counter(&h, &cur);
// A duplicated layer shares the same bytes too.
edit(&mut h, &mut cur, "Duplicate", |d| {
if let Some(copy) = d.layers.last().cloned() {
d.insert_above(None, copy);
}
});
h.trim(&cur);
assert_eq!(h.pixel_bytes(&cur), TILE + SOURCE);
assert_counter(&h, &cur);
}
#[test]
fn a_replaced_embedded_source_counts_until_the_state_holding_it_is_trimmed() {
const REPLACED: usize = 400_000;
let (doc, id) = smart_document(embedded(SOURCE));
let mut cur = Arc::new(doc);
let mut h = History { max_bytes: usize::MAX, ..History::default() };
edit(&mut h, &mut cur, "Replace Contents", |d| replace_source(d, id, embedded(REPLACED)));
assert_eq!(h.pixel_bytes(&cur), TILE + SOURCE + REPLACED, "the state before still holds the old contents");
assert_eq!(h.unique_bytes(&cur), SOURCE);
edit(&mut h, &mut cur, "Rename", |d| d.name = "renamed".into());
h.trim(&cur);
assert_counter(&h, &cur);
h.max_bytes = TILE + REPLACED;
assert_eq!(h.trim(&cur), 1, "dropping the oldest state releases the old contents");
assert_eq!(h.pixel_bytes(&cur), TILE + REPLACED);
assert_counter(&h, &cur);
}
#[test]
fn embedded_contents_push_old_states_out_of_the_budget() {
let (doc, id) = smart_document(embedded(SOURCE));
let mut cur = Arc::new(doc);
let mut h = History::new(50);
for i in 0..5 {
// Each step swaps in contents of its own; only the tile is shared between states.
edit(&mut h, &mut cur, &format!("Replace {i}"), |d| replace_source(d, id, embedded(SOURCE)));
}
assert_eq!(h.trim(&cur), 0, "unlimited by default");
assert_eq!(h.pixel_bytes(&cur), TILE + 6 * SOURCE);
// The current contents and the two newest states fit.
h.max_bytes = TILE + 3 * SOURCE;
assert_eq!(h.trim(&cur), 3);
assert_eq!(h.past_len(), 2);
assert_eq!(h.pixel_bytes(&cur), TILE + 3 * SOURCE);
assert_counter(&h, &cur);
}
#[test]
fn selection_tiles_count_current_and_history_without_double_counting() {
use photocraft_doc::{PixelFormat, Surface};
+34 -24
View File
@@ -1,7 +1,7 @@
//! Tile references owned by history snapshots. The snapshots keep these addresses alive;
//! COW edits cannot change a retained tile in place. Count occurrences (including aliases
//! within one document), so removing a state releases a tile only after its last reference.
use photocraft_doc::{Document, LayerContent, Surface};
use photocraft_doc::{Document, LayerContent, SmartSource, Surface};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -47,29 +47,42 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) {
}
}
/// Every shared allocation `doc` holds that history can retain, as `visit(address, length)`:
/// the tiles of [`surfaces`] and the embedded contents of smart objects (an `Arc` that undo
/// states and duplicated layers share, replaced by Replace Contents).
pub(crate) fn blocks(doc: &Document, mut visit: impl FnMut(usize, usize)) {
surfaces(doc, |surface| {
for (_, tile) in surface.tiles() {
visit(Arc::as_ptr(tile) as usize, tile.bytes().len());
}
});
for (_, _, layer) in doc.walk() {
if let LayerContent::Smart(smart) = &layer.content
&& let SmartSource::Embedded { bytes, .. } = &smart.source
{
visit(Arc::as_ptr(bytes) as usize, bytes.len());
}
}
}
impl Tiles {
pub(crate) fn add(&mut self, doc: &Document) {
surfaces(doc, |surface| {
for (_, tile) in surface.tiles() {
let count = self.refs.entry(Arc::as_ptr(tile) as usize).or_insert_with(|| {
self.bytes += tile.bytes().len() as u128;
(0, tile.bytes().len())
});
count.0 += 1;
}
blocks(doc, |ptr, len| {
let count = self.refs.entry(ptr).or_insert_with(|| {
self.bytes += len as u128;
(0, len)
});
count.0 += 1;
});
}
pub(crate) fn remove(&mut self, doc: &Document) {
surfaces(doc, |surface| {
for (_, tile) in surface.tiles() {
let key = Arc::as_ptr(tile) as usize;
if let Some(count) = self.refs.get_mut(&key) {
count.0 -= 1;
if count.0 == 0 {
self.bytes -= count.1 as u128;
self.refs.remove(&key);
}
blocks(doc, |ptr, _| {
if let Some(count) = self.refs.get_mut(&ptr) {
count.0 -= 1;
if count.0 == 0 {
self.bytes -= count.1 as u128;
self.refs.remove(&ptr);
}
}
});
@@ -85,12 +98,9 @@ impl Tiles {
pub(crate) fn bytes_with(&self, current: &Document) -> u128 {
let mut total = self.bytes;
let mut seen = HashSet::new();
surfaces(current, |surface| {
for (_, tile) in surface.tiles() {
let key = Arc::as_ptr(tile) as usize;
if !self.refs.contains_key(&key) && seen.insert(key) {
total += tile.bytes().len() as u128;
}
blocks(current, |ptr, len| {
if !self.refs.contains_key(&ptr) && seen.insert(ptr) {
total += len as u128;
}
});
total

Some files were not shown because too many files have changed in this diff Show More