mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge pull request #9386 from davidmann4/integration-batch-8
Integration batch 8: 37 reviewed community PRs on main
This commit is contained in:
+1
-1
@@ -116,7 +116,7 @@ next to the files:
|
||||
|
||||
| Path (fetched) | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/photoshop/` (258 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
|
||||
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
|
||||
|
||||
Generated
+7
@@ -5082,8 +5082,10 @@ dependencies = [
|
||||
name = "photocraft-gpu"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"half",
|
||||
"log",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-color",
|
||||
"photocraft-compose",
|
||||
@@ -5132,6 +5134,9 @@ dependencies = [
|
||||
"roxmltree 0.21.1",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"zune-core 0.5.3",
|
||||
"zune-jpegxl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5149,9 +5154,11 @@ dependencies = [
|
||||
name = "photocraft-paint"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -185,8 +185,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🧰 67 tools</h4>
|
||||
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
<h4>🧰 68 tools</h4>
|
||||
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🖌️ A real brush engine</h4>
|
||||
|
||||
@@ -125,7 +125,8 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
|
||||
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
|
||||
ok(bin().arg("convert").arg(&jpg).arg(&a));
|
||||
ok(bin().arg("convert").arg(&a).arg(&b));
|
||||
// Everything but the file's own path and saved document name must match.
|
||||
// Everything but the file's own path and saved document name (which the first snapshot is
|
||||
// named after) must match.
|
||||
let info = |p: &Path| {
|
||||
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
|
||||
v.as_object_mut().unwrap().remove("file");
|
||||
@@ -136,8 +137,12 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
|
||||
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
|
||||
assert_eq!(ia["name"], "a.pcraft");
|
||||
assert_eq!(ib["name"], "b.pcraft");
|
||||
ia.as_object_mut().unwrap().remove("name");
|
||||
ib.as_object_mut().unwrap().remove("name");
|
||||
assert_eq!(ia["snapshots"][0]["name"], "a.pcraft");
|
||||
assert_eq!(ib["snapshots"][0]["name"], "b.pcraft");
|
||||
for v in [&mut ia, &mut ib] {
|
||||
v.as_object_mut().unwrap().remove("name");
|
||||
v["snapshots"][0].as_object_mut().unwrap().remove("name");
|
||||
}
|
||||
assert_eq!(ia, ib);
|
||||
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&pa));
|
||||
|
||||
@@ -92,6 +92,53 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
|
||||
CUSTOM_TITLEBAR && !prefs.interface.system_title_bar
|
||||
}
|
||||
|
||||
/// Preferences › Integrations, read before the window opens the way [`custom_titlebar`] is:
|
||||
/// whether agents may drive this launch at all, and the port to listen on when no `--control`
|
||||
/// was given (0 = only with `--control`). A missing or unreadable file keeps the defaults.
|
||||
///
|
||||
/// The section is read on its own, and when it doesn't read as a whole each value on its own,
|
||||
/// as the app's lenient load reads them (#6611): a wrong-typed value elsewhere in the file, or
|
||||
/// in the other field, must not turn Allow Agent Control back on while the dialog shows it off.
|
||||
fn integrations(prefs_file: Option<&std::path::Path>) -> photocraft_engine::prefs::Integrations {
|
||||
use serde_json::Value;
|
||||
let mut out = photocraft_engine::prefs::Preferences::default().integrations;
|
||||
let file: Option<Value> = prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok());
|
||||
let Some(section) = file.as_ref().and_then(|v| v.get("integrations")) else {
|
||||
return out;
|
||||
};
|
||||
if let Ok(whole) = serde_json::from_value(section.clone()) {
|
||||
return whole;
|
||||
}
|
||||
if let Some(allow) = section.get("allowAgentControl").and_then(Value::as_bool) {
|
||||
out.allow_agent_control = allow;
|
||||
}
|
||||
if let Some(port) = section.get("controlPort").and_then(Value::as_u64).and_then(|p| u32::try_from(p).ok()) {
|
||||
out.control_port = port;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The control port this launch listens on, and the warning to print when a request was refused.
|
||||
/// `--control` (or `PHOTOCRAFT_CONTROL_PORT`) wins over the preference; Allow Agent Control off
|
||||
/// refuses both, and says so rather than ignoring the flag silently. A preferred port outside
|
||||
/// 1..=65535 is refused the same way.
|
||||
fn control_port_for(cli: Option<u16>, prefs: &photocraft_engine::prefs::Integrations) -> (Option<u16>, Option<String>) {
|
||||
if !prefs.allow_agent_control {
|
||||
let warning = cli.map(|p| format!("photocraft: Preferences › Integrations › Allow Agent Control is off: not listening on port {p}\n"));
|
||||
return (None, warning);
|
||||
}
|
||||
if let Some(port) = cli {
|
||||
return (Some(port), None);
|
||||
}
|
||||
match prefs.control_port {
|
||||
0 => (None, None),
|
||||
p => match u16::try_from(p).ok().filter(|p| *p != 0) {
|
||||
Some(port) => (Some(port), None),
|
||||
None => (None, Some(format!("photocraft: Preferences › Integrations › Control Port {p} is not a usable port: not listening\n"))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
|
||||
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
|
||||
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
|
||||
@@ -274,6 +321,10 @@ fn main() -> eframe::Result {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let (control_port, control_pref_warning) = control_port_for(control_port, &integrations(services::prefs_file().as_deref()));
|
||||
if let Some(warning) = &control_pref_warning {
|
||||
eprint!("{warning}");
|
||||
}
|
||||
let control = if let Some(port) = control_port {
|
||||
let (supplied, token_file) = (auth.control_token, auth.control_token_file);
|
||||
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
|
||||
@@ -844,6 +895,91 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrations_preferences_decide_the_control_port() {
|
||||
use photocraft_engine::prefs::Integrations;
|
||||
let off = Integrations { allow_agent_control: false, control_port: 0 };
|
||||
let on = Integrations { allow_agent_control: true, control_port: 0 };
|
||||
let preferred = Integrations { allow_agent_control: true, control_port: 7878 };
|
||||
|
||||
// Nothing asked for, nothing listening.
|
||||
assert_eq!(super::control_port_for(None, &on), (None, None));
|
||||
// --control wins over the preference.
|
||||
assert_eq!(super::control_port_for(Some(9000), &preferred), (Some(9000), None));
|
||||
// The preference opens the port when --control is absent.
|
||||
assert_eq!(super::control_port_for(None, &preferred), (Some(7878), None));
|
||||
|
||||
// Allow Agent Control off refuses both, and says so when --control asked for one
|
||||
// rather than ignoring the flag.
|
||||
assert_eq!(super::control_port_for(None, &off), (None, None));
|
||||
let (port, warning) = super::control_port_for(Some(9000), &off);
|
||||
assert_eq!(port, None);
|
||||
assert!(warning.is_some_and(|w| w.contains("Allow Agent Control") && w.contains("9000")), "the refusal names the port");
|
||||
let off_with_port = Integrations { allow_agent_control: false, control_port: 7878 };
|
||||
assert_eq!(super::control_port_for(None, &off_with_port), (None, None), "off beats a preferred port");
|
||||
|
||||
// A port a hand-edited file could hold, outside 1..=65535, is refused and named.
|
||||
let huge = Integrations { allow_agent_control: true, control_port: 70_000 };
|
||||
let (port, warning) = super::control_port_for(None, &huge);
|
||||
assert_eq!(port, None);
|
||||
assert!(warning.is_some_and(|w| w.contains("70000")), "the refusal names the value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrations_fall_back_to_the_defaults_without_a_readable_file() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-integrations-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("prefs.json");
|
||||
let default = photocraft_engine::prefs::Integrations::default();
|
||||
|
||||
assert_eq!(super::integrations(None), default, "no preferences file");
|
||||
std::fs::write(&file, "not json").unwrap();
|
||||
assert_eq!(super::integrations(Some(&file)), default, "an unreadable file");
|
||||
std::fs::write(&file, r#"{"integrations":{"allowAgentControl":false,"controlPort":7878}}"#).unwrap();
|
||||
let read = super::integrations(Some(&file));
|
||||
assert!(!read.allow_agent_control);
|
||||
assert_eq!(read.control_port, 7878);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The app loads preferences leniently (#6611): one wrong-typed value resets only itself. So
|
||||
/// a bad value elsewhere, or in the port, must not turn Allow Agent Control back on.
|
||||
#[test]
|
||||
fn a_bad_value_elsewhere_keeps_agent_control_off() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-integrations-lenient-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("prefs.json");
|
||||
// What the Preferences dialog shows for the same file.
|
||||
let shown = |text: &str| {
|
||||
let mut session = photocraft_engine::Session::new();
|
||||
session.load_prefs_json(text).unwrap();
|
||||
session.prefs().integrations.clone()
|
||||
};
|
||||
let read = |text: &str| {
|
||||
std::fs::write(&file, text).unwrap();
|
||||
let read = super::integrations(Some(&file));
|
||||
assert_eq!(read, shown(text), "{text}");
|
||||
read
|
||||
};
|
||||
|
||||
let r = read(r#"{"interface":{"enableAnimations":"nope"},"integrations":{"allowAgentControl":false,"controlPort":7878}}"#);
|
||||
assert!(!r.allow_agent_control, "a bad value in another section");
|
||||
assert_eq!(r.control_port, 7878);
|
||||
let r = read(r#"{"integrations":{"allowAgentControl":false,"controlPort":"x"}}"#);
|
||||
assert!(!r.allow_agent_control, "a bad port");
|
||||
assert_eq!(r.control_port, 0, "the bad port alone takes its default");
|
||||
// A bad Allow Agent Control alone takes its default.
|
||||
let r = read(r#"{"integrations":{"allowAgentControl":"no","controlPort":7878}}"#);
|
||||
assert_eq!(r, photocraft_engine::prefs::Integrations { allow_agent_control: true, control_port: 7878 });
|
||||
read(r#"{"integrations":{"allowAgentControl":false,"controlPort":-1}}"#);
|
||||
read(r#"{"integrations":{"allowAgentControl":false,"controlPort":4294967296}}"#);
|
||||
read(r#"{"integrations":"off"}"#);
|
||||
// serde reads a struct from a list too; the app does.
|
||||
assert!(!read(r#"{"integrations":[false]}"#).allow_agent_control, "a list");
|
||||
assert_eq!(read(r#"{"integrations":[false,7878]}"#).control_port, 7878);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
|
||||
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
|
||||
#[test]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## Layered TIFF
|
||||
|
||||
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
|
||||
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
|
||||
|
||||
## WebP
|
||||
|
||||
|
||||
@@ -265,6 +265,9 @@ sync_github_name = false
|
||||
[people.drunkeninja42]
|
||||
real_name = "Kushagra Sharma"
|
||||
|
||||
[people.WebWork-rrs]
|
||||
display_name = "Ratul Paul"
|
||||
|
||||
[people.softwaregames1]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -275,3 +278,7 @@ sync_github_name = false
|
||||
[people.erenova]
|
||||
display_name = "Erenova"
|
||||
sync_github_name = false
|
||||
|
||||
[people.jasallen]
|
||||
real_name = "Jason DeBoever"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -1,42 +1,161 @@
|
||||
//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels
|
||||
//! (3:2), each overlapping the next by half its width.
|
||||
//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP).
|
||||
//! Peak memory is the growth of the process's high-water mark during the blend (Linux only).
|
||||
//! Time and peak memory of Photomerge's multi-band blend on overlapping RGB sources.
|
||||
//! Backwards compatible: `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]`.
|
||||
//! `cargo xtask perf --bench bench_multiband --quick` also collects repeatable JSON timings.
|
||||
//! The peak is the entire process's resident-set high-water mark, not an estimate of allocations
|
||||
//! by the blend alone (`photocraft_testkit::perf`: the kernel's mark on Linux, sampled elsewhere).
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_algo::panorama::{self, RoiImage};
|
||||
use photocraft_testkit::perf::{self, RssSampler, Summary};
|
||||
use serde_json::json;
|
||||
|
||||
/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it.
|
||||
fn peak_mib() -> Option<f64> {
|
||||
let s = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?;
|
||||
Some(kb / 1024.0)
|
||||
const ROW: &str = "Photomerge multiband blend";
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Options {
|
||||
n: usize,
|
||||
mp: f64,
|
||||
reps: usize,
|
||||
json: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1);
|
||||
let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0);
|
||||
let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
|
||||
/// Parses legacy `[n] [mp]` arguments and the perf runner's `--reps`/`--json` flags.
|
||||
/// Rejects missing, out-of-range, or non-finite values before allocating images.
|
||||
fn options(args: impl IntoIterator<Item = String>) -> Result<Options, String> {
|
||||
let mut out = Options { n: 4, mp: 24.0, reps: 1, json: None };
|
||||
let mut positional = Vec::new();
|
||||
let mut it = args.into_iter();
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"--json" => {
|
||||
let path = it.next().filter(|p| !p.starts_with('-')).ok_or("--json needs a file path")?;
|
||||
out.json = Some(PathBuf::from(path));
|
||||
}
|
||||
"--reps" => {
|
||||
out.reps = it.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "--reps needs an integer")?;
|
||||
}
|
||||
_ if !arg.starts_with('-') => positional.push(arg),
|
||||
_ => return Err(format!("unknown option: {arg}")),
|
||||
}
|
||||
}
|
||||
if positional.len() > 2 {
|
||||
return Err("expected at most [n] [mp]".into());
|
||||
}
|
||||
if let Some(n) = positional.first() {
|
||||
out.n = n.parse().map_err(|_| "n must be an integer")?;
|
||||
}
|
||||
if let Some(mp) = positional.get(1) {
|
||||
out.mp = mp.parse().map_err(|_| "mp must be a number")?;
|
||||
}
|
||||
if !(1..=16).contains(&out.n) || !(1..=25).contains(&out.reps) || !out.mp.is_finite() || !(0.01..=36.0).contains(&out.mp) {
|
||||
return Err("expected 1..=16 images, 0.01..=36 MP per image and 1..=25 repetitions".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Builds deterministic overlapping RGB inputs and times each multi-band blend.
|
||||
/// Writes individual samples and whole-process peak RSS to JSON when requested.
|
||||
fn run() -> Result<(), String> {
|
||||
let o = options(std::env::args().skip(1))?;
|
||||
let sh = ((o.mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
|
||||
let sw = sh * 3 / 2;
|
||||
let step = sw / 2;
|
||||
let (cw, ch) = (step * (n - 1) + sw, sh);
|
||||
let rois: Vec<RoiImage> = (0..n)
|
||||
let (cw, ch) = (step * (o.n - 1) + sw, sh);
|
||||
let rois: Vec<RoiImage> = (0..o.n)
|
||||
.map(|k| {
|
||||
let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect();
|
||||
RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] }
|
||||
})
|
||||
.collect();
|
||||
// Each canvas column belongs to the image whose centre is nearest, as a seam would split it.
|
||||
let wts: Vec<Vec<f32>> = (0..n)
|
||||
.map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect())
|
||||
let wts: Vec<Vec<f32>> = (0..o.n)
|
||||
.map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(o.n - 1) == k { 1.0 } else { 0.0 }).collect())
|
||||
.collect();
|
||||
let levels = panorama::blend_levels(cw, ch).min(7);
|
||||
let before = peak_mib();
|
||||
let t0 = Instant::now();
|
||||
let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels);
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b));
|
||||
println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend");
|
||||
std::hint::black_box((px, cov));
|
||||
let before = perf::process_peak_rss_bytes().or_else(perf::current_rss_bytes);
|
||||
let sampler = RssSampler::start(std::time::Duration::from_millis(2));
|
||||
let mut samples_ms = Vec::with_capacity(o.reps);
|
||||
for _ in 0..o.reps {
|
||||
let t0 = Instant::now();
|
||||
let output = panorama::multiband(cw, ch, &rois, &wts, levels);
|
||||
samples_ms.push(t0.elapsed().as_secs_f64() * 1000.0);
|
||||
std::hint::black_box(&output);
|
||||
}
|
||||
let peak = perf::process_peak_rss_bytes().or(sampler.peak());
|
||||
let delta = before.zip(peak).map(|(a, b)| b.saturating_sub(a) as f64 / (1024.0 * 1024.0));
|
||||
// The same nearest-rank p50 that `cargo xtask perf` reports for these samples.
|
||||
let p50 = Summary::of(&samples_ms).map_or(f64::NAN, |s| s.p50);
|
||||
println!(
|
||||
"{} × {}×{} ({:.2} MP) RGB, canvas {}×{}, {} levels: {:.1} ms p50 ({} runs), peak +{}",
|
||||
o.n,
|
||||
sw,
|
||||
sh,
|
||||
o.mp,
|
||||
cw,
|
||||
ch,
|
||||
levels,
|
||||
p50,
|
||||
o.reps,
|
||||
delta.map_or("n/a".to_string(), |m| format!("{m:.0} MiB"))
|
||||
);
|
||||
if let Some(path) = o.json {
|
||||
let context = json!({"sources": o.n, "mp_per_source": o.mp, "canvas_width": cw, "canvas_height": ch, "levels": levels});
|
||||
let report = perf::report("bench_multiband", context, vec![perf::row(ROW, &samples_ms, peak, None)], Some(&sampler));
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs the benchmark, reporting argument or output errors with a failing exit status.
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
eprintln!("bench_multiband: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Converts borrowed argument slices for the owned-string command-line parser.
|
||||
fn parse(args: &[&str]) -> Result<Options, String> {
|
||||
options(args.iter().map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
/// Confirms backwards compatibility with the positional and perf-runner forms.
|
||||
#[test]
|
||||
fn parses_old_positional_args_and_perf_runner_args() {
|
||||
assert_eq!(parse(&[]).unwrap().n, 4);
|
||||
assert_eq!(
|
||||
parse(&["2", "0.25", "--reps", "3", "--json", "out.json"]).unwrap(),
|
||||
Options { n: 2, mp: 0.25, reps: 3, json: Some(PathBuf::from("out.json")) }
|
||||
);
|
||||
// The 24-36 MP range AGENTS.md benchmarks at, as before the flags were added.
|
||||
assert_eq!(parse(&["4", "36"]).unwrap().mp, 36.0);
|
||||
}
|
||||
|
||||
/// Checks invalid counts, non-finite sizes, repetitions, and missing flag values.
|
||||
#[test]
|
||||
fn rejects_bad_benchmark_args() {
|
||||
for args in [
|
||||
vec!["0"],
|
||||
vec!["17"],
|
||||
vec!["2", "0"],
|
||||
vec!["2", "NaN"],
|
||||
vec!["2", "inf"],
|
||||
vec!["2", "37"],
|
||||
vec!["--reps", "0"],
|
||||
vec!["--reps", "26"],
|
||||
vec!["--json"],
|
||||
vec!["--json", "--reps", "5"],
|
||||
vec!["--reps"],
|
||||
vec!["--unknown"],
|
||||
] {
|
||||
assert!(parse(&args).is_err(), "{args:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Timing of Content-Aware Scale's seam carving alone (no document plumbing), on a synthetic
|
||||
//! photo-like RGBA image: perf scenario P28's 24 MP width change (#211), the same image's height
|
||||
//! (the transposed pass) and two images short enough for the exact full-resolution seam search.
|
||||
//! Prints an FNV-style hash of each output's bits (over 32-bit words), so a speed-up can be checked
|
||||
//! to choose exactly the same seams.
|
||||
//! `cargo run --release -p photocraft-algo --example bench_seam_cas [reps] [case filter]`
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_algo::seam::carve;
|
||||
|
||||
/// Smooth gradients, a few hard-edged shapes and fine noise: enough structure that seams are
|
||||
/// steered by content, as in a photo.
|
||||
fn photo(w: usize, h: usize) -> Vec<f32> {
|
||||
let mut seed = 0x9e37_79b9u32;
|
||||
let mut noise = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
(seed % 1000) as f32 / 1000.0 - 0.5
|
||||
};
|
||||
let mut img = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (u, v) = (x as f32 / w as f32, y as f32 / h as f32);
|
||||
let sky = 0.35 + 0.4 * v;
|
||||
let disc = ((u - 0.3).powi(2) + (v - 0.55).powi(2)) < 0.02;
|
||||
let bar = (0.62..0.66).contains(&u) && v > 0.3;
|
||||
let hill = v > 0.7 + 0.08 * (u * 11.0).sin();
|
||||
let (r, g, b) = if disc {
|
||||
(0.85, 0.3, 0.2)
|
||||
} else if bar {
|
||||
(0.1, 0.1, 0.12)
|
||||
} else if hill {
|
||||
(0.2, 0.45 + 0.1 * (u * 40.0).sin(), 0.15)
|
||||
} else {
|
||||
(sky * 0.6, sky * 0.8, sky)
|
||||
};
|
||||
let n = noise() * 0.02;
|
||||
img.extend_from_slice(&[r + n, g + n, b + n, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn fnv(v: &[f32]) -> u64 {
|
||||
v.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, f| (h ^ u64::from(f.to_bits())).wrapping_mul(0x0100_0000_01b3))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let reps = args.get(1).and_then(|s| s.parse::<usize>().ok()).unwrap_or(1).clamp(1, 20);
|
||||
let only = args.get(2).cloned();
|
||||
// (name, w, h, new w, new h)
|
||||
let cases = [
|
||||
("24 MP to 90 % width (P28, proxy rows)", 6000, 4000, 5400, 4000),
|
||||
("24 MP to 90 % height (transposed, proxy rows)", 6000, 4000, 6000, 3600),
|
||||
("5.4 MP to 90 % width (exact)", 3000, 1800, 2700, 1800),
|
||||
("1.5 MP to 70 % width (exact)", 1500, 1000, 1050, 1000),
|
||||
];
|
||||
for (name, w, h, nw, nh) in cases {
|
||||
if only.as_ref().is_some_and(|o| !name.contains(o.as_str())) {
|
||||
continue;
|
||||
}
|
||||
let img = photo(w, h);
|
||||
let mut times = Vec::with_capacity(reps);
|
||||
let mut hash = 0;
|
||||
for _ in 0..reps {
|
||||
let t = Instant::now();
|
||||
let out = carve(w, h, 4, &img, None, nw, nh);
|
||||
times.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
hash = fnv(&out);
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
let p50 = times[times.len() / 2];
|
||||
let max = times[times.len() - 1];
|
||||
println!("{name:<48} p50 {p50:>9.1} ms max {max:>9.1} ms output {hash:016x}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+275
-63
@@ -56,8 +56,11 @@ pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
|
||||
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
|
||||
|
||||
mod local_tone;
|
||||
mod per_pixel;
|
||||
mod whites_blacks;
|
||||
|
||||
pub use per_pixel::{CURVE_LUT, Grade, Grain, PerPixel, Vignette, VignetteStyle, WB_STEPS, WhitesBlacks, per_pixel, white_black_points};
|
||||
|
||||
/// How the pixels encode light: the sRGB curve (gamma 0) or a pure power law (ProPhoto's 1.8,
|
||||
/// Adobe RGB's 2.2). The tone controls work on light, so the document's own curve decodes it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -166,6 +169,13 @@ pub struct CameraRaw {
|
||||
/// The document's transfer curve: 0 for sRGB's, else the gamma of a power-law space (1.8
|
||||
/// for ProPhoto). Set from the document's profile; Whites and Blacks act on light.
|
||||
pub encoding_gamma: f32,
|
||||
// Calibration (Camera Raw's Calibration panel)
|
||||
/// Shadows › Tint: −100 (green) … 100 (magenta), strongest in the darkest tones.
|
||||
pub shadow_tint: f32,
|
||||
/// Red, Green and Blue Primary › Hue: −100 … 100, the primary turned towards its neighbours.
|
||||
pub calibration_hue: [f32; 3],
|
||||
/// Red, Green and Blue Primary › Saturation: −100 (grey) … 100 (purer).
|
||||
pub calibration_sat: [f32; 3],
|
||||
}
|
||||
|
||||
impl Default for CameraRaw {
|
||||
@@ -222,6 +232,9 @@ impl Default for CameraRaw {
|
||||
seed: 0,
|
||||
pixel_scale: 1.0,
|
||||
encoding_gamma: 0.0,
|
||||
shadow_tint: 0.0,
|
||||
calibration_hue: [0.0; 3],
|
||||
calibration_sat: [0.0; 3],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,6 +242,90 @@ impl Default for CameraRaw {
|
||||
/// Hue band centres of the Color Mixer (degrees).
|
||||
pub const HSL_BANDS: [f32; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 270.0, 300.0];
|
||||
|
||||
/// How far a primary turns at Hue ±100 (degrees), as the Color Mixer's hue sliders do.
|
||||
const CALIBRATION_TURN: f32 = 30.0;
|
||||
|
||||
/// How much of each primary (red, green, blue) a hue is made of: triangular weights around
|
||||
/// 0°, 120° and 240° that sum to one, so a colour between two primaries follows both.
|
||||
pub fn calibration_weights(hue: f32) -> [f32; 3] {
|
||||
let h = hue.rem_euclid(360.0);
|
||||
let mut w = [0.0f32; 3];
|
||||
for (i, centre) in [0.0f32, 120.0, 240.0].iter().enumerate() {
|
||||
let d = (h - centre).abs().min(360.0 - (h - centre).abs());
|
||||
w[i] = (1.0 - d / 120.0).max(0.0);
|
||||
}
|
||||
let sum: f32 = w.iter().sum();
|
||||
if sum > 1e-6 { w.map(|v| v / sum) } else { [1.0, 0.0, 0.0] }
|
||||
}
|
||||
|
||||
/// The Calibration panel's primaries on one linear-light pixel: its hue turns by the weighted
|
||||
/// Hue of the primaries it is made of (±100 is ±30°, positive towards the next primary) and its
|
||||
/// saturation scales by their weighted Saturation (−100 greys it); the luminance is kept, so
|
||||
/// neutrals are untouched.
|
||||
fn calibrate(c: [f32; 3], hue: [f32; 3], sat: [f32; 3]) -> [f32; 3] {
|
||||
let [h, s, v] = rgb_to_hsv(c.map(|x| x.max(0.0)));
|
||||
if s <= 1e-5 || v <= 1e-6 {
|
||||
return c;
|
||||
}
|
||||
let w = calibration_weights(h);
|
||||
let (mut dh, mut ds) = (0.0f32, 0.0f32);
|
||||
for i in 0..3 {
|
||||
dh += w[i] * hue[i].clamp(-100.0, 100.0);
|
||||
ds += w[i] * sat[i].clamp(-100.0, 100.0);
|
||||
}
|
||||
let nh = h + dh / 100.0 * CALIBRATION_TURN;
|
||||
let ns = (s * (1.0 + ds / 100.0)).clamp(0.0, 1.0);
|
||||
let out = hsv_to_rgb(nh, ns, v);
|
||||
let (l0, l1) = (luma(c), luma(out));
|
||||
if l1 > 1e-6 { out.map(|x| x * l0 / l1) } else { out }
|
||||
}
|
||||
|
||||
/// Shadows › Tint on one linear-light pixel: `tint` −1 (green) … 1 (magenta), weighted by how
|
||||
/// dark the pixel is; the luminance is kept.
|
||||
fn shadow_tint(c: [f32; 3], tint: f32) -> [f32; 3] {
|
||||
let l = luma(c).max(0.0);
|
||||
// Linear 0.18 is middle grey: full strength below it, fading out towards white.
|
||||
let weight = (1.0 - (l / 0.18).sqrt().min(1.0)).powi(2) * 0.5 + (1.0 - l.min(1.0)).powi(4) * 0.5;
|
||||
let k = tint * 0.12 * weight * (l + 0.02);
|
||||
let o = [c[0] + k, c[1] - k, c[2] + k].map(|v| v.max(0.0));
|
||||
let l2 = luma(o);
|
||||
if l2 > 1e-6 { o.map(|v| v * l / l2) } else { o }
|
||||
}
|
||||
|
||||
/// Camera Raw's Auto for the Light panel: Exposure, Contrast, Highlights, Shadows, Whites and
|
||||
/// Blacks from the image's luminance histogram (`px` in the document's encoding). A well
|
||||
/// exposed image gets near-zero settings; a dark one more exposure and lifted shadows; one
|
||||
/// with a crushed or blown end gets Blacks or Whites back from the edge. The other panels are
|
||||
/// left alone, as Camera Raw's Auto leaves them.
|
||||
pub fn auto_tone(px: &[[f32; 4]], p: &mut CameraRaw) {
|
||||
if px.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut lum: Vec<f32> = px.iter().map(|q| luma([q[0], q[1], q[2]]).clamp(0.0, 1.0)).collect();
|
||||
lum.sort_by(|a, b| a.total_cmp(b));
|
||||
let at = |f: f32| lum[((lum.len() - 1) as f32 * f).round() as usize];
|
||||
let (black, shadow, median, light, white) = (at(0.001), at(0.1), at(0.5), at(0.9), at(0.999));
|
||||
// Exposure: the median towards middle grey (sRGB 0.46), damped so flat scenes aren't
|
||||
// forced to it.
|
||||
let median_lin = srgb_to_linear(median).max(1e-4);
|
||||
p.exposure = (0.6 * (0.18f32 / median_lin).log2()).clamp(-2.0, 2.0);
|
||||
// Blacks and Whites bring the ends to the edges without clipping more than Camera Raw does.
|
||||
p.blacks = ((0.04 - black) * 400.0).clamp(-40.0, 40.0);
|
||||
p.whites = ((0.96 - white) * 300.0).clamp(-40.0, 40.0);
|
||||
// Shadows up when the darker tenth is crushed, highlights down when the brighter tenth is
|
||||
// near white.
|
||||
p.shadows = ((0.25 - shadow) * 200.0).clamp(0.0, 50.0);
|
||||
p.highlights = ((light - 0.85) * -300.0).clamp(-50.0, 0.0);
|
||||
// A little contrast for a flat histogram.
|
||||
let spread = (light - shadow).max(0.0);
|
||||
p.contrast = ((0.6 - spread) * 60.0).clamp(0.0, 25.0);
|
||||
// Shown as Camera Raw shows them: Exposure to a hundredth of a stop, the others whole.
|
||||
p.exposure = (p.exposure * 100.0).round() / 100.0;
|
||||
for v in [&mut p.blacks, &mut p.whites, &mut p.shadows, &mut p.highlights, &mut p.contrast] {
|
||||
*v = v.round();
|
||||
}
|
||||
}
|
||||
|
||||
fn luma(c: [f32; 3]) -> f32 {
|
||||
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
||||
}
|
||||
@@ -534,6 +631,10 @@ impl CameraRaw {
|
||||
fn grade_neutral(&self) -> bool {
|
||||
self.grade_shadows.neutral() && self.grade_midtones.neutral() && self.grade_highlights.neutral() && self.grade_global.neutral()
|
||||
}
|
||||
/// The Calibration panel at rest.
|
||||
fn calibration_neutral(&self) -> bool {
|
||||
self.shadow_tint == 0.0 && self.calibration_hue.iter().chain(&self.calibration_sat).all(|v| *v == 0.0)
|
||||
}
|
||||
fn curves_neutral(&self) -> bool {
|
||||
[self.curve_highlights, self.curve_lights, self.curve_darks, self.curve_shadows].iter().all(|v| *v == 0.0)
|
||||
&& [&self.point_curve, &self.point_curve_red, &self.point_curve_green, &self.point_curve_blue].iter().all(|c| is_linear(c))
|
||||
@@ -541,6 +642,7 @@ impl CameraRaw {
|
||||
/// True when every control is neutral (the filter is an identity).
|
||||
pub fn is_identity(&self) -> bool {
|
||||
self.wb_neutral()
|
||||
&& self.calibration_neutral()
|
||||
&& self.tone_neutral()
|
||||
&& self.dehaze == 0.0
|
||||
&& self.vibrance == 0.0
|
||||
@@ -580,14 +682,27 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
||||
let ps = p.pixel_scale.clamp(0.01, 1.0);
|
||||
let long = w.max(h) as f32;
|
||||
// 1. White balance + exposure in linear light.
|
||||
if !p.wb_neutral() {
|
||||
let mut g = white_balance_gains(p.temperature, p.tint);
|
||||
let norm = luma(g);
|
||||
g = g.map(|v| v / norm * 2f32.powf(p.exposure));
|
||||
if let Some(g) = per_pixel::gains(p) {
|
||||
par_rows(px, w, 1, |_, row| {
|
||||
for q in row.iter_mut() {
|
||||
for c in 0..3 {
|
||||
q[c] = linear_to_srgb((srgb_to_linear(q[c]) * g[c]).max(0.0));
|
||||
*q = balance(*q, g);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 1b. Calibration: the primaries turned and purer or greyer, and the shadows tinted, in
|
||||
// linear light (the camera's primaries are what Camera Raw's panel calibrates).
|
||||
if !p.calibration_neutral() {
|
||||
let primaries = p.calibration_hue.iter().chain(&p.calibration_sat).any(|v| *v != 0.0);
|
||||
let tint = p.shadow_tint.clamp(-100.0, 100.0) / 100.0;
|
||||
par_rows(px, w, 1, |_, row| {
|
||||
for q in row.iter_mut() {
|
||||
let c = [srgb_to_linear(q[0]), srgb_to_linear(q[1]), srgb_to_linear(q[2])];
|
||||
let mut o = if primaries { calibrate(c, p.calibration_hue, p.calibration_sat) } else { c };
|
||||
if tint != 0.0 {
|
||||
o = shadow_tint(o, tint);
|
||||
}
|
||||
for k in 0..3 {
|
||||
q[k] = linear_to_srgb(o[k].max(0.0));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -606,7 +721,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
||||
let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005));
|
||||
let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps)));
|
||||
let k_con = p.contrast / 100.0 * 0.6;
|
||||
let tau = std::f32::consts::TAU;
|
||||
par_rows(px, w, 1, |y, row| {
|
||||
for (x, q) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
@@ -619,22 +733,14 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
||||
if let Some(t) = &tex {
|
||||
lv += p.texture / 100.0 * 0.9 * (lc[i] - t[i]).clamp(-0.1, 0.1);
|
||||
}
|
||||
if k_con != 0.0 {
|
||||
let xx = lv.clamp(0.0, 1.0);
|
||||
lv += -k_con * (tau * xx).sin() / tau;
|
||||
}
|
||||
let lv = lv.max(0.0) + (l0 - lc[i]);
|
||||
let ratio = if l0 > 1e-5 { lv / l0 } else { 1.0 };
|
||||
for c in 0..3 {
|
||||
q[c] = if l0 > 1e-5 { q[c] * ratio } else { q[c] + (lv - l0) };
|
||||
}
|
||||
*q = tone(*q, l0, lc[i], lv, k_con);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 3b. Whites and Blacks: global curves adapted to the image's black and white points.
|
||||
if p.whites != 0.0 || p.blacks != 0.0 {
|
||||
let enc = Encoding::of(p.encoding_gamma);
|
||||
let (bp, wp) = luminance_points(px, w, h, enc);
|
||||
let (bp, wp) = luminance_points(px, w, h, enc, |q| q);
|
||||
let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp);
|
||||
whites_blacks::apply(px, w, xw, xb, enc);
|
||||
}
|
||||
@@ -646,14 +752,7 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
||||
let (master, cr, cg, cb) = (pts(&p.point_curve), pts(&p.point_curve_red), pts(&p.point_curve_green), pts(&p.point_curve_blue));
|
||||
let hsl = !p.hsl_neutral();
|
||||
let grade = !p.grade_neutral();
|
||||
let wheel_tint = |wh: &Wheel| -> [f32; 3] {
|
||||
let c = hsv_to_rgb(wh.hue, 1.0, 1.0);
|
||||
let l = luma(c);
|
||||
[c[0] - l, c[1] - l, c[2] - l].map(|v| v * wh.sat / 100.0 * 0.3)
|
||||
};
|
||||
let tints = [wheel_tint(&p.grade_shadows), wheel_tint(&p.grade_midtones), wheel_tint(&p.grade_highlights), wheel_tint(&p.grade_global)];
|
||||
let blend = 0.15 + p.grade_blending.clamp(0.0, 100.0) / 100.0 * 0.35;
|
||||
let bal = p.grade_balance.clamp(-100.0, 100.0) / 100.0 * 0.25;
|
||||
let Grade { tints, lums, blend, balance: bal } = Grade::new(p);
|
||||
par_rows(px, w, 1, |_, row| {
|
||||
for q in row.iter_mut() {
|
||||
let mut c = [q[0], q[1], q[2]];
|
||||
@@ -706,7 +805,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
||||
let hi = smoothstep(0.5 + bal - blend, 1.0, l);
|
||||
let mid = (1.0 - sh - hi).max(0.0);
|
||||
let masks = [sh, mid, hi, 1.0];
|
||||
let lums = [p.grade_shadows.lum, p.grade_midtones.lum, p.grade_highlights.lum, p.grade_global.lum];
|
||||
for k in 0..4 {
|
||||
for ch in 0..3 {
|
||||
c[ch] += masks[k] * tints[k][ch];
|
||||
@@ -732,11 +830,7 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
||||
vignette(px, w, h, p);
|
||||
}
|
||||
if p.grain_amount > 0.0 {
|
||||
let cell = 1.0 + p.grain_size / 25.0;
|
||||
let rough = p.grain_roughness / 100.0;
|
||||
let amt = p.grain_amount / 100.0 * 0.12;
|
||||
let seed = p.seed as u64;
|
||||
let pscale = 1.0 / ps;
|
||||
let Grain { cell, roughness: rough, amount: amt, seed, scale: pscale } = Grain::new(p);
|
||||
par_rows(px, w, 1, |y, row| {
|
||||
for (x, q) in row.iter_mut().enumerate() {
|
||||
// Sample in full-resolution coordinates so previews match the result.
|
||||
@@ -759,24 +853,60 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of stage 3 on one pixel: Contrast (strength `k_con`) on the luminance `lv` that
|
||||
/// Clarity and Texture left, then the pixel rescaled to it. `l0` is its luminance, `lc` that
|
||||
/// clipped to 1.
|
||||
fn tone(mut q: [f32; 4], l0: f32, lc: f32, mut lv: f32, k_con: f32) -> [f32; 4] {
|
||||
if k_con != 0.0 {
|
||||
let tau = std::f32::consts::TAU;
|
||||
let xx = lv.clamp(0.0, 1.0);
|
||||
lv += -k_con * (tau * xx).sin() / tau;
|
||||
}
|
||||
let lv = lv.max(0.0) + (l0 - lc);
|
||||
let ratio = if l0 > 1e-5 { lv / l0 } else { 1.0 };
|
||||
for c in 0..3 {
|
||||
q[c] = if l0 > 1e-5 { q[c] * ratio } else { q[c] + (lv - l0) };
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
/// Stage 3 on one pixel when Contrast is its only control.
|
||||
fn contrast(q: [f32; 4], k_con: f32) -> [f32; 4] {
|
||||
let l0 = luma([q[0], q[1], q[2]]).max(0.0);
|
||||
let lc = l0.min(1.0);
|
||||
tone(q, l0, lc, lc, k_con)
|
||||
}
|
||||
|
||||
/// Stage 1 on one pixel: white balance and exposure `gains` in linear light.
|
||||
fn balance(mut q: [f32; 4], g: [f32; 3]) -> [f32; 4] {
|
||||
for c in 0..3 {
|
||||
q[c] = linear_to_srgb((srgb_to_linear(q[c]) * g[c]).max(0.0));
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
/// The image's black and white points as sRGB-encoded grey levels: the 3rd percentile of the
|
||||
/// luminance and the 99.5th of the brightest channel, on a copy at most 256 pixels across (so a
|
||||
/// preview proxy and the full image agree).
|
||||
fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding) -> (f32, f32) {
|
||||
/// preview proxy and the full image agree). `map` is applied to each pixel first.
|
||||
fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding, map: impl Fn([f32; 4]) -> [f32; 4] + Sync) -> (f32, f32) {
|
||||
let f = w.max(h).div_ceil(256).max(1);
|
||||
let (sw, sh) = (w.div_ceil(f), h.div_ceil(f));
|
||||
let mut sum = vec![[0.0f64; 4]; sw * sh];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let q = px[y * w + x];
|
||||
let s = &mut sum[(y / f) * sw + x / f];
|
||||
for c in 0..3 {
|
||||
s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0));
|
||||
// Each band of `f` rows sums into its own row of cells, in the same order as one pass would.
|
||||
let bands = crate::photo_util::par_map(sh, |sy| {
|
||||
let mut sum = vec![[0.0f64; 4]; sw];
|
||||
for y in sy * f..((sy + 1) * f).min(h) {
|
||||
for x in 0..w {
|
||||
let q = map(px[y * w + x]);
|
||||
let s = &mut sum[x / f];
|
||||
for c in 0..3 {
|
||||
s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0));
|
||||
}
|
||||
s[3] += 1.0;
|
||||
}
|
||||
s[3] += 1.0;
|
||||
}
|
||||
}
|
||||
let cells: Vec<[f32; 3]> = sum.iter().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect();
|
||||
sum
|
||||
});
|
||||
let cells: Vec<[f32; 3]> = bands.iter().flatten().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect();
|
||||
if cells.is_empty() {
|
||||
return (0.0, 1.0);
|
||||
}
|
||||
@@ -933,24 +1063,7 @@ fn detail(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, ps: f32) {
|
||||
}
|
||||
|
||||
fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
|
||||
let amt = p.vignette_amount / 100.0;
|
||||
let mid = p.vignette_midpoint.clamp(0.0, 100.0) / 100.0;
|
||||
let feather = p.vignette_feather.clamp(0.0, 100.0) / 100.0;
|
||||
let round = p.vignette_roundness.clamp(-100.0, 100.0) / 100.0;
|
||||
let hl = p.vignette_highlights.clamp(0.0, 100.0) / 100.0;
|
||||
let style = p.vignette_style.as_str();
|
||||
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
|
||||
// Roundness: 0 follows the frame's aspect, +1 a circle, −1 a rounded rectangle.
|
||||
let (ax, ay) = if round >= 0.0 {
|
||||
let r = cx.min(cy) + (cx.max(cy) - cx.min(cy)) * (1.0 - round);
|
||||
(if cx >= cy { r } else { cx }, if cy > cx { r } else { cy })
|
||||
} else {
|
||||
(cx, cy)
|
||||
};
|
||||
let pw = 2.0 + (-round).max(0.0) * 6.0;
|
||||
let inner = 1.0 - mid * 0.8;
|
||||
let edge0 = inner - feather * 0.5 * inner;
|
||||
let edge1 = inner + feather * 0.5 * (1.4 - inner) + 0.02;
|
||||
let Vignette { amount: amt, highlights: hl, style, centre: [cx, cy], axes: [ax, ay], power: pw, edges: [edge0, edge1] } = Vignette::new(w, h, p);
|
||||
par_rows(px, w, 1, |y, row| {
|
||||
for (x, q) in row.iter_mut().enumerate() {
|
||||
let (dx, dy) = (((x as f32 + 0.5 - cx) / ax).abs(), ((y as f32 + 0.5 - cy) / ay).abs());
|
||||
@@ -961,7 +1074,7 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
|
||||
}
|
||||
let l = luma([q[0], q[1], q[2]]).clamp(0.0, 1.0);
|
||||
match style {
|
||||
"paintOverlay" => {
|
||||
VignetteStyle::PaintOverlay => {
|
||||
let target = if amt < 0.0 { 0.0 } else { 1.0 };
|
||||
let k = m * amt.abs();
|
||||
for c in 0..3 {
|
||||
@@ -975,7 +1088,7 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
|
||||
let protect = smoothstep(0.6, 1.0, l) * hl;
|
||||
k += (1.0 - k) * protect;
|
||||
}
|
||||
if style == "colorPriority" {
|
||||
if style == VignetteStyle::ColorPriority {
|
||||
let nl = l * k;
|
||||
for c in 0..3 {
|
||||
q[c] += nl - l;
|
||||
@@ -1185,6 +1298,105 @@ mod tests {
|
||||
assert!(v[0][0] < 0.35 && (v[(h / 2) * w + w / 2][0] - 0.5).abs() < 0.01, "{:?}", v[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_keeps_the_develop_off_the_per_pixel_gpu_stages() {
|
||||
// The per-pixel stages (the Camera Raw dialog's GPU preview) don't run Calibration, so
|
||||
// settings with it must fall back to the CPU develop instead of previewing without it.
|
||||
for p in [
|
||||
CameraRaw { shadow_tint: 40.0, ..Default::default() },
|
||||
CameraRaw { calibration_hue: [0.0, 0.0, 30.0], whites: 20.0, ..Default::default() },
|
||||
CameraRaw { calibration_sat: [-50.0, 0.0, 0.0], exposure: 0.5, ..Default::default() },
|
||||
] {
|
||||
assert!(!p.spatial());
|
||||
assert!(per_pixel(&p, 2, 2, false, Some((0.0, 1.0))).is_none(), "{p:?}");
|
||||
}
|
||||
assert!(per_pixel(&CameraRaw { exposure: 0.5, ..Default::default() }, 2, 2, false, None).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_turns_primaries_keeps_neutrals_and_tints_shadows() {
|
||||
let w = calibration_weights(0.0);
|
||||
assert!((w[0] - 1.0).abs() < 1e-6, "pure red is all red primary: {w:?}");
|
||||
let w = calibration_weights(60.0);
|
||||
assert!((w[0] - 0.5).abs() < 1e-6 && (w[1] - 0.5).abs() < 1e-6, "yellow is half red, half green: {w:?}");
|
||||
let w = calibration_weights(300.0);
|
||||
assert!((w[2] - 0.5).abs() < 1e-6 && (w[0] - 0.5).abs() < 1e-6, "magenta wraps round: {w:?}");
|
||||
let (none, zero) = ([0.0f32; 3], [0.0f32; 3]);
|
||||
let grey = calibrate([0.3, 0.3, 0.3], [100.0, -100.0, 50.0], [100.0, -100.0, 100.0]);
|
||||
assert!(grey.iter().all(|v| (v - 0.3).abs() < 1e-5), "greys are untouched: {grey:?}");
|
||||
// Red turned towards green: it gains green, keeps its luminance.
|
||||
let red = calibrate([0.5, 0.0, 0.0], [100.0, 0.0, 0.0], zero);
|
||||
assert!(red[1] > 0.05 && (red[1] / red[0] - 0.5).abs() < 0.02 && red[2] < 1e-6, "red turns 30° towards green (orange): {red:?}");
|
||||
assert!((luma(red) - luma([0.5, 0.0, 0.0])).abs() < 1e-4, "luminance kept: {red:?}");
|
||||
// Blue at saturation −100 is grey; green at −50 is paler.
|
||||
let blue = calibrate([0.0, 0.0, 0.8], none, [0.0, 0.0, -100.0]);
|
||||
assert!((blue[0] - blue[1]).abs() < 1e-5 && (blue[1] - blue[2]).abs() < 1e-5, "blue at −100 saturation is grey: {blue:?}");
|
||||
let green = calibrate([0.2, 0.8, 0.2], none, [0.0, -50.0, 0.0]);
|
||||
assert!(green[0] > 0.2 && green[1] < 0.8, "green at −50 is paler: {green:?}");
|
||||
// A primary's sliders leave the other primaries alone.
|
||||
let unchanged = calibrate([0.0, 0.0, 0.8], [100.0, 100.0, 0.0], [100.0, 100.0, 0.0]);
|
||||
assert!(unchanged.iter().zip([0.0, 0.0, 0.8]).all(|(a, b)| (a - b).abs() < 1e-5), "{unchanged:?}");
|
||||
// Shadows tint: magenta lifts red and blue over green in a dark pixel, keeps luminance,
|
||||
// and leaves white alone.
|
||||
let dark = shadow_tint([0.02, 0.02, 0.02], 1.0);
|
||||
assert!(dark[0] > dark[1] && dark[2] > dark[1], "{dark:?}");
|
||||
assert!((luma(dark) - 0.02).abs() < 1e-4, "luminance kept: {dark:?}");
|
||||
let green = shadow_tint([0.02, 0.02, 0.02], -1.0);
|
||||
assert!(green[1] > green[0], "{green:?}");
|
||||
let white = shadow_tint([1.0, 1.0, 1.0], 1.0);
|
||||
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-3), "white is not tinted: {white:?}");
|
||||
// Through develop: neutral settings are an identity, a tint changes dark pixels only.
|
||||
let mut p = CameraRaw::default();
|
||||
assert!(p.is_identity());
|
||||
p.shadow_tint = 100.0;
|
||||
assert!(!p.is_identity());
|
||||
let mut px = vec![[0.1, 0.1, 0.1, 1.0], [0.95, 0.95, 0.95, 1.0]];
|
||||
develop(&mut px, 2, 1, &p, false);
|
||||
assert!(px[0][0] > px[0][1], "dark grey turns magenta: {:?}", px[0]);
|
||||
assert!((px[1][0] - px[1][1]).abs() < 0.01, "near-white stays neutral: {:?}", px[1]);
|
||||
p = CameraRaw::default();
|
||||
p.calibration_hue[2] = 100.0;
|
||||
let mut px = vec![[0.0, 0.0, 0.8, 1.0]];
|
||||
develop(&mut px, 1, 1, &p, false);
|
||||
assert!(px[0][0] > 0.05, "blue turned towards red: {:?}", px[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_tone_brightens_dark_and_darkens_bright_images() {
|
||||
let mut p = CameraRaw::default();
|
||||
auto_tone(&[], &mut p);
|
||||
assert_eq!(p, CameraRaw::default(), "no pixels: nothing changes");
|
||||
let dark: Vec<[f32; 4]> = (0..4096)
|
||||
.map(|i| {
|
||||
let v = 0.05 + (i % 64) as f32 / 64.0 * 0.15;
|
||||
[v, v, v, 1.0]
|
||||
})
|
||||
.collect();
|
||||
auto_tone(&dark, &mut p);
|
||||
assert!(p.exposure > 0.5, "a dark image gets exposure: {}", p.exposure);
|
||||
assert!(p.shadows > 0.0, "and lifted shadows: {}", p.shadows);
|
||||
let mut p = CameraRaw::default();
|
||||
let bright: Vec<[f32; 4]> = (0..4096)
|
||||
.map(|i| {
|
||||
let v = 0.8 + (i % 64) as f32 / 64.0 * 0.19;
|
||||
[v, v, v, 1.0]
|
||||
})
|
||||
.collect();
|
||||
auto_tone(&bright, &mut p);
|
||||
assert!(p.exposure < -0.5, "a bright image loses exposure: {}", p.exposure);
|
||||
assert!(p.highlights < 0.0, "and its highlights come down: {}", p.highlights);
|
||||
let mut p = CameraRaw::default();
|
||||
let good: Vec<[f32; 4]> = (0..4096)
|
||||
.map(|i| {
|
||||
let v = 0.04 + (i % 64) as f32 / 63.0 * 0.92;
|
||||
[v, v, v, 1.0]
|
||||
})
|
||||
.collect();
|
||||
auto_tone(&good, &mut p);
|
||||
assert!(p.exposure.abs() < 0.3 && p.blacks.abs() < 10.0 && p.whites.abs() < 10.0, "a full-range image is left nearly alone: {p:?}");
|
||||
assert!(p.saturation == 0.0 && p.hsl_neutral(), "other panels untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_documents_keep_overrange() {
|
||||
let mut a = vec![[2.0f32, 1.5, 0.5, 1.0]; 16];
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Develop's per-pixel stages resolved for one image: every constant and table they use, so
|
||||
//! another implementation of them (the GPU preview, `photocraft_gpu::CameraRawDevelop`) runs the
|
||||
//! same maths. [`develop`](super::develop) builds its own constants with the same functions.
|
||||
|
||||
use super::{CameraRaw, Encoding, Wheel, curve_lut, hsv_to_rgb, is_linear, luma, parametric_lut, white_balance_gains, whites_blacks};
|
||||
|
||||
/// Entries of the tone curve tables (parametric and point curves).
|
||||
pub const CURVE_LUT: usize = 1024;
|
||||
/// Samples of a Whites or Blacks curve row: grey levels 0, 1/32, …, 1.
|
||||
pub const WB_STEPS: usize = whites_blacks::STEPS;
|
||||
|
||||
/// White balance and exposure: channel gains in linear light, when the stage is on.
|
||||
pub(crate) fn gains(p: &CameraRaw) -> Option<[f32; 3]> {
|
||||
if p.wb_neutral() {
|
||||
return None;
|
||||
}
|
||||
let g = white_balance_gains(p.temperature, p.tint);
|
||||
let norm = luma(g);
|
||||
Some(g.map(|v| v / norm * 2f32.powf(p.exposure)))
|
||||
}
|
||||
|
||||
/// Contrast's strength, when Contrast is set.
|
||||
fn contrast(p: &CameraRaw) -> Option<f32> {
|
||||
(p.contrast != 0.0).then_some(p.contrast / 100.0 * 0.6)
|
||||
}
|
||||
|
||||
/// Whites and Blacks as two curve rows for the image's amounts.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct WhitesBlacks {
|
||||
/// The Whites curve over grey levels 0, 1/32, …, 1, when Whites is set.
|
||||
pub whites: Option<[f32; WB_STEPS]>,
|
||||
/// The Blacks curve, applied after Whites.
|
||||
pub blacks: Option<[f32; WB_STEPS]>,
|
||||
/// The clipping directions (Whites up, Blacks down) curve each channel; the others scale the
|
||||
/// pixel by the change of its luminance.
|
||||
pub per_channel: bool,
|
||||
}
|
||||
|
||||
/// Color Grading's wheels resolved.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Grade {
|
||||
/// Colour offsets of the shadows, midtones, highlights and global wheels.
|
||||
pub tints: [[f32; 3]; 4],
|
||||
/// Luminance of the same wheels (slider units).
|
||||
pub lums: [f32; 4],
|
||||
pub blend: f32,
|
||||
pub balance: f32,
|
||||
}
|
||||
|
||||
impl Grade {
|
||||
pub(crate) fn new(p: &CameraRaw) -> Self {
|
||||
let tint = |wh: &Wheel| -> [f32; 3] {
|
||||
let c = hsv_to_rgb(wh.hue, 1.0, 1.0);
|
||||
let l = luma(c);
|
||||
[c[0] - l, c[1] - l, c[2] - l].map(|v| v * wh.sat / 100.0 * 0.3)
|
||||
};
|
||||
Grade {
|
||||
tints: [tint(&p.grade_shadows), tint(&p.grade_midtones), tint(&p.grade_highlights), tint(&p.grade_global)],
|
||||
lums: [p.grade_shadows.lum, p.grade_midtones.lum, p.grade_highlights.lum, p.grade_global.lum],
|
||||
blend: 0.15 + p.grade_blending.clamp(0.0, 100.0) / 100.0 * 0.35,
|
||||
balance: p.grade_balance.clamp(-100.0, 100.0) / 100.0 * 0.25,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The vignette's `vignette_style`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum VignetteStyle {
|
||||
HighlightPriority,
|
||||
ColorPriority,
|
||||
PaintOverlay,
|
||||
}
|
||||
|
||||
/// The post-crop vignette's shape on a `w × h` frame.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Vignette {
|
||||
/// −1 … 1.
|
||||
pub amount: f32,
|
||||
/// 0 … 1.
|
||||
pub highlights: f32,
|
||||
pub style: VignetteStyle,
|
||||
/// Frame centre (pixels).
|
||||
pub centre: [f32; 2],
|
||||
/// Half axes of the shape (pixels).
|
||||
pub axes: [f32; 2],
|
||||
/// Exponent of the superellipse.
|
||||
pub power: f32,
|
||||
/// The mask's smoothstep edges in normalised radius.
|
||||
pub edges: [f32; 2],
|
||||
}
|
||||
|
||||
impl Vignette {
|
||||
pub(crate) fn new(w: usize, h: usize, p: &CameraRaw) -> Self {
|
||||
let mid = p.vignette_midpoint.clamp(0.0, 100.0) / 100.0;
|
||||
let feather = p.vignette_feather.clamp(0.0, 100.0) / 100.0;
|
||||
let round = p.vignette_roundness.clamp(-100.0, 100.0) / 100.0;
|
||||
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
|
||||
// Roundness: 0 follows the frame's aspect, +1 a circle, −1 a rounded rectangle.
|
||||
let (ax, ay) = if round >= 0.0 {
|
||||
let r = cx.min(cy) + (cx.max(cy) - cx.min(cy)) * (1.0 - round);
|
||||
(if cx >= cy { r } else { cx }, if cy > cx { r } else { cy })
|
||||
} else {
|
||||
(cx, cy)
|
||||
};
|
||||
let inner = 1.0 - mid * 0.8;
|
||||
Vignette {
|
||||
amount: p.vignette_amount / 100.0,
|
||||
highlights: p.vignette_highlights.clamp(0.0, 100.0) / 100.0,
|
||||
style: match p.vignette_style.as_str() {
|
||||
"paintOverlay" => VignetteStyle::PaintOverlay,
|
||||
"colorPriority" => VignetteStyle::ColorPriority,
|
||||
_ => VignetteStyle::HighlightPriority,
|
||||
},
|
||||
centre: [cx, cy],
|
||||
axes: [ax, ay],
|
||||
power: 2.0 + (-round).max(0.0) * 6.0,
|
||||
edges: [inner - feather * 0.5 * inner, inner + feather * 0.5 * (1.4 - inner) + 0.02],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grain: value noise sampled in full-resolution coordinates.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Grain {
|
||||
/// Noise cell size (full-resolution pixels).
|
||||
pub cell: f32,
|
||||
/// Weight of the finer octave.
|
||||
pub roughness: f32,
|
||||
pub amount: f32,
|
||||
pub seed: u64,
|
||||
/// Full-resolution pixels per pixel (`1 / pixel_scale`).
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl Grain {
|
||||
pub(crate) fn new(p: &CameraRaw) -> Self {
|
||||
Grain {
|
||||
cell: 1.0 + p.grain_size / 25.0,
|
||||
roughness: p.grain_roughness / 100.0,
|
||||
amount: p.grain_amount / 100.0 * 0.12,
|
||||
seed: u64::from(p.seed),
|
||||
scale: 1.0 / p.pixel_scale.clamp(0.01, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Develop's per-pixel stages for one image and settings, in order. `None` stages are off.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PerPixel {
|
||||
/// 1. White balance and exposure gains in linear light (sRGB-decoded values).
|
||||
pub gains: Option<[f32; 3]>,
|
||||
/// 3. Contrast's strength (Contrast without Texture or Clarity is per pixel).
|
||||
pub contrast: Option<f32>,
|
||||
/// 3b. Whites and Blacks, in the document's encoding.
|
||||
pub whites_blacks: Option<WhitesBlacks>,
|
||||
/// The document's transfer curve: 0 for sRGB's, else a power-law gamma.
|
||||
pub encoding: f32,
|
||||
/// 4. Vibrance and Saturation (slider / 100).
|
||||
pub presence: Option<[f32; 2]>,
|
||||
/// 5. The parametric tone curve, [`CURVE_LUT`] entries over 0 … 1.
|
||||
pub parametric: Option<Vec<f32>>,
|
||||
/// 5. Point curves: master, red, green, blue.
|
||||
pub point: [Option<Vec<f32>>; 4],
|
||||
/// 6. Color Mixer hue, saturation and luminance per band.
|
||||
pub hsl: Option<[[f32; 8]; 3]>,
|
||||
/// 7. Color Grading.
|
||||
pub grade: Option<Grade>,
|
||||
/// 9. Vignette.
|
||||
pub vignette: Option<Vignette>,
|
||||
/// 9. Grain.
|
||||
pub grain: Option<Grain>,
|
||||
/// Clip RGB to 0 … 1 at the end (all but 32-bit documents).
|
||||
pub clamp: bool,
|
||||
}
|
||||
|
||||
impl CameraRaw {
|
||||
/// Whether developing needs a stage that reads neighbouring pixels: Dehaze, Shadows and
|
||||
/// Highlights, Texture, Clarity, sharpening or noise reduction.
|
||||
pub fn spatial(&self) -> bool {
|
||||
self.dehaze != 0.0
|
||||
|| [self.highlights, self.shadows, self.texture, self.clarity].iter().any(|v| *v != 0.0)
|
||||
|| self.sharpen_amount > 0.0
|
||||
|| self.noise_luminance > 0.0
|
||||
|| self.noise_color > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The image's black and white points that Whites and Blacks adapt to, for `px` (`w × h`,
|
||||
/// display-encoded) developed per pixel with `p`: what [`develop`](super::develop) measures
|
||||
/// after white balance and Contrast. `None` when Whites and Blacks are 0 or `p` needs a
|
||||
/// spatial stage.
|
||||
pub fn white_black_points(px: &[[f32; 4]], w: usize, h: usize, p: &CameraRaw) -> Option<(f32, f32)> {
|
||||
if (p.whites == 0.0 && p.blacks == 0.0) || p.spatial() || px.len() != w.saturating_mul(h) {
|
||||
return None;
|
||||
}
|
||||
let enc = Encoding::of(p.encoding_gamma);
|
||||
let (g, k_con) = (gains(p), contrast(p));
|
||||
Some(super::luminance_points(px, w, h, enc, |q| {
|
||||
let q = g.map_or(q, |g| super::balance(q, g));
|
||||
k_con.map_or(q, |k| super::contrast(q, k))
|
||||
}))
|
||||
}
|
||||
|
||||
/// Develop's stages for settings without spatial stages, on a `w × h` image; `points` are the
|
||||
/// image's [`white_black_points`]. `None` when `p` needs a spatial stage or Calibration (these
|
||||
/// stages don't run it), or Whites or Blacks is set without `points`. The points themselves
|
||||
/// leave Calibration out, so a caller may cache them across Calibration changes.
|
||||
pub fn per_pixel(p: &CameraRaw, w: usize, h: usize, float: bool, points: Option<(f32, f32)>) -> Option<PerPixel> {
|
||||
if p.spatial() || !p.calibration_neutral() {
|
||||
return None;
|
||||
}
|
||||
let identity = p.is_identity();
|
||||
let whites_blacks = if p.whites != 0.0 || p.blacks != 0.0 {
|
||||
let (bp, wp) = points?;
|
||||
let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp);
|
||||
(xw != 0.0 || xb != 0.0).then(|| WhitesBlacks {
|
||||
whites: (xw != 0.0).then(|| whites_blacks::whites_row(xw)),
|
||||
blacks: (xb != 0.0).then(|| whites_blacks::blacks_row(xb)),
|
||||
per_channel: xw > 0.0 || xb < 0.0,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let parametric_on = !(p.curve_highlights == 0.0 && p.curve_lights == 0.0 && p.curve_darks == 0.0 && p.curve_shadows == 0.0);
|
||||
let pts = |c: &Vec<[f32; 2]>| (!is_linear(c)).then(|| curve_lut(c, CURVE_LUT));
|
||||
Some(PerPixel {
|
||||
gains: gains(p),
|
||||
contrast: contrast(p),
|
||||
whites_blacks,
|
||||
encoding: Encoding::of(p.encoding_gamma).0,
|
||||
presence: (p.vibrance != 0.0 || p.saturation != 0.0).then_some([p.vibrance / 100.0, p.saturation / 100.0]),
|
||||
parametric: parametric_on.then(|| parametric_lut(p, CURVE_LUT)),
|
||||
point: [pts(&p.point_curve), pts(&p.point_curve_red), pts(&p.point_curve_green), pts(&p.point_curve_blue)],
|
||||
hsl: (!p.hsl_neutral()).then_some([p.hsl_hue, p.hsl_sat, p.hsl_lum]),
|
||||
grade: (!p.grade_neutral()).then(|| Grade::new(p)),
|
||||
vignette: (p.vignette_amount != 0.0).then(|| Vignette::new(w, h, p)),
|
||||
grain: (p.grain_amount > 0.0).then(|| Grain::new(p)),
|
||||
clamp: !float && !identity,
|
||||
})
|
||||
}
|
||||
@@ -24,7 +24,7 @@ use super::Encoding;
|
||||
use crate::photo_util::par_rows;
|
||||
|
||||
/// Grey levels the curves are sampled at: 0, 1/32, …, 1 (sRGB-encoded).
|
||||
const STEPS: usize = 33;
|
||||
pub(crate) const STEPS: usize = 33;
|
||||
/// The first row's internal amount; rows are 10 apart.
|
||||
const WHITES_FROM: f32 = -100.0;
|
||||
const BLACKS_FROM: f32 = -100.0;
|
||||
@@ -315,6 +315,25 @@ fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 {
|
||||
at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t
|
||||
}
|
||||
|
||||
/// The curve at a fractional internal amount `x` as one row: `curve(rows, from, x, v)` is this
|
||||
/// row interpolated at `v`, because both interpolations are linear.
|
||||
fn row_at(rows: &[[f32; STEPS]], from: f32, x: f32) -> [f32; STEPS] {
|
||||
let r = ((x - from) / 10.0).clamp(0.0, (rows.len() - 1) as f32);
|
||||
let i = (r as usize).min(rows.len() - 2);
|
||||
let t = r - i as f32;
|
||||
std::array::from_fn(|j| rows[i][j] * (1.0 - t) + rows[i + 1][j] * t)
|
||||
}
|
||||
|
||||
/// The Whites curve at internal amount `x` as a row over grey levels 0, 1/32, …, 1.
|
||||
pub(crate) fn whites_row(x: f32) -> [f32; STEPS] {
|
||||
row_at(&WHITES, WHITES_FROM, x)
|
||||
}
|
||||
|
||||
/// The Blacks curve at internal amount `x` as a row over grey levels 0, 1/32, …, 1.
|
||||
pub(crate) fn blacks_row(x: f32) -> [f32; STEPS] {
|
||||
row_at(&BLACKS, BLACKS_FROM, x)
|
||||
}
|
||||
|
||||
/// Whites at internal amount `x` (−100 … 270) of grey level `v`.
|
||||
pub(crate) fn whites(v: f32, x: f32) -> f32 {
|
||||
curve(&WHITES, WHITES_FROM, x, v)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod art_history;
|
||||
mod artistic;
|
||||
mod artistic_fx;
|
||||
mod blur;
|
||||
|
||||
+48
-18
@@ -5,6 +5,14 @@ use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Surface, from_rgba_into};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How a fill lays down its colour: blended with a mode, or Clear, which erases what it covers
|
||||
/// (the Paint Bucket's Mode › Clear).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FillMode {
|
||||
Blend(BlendMode),
|
||||
Clear,
|
||||
}
|
||||
|
||||
/// Gradient tool shape.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -59,7 +67,7 @@ pub fn sample_stops(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] {
|
||||
fn composite_area(
|
||||
s: &mut Surface,
|
||||
area: Rect,
|
||||
blend_mode: BlendMode,
|
||||
mode: FillMode,
|
||||
coverage: impl Fn(i32, i32) -> f32 + Sync,
|
||||
color: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
lock_transparency: bool,
|
||||
@@ -89,12 +97,18 @@ fn composite_area(
|
||||
let i = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n;
|
||||
let px = &mut region[i..i + n];
|
||||
let d = photocraft_raster::to_rgba(&fmt, px);
|
||||
let o = if lock_transparency { blend::composite_atop(blend_mode, d, color(x, y), k) } else { blend::composite(blend_mode, d, color(x, y), k) };
|
||||
let o = match mode {
|
||||
FillMode::Blend(m) if lock_transparency => blend::composite_atop(m, d, color(x, y), k),
|
||||
FillMode::Blend(m) => blend::composite(m, d, color(x, y), k),
|
||||
// Locked transparency keeps the alpha, so Clear leaves the pixel as it is.
|
||||
FillMode::Clear if lock_transparency => d,
|
||||
FillMode::Clear => [d[0], d[1], d[2], d[3] * (1.0 - k.min(1.0))],
|
||||
};
|
||||
from_rgba_into(&fmt, o, &mut enc);
|
||||
// A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is.
|
||||
if let Some(i) = inks
|
||||
&& fmt.mode == photocraft_color::ColorMode::Cmyk
|
||||
&& blend_mode == BlendMode::Normal
|
||||
&& mode == FillMode::Blend(BlendMode::Normal)
|
||||
&& k >= 1.0
|
||||
{
|
||||
enc[..4].copy_from_slice(&i);
|
||||
@@ -135,7 +149,7 @@ pub fn paint_gradient(
|
||||
composite_area(
|
||||
s,
|
||||
area,
|
||||
blend_mode,
|
||||
FillMode::Blend(blend_mode),
|
||||
|x, y| opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
|
||||
|x, y| {
|
||||
let t = tool_gradient_t(shape, from, to, x as f32 + 0.5, y as f32 + 0.5);
|
||||
@@ -187,11 +201,12 @@ pub fn bucket_fill_src(
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
) -> bool {
|
||||
bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None)
|
||||
bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None, FillMode::Blend(BlendMode::Normal))
|
||||
}
|
||||
|
||||
/// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target
|
||||
/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK).
|
||||
/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK), laid down with `mode`
|
||||
/// (the options bar's Mode).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn bucket_fill_inks(
|
||||
s: &mut Surface,
|
||||
@@ -205,6 +220,7 @@ pub fn bucket_fill_inks(
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
inks: Option<[f32; 4]>,
|
||||
mode: FillMode,
|
||||
) -> bool {
|
||||
if !area.contains(seed.0, seed.1) {
|
||||
return false;
|
||||
@@ -212,7 +228,7 @@ pub fn bucket_fill_inks(
|
||||
let img = crate::selection::rgba8_image(s, area);
|
||||
let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false };
|
||||
drop(img);
|
||||
bucket_fill_region_inks(s, ®ion, opacity, selection, lock_transparency, src, inks);
|
||||
bucket_fill_region_inks(s, ®ion, opacity, selection, lock_transparency, src, inks, mode);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -226,10 +242,11 @@ pub fn bucket_fill_region_src(
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
) {
|
||||
bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None);
|
||||
bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None, FillMode::Blend(BlendMode::Normal));
|
||||
}
|
||||
|
||||
/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`].
|
||||
/// [`bucket_fill_region_src`] with the CMYK `inks` and the `mode` of [`bucket_fill_inks`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn bucket_fill_region_inks(
|
||||
s: &mut Surface,
|
||||
region: &crate::selection::Region,
|
||||
@@ -238,18 +255,11 @@ pub fn bucket_fill_region_inks(
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
inks: Option<[f32; 4]>,
|
||||
mode: FillMode,
|
||||
) {
|
||||
// Composite only over the filled region's box.
|
||||
let b = region.bbox;
|
||||
composite_area(
|
||||
s,
|
||||
b,
|
||||
BlendMode::Normal,
|
||||
|x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
|
||||
src,
|
||||
lock_transparency,
|
||||
inks,
|
||||
);
|
||||
composite_area(s, b, mode, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency, inks);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -333,4 +343,24 @@ mod tests {
|
||||
assert_eq!(s.rgba(-1, 4), [0.0; 4], "outside the selection");
|
||||
assert_eq!(s.rgba(-4, 4), [0.0; 4], "outside the region");
|
||||
}
|
||||
|
||||
/// The Paint Bucket's Mode: Multiply darkens what's there, Clear erases it (half at 50 %).
|
||||
#[test]
|
||||
fn bucket_mode_blends_or_clears() {
|
||||
let a = Rect::new(0, 0, 4, 1);
|
||||
let grey = [0.5, 0.5, 0.5, 1.0];
|
||||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
let mut s = Surface::new(fmt);
|
||||
s.fill_rect(a, &grey);
|
||||
let red = |_, _| [1.0, 0.0, 0.0, 1.0];
|
||||
assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 1.0, None, false, red, None, FillMode::Blend(BlendMode::Multiply)));
|
||||
let px = s.rgba(1, 0);
|
||||
assert!((px[0] - 0.5).abs() < 0.01 && px[1] < 0.01 && (px[3] - 1.0).abs() < 1e-3, "{fmt:?} multiply: {px:?}");
|
||||
assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 0.5, None, false, red, None, FillMode::Clear));
|
||||
assert!((s.rgba(1, 0)[3] - 0.5).abs() < 0.01, "{fmt:?} clear at 50 %");
|
||||
// Locked transparency keeps the alpha, so Clear does nothing.
|
||||
assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 1.0, None, true, red, None, FillMode::Clear));
|
||||
assert!((s.rgba(1, 0)[3] - 0.5).abs() < 0.01, "{fmt:?} locked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+627
-230
File diff suppressed because it is too large
Load Diff
+147
-18
@@ -324,33 +324,114 @@ pub fn paste(scene: &Scene, i: usize, src: &Surface, src_rect: Rect, at: [f64; 2
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// The fixed frame of one clone stroke: the source and destination planes, the source offset in
|
||||
/// plane-metric units and the brush radius, all set at the stroke's anchor (its first dab).
|
||||
struct CloneFrame {
|
||||
hs: Homography,
|
||||
ls: (f64, f64),
|
||||
hd: Homography,
|
||||
hd_inv: Homography,
|
||||
ld: (f64, f64),
|
||||
offset: [f64; 2],
|
||||
r_m: f64,
|
||||
}
|
||||
|
||||
impl CloneFrame {
|
||||
fn new(scene: &Scene, source: [f64; 2], anchor: [f64; 2], size: f64) -> Option<CloneFrame> {
|
||||
let (ps, pd) = (scene.plane_at(source)?, scene.plane_at(anchor)?);
|
||||
let ((hs, lus, lvs), (hd, lud, lvd)) = (scene.frame(ps)?, scene.frame(pd)?);
|
||||
let (hs_inv, hd_inv) = (hs.inverse()?, hd.inverse()?);
|
||||
let ms = metric(&hs_inv, (lus, lvs), source);
|
||||
let md0 = metric(&hd_inv, (lud, lvd), anchor);
|
||||
// Brush radius in metric units (from the image size at the anchor).
|
||||
let probe = metric(&hd_inv, (lud, lvd), [anchor[0] + size / 2.0, anchor[1]]);
|
||||
let r_m = ((probe[0] - md0[0]).hypot(probe[1] - md0[1])).max(1e-9);
|
||||
let offset = [ms[0] - md0[0], ms[1] - md0[1]];
|
||||
(offset.iter().all(|v| v.is_finite()) && r_m.is_finite()).then_some(CloneFrame { hs, ls: (lus, lvs), hd, hd_inv, ld: (lud, lvd), offset, r_m })
|
||||
}
|
||||
|
||||
/// The source point (image px) that destination `p` copies from.
|
||||
fn source_of(&self, p: [f64; 2]) -> [f64; 2] {
|
||||
let m = metric(&self.hd_inv, self.ld, p);
|
||||
let (x, y) = self.hs.apply((m[0] + self.offset[0]) / self.ls.0, (m[1] + self.offset[1]) / self.ls.1);
|
||||
[x, y]
|
||||
}
|
||||
}
|
||||
|
||||
/// Image point → plane-metric coordinates through the plane's inverse homography.
|
||||
fn metric(inv: &Homography, l: (f64, f64), p: [f64; 2]) -> [f64; 2] {
|
||||
let (u, v) = inv.apply(p[0], p[1]);
|
||||
[u * l.0, v * l.1]
|
||||
}
|
||||
|
||||
/// Perspective clone stamp: one stroke of dabs at image points `points` on the destination
|
||||
/// plane, copying from `source` (image px) with the offset fixed in plane-metric units at the
|
||||
/// first dab. `size` is the brush diameter in px at the first dab; `hardness` 0..=1.
|
||||
pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: &[[f64; 2]], size: f64, hardness: f64, opacity: f32) -> usize {
|
||||
let Some(first) = points.first() else { return 0 };
|
||||
let (Some(ps), Some(pd)) = (scene.plane_at(source), scene.plane_at(*first)) else { return 0 };
|
||||
let (Some((hs, lus, lvs)), Some((hd, lud, lvd))) = (scene.frame(ps), scene.frame(pd)) else { return 0 };
|
||||
let (Some(hs_inv), Some(hd_inv)) = (hs.inverse(), hd.inverse()) else { return 0 };
|
||||
let metric = |inv: &Homography, lu: f64, lv: f64, p: [f64; 2]| {
|
||||
let (u, v) = inv.apply(p[0], p[1]);
|
||||
[u * lu, v * lv]
|
||||
};
|
||||
let ms = metric(&hs_inv, lus, lvs, source);
|
||||
let md0 = metric(&hd_inv, lud, lvd, *first);
|
||||
let offset = [ms[0] - md0[0], ms[1] - md0[1]];
|
||||
// Brush radius in metric units (from the image size at the first dab).
|
||||
let probe = metric(&hd_inv, lud, lvd, [first[0] + size / 2.0, first[1]]);
|
||||
let r_m = ((probe[0] - md0[0]).hypot(probe[1] - md0[1])).max(1e-9);
|
||||
clone_dabs(scene, surf, source, *first, points, size, hardness, opacity, None).0
|
||||
}
|
||||
|
||||
/// Where a clone stroke that starts at `anchor` copying from `source` samples for destination
|
||||
/// `p`: the same plane-metric offset. A live preview paints a stroke in pieces with it, and
|
||||
/// Aligned strokes keep the first stroke's offset. `None` when `source` or `anchor` is off the
|
||||
/// planes.
|
||||
pub fn clone_source(scene: &Scene, source: [f64; 2], anchor: [f64; 2], p: [f64; 2]) -> Option<[f64; 2]> {
|
||||
let s = CloneFrame::new(scene, source, anchor, 1.0)?.source_of(p);
|
||||
s.iter().all(|v| v.is_finite()).then_some(s)
|
||||
}
|
||||
|
||||
/// [`clone_stroke`]'s dabs at `points` for a stroke anchored at `anchor` (its first dab, which
|
||||
/// fixes the offset and the brush's metric size). Samples a snapshot taken at this call.
|
||||
/// For incremental painting use [`clone_dabs_from`] with one snapshot per stroke.
|
||||
/// With `clip`, every dab is cut to it before any read or write (a document passes its canvas,
|
||||
/// so far off-canvas points allocate no tiles). Returns the dabs painted and the area they changed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn clone_dabs(
|
||||
scene: &Scene,
|
||||
surf: &mut Surface,
|
||||
source: [f64; 2],
|
||||
anchor: [f64; 2],
|
||||
points: &[[f64; 2]],
|
||||
size: f64,
|
||||
hardness: f64,
|
||||
opacity: f32,
|
||||
clip: Option<Rect>,
|
||||
) -> (usize, Rect) {
|
||||
let src_copy = surf.clone();
|
||||
clone_dabs_from(scene, surf, &src_copy, source, anchor, points, size, hardness, opacity, clip)
|
||||
}
|
||||
|
||||
/// Paint a batch of one stroke using the same immutable source snapshot for every batch.
|
||||
/// Source and destination must have the same pixel format; otherwise no dabs are painted.
|
||||
/// `clip` as in [`clone_dabs`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn clone_dabs_from(
|
||||
scene: &Scene,
|
||||
surf: &mut Surface,
|
||||
src_copy: &Surface,
|
||||
source: [f64; 2],
|
||||
anchor: [f64; 2],
|
||||
points: &[[f64; 2]],
|
||||
size: f64,
|
||||
hardness: f64,
|
||||
opacity: f32,
|
||||
clip: Option<Rect>,
|
||||
) -> (usize, Rect) {
|
||||
if surf.format() != src_copy.format() {
|
||||
return (0, Rect::EMPTY);
|
||||
}
|
||||
let Some(f) = CloneFrame::new(scene, source, anchor, size) else { return (0, Rect::EMPTY) };
|
||||
let CloneFrame { hs, ls: (lus, lvs), hd, hd_inv, ld: (lud, lvd), offset, r_m } = f;
|
||||
let fmt = surf.format();
|
||||
let n = fmt.channels();
|
||||
// Scratch for `sample`: the dab's source pixels, the four bilinear corners, the result.
|
||||
let mut win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
|
||||
let (mut corners, mut s) = (vec![0.0f32; 4 * n], vec![0.0f32; n]);
|
||||
let mut dabs = 0;
|
||||
let mut changed_area = Rect::EMPTY;
|
||||
for p in points {
|
||||
let mc = metric(&hd_inv, lud, lvd, *p);
|
||||
let mc = metric(&hd_inv, (lud, lvd), *p);
|
||||
// Image-space footprint: map a metric circle's bounding points.
|
||||
let footprint = |h: &Homography, lu: f64, lv: f64, c: [f64; 2]| {
|
||||
let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||||
@@ -359,12 +440,19 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
|
||||
let (x, y) = h.apply((c[0] + r_m * a.cos()) / lu, (c[1] + r_m * a.sin()) / lv);
|
||||
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)];
|
||||
}
|
||||
if bb.iter().any(|v| !v.is_finite()) {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1))
|
||||
};
|
||||
let area = footprint(&hd, lud, lvd, mc);
|
||||
if area.is_empty() || area.width() > 4096 || area.height() > 4096 {
|
||||
continue;
|
||||
}
|
||||
let area = clip.map_or(area, |c| area.intersect(&c));
|
||||
if area.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The source footprint, read once; `sample` reads any pixel outside it from the surface.
|
||||
// Skipped when it dwarfs the destination (cloning from near to far in strong perspective):
|
||||
// decoding it would cost more than the few per-pixel reads it saves.
|
||||
@@ -385,9 +473,9 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
|
||||
let mut changed = false;
|
||||
for (i, px) in data.chunks_exact_mut(n).enumerate() {
|
||||
let (x, y) = (area.x0 as f64 + (i % w) as f64 + 0.5, area.y0 as f64 + (i / w) as f64 + 0.5);
|
||||
let m = metric(&hd_inv, lud, lvd, [x, y]);
|
||||
let m = metric(&hd_inv, (lud, lvd), [x, y]);
|
||||
let d = (m[0] - mc[0]).hypot(m[1] - mc[1]) / r_m;
|
||||
if d >= 1.0 {
|
||||
if d >= 1.0 || d.is_nan() {
|
||||
continue;
|
||||
}
|
||||
let hard = hardness.clamp(0.0, 0.99);
|
||||
@@ -396,7 +484,7 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
|
||||
// Source pixel at the same metric offset.
|
||||
let sm = [m[0] + offset[0], m[1] + offset[1]];
|
||||
let (sx, sy) = hs.apply(sm[0] / lus, sm[1] / lvs);
|
||||
sample(&src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s);
|
||||
sample(src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s);
|
||||
for c in 0..n {
|
||||
px[c] += (s[c] - px[c]) * k;
|
||||
}
|
||||
@@ -405,9 +493,10 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
|
||||
if changed {
|
||||
surf.write_region(area, &data);
|
||||
dabs += 1;
|
||||
changed_area = if changed_area.is_empty() { area } else { changed_area.union(&area) };
|
||||
}
|
||||
}
|
||||
dabs
|
||||
(dabs, changed_area)
|
||||
}
|
||||
|
||||
/// Pixels of `area` read from the source surface (`read_region` layout).
|
||||
@@ -421,6 +510,10 @@ struct Window {
|
||||
/// read from `s`.
|
||||
fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut Vec<f32>, out: &mut [f32]) {
|
||||
let n = out.len();
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
out.fill(0.0);
|
||||
return;
|
||||
}
|
||||
let (x0, y0) = (x.floor() as i32, y.floor() as i32);
|
||||
let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
|
||||
let (x1, y1) = (x0.saturating_add(1), y0.saturating_add(1));
|
||||
@@ -537,6 +630,42 @@ mod tests {
|
||||
let n = clone_stroke(&sc, &mut s, [sx, sy], &[[dx, dy]], 10.0, 0.8, 1.0);
|
||||
assert_eq!(n, 1);
|
||||
assert!(s.rgba(dx as i32, dy as i32)[0] > 0.8, "{:?}", s.rgba(dx as i32, dy as i32));
|
||||
// The stroke's anchor copies from the source itself.
|
||||
let back = clone_source(&sc, [sx, sy], [dx, dy], [dx, dy]).unwrap();
|
||||
assert!((back[0] - sx).abs() < 1e-6 && (back[1] - sy).abs() < 1e-6, "{back:?}");
|
||||
}
|
||||
#[test]
|
||||
fn incremental_clone_uses_one_source_snapshot_per_stroke() {
|
||||
let sc = Scene::new(VpPlane { corners: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]] }, [50.0, 50.0], None, 800.0);
|
||||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
let mut original = Surface::new(fmt);
|
||||
for x in 0..100 {
|
||||
original.fill_rect(Rect::new(x, 0, x + 1, 100), &[x as f32 / 100.0, (x % 7) as f32 / 6.0, 0.3, 1.0]);
|
||||
}
|
||||
let points = [[40.0, 50.0], [44.0, 50.0], [48.0, 50.0], [52.0, 50.0]];
|
||||
let mut whole = original.clone();
|
||||
assert_eq!(clone_stroke(&sc, &mut whole, [36.0, 50.0], &points, 12.0, 0.8, 1.0), 4);
|
||||
let mut pieces = original.clone();
|
||||
for part in points.chunks(2) {
|
||||
clone_dabs_from(&sc, &mut pieces, &original, [36.0, 50.0], points[0], part, 12.0, 0.8, 1.0, None);
|
||||
}
|
||||
assert_eq!(whole.read_region(Rect::new(0, 0, 100, 100)), pieces.read_region(Rect::new(0, 0, 100, 100)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip cuts every dab before it reads or writes: dabs far outside it allocate no tiles.
|
||||
#[test]
|
||||
fn clipped_clone_allocates_no_tiles_outside_the_clip() {
|
||||
let sc = Scene::new(VpPlane { corners: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]] }, [50.0, 50.0], None, 800.0);
|
||||
let clip = Rect::new(0, 0, 100, 100);
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(clip, &[0.5, 0.5, 0.5, 1.0]);
|
||||
let tiles = s.tiles().count();
|
||||
let points = [[50.0, 50.0], [-40.0, 50.0], [5000.0, 5000.0], [-90_000.0, 70_000.0]];
|
||||
let (dabs, area) = clone_dabs(&sc, &mut s, [40.0, 50.0], points[0], &points, 20.0, 0.8, 1.0, Some(clip));
|
||||
assert_eq!(dabs, 1);
|
||||
assert!(clip.contains_rect(&area), "{area:?}");
|
||||
assert_eq!(s.tiles().count(), tiles);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -329,7 +329,7 @@ pub struct MenuParams {
|
||||
pub struct UiSetParams {
|
||||
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel
|
||||
/// ({background: bool} picks which swatch the Color panel edits), maskTarget,
|
||||
/// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
|
||||
/// vectorMaskTarget, filterMaskTarget (paint the active smart object's filter mask), selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
|
||||
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab,
|
||||
/// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it),
|
||||
/// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one
|
||||
@@ -350,8 +350,14 @@ pub struct UiSetParams {
|
||||
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
|
||||
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
|
||||
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
|
||||
/// over this channel). updatesOpen (bool, the Help › Check for Updates window). Other fields
|
||||
/// are an error.
|
||||
/// over this channel). updatesOpen (bool, the Help › Check for Updates window).
|
||||
/// histogramPanel (an object patching view ("compact", "expanded" or "allChannels"), channel
|
||||
/// ("composite", "red", "green", "blue", "cyan", "magenta", "yellow", "black", "a", "b",
|
||||
/// "luminosity" or "colors"), showChannelsInColor, showStatistics and source ("entireImage",
|
||||
/// "selectedLayer" or "adjustmentComposite") for the Histogram panel), histogramRange ([from,
|
||||
/// to] levels 0..255 whose Count and Percentile the panel shows, or null) and histogramRefresh
|
||||
/// (true: an Uncached Refresh); `ui.inspect` reports them and the statistics under
|
||||
/// `histogram`. Other fields are an error.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
|
||||
+13
-3
@@ -224,8 +224,14 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
||||
planar configuration 1 or 2.
|
||||
* Supports 1, 2 and 4-bit gray, 8/16/32/64-bit integers (32 and 64-bit are reduced to U16),
|
||||
F16, F32 and F64 (converted to F32), WhiteIsZero (#691: the tones are no longer inverted),
|
||||
palette images (opened as RGB), associated alpha (made straight) and extra samples (dropped
|
||||
unless the first is alpha).
|
||||
palette images (opened as RGB) and associated alpha (made straight).
|
||||
* Extra samples: the first alpha sample (ExtraSamples 1 or 2, wherever it sits) is the image's
|
||||
alpha. Every other extra sample, such as a spot colour or a further alpha channel
|
||||
(ExtraSamples 0), comes back in `Metadata::extra_samples`: one plane per sample in the
|
||||
image's sample type, with its ExtraSamples value as `kind` (a reserved value above 2 reads as
|
||||
0). At most `MAX_EXTRA_SAMPLES` (16) are kept, and they count against `Limits::max_alloc` with
|
||||
the image: when the image fits but its extra samples don't, it opens without them and carries
|
||||
`DecodeWarning::ExtraSamplesDropped`.
|
||||
* Every directory is reachable: `tiff_info` lists the main IFD chain and its SubIFDs (tag 330)
|
||||
with each page's size, kind (page, reduced-resolution copy, mask) and storage, walked with a
|
||||
seen-set, a depth limit and a 10 000-directory cap; `decode_tiff_page` opens any of them.
|
||||
@@ -239,7 +245,11 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
||||
* YCbCr, CIE Lab, signed integers and compressions that the `tiff` crate lacks (CCITT,
|
||||
JPEG-in-TIFF) return `Unsupported`.
|
||||
* **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or
|
||||
the file could pass 4 GiB.
|
||||
the file could pass 4 GiB. `Metadata::extra_samples` (up to 16) are written after the colour
|
||||
and alpha samples, with their `kind` in the ExtraSamples tag (a reserved `kind` above 2 is
|
||||
written as 0). Each plane must be exactly
|
||||
`width × height × sample.bytes()` bytes; a plane of any other length, and more than 16 planes,
|
||||
are encode errors rather than silently truncated or reinterpreted.
|
||||
* **EXR**
|
||||
* Multi-part files (a Maya/Arnold render writes one part per AOV) open the part that
|
||||
looks most like a colour image — RGBA beats RGB beats YA beats a single channel, so a
|
||||
|
||||
@@ -249,7 +249,9 @@ pub(crate) fn decode_page(bytes: &[u8], page: Option<usize>, limits: &Limits) ->
|
||||
.ok_or_else(|| CodecError::InvalidImage(format!("TIFF page {index} does not exist (the file has {} directories)", info.pages.len())))?;
|
||||
let dir = file.ifd(p.ifd_offset).ok_or_else(|| err("image directory cut off"))?;
|
||||
let mut img = decode_ifd(&file, &dir, limits)?;
|
||||
let extra_samples = std::mem::take(&mut img.meta.extra_samples);
|
||||
img.meta = metadata(&file, &dir, &mut img.icc);
|
||||
img.meta.extra_samples = extra_samples;
|
||||
let pages = info.page_count();
|
||||
if pages > 1 {
|
||||
img.warnings.push(DecodeWarning::MorePages { total: info.complete.then_some(u32::try_from(pages).unwrap_or(u32::MAX)) });
|
||||
@@ -423,6 +425,8 @@ struct Layout {
|
||||
palette: Option<Vec<[u16; 3]>>,
|
||||
/// Upper bound for one chunk's scratch buffer.
|
||||
scratch_cap: usize,
|
||||
/// File sample indices that are neither colour nor the chosen alpha (spot / extra alpha).
|
||||
extra_at: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
@@ -436,6 +440,9 @@ impl Layout {
|
||||
fn out_row(&self) -> usize {
|
||||
self.w * self.out_channels() * self.out.bytes()
|
||||
}
|
||||
fn extra_row(&self) -> usize {
|
||||
self.w.saturating_mul(self.extra_at.len()).saturating_mul(self.out.bytes())
|
||||
}
|
||||
/// A chunky strip already in the output's layout: decoded in place.
|
||||
fn direct(&self) -> bool {
|
||||
self.copyable() && self.across == 1
|
||||
@@ -543,13 +550,16 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
// returns the alpha only when it is the first extra sample: otherwise it is asked for every
|
||||
// sample (as multiband gray) and the colour and alpha samples are picked here. An alpha
|
||||
// position past the samples the file has is ignored.
|
||||
let color = match photometric {
|
||||
let color: usize = match photometric {
|
||||
2 | 8 | 9 => 3,
|
||||
5 => 4,
|
||||
_ => 1,
|
||||
};
|
||||
let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp);
|
||||
let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0);
|
||||
let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color as u64).saturating_add(a as u64) < spp);
|
||||
// Samples the colour model does not keep. RGB, CMYK and Lab only hand back every sample when
|
||||
// asked (photometric patched to black-is-zero below); gray already comes back as multiband.
|
||||
let unused = !extra.is_empty() && (spp as usize) > color.saturating_add(usize::from(alpha_at.is_some()));
|
||||
let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && (alpha_at.is_some_and(|a| a > 0) || (!planar && unused));
|
||||
let mut patches: Vec<(u64, Vec<u8>)> = Vec::new();
|
||||
let encode_u = |v: u64, len: usize| -> Vec<u8> {
|
||||
let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() };
|
||||
@@ -592,7 +602,31 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
_ if palette.is_some() => (ChannelLayout::Rgb, 1),
|
||||
tiff::ColorType::Gray(_) => (ChannelLayout::Gray, 1),
|
||||
tiff::ColorType::Multiband { num_samples, .. } if all_samples => {
|
||||
(if photometric == 5 { ChannelLayout::CmykA } else { ChannelLayout::Rgba }, usize::from(num_samples))
|
||||
// Lab reads as RGB here and is relabelled below.
|
||||
let layout = match photometric {
|
||||
2 | 8 | 9 => {
|
||||
if alpha_at.is_some() {
|
||||
ChannelLayout::Rgba
|
||||
} else {
|
||||
ChannelLayout::Rgb
|
||||
}
|
||||
}
|
||||
5 => {
|
||||
if alpha_at.is_some() {
|
||||
ChannelLayout::CmykA
|
||||
} else {
|
||||
ChannelLayout::Cmyk
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if gray_alpha {
|
||||
ChannelLayout::GrayA
|
||||
} else {
|
||||
ChannelLayout::Gray
|
||||
}
|
||||
}
|
||||
};
|
||||
(layout, usize::from(num_samples))
|
||||
}
|
||||
tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => {
|
||||
(if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples))
|
||||
@@ -661,7 +695,8 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
.and_then(|s| s.checked_mul(src.bytes()))
|
||||
.ok_or_else(|| CodecError::LimitExceeded("tile size".into()))?;
|
||||
let scratch_cap = chunk_bytes.saturating_mul(2).max(64 << 20).min(limits.alloc_usize());
|
||||
let lay = Layout {
|
||||
let (extra_at, extras_over_limit) = extra_sample_indices(spp as usize, color, alpha_at, out_layout.has_alpha());
|
||||
let mut lay = Layout {
|
||||
w: wu,
|
||||
src,
|
||||
out,
|
||||
@@ -677,7 +712,19 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
premultiplied: out_layout.has_alpha() && alpha_at.and_then(|a| extra.get(a)) == Some(&1),
|
||||
palette,
|
||||
scratch_cap,
|
||||
extra_at,
|
||||
};
|
||||
// Extra samples share the allocation budget with the image: their interleaved buffer and the
|
||||
// planes split from it are held next to the image, so both count against `max_alloc`. When
|
||||
// they don't fit, the image still opens (it passed its own check above) without them.
|
||||
let mut extras_dropped = 0;
|
||||
if !lay.extra_at.is_empty() {
|
||||
let per_pixel = out_layout.channels().saturating_add(lay.extra_at.len().saturating_mul(2)).saturating_mul(out.bytes());
|
||||
if limits.check_bytes(w, h, per_pixel as u64).is_err() {
|
||||
extras_dropped = u32::try_from(lay.extra_at.len()).unwrap_or(u32::MAX);
|
||||
lay.extra_at.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// The output, allocated once and fallibly.
|
||||
let out_row = lay.out_row();
|
||||
@@ -685,7 +732,11 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
let mut data = zeroed(len)?;
|
||||
let band_bytes = out_row.checked_mul(ch.min(hu)).ok_or_else(|| CodecError::LimitExceeded("band size".into()))?;
|
||||
|
||||
let tally = decode_bands(&lay, &mut data, band_bytes, &mut dec, &open)?;
|
||||
let extra_row = lay.extra_row();
|
||||
let extra_len = extra_row.checked_mul(hu).ok_or_else(|| CodecError::LimitExceeded("extra samples".into()))?;
|
||||
let mut extra_data = if lay.extra_at.is_empty() { Vec::new() } else { zeroed(extra_len)? };
|
||||
let extra_band = extra_row.saturating_mul(ch.min(hu));
|
||||
let tally = decode_bands(&lay, &mut data, band_bytes, extra_data.as_mut_slice(), extra_band, &mut dec, &open)?;
|
||||
if tally.decoded == 0 && tally.missing > 0 {
|
||||
return Err(err("the image data is missing (cut off or past the end of the file)"));
|
||||
}
|
||||
@@ -693,6 +744,22 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
convert_ab(&mut data, out_layout.channels(), out, photometric == 8, false);
|
||||
}
|
||||
let mut img = Image::from_raw(w, h, out_layout, out, data)?;
|
||||
if !lay.extra_at.is_empty() {
|
||||
// TIFF 6.0 defines ExtraSamples 0, 1 and 2 only; a reserved value reads as unspecified,
|
||||
// so a save never writes it back.
|
||||
let kind_of = |si: usize| match si.checked_sub(color).and_then(|k| extra.get(k)).copied() {
|
||||
Some(v @ 0..=2) => v as u16,
|
||||
_ => 0,
|
||||
};
|
||||
img.meta.extra_samples = split_extra_samples(&extra_data, wu, hu, &lay.extra_at, out, kind_of)?;
|
||||
}
|
||||
if extras_over_limit > 0 {
|
||||
let count = u32::try_from(extras_over_limit).unwrap_or(u32::MAX);
|
||||
img.warnings.push(DecodeWarning::ExtraSamplesOverLimit { format: F, count });
|
||||
}
|
||||
if extras_dropped > 0 {
|
||||
img.warnings.push(DecodeWarning::ExtraSamplesDropped { format: F, count: extras_dropped });
|
||||
}
|
||||
if tally.missing > 0 {
|
||||
img.warnings.push(DecodeWarning::Truncated { format: F });
|
||||
}
|
||||
@@ -728,6 +795,55 @@ fn convert_ab(data: &mut [u8], channels: usize, sample: SampleType, signed: bool
|
||||
}
|
||||
}
|
||||
|
||||
/// File sample indices that are not a colour channel and not the one alpha sample, capped at
|
||||
/// [`crate::MAX_EXTRA_SAMPLES`], and how many past the cap were left out.
|
||||
fn extra_sample_indices(spp: usize, color: usize, alpha_at: Option<usize>, has_alpha: bool) -> (Vec<usize>, usize) {
|
||||
let mut used = vec![false; spp];
|
||||
for i in 0..color.min(spp) {
|
||||
if let Some(slot) = used.get_mut(i) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
if has_alpha {
|
||||
let a = alpha_at.map(|k| color.saturating_add(k)).unwrap_or(color);
|
||||
if let Some(slot) = used.get_mut(a) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
let mut all: Vec<usize> = used.into_iter().enumerate().filter(|(_, on)| !*on).map(|(i, _)| i).collect();
|
||||
let over = all.len().saturating_sub(crate::MAX_EXTRA_SAMPLES);
|
||||
all.truncate(crate::MAX_EXTRA_SAMPLES);
|
||||
(all, over)
|
||||
}
|
||||
|
||||
/// Interleaved extra samples (`extra_at.len()` per pixel) → one plane per sample. Each plane is
|
||||
/// allocated fallibly (the caller has checked their total against the limits).
|
||||
fn split_extra_samples(
|
||||
buf: &[u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
at: &[usize],
|
||||
sample: SampleType,
|
||||
mut kind_of: impl FnMut(usize) -> u16,
|
||||
) -> Result<Vec<crate::ExtraSample>, CodecError> {
|
||||
let b = sample.bytes();
|
||||
let n = w.saturating_mul(h);
|
||||
let spp = at.len();
|
||||
let mut out = Vec::with_capacity(spp);
|
||||
for (ei, &si) in at.iter().enumerate() {
|
||||
let mut data = zeroed(n.saturating_mul(b))?;
|
||||
for i in 0..n {
|
||||
let src = (i.saturating_mul(spp).saturating_add(ei)).saturating_mul(b);
|
||||
let dst = i.saturating_mul(b);
|
||||
if let (Some(s), Some(d)) = (buf.get(src..src.saturating_add(b)), data.get_mut(dst..dst.saturating_add(b))) {
|
||||
d.copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
out.push(crate::ExtraSample { kind: kind_of(si), data });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A zero-filled buffer, allocated fallibly. On native targets the zeroing (and the first
|
||||
/// touch of every page, which dominates on a fresh multi-hundred-megabyte buffer) is spread over
|
||||
/// all cores.
|
||||
@@ -760,20 +876,43 @@ impl std::ops::Add for Tally {
|
||||
}
|
||||
|
||||
/// Decodes every band into `data`, in parallel on native targets (one decoder and scratch
|
||||
/// buffer per worker).
|
||||
/// buffer per worker). `extra` receives samples that are not part of the colour image, one
|
||||
/// band of rows at a time, when it is non-empty.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn decode_bands<'a, O>(lay: &Layout, data: &mut [u8], band_bytes: usize, _first: &mut Decoder<Patched<'a>>, open: &O) -> Result<Tally, CodecError>
|
||||
fn decode_bands<'a, O>(
|
||||
lay: &Layout,
|
||||
data: &mut [u8],
|
||||
band_bytes: usize,
|
||||
extra: &mut [u8],
|
||||
extra_band: usize,
|
||||
_first: &mut Decoder<Patched<'a>>,
|
||||
open: &O,
|
||||
) -> Result<Tally, CodecError>
|
||||
where
|
||||
O: Fn() -> Result<Decoder<Patched<'a>>, CodecError> + Sync,
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
if extra.is_empty() {
|
||||
return data
|
||||
.par_chunks_mut(band_bytes.max(1))
|
||||
.enumerate()
|
||||
.map_init(
|
||||
|| open().map(|d| (d, Vec::new())).map_err(|e| e.to_string()),
|
||||
|worker, (b, band)| {
|
||||
let (dec, scratch) = worker.as_mut().map_err(|e| err(e.clone()))?;
|
||||
decode_band(lay, dec, scratch, b, band, None)
|
||||
},
|
||||
)
|
||||
.try_reduce(Tally::default, |a, b| Ok(a + b));
|
||||
}
|
||||
data.par_chunks_mut(band_bytes.max(1))
|
||||
.zip(extra.par_chunks_mut(extra_band.max(1)))
|
||||
.enumerate()
|
||||
.map_init(
|
||||
|| open().map(|d| (d, Vec::new())).map_err(|e| e.to_string()),
|
||||
|worker, (b, band)| {
|
||||
|worker, (b, (band, extra_band))| {
|
||||
let (dec, scratch) = worker.as_mut().map_err(|e| err(e.clone()))?;
|
||||
decode_band(lay, dec, scratch, b, band)
|
||||
decode_band(lay, dec, scratch, b, band, Some(extra_band))
|
||||
},
|
||||
)
|
||||
.try_reduce(Tally::default, |a, b| Ok(a + b))
|
||||
@@ -781,20 +920,41 @@ where
|
||||
|
||||
/// Decodes every band into `data`, one after the other.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn decode_bands<'a, O>(lay: &Layout, data: &mut [u8], band_bytes: usize, first: &mut Decoder<Patched<'a>>, _open: &O) -> Result<Tally, CodecError>
|
||||
fn decode_bands<'a, O>(
|
||||
lay: &Layout,
|
||||
data: &mut [u8],
|
||||
band_bytes: usize,
|
||||
extra: &mut [u8],
|
||||
extra_band: usize,
|
||||
first: &mut Decoder<Patched<'a>>,
|
||||
_open: &O,
|
||||
) -> Result<Tally, CodecError>
|
||||
where
|
||||
O: Fn() -> Result<Decoder<Patched<'a>>, CodecError> + Sync,
|
||||
{
|
||||
let mut scratch = Vec::new();
|
||||
let mut tally = Tally::default();
|
||||
for (b, band) in data.chunks_mut(band_bytes.max(1)).enumerate() {
|
||||
tally = tally + decode_band(lay, first, &mut scratch, b, band)?;
|
||||
if extra.is_empty() {
|
||||
for (b, band) in data.chunks_mut(band_bytes.max(1)).enumerate() {
|
||||
tally = tally + decode_band(lay, first, &mut scratch, b, band, None)?;
|
||||
}
|
||||
return Ok(tally);
|
||||
}
|
||||
for (b, (band, extra_band)) in data.chunks_mut(band_bytes.max(1)).zip(extra.chunks_mut(extra_band.max(1))).enumerate() {
|
||||
tally = tally + decode_band(lay, first, &mut scratch, b, band, Some(extra_band))?;
|
||||
}
|
||||
Ok(tally)
|
||||
}
|
||||
|
||||
/// Decodes band `b` (one strip, or one row of tiles, in every plane) into its output rows.
|
||||
fn decode_band(lay: &Layout, dec: &mut Decoder<Patched<'_>>, scratch: &mut Vec<u8>, b: usize, band: &mut [u8]) -> Result<Tally, CodecError> {
|
||||
fn decode_band(
|
||||
lay: &Layout,
|
||||
dec: &mut Decoder<Patched<'_>>,
|
||||
scratch: &mut Vec<u8>,
|
||||
b: usize,
|
||||
band: &mut [u8],
|
||||
mut extra: Option<&mut [u8]>,
|
||||
) -> Result<Tally, CodecError> {
|
||||
let out_row = lay.out_row();
|
||||
let rows = band.len() / out_row.max(1);
|
||||
let px = lay.out_channels() * lay.out.bytes();
|
||||
@@ -836,16 +996,95 @@ fn decode_band(lay: &Layout, dec: &mut Decoder<Patched<'_>>, scratch: &mut Vec<u
|
||||
}
|
||||
let x0 = col * lay.cw;
|
||||
let n = lay.cw.min(lay.w.saturating_sub(x0));
|
||||
for (src_row, dst_row) in buf.chunks_exact(stride).zip(band.chunks_exact_mut(out_row)).take(rows) {
|
||||
for (ri, (src_row, dst_row)) in buf.chunks_exact(stride).zip(band.chunks_exact_mut(out_row)).take(rows).enumerate() {
|
||||
let Some(dst) = dst_row.get_mut(x0 * px..(x0 + n) * px) else { continue };
|
||||
convert_row(lay, src_row, dst, n, plane);
|
||||
if !lay.planar && plane == 0 && !lay.extra_at.is_empty() {
|
||||
let erow = lay.extra_row();
|
||||
if let Some(extra) = extra.as_deref_mut()
|
||||
&& let Some(er) = extra.get_mut(ri.saturating_mul(erow)..ri.saturating_mul(erow).saturating_add(erow))
|
||||
{
|
||||
deposit_extras(lay, src_row, er, n, x0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if lay.planar && !lay.extra_at.is_empty() {
|
||||
read_planar_extras(lay, dec, scratch, b, rows, extra, &mut tally)?;
|
||||
}
|
||||
finish_band(lay, band);
|
||||
Ok(tally)
|
||||
}
|
||||
|
||||
/// Copies the non-colour samples of a chunky row into the extra-sample band.
|
||||
fn deposit_extras(lay: &Layout, src: &[u8], dst: &mut [u8], n: usize, x0: usize) {
|
||||
let ech = lay.extra_at.len();
|
||||
if ech == 0 {
|
||||
return;
|
||||
}
|
||||
let px = ech * lay.out.bytes();
|
||||
let Some(dst) = dst.get_mut(x0.saturating_mul(px)..(x0.saturating_add(n)).saturating_mul(px)) else { return };
|
||||
for (ei, &si) in lay.extra_at.iter().enumerate() {
|
||||
transfer(lay.src, src, lay.chunk_spp, si, dst, lay.out, ech, ei, n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Planar files store each extra sample as its own plane. Read those planes into `extra`.
|
||||
fn read_planar_extras(
|
||||
lay: &Layout,
|
||||
dec: &mut Decoder<Patched<'_>>,
|
||||
scratch: &mut Vec<u8>,
|
||||
b: usize,
|
||||
rows: usize,
|
||||
extra: Option<&mut [u8]>,
|
||||
tally: &mut Tally,
|
||||
) -> Result<(), CodecError> {
|
||||
let Some(extra) = extra else { return Ok(()) };
|
||||
let ech = lay.extra_at.len();
|
||||
let erow = lay.extra_row();
|
||||
if ech == 0 || erow == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
for (ei, &file_plane) in lay.extra_at.iter().enumerate() {
|
||||
for col in 0..lay.across {
|
||||
let chunk = file_plane
|
||||
.checked_mul(lay.chunks_per_plane)
|
||||
.and_then(|c| c.checked_add(b.checked_mul(lay.across)?))
|
||||
.and_then(|c| c.checked_add(col))
|
||||
.and_then(|c| u32::try_from(c).ok())
|
||||
.ok_or_else(|| err("chunk index out of range"))?;
|
||||
let pref = dec.image_chunk_buffer_layout(chunk).map_err(map_err)?;
|
||||
let stride = pref.row_stride.map(|s| s.get()).ok_or_else(|| err("empty chunk row"))?;
|
||||
if pref.len > lay.scratch_cap {
|
||||
return Err(CodecError::LimitExceeded(format!("a {} byte strip or tile", pref.len)));
|
||||
}
|
||||
if scratch.len() < pref.len {
|
||||
scratch.resize(pref.len, 0);
|
||||
}
|
||||
let buf = scratch.get_mut(..pref.len).ok_or_else(|| err("scratch buffer"))?;
|
||||
match dec.read_chunk_bytes(chunk, buf) {
|
||||
Ok(()) => tally.decoded += 1,
|
||||
Err(e) if is_eof(&e) => {
|
||||
tally.missing += 1;
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(map_err(e)),
|
||||
}
|
||||
let x0 = col * lay.cw;
|
||||
let n = lay.cw.min(lay.w.saturating_sub(x0));
|
||||
for (ri, src_row) in buf.chunks_exact(stride).take(rows).enumerate() {
|
||||
let start = ri.saturating_mul(erow);
|
||||
let Some(er) = extra.get_mut(start..start.saturating_add(erow)) else { continue };
|
||||
let px = ech * lay.out.bytes();
|
||||
let Some(dst) = er.get_mut(x0.saturating_mul(px)..(x0.saturating_add(n)).saturating_mul(px)) else { continue };
|
||||
transfer(lay.src, src_row, 1, 0, dst, lay.out, ech, ei, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts `n` pixels of one decoded chunk row into output pixels (`plane` for planar data).
|
||||
fn convert_row(lay: &Layout, src: &[u8], dst: &mut [u8], n: usize, plane: usize) {
|
||||
let och = lay.out_channels();
|
||||
@@ -1072,6 +1311,230 @@ custom_colortype!(Lab16, u16, PI::CIELab, &[16; 3], &[SampleFormat::Uint; 3], in
|
||||
custom_colortype!(LabA8, u8, PI::CIELab, &[8; 4], &[SampleFormat::Uint; 4], int);
|
||||
custom_colortype!(LabA16, u16, PI::CIELab, &[16; 4], &[SampleFormat::Uint; 4], int);
|
||||
|
||||
// Colour types with 1..=MAX_EXTRA_SAMPLES trailing extra samples (spot channels, extra alpha).
|
||||
// One generic type per (sample, photometric, bit depth, total sample count) instead of a
|
||||
// hand-written type for every layout. `N` is the total sample count, not the extra count.
|
||||
const fn extra_photo(v: u16) -> PhotometricInterpretation {
|
||||
match v {
|
||||
1 => PhotometricInterpretation::BlackIsZero,
|
||||
2 => PhotometricInterpretation::RGB,
|
||||
5 => PhotometricInterpretation::CMYK,
|
||||
8 => PhotometricInterpretation::CIELab,
|
||||
_ => PhotometricInterpretation::RGB,
|
||||
}
|
||||
}
|
||||
|
||||
trait ExtraKind: Copy + TiffValue + 'static {
|
||||
const FORMAT: SampleFormat;
|
||||
fn predict(row: &[Self], out: &mut Vec<Self>, samples: usize);
|
||||
}
|
||||
|
||||
impl ExtraKind for u8 {
|
||||
const FORMAT: SampleFormat = SampleFormat::Uint;
|
||||
fn predict(row: &[Self], out: &mut Vec<Self>, samples: usize) {
|
||||
let n = samples.min(row.len());
|
||||
out.extend_from_slice(&row[..n]);
|
||||
out.extend(row.iter().zip(&row[n..]).map(|(prev, current)| current.wrapping_sub(*prev)));
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtraKind for u16 {
|
||||
const FORMAT: SampleFormat = SampleFormat::Uint;
|
||||
fn predict(row: &[Self], out: &mut Vec<Self>, samples: usize) {
|
||||
let n = samples.min(row.len());
|
||||
out.extend_from_slice(&row[..n]);
|
||||
out.extend(row.iter().zip(&row[n..]).map(|(prev, current)| current.wrapping_sub(*prev)));
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtraKind for f32 {
|
||||
const FORMAT: SampleFormat = SampleFormat::IEEEFP;
|
||||
fn predict(row: &[Self], out: &mut Vec<Self>, _samples: usize) {
|
||||
out.extend_from_slice(row);
|
||||
}
|
||||
}
|
||||
|
||||
struct Extras<T, const PHOTO: u16, const BITS: u16, const N: usize>(std::marker::PhantomData<T>);
|
||||
|
||||
impl<T, const PHOTO: u16, const BITS: u16, const N: usize> TiffColorType for Extras<T, PHOTO, BITS, N>
|
||||
where
|
||||
T: ExtraKind,
|
||||
{
|
||||
type Inner = T;
|
||||
const TIFF_VALUE: PhotometricInterpretation = extra_photo(PHOTO);
|
||||
const BITS_PER_SAMPLE: &'static [u16] = &[BITS; N];
|
||||
const SAMPLE_FORMAT: &'static [SampleFormat] = &[T::FORMAT; N];
|
||||
fn horizontal_predict(row: &[Self::Inner], result: &mut Vec<Self::Inner>) {
|
||||
T::predict(row, result, N);
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! write_extras {
|
||||
($n:expr, $enc:expr, $w:expr, $h:expr, $data:expr, $tags:expr, $sample:ty, $photo:literal, $bits:literal, $base:literal) => {
|
||||
match $n {
|
||||
1 => write_one::<Extras<$sample, $photo, $bits, { $base + 1 }>, K>($enc, $w, $h, $data, $tags),
|
||||
2 => write_one::<Extras<$sample, $photo, $bits, { $base + 2 }>, K>($enc, $w, $h, $data, $tags),
|
||||
3 => write_one::<Extras<$sample, $photo, $bits, { $base + 3 }>, K>($enc, $w, $h, $data, $tags),
|
||||
4 => write_one::<Extras<$sample, $photo, $bits, { $base + 4 }>, K>($enc, $w, $h, $data, $tags),
|
||||
5 => write_one::<Extras<$sample, $photo, $bits, { $base + 5 }>, K>($enc, $w, $h, $data, $tags),
|
||||
6 => write_one::<Extras<$sample, $photo, $bits, { $base + 6 }>, K>($enc, $w, $h, $data, $tags),
|
||||
7 => write_one::<Extras<$sample, $photo, $bits, { $base + 7 }>, K>($enc, $w, $h, $data, $tags),
|
||||
8 => write_one::<Extras<$sample, $photo, $bits, { $base + 8 }>, K>($enc, $w, $h, $data, $tags),
|
||||
9 => write_one::<Extras<$sample, $photo, $bits, { $base + 9 }>, K>($enc, $w, $h, $data, $tags),
|
||||
10 => write_one::<Extras<$sample, $photo, $bits, { $base + 10 }>, K>($enc, $w, $h, $data, $tags),
|
||||
11 => write_one::<Extras<$sample, $photo, $bits, { $base + 11 }>, K>($enc, $w, $h, $data, $tags),
|
||||
12 => write_one::<Extras<$sample, $photo, $bits, { $base + 12 }>, K>($enc, $w, $h, $data, $tags),
|
||||
13 => write_one::<Extras<$sample, $photo, $bits, { $base + 13 }>, K>($enc, $w, $h, $data, $tags),
|
||||
14 => write_one::<Extras<$sample, $photo, $bits, { $base + 14 }>, K>($enc, $w, $h, $data, $tags),
|
||||
15 => write_one::<Extras<$sample, $photo, $bits, { $base + 15 }>, K>($enc, $w, $h, $data, $tags),
|
||||
16 => write_one::<Extras<$sample, $photo, $bits, { $base + 16 }>, K>($enc, $w, $h, $data, $tags),
|
||||
other => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported extra samples ({other})")).into()),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// An empty buffer with room for `len` elements, allocated fallibly: a save too large for memory
|
||||
/// is an error, not an abort.
|
||||
fn try_with_capacity<T>(len: usize) -> io::Result<Vec<T>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(len).map_err(|_| io::Error::new(io::ErrorKind::OutOfMemory, format!("cannot allocate {len} samples for the extra samples")))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn bytes_as_u16(b: &[u8]) -> io::Result<Vec<u16>> {
|
||||
let chunks = b.as_chunks::<2>().0;
|
||||
let mut v = try_with_capacity(chunks.len())?;
|
||||
v.extend(chunks.iter().map(|c| u16::from_ne_bytes(*c)));
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn bytes_as_f32(b: &[u8]) -> io::Result<Vec<f32>> {
|
||||
let chunks = b.as_chunks::<4>().0;
|
||||
let mut v = try_with_capacity(chunks.len())?;
|
||||
v.extend(chunks.iter().map(|c| f32::from_ne_bytes(*c)));
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Interleaves colour (and alpha) samples with [`Metadata::extra_samples`].
|
||||
fn interleave_extras(img: &Image) -> Result<Vec<u8>, String> {
|
||||
let (w, h) = img.dimensions();
|
||||
let n = (w as usize).checked_mul(h as usize).ok_or_else(|| "image size overflows".to_string())?;
|
||||
let sb = img.sample_type().bytes();
|
||||
let ch = img.layout().channels();
|
||||
let extras = &img.meta.extra_samples;
|
||||
let total = ch.checked_add(extras.len()).ok_or_else(|| "too many samples".to_string())?;
|
||||
let px = total.checked_mul(sb).ok_or_else(|| "sample size overflows".to_string())?;
|
||||
let len = n.checked_mul(px).ok_or_else(|| "image size overflows".to_string())?;
|
||||
let mut out = try_with_capacity::<u8>(len).map_err(|e| e.to_string())?;
|
||||
out.resize(len, 0);
|
||||
let base = img.data();
|
||||
let base_px = ch.saturating_mul(sb);
|
||||
for i in 0..n {
|
||||
let src = i.saturating_mul(base_px);
|
||||
let dst = i.saturating_mul(px);
|
||||
let Some(s) = base.get(src..src.saturating_add(base_px)) else {
|
||||
return Err("image buffer is shorter than its dimensions".into());
|
||||
};
|
||||
let Some(d) = out.get_mut(dst..dst.saturating_add(base_px)) else {
|
||||
return Err("extra-sample buffer is short".into());
|
||||
};
|
||||
d.copy_from_slice(s);
|
||||
for (k, e) in extras.iter().enumerate() {
|
||||
let es = i.saturating_mul(sb);
|
||||
let ed = dst.saturating_add(base_px).saturating_add(k.saturating_mul(sb));
|
||||
let Some(s) = e.data.get(es..es.saturating_add(sb)) else {
|
||||
return Err(format!("extra sample {k} is shorter than the image"));
|
||||
};
|
||||
let Some(d) = out.get_mut(ed..ed.saturating_add(sb)) else {
|
||||
return Err("extra-sample buffer is short".into());
|
||||
};
|
||||
d.copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn write_with_extras<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, img: &Image, tags: &TagSet<'_>) -> tiff::TiffResult<()> {
|
||||
use ChannelLayout as L;
|
||||
use SampleType as S;
|
||||
let (w, h) = img.dimensions();
|
||||
let n = img.meta.extra_samples.len();
|
||||
if img.layout().is_lab() {
|
||||
let file = lab_file(img);
|
||||
let bytes = interleave_extras(&file).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
return match (file.layout(), file.sample_type()) {
|
||||
(L::Lab, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 8, 8, 3),
|
||||
(L::Lab, _) => {
|
||||
let v = bytes_as_u16(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, u16, 8, 16, 3)
|
||||
}
|
||||
(_, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 8, 8, 4),
|
||||
_ => {
|
||||
let v = bytes_as_u16(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, u16, 8, 16, 4)
|
||||
}
|
||||
};
|
||||
}
|
||||
let bytes = interleave_extras(img).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
match (img.layout(), img.sample_type()) {
|
||||
(L::Gray, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 1, 8, 1),
|
||||
(L::Gray, S::U16) => {
|
||||
let v = bytes_as_u16(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, u16, 1, 16, 1)
|
||||
}
|
||||
(L::Gray, S::F32) => {
|
||||
let v = bytes_as_f32(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, f32, 1, 32, 1)
|
||||
}
|
||||
(L::GrayA, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 1, 8, 2),
|
||||
(L::GrayA, S::U16) => {
|
||||
let v = bytes_as_u16(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, u16, 1, 16, 2)
|
||||
}
|
||||
(L::GrayA, S::F32) => {
|
||||
let v = bytes_as_f32(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, f32, 1, 32, 2)
|
||||
}
|
||||
(L::Rgb, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 2, 8, 3),
|
||||
(L::Rgb, S::U16) => {
|
||||
let v = bytes_as_u16(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, u16, 2, 16, 3)
|
||||
}
|
||||
(L::Rgb, S::F32) => {
|
||||
let v = bytes_as_f32(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, f32, 2, 32, 3)
|
||||
}
|
||||
(L::Rgba, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 2, 8, 4),
|
||||
(L::Rgba, S::U16) => {
|
||||
let v = bytes_as_u16(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, u16, 2, 16, 4)
|
||||
}
|
||||
(L::Rgba, S::F32) => {
|
||||
let v = bytes_as_f32(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, f32, 2, 32, 4)
|
||||
}
|
||||
(L::Cmyk, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 5, 8, 4),
|
||||
(L::Cmyk, S::U16) => {
|
||||
let v = bytes_as_u16(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, u16, 5, 16, 4)
|
||||
}
|
||||
(L::Cmyk, S::F32) => {
|
||||
let v = bytes_as_f32(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, f32, 5, 32, 4)
|
||||
}
|
||||
(L::CmykA, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 5, 8, 5),
|
||||
(L::CmykA, S::U16) => {
|
||||
let v = bytes_as_u16(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, u16, 5, 16, 5)
|
||||
}
|
||||
(L::CmykA, S::F32) => {
|
||||
let v = bytes_as_f32(&bytes)?;
|
||||
write_extras!(n, enc, w, h, &v, tags, f32, 5, 32, 5)
|
||||
}
|
||||
_ => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported extra samples ({n})")).into()),
|
||||
}
|
||||
}
|
||||
|
||||
struct TagSet<'a> {
|
||||
icc: Option<&'a [u8]>,
|
||||
/// The EXIF block's directories and IFD0 tags, written as the TIFF's own.
|
||||
@@ -1079,7 +1542,8 @@ struct TagSet<'a> {
|
||||
xmp: Option<&'a [u8]>,
|
||||
dpi: Option<(f32, f32)>,
|
||||
text: Vec<(Tag, &'a str)>,
|
||||
alpha: bool,
|
||||
/// ExtraSamples tag, including the unassociated-alpha marker when the image has alpha.
|
||||
extra_samples: &'a [u16],
|
||||
photoshop_resources: Option<&'a [u8]>,
|
||||
photoshop_layers: Option<&'a [u8]>,
|
||||
}
|
||||
@@ -1109,9 +1573,8 @@ where
|
||||
im.y_resolution(Rational { n: (y * 1000.0).round() as u32, d: 1000 });
|
||||
}
|
||||
let d = im.encoder();
|
||||
if tags.alpha {
|
||||
// 2 = unassociated alpha
|
||||
d.write_tag(Tag::ExtraSamples, &[2u16][..])?;
|
||||
if !tags.extra_samples.is_empty() {
|
||||
d.write_tag(Tag::ExtraSamples, tags.extra_samples)?;
|
||||
}
|
||||
if let Some(icc) = tags.icc {
|
||||
d.write_tag(Tag::IccProfile, Undefined(icc))?;
|
||||
@@ -1142,10 +1605,24 @@ where
|
||||
im.write_data(data)
|
||||
}
|
||||
|
||||
/// A Lab image as CIELab stores it: a* and b* signed, 8 or 16 bits (CIELab has no float form:
|
||||
/// 32-bit Lab is written with 16 bits). Extra samples follow the new sample type.
|
||||
fn lab_file(img: &Image) -> Image {
|
||||
let sample = if img.sample_type() == SampleType::U8 { SampleType::U8 } else { SampleType::U16 };
|
||||
// `convert` brings the extra samples to the new sample type with the image.
|
||||
let mut file = img.convert(img.layout(), sample);
|
||||
let channels = file.layout().channels();
|
||||
convert_ab(file.data_mut(), channels, sample, true, true);
|
||||
file
|
||||
}
|
||||
|
||||
/// Writes the image with the colour type matching its layout and sample type.
|
||||
fn write_image<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, img: &Image, tags: &TagSet<'_>) -> tiff::TiffResult<()> {
|
||||
use ChannelLayout as L;
|
||||
use SampleType as S;
|
||||
if !img.meta.extra_samples.is_empty() {
|
||||
return write_with_extras::<K>(enc, img, tags);
|
||||
}
|
||||
let (w, h) = img.dimensions();
|
||||
let u16s = || img.to_u16_samples().unwrap_or_default();
|
||||
let f32s = || img.to_f32_samples().unwrap_or_default();
|
||||
@@ -1170,13 +1647,9 @@ fn write_image<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, img:
|
||||
(L::CmykA, S::U16) => write_one::<CmykA16, K>(enc, w, h, &u16s(), tags),
|
||||
(L::CmykA, S::F32) => write_one::<CmykA32F, K>(enc, w, h, &f32s(), tags),
|
||||
(L::Lab | L::LabA, _) => {
|
||||
// CIELab has no float form: 32-bit Lab is written with 16 bits.
|
||||
let sample = if img.sample_type() == S::U8 { S::U8 } else { S::U16 };
|
||||
let mut file = img.convert(img.layout(), sample);
|
||||
let channels = file.layout().channels();
|
||||
convert_ab(file.data_mut(), channels, sample, true, true);
|
||||
let file = lab_file(img);
|
||||
let u16s = || file.to_u16_samples().unwrap_or_default();
|
||||
match (file.layout(), sample) {
|
||||
match (file.layout(), file.sample_type()) {
|
||||
(L::Lab, S::U8) => write_one::<Lab8, K>(enc, w, h, file.data(), tags),
|
||||
(L::Lab, _) => write_one::<Lab16, K>(enc, w, h, &u16s(), tags),
|
||||
(_, S::U8) => write_one::<LabA8, K>(enc, w, h, file.data(), tags),
|
||||
@@ -1198,11 +1671,48 @@ fn estimated_size(img: &Image, tags: &TagSet<'_>) -> u64 {
|
||||
+ tags.exif.iter().flat_map(|e| e.ifd0.iter().chain(&e.exif).chain(&e.gps)).map(|f| f.value.len() as u64 + 12).sum::<u64>();
|
||||
// Strip tables: one offset and one count per strip of about 1 MB.
|
||||
let tables = (img.data().len() as u64 / (1 << 20) + 1) * 16;
|
||||
(img.data().len() as u64).saturating_add(payloads).saturating_add(text).saturating_add(tables).saturating_add(1 << 20)
|
||||
let extras: u64 = img.meta.extra_samples.iter().map(|e| e.data.len() as u64).sum();
|
||||
(img.data().len() as u64).saturating_add(extras).saturating_add(payloads).saturating_add(text).saturating_add(tables).saturating_add(1 << 20)
|
||||
}
|
||||
|
||||
/// The byte length of one extra-sample plane: one sample per pixel of `img`, in its sample type.
|
||||
/// `None` when the pixel count overflows a `usize` (no such image buffer can exist).
|
||||
fn extra_plane_len(img: &Image) -> Option<usize> {
|
||||
let (w, h) = img.dimensions();
|
||||
(w as usize).checked_mul(h as usize)?.checked_mul(img.sample_type().bytes())
|
||||
}
|
||||
|
||||
/// Every extra plane must hold exactly one sample per pixel of `img`, in its sample type.
|
||||
///
|
||||
/// The writer copies one sample per pixel out of each plane, so a plane of any other length
|
||||
/// would be silently truncated (long) or read past its end (short). A sample-type conversion
|
||||
/// reinterprets the bytes in the new width instead, so an unchecked plane would also change
|
||||
/// meaning rather than fail. Refuse the image instead of writing either.
|
||||
fn check_extra_planes(img: &Image) -> Result<(), CodecError> {
|
||||
let (w, h) = img.dimensions();
|
||||
let sample = img.sample_type();
|
||||
let plane_len = extra_plane_len(img).ok_or_else(|| CodecError::encode(F, format!("the extra-sample size of a {w}x{h} image overflows")))?;
|
||||
for (k, e) in img.meta.extra_samples.iter().enumerate() {
|
||||
if e.data.len() != plane_len {
|
||||
return Err(CodecError::encode(
|
||||
F,
|
||||
format!("extra sample {k} is {} bytes; a {w}x{h} {sample:?} image needs exactly {plane_len} (one sample per pixel)", e.data.len()),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
// Check the source planes before `converted`, which brings them to the plan's sample type
|
||||
// with the image but deliberately leaves a wrong-sized plane alone: past that point a bad
|
||||
// plane would be reinterpreted in the new width rather than rejected.
|
||||
check_extra_planes(src)?;
|
||||
// `converted` brings the extra samples to the plan's sample type with the image.
|
||||
encode_ready(&src.converted(plan.layout, plan.sample), opts)
|
||||
}
|
||||
|
||||
fn encode_ready(img: &Image, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let compression = match opts.tiff_compression {
|
||||
TiffCompression::None => tiff::encoder::Compression::Uncompressed,
|
||||
TiffCompression::Lzw => tiff::encoder::Compression::Lzw,
|
||||
@@ -1226,13 +1736,29 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
};
|
||||
// TIFF writes no Orientation tag (= 1): keep the XMP from contradicting it.
|
||||
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(|x| crate::resolution::export_xmp(x, img.meta.dpi)) } else { None };
|
||||
if img.meta.extra_samples.len() > crate::MAX_EXTRA_SAMPLES {
|
||||
return Err(CodecError::encode(F, format!("TIFF stores at most {} extra samples", crate::MAX_EXTRA_SAMPLES)));
|
||||
}
|
||||
// Validate again in the sample type the planes now hold: `Image::convert` takes a plane's
|
||||
// bytes before it rebuilds them, so one it could not rebuild would arrive here empty. The Lab
|
||||
// path's later re-type through `lab_file` converts a plane only when its length already
|
||||
// matches the image, which this guarantees.
|
||||
check_extra_planes(img)?;
|
||||
let mut kinds = Vec::with_capacity(img.meta.extra_samples.len().saturating_add(1));
|
||||
if img.layout().has_alpha() {
|
||||
kinds.push(2);
|
||||
}
|
||||
// TIFF 6.0 reserves ExtraSamples values above 2: those are written as 0 (unspecified).
|
||||
for e in &img.meta.extra_samples {
|
||||
kinds.push(if e.kind <= 2 { e.kind } else { 0 });
|
||||
}
|
||||
let tags = TagSet {
|
||||
icc: if opts.embed_icc { img.icc.as_deref() } else { None },
|
||||
exif: if opts.embed_metadata { img.meta.exif.as_deref().and_then(|e| tiff_exif::read(crate::orientation::tiff_body(e))) } else { None },
|
||||
xmp: xmp.as_deref().map(str::as_bytes),
|
||||
dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None },
|
||||
text,
|
||||
alpha: img.layout().has_alpha(),
|
||||
extra_samples: &kinds,
|
||||
// Document content, not metadata: written whenever present.
|
||||
photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()),
|
||||
photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()),
|
||||
@@ -1245,17 +1771,48 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
if big {
|
||||
let mut enc = TiffEncoder::new_big(&mut cursor)?.with_compression(compression).with_predictor(predictor);
|
||||
write_image::<TiffKindBig>(&mut enc, &img, &tags)?;
|
||||
write_image::<TiffKindBig>(&mut enc, img, &tags)?;
|
||||
} else {
|
||||
let mut enc = TiffEncoder::new(&mut cursor)?.with_compression(compression).with_predictor(predictor);
|
||||
write_image::<TiffKindStandard>(&mut enc, &img, &tags)?;
|
||||
write_image::<TiffKindStandard>(&mut enc, img, &tags)?;
|
||||
}
|
||||
Ok(cursor.into_inner())
|
||||
};
|
||||
let big = opts.tiff_bigtiff || estimated_size(&img, &tags) > CLASSIC_LIMIT;
|
||||
let big = opts.tiff_bigtiff || estimated_size(img, &tags) > CLASSIC_LIMIT;
|
||||
match write(big) {
|
||||
Ok(v) => Ok(v),
|
||||
Err(tiff::TiffError::IntSizeError) if !big => write(true).map_err(|e| CodecError::encode(F, e)),
|
||||
Err(e) => Err(CodecError::encode(F, e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bits_len {
|
||||
use super::Extras;
|
||||
use tiff::encoder::colortype::ColorType as TiffColorType;
|
||||
|
||||
#[test]
|
||||
fn const_generic_sample_tables_have_one_entry_per_sample() {
|
||||
// A repeat expression in an associated const (`&[BITS; N]`) has, on some compilers,
|
||||
// promoted to a slice shorter than `N`. The encoder sizes every strip from that length.
|
||||
assert_eq!(<Extras<u8, 2, 8, 4> as TiffColorType>::BITS_PER_SAMPLE.len(), 4, "{:?}", <Extras<u8, 2, 8, 4> as TiffColorType>::BITS_PER_SAMPLE);
|
||||
assert_eq!(<Extras<u8, 2, 8, 5> as TiffColorType>::BITS_PER_SAMPLE.len(), 5);
|
||||
assert_eq!(<Extras<u8, 2, 8, 19> as TiffColorType>::BITS_PER_SAMPLE.len(), 19);
|
||||
assert_eq!(<Extras<u16, 5, 16, 6> as TiffColorType>::SAMPLE_FORMAT.len(), 6);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod extra_sample_buffers {
|
||||
use super::try_with_capacity;
|
||||
|
||||
/// The extra-sample writer's buffers are allocated fallibly: a size no allocation can serve
|
||||
/// is an out-of-memory error the save reports, not an abort.
|
||||
#[test]
|
||||
fn an_impossible_buffer_is_an_error() {
|
||||
let err = try_with_capacity::<u8>(usize::MAX).unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::OutOfMemory);
|
||||
assert!(try_with_capacity::<u16>(usize::MAX / 2).is_err());
|
||||
assert!(try_with_capacity::<f32>(64).is_ok_and(|v| v.is_empty() && v.capacity() >= 64));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,24 @@ pub struct Metadata {
|
||||
/// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept
|
||||
/// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`.
|
||||
pub photoshop_layers: Option<Vec<u8>>,
|
||||
/// Samples per pixel beyond the colour channels and the one alpha sample. TIFF extra
|
||||
/// samples (spot colours, extra alpha channels) the pixel layout does not hold. Each entry
|
||||
/// is one sample per pixel, native endian, in the image's [`SampleType`], row-major.
|
||||
pub extra_samples: Vec<ExtraSample>,
|
||||
}
|
||||
|
||||
/// How many extra TIFF samples an encoded file may carry. Decoders keep at most this many.
|
||||
/// Sixteen covers a UV-DTF stack (white, varnish, gloss, primer, and further spots) without
|
||||
/// approaching the PSD 56-channel limit.
|
||||
pub const MAX_EXTRA_SAMPLES: usize = 16;
|
||||
|
||||
/// One TIFF extra sample (a spot channel or an alpha channel that is not the image alpha).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ExtraSample {
|
||||
/// TIFF ExtraSamples value: 0 unspecified, 1 associated alpha, 2 unassociated alpha.
|
||||
pub kind: u16,
|
||||
/// One sample per pixel, native endian, same sample type as the image.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
@@ -155,6 +173,7 @@ impl Metadata {
|
||||
&& self.text.is_empty()
|
||||
&& self.photoshop_resources.is_none()
|
||||
&& self.photoshop_layers.is_none()
|
||||
&& self.extra_samples.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +200,12 @@ pub enum DecodeWarning {
|
||||
/// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes,
|
||||
/// 255 when undefined). The code values are imported as stored, without a colour transform.
|
||||
DpxEncoding { transfer: u8, colorimetric: u8 },
|
||||
/// The file's `count` extra samples (spot or extra alpha channels) did not fit the allocation
|
||||
/// limit next to the image, so only the image was decoded.
|
||||
ExtraSamplesDropped { format: Format, count: u32 },
|
||||
/// The file has `count` extra samples past the first [`MAX_EXTRA_SAMPLES`], which are all a
|
||||
/// decoder keeps; those past the cap were not imported.
|
||||
ExtraSamplesOverLimit { format: Format, count: u32 },
|
||||
}
|
||||
|
||||
/// The SMPTE 268M name of a transfer characteristic or colorimetric code.
|
||||
@@ -231,6 +256,20 @@ impl fmt::Display for DecodeWarning {
|
||||
dpx_code_name(*transfer),
|
||||
dpx_code_name(*colorimetric)
|
||||
),
|
||||
DecodeWarning::ExtraSamplesDropped { format, count } => write!(
|
||||
f,
|
||||
"{count} extra {} file channel{} (spot or alpha) did not fit in memory and {} not imported",
|
||||
format.name(),
|
||||
if *count == 1 { "" } else { "s" },
|
||||
if *count == 1 { "was" } else { "were" }
|
||||
),
|
||||
DecodeWarning::ExtraSamplesOverLimit { format, count } => write!(
|
||||
f,
|
||||
"{count} extra {} file channel{} (spot or alpha) past the first {MAX_EXTRA_SAMPLES} {} not imported",
|
||||
format.name(),
|
||||
if *count == 1 { "" } else { "s" },
|
||||
if *count == 1 { "was" } else { "were" }
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -642,6 +681,17 @@ impl Image {
|
||||
let values = convert_layout(&src, self.layout, layout);
|
||||
quantize(&values, sample)
|
||||
};
|
||||
let mut meta = self.meta.clone();
|
||||
if sample != self.sample {
|
||||
// Extra samples are stored in the image's sample type, so they convert with it. A
|
||||
// plane of the wrong size is left as it is: importers drop it with a warning.
|
||||
let plane_len = (self.width as usize).saturating_mul(self.height as usize).saturating_mul(self.sample.bytes());
|
||||
for e in meta.extra_samples.iter_mut().filter(|e| e.data.len() == plane_len) {
|
||||
if let Ok(plane) = Image::from_raw(self.width, self.height, ChannelLayout::Gray, self.sample, std::mem::take(&mut e.data)) {
|
||||
e.data = plane.convert(ChannelLayout::Gray, sample).data;
|
||||
}
|
||||
}
|
||||
}
|
||||
Image {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
@@ -650,7 +700,7 @@ impl Image {
|
||||
data,
|
||||
icc: if layout.same_model(self.layout) { self.icc.clone() } else { None },
|
||||
cicp: if layout.same_model(self.layout) { self.cicp } else { None },
|
||||
meta: self.meta.clone(),
|
||||
meta,
|
||||
warnings: self.warnings.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ pub use crate::exif_color::{EXIF_COLOR_SPACE_SRGB, exif_color_space};
|
||||
pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with};
|
||||
pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension};
|
||||
pub use crate::image::{
|
||||
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType,
|
||||
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, ExtraSample, Image,
|
||||
MAX_EXTRA_SAMPLES, Metadata, SampleType,
|
||||
};
|
||||
pub use crate::options::{
|
||||
DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TiffCompression,
|
||||
|
||||
@@ -331,6 +331,12 @@ impl Image {
|
||||
}
|
||||
let data = orient_bytes(self.data(), bpp, o, (w, h), ow)?;
|
||||
let mut meta = self.meta.clone();
|
||||
// Extra samples (a TIFF's alpha and spot planes, one sample a pixel) turn with the image.
|
||||
// A plane of the wrong size is left as it is: importers drop it with a warning.
|
||||
let plane_len = w.saturating_mul(h).saturating_mul(sample.bytes());
|
||||
for e in meta.extra_samples.iter_mut().filter(|e| e.data.len() == plane_len) {
|
||||
e.data = orient_bytes(&e.data, sample.bytes(), o, (w, h), ow)?;
|
||||
}
|
||||
if let Some(e) = &mut meta.exif
|
||||
&& let Cow::Owned(fixed) = upright_exif(e)
|
||||
{
|
||||
|
||||
@@ -211,6 +211,25 @@ fn png_opens_upright_for_every_orientation() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra samples (a TIFF's alpha and spot planes) turn with the image, so a channel stays
|
||||
/// aligned with the pixels for every orientation.
|
||||
#[test]
|
||||
fn extra_samples_turn_with_the_image() {
|
||||
let (w, h) = (4, 7);
|
||||
let up = exact_scene(w, h);
|
||||
let up_extra: Vec<u8> = up.iter().map(|v| 255 - v).collect();
|
||||
for o in 1..=8u16 {
|
||||
let (s, sw, sh) = stored(&up, w, h, o);
|
||||
let (s_extra, _, _) = stored(&up_extra, w, h, o);
|
||||
let mut img = gray(sw, sh, s);
|
||||
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: s_extra }];
|
||||
let turned = img.oriented(o).unwrap();
|
||||
assert_eq!(turned.data(), &up[..], "o={o}");
|
||||
assert_eq!(turned.meta.extra_samples.len(), 1, "o={o}");
|
||||
assert_eq!(turned.meta.extra_samples[0].data, up_extra, "o={o}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiff_opens_upright_for_every_orientation() {
|
||||
let (w, h) = (4, 7);
|
||||
|
||||
@@ -74,3 +74,36 @@ fn lab_converts_to_rgb_for_other_formats() {
|
||||
assert_eq!(&p[4..7], &[255, 255, 255], "white");
|
||||
assert!(p[0] > 180 && p[1] < 120 && p[2] > 140, "L 50, a 50, b −30 is a magenta-ish purple: {:?}", &p[..3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_keeps_extra_samples() {
|
||||
// A spot plate next to Lab: written as CIELab plus one unspecified extra sample, and read
|
||||
// back as Lab with the spot (not as LabA).
|
||||
for (layout, sample) in [(ChannelLayout::Lab, SampleType::U8), (ChannelLayout::LabA, SampleType::U16)] {
|
||||
let px: Vec<f32> =
|
||||
[lab(50.0, 50.0, -30.0), lab(100.0, 0.0, 0.0)].iter().flat_map(|p| p.iter().copied().chain(layout.has_alpha().then_some(0.5))).collect();
|
||||
let mut img = Image::from_normalized(2, 1, layout, sample, &px).unwrap();
|
||||
let spot: Vec<u8> = (0..2 * sample.bytes()).map(|i| (i * 37 + 5) as u8).collect();
|
||||
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: spot.clone() }];
|
||||
let file = uncompressed(&img);
|
||||
if layout == ChannelLayout::Lab {
|
||||
assert!(find(&file, &[128, 50, (-30i8) as u8, spot[0], 255, 0, 0, spot[1]]).is_some(), "CIELab samples with the spot");
|
||||
}
|
||||
let back = decode(&file).unwrap();
|
||||
assert_eq!((back.layout(), back.sample_type()), (layout, sample));
|
||||
let (a, b) = (img.to_normalized(), back.to_normalized());
|
||||
assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 1.5 / 65535.0), "{layout:?}: {a:?} {b:?}");
|
||||
assert_eq!(back.meta.extra_samples, vec![ExtraSample { kind: 0, data: spot }], "{layout:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_lab_writes_its_extra_samples_with_16_bits() {
|
||||
let px: Vec<f32> = [lab(50.0, 50.0, -30.0), lab(100.0, 0.0, 0.0)].concat();
|
||||
let mut img = Image::from_normalized(2, 1, ChannelLayout::Lab, SampleType::F32, &px).unwrap();
|
||||
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: [0.25f32, 1.0].iter().flat_map(|v| v.to_ne_bytes()).collect() }];
|
||||
let back = decode(&uncompressed(&img)).unwrap();
|
||||
assert_eq!((back.layout(), back.sample_type()), (ChannelLayout::Lab, SampleType::U16));
|
||||
let spot: Vec<u8> = [16384u16, 65535].iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
assert_eq!(back.meta.extra_samples, vec![ExtraSample { kind: 0, data: spot }]);
|
||||
}
|
||||
|
||||
@@ -128,13 +128,16 @@ fn planar_strips_at_every_depth() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra samples that are not the alpha (ExtraSamples 0: a spot colour or an extra channel) are
|
||||
/// not part of the image: they come back in `meta.extra_samples`, one plane each.
|
||||
#[test]
|
||||
fn extra_samples_that_are_not_alpha_are_dropped() {
|
||||
fn extra_samples_that_are_not_alpha_are_kept_apart() {
|
||||
let (w, h) = (5usize, 3usize);
|
||||
let t = truth(w * h * 5, 4);
|
||||
let rgb_of = |spp: usize, keep: usize| -> Vec<u8> { t.u8s[..w * h * spp].chunks(spp).flat_map(|p| p[..keep].to_vec()).collect() };
|
||||
let extra = |spp: usize, at: usize| vec![ExtraSample { kind: 0, data: t.u8s[..w * h * spp].chunks(spp).map(|p| p[at]).collect() }];
|
||||
for planar in [false, true] {
|
||||
// RGB + one unspecified extra sample: RGB.
|
||||
// RGB + one unspecified extra sample: RGB, and the sample as an extra plane.
|
||||
let px = &t.u8s[..w * h * 4];
|
||||
let chunks = if planar { planar_strips(px, w, h, 4, 1, 2) } else { px.chunks(w * 4 * 2).map(<[u8]>::to_vec).collect() };
|
||||
let mut d = Dir { entries: base(w as u32, h as u32, 8, 4, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[0]);
|
||||
@@ -144,7 +147,8 @@ fn extra_samples_that_are_not_alpha_are_dropped() {
|
||||
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
|
||||
assert_eq!(img.layout(), ChannelLayout::Rgb);
|
||||
assert_eq!(img.data(), &rgb_of(4, 3)[..], "planar {planar}");
|
||||
// RGB + alpha + an unspecified extra sample: RGBA.
|
||||
assert_eq!(img.meta.extra_samples, extra(4, 3), "planar {planar}");
|
||||
// RGB + alpha + an unspecified extra sample: RGBA and one extra plane.
|
||||
let px = &t.u8s[..w * h * 5];
|
||||
let chunks = if planar { planar_strips(px, w, h, 5, 1, 2) } else { px.chunks(w * 5 * 2).map(<[u8]>::to_vec).collect() };
|
||||
let mut d = Dir { entries: base(w as u32, h as u32, 8, 5, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[2, 0]);
|
||||
@@ -154,18 +158,48 @@ fn extra_samples_that_are_not_alpha_are_dropped() {
|
||||
let img = decode_ok(&build(false, true, &[d], &[0]).bytes);
|
||||
assert_eq!(img.layout(), ChannelLayout::Rgba);
|
||||
assert_eq!(img.data(), &rgb_of(5, 4)[..], "planar {planar}");
|
||||
assert_eq!(img.meta.extra_samples, extra(5, 4), "planar {planar}");
|
||||
}
|
||||
// Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray.
|
||||
// Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray. Each keeps the
|
||||
// extra sample as a plane.
|
||||
let px = &t.u8s[..w * h * 3];
|
||||
let d = strips(w as u32, h as u32, 8, 3, 1, 3, px).tag(338, SHORT, &[2, 0]);
|
||||
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
|
||||
assert_eq!(img.layout(), ChannelLayout::GrayA);
|
||||
assert_eq!(img.data(), &rgb_of(3, 2)[..]);
|
||||
assert_eq!(img.meta.extra_samples, extra(3, 2));
|
||||
let px = &t.u8s[..w * h * 2];
|
||||
let d = strips(w as u32, h as u32, 8, 2, 1, 3, px).tag(338, SHORT, &[0]);
|
||||
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
|
||||
assert_eq!(img.layout(), ChannelLayout::Gray);
|
||||
assert_eq!(img.data(), &rgb_of(2, 1)[..]);
|
||||
assert_eq!(img.meta.extra_samples, extra(2, 1));
|
||||
}
|
||||
|
||||
/// A file may hold more extra samples than the `MAX_EXTRA_SAMPLES` a decode keeps (up to 64
|
||||
/// samples a pixel open): the first ones open, and a warning counts those left out, so nothing
|
||||
/// is lost without notice.
|
||||
#[test]
|
||||
fn extra_samples_past_the_cap_are_reported() {
|
||||
let (w, h) = (3usize, 2usize);
|
||||
const EXTRAS: usize = MAX_EXTRA_SAMPLES + 4;
|
||||
let spp = 3 + EXTRAS;
|
||||
let px: Vec<u8> = (0..w * h * spp).map(|i| (i % spp) as u8).collect();
|
||||
let d = strips(w as u32, h as u32, 8, spp as u16, 2, h as u32, &px).tag(338, SHORT, &[0; EXTRAS]);
|
||||
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
|
||||
assert_eq!(img.layout(), ChannelLayout::Rgb);
|
||||
assert_eq!(img.meta.extra_samples.len(), MAX_EXTRA_SAMPLES);
|
||||
assert_eq!(img.meta.extra_samples.last().map(|e| e.data[0]), Some((3 + MAX_EXTRA_SAMPLES - 1) as u8));
|
||||
assert_eq!(img.warnings, vec![DecodeWarning::ExtraSamplesOverLimit { format: Format::Tiff, count: 4 }]);
|
||||
let text = img.warnings[0].to_string();
|
||||
assert!(text.contains("4 extra TIFF file channels") && text.contains("past the first 16"), "{text}");
|
||||
// At the cap exactly, nothing is left out.
|
||||
let spp = 3 + MAX_EXTRA_SAMPLES;
|
||||
let px: Vec<u8> = (0..w * h * spp).map(|i| (i % spp) as u8).collect();
|
||||
let d = strips(w as u32, h as u32, 8, spp as u16, 2, h as u32, &px).tag(338, SHORT, &[0; MAX_EXTRA_SAMPLES]);
|
||||
let img = decode_ok(&build(true, false, &[d], &[0]).bytes);
|
||||
assert_eq!(img.meta.extra_samples.len(), MAX_EXTRA_SAMPLES);
|
||||
assert!(img.warnings.is_empty(), "{:?}", img.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -41,6 +41,268 @@ fn tags_round_trip_through_the_encoder() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_samples_round_trip() {
|
||||
for (layout, sample) in [
|
||||
(ChannelLayout::Rgb, SampleType::U8),
|
||||
(ChannelLayout::Rgba, SampleType::U8),
|
||||
(ChannelLayout::Gray, SampleType::U16),
|
||||
(ChannelLayout::Cmyk, SampleType::F32),
|
||||
] {
|
||||
let mut img = synth(4, 3, layout, sample, 2, 0.4);
|
||||
let n = 4 * 3;
|
||||
let b = sample.bytes();
|
||||
let data: Vec<u8> = (0..n * b).map(|i| (i * 17) as u8).collect();
|
||||
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: data.clone() }];
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert_eq!(back.layout(), layout, "{layout:?} {sample:?}");
|
||||
assert_eq!(back.data(), img.data(), "{layout:?} {sample:?}");
|
||||
assert_eq!(back.meta.extra_samples.len(), 1, "{layout:?} {sample:?}");
|
||||
assert_eq!(back.meta.extra_samples[0].kind, 0, "{layout:?} {sample:?}");
|
||||
assert_eq!(back.meta.extra_samples[0].data, data, "{layout:?} {sample:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Tall enough that the default strip size (about 1 MB) cannot hold every row, so a writer
|
||||
/// that only fills the first strip, or predicts across the wrong sample count, cannot hide.
|
||||
/// The spot plane is checked with the `tiff` crate, not PhotoCraft's decoder.
|
||||
#[test]
|
||||
fn multi_strip_spot_round_trip_and_dump() {
|
||||
let w = 800u32;
|
||||
let h = 400u32;
|
||||
let n = (w as usize) * (h as usize);
|
||||
for (layout, channels) in [(ChannelLayout::Rgb, 3usize), (ChannelLayout::Rgba, 4usize)] {
|
||||
let mut px = vec![0u8; n * channels];
|
||||
let mut spot = vec![0u8; n];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
let i = y * w as usize + x;
|
||||
px[i * channels] = (x % 251) as u8;
|
||||
px[i * channels + 1] = (y % 251) as u8;
|
||||
px[i * channels + 2] = 40;
|
||||
if channels == 4 {
|
||||
px[i * channels + 3] = if x > 20 && x < 780 && y > 10 && y + 10 < h as usize { 255 } else { 0 };
|
||||
}
|
||||
spot[i] = ((x * 3 + y * 17) % 251) as u8;
|
||||
}
|
||||
}
|
||||
let mut img = Image::from_u8(w, h, layout, px).unwrap();
|
||||
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: spot.clone() }];
|
||||
for compression in [TiffCompression::None, TiffCompression::Deflate, TiffCompression::Lzw, TiffCompression::PackBits] {
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: compression, ..Default::default() }).unwrap();
|
||||
let label = format!("{layout:?} {compression:?}");
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert_eq!(back.meta.extra_samples.len(), 1, "{label}");
|
||||
assert_eq!(back.meta.extra_samples[0].data, spot, "own decoder {label}");
|
||||
assert_eq!(back.data(), img.data(), "own decoder rgb {label}");
|
||||
let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
|
||||
let spp: u16 = dec.get_tag_unsigned(tiff::tags::Tag::SamplesPerPixel).unwrap();
|
||||
let bps = dec.get_tag_u16_vec(tiff::tags::Tag::BitsPerSample).unwrap();
|
||||
let extra = dec.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap();
|
||||
let rows = dec.find_tag_unsigned::<u32>(tiff::tags::Tag::RowsPerStrip).unwrap();
|
||||
assert_eq!(bps.len(), spp as usize, "{label}");
|
||||
assert_eq!(spp as usize, channels + 1, "{label}");
|
||||
assert_eq!(*extra.last().unwrap(), 0, "{label} spot ExtraSamples {extra:?}");
|
||||
assert!(rows.is_some_and(|r| r < h) || compression == TiffCompression::PackBits, "{label} rows {rows:?}");
|
||||
let patched = min_is_black(&bytes);
|
||||
let mut indep = tiff::decoder::Decoder::new(std::io::Cursor::new(patched.as_slice())).unwrap();
|
||||
let tiff::decoder::DecodingResult::U8(samples) = indep.read_image().unwrap() else { panic!("{label} not u8") };
|
||||
let spp = spp as usize;
|
||||
assert_eq!(samples.len(), n * spp, "{label}");
|
||||
let mut bad = 0usize;
|
||||
let mut first = None;
|
||||
for (i, want) in spot.iter().enumerate() {
|
||||
let got = samples.get(i * spp + spp - 1).copied();
|
||||
if got != Some(*want) {
|
||||
bad += 1;
|
||||
first.get_or_insert((i, got));
|
||||
}
|
||||
}
|
||||
assert_eq!(bad, 0, "{label} independent reader, first {first:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retag photometric interpretation 262 as MinIsBlack so `tiff`'s decoder returns every sample.
|
||||
fn min_is_black(src: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = src.to_vec();
|
||||
let le = bytes.first().copied() == Some(b'I');
|
||||
let ru16 = |b: &[u8], i: usize| -> Option<u16> {
|
||||
let s = b.get(i..i + 2)?;
|
||||
Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) })
|
||||
};
|
||||
let ru32 = |b: &[u8], i: usize| -> Option<u32> {
|
||||
let s = b.get(i..i + 4)?;
|
||||
Some(if le { u32::from_le_bytes(s.try_into().ok()?) } else { u32::from_be_bytes(s.try_into().ok()?) })
|
||||
};
|
||||
let Some(ifd) = ru32(&bytes, 4) else { return bytes };
|
||||
let Some(n) = ru16(&bytes, ifd as usize) else { return bytes };
|
||||
for e in 0..n as usize {
|
||||
let at = ifd as usize + 2 + e * 12;
|
||||
if ru16(&bytes, at) == Some(262) {
|
||||
let value = if le { 1u16.to_le_bytes() } else { 1u16.to_be_bytes() };
|
||||
if let Some(slot) = bytes.get_mut(at + 8..at + 10) {
|
||||
slot.copy_from_slice(&value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_than_sixteen_extra_samples_is_refused() {
|
||||
let mut img = synth(2, 2, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
|
||||
img.meta.extra_samples = (0..MAX_EXTRA_SAMPLES + 1).map(|_| ExtraSample { kind: 0, data: vec![0; 4] }).collect();
|
||||
assert!(encode(&img, Format::Tiff, &EncodeOptions::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_extra_samples_round_trip() {
|
||||
for (layout, sample, base) in [
|
||||
(ChannelLayout::Rgb, SampleType::U8, 3usize),
|
||||
(ChannelLayout::Rgba, SampleType::U8, 4),
|
||||
(ChannelLayout::Gray, SampleType::U16, 1),
|
||||
(ChannelLayout::Cmyk, SampleType::F32, 4),
|
||||
] {
|
||||
let mut img = synth(2, 2, layout, sample, 1, 0.0);
|
||||
let n = 2 * 2 * sample.bytes();
|
||||
img.meta.extra_samples = (0..MAX_EXTRA_SAMPLES).map(|k| ExtraSample { kind: 0, data: vec![k as u8; n] }).collect();
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert_eq!(back.layout(), layout, "{layout:?} {sample:?} base {base}");
|
||||
assert_eq!(back.data(), img.data(), "{layout:?}");
|
||||
assert_eq!(back.meta.extra_samples.len(), MAX_EXTRA_SAMPLES, "{layout:?}");
|
||||
assert_eq!(back.meta.extra_samples[7].kind, 0, "{layout:?}");
|
||||
assert_eq!(back.meta.extra_samples[15].data, vec![15; n], "{layout:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An extra plane holds exactly one sample per pixel of the image's sample type. A plane of any
|
||||
/// other length is refused rather than written: the writer copies one sample per pixel, so a
|
||||
/// longer plane would lose its tail and a shorter one would run out mid-image.
|
||||
#[test]
|
||||
fn wrong_sized_extra_planes_are_refused() {
|
||||
let (w, h) = (2u32, 1u32);
|
||||
for (label, plane) in [("too long", vec![10u8, 20, 30]), ("too short", vec![10u8]), ("empty", Vec::new())] {
|
||||
let mut img = synth(w, h, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
|
||||
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: plane.clone() }];
|
||||
let err = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(err, CodecError::Encode { .. }), "{label}: {err}");
|
||||
assert!(err.to_string().contains("extra sample 0") && err.to_string().contains("exactly 2"), "{label}: {err}");
|
||||
}
|
||||
// The exact length still writes, and comes back unchanged.
|
||||
let plane = vec![10u8, 20];
|
||||
let mut img = synth(w, h, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
|
||||
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: plane.clone() }];
|
||||
let back = decode(&encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
assert_eq!(back.meta.extra_samples, vec![ExtraSample { kind: 0, data: plane }]);
|
||||
}
|
||||
|
||||
/// The sample-type conversion an encode performs must not launder a wrong-sized plane: float Lab
|
||||
/// is stored as CIELab at 16 bits, and `Image::convert` leaves a plane whose length does not match
|
||||
/// the image alone, so a 7- or 9-byte plane for two pixels would otherwise reach the writer
|
||||
/// reinterpreted at the new width.
|
||||
#[test]
|
||||
fn a_wrong_sized_plane_is_refused_before_a_sample_type_conversion() {
|
||||
let (w, h) = (2u32, 1u32);
|
||||
// Two pixels at 32-bit float are 8 bytes; the file's 16-bit samples are 4.
|
||||
for (label, len) in [("short", 7usize), ("long", 9)] {
|
||||
let mut img = synth(w, h, ChannelLayout::Lab, SampleType::F32, 1, 0.0);
|
||||
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: vec![0; len] }];
|
||||
let err = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(err, CodecError::Encode { .. }), "{label}: {err}");
|
||||
assert!(err.to_string().contains("exactly 8"), "{label}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra samples count against `max_alloc` with the image (their interleaved buffer and the
|
||||
/// planes split from it): a budget that holds the RGB image but not sixteen extra planes opens
|
||||
/// the image without them, with a warning, instead of allocating past it.
|
||||
#[test]
|
||||
fn extra_samples_count_against_the_allocation_limit() {
|
||||
// Big enough for several strips (about 1 MB each): one strip of all 19 samples fits the budget
|
||||
// of the image alone, as in real files.
|
||||
let (w, h) = (1024u32, 1024u32);
|
||||
let n = (w * h) as usize;
|
||||
let mut img = synth(w, h, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
|
||||
img.meta.extra_samples = (0..MAX_EXTRA_SAMPLES).map(|k| ExtraSample { kind: 0, data: vec![k as u8; n] }).collect();
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
let with = |max_alloc: u64| decode_with(&bytes, &DecodeOptions { limits: Limits { max_alloc, ..Limits::default() }, ..DecodeOptions::default() });
|
||||
// RGB is 3 bytes a pixel; the extra samples add 2 x 16 more.
|
||||
let image_only = 3 * n as u64;
|
||||
let with_extras = (3 + 2 * MAX_EXTRA_SAMPLES as u64) * n as u64;
|
||||
let back = with(with_extras).expect("everything fits");
|
||||
assert_eq!(back.meta.extra_samples.len(), MAX_EXTRA_SAMPLES);
|
||||
assert!(back.warnings.is_empty());
|
||||
// The image fits, its extra samples don't: it opens as before they were read, with a warning.
|
||||
for max_alloc in [image_only, with_extras - 1] {
|
||||
let only = with(max_alloc).expect("the image alone fits");
|
||||
assert!(only.meta.extra_samples.is_empty());
|
||||
assert_eq!(only.warnings, vec![DecodeWarning::ExtraSamplesDropped { format: Format::Tiff, count: MAX_EXTRA_SAMPLES as u32 }]);
|
||||
assert_eq!(only.data(), back.data());
|
||||
}
|
||||
assert!(matches!(with(image_only - 1), Err(CodecError::LimitExceeded(_))), "the image itself must still fit");
|
||||
}
|
||||
|
||||
/// The value field of a little-endian classic TIFF's ExtraSamples entry (tag 338, SHORT) holding
|
||||
/// two values, so they sit in the entry itself.
|
||||
fn extra_samples_values(bytes: &mut [u8]) -> &mut [u8] {
|
||||
assert_eq!(&bytes[..4], b"II*\0");
|
||||
let entry = [0x52, 0x01, 3, 0, 2, 0, 0, 0];
|
||||
let at = bytes.windows(8).position(|w| w == entry).expect("an ExtraSamples entry with two values");
|
||||
&mut bytes[at + 8..at + 12]
|
||||
}
|
||||
|
||||
/// ExtraSamples values above 2 are reserved (TIFF 6.0, p. 31): they read as 0 (unspecified) and
|
||||
/// are written as 0, so a save never carries a reserved value.
|
||||
#[test]
|
||||
fn reserved_extra_sample_kinds_read_as_unspecified() {
|
||||
let n = 4 * 4;
|
||||
let mut img = synth(4, 4, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
|
||||
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: vec![7; n] }, ExtraSample { kind: 0, data: vec![9; n] }];
|
||||
let mut bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
// A file from another writer with reserved values 3 and 65535.
|
||||
let values = extra_samples_values(&mut bytes);
|
||||
assert_eq!(values, [0, 0, 0, 0]);
|
||||
values.copy_from_slice(&[3, 0, 0xFF, 0xFF]);
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert_eq!(back.meta.extra_samples.iter().map(|e| e.kind).collect::<Vec<_>>(), [0, 0]);
|
||||
assert_eq!(back.layout(), ChannelLayout::Rgb, "a reserved value is no alpha");
|
||||
assert_eq!(back.meta.extra_samples[0].data, vec![7; n]);
|
||||
assert_eq!(back.meta.extra_samples[1].data, vec![9; n]);
|
||||
|
||||
// An image that carries a reserved kind saves it as 0.
|
||||
img.meta.extra_samples[0].kind = 3;
|
||||
img.meta.extra_samples[1].kind = u16::MAX;
|
||||
let mut bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(extra_samples_values(&mut bytes), [0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
/// Converting an image's sample type converts its extra samples with it, so they keep matching
|
||||
/// the image: a 16-bit float TIFF's spot planes survive the 32-bit float a layered import uses.
|
||||
#[test]
|
||||
fn extra_samples_follow_a_sample_type_conversion() {
|
||||
let (w, h) = (3u32, 2u32);
|
||||
let n = (w * h) as usize;
|
||||
let values = [0.0f32, 0.25, 0.5, 0.75, 1.0, 0.125];
|
||||
let half = |v: f32| f16::from_f32(v).to_bits().to_ne_bytes();
|
||||
let rgb: Vec<u8> = (0..n * 3).flat_map(|_| half(0.5)).collect();
|
||||
let mut img = Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::F16, rgb).unwrap();
|
||||
img.meta.extra_samples = vec![ExtraSample { kind: 0, data: values.iter().flat_map(|v| half(*v)).collect() }];
|
||||
|
||||
let wide = img.convert(ChannelLayout::Rgb, SampleType::F32);
|
||||
let plane: Vec<f32> = wide.meta.extra_samples[0].data.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes(*c)).collect();
|
||||
assert_eq!(plane, values);
|
||||
|
||||
// To 8 bits, the plane quantizes exactly as a gray image of the same values does.
|
||||
let narrow = wide.convert(ChannelLayout::Rgb, SampleType::U8);
|
||||
let gray = Image::from_raw(w, h, ChannelLayout::Gray, SampleType::F32, wide.meta.extra_samples[0].data.clone()).unwrap();
|
||||
assert_eq!(narrow.meta.extra_samples[0].data, gray.convert(ChannelLayout::Gray, SampleType::U8).data());
|
||||
assert_eq!(narrow.meta.extra_samples[0].data.len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_tags_are_not_written() {
|
||||
let mut img = synth(4, 4, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! 24 MP timing of Art History Brush on a representative stroke.
|
||||
//! `cargo run --release -p photocraft-engine --example bench_art_history`
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
fn main() {
|
||||
let (w, h) = (6016u32, 4000u32);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": w, "height": h, "depth": 8, "mode": "rgb"})).unwrap();
|
||||
s.edit("texture", |doc, active| {
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
let row: Vec<f32> = (0..w)
|
||||
.flat_map(|x| {
|
||||
let t = (x % 256) as f32 / 255.0;
|
||||
photocraft_raster::from_rgba(&fmt, [t, 0.35, 1.0 - t, 1.0])
|
||||
})
|
||||
.collect();
|
||||
for y in 0..h as i32 {
|
||||
surf.write_region(Rect::new(0, y, w as i32, y + 1), &row);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#606060"})).unwrap();
|
||||
|
||||
let points: Vec<[i32; 2]> = (0..200).map(|i| [2800 + i, 2000]).collect();
|
||||
let run = |s: &mut Session, style: &str| {
|
||||
s.execute(
|
||||
"paint.artHistoryBrush",
|
||||
json!({
|
||||
"points": points,
|
||||
"size": 40,
|
||||
"hardness": 100,
|
||||
"area": 50,
|
||||
"tolerance": 0,
|
||||
"style": style,
|
||||
"state": 0
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
// Warm the allocator / tile cache.
|
||||
run(&mut s, "tightMedium");
|
||||
s.undo();
|
||||
|
||||
for style in ["tightMedium", "looseCurl"] {
|
||||
let t = Instant::now();
|
||||
run(&mut s, style);
|
||||
let ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!(
|
||||
"artHistoryBrush document {w}×{h} ({:.1} MP) stroke 200 px size 40 area 50 style {style} {ms:.3} ms",
|
||||
(w as u64 * h as u64) as f64 / 1e6
|
||||
);
|
||||
s.undo();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! End-to-end timing of `edit.contentAwareScale` (perf scenario P28, #211) without the UI: a
|
||||
//! synthetic 24 MP RGB photo opened from JPEG like `perf_scenarios`' (gradients, soft blobs, fine
|
||||
//! noise and a high-contrast disc), scaled to 90 % width, undone between runs.
|
||||
//! `cargo run --release -p photocraft-engine --example bench_content_aware_scale [reps] [width height]`
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn photo_jpeg(w: u32, h: u32) -> Result<Vec<u8>, String> {
|
||||
let mut px = vec![0u8; w as usize * h as usize * 3];
|
||||
let (cx, cy, r) = (w as f32 * 0.55, h as f32 * 0.5, w.min(h) as f32 / 3.0);
|
||||
for (y, row) in px.chunks_mut(w as usize * 3).enumerate() {
|
||||
for x in 0..w as usize {
|
||||
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
|
||||
let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5;
|
||||
let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp();
|
||||
let inside = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt() < r;
|
||||
let (r0, g0, b0) = if inside { (0.85, 0.3 + 0.2 * (fx * 40.0).sin(), 0.15) } else { (0.35, 0.45, 0.55) };
|
||||
let rgb = [r0 + 0.1 * blob + n * 0.08, g0 + 0.1 * blob + n * 0.08, b0 + 0.2 * fy + n * 0.08];
|
||||
for (c, v) in rgb.into_iter().enumerate() {
|
||||
row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
let img =
|
||||
photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).map_err(|e| e.to_string())?;
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let num = |i: usize, d: u32| args.get(i).and_then(|v| v.parse::<u32>().ok()).unwrap_or(d);
|
||||
let (reps, w, h) = (num(1, 3).clamp(1, 20) as usize, num(2, 6000).clamp(16, 20_000), num(3, 4000).clamp(16, 20_000));
|
||||
let jpeg = photo_jpeg(w, h)?;
|
||||
let doc = photocraft_io::import("photo.jpg", &jpeg).map_err(|e| e.to_string())?.document;
|
||||
let mut s = Session::new();
|
||||
s.open_document(doc, Some("photo.jpg".into()));
|
||||
let mut times = Vec::with_capacity(reps);
|
||||
for _ in 0..reps {
|
||||
let t = Instant::now();
|
||||
s.execute("edit.contentAwareScale", json!({"scaleX": 90, "scaleY": 100})).map_err(|e| e.to_string())?;
|
||||
times.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
s.execute("edit.undo", json!({})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
let (p50, max) = (times[times.len() / 2], times[times.len() - 1]);
|
||||
println!("edit.contentAwareScale {w}x{h} to 90 % width: p50 {p50:.1} ms, max {max:.1} ms ({reps} runs)");
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use photocraft_doc::LayerContent;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke};
|
||||
use photocraft_paint::mixer::{MixerSettings, MixerState};
|
||||
use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect};
|
||||
use photocraft_paint::tile::PREVIEW_SIDE;
|
||||
use photocraft_paint::{
|
||||
@@ -462,6 +462,8 @@ pub struct LiveStroke {
|
||||
/// document revision, params, seed and points), it takes these pixels instead of rendering the
|
||||
/// stroke again.
|
||||
pub struct LiveCommit {
|
||||
/// Mixer load before and after the stroke; transient, never serialized into the journal.
|
||||
pub(crate) mixer: Option<(MixerState, MixerState)>,
|
||||
pub(crate) cmd: String,
|
||||
pub(crate) base: (photocraft_doc::DocId, u64),
|
||||
pub(crate) params: Value,
|
||||
@@ -553,7 +555,16 @@ impl LiveStroke {
|
||||
pub fn into_commit(mut self) -> Result<LiveCommit> {
|
||||
let bounds = self.bounds();
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
|
||||
Ok(LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: Some(self.seed), points: self.points, surface: surf.clone(), bounds })
|
||||
Ok(LiveCommit {
|
||||
mixer: None,
|
||||
cmd: self.cmd,
|
||||
base: self.base,
|
||||
params: self.params,
|
||||
seed: Some(self.seed),
|
||||
points: self.points,
|
||||
surface: surf.clone(),
|
||||
bounds,
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything the stroke has touched so far.
|
||||
@@ -644,49 +655,9 @@ fn pct(p: &Value, k: &str, d: f32) -> f32 {
|
||||
num(p, k).unwrap_or(d).clamp(0.0, 100.0) / 100.0
|
||||
}
|
||||
|
||||
pub mod mixer_live;
|
||||
fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "paint.mixerBrush";
|
||||
let pts = parse_points(p, cmd)?;
|
||||
let brush = resolve_brush(s, p, cmd)?;
|
||||
// The brush's Mixer Brush options, overridden by the scalar params.
|
||||
let bm = &brush.mixer;
|
||||
let m = MixerSettings {
|
||||
wet: pct(p, "wet", bm.wet * 100.0),
|
||||
load: pct(p, "load", bm.load * 100.0),
|
||||
mix: pct(p, "mix", bm.mix * 100.0),
|
||||
flow: pct(p, "flow", bm.flow * 100.0),
|
||||
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers),
|
||||
load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke),
|
||||
clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke),
|
||||
};
|
||||
let sample_all = m.sample_all_layers;
|
||||
let paint = color(p.get("color"), s.tools.foreground);
|
||||
let mut state = s.tools.mixer.clone();
|
||||
state.begin_stroke(paint);
|
||||
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
|
||||
let stroke = Stroke { brush, points: pts };
|
||||
let dmg = s.edit("Mixer Brush", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let sample = if sample_all {
|
||||
let ds = photocraft_paint::dabs(&stroke);
|
||||
let ctx = photocraft_paint::BrushContext::new(&stroke.brush);
|
||||
let area = ds.iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false)));
|
||||
let buf = photocraft_compose::render(doc, area);
|
||||
let mut surf = Surface::new(photocraft_color::PixelFormat::RGBA32F);
|
||||
let flat: Vec<f32> = buf.px.iter().flatten().copied().collect();
|
||||
if !area.is_empty() {
|
||||
surf.write_region(area, &flat);
|
||||
}
|
||||
Some(surf)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock))
|
||||
})?;
|
||||
state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke);
|
||||
s.tools.mixer = state;
|
||||
Ok(damage_json(s, dmg))
|
||||
mixer_live::run(s, p)
|
||||
}
|
||||
|
||||
/// `brush.mixer.load`: Load Brush fills the Mixer Brush's reservoir with the color. The next stroke
|
||||
@@ -1192,10 +1163,18 @@ macro_rules! spec {
|
||||
/// Brush command specs.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"brush.mixer.sample",
|
||||
"Sample Mixer Brush",
|
||||
r#"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?]],"sampleAllLayers":bool=brush setting,"size":px?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}}"#,
|
||||
|s| s.active().map(|_| ()).ok_or_else(|| "no document".into()),
|
||||
mixer_live::sample,
|
||||
true
|
||||
),
|
||||
spec!(
|
||||
"paint.pencil",
|
||||
"Pencil",
|
||||
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
|
||||
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##,
|
||||
has_paintable,
|
||||
pencil,
|
||||
true
|
||||
@@ -1203,7 +1182,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"paint.mixerBrush",
|
||||
"Mixer Brush",
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##,
|
||||
r##"{"points":[…] (long segments are interpolated),"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke,"loadAfterStroke":bool=brush.mixer.loadAfterStroke,"seed":u64?,"mixerState":{reservoir,amount,pickup,spatial,color} (optional captured replay state; spatial is null or 4096 RGBA colors)}"##,
|
||||
has_paintable,
|
||||
mixer_brush,
|
||||
true
|
||||
@@ -1252,3 +1231,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mixer_live_tests;
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
//! Incremental Mixer Brush transaction. Source pixels are composed before each dab;
|
||||
//! only the selected target changes. Dropping the transaction cancels it.
|
||||
use super::*;
|
||||
use photocraft_color::{PixelFormat, SampleType};
|
||||
use photocraft_doc::{Document, LayerId};
|
||||
use photocraft_paint::dynamics::DabGenerator;
|
||||
use photocraft_paint::mixer::{MixerState, apply_mixer_dab_region};
|
||||
use photocraft_paint::retouch::{Region, mix_back};
|
||||
use photocraft_paint::{BrushContext, Dab};
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
const CMD: &str = "paint.mixerBrush";
|
||||
const MAX_TILES: usize = 256;
|
||||
/// The largest tip the live preview renders; larger valid tips render on release.
|
||||
const PREVIEW_SIZE: f32 = 1024.0;
|
||||
const TILE: i32 = 256;
|
||||
|
||||
/// Accept bounded finite channels, including HDR values, without admitting unsafe arithmetic inputs.
|
||||
fn valid_color(color: &[f32; 4]) -> bool {
|
||||
color.iter().all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
|
||||
}
|
||||
|
||||
/// Validate finite paint-load values and the exact 64 by 64 shape of a spatial reservoir.
|
||||
fn valid_state(state: &MixerState) -> bool {
|
||||
state.amount.is_finite()
|
||||
&& state.reservoir.as_ref().is_none_or(valid_color)
|
||||
&& state.pickup.as_ref().is_none_or(valid_color)
|
||||
&& state.color.as_ref().is_none_or(valid_color)
|
||||
&& state.spatial.as_ref().is_none_or(|colors| colors.len() == 4096 && colors.iter().all(valid_color))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Core {
|
||||
doc: Arc<Document>,
|
||||
generator: DabGenerator,
|
||||
ctx: BrushContext,
|
||||
pre: Surface,
|
||||
work: Surface,
|
||||
tiles: BTreeSet<(i32, i32)>,
|
||||
preview_limits: bool,
|
||||
state: MixerState,
|
||||
settings: MixerSettings,
|
||||
layer: Option<LayerId>,
|
||||
params: Value,
|
||||
selection: Option<Surface>,
|
||||
lock: bool,
|
||||
color_restrict: Option<usize>,
|
||||
count: usize,
|
||||
last: Option<StrokePoint>,
|
||||
bounds: Rect,
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Apply a batch against evolving working pixels and return its damage bounds.
|
||||
/// Dab, footprint and tile budgets apply only to live previews; the release replays without them.
|
||||
fn apply(&mut self, dabs: &[Dab]) -> Result<Rect> {
|
||||
if self.preview_limits && self.count.saturating_add(dabs.len()) > 250_000 {
|
||||
return Err(bad(CMD, "stroke exceeds 250000 dabs; start another stroke"));
|
||||
}
|
||||
let mut damage = Rect::EMPTY;
|
||||
for dab in dabs {
|
||||
let index = self.count;
|
||||
self.count += 1;
|
||||
let r = self.ctx.dab_rect(dab, false);
|
||||
if r.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let full = self.ctx.dab_rect(dab, false);
|
||||
if self.preview_limits && u64::from(full.width()).saturating_mul(u64::from(full.height())) > 4_194_304 {
|
||||
return Err(bad(CMD, "Mixer Brush footprint exceeds 4194304 pixels"));
|
||||
}
|
||||
// Float working tiles avoid repeated integer-depth quantization. Initialize only
|
||||
// touched tiles, keeping unchanged source tiles shared by the document snapshots.
|
||||
for ty in r.y0.div_euclid(TILE)..=(r.y1 - 1).div_euclid(TILE) {
|
||||
for tx in r.x0.div_euclid(TILE)..=(r.x1 - 1).div_euclid(TILE) {
|
||||
if !self.tiles.contains(&(tx, ty)) {
|
||||
if self.preview_limits && self.tiles.len() >= MAX_TILES {
|
||||
return Err(bad(CMD, "Mixer Brush stroke working set exceeds 256 tiles"));
|
||||
}
|
||||
let tile = Rect::new(tx * TILE, ty * TILE, (tx + 1) * TILE, (ty + 1) * TILE);
|
||||
self.work.write_region(tile, &self.pre.read_region(tile));
|
||||
self.tiles.insert((tx, ty));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 buf = photocraft_compose::render(&self.doc, r);
|
||||
let fmt = self.pre.format();
|
||||
let mut sample = Region::new(r, fmt.channels());
|
||||
for (src, dst) in buf.px.iter().zip(sample.data.chunks_exact_mut(sample.ch)) {
|
||||
photocraft_raster::from_rgba_into(&fmt, *src, dst);
|
||||
}
|
||||
sample
|
||||
});
|
||||
let fp = self.ctx.footprint(dab, index);
|
||||
let mut region = Region::read(&self.work, r);
|
||||
apply_mixer_dab_region(&mut region, &fp, self.pre.format(), sample.as_ref(), &self.settings, &mut self.state, self.lock);
|
||||
region.write(&mut self.work);
|
||||
let original = Region::read(&self.pre, r);
|
||||
let (target, _) = crate::channel_cmds::target_surface(Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
|
||||
mix_back(target, &original, ®ion, r, 1.0, self.selection.as_ref(), self.lock);
|
||||
if let Some(k) = self.color_restrict {
|
||||
let n = target.channels();
|
||||
let mut values = target.read_region(r);
|
||||
let before = self.pre.read_region(r);
|
||||
for (new, old) in values.chunks_exact_mut(n).zip(before.chunks_exact(n)) {
|
||||
for (c, (new, old)) in new.iter_mut().zip(old).enumerate() {
|
||||
if c != k {
|
||||
*new = *old;
|
||||
}
|
||||
}
|
||||
}
|
||||
target.write_region(r, &values);
|
||||
}
|
||||
damage = damage.union(&r);
|
||||
}
|
||||
self.bounds = self.bounds.union(&damage);
|
||||
Ok(damage)
|
||||
}
|
||||
}
|
||||
|
||||
/// A real, cancellable paint result, displayed by the existing damage-aware canvas.
|
||||
pub struct LiveMixer {
|
||||
pub doc: Arc<Document>,
|
||||
pub seed: u64,
|
||||
core: Core,
|
||||
state: MixerState,
|
||||
tail: Rect,
|
||||
cmyk: Option<Arc<photocraft_color::convert::CmykSpace>>,
|
||||
original: Arc<Document>,
|
||||
points: Vec<StrokePoint>,
|
||||
original_state: MixerState,
|
||||
}
|
||||
|
||||
impl LiveMixer {
|
||||
/// Capture the current document and paint load, then render the initial samples with live-preview limits.
|
||||
pub fn begin(s: &Session, p: &Value) -> Result<Self> {
|
||||
Self::begin_with_limits(s, p, true)
|
||||
}
|
||||
|
||||
/// Share initialization between live previews and command replay while keeping their tile budgets distinct.
|
||||
fn begin_with_limits(s: &Session, p: &Value, preview_limits: bool) -> Result<Self> {
|
||||
let (mut live, points) = Self::prepare(s, p, preview_limits)?;
|
||||
live.push(&points)?;
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// Whether a stroke the live preview refuses only for its tip size still paints on release.
|
||||
/// `Err` when the stroke can't be painted at all (locked layer, bad params...).
|
||||
pub fn defers_to_release(s: &Session, p: &Value) -> Result<bool> {
|
||||
let (live, _) = Self::prepare(s, p, false)?;
|
||||
Ok(live.core.ctx.brush.size > PREVIEW_SIZE)
|
||||
}
|
||||
|
||||
/// Validate the params and capture the document and paint load, without rendering a dab.
|
||||
fn prepare(s: &Session, p: &Value, preview_limits: bool) -> Result<(Self, Vec<StrokePoint>)> {
|
||||
has_paintable(s).map_err(EngineError::Other)?;
|
||||
if preview_limits && p.get("points").and_then(Value::as_array).is_some_and(|v| v.len() > 65_536) {
|
||||
return Err(bad(CMD, "stroke exceeds 65536 recorded input samples"));
|
||||
}
|
||||
let points = parse_points(p, CMD)?;
|
||||
let brush = resolve_brush(s, p, CMD)?;
|
||||
let bm = &brush.mixer;
|
||||
let settings = MixerSettings {
|
||||
wet: pct(p, "wet", bm.wet * 100.0),
|
||||
load: pct(p, "load", bm.load * 100.0),
|
||||
mix: pct(p, "mix", bm.mix * 100.0),
|
||||
flow: pct(p, "flow", bm.flow * 100.0),
|
||||
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels,
|
||||
clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke),
|
||||
load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke),
|
||||
};
|
||||
let initial_state = if let Some(value) = p.get("mixerState") {
|
||||
if value.get("spatial").and_then(Value::as_array).is_some_and(|v| v.len() != 4096) {
|
||||
return Err(bad(CMD, "spatial reservoir must contain exactly 4096 colors"));
|
||||
}
|
||||
serde_json::from_value::<MixerState>(value.clone()).map_err(|e| bad(CMD, e.to_string()))?
|
||||
} else {
|
||||
s.tools.mixer.clone()
|
||||
};
|
||||
let mut state = initial_state;
|
||||
if !valid_state(&state) {
|
||||
return Err(bad(CMD, "loaded paint must be finite and color magnitudes at most 1000000"));
|
||||
}
|
||||
let paint = color(p.get("color"), s.tools.foreground);
|
||||
if !valid_color(&paint) {
|
||||
return Err(bad(CMD, "loaded color must be finite and magnitudes at most 1000000"));
|
||||
}
|
||||
// Earlier captured replay states had no foreground marker. Keep their loaded paint.
|
||||
if p.get("mixerState").is_some_and(|v| v.get("color").is_none()) && (state.reservoir.is_some() || state.spatial.is_some()) {
|
||||
state.color = Some(paint);
|
||||
}
|
||||
state.begin_stroke(paint);
|
||||
let layer = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
|
||||
let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone();
|
||||
let selection = doc.selection.clone();
|
||||
let (target, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?;
|
||||
let pre = target.clone();
|
||||
let work = Surface::new(PixelFormat { sample: SampleType::F32, ..pre.format() });
|
||||
let core = Core {
|
||||
doc: Arc::new(doc),
|
||||
generator: DabGenerator::new(&brush, num(p, "zoom").unwrap_or(1.0)),
|
||||
ctx: BrushContext::new(&brush),
|
||||
pre,
|
||||
work,
|
||||
tiles: BTreeSet::new(),
|
||||
preview_limits,
|
||||
state: state.clone(),
|
||||
settings,
|
||||
layer,
|
||||
params: p.clone(),
|
||||
selection,
|
||||
lock,
|
||||
color_restrict: crate::channel_cmds::color_restriction(s, CMD, p),
|
||||
count: 0,
|
||||
last: None,
|
||||
bounds: Rect::EMPTY,
|
||||
};
|
||||
// Checked last, so a tip that is too big is the only reason left (`defers_to_release`),
|
||||
// and before `push` allocates a dense brush footprint.
|
||||
if preview_limits && core.ctx.brush.size > PREVIEW_SIZE {
|
||||
return Err(bad(CMD, "the live Mixer Brush preview is limited to 1024 px tips"));
|
||||
}
|
||||
let live = Self {
|
||||
doc: core.doc.clone(),
|
||||
seed: brush.seed,
|
||||
core,
|
||||
state,
|
||||
tail: Rect::EMPTY,
|
||||
cmyk: doc_cmyk_space(s),
|
||||
original: s.active().ok_or(EngineError::NoDocument)?.doc.clone(),
|
||||
points: Vec::new(),
|
||||
original_state: s.tools.mixer.clone(),
|
||||
};
|
||||
Ok((live, points))
|
||||
}
|
||||
|
||||
/// Return all damaged pixels, including the provisional smoothing tail currently shown by the preview.
|
||||
pub fn bounds(&self) -> Rect {
|
||||
self.core.bounds.union(&self.tail)
|
||||
}
|
||||
/// Whether the dab generator needs stationary time samples, as for a continuing airbrush stroke.
|
||||
pub fn wants_time(&self) -> bool {
|
||||
self.core.generator.wants_time()
|
||||
}
|
||||
|
||||
/// Batches and individual input samples produce identical results. The speculative end
|
||||
/// dab / smoothing tail is displayed but never fed into the next accepted input.
|
||||
pub fn push(&mut self, points: &[StrokePoint]) -> Result<Rect> {
|
||||
check_coords(points, CMD)?;
|
||||
if self.core.preview_limits && self.points.len().saturating_add(points.len()) > 65_536 {
|
||||
return Err(bad(CMD, "stroke exceeds 65536 recorded input samples"));
|
||||
}
|
||||
let cmyk = self.cmyk.clone();
|
||||
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
|
||||
let old_tail = self.tail;
|
||||
let mut damage = Rect::EMPTY;
|
||||
for point in points {
|
||||
// Bound each temporary dab batch without rejecting sparse mouse events or
|
||||
// long Shift-click lines. Live and recorded replay use identical interpolation.
|
||||
let start = self.core.last.unwrap_or(*point);
|
||||
let steps = ((point.x - start.x).hypot(point.y - start.y) / 2048.0).ceil().max(1.0) as usize;
|
||||
for step in 1..=steps {
|
||||
let f = step as f64 / steps as f64;
|
||||
let ff = f as f32;
|
||||
let q = if step == steps {
|
||||
*point
|
||||
} else {
|
||||
StrokePoint {
|
||||
x: start.x + (point.x - start.x) * f,
|
||||
y: start.y + (point.y - start.y) * f,
|
||||
pressure: start.pressure + (point.pressure - start.pressure) * ff,
|
||||
tilt_x: start.tilt_x + (point.tilt_x - start.tilt_x) * ff,
|
||||
tilt_y: start.tilt_y + (point.tilt_y - start.tilt_y) * ff,
|
||||
rotation: start.rotation + (point.rotation - start.rotation) * ff,
|
||||
wheel: start.wheel + (point.wheel - start.wheel) * ff,
|
||||
time: start.time + (point.time - start.time) * f,
|
||||
}
|
||||
};
|
||||
let (mut dabs, mut dual) = (Vec::new(), Vec::new());
|
||||
self.core.generator.push(&[q], &mut dabs, &mut dual);
|
||||
damage = damage.union(&self.core.apply(&dabs)?);
|
||||
}
|
||||
self.core.last = Some(*point);
|
||||
}
|
||||
let mut finished = self.core.clone();
|
||||
let (mut dabs, mut dual) = (Vec::new(), Vec::new());
|
||||
finished.generator.finish(&mut dabs, &mut dual);
|
||||
self.tail = finished.apply(&dabs)?;
|
||||
self.doc = finished.doc;
|
||||
self.state = finished.state;
|
||||
self.points.extend_from_slice(points);
|
||||
Ok(damage.union(&old_tail).union(&self.tail))
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep the same compact journal params as other paint tools: UI settings, points and seed.
|
||||
fn commit_params(&self) -> Result<Value> {
|
||||
let mut params = self.core.params.clone();
|
||||
params["points"] = serde_json::to_value(&self.points).map_err(|e| bad(CMD, e.to_string()))?;
|
||||
params["seed"] = json!(self.seed);
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
/// Stage the already rendered pixels for the ordinary journaled command. Refuse stale
|
||||
/// document/tool state. The command consumes this single-use cache only for identical params.
|
||||
pub fn prepare_commit(self, session: &mut Session) -> Result<Value> {
|
||||
if !session.active().is_some_and(|st| Arc::ptr_eq(&st.doc, &self.original) && (self.core.layer.is_none() || st.active_layer == self.core.layer))
|
||||
|| session.tools.mixer != self.original_state
|
||||
{
|
||||
return Err(bad(CMD, "document or loaded paint changed during the stroke"));
|
||||
}
|
||||
let params = self.commit_params()?;
|
||||
let base = session.active().map(|st| (st.doc.id, st.revision)).ok_or(EngineError::NoDocument)?;
|
||||
let bounds = self.bounds();
|
||||
let mut doc = (*self.doc).clone();
|
||||
let surface = crate::channel_cmds::target_surface(&mut doc, self.core.layer, ¶ms)?.0.clone();
|
||||
let mut after = self.state;
|
||||
after.end_stroke(self.core.ctx.brush.color, self.core.settings.clean_after_stroke, self.core.settings.load_after_stroke);
|
||||
session.live_commit = Some(LiveCommit {
|
||||
cmd: CMD.into(),
|
||||
base,
|
||||
params: params.clone(),
|
||||
seed: Some(self.seed),
|
||||
points: self.points,
|
||||
surface,
|
||||
bounds,
|
||||
mixer: Some((self.original_state, after)),
|
||||
});
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume a matching prepared result or replay without the preview tile cap, then commit one history step.
|
||||
/// The completed stroke also applies the automatic clean/load settings to the stored paint load.
|
||||
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let points = parse_points(p, CMD)?;
|
||||
let cached =
|
||||
s.live_commit.take().filter(|live| live.matches(s, CMD, p, &points) && live.mixer.as_ref().is_some_and(|(before, _)| *before == s.tools.mixer));
|
||||
if let Some(live) = cached {
|
||||
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
|
||||
s.edit("Mixer Brush", |doc, _| {
|
||||
*crate::channel_cmds::target_surface(doc, id, p)?.0 = live.surface;
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some((_, after)) = live.mixer {
|
||||
s.tools.mixer = after;
|
||||
}
|
||||
let mut result = damage_json(s, live.bounds);
|
||||
result["fromLive"] = json!(true);
|
||||
return Ok(result);
|
||||
}
|
||||
let live = LiveMixer::begin_with_limits(s, p, false)?;
|
||||
let damage = live.bounds();
|
||||
s.edit("Mixer Brush", |doc, _| {
|
||||
*doc = (*live.doc).clone();
|
||||
Ok(())
|
||||
})?;
|
||||
let mut state = live.state;
|
||||
let paint = live.core.ctx.brush.color;
|
||||
state.end_stroke(paint, live.core.settings.clean_after_stroke, live.core.settings.load_after_stroke);
|
||||
s.tools.mixer = state;
|
||||
Ok(damage_json(s, damage))
|
||||
}
|
||||
|
||||
/// Sampling changes only session tool state, never document pixels or document Undo.
|
||||
pub(super) fn sample(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("points").and_then(Value::as_array).is_some_and(|v| v.len() != 1) {
|
||||
return Err(bad("brush.mixer.sample", "exactly one sample point is required"));
|
||||
}
|
||||
let points = parse_points(p, "brush.mixer.sample")?;
|
||||
if points.len() != 1 {
|
||||
return Err(bad("brush.mixer.sample", "exactly one sample point is required"));
|
||||
}
|
||||
let mut brush = resolve_brush(s, p, "brush.mixer.sample")?;
|
||||
if brush.size > 1024.0 {
|
||||
return Err(bad("brush.mixer.sample", "sampling size is limited to 1024 pixels"));
|
||||
}
|
||||
brush.flow = 1.0;
|
||||
let dabs = photocraft_paint::dabs(&Stroke { brush: brush.clone(), points });
|
||||
let dab = dabs.first().ok_or_else(|| bad("brush.mixer.sample", "brush has no contact"))?;
|
||||
let ctx = BrushContext::new(&brush);
|
||||
let rect = ctx.dab_rect(dab, false);
|
||||
if u64::from(rect.width()).saturating_mul(u64::from(rect.height())) > 4_194_304 {
|
||||
return Err(bad("brush.mixer.sample", "sampling footprint is too large"));
|
||||
}
|
||||
let fp = ctx.footprint(dab, 0);
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let mut doc = crate::float_cmds::displayed(st, (0, 0)).unwrap_or_else(|| (*st.doc).clone());
|
||||
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
|
||||
let cmyk = doc_cmyk_space(s);
|
||||
let load = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || -> Result<Vec<[f32; 4]>> {
|
||||
let surface = if flag(p, "sampleAllLayers", brush.mixer.sample_all_layers) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels {
|
||||
let buf = photocraft_compose::render(&doc, rect);
|
||||
let mut surface = Surface::new(PixelFormat::RGBA32F);
|
||||
surface.write_region(rect, &buf.px.iter().flatten().copied().collect::<Vec<_>>());
|
||||
surface
|
||||
} else if crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels {
|
||||
id.and_then(|id| doc.layer(id))
|
||||
.and_then(|layer| layer.surface())
|
||||
.cloned()
|
||||
.ok_or_else(|| bad("brush.mixer.sample", "current layer has no raster pixels to sample"))?
|
||||
} else {
|
||||
crate::channel_cmds::target_surface(&mut doc, id, p)?.0.clone()
|
||||
};
|
||||
let mut load = Vec::with_capacity(4096);
|
||||
let (sin, cos) = dab.angle.sin_cos();
|
||||
for row in 0..64 {
|
||||
for col in 0..64 {
|
||||
let mut u = ((col as f32 + 0.5) / 32.0 - 1.0) * dab.radius;
|
||||
let mut v = ((row as f32 + 0.5) / 32.0 - 1.0) * (dab.radius * dab.roundness).max(0.5);
|
||||
if dab.flip_x {
|
||||
u = -u;
|
||||
}
|
||||
if dab.flip_y {
|
||||
v = -v;
|
||||
}
|
||||
let (mut dx, mut up) = (u * cos + v * sin, u * sin - v * cos);
|
||||
if dab.proj_scale < 0.999 {
|
||||
let (sin, cos) = dab.proj_angle.sin_cos();
|
||||
let along = (dx * cos + up * sin) * (dab.proj_scale.max(0.05) - 1.0);
|
||||
dx += along * cos;
|
||||
up += along * sin;
|
||||
}
|
||||
let x = (dab.center.x as f32 + dx).floor() as i32;
|
||||
let y = (dab.center.y as f32 - up).floor() as i32;
|
||||
let mut color = surface.rgba(x, y);
|
||||
let coverage = if x >= fp.rect.x0 && x < fp.rect.x1 && y >= fp.rect.y0 && y < fp.rect.y1 { fp.at(x, y) } else { 0.0 };
|
||||
color[3] *= coverage.clamp(0.0, 1.0);
|
||||
load.push(color);
|
||||
}
|
||||
}
|
||||
Ok(load)
|
||||
})?;
|
||||
let mut average = [0.0f32; 4];
|
||||
for c in &load {
|
||||
for k in 0..3 {
|
||||
average[k] += c[k] * c[3];
|
||||
}
|
||||
average[3] += c[3];
|
||||
}
|
||||
if average[3] <= 0.0 {
|
||||
return Err(bad("brush.mixer.sample", "the sampled area contains no paint; the previous brush load is unchanged"));
|
||||
}
|
||||
for k in 0..3 {
|
||||
average[k] /= average[3];
|
||||
}
|
||||
average[3] = 1.0;
|
||||
s.tools.mixer.load(average);
|
||||
s.tools.mixer.spatial = Some(load);
|
||||
s.tools.mixer.pickup = None;
|
||||
s.tools.mixer.color = Some(s.tools.foreground);
|
||||
Ok(json!({"width":64,"height":64,"color":average}))
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
use super::mixer_live::LiveMixer;
|
||||
use super::*;
|
||||
use photocraft_color::PixelFormat;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Build format-specific red/green sampling layers and an empty painting target.
|
||||
fn fixture(fmt: PixelFormat) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":192,"height":96,"background":"#808080"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name":"Source"})).unwrap();
|
||||
s.edit("fixture", |doc, id| {
|
||||
let (surface, _) = crate::channel_cmds::target_surface(doc, *id, &json!({}))?;
|
||||
*surface = Surface::new(fmt);
|
||||
surface.fill_rect(Rect::new(0, 0, 96, 96), &[1.0, 0.0, 0.0, 1.0]);
|
||||
surface.fill_rect(Rect::new(96, 0, 192, 96), &[0.0, 1.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.new.layer", json!({"name":"Paint"})).unwrap();
|
||||
s.edit("depth", |doc, id| {
|
||||
*crate::channel_cmds::target_surface(doc, *id, &json!({}))?.0 = Surface::new(fmt);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.tools.brush.mixer.sample_all_layers = true;
|
||||
s
|
||||
}
|
||||
|
||||
/// Snapshot the active raster target for exact pixel comparisons without borrowing the session.
|
||||
fn target(s: &Session) -> Surface {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone()
|
||||
}
|
||||
|
||||
/// All-layer previews match replay and Undo exactly across supported depths without editing source layers.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_live_empty_layer_exact_commit_undo_source_preservation_and_chunking() {
|
||||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
let mut s = fixture(fmt);
|
||||
assert!(s.tools.brush.mixer.sample_all_layers);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let params = json!({"points":[[40,48]],"seed":47,"size":24,"wet":100,"mix":100,"cleanAfterStroke":false});
|
||||
let mut live = LiveMixer::begin(&s, ¶ms).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
assert!(live.doc.layer(id).unwrap().surface().unwrap().rgba(40, 48)[3] > 0.0);
|
||||
assert_eq!(target(&s).rgba(40, 48)[3], 0.0, "preview must not edit the session");
|
||||
let mut points = vec![StrokePoint::new(40.0, 48.0, 1.0)];
|
||||
for x in [56.0, 72.0, 88.0, 104.0, 120.0, 136.0, 152.0] {
|
||||
let p = StrokePoint::new(x, 48.0, 1.0);
|
||||
live.push(&[p]).unwrap();
|
||||
points.push(p);
|
||||
}
|
||||
let preview = live.doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
let mut full = params.clone();
|
||||
full["points"] = json!(points);
|
||||
s.execute("paint.mixerBrush", full).unwrap();
|
||||
assert_eq!(preview.read_region(before.bounds()), target(&s).read_region(before.bounds()), "live/replay tolerance is exactly zero");
|
||||
for old in &before.layers {
|
||||
if old.id != id {
|
||||
assert_eq!(
|
||||
old.surface().unwrap().read_region(before.bounds()),
|
||||
s.active().unwrap().doc.layer(old.id).unwrap().surface().unwrap().read_region(before.bounds())
|
||||
);
|
||||
}
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(target(&s).read_region(before.bounds()), before.layer(id).unwrap().surface().unwrap().read_region(before.bounds()));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(target(&s).read_region(before.bounds()), preview.read_region(before.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() {
|
||||
let mut s = fixture(PixelFormat::RGBA32F);
|
||||
let p = json!({"points":[[30,30],[60,34],[100,60]],"seed":9,"size":16,"sampleAllLayers":false,"wet":65,"mix":70,"load":90});
|
||||
let mut expected = target(&s);
|
||||
let brush = resolve_brush(&s, &p, "paint.mixerBrush").unwrap();
|
||||
let mut state = photocraft_paint::mixer::MixerState::default();
|
||||
state.load(s.tools.foreground);
|
||||
photocraft_paint::mixer::apply_mixer_stroke(
|
||||
&mut expected,
|
||||
None,
|
||||
&Stroke { brush, points: parse_points(&p, "paint.mixerBrush").unwrap() },
|
||||
&MixerSettings { wet: 0.65, mix: 0.70, load: 0.90, sample_all_layers: false, ..Default::default() },
|
||||
&mut state,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let live = LiveMixer::begin(&s, &p).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let actual = live.doc.layer(id).unwrap().surface().unwrap().read_region(s.active().unwrap().doc.bounds());
|
||||
let expected = expected.read_region(s.active().unwrap().doc.bounds());
|
||||
let max = actual.iter().zip(expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(max <= 1e-6, "reference error {max}");
|
||||
drop(live);
|
||||
assert_eq!(target(&s).rgba(30, 30)[3], 0.0);
|
||||
s.execute("brush.mixer.clean", json!({})).unwrap();
|
||||
s.execute("paint.mixerBrush", json!({"points":[[30,30],[60,30]],"sampleAllLayers":false,"wet":0})).unwrap();
|
||||
assert_eq!(target(&s).rgba(30, 30)[3], 0.0, "clean brush must stay empty");
|
||||
}
|
||||
|
||||
/// Sampling retains a spatial two-color load while leaving document pixels and history alone.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_sample_is_spatial_and_never_edits_document() {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
s.execute("brush.mixer.sample", json!({"points":[[96,48]],"size":40,"brush":{"hardness":1.0,"pressureSize":false}})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.active().unwrap().doc));
|
||||
let load = s.tools.mixer.spatial.as_ref().unwrap();
|
||||
assert_eq!(load.len(), 4096);
|
||||
assert!(load.iter().any(|p| p[0] > 0.9 && p[1] < 0.1 && p[3] > 0.9));
|
||||
assert!(load.iter().any(|p| p[1] > 0.9 && p[0] < 0.1 && p[3] > 0.9));
|
||||
s.execute("paint.mixerBrush", json!({"points":[[48,48]],"size":40,"wet":0,"mix":0,"brush":{"hardness":1.0,"pressureSize":false}})).unwrap();
|
||||
assert!(target(&s).rgba(40, 48)[0] > 0.9);
|
||||
assert!(target(&s).rgba(56, 48)[1] > 0.9);
|
||||
assert!(s.tools.mixer.spatial.is_some(), "sample persists across strokes");
|
||||
}
|
||||
|
||||
/// Smoothing tails match replay and invalid input cannot mutate the last valid preview.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_live_smoothing_tail_and_invalid_input() {
|
||||
let mut s = fixture(PixelFormat::RGBA16);
|
||||
let p = json!({"points":[[20,40]],"size":12,"seed":42,"brush":{"smoothing":{"amount":0.6,"catchUpOnEnd":true}}});
|
||||
let mut live = LiveMixer::begin(&s, &p).unwrap();
|
||||
live.push(&[StrokePoint::new(60.0, 40.0, 0.6)]).unwrap();
|
||||
live.push(&[StrokePoint::new(100.0, 48.0, 1.0)]).unwrap();
|
||||
let before = live.doc.clone();
|
||||
assert!(live.push(&[StrokePoint::new(f64::NAN, 0.0, 1.0)]).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &live.doc));
|
||||
let mut commit = p.clone();
|
||||
commit["points"] = json!([[20, 40, 1], [60, 40, 0.6], [100, 48, 1]]);
|
||||
s.execute("paint.mixerBrush", commit).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
assert_eq!(
|
||||
live.doc.layer(id).unwrap().surface().unwrap().read_region(s.active().unwrap().doc.bounds()),
|
||||
target(&s).read_region(s.active().unwrap().doc.bounds())
|
||||
);
|
||||
assert!(s.execute("paint.mixerBrush", json!({"points":[[0,0]],"size":5001})).is_err());
|
||||
assert!(s.execute("brush.mixer.sample", json!({"points":[[0,0],[1,1]]})).is_err());
|
||||
}
|
||||
|
||||
/// Composite sampling honors source masks and blending while current-layer mode ignores lower paint.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_sampling_obeys_masks_opacity_blend_and_current_layer_switch() {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
let source = s.active().unwrap().doc.layers.iter().find(|l| l.name == "Source").unwrap().id;
|
||||
s.edit("source composition", |doc, _| {
|
||||
let layer = doc.layer_mut(source).unwrap();
|
||||
layer.opacity = 0.4;
|
||||
layer.blend = photocraft_color::BlendMode::Multiply;
|
||||
layer.mask = Some(photocraft_doc::LayerMask::hide_all());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let expected = photocraft_compose::render(&s.active().unwrap().doc, Rect::new(39, 39, 40, 40)).px[0];
|
||||
s.execute("brush.mixer.sample", json!({"points":[[40,40]],"size":8})).unwrap();
|
||||
let actual = s.tools.mixer.reservoir.unwrap();
|
||||
for c in 0..3 {
|
||||
assert!((actual[c] - expected[c]).abs() < 1e-5);
|
||||
}
|
||||
s.execute("brush.mixer.clean", json!({})).unwrap();
|
||||
s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":8,"wet":100,"mix":100,"sampleAllLayers":false})).unwrap();
|
||||
assert_eq!(target(&s).rgba(50, 40)[3], 0.0, "current empty layer provides no paint");
|
||||
s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":8,"wet":100,"mix":100,"sampleAllLayers":true})).unwrap();
|
||||
assert!(target(&s).rgba(50, 40)[3] > 0.9, "all layers supplies the visible composite");
|
||||
}
|
||||
|
||||
/// Later overlapping dabs pick up freshly deposited paint from the live composite.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_overlapping_dabs_resample_the_evolving_composite() {
|
||||
let mut s = fixture(PixelFormat::RGBA32F);
|
||||
s.execute("tools.setColors", json!({"foreground":"#0000ff"})).unwrap();
|
||||
let params = json!({"points":[[50,48]],"size":20,"seed":1,"wet":100,"mix":50,"cleanAfterStroke":false});
|
||||
let mut live = LiveMixer::begin(&s, ¶ms).unwrap();
|
||||
live.push(&[StrokePoint::new(54.0, 48.0, 1.0), StrokePoint::new(50.0, 48.0, 1.0)]).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let pixel = live.doc.layer(id).unwrap().surface().unwrap().rgba(50, 48);
|
||||
assert!(pixel[2] > 0.5, "deposited blue feeds back into later pickups: {pixel:?}");
|
||||
}
|
||||
|
||||
/// The shared live commit keeps shown pixels and one Undo step, refusing stale document snapshots.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_prepared_commit_uses_displayed_pixels_and_rejects_stale_documents() {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
let p = json!({"points":[[20,40]],"size":16,"target":"pixels","seed":55});
|
||||
let mut live = LiveMixer::begin(&s, &p).unwrap();
|
||||
live.push(&[StrokePoint::new(80.0, 40.0, 0.6)]).unwrap();
|
||||
let shown = live.doc.clone();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let commit = live.prepare_commit(&mut s).unwrap();
|
||||
assert!(s.live_commit.is_some());
|
||||
s.execute("paint.mixerBrush", commit.clone()).unwrap();
|
||||
assert!(s.live_commit.is_none());
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
|
||||
assert_eq!(target(&s).read_region(shown.bounds()), shown.layer(id).unwrap().surface().unwrap().read_region(shown.bounds()));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("paint.mixerBrush", commit).unwrap();
|
||||
assert_eq!(target(&s).read_region(shown.bounds()), shown.layer(id).unwrap().surface().unwrap().read_region(shown.bounds()));
|
||||
let stale = LiveMixer::begin(&s, &p).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
assert!(stale.prepare_commit(&mut s).is_err());
|
||||
}
|
||||
|
||||
/// Load and clean affect tool state without dropping a floating selection or creating document history.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_load_commands_preserve_floating_selection_and_history() {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
s.execute("select.float", json!({"dx":8,"dy":0,"copy":true})).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
for (command, params) in [("brush.mixer.sample", json!({"points":[[96,48]],"size":24})), ("brush.mixer.load", json!({})), ("brush.mixer.clean", json!({}))]
|
||||
{
|
||||
s.execute(command, params).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
assert!(crate::float_cmds::floating(s.active().unwrap()).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// Malformed reservoir input is rejected and automatic reload follows the selected setting.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_rejects_malformed_load_and_retains_or_reloads_paint() {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
for state in [json!({"spatial":[]}), json!({"spatial":[[1,0,0,1]]}), json!({"amount":"bad"}), json!({"reservoir":[1e300,0,0,1]})] {
|
||||
assert!(s.execute("paint.mixerBrush", json!({"points":[[40,40]],"mixerState":state})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.active().unwrap().doc));
|
||||
}
|
||||
assert!(s.execute("brush.mixer.load", json!({"color":[1e300,0,0,1]})).is_err());
|
||||
s.execute("brush.mixer.load", json!({"color":"#ff0000"})).unwrap();
|
||||
s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":10,"loadAfterStroke":false,"cleanAfterStroke":false,"load":10})).unwrap();
|
||||
assert!(s.tools.mixer.amount > 0.0 && s.tools.mixer.amount < 1.0);
|
||||
s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":10,"loadAfterStroke":true,"load":10})).unwrap();
|
||||
assert_eq!(s.tools.mixer.amount, 1.0);
|
||||
}
|
||||
|
||||
/// Large tips keep off-canvas layer pixels and commit the exact preview with reversible history.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_large_tip_retains_off_canvas_paint_and_commits_the_live_result() {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
let source = s.active().unwrap().doc.clone();
|
||||
let params = json!({"points":[[96,48]],"size":1024,"wet":0,"mix":0,"color":"#1122ee","brush":{"pressureSize":false}});
|
||||
let live = LiveMixer::begin(&s, ¶ms).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let shown = live.doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
assert!(shown.rgba(-100, 48)[3] > 0.99, "off-canvas layer pixels remain paintable");
|
||||
let params = live.prepare_commit(&mut s).unwrap();
|
||||
s.execute("paint.mixerBrush", params).unwrap();
|
||||
assert_eq!(target(&s), shown);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.active().unwrap().doc, *source);
|
||||
}
|
||||
|
||||
/// Large pointer jumps must replay identically instead of turning a valid stroke into an input-limit error.
|
||||
#[test]
|
||||
fn mixer_long_segments_are_interpolated_and_replay_matches_live() {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
let p = json!({"points":[[20,48],[5020,48]],"seed":71,"size":2,"sampleAllLayers":false,"wet":0,"mix":0,"load":100,"color":"#ff0000"});
|
||||
let live = LiveMixer::begin(&s, &p).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let expected = live.doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
s.execute("paint.mixerBrush", p).unwrap();
|
||||
for x in [20, 1020, 2068, 4090, 5019] {
|
||||
assert_eq!(target(&s).rgba(x, 48), expected.rgba(x, 48));
|
||||
assert!(target(&s).rgba(x, 48)[3] > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorization, validation, unknown commands and missing targets all discard the single-use offer.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_prepared_offer_is_dropped_on_every_early_dispatch_failure() {
|
||||
for failure in 0..4 {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
let live = LiveMixer::begin(&s, &json!({"points":[[20,20]],"size":4})).unwrap();
|
||||
let p = live.prepare_commit(&mut s).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let result = match failure {
|
||||
0 => {
|
||||
s.authorize = Some(|_, _| Err(EngineError::Other("denied".into())));
|
||||
s.execute("paint.mixerBrush", p)
|
||||
}
|
||||
1 => s.execute("paint.mixerBrush", json!("invalid params")),
|
||||
2 => s.execute("unknown.command", p),
|
||||
_ => {
|
||||
s.active_mut().unwrap().active_layer = None;
|
||||
s.execute("paint.mixerBrush", p)
|
||||
}
|
||||
};
|
||||
assert!(result.is_err());
|
||||
assert!(s.live_commit.is_none(), "failed dispatch retained the preview: {failure}");
|
||||
assert!(Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke beyond the live tile budget still renders on command replay and remains undoable.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_command_outgrows_the_preview_tile_budget_without_losing_the_stroke() {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
let points: Vec<_> = (0..257).map(|i| [128.0 + (i % 17) as f64 * 512.0, 128.0 + (i / 17) as f64 * 512.0]).collect();
|
||||
let p = json!({"points":points,"size":2,"brush":{"spacing":10.0,"smoothing":{"amount":0.0}},"seed":44,
|
||||
"sampleAllLayers":false,"wet":0,"mix":0,"load":100,"color":"#ff0000"});
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let err = LiveMixer::begin(&s, &p).err().expect("bounded preview");
|
||||
assert!(err.to_string().contains("256 tiles"), "{err}");
|
||||
assert!(Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
s.execute("paint.mixerBrush", p).unwrap();
|
||||
// Spacing deliberately leaves gaps; inspect the final tile's stroke region instead
|
||||
// of assuming the exact release pixel is a dab center.
|
||||
let end = Rect::new(600, 7776, 672, 7840);
|
||||
assert!(target(&s).read_region(end).as_chunks::<4>().0.iter().any(|px| px[3] > 0.0));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(target(&s).read_region(end).as_chunks::<4>().0.iter().all(|px| px[3] == 0.0));
|
||||
}
|
||||
|
||||
/// An empty sample reports an error while retaining the previous paint load and document.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_empty_samples_preserve_the_previous_load_and_document() {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
s.execute("brush.mixer.sample", json!({"points": [[96,48]], "size": 40})).unwrap();
|
||||
let state = s.tools.mixer.clone();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
for params in [json!({"points": [[20,20]], "size": 8, "sampleAllLayers": false}), json!({"points": [[-100,-100]], "size": 8, "sampleAllLayers": true})] {
|
||||
let error = s.execute("brush.mixer.sample", params).unwrap_err().to_string();
|
||||
assert!(error.contains("contains no paint"), "{error}");
|
||||
assert_eq!(s.tools.mixer, state);
|
||||
assert!(Arc::ptr_eq(&s.active().unwrap().doc, &doc));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotating a hard round sampling grid must give zero coverage beyond the footprint, not panic.
|
||||
#[test]
|
||||
fn mixer_rotated_hard_tip_samples_safely_and_preserves_the_document() {
|
||||
for angle in [45.0, -45.0, 135.0] {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
s.execute("tools.setBrush", json!({"brush":{"size":40,"angle":angle,"hardness":1.0,"pressureSize":false}})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
s.execute("brush.mixer.sample", json!({"points":[[48,48]]})).unwrap();
|
||||
let load = s.tools.mixer.spatial.as_ref().unwrap();
|
||||
assert_eq!(load.len(), 4096);
|
||||
assert!(load.iter().any(|c| c[3] == 0.0));
|
||||
assert!(load.iter().any(|c| c[0] > 0.9 && c[3] > 0.9));
|
||||
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
|
||||
}
|
||||
}
|
||||
|
||||
/// The default stays current-layer-only; all-layer sampling is an explicit user choice.
|
||||
#[test]
|
||||
fn mixer_default_does_not_sample_other_layers() {
|
||||
assert!(!MixerSettings::default().sample_all_layers);
|
||||
assert!(!Session::new().tools.brush.mixer.sample_all_layers);
|
||||
}
|
||||
|
||||
/// Large session tips and spatial reservoirs stay out of each journaled stroke.
|
||||
#[test]
|
||||
fn mixer_shared_live_commit_keeps_the_journal_compact() {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
s.execute("brush.mixer.sample", json!({"points":[[96,48]],"size":40})).unwrap();
|
||||
s.tools.brush.tip = TipShape::Sampled(GrayTile::from_fn(256, 256, |_, _| 1.0));
|
||||
for x in [40.0, 60.0, 80.0] {
|
||||
let p = json!({"points":[[x,48]],"size":12,"seed":7,"wet":0,"mix":0});
|
||||
let live = LiveMixer::begin(&s, &p).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let shown = live.doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
let params = live.prepare_commit(&mut s).unwrap();
|
||||
assert!(s.live_commit.is_some());
|
||||
let result = s.execute("paint.mixerBrush", params).unwrap();
|
||||
assert_eq!(result["fromLive"], true);
|
||||
assert_eq!(target(&s), shown);
|
||||
assert!(s.live_commit.is_none());
|
||||
let params = &s.journal.last().unwrap().1;
|
||||
for field in ["brush", "mixerState", "color"] {
|
||||
assert!(params.get(field).is_none(), "{field}");
|
||||
}
|
||||
assert!(serde_json::to_vec(params).unwrap().len() < 1024);
|
||||
}
|
||||
}
|
||||
|
||||
/// Preview limits never reject valid command sizes, including the maximum session size.
|
||||
#[test]
|
||||
fn mixer_large_brushes_replay_after_the_preview_budget() {
|
||||
for (size, pressure) in [(2000, 1.0), (5000, 1.0)] {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let params = json!({"points":[[96,48,pressure]],"size":size,"wet":0,"mix":0,"sampleAllLayers":false,
|
||||
"color":"#ff0000","brush":{"pressureSize":true,"hardness":1.0}});
|
||||
assert!(LiveMixer::begin(&s, ¶ms).is_err());
|
||||
s.execute("paint.mixerBrush", params).unwrap();
|
||||
assert!(target(&s).rgba(96, 48)[0] > 0.9);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.active().unwrap().doc, *before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Long strokes past the live preview's sample and dab budgets still render on command replay
|
||||
/// (Stroke Path with Simulate Pressure sends up to 200,000 samples), as main painted them.
|
||||
|
||||
#[test]
|
||||
|
||||
fn mixer_command_paints_strokes_beyond_the_preview_sample_and_dab_budgets() {
|
||||
// 70,000 samples a pixel apart (above 65,536), or 2,000 samples 170 px apart (about 340,000 dabs).
|
||||
for (count, step, budget) in [(70_000, 1, "65536"), (2_000, 170, "250000 dabs")] {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
// Zig-zagging along one row, one dab per pixel.
|
||||
let points: Vec<_> = (0..count).map(|i: i32| [f64::from(10 + (i * step % 340 - 170).abs()), 48.0]).collect();
|
||||
let p = json!({"points":points,"size":2,"brush":{"spacing":0.5,"hardness":1.0,"pressureSize":false,"smoothing":{"amount":0.0}},
|
||||
"seed":7,"sampleAllLayers":false,"wet":0,"mix":0,"load":100,"color":"#ff0000"});
|
||||
let err = LiveMixer::begin(&s, &p).err().expect("bounded preview");
|
||||
assert!(err.to_string().contains(budget), "{err}");
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
s.execute("paint.mixerBrush", p).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
|
||||
assert!(target(&s).rgba(100, 48)[0] > 0.9);
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ impl ChannelView {
|
||||
/// The layer mask shown on the canvas (#196) and how, if its layer still has a mask.
|
||||
pub fn shown_layer_mask<'a>(&self, doc: &'a Document) -> Option<(&'a photocraft_doc::LayerMask, crate::mask_view_cmds::MaskViewMode)> {
|
||||
let v = self.layer_mask?;
|
||||
Some((doc.layer(v.layer)?.mask.as_ref()?, v.mode))
|
||||
Some((crate::mask_view_cmds::viewed_mask(doc.layer(v.layer)?, v.filter)?, v.mode))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,8 @@ pub enum GraySurface {
|
||||
Alpha(usize),
|
||||
/// The Quick Mask.
|
||||
QuickMask,
|
||||
/// The active smart object's filter mask (FILE-215-3).
|
||||
FilterMask,
|
||||
}
|
||||
|
||||
/// Isolated Curves channel for the active document, matching [`inject_target`].
|
||||
@@ -246,6 +248,7 @@ pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Opt
|
||||
Some(IsolatedCurveChannel { name: "Quick Mask".into(), key: "", hist: 0, tint: false, translate: true, gray: Some(GraySurface::QuickMask) })
|
||||
}
|
||||
ChannelTarget::LayerMask => layer_mask_curve_channel(st),
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => filter_mask_curve_channel(st),
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st),
|
||||
_ => None,
|
||||
}
|
||||
@@ -258,6 +261,15 @@ fn layer_mask_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
|
||||
Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::LayerMask) })
|
||||
}
|
||||
|
||||
/// The Curves target when the active smart object's filter mask is targeted (its thumbnail on the
|
||||
/// Smart Filters row, FILE-215-3): a gray plane, though the mask may not exist until the first edit.
|
||||
pub fn filter_mask_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
|
||||
let layer = st.active_layer.and_then(|id| st.doc.layer(id))?;
|
||||
crate::smart_cmds::filter_mask_paintable(layer).ok()?;
|
||||
let name = format!("{} Filter Mask", layer.name);
|
||||
Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::FilterMask) })
|
||||
}
|
||||
|
||||
fn edit_mode(doc: &Document) -> ColorMode {
|
||||
doc.pixel_format().mode
|
||||
}
|
||||
@@ -602,6 +614,9 @@ fn mask_plane(s: &Session, p: &Value, cmd: &str) -> Result<Option<Vec<f32>>> {
|
||||
pub(crate) enum Target {
|
||||
Pixels,
|
||||
Mask,
|
||||
/// A smart object's filter mask (`"target":"filterMask"`): where it is black the smart
|
||||
/// filters don't show (FILE-215-3).
|
||||
FilterMask,
|
||||
Alpha(usize),
|
||||
QuickMask,
|
||||
}
|
||||
@@ -610,6 +625,7 @@ pub(crate) fn target_of(p: &Value) -> Target {
|
||||
match p.get("target") {
|
||||
Some(Value::String(s)) => match s.as_str() {
|
||||
"mask" => Target::Mask,
|
||||
"filterMask" => Target::FilterMask,
|
||||
"quickMask" => Target::QuickMask,
|
||||
_ => Target::Pixels,
|
||||
},
|
||||
@@ -654,6 +670,13 @@ pub(crate) fn read_target_surface<'a>(doc: &'a Document, layer: Option<LayerId>,
|
||||
let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
doc.layer(id).and_then(|l| l.mask.as_ref()).map(|m| &m.surface).ok_or(EngineError::NoLayer(id))
|
||||
}
|
||||
Target::FilterMask => {
|
||||
let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
doc.layer(id)
|
||||
.and_then(|l| crate::mask_view_cmds::viewed_mask(l, true))
|
||||
.map(|m| &m.surface)
|
||||
.ok_or_else(|| EngineError::Other("the layer has no filter mask".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,10 +709,17 @@ fn edits_mask(id: &str) -> bool {
|
||||
/// The precondition of `id` when `p` targets the active layer's mask (`"target":"mask"`): the
|
||||
/// layer needs a mask. None when another precondition applies.
|
||||
pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option<std::result::Result<(), String>> {
|
||||
if target_of(p) != Target::Mask || !edits_mask(id) {
|
||||
return None;
|
||||
match target_of(p) {
|
||||
// The commands that paint, fill, filter or adjust the filter mask need a smart object with
|
||||
// smart filters, not a pixel layer. Paste keeps its own check (something to paste).
|
||||
Target::FilterMask if follows_filter_mask(id) && !PASTE_IDS.contains(&id) => {
|
||||
Some(crate::active_layer_of(s).and_then(crate::smart_cmds::filter_mask_paintable))
|
||||
}
|
||||
Target::Mask if edits_mask(id) => {
|
||||
Some(crate::active_layer_of(s).and_then(|l| if l.mask.is_some() { Ok(()) } else { Err("the active layer has no layer mask".into()) }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
Some(crate::active_layer_of(s).and_then(|l| if l.mask.is_some() { Ok(()) } else { Err("the active layer has no layer mask".into()) }))
|
||||
}
|
||||
|
||||
/// Whether command `id` edits the targeted channel or mask when its params name no `"target"`
|
||||
@@ -700,15 +730,68 @@ pub fn follows_target(id: &str) -> bool {
|
||||
routed(id) || id == "select.float" || id == "layer.translate"
|
||||
}
|
||||
|
||||
/// Whether command `id` edits a smart object's targeted filter mask (`"target":"filterMask"`):
|
||||
/// every routed command but those that act on the smart object itself while its filter mask is
|
||||
/// targeted (Edit › Transform, Apply Image, Convert for Smart Filters, Last Filter, Liquify, the
|
||||
/// lens filters and Vanishing Point, which add a smart filter, the render filters that make a new
|
||||
/// layer, and Desaturate), so neither the shell nor the engine sends those the filter mask.
|
||||
pub fn follows_filter_mask(id: &str) -> bool {
|
||||
routed(id)
|
||||
&& !id.starts_with("edit.transform.")
|
||||
&& !matches!(
|
||||
id,
|
||||
"image.applyImage"
|
||||
| "image.adjustments.desaturate"
|
||||
| "filter.vanishingPoint"
|
||||
| "filter.render.flame"
|
||||
| "filter.render.tree"
|
||||
| "filter.render.pictureFrame"
|
||||
| "filter.convertForSmartFilters"
|
||||
| "filter.lastFilter"
|
||||
| crate::distort_cmds::LIQUIFY
|
||||
| crate::lens_cmds::LENS
|
||||
| crate::lens_cmds::WIDE
|
||||
| crate::lens_cmds::RAW
|
||||
)
|
||||
}
|
||||
|
||||
/// The paint and retouch tools that follow the targeted channel or mask.
|
||||
const PAINT_IDS: &[&str] = &[
|
||||
"paint.stroke",
|
||||
"paint.pencil",
|
||||
"paint.bucket",
|
||||
"paint.gradient",
|
||||
"paint.mixerBrush",
|
||||
"paint.cloneStamp",
|
||||
"paint.patternStamp",
|
||||
"paint.healingBrush",
|
||||
"paint.spotHealing",
|
||||
"paint.dodge",
|
||||
"paint.burn",
|
||||
"paint.sponge",
|
||||
"paint.blur",
|
||||
"paint.sharpen",
|
||||
"paint.smudge",
|
||||
"paint.historyBrush",
|
||||
"paint.artHistoryBrush",
|
||||
"paint.redEye",
|
||||
"paint.remove",
|
||||
];
|
||||
|
||||
/// Pasting into a targeted mask or channel (#1035).
|
||||
const PASTE_IDS: &[&str] = &["edit.paste", "edit.pasteSpecial.pasteInPlace", "edit.pasteSpecial.pasteInto", "edit.pasteSpecial.pasteOutside"];
|
||||
|
||||
/// Commands whose target follows the Channels panel.
|
||||
pub(crate) fn routed(id: &str) -> bool {
|
||||
id.starts_with("filter.")
|
||||
|| id == "plugin.run"
|
||||
|| id.starts_with("image.adjustments.")
|
||||
|| matches!(id, "paint.stroke" | "paint.pencil" | "paint.bucket" | "paint.gradient" | "paint.mixerBrush" | "edit.fill" | "image.applyImage")
|
||||
|| matches!(id, "edit.fill" | "image.applyImage")
|
||||
|| crate::fill_key_cmds::IDS.contains(&id)
|
||||
|| PAINT_IDS.contains(&id)
|
||||
// Pasting into a targeted mask or channel (#1035); Copy takes a targeted mask (#2928).
|
||||
|| matches!(id, "edit.copy" | "edit.paste" | "edit.pasteSpecial.pasteInPlace" | "edit.pasteSpecial.pasteInto" | "edit.pasteSpecial.pasteOutside")
|
||||
|| id == "edit.copy"
|
||||
|| PASTE_IDS.contains(&id)
|
||||
// Edit › Transform › Rotate/Flip turn a targeted mask or channel (#2835).
|
||||
|| matches!(
|
||||
id,
|
||||
@@ -718,22 +801,6 @@ pub(crate) fn routed(id: &str) -> bool {
|
||||
| "edit.transform.flipHorizontal"
|
||||
| "edit.transform.flipVertical"
|
||||
)
|
||||
|| matches!(
|
||||
id,
|
||||
"paint.cloneStamp"
|
||||
| "paint.patternStamp"
|
||||
| "paint.healingBrush"
|
||||
| "paint.spotHealing"
|
||||
| "paint.dodge"
|
||||
| "paint.burn"
|
||||
| "paint.sponge"
|
||||
| "paint.blur"
|
||||
| "paint.sharpen"
|
||||
| "paint.smudge"
|
||||
| "paint.historyBrush"
|
||||
| "paint.redEye"
|
||||
| "paint.remove"
|
||||
)
|
||||
}
|
||||
|
||||
/// Fill in `"target"` from the targeted channel (or Quick Mask mode) when the caller gave none.
|
||||
@@ -747,6 +814,13 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value {
|
||||
ChannelTarget::Composite if st.doc.quick_mask.is_some() => json!("quickMask"),
|
||||
ChannelTarget::LayerMask => json!("mask"),
|
||||
// Viewing the active layer's mask (⌥-click): pixel commands edit the mask.
|
||||
// A shown filter mask takes only the commands that edit it; the rest act on the smart object.
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => {
|
||||
if !follows_filter_mask(id) {
|
||||
return params;
|
||||
}
|
||||
json!("filterMask")
|
||||
}
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() => json!("mask"),
|
||||
_ => return params,
|
||||
};
|
||||
@@ -759,6 +833,16 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// The smart object whose filter mask command `id` with `params` edits (`"target":"filterMask"`):
|
||||
/// its `"layer"` param, else the active layer. None for a command that doesn't edit the filter
|
||||
/// mask, so moving the smart object doesn't re-render its filters.
|
||||
pub(crate) fn filter_mask_layer(s: &Session, id: &str, params: &Value) -> Option<LayerId> {
|
||||
if target_of(params) != Target::FilterMask || !follows_filter_mask(id) {
|
||||
return None;
|
||||
}
|
||||
params.get("layer").and_then(Value::as_u64).map(LayerId).or_else(|| s.active()?.active_layer)
|
||||
}
|
||||
|
||||
/// The colour channel pixel commands are limited to, if a single colour channel is targeted.
|
||||
pub(crate) fn color_restriction(s: &Session, id: &str, params: &Value) -> Option<usize> {
|
||||
if !routed(id) || is_channel_target(params) || crate::commands::is_mask_target(params) {
|
||||
|
||||
@@ -65,13 +65,17 @@ pub(crate) fn has_paintable(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Surface a paint command writes to: the layer's pixels, or its mask with `"target":"mask"`.
|
||||
/// Surface a paint command writes to: the layer's pixels, its mask with `"target":"mask"`, or a
|
||||
/// smart object's filter mask with `"target":"filterMask"`.
|
||||
pub(crate) fn paint_surface<'a>(doc: &'a mut Document, id: LayerId, p: &Value) -> Result<&'a mut photocraft_raster::Surface> {
|
||||
if is_mask_target(p) {
|
||||
check_all_unlocked(doc, id)?;
|
||||
} else {
|
||||
check_pixels_unlocked(doc, id)?;
|
||||
}
|
||||
if crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::FilterMask {
|
||||
return crate::smart_cmds::filter_mask_surface(doc, id);
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if is_mask_target(p) {
|
||||
l.mask.as_mut().map(|m| &mut m.surface).ok_or_else(|| EngineError::Other("layer has no mask".into()))
|
||||
@@ -99,8 +103,10 @@ pub(crate) fn check_pixels_unlocked(doc: &Document, id: LayerId) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The params target a grayscale mask of the layer: its layer mask (`"mask"`) or, on a smart
|
||||
/// object, its filter mask (`"filterMask"`).
|
||||
pub(crate) fn is_mask_target(p: &Value) -> bool {
|
||||
p.get("target").and_then(Value::as_str) == Some("mask")
|
||||
matches!(p.get("target").and_then(Value::as_str), Some("mask" | "filterMask"))
|
||||
}
|
||||
|
||||
fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -467,9 +473,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
|
||||
return s.edit(label, |doc, _| {
|
||||
check_all_unlocked(doc, id)?;
|
||||
let sel = doc.selection.clone();
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let mask = l.mask.as_mut().ok_or_else(|| EngineError::Other("layer has no mask".into()))?;
|
||||
pixels::adjust_mask(&mut mask.surface, &adj, sel.as_ref()).map_err(EngineError::Other)?;
|
||||
pixels::adjust_mask(paint_surface(doc, id, p)?, &adj, sel.as_ref()).map_err(EngineError::Other)?;
|
||||
Ok(Value::Null)
|
||||
});
|
||||
}
|
||||
@@ -614,7 +618,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Fill…",
|
||||
["Edit"],
|
||||
Some("Shift+F5"),
|
||||
r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":row?,"snapshot":id? (contents=history: a History panel row as history.setSource, redo rows last, or a snapshot; default the History Brush source),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##,
|
||||
r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":row?,"snapshot":id? (contents=history: a History panel row as history.setSource, redo rows last, or a snapshot; default the History Brush source),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##,
|
||||
has_pixel_or_channel,
|
||||
crate::fill_cmds::fill
|
||||
),
|
||||
@@ -1195,7 +1199,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Brush Stroke",
|
||||
[],
|
||||
None,
|
||||
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
|
||||
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##,
|
||||
has_paintable,
|
||||
crate::brush_cmds::paint_stroke
|
||||
),
|
||||
|
||||
@@ -8,9 +8,15 @@
|
||||
//! layers keep their transparency there, as after a plain crop. Each connected piece of the
|
||||
//! uncovered area (each corner, each edge strip) is filled on its own, with Photoshop's sampling
|
||||
//! window around that piece, and only pixels the image covers are sampled.
|
||||
//!
|
||||
//! Pixels an earlier crop with Delete Cropped Pixels off hid past the canvas edges are still the
|
||||
//! layer's. Under a turned frame they count as covered, so they are kept (and sampled) rather than
|
||||
//! filled over; a straight crop fills all of the area past the old canvas, them included, as
|
||||
//! Photoshop does (#8060).
|
||||
|
||||
use photocraft_doc::{Document, LayerId};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
@@ -22,7 +28,7 @@ const SEED: u64 = 1;
|
||||
const MAX_PIXELS: u64 = 400_000_000;
|
||||
|
||||
/// Where the cropped canvas came from in the original document.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Coverage {
|
||||
/// Cropped canvas → original document: `[a, b, c, d, e, f]` with
|
||||
/// `x = a·u + b·v + e`, `y = c·u + d·v + f`.
|
||||
@@ -32,12 +38,23 @@ pub(crate) struct Coverage {
|
||||
pub height: f64,
|
||||
/// Whether the map rotates (its resampled edge is antialiased against the background colour).
|
||||
pub rotated: bool,
|
||||
/// The filled layer's pixels before a turned crop (original document px), set by
|
||||
/// [`Coverage::holding`]: where they lie past the old canvas they are hidden image, not
|
||||
/// uncovered area. A straight crop has none: Photoshop fills over hidden pixels there.
|
||||
pub held: Option<Surface>,
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
/// An unrotated crop to `r` (original document px).
|
||||
pub fn crop(r: Rect, width: u32, height: u32) -> Self {
|
||||
Coverage { map: [1.0, 0.0, 0.0, 1.0, f64::from(r.x0), f64::from(r.y0)], width: f64::from(width), height: f64::from(height), rotated: false }
|
||||
Coverage { map: [1.0, 0.0, 0.0, 1.0, f64::from(r.x0), f64::from(r.y0)], width: f64::from(width), height: f64::from(height), rotated: false, held: None }
|
||||
}
|
||||
|
||||
/// Takes the pixels of the layer the fill will go to from `doc` before a turned crop (the
|
||||
/// surface is shared, not copied), so hidden pixels past the old canvas count as covered.
|
||||
pub fn holding(mut self, doc: &Document, active: Option<LayerId>) -> Self {
|
||||
self.held = target_layer(doc, active).ok().and_then(|id| doc.layer(id)).and_then(|l| l.surface()).cloned();
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the cropped canvas's pixel (u, v) is the original image's: its centre maps inside
|
||||
@@ -48,7 +65,27 @@ impl Coverage {
|
||||
let [a, b, c, d, e, f] = self.map;
|
||||
let (cu, cv) = (f64::from(u) + 0.5, f64::from(v) + 0.5);
|
||||
let (x, y) = (a * cu + b * cv + e, c * cu + d * cv + f);
|
||||
x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin
|
||||
(x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin) || self.holds(x, y)
|
||||
}
|
||||
|
||||
/// Whether the layer held an image pixel at original document point (x, y) past the canvas
|
||||
/// (#8060): mostly opaque there and one pixel around it too (the turned edge is blended with
|
||||
/// the background colour, as at the canvas edge). Without an alpha channel
|
||||
/// hidden pixels can't be told from empty ones: nothing is held.
|
||||
fn holds(&self, x: f64, y: f64) -> bool {
|
||||
let Some(surf) = &self.held else { return false };
|
||||
let format = surf.format();
|
||||
if !format.alpha || !(x.is_finite() && y.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let a = surf.channels().saturating_sub(1);
|
||||
let at = |x: f64, y: f64| {
|
||||
let (fx, fy) = (x.floor(), y.floor());
|
||||
let limit = f64::from(i32::MAX);
|
||||
fx.abs() < limit && fy.abs() < limit && surf.sample_channel(fx as i32, fy as i32, a) > 0.5
|
||||
};
|
||||
const ROUND: [(f64, f64); 5] = [(0.0, 0.0), (-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)];
|
||||
ROUND.iter().all(|&(dx, dy)| at(x + dx, y + dy))
|
||||
}
|
||||
|
||||
fn margin(&self) -> f64 {
|
||||
|
||||
@@ -307,15 +307,18 @@ fn copy(s: &mut Session, merged: bool) -> Result<Value> {
|
||||
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()]}))
|
||||
}
|
||||
|
||||
/// Copy with the layer mask targeted (`"target":"mask"`, #2928): the mask's values as a
|
||||
/// Copy the targeted layer mask (`"target":"mask"`, #2928) or smart-filter mask
|
||||
/// (`"target":"filterMask"`): the mask's values as a
|
||||
/// grayscale clip, inside the selection or the whole canvas — an untouched mask copies at its
|
||||
/// default value, so a plain Reveal All / Hide All mask transfers too. Paste then writes into
|
||||
/// another layer's mask (targeted, #1035), in this or another document.
|
||||
fn copy_mask(s: &mut Session) -> Result<Value> {
|
||||
fn copy_mask(s: &mut Session, filter: bool) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let canvas = d.doc.bounds();
|
||||
let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let mask = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?.mask.as_ref().ok_or_else(|| EngineError::Other("the active layer has no layer mask".into()))?;
|
||||
let layer = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let mask = crate::mask_view_cmds::viewed_mask(layer, filter)
|
||||
.ok_or_else(|| EngineError::Other(if filter { "the active layer has no filter mask" } else { "the active layer has no layer mask" }.into()))?;
|
||||
let sel = d.doc.selection.as_ref();
|
||||
let area = sel.map_or(canvas, |m| m.content_bounds().intersect(&canvas));
|
||||
if area.is_empty() {
|
||||
@@ -347,7 +350,7 @@ fn copy_mask(s: &mut Session) -> Result<Value> {
|
||||
let b = bounds;
|
||||
let (resolution, source) = (Some(d.doc.resolution_dpi), Some(d.doc.id));
|
||||
s.clipboard = Some(Clip { surface, bounds: b, layers: None, resolution, source });
|
||||
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": "mask"}))
|
||||
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": if filter { "filterMask" } else { "mask" }}))
|
||||
}
|
||||
|
||||
fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Result<()> {
|
||||
@@ -999,11 +1002,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy",
|
||||
&["Edit"],
|
||||
Some("Cmd+C"),
|
||||
r##"{"target":"mask"? = the targeted mask, copied as grayscale (#2928)} (a targeted colour or alpha channel copies that channel, as grayscale)"##,
|
||||
r##"{"target":"mask|filterMask"? = the targeted mask, copied as grayscale (#2928)} (a targeted colour or alpha channel copies that channel, as grayscale)"##,
|
||||
can_copy,
|
||||
|s, p| {
|
||||
if crate::commands::is_mask_target(p) {
|
||||
return copy_mask(s);
|
||||
return copy_mask(s, crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::FilterMask);
|
||||
}
|
||||
match crate::channel_clip::copy(s)? {
|
||||
Some(r) => Ok(r),
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{EngineError, Result, Session};
|
||||
/// The fill-key command ids (routed to the Channels panel target like `edit.fill`).
|
||||
pub const IDS: [&str; 5] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve", "edit.fillHistory"];
|
||||
|
||||
const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##;
|
||||
const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##;
|
||||
|
||||
/// The shape or type layer the colour fill keys recolor instead of filling pixels.
|
||||
fn target_live_layer(s: &Session, p: &Value) -> Option<LayerId> {
|
||||
|
||||
@@ -161,9 +161,9 @@ pub(crate) fn validate_params(id: &str, params: &Value) -> Result<()> {
|
||||
"layer" => (value.as_u64().is_some(), "an unsigned layer id".into()),
|
||||
"coalesce" => (value.is_string(), "a string".into()),
|
||||
"target" => (
|
||||
value.as_str().is_some_and(|s| matches!(s, "pixels" | "mask" | "quickMask"))
|
||||
value.as_str().is_some_and(|s| matches!(s, "pixels" | "mask" | "filterMask" | "quickMask"))
|
||||
|| value.as_object().is_some_and(|o| o.len() == 1 && o.get("channel").and_then(Value::as_u64).is_some()),
|
||||
"pixels, mask, quickMask or {\"channel\":index}".into(),
|
||||
"pixels, mask, filterMask, quickMask or {\"channel\":index}".into(),
|
||||
),
|
||||
_ => return Err(bad(format!("unknown parameter `{key}`"))),
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ fn float(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
if floating(st).is_none() {
|
||||
let layer = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let parts = CutParts::new(&st.doc, layer, copy, background, crate::commands::is_mask_target(p))?;
|
||||
let parts = CutParts::new(&st.doc, layer, copy, background, crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Mask)?;
|
||||
st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts), moved: false });
|
||||
}
|
||||
let f = st.floating.as_mut().ok_or(EngineError::NoDocument)?;
|
||||
@@ -333,7 +333,8 @@ pub fn drop_floating(s: &mut Session) -> Result<Value> {
|
||||
|
||||
/// Commands that leave a floating piece floating: its own, and ones that don't touch documents.
|
||||
fn keeps_floating(id: &str) -> bool {
|
||||
matches!(id, "select.float" | "select.drop") || ["view.", "window.", "help.", "prefs."].iter().any(|p| id.starts_with(p))
|
||||
matches!(id, "select.float" | "select.drop" | "brush.mixer.sample" | "brush.mixer.load" | "brush.mixer.clean")
|
||||
|| ["view.", "window.", "help.", "prefs."].iter().any(|p| id.starts_with(p))
|
||||
}
|
||||
|
||||
/// Before any command (`Session::dispatch`): Undo puts a floating piece back (and is used up:
|
||||
|
||||
@@ -32,6 +32,7 @@ const GATED: &[&str] = &[
|
||||
"paint.sharpen",
|
||||
"paint.smudge",
|
||||
"paint.historyBrush",
|
||||
"paint.artHistoryBrush",
|
||||
"layer.translate",
|
||||
];
|
||||
|
||||
|
||||
@@ -101,6 +101,12 @@ fn snapshot_index(s: &Session, cmd: &str, p: &Value) -> Result<usize> {
|
||||
st.snapshots.iter().position(|snap| snap.id == id).ok_or_else(|| bad(cmd, format!("no snapshot {id}")))
|
||||
}
|
||||
|
||||
/// The name `history.newSnapshot` gives a snapshot without one: "Snapshot N" (the New Snapshot
|
||||
/// dialog suggests it too).
|
||||
pub fn next_snapshot_name(st: &crate::DocState) -> String {
|
||||
format!("Snapshot {}", st.next_snapshot)
|
||||
}
|
||||
|
||||
/// A non-blank `name` param, trimmed; `None` when absent.
|
||||
fn name_param(cmd: &str, p: &Value) -> Result<Option<String>> {
|
||||
match p.get("name") {
|
||||
@@ -140,9 +146,9 @@ fn new_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => (st.doc.clone(), st.layer_target()),
|
||||
};
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
let name = name.unwrap_or_else(|| next_snapshot_name(st));
|
||||
let id = st.next_snapshot;
|
||||
st.next_snapshot = id.saturating_add(1);
|
||||
let name = name.unwrap_or_else(|| format!("Snapshot {id}"));
|
||||
st.snapshots.push(Snapshot { id, name: name.clone(), doc, layers });
|
||||
Ok(json!({ "snapshot": id, "name": name }))
|
||||
}
|
||||
@@ -413,7 +419,8 @@ mod tests {
|
||||
assert_eq!(entries(&s), before, "taking a snapshot is not a step");
|
||||
let r = s.execute("history.newSnapshot", json!({"name": "Before green"})).unwrap();
|
||||
assert_eq!(r, json!({"snapshot": 2, "name": "Before green"}));
|
||||
assert_eq!(snapshots(&s), json!([{"id": 1, "name": "Snapshot 1"}, {"id": 2, "name": "Before green"}]));
|
||||
let first = json!({"id": 0, "name": s.active().unwrap().doc.name});
|
||||
assert_eq!(snapshots(&s), json!([first, {"id": 1, "name": "Snapshot 1"}, {"id": 2, "name": "Before green"}]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -433,7 +440,7 @@ mod tests {
|
||||
assert!(s.active().unwrap().active_layer.is_some());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(top_pixel(&s), blue, "undo leaves the snapshot");
|
||||
assert_eq!(snapshots(&s).as_array().unwrap().len(), 1, "snapshots are not history");
|
||||
assert_eq!(snapshots(&s).as_array().unwrap().len(), 2, "snapshots are not history");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -465,7 +472,7 @@ mod tests {
|
||||
let base = with.active().unwrap().memory_bytes();
|
||||
with.execute("history.newSnapshot", json!({"from": "mergedLayers"})).unwrap();
|
||||
let st = with.active().unwrap();
|
||||
let flat = tile_bytes(&st.snapshots[0].doc);
|
||||
let flat = tile_bytes(&st.snapshots.last().unwrap().doc);
|
||||
assert!(flat > 0);
|
||||
assert_eq!(st.memory_bytes(), base + flat);
|
||||
// Room for the document's tiles and four fill tiles.
|
||||
@@ -494,11 +501,11 @@ mod tests {
|
||||
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
// The snapshot's green layer tile, now only in it and the last state.
|
||||
let shared = tile_bytes(&Document { layers: st.snapshots[0].doc.layers[1..].to_vec(), ..(*st.doc).clone() });
|
||||
let shared = tile_bytes(&Document { layers: st.snapshots.last().unwrap().doc.layers[1..].to_vec(), ..(*st.doc).clone() });
|
||||
let all = st.history.unique_bytes(&st.doc);
|
||||
let r = s.execute("edit.purge.histories", json!({})).unwrap();
|
||||
assert_eq!(r["bytes"], json!(all - shared), "the green tile stays with the snapshot");
|
||||
assert_eq!(s.active().unwrap().snapshots.len(), 1);
|
||||
assert_eq!(s.active().unwrap().snapshots.len(), 2, "the first snapshot and the new one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -509,11 +516,31 @@ mod tests {
|
||||
}
|
||||
s.execute("history.renameSnapshot", json!({"snapshot": 2, "name": "Lesson start"})).unwrap();
|
||||
s.execute("history.deleteSnapshot", json!({"snapshot": 1})).unwrap();
|
||||
assert_eq!(snapshots(&s), json!([{"id": 2, "name": "Lesson start"}, {"id": 3, "name": "Snapshot 3"}]));
|
||||
s.execute("history.deleteSnapshot", json!({"snapshot": 0})).unwrap();
|
||||
assert_eq!(snapshots(&s), json!([{"id": 2, "name": "Lesson start"}, {"id": 3, "name": "Snapshot 3"}]), "the first snapshot can go too");
|
||||
let r = s.execute("history.newSnapshot", json!({})).unwrap();
|
||||
assert_eq!(r["snapshot"], json!(4), "ids are never reused");
|
||||
}
|
||||
|
||||
/// Photoshop's first snapshot (#2966): every document starts with a snapshot of itself as
|
||||
/// opened, named after it, so the opening state can always be brought back, even after
|
||||
/// History has dropped its oldest states.
|
||||
#[test]
|
||||
fn a_document_starts_with_a_first_snapshot_of_itself() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 20, "height": 10})).unwrap();
|
||||
let name = s.active().unwrap().doc.name.clone();
|
||||
assert_eq!(snapshots(&s), json!([{"id": 0, "name": name}]));
|
||||
let opened = top_pixel(&s);
|
||||
for _ in 0..3 {
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
}
|
||||
s.active_mut().unwrap().history.purge_last();
|
||||
s.execute("history.selectSnapshot", json!({"snapshot": 0})).unwrap();
|
||||
assert_eq!(top_pixel(&s), opened, "back to the document as opened");
|
||||
assert_eq!(entries(&s).last(), Some(&name), "a state named after the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_commands_refuse_bad_params() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -366,7 +366,6 @@ mod tests {
|
||||
json!({"state": 1.5}),
|
||||
json!({"state": "1"}),
|
||||
json!({"state": [1]}),
|
||||
json!({"snapshot": 0}),
|
||||
json!({"snapshot": 1}),
|
||||
json!({"snapshot": -1}),
|
||||
json!({"snapshot": "Open"}),
|
||||
@@ -376,5 +375,7 @@ mod tests {
|
||||
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.active().unwrap().history.brush_source(), Some(0), "nothing changed");
|
||||
// Snapshot 0 is the document as opened, so it is a valid source.
|
||||
assert_eq!(s.execute(CMD, json!({"snapshot": 0})).unwrap()["snapshot"], 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,6 +610,8 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Crop", |doc, active| {
|
||||
let original = doc.size;
|
||||
crop_doc(doc, "image.crop", r, delete, extension)?;
|
||||
// A straight crop fills all of the area past the old canvas, pixels an earlier Delete Cropped
|
||||
// Pixels off crop hid there included, as Photoshop does; only a turned frame keeps them (#8060).
|
||||
if content_aware {
|
||||
crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height))?;
|
||||
}
|
||||
@@ -769,6 +771,15 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar
|
||||
width: f64::from(size.width),
|
||||
height: f64::from(size.height),
|
||||
rotated: true,
|
||||
held: None,
|
||||
};
|
||||
// The pixels before the turn: under a turned frame a Delete Cropped Pixels off crop's hidden ones
|
||||
// count as image, as in Photoshop (a straight crop fills over them) (#8060).
|
||||
let coverage = if content_aware {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
coverage.holding(&d.doc, d.active_layer)
|
||||
} else {
|
||||
coverage
|
||||
};
|
||||
crate::analysis_cmds::compound(s, "Crop", |s| {
|
||||
// Pixels the crop deletes needn't be turned first: keeping only what lands in the frame
|
||||
|
||||
@@ -229,6 +229,7 @@ struct Running {
|
||||
started_ms: f64,
|
||||
coalesce: Option<String>,
|
||||
color_restrict: Option<usize>,
|
||||
filter_mask_layer: Option<LayerId>,
|
||||
/// The document's layer selection when the job started: applying targets it, not whatever
|
||||
/// got selected meanwhile (a Type click does that without editing the document, #7366).
|
||||
layers: Option<LayerSelection>,
|
||||
@@ -418,6 +419,7 @@ pub fn run<T: Send + 'static>(
|
||||
started_ms: now_ms(),
|
||||
coalesce: s.coalesce_request.clone(),
|
||||
color_restrict: s.color_restrict,
|
||||
filter_mask_layer: s.filter_mask_layer,
|
||||
layers,
|
||||
rx,
|
||||
apply: Some(erased),
|
||||
@@ -762,6 +764,9 @@ impl Session {
|
||||
|
||||
/// Run the command `id`: inline, or (with `background`) as a job when it is one.
|
||||
pub(crate) fn dispatch(&mut self, id: &str, params: Value, background: bool) -> Result<Started> {
|
||||
// The offer belongs to this one dispatch. Early errors drop it locally, including
|
||||
// authorization, validation, floating-selection and admission failures.
|
||||
let live_commit = self.live_commit.take();
|
||||
let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
// Params are named fields: anything but an object (or null, meaning none) is a caller
|
||||
// mistake, not "use every default".
|
||||
@@ -818,6 +823,7 @@ impl Session {
|
||||
let expand_new_fx = crate::commands::layer_panel_option(self, "expandNewEffects", true);
|
||||
self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string);
|
||||
self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params);
|
||||
self.filter_mask_layer = crate::channel_cmds::filter_mask_layer(self, id, &run_params);
|
||||
self.jobs.spawn = background;
|
||||
// Commands this one runs on its own behalf journal after this mark (#3067).
|
||||
let nested_from = self.journal.len();
|
||||
@@ -830,14 +836,17 @@ impl Session {
|
||||
// Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an
|
||||
// error instead of taking the app down. `edit` only commits a document after its closure
|
||||
// returns, so the documents are unchanged.
|
||||
self.live_commit = live_commit.filter(|live| live.cmd == id);
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, &run_params)))
|
||||
.unwrap_or_else(|_| Err(EngineError::Other(format!("`{id}` failed with an internal error (logged); the document is unchanged"))));
|
||||
self.live_commit = None;
|
||||
if self.actions.recording.is_none() {
|
||||
self.actions.recording = recording;
|
||||
}
|
||||
self.jobs.spawn = false;
|
||||
self.coalesce_request = None;
|
||||
self.color_restrict = None;
|
||||
self.filter_mask_layer = None;
|
||||
let pending = self.jobs.pending.take();
|
||||
match (r, pending) {
|
||||
(Err(e), Some(job)) => {
|
||||
@@ -950,7 +959,8 @@ impl Session {
|
||||
}
|
||||
|
||||
fn apply_job(&mut self, job: &Running, apply: Option<Apply>, payload: Payload) -> Result<Value> {
|
||||
let Running { label, doc, snapshot, coalesce, color_restrict, layers, command, params, journal, recorded_by, pre_selected, .. } = job;
|
||||
let Running { label, doc, snapshot, coalesce, color_restrict, filter_mask_layer, layers, command, params, journal, recorded_by, pre_selected, .. } =
|
||||
job;
|
||||
// A recording started after the job (#7289) keeps what it has so far, and the job's step
|
||||
// goes into the journal past its start, so that recording doesn't get it.
|
||||
let foreign = self.actions.recording.is_some() && *recorded_by != Some(self.actions.take);
|
||||
@@ -987,6 +997,7 @@ impl Session {
|
||||
};
|
||||
self.coalesce_request = coalesce.clone();
|
||||
self.color_restrict = *color_restrict;
|
||||
self.filter_mask_layer = *filter_mask_layer;
|
||||
// Apply-time hooks can dispatch commands too. Keep the same single-step recording
|
||||
// contract as synchronous dispatch without truncating work done while the worker ran.
|
||||
let nested_from = self.journal.len();
|
||||
@@ -1001,6 +1012,7 @@ impl Session {
|
||||
}
|
||||
self.coalesce_request = None;
|
||||
self.color_restrict = None;
|
||||
self.filter_mask_layer = None;
|
||||
// Finish successful command bookkeeping on the document the job edited.
|
||||
// Restoring the viewed document first would attribute Fade and slice updates
|
||||
// to that document instead, when the user switched tabs mid-job.
|
||||
@@ -1029,6 +1041,9 @@ impl Session {
|
||||
}
|
||||
|
||||
fn end_job(&mut self, job: &Running, outcome: JobOutcome) {
|
||||
// Every terminal path invalidates a preview, including worker errors, cancellation
|
||||
// and discarded results whose document was closed or changed.
|
||||
self.live_commit = None;
|
||||
let (state, result, error) = match &outcome {
|
||||
JobOutcome::Done(v) => ("done", Some(v.clone()), None),
|
||||
JobOutcome::Failed(e) => ("failed", None, Some(e.clone())),
|
||||
|
||||
@@ -727,3 +727,58 @@ fn a_job_lands_in_the_recording_it_was_started_in_only() {
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(action_steps(&mut s, "A"), ["filter.blur.gaussianBlur"]);
|
||||
}
|
||||
|
||||
/// Exercise six terminal job routes so prepared paint snapshots cannot outlive a completed or discarded job.
|
||||
#[test]
|
||||
fn every_job_terminal_path_drops_a_live_commit_stroke() {
|
||||
use crate::brush_cmds::mixer_live::LiveMixer;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
for outcome in 0..6 {
|
||||
let mut s = session(32, 32);
|
||||
let live = LiveMixer::begin(&s, &json!({"points": [[16, 16]], "size": 4})).unwrap();
|
||||
live.prepare_commit(&mut s).unwrap();
|
||||
let gate = Arc::new(AtomicBool::new(false));
|
||||
let worker_gate = gate.clone();
|
||||
let id = job(s
|
||||
.start_job(
|
||||
"test.mixer_cleanup",
|
||||
json!({}),
|
||||
"Mixer cleanup",
|
||||
true,
|
||||
move |ctx| {
|
||||
while !worker_gate.load(Ordering::Relaxed) {
|
||||
ctx.check()?;
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
if outcome == 1 { Err(EngineError::Other("worker failed".into())) } else { Ok(()) }
|
||||
},
|
||||
move |_, ()| {
|
||||
if outcome == 4 { Err(EngineError::Other("apply failed".into())) } else { Ok(json!({})) }
|
||||
},
|
||||
)
|
||||
.unwrap());
|
||||
assert!(s.live_commit.is_some());
|
||||
if outcome == 2 {
|
||||
assert!(s.cancel_job(id));
|
||||
s.join_cancelled_jobs();
|
||||
} else {
|
||||
if outcome == 3 {
|
||||
// Simulate replacement outside command dispatch, which itself clears offers.
|
||||
let doc = (*s.active().unwrap().doc).clone();
|
||||
s.active_mut().unwrap().doc = Arc::new(doc);
|
||||
} else if outcome == 5 {
|
||||
s.docs.clear();
|
||||
s.active = None;
|
||||
}
|
||||
gate.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let event = wait_event(&mut s, id);
|
||||
match outcome {
|
||||
0 => assert!(matches!(event.outcome, JobOutcome::Done(_))),
|
||||
2 => assert!(matches!(event.outcome, JobOutcome::Cancelled)),
|
||||
_ => assert!(matches!(event.outcome, JobOutcome::Failed(_))),
|
||||
}
|
||||
assert!(s.live_commit.is_none(), "terminal path {outcome} retained the preview");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,7 +472,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Camera Raw Filter…",
|
||||
menu: &["Filter"],
|
||||
shortcut: Some("Cmd+Shift+A"),
|
||||
params: r##"{"temperature":-100..100=0,"tint":-100..100=0,"exposure":-5..5=0,"contrast":-100..100=0,"highlights":-100..100=0,"shadows":-100..100=0,"whites":-100..100=0,"blacks":-100..100=0,"texture":-100..100=0,"clarity":-100..100=0,"dehaze":-100..100=0,"vibrance":-100..100=0,"saturation":-100..100=0,"curveHighlights":-100..100=0,"curveLights":-100..100=0,"curveDarks":-100..100=0,"curveShadows":-100..100=0,"curveSplits":[25,50,75],"pointCurve":[[in,out]],"pointCurveRed":[[in,out]],"pointCurveGreen":[[in,out]],"pointCurveBlue":[[in,out]],"hslHue":[8],"hslSat":[8],"hslLum":[8],"gradeShadows":{"hue":deg,"sat":0..100,"lum":-100..100},"gradeMidtones":{},"gradeHighlights":{},"gradeGlobal":{},"gradeBlending":0..100=50,"gradeBalance":-100..100=0,"sharpenAmount":0..150=0,"sharpenRadius":0.5..3=1,"sharpenDetail":0..100=25,"sharpenMasking":0..100=0,"noiseLuminance":0..100=0,"noiseLuminanceDetail":0..100=50,"noiseColor":0..100=0,"noiseColorDetail":0..100=50,"grainAmount":0..100=0,"grainSize":0..100=25,"grainRoughness":0..100=50,"vignetteAmount":-100..100=0,"vignetteMidpoint":0..100=50,"vignetteRoundness":-100..100=0,"vignetteFeather":0..100=50,"vignetteHighlights":0..100=0,"vignetteStyle":"highlightPriority|colorPriority|paintOverlay","seed":u32=0}"##,
|
||||
params: r##"{"temperature":-100..100=0,"tint":-100..100=0,"exposure":-5..5=0,"contrast":-100..100=0,"highlights":-100..100=0,"shadows":-100..100=0,"whites":-100..100=0,"blacks":-100..100=0,"texture":-100..100=0,"clarity":-100..100=0,"dehaze":-100..100=0,"vibrance":-100..100=0,"saturation":-100..100=0,"curveHighlights":-100..100=0,"curveLights":-100..100=0,"curveDarks":-100..100=0,"curveShadows":-100..100=0,"curveSplits":[25,50,75],"pointCurve":[[in,out]],"pointCurveRed":[[in,out]],"pointCurveGreen":[[in,out]],"pointCurveBlue":[[in,out]],"hslHue":[8],"hslSat":[8],"hslLum":[8],"gradeShadows":{"hue":deg,"sat":0..100,"lum":-100..100},"gradeMidtones":{},"gradeHighlights":{},"gradeGlobal":{},"gradeBlending":0..100=50,"gradeBalance":-100..100=0,"shadowTint":-100..100=0,"calibrationHue":[red,green,blue] -100..100,"calibrationSat":[red,green,blue] -100..100 (the Calibration panel: Shadows tint and the primaries' hue and saturation),"sharpenAmount":0..150=0,"sharpenRadius":0.5..3=1,"sharpenDetail":0..100=25,"sharpenMasking":0..100=0,"noiseLuminance":0..100=0,"noiseLuminanceDetail":0..100=50,"noiseColor":0..100=0,"noiseColorDetail":0..100=50,"grainAmount":0..100=0,"grainSize":0..100=25,"grainRoughness":0..100=50,"vignetteAmount":-100..100=0,"vignetteMidpoint":0..100=50,"vignetteRoundness":-100..100=0,"vignetteFeather":0..100=50,"vignetteHighlights":0..100=0,"vignetteStyle":"highlightPriority|colorPriority|paintOverlay","seed":u32=0}"##,
|
||||
enabled: raw_enabled,
|
||||
run: camera_raw_cmd,
|
||||
journal: true,
|
||||
|
||||
@@ -244,8 +244,9 @@ pub struct DocState {
|
||||
/// pixel coordinates. Per-document view state: discarded when the document closes, never saved
|
||||
/// to `.pcraft` or PSD, and not a history step.
|
||||
pub color_samplers: Vec<[f64; 2]>,
|
||||
/// History panel snapshots, oldest first (`history.newSnapshot`, #2966). Kept for the
|
||||
/// session only, as in Photoshop: not history states, and not saved with the document.
|
||||
/// History panel snapshots, oldest first (`history.newSnapshot`, #2966): the first (id 0) is
|
||||
/// the document as opened. Kept for the session only, as in Photoshop: not history states,
|
||||
/// and not saved with the document.
|
||||
pub snapshots: Vec<history_cmds::Snapshot>,
|
||||
/// The id the next snapshot gets; ids are never reused, so deleting one keeps the others.
|
||||
next_snapshot: u64,
|
||||
@@ -272,9 +273,14 @@ impl DocState {
|
||||
pub fn new(doc: Document, path: Option<String>) -> Self {
|
||||
let active_layer = doc.top_layer();
|
||||
let mut history = History::default();
|
||||
history.set_current_layers(LayerTarget { active: active_layer, selected: active_layer.into_iter().collect() });
|
||||
let target = LayerTarget { active: active_layer, selected: active_layer.into_iter().collect() };
|
||||
history.set_current_layers(target.clone());
|
||||
let doc = Arc::new(doc);
|
||||
// Photoshop's first snapshot: the document as opened, named after it, so the History
|
||||
// panel's top row can always bring it back (#2966).
|
||||
let first = history_cmds::Snapshot { id: 0, name: doc.name.clone(), doc: doc.clone(), layers: target };
|
||||
Self {
|
||||
doc: Arc::new(doc),
|
||||
doc,
|
||||
history,
|
||||
active_layer,
|
||||
selected_layers: active_layer.into_iter().collect(),
|
||||
@@ -294,7 +300,7 @@ impl DocState {
|
||||
show_only: None,
|
||||
floating: None,
|
||||
color_samplers: Vec::new(),
|
||||
snapshots: Vec::new(),
|
||||
snapshots: vec![first],
|
||||
next_snapshot: 1,
|
||||
source_snapshot: None,
|
||||
}
|
||||
@@ -499,6 +505,9 @@ pub struct Session {
|
||||
pub quick_mask_options: channel_cmds::QuickMaskOptions,
|
||||
/// Colour channel the running command may change (a single colour channel is targeted).
|
||||
color_restrict: Option<usize>,
|
||||
/// Smart object whose filter mask the running command edits (`"target":"filterMask"`): each
|
||||
/// edit re-renders it, in the same history step.
|
||||
filter_mask_layer: Option<LayerId>,
|
||||
/// Edit › Preferences, keyboard shortcuts, menu and toolbar customisation (see `prefs`).
|
||||
pub prefs: prefs::PrefsStore,
|
||||
/// Release checks use an injected platform transport; no network I/O in the engine.
|
||||
@@ -858,7 +867,7 @@ impl Session {
|
||||
let d = self.active_mut()?;
|
||||
d.doc.layer(target)?;
|
||||
let active = d.active_layer.replace(target);
|
||||
let r = (spec.enabled)(self);
|
||||
let r = self.precondition(spec, params);
|
||||
if let Some(d) = self.active_mut() {
|
||||
d.active_layer = active;
|
||||
}
|
||||
@@ -872,6 +881,7 @@ impl Session {
|
||||
let _authorization = crate::jobs::render_authorization_scope(self.authorize);
|
||||
let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new));
|
||||
let restrict = self.color_restrict;
|
||||
let filter_mask_layer = self.filter_mask_layer;
|
||||
let sampler_map = self.sampler_map.take();
|
||||
let symmetry_map = self.symmetry_map.take();
|
||||
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
@@ -885,6 +895,9 @@ impl Session {
|
||||
if let (Some(k), Some(id)) = (restrict, active) {
|
||||
channel_cmds::restrict_to_color(&before, &mut doc, id, k);
|
||||
}
|
||||
if let Some(id) = filter_mask_layer {
|
||||
smart_cmds::refresh_after_filter_mask_edit(&mut doc, id)?;
|
||||
}
|
||||
st.doc = Arc::new(doc);
|
||||
st.active_layer = active;
|
||||
fix_selection(st);
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
//! While a mask is shown, pixel commands without an explicit `target` edit that mask
|
||||
//! ([`crate::channel_cmds`]'s target injection), so painting in mask view paints the mask.
|
||||
//! The view ends when its layer stops being the active layer or loses its mask ([`fix`]).
|
||||
//!
|
||||
//! With `"mask":"filter"` the same view shows a smart object's filter mask (⌥-click the filter
|
||||
//! mask thumbnail on its Smart Filters row), and painting while it's shown paints that mask.
|
||||
|
||||
use photocraft_doc::LayerId;
|
||||
use serde::Serialize;
|
||||
@@ -35,6 +38,19 @@ pub enum MaskViewMode {
|
||||
pub struct LayerMaskView {
|
||||
pub layer: LayerId,
|
||||
pub mode: MaskViewMode,
|
||||
/// The smart object's filter mask is shown, not its layer mask.
|
||||
pub filter: bool,
|
||||
}
|
||||
|
||||
/// The mask a view of `l` shows: its layer mask, or (`filter`) its smart filter mask.
|
||||
pub fn viewed_mask(l: &photocraft_doc::Layer, filter: bool) -> Option<&photocraft_doc::LayerMask> {
|
||||
if !filter {
|
||||
return l.mask.as_ref();
|
||||
}
|
||||
match &l.content {
|
||||
photocraft_doc::LayerContent::Smart(sm) => sm.filter_mask.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bad(msg: impl Into<String>) -> EngineError {
|
||||
@@ -48,7 +64,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
/// The mask view of `st` when it is live: its layer is the active layer and still has a mask.
|
||||
pub fn current(st: &DocState) -> Option<LayerMaskView> {
|
||||
let v = st.channel_view.layer_mask?;
|
||||
let live = st.active_layer == Some(v.layer) && st.doc.layer(v.layer).is_some_and(|l| l.mask.is_some());
|
||||
let live = st.active_layer == Some(v.layer) && st.doc.layer(v.layer).is_some_and(|l| viewed_mask(l, v.filter).is_some());
|
||||
live.then_some(v)
|
||||
}
|
||||
|
||||
@@ -68,16 +84,26 @@ fn mode_name(v: Option<LayerMaskView>) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The view as JSON (`{"layer","mode"}`, or `null` when off); part of `document.inspect`.
|
||||
/// The view as JSON (`{"layer","mode"}`, plus `"mask":"filter"` for a filter mask, or `null` when
|
||||
/// off); part of `document.inspect`.
|
||||
pub fn view_json(st: &DocState) -> Value {
|
||||
match current(st) {
|
||||
Some(v) if v.filter => json!({"layer": v.layer.0, "mode": mode_name(Some(v)), "mask": "filter"}),
|
||||
Some(v) => json!({"layer": v.layer.0, "mode": mode_name(Some(v))}),
|
||||
None => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// `{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray"}`.
|
||||
/// `{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray","mask":"layer"|"filter"="layer"}`.
|
||||
fn set_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let filter = match p.get("mask") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("layer") => false,
|
||||
Some("filter") => true,
|
||||
_ => return Err(bad("`mask` must be \"layer\" or \"filter\"")),
|
||||
},
|
||||
};
|
||||
let mode = match p.get("mode") {
|
||||
None | Some(Value::Null) => "toggleGray",
|
||||
Some(v) => v.as_str().ok_or_else(|| bad("`mode` must be a string"))?,
|
||||
@@ -88,9 +114,9 @@ fn set_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?),
|
||||
};
|
||||
let l = st.doc.layer(layer).ok_or(EngineError::NoLayer(layer))?;
|
||||
let has_mask = l.mask.is_some();
|
||||
let has_mask = viewed_mask(l, filter).is_some();
|
||||
let name = l.name.clone();
|
||||
let shown = current(st).filter(|v| v.layer == layer).map(|v| v.mode);
|
||||
let shown = current(st).filter(|v| v.layer == layer && v.filter == filter).map(|v| v.mode);
|
||||
let want = match mode {
|
||||
"off" => None,
|
||||
"gray" => Some(MaskViewMode::Gray),
|
||||
@@ -100,16 +126,16 @@ fn set_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
other => return Err(bad(format!("unknown mode `{other}` (off|gray|overlay|toggleGray|toggleOverlay)"))),
|
||||
};
|
||||
if want.is_some() && !has_mask {
|
||||
return Err(bad(format!("layer \"{name}\" has no layer mask")));
|
||||
return Err(bad(format!("layer \"{name}\" has no {}", if filter { "filter mask" } else { "layer mask" })));
|
||||
}
|
||||
// ⌥-clicking another layer's mask selects that layer, as in Photoshop.
|
||||
if want.is_some() && s.active().and_then(|st| st.active_layer) != Some(layer) {
|
||||
s.select_layer(layer)?;
|
||||
}
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
let next = want.map(|mode| LayerMaskView { layer, mode });
|
||||
let next = want.map(|mode| LayerMaskView { layer, mode, filter });
|
||||
// "off" for a layer whose mask isn't the one shown leaves the other view alone.
|
||||
let applies = next.is_some() || st.channel_view.layer_mask.is_some_and(|v| v.layer == layer);
|
||||
let applies = next.is_some() || st.channel_view.layer_mask.is_some_and(|v| v.layer == layer && v.filter == filter);
|
||||
if applies && st.channel_view.layer_mask != next {
|
||||
st.channel_view.layer_mask = next;
|
||||
// Hiding the mask while every colour channel is off would leave a blank canvas.
|
||||
@@ -123,7 +149,7 @@ fn set_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.last_damage = Some(photocraft_geom::Rect::EMPTY);
|
||||
}
|
||||
let v = current(st);
|
||||
Ok(json!({"layer": layer.0, "mode": mode_name(v.filter(|v| v.layer == layer))}))
|
||||
Ok(json!({"layer": layer.0, "mode": mode_name(v.filter(|v| v.layer == layer && v.filter == filter)), "mask": if filter { "filter" } else { "layer" }}))
|
||||
}
|
||||
|
||||
/// `\` needs a layer mask on the active layer; without one Photoshop's key does nothing.
|
||||
@@ -150,7 +176,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "View Layer Mask",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray"} (gray: the mask alone, ⌥-click its thumbnail; overlay: red rubylith over the composite, ⇧⌥-click or `\`; view state, not an undo step; painting while shown paints the mask)"##,
|
||||
params: r##"{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray","mask":"layer"|"filter"="layer" (filter: a smart object's filter mask)} (gray: the mask alone, ⌥-click its thumbnail; overlay: red rubylith over the composite, ⇧⌥-click or `\`; view state, not an undo step; painting while shown paints the mask)"##,
|
||||
enabled: has_doc,
|
||||
run: set_view,
|
||||
journal: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Paint helpers: Paint Bucket and Gradient tool.
|
||||
|
||||
use photocraft_algo::paint::{GradientShape, bucket_fill_inks, bucket_fill_region_inks, bucket_fill_region_src, bucket_fill_src, paint_gradient};
|
||||
use photocraft_algo::paint::{FillMode, GradientShape, bucket_fill_inks, bucket_fill_region_inks, paint_gradient};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, blend_from_str};
|
||||
@@ -41,12 +41,27 @@ fn opacity(p: &Value, cmd: &str) -> Result<f32> {
|
||||
Ok(crate::eraser_cmds::number(p, cmd, "opacity", 100.0, 1.0..=100.0)? as f32 / 100.0)
|
||||
}
|
||||
|
||||
/// The Paint Bucket's `mode`: a blend mode (not Pass Through, a group's) or `clear`.
|
||||
fn fill_mode(p: &Value) -> Result<FillMode> {
|
||||
let bad = |msg: String| EngineError::BadParams { cmd: "paint.bucket".into(), msg };
|
||||
match p.get("mode") {
|
||||
None | Some(Value::Null) => Ok(FillMode::Blend(photocraft_color::BlendMode::Normal)),
|
||||
Some(Value::String(m)) if m.eq_ignore_ascii_case("clear") => Ok(FillMode::Clear),
|
||||
Some(Value::String(m)) => blend_from_str(m)
|
||||
.filter(|m| *m != photocraft_color::BlendMode::PassThrough)
|
||||
.map(FillMode::Blend)
|
||||
.ok_or_else(|| bad(format!("unknown blend mode `{m}`"))),
|
||||
Some(v) => Err(bad(format!("`mode` must be a blend mode name or `clear`, not {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn bucket(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fg = s.tools.foreground;
|
||||
let c = color(p.get("color"), fg);
|
||||
let (x, y) = (f(p, "x", 0.0).floor() as i32, f(p, "y", 0.0).floor() as i32);
|
||||
let (tol, contiguous, aa) = (f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true));
|
||||
let opacity = opacity(p, "paint.bucket")?;
|
||||
let mode = fill_mode(p)?;
|
||||
let all = b(p, "sampleAllLayers", false) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels;
|
||||
// Other visible layers can define the region, but the fill still writes only to the target.
|
||||
let region = if all {
|
||||
@@ -83,16 +98,16 @@ fn bucket(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let w = area.width() as usize;
|
||||
let src = |px: i32, py: i32| rendered[(py - area.y0) as usize * w + (px - area.x0) as usize];
|
||||
if let Some(region) = ®ion {
|
||||
bucket_fill_region_src(surf, region, opacity, sel.as_ref(), lock, src);
|
||||
bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, src, None, mode);
|
||||
true
|
||||
} else {
|
||||
bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, src)
|
||||
bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, src, None, mode)
|
||||
}
|
||||
} else if let Some(region) = ®ion {
|
||||
bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, |_, _| c, inks);
|
||||
bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, |_, _| c, inks, mode);
|
||||
true
|
||||
} else {
|
||||
bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |_, _| c, inks)
|
||||
bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |_, _| c, inks, mode)
|
||||
};
|
||||
surf.prune();
|
||||
Ok(ok)
|
||||
@@ -162,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Paint Bucket",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"sampleAllLayers":bool=false,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
|
||||
params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"sampleAllLayers":bool=false,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"mode":"normal|multiply|…|clear (erases the filled area)"="normal","target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##,
|
||||
enabled: crate::commands::has_paintable,
|
||||
run: bucket,
|
||||
journal: true,
|
||||
@@ -172,7 +187,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Gradient",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted: straight stops),"smoothness":0..100=100,"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
|
||||
params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted: straight stops),"smoothness":0..100=100,"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##,
|
||||
enabled: crate::commands::has_paintable,
|
||||
run: gradient,
|
||||
journal: true,
|
||||
@@ -210,6 +225,33 @@ mod tests {
|
||||
(s, bottom)
|
||||
}
|
||||
|
||||
/// The options bar's Mode reaches the fill: Multiply over grey, Clear erases, a bad name or
|
||||
/// Pass Through is an error and changes nothing.
|
||||
#[test]
|
||||
fn bucket_mode_multiplies_clears_and_rejects_bad_names() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let base = json!({"x": 1, "y": 1, "antiAlias": false, "tolerance": 0});
|
||||
let with = |extra: Value| {
|
||||
let mut p = base.clone();
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
s.execute("paint.bucket", with(json!({"color": "#808080"}))).unwrap();
|
||||
s.execute("paint.bucket", with(json!({"color": "#ff0000", "mode": "multiply"}))).unwrap();
|
||||
let p = px(&s, 3, 3);
|
||||
assert!((p[0] - 0.5).abs() < 0.01 && p[1] < 0.01 && p[3] > 0.99, "depth {depth}: {p:?}");
|
||||
for bad in [json!("passThrough"), json!("sparkle"), json!(3)] {
|
||||
assert!(s.execute("paint.bucket", with(json!({"mode": bad}))).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(px(&s, 3, 3), p, "a refused mode paints nothing");
|
||||
s.execute("paint.bucket", with(json!({"mode": "Clear", "opacity": 50}))).unwrap();
|
||||
assert!((px(&s, 3, 3)[3] - 0.5).abs() < 0.01, "depth {depth}: half cleared");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_sample_all_layers_uses_visible_boundaries_at_all_depths() {
|
||||
for depth in [8, 16, 32] {
|
||||
|
||||
+37
-12
@@ -61,7 +61,7 @@ choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", Sol
|
||||
choice!(LightTheme { ProLight = "proLight", ProLightGray = "proLightGray", StudioLight = "studioLight", Classic = "classic", Adwaita = "adwaita", BreezeLight = "breezeLight", SoftLight = "softLight" } default ProLight);
|
||||
choice!(CanvasColor { Default = "default", Black = "black", DarkGray = "darkGray", MediumGray = "mediumGray", LightGray = "lightGray", Custom = "custom" } default Default);
|
||||
choice!(CanvasBorder { DropShadow = "dropShadow", Line = "line", None = "none" } default DropShadow);
|
||||
choice!(UiScale { Auto = "auto", P75 = "75", P80 = "80", P85 = "85", P90 = "90", P95 = "95", P100 = "100", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto);
|
||||
choice!(UiScale { Auto = "auto", P75 = "75", P80 = "80", P85 = "85", P90 = "90", P95 = "95", P100 = "100", P105 = "105", P110 = "110", P115 = "115", P120 = "120", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto);
|
||||
choice!(
|
||||
/// Graphics backend of the desktop app's window and GPU canvas (applies at next launch).
|
||||
/// `auto` lets PhotoCraft pick (DX12 for Intel adapters on Windows); `cpu` composites on the
|
||||
@@ -223,6 +223,7 @@ pub struct General {
|
||||
pub always_create_smart_objects_when_placing: bool,
|
||||
/// Scroll wheel zooms instead of panning.
|
||||
pub zoom_with_scroll_wheel: bool,
|
||||
/// Wheel zoom eases over the frames of a notch; off, a notch is one instant ×1.1 step.
|
||||
pub animated_zoom: bool,
|
||||
pub zoom_resizes_windows: bool,
|
||||
pub use_legacy_free_transform: bool,
|
||||
@@ -549,7 +550,11 @@ pub struct Performance {
|
||||
pub memory_usage_mb: u32,
|
||||
/// Undo steps kept per document (History panel states).
|
||||
pub history_states: u32,
|
||||
/// Display cache levels (mip levels kept for zoomed-out views).
|
||||
/// Cache Levels, 1–8: the highest cache level the Histogram panel counts a large document at
|
||||
/// (level n counts a proxy 2^(n-1) times smaller; 1 counts every pixel). Lower is more
|
||||
/// accurate, higher faster. The panel picks the level from the document's size up to this;
|
||||
/// the web build, which counts on the UI thread, reduces documents from a quarter of the size
|
||||
/// the desktop does. Uncached Refresh always counts at level 1.
|
||||
pub cache_levels: u32,
|
||||
/// Side of the GPU canvas texture tiles, in pixels (applies at next launch).
|
||||
pub cache_tile_size: u32,
|
||||
@@ -901,6 +906,35 @@ pub struct RawDefaults {
|
||||
pub open_in_camera_raw: bool,
|
||||
/// The demosaic method raws are developed with; the open-time Camera Raw dialog starts from it.
|
||||
pub demosaic: RawDemosaic,
|
||||
/// Keep a raw file's develop settings in a sidecar (`IMG_0001.xmp` beside `IMG_0001.CR2`),
|
||||
/// as Camera Raw's "Save image settings in: Sidecar .xmp files"; the sidecar is read again
|
||||
/// when the file is next opened. Off reads sidecars but never writes one.
|
||||
pub write_xmp_sidecars: bool,
|
||||
}
|
||||
|
||||
impl RawColorSpace {
|
||||
/// The built-in profile `edit.convertToProfile` knows this space as.
|
||||
pub fn profile_id(self) -> &'static str {
|
||||
match self {
|
||||
RawColorSpace::Srgb => "srgb",
|
||||
RawColorSpace::AdobeRgb => "adobe-rgb-compat",
|
||||
RawColorSpace::ProPhoto => "prophoto-compat",
|
||||
RawColorSpace::DisplayP3 => "display-p3",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RawSharpen {
|
||||
/// Camera Raw's output sharpening for this medium as the Detail panel's (amount, radius):
|
||||
/// none, Screen (light, fine), Matte paper (stronger, wider: ink spreads), Glossy paper.
|
||||
pub fn preset(self) -> Option<(f32, f32)> {
|
||||
match self {
|
||||
RawSharpen::None => None,
|
||||
RawSharpen::Screen => Some((25.0, 1.0)),
|
||||
RawSharpen::Matte => Some((35.0, 1.2)),
|
||||
RawSharpen::Glossy => Some((40.0, 1.0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RawDemosaic {
|
||||
@@ -925,6 +959,7 @@ impl Default for RawDefaults {
|
||||
apply_auto_tone: false,
|
||||
open_in_camera_raw: true,
|
||||
demosaic: RawDemosaic::Ahd,
|
||||
write_xmp_sidecars: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1119,7 +1154,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
|
||||
"general.colorPicker",
|
||||
"workspace.autoShowHiddenPanels",
|
||||
"workspace.enableFloatingDocumentWindowDocking",
|
||||
"performance.cacheLevels",
|
||||
"performance.effectCacheMb",
|
||||
"performance.legacyCompositing",
|
||||
"scratchDisks.disks",
|
||||
@@ -1129,15 +1163,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
|
||||
"plugIns.allowScriptsToConnect",
|
||||
"type.textEngine",
|
||||
"enhancedControls.scrubbySliderAcceleration",
|
||||
"rawDefaults.colorSpace",
|
||||
"rawDefaults.bitDepth",
|
||||
"rawDefaults.sharpenFor",
|
||||
"rawDefaults.openAsSmartObject",
|
||||
"rawDefaults.applyAutoTone",
|
||||
// Agent access is governed by the launch flags (`--control`, the automation roots), not
|
||||
// by these yet.
|
||||
"integrations.allowAgentControl",
|
||||
"integrations.controlPort",
|
||||
];
|
||||
|
||||
/// Is the preference at `path` (`"section.key"`) hidden from the Preferences dialog?
|
||||
|
||||
@@ -22,6 +22,21 @@ fn defaults_match_photoshop() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intermediate_ui_scale_presets_survive_restart() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute("prefs.get", json!({"path": "interface.uiScale"})).unwrap(), json!("auto"));
|
||||
for scale in ["100", "105", "110", "115", "120", "125"] {
|
||||
s.execute("prefs.set", json!({"path": "interface.uiScale", "value": scale})).unwrap();
|
||||
let mut restored = Session::new();
|
||||
restored.load_prefs_json(&s.prefs_to_json()).unwrap();
|
||||
assert_eq!(restored.execute("prefs.get", json!({"path": "interface.uiScale"})).unwrap(), json!(scale));
|
||||
}
|
||||
// Presets remain discrete choices: arbitrary values must not silently replace a valid scale.
|
||||
assert!(s.execute("prefs.set", json!({"path": "interface.uiScale", "value": "112"})).is_err());
|
||||
assert_eq!(s.execute("prefs.get", json!({"path": "interface.uiScale"})).unwrap(), json!("125"));
|
||||
}
|
||||
|
||||
/// #2264: the Studio workspace dot pattern can be hidden without changing themes.
|
||||
#[test]
|
||||
fn canvas_dot_grid_can_be_disabled_persisted_and_reset() {
|
||||
|
||||
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Red Eye",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"x":px, "y":px} | {"points":[[x,y],…] (at most 64)},"pupilSize":1..100=50,"darken":0..100=50,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i} → {"damage":[x,y,w,h], "pixels":n}"#,
|
||||
params: r#"{"x":px, "y":px} | {"points":[[x,y],…] (at most 64)},"pupilSize":1..100=50,"darken":0..100=50,"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i} → {"damage":[x,y,w,h], "pixels":n}"#,
|
||||
enabled,
|
||||
run: red_eye,
|
||||
journal: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Retouching tools as commands: Clone Stamp, Pattern Stamp, Healing Brush, Spot Healing Brush,
|
||||
//! Patch, Content-Aware Move, Remove, Dodge, Burn, Sponge, Blur, Sharpen, Smudge and History Brush.
|
||||
//! Patch, Content-Aware Move, Remove, Dodge, Burn, Sponge, Blur, Sharpen, Smudge, History Brush and
|
||||
//! Art History Brush.
|
||||
//!
|
||||
//! Every command takes a Photoshop-style brush (`points`, `size`, `hardness`, `opacity`, `flow`,
|
||||
//! `spacing`, `layer`), respects the active selection as a mask and the layer's transparency lock, and
|
||||
@@ -24,6 +25,7 @@ use serde_json::{Value, json};
|
||||
use crate::commands::{CommandSpec, blend_from_str};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
mod art_history;
|
||||
mod content_aware_move;
|
||||
mod patch;
|
||||
mod pattern_stamp;
|
||||
@@ -552,7 +554,7 @@ impl LiveRetouch {
|
||||
let bounds = self.bounds();
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
|
||||
let surface = surf.clone();
|
||||
Ok(crate::brush_cmds::LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: None, points: self.points, surface, bounds })
|
||||
Ok(crate::brush_cmds::LiveCommit { mixer: None, cmd: self.cmd, base: self.base, params: self.params, seed: None, points: self.points, surface, bounds })
|
||||
}
|
||||
|
||||
/// Render more points; returns the rectangle that changed. Each changed area is redrawn from
|
||||
@@ -1472,7 +1474,16 @@ impl LiveDab {
|
||||
self.bounds = self.bounds.union(&dmg);
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
|
||||
let surface = surf.clone();
|
||||
Ok(crate::brush_cmds::LiveCommit { cmd: self.cmd, base, params: self.params, seed: None, points: self.points, surface, bounds: self.bounds })
|
||||
Ok(crate::brush_cmds::LiveCommit {
|
||||
mixer: None,
|
||||
cmd: self.cmd,
|
||||
base,
|
||||
params: self.params,
|
||||
seed: None,
|
||||
points: self.points,
|
||||
surface,
|
||||
bounds: self.bounds,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1522,7 +1533,7 @@ impl<'a> AllLayers<'a> {
|
||||
/// The brush every retouching command shares, plus the tool's own params.
|
||||
macro_rules! brush_params {
|
||||
($extra:literal) => {
|
||||
concat!("{", r#""points":[[x,y,pressure?],…],"size":1..5000 px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=Brush Settings spacing,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}")
|
||||
concat!("{", r#""points":[[x,y,pressure?],…],"size":1..5000 px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=Brush Settings spacing,"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1580,7 +1591,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Patch",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"source|destination"="source","contentAware":bool=false (fill the selection content-aware from the dragged-to place; a background job),"structure":1..7=4 (content-aware: 3..7 copy the middle exactly, 1..2 re-synthesise more of it),"color":0..10=0 (content-aware: how far the copy's level adapts to the selection's surroundings),"sampleAllLayers":bool=false (content-aware),"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset"}"#,
|
||||
params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"source|destination"="source","contentAware":bool=false (fill the selection content-aware from the dragged-to place; a background job),"structure":1..7=4 (content-aware: 3..7 copy the middle exactly, 1..2 re-synthesise more of it),"color":0..10=0 (content-aware: how far the copy's level adapts to the selection's surroundings),"sampleAllLayers":bool=false (content-aware),"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset"}"#,
|
||||
enabled: patch::enabled,
|
||||
run: patch::patch,
|
||||
journal: true,
|
||||
@@ -1590,7 +1601,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Content-Aware Move",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"move|extend"="move","structure":1..7=4 (3..7 copy the content exactly but for a band about a patch wide along its edge; 2 widens the band, 1 re-synthesises the content too),"color":0..10=0 (0 keeps the content's level; higher values bring it towards its new place, by up to about 0.04 × color),"sampleAllLayers":bool=false,"scaleX":%=100,"scaleY":%=100 (Transform On Drop: 1..10000, negative flips),"rotate":deg=0 (Transform On Drop, clockwise; scale and rotation are about the selection's centre, before the offset; a transformed patch may reach past the canvas edge),"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset","mode","transformed"?} (a background job; the selection moves, and is transformed, with the content)"#,
|
||||
params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"move|extend"="move","structure":1..7=4 (3..7 copy the content exactly but for a band about a patch wide along its edge; 2 widens the band, 1 re-synthesises the content too),"color":0..10=0 (0 keeps the content's level; higher values bring it towards its new place, by up to about 0.04 × color),"sampleAllLayers":bool=false,"scaleX":%=100,"scaleY":%=100 (Transform On Drop: 1..10000, negative flips),"rotate":deg=0 (Transform On Drop, clockwise; scale and rotation are about the selection's centre, before the offset; a transformed patch may reach past the canvas edge),"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset","mode","transformed"?} (a background job; the selection moves, and is transformed, with the content)"#,
|
||||
enabled: content_aware_move::enabled,
|
||||
run: content_aware_move::content_aware_move,
|
||||
journal: true,
|
||||
@@ -1679,6 +1690,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
run: history_brush,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "paint.artHistoryBrush",
|
||||
label: "Art History Brush",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: brush_params!(
|
||||
r#","state":row (a History panel row, as history.setSource),"snapshot":id (a history.newSnapshot id; default: the History panel's source, as paint.historyBrush; a source of another canvas size is an error),"style":"tightShort|tightMedium|tightLong|looseMedium|looseLong|dab|tightCurl|looseCurl"="tightMedium","area":0..100=50,"tolerance":0..100=0"#
|
||||
),
|
||||
enabled: has_pixel_layer,
|
||||
run: art_history::run,
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
//! Art History Brush: stylized, oriented dabs sampled from a history state.
|
||||
|
||||
use photocraft_algo::art_history::{self, Dab, MAX_COVERAGE, MAX_DABS, RNG_SEED, StampError, StampParams, Style};
|
||||
use photocraft_color::BlendMode;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_paint::MAX_BRUSH_SIZE;
|
||||
use photocraft_paint::retouch::{Region, alpha_index};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
const CMD: &str = "paint.artHistoryBrush";
|
||||
const MAX_POINTS: usize = 4096;
|
||||
|
||||
fn err(msg: impl Into<String>) -> EngineError {
|
||||
bad(CMD, msg)
|
||||
}
|
||||
|
||||
fn object(p: &Value) -> Result<&serde_json::Map<String, Value>> {
|
||||
p.as_object().ok_or_else(|| err("expected an object"))
|
||||
}
|
||||
|
||||
fn finite(v: Option<&Value>, _key: &str) -> Result<Option<f32>> {
|
||||
match v {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| x as f32).ok_or_else(|| err("invalid number")).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn finite_key(p: &Value, key: &str, default: f32) -> Result<f32> {
|
||||
match finite(p.get(key), key)? {
|
||||
None => Ok(default),
|
||||
Some(v) => Ok(v),
|
||||
}
|
||||
}
|
||||
|
||||
fn ranged(p: &Value, key: &str, default: f32, lo: f32, hi: f32) -> Result<f32> {
|
||||
let v = finite_key(p, key, default)?;
|
||||
if (lo..=hi).contains(&v) { Ok(v) } else { Err(err(format!("`{key}` must be a number {lo}..{hi}"))) }
|
||||
}
|
||||
|
||||
fn points(p: &Value) -> Result<Vec<[f32; 2]>> {
|
||||
let Some(arr) = p.get("points").and_then(Value::as_array) else {
|
||||
return Err(err("no stroke points"));
|
||||
};
|
||||
if arr.is_empty() {
|
||||
return Err(err("no stroke points"));
|
||||
}
|
||||
if arr.len() > MAX_POINTS {
|
||||
return Err(err("too many points"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(arr.len());
|
||||
for q in arr {
|
||||
let Some(a) = q.as_array() else {
|
||||
return Err(err("invalid number"));
|
||||
};
|
||||
let (Some(x), Some(y)) = (a.first(), a.get(1)) else {
|
||||
return Err(err("invalid number"));
|
||||
};
|
||||
let x = x.as_f64().filter(|v| v.is_finite()).ok_or_else(|| err("invalid number"))?;
|
||||
let y = y.as_f64().filter(|v| v.is_finite()).ok_or_else(|| err("invalid number"))?;
|
||||
if let Some(pr) = a.get(2)
|
||||
&& pr.as_f64().filter(|v| v.is_finite()).is_none()
|
||||
{
|
||||
return Err(err("invalid number"));
|
||||
}
|
||||
out.push([x as f32, y as f32]);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(err("no stroke points"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn style_of(p: &Value) -> Result<Style> {
|
||||
match p.get("style") {
|
||||
None | Some(Value::Null) => Ok(Style::TightMedium),
|
||||
Some(v) => {
|
||||
let Some(s) = v.as_str() else {
|
||||
return Err(err("unknown Art History style"));
|
||||
};
|
||||
Style::parse(s).ok_or_else(|| err("unknown Art History style"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn union_bounds(dabs: &[Dab]) -> Result<Rect> {
|
||||
let mut r = Rect::EMPTY;
|
||||
for d in dabs {
|
||||
let (x0, y0, x1, y1) = art_history::dab_bounds(d).ok_or_else(|| err("stroke too large"))?;
|
||||
r = r.union(&Rect::new(x0, y0, x1, y1));
|
||||
}
|
||||
if r.is_empty() {
|
||||
return Err(err("no stroke points"));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn selection_mask(sel: Option<&photocraft_raster::Surface>, rect: Rect) -> Option<Vec<f32>> {
|
||||
let sel = sel?;
|
||||
let w = rect.width() as usize;
|
||||
let h = rect.height() as usize;
|
||||
let mut v = vec![0.0f32; w.saturating_mul(h)];
|
||||
for y in rect.y0..rect.y1 {
|
||||
for x in rect.x0..rect.x1 {
|
||||
let i = ((y - rect.y0) as usize).saturating_mul(w).saturating_add((x - rect.x0) as usize);
|
||||
if let Some(slot) = v.get_mut(i) {
|
||||
*slot = sel.sample_channel(x, y, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn stamp_region(
|
||||
dest: &mut Region,
|
||||
src: &Region,
|
||||
dabs: &[Dab],
|
||||
hardness: f32,
|
||||
opacity: f32,
|
||||
flow: f32,
|
||||
tolerance: f32,
|
||||
sel: Option<&[f32]>,
|
||||
lock: bool,
|
||||
alpha: Option<usize>,
|
||||
) -> Result<u32> {
|
||||
if dest.ch != src.ch {
|
||||
return Err(err("source and destination do not match"));
|
||||
}
|
||||
let dest_w = dest.width();
|
||||
let dest_h = dest.height();
|
||||
let dest_origin = (dest.rect.x0, dest.rect.y0);
|
||||
let src_w = src.width();
|
||||
let src_h = src.height();
|
||||
let src_origin = (src.rect.x0, src.rect.y0);
|
||||
let ch = dest.ch;
|
||||
let mut p = StampParams {
|
||||
dest: &mut dest.data,
|
||||
dest_w,
|
||||
dest_h,
|
||||
dest_origin,
|
||||
src: &src.data,
|
||||
src_w,
|
||||
src_h,
|
||||
src_origin,
|
||||
ch,
|
||||
dabs,
|
||||
hardness,
|
||||
opacity,
|
||||
flow,
|
||||
tolerance,
|
||||
tile: 64,
|
||||
selection: sel,
|
||||
lock_transparent: lock,
|
||||
alpha,
|
||||
};
|
||||
match art_history::stamp(&mut p) {
|
||||
Ok(n) => Ok(n),
|
||||
Err(StampError::Empty) => Err(err("no stroke points")),
|
||||
Err(StampError::Mismatched) => Err(err("source and destination do not match")),
|
||||
Err(StampError::TooLarge) => Err(err("stroke too large")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `paint.artHistoryBrush`.
|
||||
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let _ = object(p)?;
|
||||
if s.active().is_none() {
|
||||
return Err(EngineError::NoDocument);
|
||||
}
|
||||
let pts = points(p)?;
|
||||
if pts.len() > MAX_DABS {
|
||||
return Err(err("too many points"));
|
||||
}
|
||||
let size = finite_key(p, "size", s.tools.brush.size)?;
|
||||
if !size.is_finite() {
|
||||
return Err(err("invalid number"));
|
||||
}
|
||||
if size <= 0.0 || size > MAX_BRUSH_SIZE {
|
||||
return Err(err("stroke too large"));
|
||||
}
|
||||
let hardness = ranged(p, "hardness", s.tools.brush.hardness * 100.0, 0.0, 100.0)? / 100.0;
|
||||
let opacity = ranged(p, "opacity", 100.0, 0.0, 100.0)? / 100.0;
|
||||
let flow = ranged(p, "flow", 100.0, 0.0, 100.0)? / 100.0;
|
||||
let area = ranged(p, "area", 50.0, 0.0, 100.0)?;
|
||||
// Percent of the channel range; 0 (paint everywhere) is the default, as in Photoshop.
|
||||
let tolerance = ranged(p, "tolerance", 0.0, 0.0, 100.0)? / 100.0;
|
||||
let style = style_of(p)?;
|
||||
// The stamp mixes the source in by coverage, so only Normal is right; other modes need a
|
||||
// stroke buffer (blending each dab with the already-mixed result rings every dab edge).
|
||||
if blend_param(p, CMD)? != BlendMode::Normal {
|
||||
return Err(err("only the Normal mode is supported"));
|
||||
}
|
||||
let (stroke, id) = parse_brush(s, p, CMD)?;
|
||||
let _ = stroke;
|
||||
// The same source as the History Brush: the `state` or `snapshot` passed, else the History
|
||||
// panel's source, refused after a canvas size change.
|
||||
let src_surface = super::history_source(s, id, p, CMD)?;
|
||||
// Truncating a huge row index only changes the seed.
|
||||
let seed = RNG_SEED ^ p.get("state").and_then(Value::as_u64).map_or(0, |i| i as u32);
|
||||
let dabs = art_history::place_dabs(&pts, size, area, style, seed).map_err(|e| match e {
|
||||
StampError::TooLarge => err("stroke too large"),
|
||||
_ => err("no stroke points"),
|
||||
})?;
|
||||
if dabs.is_empty() {
|
||||
return Err(err("no stroke points"));
|
||||
}
|
||||
let bounds = union_bounds(&dabs)?;
|
||||
// The stamp reads each dab's bounds plus one pixel for bilinear sampling,
|
||||
// so only that margin counts against the coverage limit.
|
||||
let outer = bounds.inflate(1);
|
||||
let area_px = (outer.width() as u64).saturating_mul(outer.height() as u64);
|
||||
if outer.is_empty() || area_px > MAX_COVERAGE {
|
||||
return Err(err("stroke too large"));
|
||||
}
|
||||
|
||||
// Nothing written (every dab under the tolerance, outside the selection or on locked
|
||||
// transparency): a no-op stroke, with no history step.
|
||||
let mut nothing_written = false;
|
||||
let dmg = run_stroke(s, "Art History Brush", id, p, |_, surf, sel, lock| {
|
||||
let fmt = surf.format();
|
||||
let src = if src_surface.format() == fmt { src_surface.clone() } else { src_surface.convert(fmt) };
|
||||
if src.channels() != surf.channels() {
|
||||
return Err(err("source and destination do not match"));
|
||||
}
|
||||
let mut dest = Region::read(surf, outer);
|
||||
let src_reg = Region::read(&src, outer);
|
||||
if dest.ch != src_reg.ch || dest.width() != src_reg.width() || dest.height() != src_reg.height() {
|
||||
return Err(err("source and destination do not match"));
|
||||
}
|
||||
let sel_mask = selection_mask(sel, outer);
|
||||
let n = stamp_region(&mut dest, &src_reg, &dabs, hardness, opacity, flow, tolerance, sel_mask.as_deref(), lock, alpha_index(&fmt))?;
|
||||
if n == 0 {
|
||||
nothing_written = true;
|
||||
return Err(err("the stroke changed no pixel"));
|
||||
}
|
||||
dest.write(surf);
|
||||
Ok(bounds)
|
||||
});
|
||||
let dmg = match dmg {
|
||||
Err(_) if nothing_written => return Ok(json!({ "damage": Value::Null })),
|
||||
r => r?,
|
||||
};
|
||||
Ok(json!({ "damage": damage_json(dmg) }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_doc::LayerContent;
|
||||
use serde_json::json;
|
||||
|
||||
fn session(w: u32, h: u32, depth: u64, mode: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": w, "height": h, "depth": depth, "mode": mode})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rgba(s: &Session, x: i32, y: i32) -> [f32; 4] {
|
||||
let d = s.active().unwrap();
|
||||
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)
|
||||
}
|
||||
|
||||
fn paint_layer(s: &mut Session, f: impl Fn(i32, i32) -> [f32; 4]) {
|
||||
s.edit("setup", |doc, active| {
|
||||
let b = doc.bounds();
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
let mut data = Vec::new();
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
data.extend(photocraft_raster::from_rgba(&fmt, f(x, y)));
|
||||
}
|
||||
}
|
||||
surf.write_region(b, &data);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn stroke(s: &mut Session, extra: Value) -> std::result::Result<Value, EngineError> {
|
||||
let mut p = json!({"points": [[8, 16], [40, 16]], "size": 12, "hardness": 100, "area": 50, "tolerance": 0, "style": "tightMedium"});
|
||||
if let Some(map) = extra.as_object() {
|
||||
for (k, v) in map {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
s.execute("paint.artHistoryBrush", p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_open_state_at_every_depth_and_undoes() {
|
||||
for (depth, mode) in [(8u64, "rgb"), (16, "rgb"), (32, "rgb"), (16, "gray")] {
|
||||
let mut s = session(48, 32, depth, mode);
|
||||
paint_layer(&mut s, |_, _| [0.1, 0.8, 0.2, 1.0]);
|
||||
s.execute("edit.fill", json!({"color": "#2030c0"})).unwrap();
|
||||
let before = rgba(&s, 20, 16);
|
||||
stroke(&mut s, json!({"state": 1})).unwrap();
|
||||
let after = rgba(&s, 20, 16);
|
||||
if mode == "gray" {
|
||||
assert!(after[0] > before[0] + 0.1, "restored lighter gray {depth}: before {before:?} after {after:?}");
|
||||
} else {
|
||||
assert!(before[2] > 0.5, "{mode} {depth} {before:?}");
|
||||
assert!(after[1] > after[2], "restored green {mode} {depth}: {after:?}");
|
||||
}
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
stroke(&mut s, json!({"style": "looseCurl"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one mouse-up = one step");
|
||||
assert!(s.undo());
|
||||
assert!(s.undo());
|
||||
let undone = rgba(&s, 20, 16);
|
||||
for c in 0..3 {
|
||||
assert!((undone[c] - before[c]).abs() < 0.05, "undo {mode} {depth}");
|
||||
}
|
||||
assert!(s.redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_tolerance_paints_a_moderate_history_edit() {
|
||||
let mut s = session(48, 32, 8, "rgb");
|
||||
paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]);
|
||||
paint_layer(&mut s, |_, _| [0.6, 0.6, 0.6, 1.0]);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let p = json!({"points": [[8, 16], [40, 16]], "size": 12, "hardness": 100, "style": "tightMedium", "state": 1});
|
||||
s.execute("paint.artHistoryBrush", p).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
|
||||
assert!(rgba(&s, 24, 16)[0] < 0.55, "{:?}", rgba(&s, 24, 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_under_the_tolerance_records_no_step() {
|
||||
let mut s = session(48, 32, 8, "rgb");
|
||||
paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]);
|
||||
paint_layer(&mut s, |_, _| [0.6, 0.6, 0.6, 1.0]);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let r = stroke(&mut s, json!({"state": 1, "tolerance": 50})).unwrap();
|
||||
assert!(r["damage"].is_null(), "{r}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
assert!((rgba(&s, 24, 16)[0] - 0.6).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_brush_click_is_not_too_large() {
|
||||
let mut s = session(2600, 2600, 8, "rgb");
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
|
||||
for (style, size) in [("looseCurl", 2000), ("tightMedium", 2400)] {
|
||||
let r = stroke(&mut s, json!({"state": 1, "points": [[1300, 1300]], "size": size, "style": style}));
|
||||
assert!(r.is_ok(), "{style} size {size}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_one_uses_that_snapshot() {
|
||||
let mut s = session(40, 24, 8, "rgb");
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
|
||||
stroke(&mut s, json!({"state": 1, "points": [[4, 12], [20, 12]], "size": 10})).unwrap();
|
||||
let p = rgba(&s, 10, 12);
|
||||
assert!(p[0] > 0.9 && p[1] < 0.2, "state 1 is red, got {p:?}");
|
||||
}
|
||||
|
||||
/// With no `state`, the brush paints from the History panel's source, like the History Brush.
|
||||
#[test]
|
||||
fn paints_from_the_history_panel_source() {
|
||||
let mut s = session(40, 24, 8, "rgb");
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("history.setSource", json!({"state": 1})).unwrap();
|
||||
s.execute("paint.artHistoryBrush", json!({"points": [[4, 12], [30, 12]], "size": 10, "hardness": 100})).unwrap();
|
||||
let p = rgba(&s, 12, 12);
|
||||
assert!(p[0] > 0.9 && p[1] < 0.2, "the source row is red, got {p:?}");
|
||||
}
|
||||
|
||||
/// A source of another canvas size is refused, not painted shifted.
|
||||
#[test]
|
||||
fn a_source_of_another_canvas_size_is_refused() {
|
||||
let mut s = session(40, 24, 8, "rgb");
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("image.canvasSize", json!({"width": 60, "height": 30, "anchor": "center"})).unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let e = stroke(&mut s, json!({"state": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("canvas size"), "{e}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
|
||||
/// A stroke that writes nothing (wholly outside the selection, or on transparent pixels
|
||||
/// with Lock Transparency) does nothing, as with the tolerance: no error, no History step.
|
||||
#[test]
|
||||
fn a_stroke_that_writes_nothing_is_a_quiet_no_op() {
|
||||
let mut s = session(48, 32, 8, "rgb");
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let r = stroke(&mut s, json!({"state": 1})).unwrap();
|
||||
assert!(r["damage"].is_null(), "{r}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
assert!(rgba(&s, 24, 16)[2] > 0.9, "still blue");
|
||||
|
||||
let mut s = session(48, 32, 8, "rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.edit("lock", |doc, active| {
|
||||
doc.layer_mut(active.unwrap()).unwrap().locks.transparency = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
// Row 1: the new, transparent layer.
|
||||
let r = stroke(&mut s, json!({"state": 1})).unwrap();
|
||||
assert!(r["damage"].is_null(), "{r}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_normal_mode_paints() {
|
||||
let mut s = session(48, 32, 8, "rgb");
|
||||
paint_layer(&mut s, |_, _| [1.0, 1.0, 1.0, 1.0]);
|
||||
paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
// Multiply by a white source would leave the grey as it is, but blending each dab with the
|
||||
// already-mixed result darkened every dab edge: refused until a stroke buffer exists.
|
||||
assert!(stroke(&mut s, json!({"state": 1, "mode": "multiply"})).is_err());
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
assert!((rgba(&s, 24, 16)[0] - 0.5).abs() < 0.01);
|
||||
stroke(&mut s, json!({"state": 1, "mode": "normal"})).unwrap();
|
||||
assert!(rgba(&s, 24, 16)[0] > 0.9, "{:?}", rgba(&s, 24, 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_errors() {
|
||||
let mut s = session(32, 24, 8, "rgb");
|
||||
paint_layer(&mut s, |_, _| [0.2, 0.3, 0.4, 1.0]);
|
||||
assert!(s.execute("paint.artHistoryBrush", json!([])).is_err());
|
||||
assert!(s.execute("paint.artHistoryBrush", json!({})).is_err());
|
||||
assert!(s.execute("paint.artHistoryBrush", json!({"points": []})).is_err());
|
||||
assert!(
|
||||
s.execute("paint.artHistoryBrush", json!({"points": [[0, 0]], "style": "nope"})).unwrap_err().to_string().contains("unknown Art History style")
|
||||
);
|
||||
assert!(s.execute("paint.artHistoryBrush", json!({"points": [[0, 0]], "state": 99})).is_err());
|
||||
assert!(s.execute("paint.artHistoryBrush", json!({"points": [[0, 0]], "state": u64::MAX})).is_err());
|
||||
assert!(s.execute("paint.artHistoryBrush", json!({"points": [[0, 0]], "area": -1})).is_err());
|
||||
assert!(s.execute("paint.artHistoryBrush", json!({"points": [[0, 0]], "size": "nan"})).is_err());
|
||||
let many: Vec<[f32; 2]> = (0..10_000).map(|i| [i as f32, 0.0]).collect();
|
||||
assert!(s.execute("paint.artHistoryBrush", json!({"points": many})).unwrap_err().to_string().contains("too many points"));
|
||||
assert!(s.execute("paint.artHistoryBrush", json!({"points": [[5, 5]], "size": 8000})).unwrap_err().to_string().contains("stroke too large"));
|
||||
assert!(!Session::new().is_enabled("paint.artHistoryBrush"));
|
||||
assert!(crate::commands::find("paint.artHistoryBrush").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_lock_and_type_layer_error() {
|
||||
let mut s = session(24, 24, 8, "rgb");
|
||||
s.execute("layer.setProps", json!({"locks": {"pixels": true}})).unwrap();
|
||||
let e = stroke(&mut s, json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("locked"), "{e}");
|
||||
s.execute("layer.setProps", json!({"locks": {"pixels": false}})).unwrap();
|
||||
s.execute("type.create", json!({"x": 8, "y": 16, "text": "Hi", "size": 12})).unwrap();
|
||||
assert!(stroke(&mut s, json!({})).is_err());
|
||||
let kind = s.active().and_then(|d| d.active_layer.and_then(|id| d.doc.layer(id))).map(|l| matches!(l.content, LayerContent::Text(_)));
|
||||
assert_eq!(kind, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_does_not_panic() {
|
||||
let mut s = session(32, 24, 8, "cmyk");
|
||||
paint_layer(&mut s, |_, _| [0.2, 0.3, 0.4, 1.0]);
|
||||
s.execute("edit.fill", json!({"color": "#404040"})).ok();
|
||||
let _ = stroke(&mut s, json!({"tolerance": 0}));
|
||||
assert_eq!(s.active().unwrap().doc.mode, ColorMode::Cmyk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_is_registered() {
|
||||
let spec = crate::commands::find("paint.artHistoryBrush").expect("registered");
|
||||
assert_eq!(spec.label, "Art History Brush");
|
||||
assert!(spec.params.contains("style"));
|
||||
assert!(spec.journal);
|
||||
assert!(!Session::new().is_enabled("paint.artHistoryBrush"));
|
||||
let s = session(16, 16, 8, "rgb");
|
||||
assert!(s.is_enabled("paint.artHistoryBrush"));
|
||||
}
|
||||
}
|
||||
@@ -690,6 +690,7 @@ fn retouch_commands_are_registered_and_need_a_pixel_layer() {
|
||||
"paint.smudge",
|
||||
"paint.historyBrush",
|
||||
"paint.remove",
|
||||
"paint.artHistoryBrush",
|
||||
];
|
||||
for id in ids {
|
||||
let spec = crate::commands::find(id).unwrap_or_else(|| panic!("{id} missing"));
|
||||
@@ -706,7 +707,7 @@ fn retouch_commands_are_registered_and_need_a_pixel_layer() {
|
||||
// Paint target (#207): mask, alpha channel, Quick Mask
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const RETOUCH_IDS: [&str; 12] = [
|
||||
const RETOUCH_IDS: [&str; 13] = [
|
||||
"paint.cloneStamp",
|
||||
"paint.patternStamp",
|
||||
"paint.healingBrush",
|
||||
@@ -719,6 +720,7 @@ const RETOUCH_IDS: [&str; 12] = [
|
||||
"paint.smudge",
|
||||
"paint.historyBrush",
|
||||
"paint.remove",
|
||||
"paint.artHistoryBrush",
|
||||
];
|
||||
|
||||
/// Grey noise in 0.3..0.7 (no period along the clone offset used below).
|
||||
@@ -769,6 +771,10 @@ fn retouch_params(id: &str, history_state: usize, target: Value) -> Value {
|
||||
match id {
|
||||
"paint.cloneStamp" | "paint.healingBrush" => p["offset"] = json!([-13, 3]),
|
||||
"paint.historyBrush" => p["state"] = json!(history_state),
|
||||
"paint.artHistoryBrush" => {
|
||||
p["state"] = json!(history_state);
|
||||
p["tolerance"] = json!(0);
|
||||
}
|
||||
"paint.sponge" => p["mode"] = json!("saturate"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
|
||||
("tools.select.cloneStamp", "Clone Stamp Tool", Some("S")),
|
||||
("tools.select.patternStamp", "Pattern Stamp Tool", Some("S")),
|
||||
("tools.select.historyBrush", "History Brush Tool", Some("Y")),
|
||||
("tools.select.artHistoryBrush", "Art History Brush Tool", Some("Y")),
|
||||
("tools.select.blur", "Blur Tool", None),
|
||||
("tools.select.sharpen", "Sharpen Tool", None),
|
||||
("tools.select.smudge", "Smudge Tool", None),
|
||||
|
||||
@@ -644,6 +644,40 @@ fn smart_mut(doc: &mut Document, id: LayerId) -> Result<&mut SmartObject> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `l`'s filter mask can be painted (`"target":"filterMask"`): a smart object with smart
|
||||
/// filters. Its filter mask need not exist yet: painting starts it white (Reveal All), the mask
|
||||
/// Photoshop adds with the first smart filter.
|
||||
pub(crate) fn filter_mask_paintable(l: &Layer) -> std::result::Result<(), String> {
|
||||
match &l.content {
|
||||
LayerContent::Smart(sm) if !sm.smart_filters.is_empty() => Ok(()),
|
||||
LayerContent::Smart(_) => Err(format!("smart object \"{}\" has no smart filters, so no filter mask", l.name)),
|
||||
other => Err(format!("the active layer is {} {} layer, not a smart object", other.article(), other.kind_name())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The filter mask surface of smart object `id` for painting into, made white (Reveal All) first
|
||||
/// when the object has smart filters but no filter mask yet. The caller re-renders the object
|
||||
/// afterwards ([`refresh_after_filter_mask_edit`]).
|
||||
pub(crate) fn filter_mask_surface(doc: &mut Document, id: LayerId) -> Result<&mut Surface> {
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
filter_mask_paintable(l).map_err(EngineError::Other)?;
|
||||
let sm = smart_mut_of(l)?;
|
||||
Ok(&mut sm.filter_mask.get_or_insert_with(LayerMask::reveal_all).surface)
|
||||
}
|
||||
|
||||
/// Re-renders smart object `id` after its filter mask was painted, filled or filtered, so the
|
||||
/// stroke shows (where the mask is black the smart filters don't). Part of the same history step.
|
||||
pub(crate) fn refresh_after_filter_mask_edit(doc: &mut Document, id: LayerId) -> Result<()> {
|
||||
if matches!(doc.layer(id).map(|l| &l.content), Some(LayerContent::Smart(_))) { refresh_or_fail(doc, id) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn smart_mut_of(l: &mut Layer) -> Result<&mut SmartObject> {
|
||||
match &mut l.content {
|
||||
LayerContent::Smart(sm) => Ok(sm),
|
||||
_ => Err(other("the layer is not a smart object")),
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_from_selection(sel: &Surface) -> LayerMask {
|
||||
LayerMask { surface: sel.clone(), enabled: true, linked: true, density: 1.0, feather: 0.0 }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
|
||||
mod filter_mask_paint;
|
||||
mod selection;
|
||||
mod stack_cache;
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! FILE-215-3: painting a smart object's filter mask (`"target":"filterMask"`) hides the smart
|
||||
//! filters where it is black, in one undo step, and the painted mask is saved.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A vertical black stroke over x = 8 into the filter mask, as the Brush tool sends it.
|
||||
fn stroke(s: &mut Session, target: Option<&str>) -> Result<Value> {
|
||||
let mut p = json!({"points": [[8, 2], [8, 46]], "size": 8, "hardness": 1, "color": "#000000"});
|
||||
if let Some(t) = target {
|
||||
p["target"] = json!(t);
|
||||
}
|
||||
s.execute("paint.stroke", p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painting_the_filter_mask_hides_the_filters_there_in_one_undo_step() {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(depth);
|
||||
paint(&mut s);
|
||||
let original = flat(&s);
|
||||
convert(&mut s);
|
||||
// Without smart filters there is no filter mask to paint.
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
assert!(matches!(stroke(&mut s, Some("filterMask")), Err(EngineError::Disabled(..))), "depth {depth}");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
|
||||
let blurred = flat(&s);
|
||||
assert!(active_smart(&s).filter_mask.is_none());
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
|
||||
stroke(&mut s, Some("filterMask")).unwrap();
|
||||
let mask = active_smart(&s).filter_mask.expect("painting starts a white filter mask");
|
||||
assert_eq!((mask.value(8, 20), mask.value(30, 20)), (0.0, 1.0), "depth {depth}");
|
||||
let px = flat(&s);
|
||||
let at = |v: &Vec<[f32; 4]>, x: i32, y: i32| v[(y * W + x) as usize];
|
||||
assert!(max_diff(&[at(&px, 8, 20)], &[at(&original, 8, 20)]) < 1e-6, "depth {depth}: black hides the blur");
|
||||
assert_eq!(at(&px, 30, 20), at(&blurred, 30, 20), "depth {depth}: white keeps it");
|
||||
assert!(active_smart(&s).smart_filters.len() == 1, "the stroke is no smart filter");
|
||||
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(active_smart(&s).filter_mask.is_none());
|
||||
assert_eq!(flat(&s), blurred, "depth {depth}: undo brings the blur back");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(flat(&s), px);
|
||||
|
||||
// The painted mask is saved: .pcraft and PSD give it back.
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let back = photocraft_format::load_from_bytes(&photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap()).unwrap();
|
||||
assert_eq!(smart(&back, id).unwrap().filter_mask, smart(&doc, id).unwrap().filter_mask, "depth {depth}: .pcraft");
|
||||
let psd = photocraft_io::export(&doc, "psd", &Default::default()).unwrap();
|
||||
let back = photocraft_io::import("x.psd", &psd.bytes).unwrap().document;
|
||||
let sm = back.walk().into_iter().find_map(|(_, _, l)| if let LayerContent::Smart(sm) = &l.content { Some(sm.clone()) } else { None }).unwrap();
|
||||
let (mut a, mut b) = (Vec::new(), Vec::new());
|
||||
sm.filter_mask.as_ref().unwrap().values_into(doc.bounds(), &mut a);
|
||||
mask.values_into(doc.bounds(), &mut b);
|
||||
assert_eq!(a, b, "depth {depth}: PSD");
|
||||
}
|
||||
}
|
||||
|
||||
/// ⌥-click on the filter mask thumbnail shows it; painting then goes into it without a target.
|
||||
#[test]
|
||||
fn a_shown_filter_mask_takes_the_paint() {
|
||||
let mut s = session(8);
|
||||
paint(&mut s);
|
||||
convert(&mut s);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
|
||||
let before = s.active().unwrap().channel_view.clone();
|
||||
assert!(s.execute("view.layerMask", json!({"mode": "gray", "mask": "filter"})).is_err(), "no filter mask yet");
|
||||
assert_eq!(s.active().unwrap().channel_view, before);
|
||||
stroke(&mut s, Some("filterMask")).unwrap();
|
||||
let v = s.execute("view.layerMask", json!({"mode": "gray", "mask": "filter"})).unwrap();
|
||||
assert_eq!(v["mode"], "gray");
|
||||
assert!(s.active().unwrap().channel_view.shown_layer_mask(&s.active().unwrap().doc).is_some());
|
||||
s.execute("paint.stroke", json!({"points": [[30, 2], [30, 46]], "size": 8, "hardness": 1, "color": "#000000"})).unwrap();
|
||||
let mask = active_smart(&s).filter_mask.unwrap();
|
||||
assert_eq!(mask.value(30, 20), 0.0, "the stroke went into the filter mask");
|
||||
let d = s.active().unwrap();
|
||||
assert!(matches!(d.doc.layer(d.active_layer.unwrap()).unwrap().content, LayerContent::Smart(_)), "still a smart object");
|
||||
// Bad params are refused, not a panic.
|
||||
let before = s.active().unwrap().channel_view.clone();
|
||||
assert!(s.execute("view.layerMask", json!({"mask": 3})).is_err());
|
||||
assert_eq!(s.active().unwrap().channel_view, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_the_filter_mask_never_copies_the_layer_mask() {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(depth);
|
||||
paint(&mut s);
|
||||
convert(&mut s);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
|
||||
stroke(&mut s, Some("filterMask")).unwrap();
|
||||
for layer_mask in [false, true] {
|
||||
if layer_mask {
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
}
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let result = s.execute("edit.copy", json!({"target": "filterMask"})).unwrap();
|
||||
assert_eq!(result["target"], "filterMask");
|
||||
let clip = s.clipboard.as_ref().unwrap();
|
||||
assert_eq!(clip.surface.rgba(8, 20), [0.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(clip.surface.rgba(30, 20), [1.0; 4]);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_layer_checks_filter_mask_eligibility() {
|
||||
let mut s = session(8);
|
||||
paint(&mut s);
|
||||
let id = convert(&mut s);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
|
||||
s.execute("shape.create", json!({"kind": "rect", "rect": [1, 1, 20, 20], "fill": "#ff0000"})).unwrap();
|
||||
let active = s.active().unwrap().active_layer;
|
||||
s.execute("paint.cloneStamp", json!({"layer": id, "target": "filterMask", "source": [8, 8], "points": [[20, 20]], "size": 4})).unwrap();
|
||||
assert_eq!(s.active().unwrap().active_layer, active);
|
||||
// A shape has no filter mask: rejection must precede recoloring its fill.
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
assert!(s.execute("edit.fillForeground", json!({"layer": active.unwrap().0, "target": "filterMask"})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
|
||||
/// With its filter mask targeted, the commands that don't edit a filter mask act on the smart
|
||||
/// object as without the target: Edit › Transform flips it, and Convert for Smart Filters stays
|
||||
/// disabled (it would nest the smart object).
|
||||
#[test]
|
||||
fn the_filter_mask_target_leaves_the_smart_object_commands_alone() {
|
||||
let mut s = session(8);
|
||||
paint(&mut s);
|
||||
convert(&mut s);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
|
||||
stroke(&mut s, Some("filterMask")).unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
assert!(
|
||||
matches!(s.execute("filter.convertForSmartFilters", json!({"target": "filterMask"})), Err(EngineError::Disabled(..))),
|
||||
"the layer is already a smart object"
|
||||
);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
let before = active_smart(&s).transform;
|
||||
s.execute("edit.transform.flipHorizontal", json!({"target": "filterMask"})).unwrap();
|
||||
assert_ne!(active_smart(&s).transform, before, "the smart object flips");
|
||||
// A shown filter mask doesn't send them the target either.
|
||||
s.execute("view.layerMask", json!({"mode": "gray", "mask": "filter"})).unwrap();
|
||||
assert_eq!(crate::channel_cmds::inject_target(&s, "edit.transform.flipHorizontal", json!({})), json!({}));
|
||||
assert_eq!(crate::channel_cmds::inject_target(&s, "filter.convertForSmartFilters", json!({})), json!({}));
|
||||
assert_eq!(crate::channel_cmds::inject_target(&s, "paint.stroke", json!({}))["target"], "filterMask");
|
||||
// Curves on the shown filter mask edits that gray plane, not the layer mask or the colours.
|
||||
let curve = crate::channel_cmds::isolated_curve_channel(s.active().unwrap()).expect("a gray target");
|
||||
assert_eq!(curve.gray, Some(crate::channel_cmds::GraySurface::FilterMask));
|
||||
let flipped = active_smart(&s).transform;
|
||||
s.execute("edit.transform.flipHorizontal", json!({})).unwrap();
|
||||
assert_ne!(active_smart(&s).transform, flipped, "the smart object flips again");
|
||||
// Moving the smart object doesn't re-render its filters as a filter-mask edit.
|
||||
let p = json!({"dx": 2, "target": "filterMask"});
|
||||
assert_eq!(crate::channel_cmds::filter_mask_layer(&s, "layer.translate", &p), None);
|
||||
assert!(crate::channel_cmds::filter_mask_layer(&s, "paint.stroke", &p).is_some());
|
||||
// Every other routed command edits it and re-renders the object: Equalize too, though it
|
||||
// takes no layer mask target by default.
|
||||
for cmd in ["image.adjustments.shadowsHighlights", "plugin.run", "filter.blur.average"] {
|
||||
assert!(crate::channel_cmds::filter_mask_layer(&s, cmd, &p).is_some(), "{cmd}");
|
||||
}
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
s.execute("image.adjustments.equalize", json!({"target": "filterMask"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
|
||||
assert_eq!(active_smart(&s).smart_filters.len(), 1, "no new smart filter: the filter mask was equalized");
|
||||
}
|
||||
|
||||
/// Gaussian Blur with the filter mask targeted softens the mask's edge (the usual way to feather
|
||||
/// it), re-renders the smart object, and adds no second smart filter.
|
||||
#[test]
|
||||
fn blurring_the_filter_mask_feathers_it() {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(depth);
|
||||
paint(&mut s);
|
||||
convert(&mut s);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
|
||||
stroke(&mut s, Some("filterMask")).unwrap();
|
||||
let hard = active_smart(&s).filter_mask.unwrap();
|
||||
let edge = (9..W).find(|&x| hard.value(x, 20) == 1.0).expect("white right of the stroke");
|
||||
let px = flat(&s);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3, "target": "filterMask"})).unwrap();
|
||||
let soft = active_smart(&s).filter_mask.unwrap();
|
||||
let v = soft.value(edge, 20);
|
||||
assert!(v > 0.0 && v < 1.0, "depth {depth}: the mask edge is soft, got {v}");
|
||||
assert_eq!(soft.value(30, 20), 1.0, "depth {depth}: far from the stroke stays white");
|
||||
assert_eq!(active_smart(&s).smart_filters.len(), 1, "depth {depth}: no second smart filter");
|
||||
assert_ne!(flat(&s), px, "depth {depth}: the smart object is re-rendered");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "depth {depth}: one step");
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,8 @@ pub fn lone_target<'a>(doc: &'a Document, layer: Option<LayerId>, p: &Value) ->
|
||||
let m = l.mask.as_ref().ok_or_else(|| EngineError::Other("layer has no mask".into()))?;
|
||||
Ok((!m.linked).then_some(&m.surface))
|
||||
}
|
||||
// A filter mask is not transformed on its own: the smart object transforms, as in Photoshop.
|
||||
Target::FilterMask => Ok(None),
|
||||
Target::Alpha(i) => doc
|
||||
.channels
|
||||
.get(i)
|
||||
|
||||
@@ -163,6 +163,11 @@ pub(crate) fn color(v: &Value) -> Option<Color> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// A stored `Color` (mode + components), so a CMYK or Lab gradient stop survives a
|
||||
// read-modify-write through the shape swatches.
|
||||
Value::Object(_) => serde_json::from_value::<Color>(v.clone())
|
||||
.ok()
|
||||
.filter(|c| c.c.iter().all(|x| x.is_finite()) && c.alpha.is_finite() && (0.0..=1.0).contains(&c.alpha)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -247,15 +252,21 @@ pub fn path_json(p: &Path) -> Value {
|
||||
fn fill_json(f: &Fill) -> Value {
|
||||
match f {
|
||||
Fill::Solid(c) => json!(hex(c)),
|
||||
Fill::Gradient { stops, angle, scale, style, reverse, .. } => json!({ "gradient": {
|
||||
Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => json!({ "gradient": {
|
||||
"stops": stops.iter().map(|(t, c)| json!([t, hex(c)])).collect::<Vec<_>>(),
|
||||
"angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse } }),
|
||||
"angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse,
|
||||
"opacityStops": opacity_stops, "midpoints": midpoints, "offset": [offset.0, offset.1], "dither": dither, "align": align } }),
|
||||
Fill::Pattern { name, scale, .. } => json!({ "pattern": name, "scale": scale * 100.0 }),
|
||||
}
|
||||
}
|
||||
|
||||
/// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"],…],"angle","scale","style","reverse"}}` /
|
||||
/// `{"pattern":name,"scale"}`. `null` → `Ok(None)` (no fill).
|
||||
/// The most colour stops, opacity stops or midpoints a gradient fill takes, as the gradient stop
|
||||
/// edits (`gradient.fill.stop`, `gradient.presets.stop`) cap them.
|
||||
const MAX_GRADIENT_STOPS: usize = 1024;
|
||||
|
||||
/// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]],…],"angle","scale","style","reverse",
|
||||
/// "opacityStops":[[t,0..1],…],"midpoints":[0..1,…],"offset":[x,y],"dither","align"}}` / `{"pattern":name,"scale"}`.
|
||||
/// `null` → `Ok(None)` (no fill).
|
||||
fn parse_fill(v: &Value) -> std::result::Result<Option<Fill>, String> {
|
||||
if v.is_null() {
|
||||
return Ok(None);
|
||||
@@ -264,9 +275,14 @@ fn parse_fill(v: &Value) -> std::result::Result<Option<Fill>, String> {
|
||||
return Ok(Some(Fill::Solid(c)));
|
||||
}
|
||||
if let Some(g) = v.get("gradient") {
|
||||
let stops: Vec<(f32, Color)> = g
|
||||
.get("stops")
|
||||
.and_then(Value::as_array)
|
||||
// Stop lists are capped as `gradient.fill.stop` caps them: more is an error, not a cut.
|
||||
let list = |key: &str, what: &str| match g.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Array(a)) if a.len() > MAX_GRADIENT_STOPS => Err(format!("too many {what} (max {MAX_GRADIENT_STOPS})")),
|
||||
Some(Value::Array(a)) => Ok(Some(a)),
|
||||
Some(_) => Err(format!("gradient `{key}` must be an array")),
|
||||
};
|
||||
let stops: Vec<(f32, Color)> = list("stops", "colour stops")?
|
||||
.map(|a| a.iter().filter_map(|s| Some((s.get(0)?.as_f64()? as f32, color(s.get(1)?)?))).collect())
|
||||
.unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]);
|
||||
let style = match g.get("style").and_then(Value::as_str).unwrap_or("linear") {
|
||||
@@ -276,13 +292,35 @@ fn parse_fill(v: &Value) -> std::result::Result<Option<Fill>, String> {
|
||||
"diamond" => GradientStyle::Diamond,
|
||||
_ => GradientStyle::Linear,
|
||||
};
|
||||
return Ok(Some(Fill::gradient(
|
||||
let mut fill = Fill::gradient(
|
||||
stops,
|
||||
f64p(g, "angle").unwrap_or(90.0) as f32,
|
||||
f64p(g, "scale").unwrap_or(100.0) as f32 / 100.0,
|
||||
style,
|
||||
g.get("reverse").and_then(Value::as_bool).unwrap_or(false),
|
||||
)));
|
||||
);
|
||||
// The rest of a gradient, so a read-modify-write (the shape swatches) loses nothing.
|
||||
if let Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. } = &mut fill {
|
||||
let unit = |v: &Value| v.as_f64().map(|n| n.clamp(0.0, 1.0) as f32);
|
||||
if let Some(a) = list("opacityStops", "opacity stops")? {
|
||||
*opacity_stops = a.iter().filter_map(|s| Some((unit(s.get(0)?)?, unit(s.get(1)?)?))).collect();
|
||||
opacity_stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
}
|
||||
if let Some(a) = list("midpoints", "midpoints")? {
|
||||
*midpoints = a.iter().filter_map(unit).collect();
|
||||
}
|
||||
if let Some(o) = g.get("offset") {
|
||||
let at = |i: usize| o.get(i).and_then(Value::as_f64).map(|n| n.clamp(-1000.0, 1000.0) as f32);
|
||||
*offset = (at(0).ok_or("gradient offset must be [x, y]")?, at(1).ok_or("gradient offset must be [x, y]")?);
|
||||
}
|
||||
if let Some(d) = g.get("dither").and_then(Value::as_bool) {
|
||||
*dither = d;
|
||||
}
|
||||
if let Some(a) = g.get("align").and_then(Value::as_bool) {
|
||||
*align = a;
|
||||
}
|
||||
}
|
||||
return Ok(Some(fill));
|
||||
}
|
||||
if let Some(name) = v.get("pattern").and_then(Value::as_str) {
|
||||
return Ok(Some(Fill::Pattern {
|
||||
@@ -1756,7 +1794,7 @@ pub(crate) fn leak(s: String) -> &'static str {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let stroke = r##""stroke":{"width":px,"color":"#rrggbb"|fill,"opacity":0..100,"align":"inside|center|outside","cap":"butt|round|square","join":"miter|round|bevel","miterLimit":n,"dashes":[multiples of width],"dashOffset":n}|null"##;
|
||||
let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool}}|{"pattern":name}|null (no fill)"##;
|
||||
let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]|stored Color]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"opacityStops":[[t,0..1]]?,"midpoints":[0..1]?,"offset":[x,y]?,"dither":bool?,"align":bool?}}|{"pattern":name}|null (no fill)"##;
|
||||
vec![
|
||||
spec!(
|
||||
"shape.create",
|
||||
|
||||
@@ -27,15 +27,83 @@ fn stroke_validation_rejects_hostile_values_without_editing_history() {
|
||||
json!({"dashOffset": 1e100}),
|
||||
json!({"width": "4"}),
|
||||
json!({"width": 0.00001, "dashes": [0.01, 0.01]}),
|
||||
json!({"color": {"gradient": {"offset": [0.5]}}}),
|
||||
json!({"color": {"gradient": {"offset": "centre"}}}),
|
||||
// Stop lists past the gradient stop edits' 1024 cap are refused, not cut.
|
||||
json!({"color": {"gradient": {"stops": vec![json!([0.5, "#ff0000"]); 1025]}}}),
|
||||
json!({"color": {"gradient": {"opacityStops": vec![json!([0.5, 1.0]); 1025]}}}),
|
||||
json!({"color": {"gradient": {"midpoints": vec![0.5; 1025]}}}),
|
||||
// A stop list that is not an array is an error, not the default black-to-white.
|
||||
json!({"color": {"gradient": {"stops": "red"}}}),
|
||||
json!({"color": {"gradient": {"opacityStops": {}}}}),
|
||||
json!({"color": {"gradient": {"midpoints": 1}}}),
|
||||
];
|
||||
for stroke in invalid {
|
||||
assert!(s.execute("shape.edit", json!({"layer": id, "stroke": stroke})).is_err(), "{stroke}");
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), history);
|
||||
assert!(s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": stroke})).is_err(), "{stroke}");
|
||||
assert_eq!(s.active().unwrap().doc, before, "{stroke}: a rejected create adds no layer");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), history, "{stroke}: nor a history state");
|
||||
}
|
||||
assert!(s.execute("shape.edit", json!({"layer": id, "rect": [1e100, 0, 20, 20]})).is_err());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
// The cap itself is accepted.
|
||||
let at_cap = json!({"color": {"gradient": {"opacityStops": vec![json!([0.5, 1.0]); 1024], "midpoints": vec![0.5; 1024]}}});
|
||||
s.execute("shape.edit", json!({"layer": id, "stroke": at_cap})).unwrap();
|
||||
}
|
||||
|
||||
/// A gradient stop given as a stored `Color` keeps its colour model: a CMYK stop stays CMYK.
|
||||
#[test]
|
||||
fn gradient_stops_take_a_stored_color_and_keep_its_model() {
|
||||
let mut s = session(100, 80, 8);
|
||||
let id = s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": {"width": 6}})).unwrap()["layer"].as_u64().unwrap();
|
||||
let cmyk = Color { mode: photocraft_doc::ColorMode::Cmyk, c: [0.1, 0.2, 0.3, 0.9], alpha: 0.75 };
|
||||
let stops = json!([[0.0, cmyk], [1.0, "#ffffff"]]);
|
||||
s.execute("shape.edit", json!({"layer": id, "stroke": {"color": {"gradient": {"stops": stops}}}})).unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let LayerContent::Shape(sh) = &doc.layer(LayerId(id)).unwrap().content else { panic!("a shape layer") };
|
||||
let Fill::Gradient { stops, .. } = &sh.stroke.as_ref().unwrap().paint else { panic!("a gradient stroke") };
|
||||
assert_eq!(stops[0], (0.0, cmyk));
|
||||
assert_eq!(stops[1], (1.0, Color::WHITE), "hex stops still parse as before");
|
||||
// A stored colour that isn't finite or has alpha past 1 is not a colour: the stop is dropped,
|
||||
// as an unparseable hex stop is.
|
||||
let bad = json!({"mode": "Cmyk", "c": [0.1, 0.2, 0.3, 0.9], "alpha": 2.0});
|
||||
s.execute("shape.edit", json!({"layer": id, "stroke": {"color": {"gradient": {"stops": [[0.0, bad], [1.0, "#000000"]]}}}})).unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let LayerContent::Shape(sh) = &doc.layer(LayerId(id)).unwrap().content else { panic!("a shape layer") };
|
||||
let Fill::Gradient { stops, .. } = &sh.stroke.as_ref().unwrap().paint else { panic!("a gradient stroke") };
|
||||
assert_eq!(stops.as_slice(), [(1.0, Color::BLACK)]);
|
||||
}
|
||||
|
||||
/// A gradient stroke keeps every gradient field through `shape.edit`, and `shape.info` reports
|
||||
/// them, so the shape swatches can change one setting without dropping the rest.
|
||||
#[test]
|
||||
fn gradient_stroke_round_trips_every_gradient_field() {
|
||||
let mut s = session(100, 80, 16);
|
||||
let id = s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": {"width": 6}})).unwrap()["layer"].as_u64().unwrap();
|
||||
let gradient = json!({"gradient": {
|
||||
"stops": [[0.0, [0.25, 0.5, 0.75, 1.0]], [1.0, [1.0, 0.125, 0.0, 0.5]]],
|
||||
"angle": 30.0, "scale": 50.0, "style": "radial", "reverse": true,
|
||||
"opacityStops": [[1.0, 0.25], [0.0, 1.0]], "midpoints": [0.25], "offset": [0.125, -0.25], "dither": true, "align": false,
|
||||
}});
|
||||
let r = s.execute("shape.edit", json!({"layer": id, "stroke": {"color": gradient}})).unwrap();
|
||||
let Some(Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align }) = shape(&s, id).stroke.map(|st| st.paint)
|
||||
else {
|
||||
panic!("a gradient stroke")
|
||||
};
|
||||
assert_eq!(stops, vec![(0.0, Color::rgba(0.25, 0.5, 0.75, 1.0)), (1.0, Color::rgba(1.0, 0.125, 0.0, 0.5))]);
|
||||
assert_eq!((angle, scale, style, reverse), (30.0, 0.5, GradientStyle::Radial, true));
|
||||
assert_eq!(opacity_stops, vec![(0.0, 1.0), (1.0, 0.25)], "opacity stops are sorted");
|
||||
assert_eq!((midpoints, offset, dither, align), (vec![0.25], (0.125, -0.25), true, false));
|
||||
let g = &r["stroke"]["fill"]["gradient"];
|
||||
assert_eq!((g["style"].as_str(), g["midpoints"].clone(), g["align"].as_bool()), (Some("radial"), json!([0.25]), Some(false)));
|
||||
// A partial gradient keeps the defaults it always had.
|
||||
s.execute("shape.edit", json!({"layer": id, "stroke": {"color": {"gradient": {"stops": [[0, "#000000"], [1, "#ffffff"]]}}}})).unwrap();
|
||||
let Some(Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. }) = shape(&s, id).stroke.map(|st| st.paint) else {
|
||||
panic!("a gradient stroke")
|
||||
};
|
||||
assert_eq!((opacity_stops, midpoints, offset, dither, align), (Vec::new(), Vec::new(), (0.0, 0.0), false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+250
-40
@@ -3,7 +3,7 @@
|
||||
//! ([`photocraft_algo::vanishing`]). The edit lands on the active pixel layer, or on a new layer
|
||||
//! with `"newLayer": true` (Photoshop's advice for non-destructive Vanishing Point work).
|
||||
|
||||
use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_stroke, paste};
|
||||
use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_dabs, paste};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
@@ -13,6 +13,11 @@ use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub const VP: &str = "filter.vanishingPoint";
|
||||
/// Most planes a scene holds.
|
||||
pub const MAX_PLANES: usize = 64;
|
||||
/// Most clone strokes, and dabs over all of them, one run paints.
|
||||
pub const MAX_CLONE_STROKES: usize = 4096;
|
||||
pub const MAX_CLONE_DABS: usize = 200_000;
|
||||
|
||||
fn bad(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: VP.into(), msg: msg.into() }
|
||||
@@ -29,6 +34,9 @@ pub fn scene(p: &Value, canvas: Rect) -> Result<Scene> {
|
||||
.and_then(Value::as_array)
|
||||
.filter(|a| !a.is_empty())
|
||||
.ok_or_else(|| bad("pass `planes`: [{\"corners\":[[x,y]×4]}, {\"from\":0,\"edge\":\"top\",\"angle\":90}]"))?;
|
||||
if planes.len() > MAX_PLANES {
|
||||
return Err(bad(format!("at most {MAX_PLANES} planes")));
|
||||
}
|
||||
let center = [(canvas.x0 + canvas.x1) as f64 / 2.0, (canvas.y0 + canvas.y1) as f64 / 2.0];
|
||||
let corners = |v: &Value| -> Option<[[f64; 2]; 4]> {
|
||||
let a = v.get("corners")?.as_array()?;
|
||||
@@ -67,6 +75,12 @@ pub fn scene(p: &Value, canvas: Rect) -> Result<Scene> {
|
||||
};
|
||||
let angle = pl.get("angle").and_then(Value::as_f64).unwrap_or(90.0);
|
||||
let depth = pl.get("depth").and_then(Value::as_f64).unwrap_or(1.0);
|
||||
if !angle.is_finite() || !(0.0..=360.0).contains(&angle) {
|
||||
return Err(bad(format!("plane {i}: `angle` must be 0..360 degrees")));
|
||||
}
|
||||
if !depth.is_finite() || depth <= 0.0 || depth > 1000.0 {
|
||||
return Err(bad(format!("plane {i}: `depth` must be above 0 and at most 1000")));
|
||||
}
|
||||
sc.tear_off(from, edge, angle, depth).ok_or_else(|| bad(format!("plane {i} would lie behind the camera")))?;
|
||||
}
|
||||
if sc.planes.iter().any(|p| p.homography().is_none()) {
|
||||
@@ -75,6 +89,88 @@ pub fn scene(p: &Value, canvas: Rect) -> Result<Scene> {
|
||||
Ok(sc)
|
||||
}
|
||||
|
||||
/// One stroke of `"clone"`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CloneSpec {
|
||||
pub source: [f64; 2],
|
||||
pub points: Vec<[f64; 2]>,
|
||||
pub size: f64,
|
||||
pub hardness: f64,
|
||||
pub opacity: f64,
|
||||
}
|
||||
|
||||
/// Reads `"clone"`: finite points, at most [`MAX_CLONE_STROKES`] strokes and
|
||||
/// [`MAX_CLONE_DABS`] dabs in all.
|
||||
pub fn clone_specs(p: &Value) -> Result<Vec<CloneSpec>> {
|
||||
let Some(v) = p.get("clone").filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
|
||||
let strokes = v.as_array().ok_or_else(|| bad("`clone` is a list of strokes"))?;
|
||||
if strokes.len() > MAX_CLONE_STROKES {
|
||||
return Err(bad(format!("at most {MAX_CLONE_STROKES} clone strokes")));
|
||||
}
|
||||
let finite = |q: [f64; 2]| q.iter().all(|v| v.is_finite() && v.abs() < 1e7);
|
||||
let num = |c: &Value, k: &str, default: f64, lo: f64, hi: f64| -> Result<f64> {
|
||||
match c.get(k) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(lo, hi)).ok_or_else(|| bad(format!("clone `{k}` must be a number"))),
|
||||
}
|
||||
};
|
||||
let mut dabs = 0usize;
|
||||
let mut out = Vec::with_capacity(strokes.len());
|
||||
for (k, c) in strokes.iter().enumerate() {
|
||||
let source = c.get("source").and_then(pt).filter(|q| finite(*q)).ok_or_else(|| bad(format!("clone {k}: `source` [x,y]")))?;
|
||||
let raw = c.get("points").and_then(Value::as_array).ok_or_else(|| bad(format!("clone {k}: `points` [[x,y], …]")))?;
|
||||
dabs = dabs.saturating_add(raw.len());
|
||||
if dabs > MAX_CLONE_DABS {
|
||||
return Err(bad(format!("at most {MAX_CLONE_DABS} clone points in all")));
|
||||
}
|
||||
let points =
|
||||
raw.iter().map(|q| pt(q).filter(|q| finite(*q))).collect::<Option<Vec<_>>>().ok_or_else(|| bad(format!("clone {k}: every point is [x,y]")))?;
|
||||
out.push(CloneSpec {
|
||||
source,
|
||||
points,
|
||||
size: num(c, "size", 40.0, 1.0, 2000.0)?,
|
||||
hardness: num(c, "hardness", 50.0, 0.0, 100.0)? / 100.0,
|
||||
opacity: num(c, "opacity", 100.0, 0.0, 100.0)? / 100.0,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Paints clone stroke `c` on `surf` inside `canvas`, or, with `work` (the image it samples), on
|
||||
/// `work` and copies the pixels it changed to `surf`. Returns the dabs painted.
|
||||
pub fn clone_into(sc: &Scene, surf: &mut Surface, work: Option<&mut Surface>, c: &CloneSpec, canvas: Rect) -> usize {
|
||||
let Some(first) = c.points.first() else { return 0 };
|
||||
let Some(work) = work else {
|
||||
return clone_dabs(sc, surf, c.source, *first, &c.points, c.size, c.hardness, c.opacity as f32, Some(canvas)).0;
|
||||
};
|
||||
let before = work.clone();
|
||||
// The dabs are cut to the canvas before they touch `work` or `surf`: points up to 1e7 px off
|
||||
// it would otherwise allocate tiles there (and reading their union back, without bound).
|
||||
let (dabs, area) = clone_dabs(sc, work, c.source, *first, &c.points, c.size, c.hardness, c.opacity as f32, Some(canvas));
|
||||
copy_changed(surf, &before, work, area.intersect(&canvas));
|
||||
dabs
|
||||
}
|
||||
|
||||
/// Copies to `dst` the pixels of `area` where `after` differs from `before` (same format).
|
||||
pub fn copy_changed(dst: &mut Surface, before: &Surface, after: &Surface, area: Rect) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = dst.format().channels();
|
||||
let (nw, ow, mut cur) = (after.read_region(area), before.read_region(area), dst.read_region(area));
|
||||
for ((c, a), b) in cur.chunks_exact_mut(n).zip(nw.chunks_exact(n)).zip(ow.chunks_exact(n)) {
|
||||
if a != b {
|
||||
c.copy_from_slice(a);
|
||||
}
|
||||
}
|
||||
dst.write_region(area, &cur);
|
||||
}
|
||||
|
||||
/// The image a `"newLayer"` run's clone strokes sample: the visible document.
|
||||
pub fn visible_image(doc: &Document) -> Surface {
|
||||
crate::file_cmds::flattened(doc, doc.pixel_format())
|
||||
}
|
||||
|
||||
fn has_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
match d.active_layer.and_then(|id| d.doc.layer(id)).map(|l| &l.content) {
|
||||
@@ -84,8 +180,16 @@ fn has_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pastes `src` (content in `rect`) onto plane `plane` of `sc` over `surf`, as the command's
|
||||
/// `"paste"` does. Returns the area the paste covers.
|
||||
pub fn paste_onto(sc: &Scene, surf: &mut Surface, plane: usize, src: &Surface, rect: Rect, at: [f64; 2], width: f64) -> Result<Rect> {
|
||||
let Some(out) = paste(sc, plane, src, rect, at, width)? else { return Ok(Rect::EMPTY) };
|
||||
over(surf, &out);
|
||||
Ok(out.content_bounds())
|
||||
}
|
||||
|
||||
/// Composites `top` over `dst` (premultiplied source-over), same format.
|
||||
fn over(dst: &mut Surface, top: &Surface) {
|
||||
pub fn over(dst: &mut Surface, top: &Surface) {
|
||||
let b = top.content_bounds();
|
||||
if b.is_empty() {
|
||||
return;
|
||||
@@ -134,20 +238,31 @@ fn vanishing_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let width = item.get("width").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.01, 10.0);
|
||||
pastes.push((plane, surf, rect, at, width));
|
||||
}
|
||||
let clones: Vec<Value> = p.get("clone").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let clones = clone_specs(p)?;
|
||||
let new_layer = p.get("newLayer").and_then(Value::as_bool).unwrap_or(false);
|
||||
// The layer the mode was opened on (the UI passes it: other layers can be picked meanwhile).
|
||||
let layer = crate::commands::explicit_layer(s, p)?;
|
||||
let mut dabs = 0usize;
|
||||
let target = s.edit("Vanishing Point", |doc: &mut Document, active| {
|
||||
if let Some(l) = layer
|
||||
&& doc.layer(l).is_none()
|
||||
{
|
||||
return Err(EngineError::NoLayer(l));
|
||||
}
|
||||
let on = layer.or(*active);
|
||||
let id = if new_layer {
|
||||
let l = Layer::raster(doc.next_layer_name("Vanishing Point"), doc.pixel_format());
|
||||
let nid = doc.insert_above(*active, l);
|
||||
let nid = doc.insert_above(on, l);
|
||||
*active = Some(nid);
|
||||
nid
|
||||
} else {
|
||||
active.ok_or_else(|| EngineError::Other("no active layer".into()))?
|
||||
// The painted layer becomes active, so Fade and a colour-channel target follow it.
|
||||
let id = on.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
*active = Some(id);
|
||||
id
|
||||
};
|
||||
// A new layer clones from the visible image; otherwise from the layer itself.
|
||||
let base: Option<Surface> = new_layer.then(|| crate::file_cmds::flattened(doc, doc.pixel_format()));
|
||||
let base: Option<Surface> = new_layer.then(|| visible_image(doc));
|
||||
let locks = doc.effective_locks(id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if locks.all || locks.pixels {
|
||||
@@ -155,40 +270,16 @@ fn vanishing_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Vanishing Point needs a pixel layer".into()))?;
|
||||
for (plane, src, rect, at, width) in &pastes {
|
||||
if let Some(out) = paste(&sc, *plane, src, *rect, *at, *width)? {
|
||||
over(surf, &out);
|
||||
}
|
||||
paste_onto(&sc, surf, *plane, src, *rect, *at, *width)?;
|
||||
}
|
||||
for (k, c) in clones.iter().enumerate() {
|
||||
let source = c.get("source").and_then(pt).ok_or_else(|| bad(format!("clone {k}: `source` [x,y]")))?;
|
||||
let points: Vec<[f64; 2]> = c.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(pt).collect()).unwrap_or_default();
|
||||
let size = c.get("size").and_then(Value::as_f64).unwrap_or(40.0).clamp(1.0, 2000.0);
|
||||
let hardness = c.get("hardness").and_then(Value::as_f64).unwrap_or(50.0).clamp(0.0, 100.0) / 100.0;
|
||||
let opacity = (c.get("opacity").and_then(Value::as_f64).unwrap_or(100.0).clamp(0.0, 100.0) / 100.0) as f32;
|
||||
// Sample from the original pixels (merged with what's been pasted).
|
||||
let mut work = match &base {
|
||||
Some(b) => {
|
||||
let mut w = b.clone();
|
||||
over(&mut w, surf);
|
||||
w
|
||||
}
|
||||
None => surf.clone(),
|
||||
};
|
||||
let before = work.clone();
|
||||
dabs += clone_stroke(&sc, &mut work, source, &points, size, hardness, opacity);
|
||||
// Copy back only what the stroke changed.
|
||||
let b = work.content_bounds().union(&before.content_bounds());
|
||||
if !b.is_empty() {
|
||||
let (nw, ow) = (work.read_region(b), before.read_region(b));
|
||||
let n = surf.format().channels();
|
||||
let mut cur = surf.read_region(b);
|
||||
for i in 0..cur.len() / n {
|
||||
if nw[i * n..(i + 1) * n] != ow[i * n..(i + 1) * n] {
|
||||
cur[i * n..(i + 1) * n].copy_from_slice(&nw[i * n..(i + 1) * n]);
|
||||
}
|
||||
}
|
||||
surf.write_region(b, &cur);
|
||||
}
|
||||
// A new layer samples the visible image (merged with what's been pasted) and takes only
|
||||
// the pixels the strokes change; otherwise the strokes paint the layer itself.
|
||||
let mut work = base.map(|mut w| {
|
||||
over(&mut w, surf);
|
||||
w
|
||||
});
|
||||
for c in &clones {
|
||||
dabs += clone_into(&sc, surf, work.as_mut(), c, canvas);
|
||||
}
|
||||
surf.prune();
|
||||
Ok(id)
|
||||
@@ -211,7 +302,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Vanishing Point…",
|
||||
menu: &["Filter"],
|
||||
shortcut: Some("Cmd+Alt+V"),
|
||||
params: r##"{"planes":[{"corners":[[x,y]×4]} | {"from":plane,"edge":"top|right|bottom|left","angle":deg=90,"depth":1}],"focalLength":px=0,"paste":[{"plane":0,"layer":id? (else the clipboard),"at":[u,v]=[0.25,0.25],"width":0..1=0.5}],"clone":[{"source":[x,y],"points":[[x,y]],"size":px=40,"hardness":0..100=50,"opacity":0..100=100}],"newLayer":bool=false}"##,
|
||||
params: r##"{"planes":[{"corners":[[x,y]×4]} | {"from":plane,"edge":"top|right|bottom|left","angle":deg=90,"depth":1}],"focalLength":px=0,"paste":[{"plane":0,"layer":id? (else the clipboard),"at":[u,v]=[0.25,0.25],"width":0..1=0.5}],"clone":[{"source":[x,y],"points":[[x,y]],"size":px=40,"hardness":0..100=50,"opacity":0..100=100}],"newLayer":bool=false,"layer":id|name? (else the active layer)}"##,
|
||||
enabled: has_layer,
|
||||
run: vanishing_point,
|
||||
journal: true,
|
||||
@@ -282,4 +373,123 @@ mod tests {
|
||||
}
|
||||
assert!(s.execute(VP, json!({"planes": [{"corners": square}]})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_hostile_clone_and_tear_off_params() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 200, "depth": 32})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let floor = json!({"corners": [[60, 60], [140, 60], [190, 190], [10, 190]]});
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
for torn in [json!({"from": 0, "angle": 1e9}), json!({"from": 0, "depth": -1}), json!({"from": 0, "depth": 1e300})] {
|
||||
let e = s.execute(VP, json!({"planes": [floor, torn]})).unwrap_err().to_string();
|
||||
assert!(e.contains("plane 1"), "{e}");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
let many: Vec<[f64; 2]> = vec![[100.0, 150.0]; MAX_CLONE_DABS + 1];
|
||||
for clone in [
|
||||
json!([{"points": [[100, 150]]}]),
|
||||
json!([{"source": [100, 180], "points": [[100, 150], "x"]}]),
|
||||
json!([{"source": [100, 180], "points": [[100, 150]], "size": "big"}]),
|
||||
json!([{"source": [100, 180], "points": many}]),
|
||||
json!({"source": [100, 180]}),
|
||||
] {
|
||||
let label: String = clone.to_string().chars().take(80).collect();
|
||||
assert!(s.execute(VP, json!({"planes": [floor], "clone": clone, "newLayer": true})).is_err(), "{label}");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
let planes: Vec<Value> = std::iter::repeat_n(floor.clone(), MAX_PLANES + 1).collect();
|
||||
assert!(s.execute(VP, json!({"planes": planes})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
// A clone on a torn-off wall, at 32 bits, sampling the floor.
|
||||
let r = s
|
||||
.execute(VP, json!({"planes": [floor, {"from": 0, "edge": "top", "angle": 90, "depth": 0.5}], "clone": [{"source": [100, 150], "points": [[100, 50], [104, 50]], "size": 8}]}))
|
||||
.unwrap();
|
||||
assert_eq!(r["planes"].as_array().unwrap().len(), 2);
|
||||
assert!(r["dabs"].as_u64().unwrap() >= 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layer_clone_stays_on_the_canvas() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#40c040"})).unwrap();
|
||||
// A red stripe where the first dab samples, so cloning it changes pixels.
|
||||
s.edit("stripe", |doc, active| {
|
||||
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(140, 80, 160, 120), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let planes = json!([{"corners": [[0, 0], [200, 0], [200, 200], [0, 200]]}]);
|
||||
// The second dab lies left of the canvas and samples the canvas 50 px to its right; the
|
||||
// third is far off it (its box joined with the first's once asked for petabytes).
|
||||
for points in [json!([[100, 100], [-30, 100]]), json!([[100, 100], [-30, 100], [9_000_000, 9_000_000]])] {
|
||||
let r = s.execute(VP, json!({"planes": planes, "newLayer": true, "clone": [{"source": [150, 100], "points": points, "size": 20}]})).unwrap();
|
||||
let l = s.active().unwrap().doc.layer(LayerId(r["layer"].as_u64().unwrap())).unwrap().surface().unwrap();
|
||||
let b = l.content_bounds();
|
||||
assert!(!b.is_empty() && Rect::new(0, 0, 200, 200).contains_rect(&b), "{b:?}");
|
||||
assert!(l.rgba(100, 100)[0] > 0.9, "{:?}", l.rgba(100, 100));
|
||||
s.undo();
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a new layer the strokes paint the layer itself, and only inside the canvas, as in
|
||||
/// Photoshop: a dab off it painted pixels there (and allocated tiles for every dab, up to
|
||||
/// 1e7 px away).
|
||||
#[test]
|
||||
fn layer_clone_stays_on_the_canvas() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#40c040"})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let planes = json!([{"corners": [[0, 0], [200, 0], [200, 200], [0, 200]]}]);
|
||||
// The second dab lies left of the canvas and samples the canvas 50 px to its right; the
|
||||
// third is far off it.
|
||||
let points = json!([[100, 100], [-30, 100], [9_000_000, 9_000_000]]);
|
||||
let r = s.execute(VP, json!({"planes": planes, "clone": [{"source": [150, 100], "points": points, "size": 20}]})).unwrap();
|
||||
assert_eq!(r["dabs"].as_u64(), Some(1), "{r}");
|
||||
let b = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds();
|
||||
assert!(Rect::new(0, 0, 200, 200).contains_rect(&b), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_param_paints_that_layer_not_the_active_one() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "floor"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#40c040"})).unwrap();
|
||||
s.edit("stripe", |doc, active| {
|
||||
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(140, 80, 160, 120), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let floor = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "other"})).unwrap();
|
||||
let other = s.active().unwrap().active_layer.unwrap();
|
||||
let planes = json!([{"corners": [[0, 0], [200, 0], [200, 200], [0, 200]]}]);
|
||||
let clone = json!([{"source": [150, 100], "points": [[100, 100]], "size": 20}]);
|
||||
let surf = |s: &Session, id| s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
let r = s.execute(VP, json!({"planes": planes, "clone": clone, "layer": floor.0})).unwrap();
|
||||
assert_eq!(r["layer"].as_u64(), Some(floor.0));
|
||||
assert!(surf(&s, floor).rgba(100, 100)[0] > 0.9, "{:?}", surf(&s, floor).rgba(100, 100));
|
||||
assert!(surf(&s, other).content_bounds().is_empty(), "the active layer stays untouched");
|
||||
s.undo();
|
||||
// A new layer goes right above the named layer.
|
||||
let r = s.execute(VP, json!({"planes": planes, "clone": clone, "layer": floor.0, "newLayer": true})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
let ids: Vec<u64> = d.layers.iter().map(|l| l.id.0).collect();
|
||||
let at = |id: u64| ids.iter().position(|&i| i == id).unwrap();
|
||||
assert_eq!(at(r["layer"].as_u64().unwrap()), at(floor.0) + 1, "{ids:?}");
|
||||
s.undo();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
assert!(s.execute(VP, json!({"planes": planes, "clone": clone, "layer": 999_999})).is_err());
|
||||
assert!(s.execute(VP, json!({"planes": planes, "clone": clone, "layer": 999_999, "newLayer": true})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,3 +157,62 @@ fn bad_params_and_unfillable_crops_fail_cleanly() {
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` with its left 16 columns red, then cropped to x = 16 keeping the cropped pixels: the red
|
||||
/// lies hidden past the canvas's left edge.
|
||||
fn hidden_red(background: &str) -> Session {
|
||||
let mut s = doc("rgb", 8, background);
|
||||
s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#000000"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 48})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "foreground"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("image.crop", json!({"x": 16, "y": 0, "width": 48, "height": 48, "deleteCroppedPixels": false})).unwrap();
|
||||
assert_eq!(size(&s), (48, 48));
|
||||
s
|
||||
}
|
||||
|
||||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
|
||||
/// A straight frame pulled back out over pixels a Delete Cropped Pixels off crop hid fills over
|
||||
/// them from the canvas, as Photoshop does with Delete Cropped Pixels off or on (#8060).
|
||||
#[test]
|
||||
fn extending_crop_fills_over_the_hidden_pixels() {
|
||||
for background in ["transparent", "white"] {
|
||||
for delete in [false, true] {
|
||||
let mut s = hidden_red(background);
|
||||
let green = layer(&s).rgba(24, 24);
|
||||
crop(&mut s, json!({"x": -24, "y": 0, "width": 72, "height": 48, "deleteCroppedPixels": delete}), true).unwrap();
|
||||
assert_eq!(size(&s), (72, 48));
|
||||
let surf = layer(&s);
|
||||
for y in 0..48 {
|
||||
for x in 0..24 {
|
||||
let px = surf.rgba(x, y);
|
||||
assert!(close(px, green, 0.06), "{background} delete {delete}: ({x}, {y}) is filled from the canvas, not the hidden red: {px:?}");
|
||||
}
|
||||
assert!(close(surf.rgba(40, y), green, 1e-6), "{background} delete {delete}: the canvas is unchanged");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A turned frame that reaches over the Background's hidden pixels shows them, not a fill
|
||||
/// from the canvas (#8060).
|
||||
#[test]
|
||||
fn rotated_crop_keeps_the_hidden_pixels() {
|
||||
let mut s = hidden_red("white");
|
||||
// A diamond on the canvas's left edge: its left half lies over the hidden red, all of it inside
|
||||
// the turned canvas (so nothing takes the background colour).
|
||||
crop(&mut s, json!({"x": -10, "y": 14, "width": 20, "height": 20, "angle": 45, "deleteCroppedPixels": false}), true).unwrap();
|
||||
assert_eq!(size(&s), (20, 20));
|
||||
let (w, h) = size(&s);
|
||||
let surf = layer(&s);
|
||||
let mut red = 0;
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
let px = surf.rgba(x, y);
|
||||
assert!(px[0] > 0.5 && px[3] > 0.99, "no background colour or transparency at ({x}, {y}): {px:?}");
|
||||
red += usize::from(close(px, RED, 0.06));
|
||||
}
|
||||
}
|
||||
assert!(red >= 100, "the hidden red shows in the turned frame: {red} red pixels");
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ photocraft-color = { workspace = true }
|
||||
photocraft-raster = { workspace = true }
|
||||
photocraft-doc = { workspace = true }
|
||||
photocraft-compose = { workspace = true }
|
||||
# Camera Raw's per-pixel stages (`camera_raw.rs`) take their constants from the CPU develop.
|
||||
photocraft-algo = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
# Same major version as eframe's wgpu so the app's device can be shared.
|
||||
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Camera Raw's preview develop on the 858×572 proxy of a 6000×4000 image: the CPU develop
|
||||
//! against the GPU pass (upload, render, readback), for per-pixel settings.
|
||||
//! `cargo run --release -p photocraft-gpu --example bench_camera_raw [-- --quick]`
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_algo::camera_raw::{CameraRaw, Wheel, develop, per_pixel, white_black_points};
|
||||
use photocraft_gpu::CameraRawDevelop;
|
||||
|
||||
fn median(mut v: Vec<f64>) -> f64 {
|
||||
v.sort_by(f64::total_cmp);
|
||||
v[v.len() / 2]
|
||||
}
|
||||
|
||||
fn time(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
f();
|
||||
median(
|
||||
(0..runs)
|
||||
.map(|_| {
|
||||
let t = Instant::now();
|
||||
f();
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// [`time`] for a call that can fail: its first error ends the benchmark instead of being timed.
|
||||
fn try_time<E>(runs: usize, mut f: impl FnMut() -> Result<(), E>) -> Result<f64, E> {
|
||||
let mut failed = None;
|
||||
let ms = time(runs, || {
|
||||
if failed.is_none() {
|
||||
failed = f().err();
|
||||
}
|
||||
});
|
||||
failed.map_or(Ok(ms), Err)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runs = if std::env::args().any(|a| a == "--quick") { 5 } else { 40 };
|
||||
let (w, h) = (858usize, 572usize);
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
|
||||
let options = wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() };
|
||||
let adapter = pollster::block_on(instance.request_adapter(&options))?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
|
||||
let info = adapter.get_info();
|
||||
println!("adapter: {} ({:?}), proxy {w}x{h}, median of {runs}", info.name, info.backend);
|
||||
let built = Instant::now();
|
||||
let mut pass = CameraRawDevelop::new(&device)?;
|
||||
println!("pipeline build: {:.1} ms", built.elapsed().as_secs_f64() * 1e3);
|
||||
let src: Vec<[f32; 4]> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32 / w as f32, (i / w) as f32 / h as f32);
|
||||
[0.1 + 0.8 * x, 0.2 + 0.6 * y, 0.6 - 0.5 * x * y, 1.0]
|
||||
})
|
||||
.collect();
|
||||
let scale = w as f32 / 6000.0;
|
||||
let mut hsl = [0.0; 8];
|
||||
hsl[1] = 30.0;
|
||||
hsl[5] = -40.0;
|
||||
let sets = [
|
||||
(
|
||||
"Basic (WB, Exposure, Contrast, Whites, Blacks)",
|
||||
CameraRaw { temperature: 20.0, exposure: 0.4, contrast: 25.0, whites: 30.0, blacks: -20.0, pixel_scale: scale, ..Default::default() },
|
||||
),
|
||||
(
|
||||
"every per-pixel stage",
|
||||
CameraRaw {
|
||||
temperature: 20.0,
|
||||
tint: 5.0,
|
||||
exposure: 0.4,
|
||||
contrast: 25.0,
|
||||
whites: 30.0,
|
||||
blacks: -20.0,
|
||||
vibrance: 30.0,
|
||||
saturation: 10.0,
|
||||
curve_lights: 20.0,
|
||||
point_curve: vec![[0.0, 8.0], [128.0, 140.0], [255.0, 250.0]],
|
||||
hsl_hue: hsl,
|
||||
hsl_sat: hsl,
|
||||
hsl_lum: hsl,
|
||||
grade_shadows: Wheel { hue: 210.0, sat: 30.0, lum: 0.0 },
|
||||
grade_highlights: Wheel { hue: 40.0, sat: 20.0, lum: 5.0 },
|
||||
vignette_amount: -30.0,
|
||||
grain_amount: 25.0,
|
||||
pixel_scale: scale,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
];
|
||||
for (name, p) in &sets {
|
||||
let cpu = time(runs, || {
|
||||
let mut px = src.clone();
|
||||
develop(&mut px, w, h, p, false);
|
||||
});
|
||||
let points_ms = time(runs, || {
|
||||
std::hint::black_box(white_black_points(&src, w, h, p));
|
||||
});
|
||||
let points = white_black_points(&src, w, h, p);
|
||||
let stages = per_pixel(p, w, h, false, points).ok_or("not per pixel")?;
|
||||
let size = [w as u32, h as u32];
|
||||
let upload = try_time(runs, || pass.develop(&device, &queue, None, &src, None, size, &stages).map(drop))?;
|
||||
let cached = try_time(runs, || pass.develop(&device, &queue, None, &src, Some(1), size, &stages).map(drop))?;
|
||||
println!("{name}:");
|
||||
println!(" CPU develop {cpu:7.2} ms");
|
||||
println!(" GPU, source uploaded every time {upload:7.2} ms");
|
||||
println!(" GPU, source cached (slider drag) {cached:7.2} ms");
|
||||
println!(" Whites/Blacks points on the CPU {points_ms:7.2} ms (cached per WB/Exposure/Contrast in the dialog)");
|
||||
}
|
||||
let shadows = CameraRaw { shadows: 50.0, pixel_scale: scale, ..Default::default() };
|
||||
let cpu = time(runs.min(10), || {
|
||||
let mut px = src.clone();
|
||||
develop(&mut px, w, h, &shadows, false);
|
||||
});
|
||||
println!("for reference, Shadows +50 (CPU only): {cpu:.2} ms");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
//! Camera Raw's per-pixel develop on the GPU, for the dialog's interactive preview (#6916).
|
||||
//!
|
||||
//! One fragment pass (`camera_raw.wgsl`) runs the stages that read only their own pixel: white
|
||||
//! balance and exposure, Contrast without Texture or Clarity, Whites and Blacks, Vibrance and
|
||||
//! Saturation, the tone curves, the Color Mixer, Color Grading, the vignette and grain. Their
|
||||
//! constants and tables come from [`photocraft_algo::camera_raw::per_pixel`], which the CPU
|
||||
//! develop builds its own from, so both evaluate the same maths; the curves and the Whites and
|
||||
//! Blacks rows are rows of one `R32Float` texture. Settings with a spatial stage (Dehaze, Shadows
|
||||
//! and Highlights, Texture, Clarity, Detail) have no [`PerPixel`] and stay on the CPU.
|
||||
//!
|
||||
//! The pipeline is built on demand ([`CameraRawDevelop::new`]), not with the compositor (#6419).
|
||||
//! It renders into `Rgba32Float`; [`CameraRawDevelop::supported`] says whether the adapter can.
|
||||
|
||||
use photocraft_algo::camera_raw::{CURVE_LUT, PerPixel, VignetteStyle, WB_STEPS};
|
||||
|
||||
use crate::Unsupported;
|
||||
|
||||
/// The pass's WGSL source.
|
||||
pub const CAMERA_RAW_SHADER: &str = include_str!("camera_raw.wgsl");
|
||||
|
||||
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
/// `Params` in the shader: 19 rows of 16 bytes.
|
||||
const PARAMS_SIZE: u64 = 19 * 16;
|
||||
/// Rows of the table texture: parametric, master, red, green, blue curves; Whites, Blacks.
|
||||
const LUT_ROWS: u32 = 7;
|
||||
const LUT_WIDTH: u32 = CURVE_LUT as u32;
|
||||
const BYTES_PER_PIXEL: u32 = 16;
|
||||
|
||||
struct Image {
|
||||
key: Option<u64>,
|
||||
size: [u32; 2],
|
||||
texture: wgpu::Texture,
|
||||
view: wgpu::TextureView,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
fn new(device: &wgpu::Device, label: &str, size: [u32; 2], usage: wgpu::TextureUsages) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: FORMAT,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
Image { key: None, size, texture, view }
|
||||
}
|
||||
}
|
||||
|
||||
/// The Camera Raw preview pass and its textures (reused while the size stays).
|
||||
pub struct CameraRawDevelop {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
params: wgpu::Buffer,
|
||||
luts: wgpu::Texture,
|
||||
luts_view: wgpu::TextureView,
|
||||
source: Option<Image>,
|
||||
target: Option<Image>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
readback: Option<wgpu::Buffer>,
|
||||
}
|
||||
|
||||
impl CameraRawDevelop {
|
||||
/// Whether `adapter` can render the pass's `Rgba32Float` output.
|
||||
pub fn supported(adapter: &wgpu::Adapter) -> bool {
|
||||
adapter.get_texture_format_features(FORMAT).allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
|
||||
}
|
||||
|
||||
/// Builds the pipeline. A shader or pipeline the driver can't build is an `Err`, so the
|
||||
/// caller keeps the CPU develop (where errors only arrive asynchronously, on WebGPU, creation
|
||||
/// is assumed to have worked).
|
||||
pub fn new(device: &wgpu::Device) -> Result<Self, Unsupported> {
|
||||
match crate::first_error(device, || Self::build(device)) {
|
||||
(dev, None) => Ok(dev),
|
||||
(_, Some(e)) => Err(Unsupported(format!("couldn't build the Camera Raw pipeline: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn build(device: &wgpu::Device) -> Self {
|
||||
let module = device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_camera_raw"), source: wgpu::ShaderSource::Wgsl(CAMERA_RAW_SHADER.into()) });
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("pc_camera_raw"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(PARAMS_SIZE),
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
crate::tex_entry(1),
|
||||
crate::tex_entry(2),
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("pc_camera_raw"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("pc_camera_raw"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() },
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
targets: &[Some(wgpu::ColorTargetState { format: FORMAT, blend: None, write_mask: wgpu::ColorWrites::ALL })],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("pc_camera_raw_params"),
|
||||
size: PARAMS_SIZE,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let luts = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("pc_camera_raw_luts"),
|
||||
size: wgpu::Extent3d { width: LUT_WIDTH, height: LUT_ROWS, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let luts_view = luts.create_view(&Default::default());
|
||||
CameraRawDevelop {
|
||||
pipeline,
|
||||
layout,
|
||||
params,
|
||||
luts,
|
||||
luts_view,
|
||||
source: None,
|
||||
target: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
readback: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the develop of `source` (`w × h` straight RGBA, display-encoded) with `stages`
|
||||
/// into `encoder` and returns the `Rgba32Float` texture it renders. The source is uploaded
|
||||
/// unless the last one had the same `key` and size (`None` always uploads).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn encode(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
source: &[[f32; 4]],
|
||||
key: Option<u64>,
|
||||
size: [u32; 2],
|
||||
stages: &PerPixel,
|
||||
) -> Result<&wgpu::Texture, Unsupported> {
|
||||
check_size(device, source, size)?;
|
||||
let [w, h] = size;
|
||||
let usage = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST;
|
||||
let src = match self.source.take() {
|
||||
Some(s) if s.size == size => s,
|
||||
_ => Image::new(device, "pc_camera_raw_source", size, usage),
|
||||
};
|
||||
let src = self.source.insert(src);
|
||||
if key.is_none() || src.key != key {
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
bytemuck::cast_slice(source),
|
||||
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * BYTES_PER_PIXEL), rows_per_image: Some(h) },
|
||||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
src.key = key;
|
||||
}
|
||||
queue.write_buffer(&self.params, 0, ¶ms(size, stages));
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo { texture: &self.luts, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
bytemuck::cast_slice(&tables(stages)),
|
||||
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(LUT_WIDTH * 4), rows_per_image: Some(LUT_ROWS) },
|
||||
wgpu::Extent3d { width: LUT_WIDTH, height: LUT_ROWS, depth_or_array_layers: 1 },
|
||||
);
|
||||
let target = match self.target.take() {
|
||||
Some(t) if t.size == size => t,
|
||||
_ => Image::new(device, "pc_camera_raw_target", size, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC),
|
||||
};
|
||||
let target = self.target.insert(target);
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("pc_camera_raw"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: self.params.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&src.view) },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.luts_view) },
|
||||
],
|
||||
});
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("pc_camera_raw"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target.view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store },
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
Ok(&target.texture)
|
||||
}
|
||||
|
||||
/// Develops `source` and reads the result back (blocking; native only, the web has no
|
||||
/// blocking wait). GPU errors and a lost device are `Err`; `health`, when the device is
|
||||
/// watched, records a loss.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn develop(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
health: Option<&crate::DeviceHealth>,
|
||||
source: &[[f32; 4]],
|
||||
key: Option<u64>,
|
||||
size: [u32; 2],
|
||||
stages: &PerPixel,
|
||||
) -> Result<Vec<[f32; 4]>, Unsupported> {
|
||||
if let Some(f) = health.and_then(crate::DeviceHealth::fault) {
|
||||
return Err(Unsupported(f.to_string()));
|
||||
}
|
||||
let (result, error) = crate::first_error(device, || self.develop_inner(device, queue, health, source, key, size, stages));
|
||||
match error {
|
||||
Some(e) => {
|
||||
// A half-done upload must not be taken for the cached source next time.
|
||||
self.source = None;
|
||||
Err(Unsupported(format!("GPU Camera Raw develop failed: {e}")))
|
||||
}
|
||||
None => result,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn develop_inner(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
health: Option<&crate::DeviceHealth>,
|
||||
source: &[[f32; 4]],
|
||||
key: Option<u64>,
|
||||
size: [u32; 2],
|
||||
stages: &PerPixel,
|
||||
) -> Result<Vec<[f32; 4]>, Unsupported> {
|
||||
check_size(device, source, size)?;
|
||||
let [w, h] = size;
|
||||
let row = (w * BYTES_PER_PIXEL).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let bytes = u64::from(row) * u64::from(h);
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_camera_raw") });
|
||||
self.encode(device, queue, &mut encoder, source, key, size, stages)?;
|
||||
let Some(target) = self.target.as_ref().map(|t| &t.texture) else { return Err(Unsupported("no Camera Raw target".into())) };
|
||||
let buffer = match self.readback.take() {
|
||||
Some(b) if b.size() == bytes => b,
|
||||
_ => device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("pc_camera_raw_readback"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
};
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo { texture: target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::TexelCopyBufferInfo { buffer: &buffer, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(h) } },
|
||||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
let index = queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
match health {
|
||||
Some(hl) => {
|
||||
if !hl.wait(device, Some(index)) {
|
||||
return Err(Unsupported(hl.fault().map_or_else(|| "GPU device lost".into(), |f| f.to_string())));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
device.poll(wgpu::PollType::Wait { submission_index: Some(index), timeout: None }).map_err(|e| Unsupported(format!("GPU wait failed: {e}")))?;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(source.len());
|
||||
{
|
||||
let data = buffer.slice(..).get_mapped_range().map_err(|e| Unsupported(format!("GPU readback failed: {e:?}")))?;
|
||||
let width = w as usize * BYTES_PER_PIXEL as usize;
|
||||
for y in 0..h as usize {
|
||||
let start = y * row as usize;
|
||||
let line = data.get(start..start + width).ok_or_else(|| Unsupported("short GPU readback".into()))?;
|
||||
out.extend(bytemuck::pod_collect_to_vec::<u8, [f32; 4]>(line));
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
self.readback = Some(buffer);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_size(device: &wgpu::Device, source: &[[f32; 4]], [w, h]: [u32; 2]) -> Result<(), Unsupported> {
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
if w == 0 || h == 0 || w > max || h > max {
|
||||
return Err(Unsupported(format!("a {w}×{h} Camera Raw preview doesn't fit the GPU's {max} px textures")));
|
||||
}
|
||||
if source.len() as u64 != u64::from(w) * u64::from(h) {
|
||||
return Err(Unsupported(format!("{} Camera Raw preview pixels for {w}×{h}", source.len())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flags of `Params.flags`, in the shader's bit order.
|
||||
fn flags(s: &PerPixel) -> u32 {
|
||||
let wb = s.whites_blacks.as_ref();
|
||||
let on = [
|
||||
s.gains.is_some(),
|
||||
s.contrast.is_some(),
|
||||
wb.is_some_and(|w| w.whites.is_some()),
|
||||
wb.is_some_and(|w| w.blacks.is_some()),
|
||||
wb.is_some_and(|w| w.per_channel),
|
||||
s.presence.is_some(),
|
||||
s.parametric.is_some(),
|
||||
s.point[0].is_some(),
|
||||
s.point[1].is_some(),
|
||||
s.point[2].is_some(),
|
||||
s.point[3].is_some(),
|
||||
s.hsl.is_some(),
|
||||
s.grade.is_some(),
|
||||
s.vignette.is_some(),
|
||||
s.grain.is_some(),
|
||||
s.clamp,
|
||||
];
|
||||
on.iter().enumerate().fold(0, |f, (bit, on)| if *on { f | 1 << bit } else { f })
|
||||
}
|
||||
|
||||
/// The shader's `Params` for a `size` image.
|
||||
fn params(size: [u32; 2], s: &PerPixel) -> Vec<u8> {
|
||||
let g = s.gains.unwrap_or([1.0; 3]);
|
||||
let presence = s.presence.unwrap_or_default();
|
||||
let hsl = s.hsl.unwrap_or_default();
|
||||
let grade = s.grade.as_ref();
|
||||
let vig = s.vignette.as_ref();
|
||||
let grain = s.grain.as_ref();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(PARAMS_SIZE as usize);
|
||||
out.extend([size[0], size[1], flags(s)].iter().flat_map(|v| v.to_le_bytes()).chain(s.encoding.to_le_bytes()));
|
||||
let mut f = |v: [f32; 4]| out.extend(v.iter().flat_map(|x| x.to_le_bytes()));
|
||||
f([g[0], g[1], g[2], s.contrast.unwrap_or(0.0)]);
|
||||
f([presence[0], presence[1], 0.0, 0.0]);
|
||||
for row in hsl {
|
||||
f([row[0], row[1], row[2], row[3]]);
|
||||
f([row[4], row[5], row[6], row[7]]);
|
||||
}
|
||||
for k in 0..4 {
|
||||
let (t, l) = grade.map_or(([0.0; 3], 0.0), |gr| (gr.tints[k], gr.lums[k]));
|
||||
f([t[0], t[1], t[2], l]);
|
||||
}
|
||||
f(grade.map_or([0.0; 4], |gr| [gr.blend, gr.balance, 0.0, 0.0]));
|
||||
let style = |v: VignetteStyle| match v {
|
||||
VignetteStyle::HighlightPriority => 0.0,
|
||||
VignetteStyle::ColorPriority => 1.0,
|
||||
VignetteStyle::PaintOverlay => 2.0,
|
||||
};
|
||||
f(vig.map_or([0.0, 0.0, 0.0, 2.0], |v| [v.amount, v.highlights, style(v.style), v.power]));
|
||||
f(vig.map_or([0.0, 0.0, 1.0, 1.0], |v| [v.centre[0], v.centre[1], v.axes[0], v.axes[1]]));
|
||||
f(vig.map_or([0.0; 4], |v| [v.edges[0], v.edges[1], 0.0, 0.0]));
|
||||
f(grain.map_or([1.0, 0.0, 0.0, 1.0], |gr| [gr.cell, gr.roughness, gr.amount, gr.scale]));
|
||||
let seed = grain.map_or(0, |gr| gr.seed);
|
||||
let fine = seed.wrapping_add(7);
|
||||
out.extend([seed as u32, (seed >> 32) as u32, fine as u32, (fine >> 32) as u32].iter().flat_map(|v| v.to_le_bytes()));
|
||||
out
|
||||
}
|
||||
|
||||
/// The table texture's rows (see [`LUT_ROWS`]); rows of stages that are off stay zero.
|
||||
fn tables(s: &PerPixel) -> Vec<f32> {
|
||||
let mut t = vec![0.0f32; (LUT_WIDTH * LUT_ROWS) as usize];
|
||||
let mut put = |row: usize, v: &[f32]| {
|
||||
let start = row * LUT_WIDTH as usize;
|
||||
let n = v.len().min(LUT_WIDTH as usize);
|
||||
if let (Some(dst), Some(src)) = (t.get_mut(start..start + n), v.get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
};
|
||||
if let Some(l) = &s.parametric {
|
||||
put(0, l);
|
||||
}
|
||||
for (k, l) in s.point.iter().enumerate() {
|
||||
if let Some(l) = l {
|
||||
put(1 + k, l);
|
||||
}
|
||||
}
|
||||
if let Some(wb) = &s.whites_blacks {
|
||||
if let Some(r) = &wb.whites {
|
||||
put(5, r);
|
||||
}
|
||||
if let Some(r) = &wb.blacks {
|
||||
put(6, r);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
// The Whites and Blacks rows fit a table row.
|
||||
const _: () = assert!(WB_STEPS <= CURVE_LUT);
|
||||
@@ -0,0 +1,416 @@
|
||||
// Camera Raw's per-pixel develop stages (`photocraft_algo::camera_raw::develop`, constants from
|
||||
// `camera_raw::per_pixel`), one fragment per pixel. Each function mirrors the CPU code it is
|
||||
// named after, in the same order of operations; `camera_raw.rs` holds the bindings.
|
||||
|
||||
struct Params {
|
||||
size: vec2<u32>,
|
||||
flags: u32,
|
||||
// The document's transfer curve: 0 for sRGB's, else a power-law gamma.
|
||||
encoding: f32,
|
||||
// White balance gains (rgb), Contrast's strength (w).
|
||||
gains: vec4<f32>,
|
||||
// Vibrance, Saturation.
|
||||
presence: vec4<f32>,
|
||||
// Color Mixer bands 0-3 and 4-7: hue, saturation, luminance.
|
||||
hsl: array<vec4<f32>, 6>,
|
||||
// Color Grading wheels: tint (rgb), luminance (w).
|
||||
tints: array<vec4<f32>, 4>,
|
||||
// Blending, Balance.
|
||||
grade: vec4<f32>,
|
||||
// [amount, highlights, style, power], [centre, axes], [edges].
|
||||
vignette: array<vec4<f32>, 3>,
|
||||
// Cell, roughness, amount, full-resolution pixels per pixel.
|
||||
grain: vec4<f32>,
|
||||
// The two octaves' seeds as (low, high) words.
|
||||
seeds: vec4<u32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> P: Params;
|
||||
@group(0) @binding(1) var src: texture_2d<f32>;
|
||||
// R32Float rows of `LUT_WIDTH`: parametric, master, red, green, blue curves; Whites, Blacks rows.
|
||||
@group(0) @binding(2) var luts: texture_2d<f32>;
|
||||
|
||||
const GAINS: u32 = 0u;
|
||||
const CONTRAST: u32 = 1u;
|
||||
const WHITES: u32 = 2u;
|
||||
const BLACKS: u32 = 3u;
|
||||
const PER_CHANNEL: u32 = 4u;
|
||||
const PRESENCE: u32 = 5u;
|
||||
const PARAMETRIC: u32 = 6u;
|
||||
const MASTER: u32 = 7u;
|
||||
const RED: u32 = 8u;
|
||||
const HSL: u32 = 11u;
|
||||
const GRADE: u32 = 12u;
|
||||
const VIGNETTE: u32 = 13u;
|
||||
const GRAIN: u32 = 14u;
|
||||
const CLAMP: u32 = 15u;
|
||||
|
||||
const CURVE_LUT: i32 = 1024;
|
||||
const WB_STEPS: i32 = 33;
|
||||
const TAU: f32 = 6.2831855;
|
||||
|
||||
struct VOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) vi: u32) -> VOut {
|
||||
let uv = vec2(f32((vi << 1u) & 2u), f32(vi & 2u));
|
||||
return VOut(vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0));
|
||||
}
|
||||
|
||||
fn on(flag: u32) -> bool {
|
||||
return (P.flags & (1u << flag)) != 0u;
|
||||
}
|
||||
|
||||
fn luma(c: vec3<f32>) -> f32 {
|
||||
return 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z;
|
||||
}
|
||||
|
||||
fn smoothstep_(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0);
|
||||
return t * t * (3.0 - 2.0 * t);
|
||||
}
|
||||
|
||||
fn rem_euclid(x: f32, m: f32) -> f32 {
|
||||
let r = x % m;
|
||||
return select(r, r + m, r < 0.0);
|
||||
}
|
||||
|
||||
fn srgb_to_linear(v: f32) -> f32 {
|
||||
if v <= 0.04045 {
|
||||
return v / 12.92;
|
||||
}
|
||||
return pow((v + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
fn linear_to_srgb(v: f32) -> f32 {
|
||||
if v <= 0.0031308 {
|
||||
return v * 12.92;
|
||||
}
|
||||
return 1.055 * pow(v, 1.0 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
fn decode(v: f32) -> f32 {
|
||||
if P.encoding > 0.0 {
|
||||
return pow(max(v, 0.0), P.encoding);
|
||||
}
|
||||
return srgb_to_linear(v);
|
||||
}
|
||||
|
||||
fn encode(v: f32) -> f32 {
|
||||
if P.encoding > 0.0 {
|
||||
return pow(max(v, 0.0), 1.0 / P.encoding);
|
||||
}
|
||||
return linear_to_srgb(v);
|
||||
}
|
||||
|
||||
fn lut_at(row: i32, i: i32) -> f32 {
|
||||
return textureLoad(luts, vec2(i, row), 0).r;
|
||||
}
|
||||
|
||||
// `lut_eval`: linear inside 0..1, extended linearly outside (float documents).
|
||||
fn lut_eval(row: i32, v: f32) -> f32 {
|
||||
if v < 0.0 {
|
||||
return v + lut_at(row, 0);
|
||||
}
|
||||
if v > 1.0 {
|
||||
return lut_at(row, CURVE_LUT - 1) + (v - 1.0);
|
||||
}
|
||||
let x = v * f32(CURVE_LUT - 1);
|
||||
let i = min(i32(x), CURVE_LUT - 2);
|
||||
let t = x - f32(i);
|
||||
let a = lut_at(row, i);
|
||||
return a + (lut_at(row, i + 1) - a) * t;
|
||||
}
|
||||
|
||||
// `whites_blacks::curve` on one row.
|
||||
fn wb_curve(row: i32, v: f32) -> f32 {
|
||||
if v >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let p = max(v, 0.0) * f32(WB_STEPS - 1);
|
||||
let j = min(i32(p), WB_STEPS - 2);
|
||||
let s = p - f32(j);
|
||||
let a = lut_at(row, j);
|
||||
return a + (lut_at(row, j + 1) - a) * s;
|
||||
}
|
||||
|
||||
fn grey(v: f32) -> f32 {
|
||||
var g = v;
|
||||
if on(WHITES) {
|
||||
g = wb_curve(5, g);
|
||||
}
|
||||
if on(BLACKS) {
|
||||
g = wb_curve(6, g);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
// `whites_blacks::apply`.
|
||||
fn whites_blacks(q: vec3<f32>) -> vec3<f32> {
|
||||
let lin = vec3(decode(q.x), decode(q.y), decode(q.z));
|
||||
var out = q;
|
||||
if on(PER_CHANNEL) {
|
||||
for (var c = 0; c < 3; c++) {
|
||||
let over = max(lin[c] - 1.0, 0.0);
|
||||
out[c] = encode(srgb_to_linear(grey(linear_to_srgb(min(lin[c], 1.0)))) + over);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let y = max(luma(lin), 0.0);
|
||||
if y <= 1e-7 {
|
||||
return q;
|
||||
}
|
||||
let y2 = srgb_to_linear(grey(linear_to_srgb(min(y, 1.0)))) + max(y - 1.0, 0.0);
|
||||
let k = y2 / y;
|
||||
for (var c = 0; c < 3; c++) {
|
||||
out[c] = encode(lin[c] * k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
fn rgb_to_hsv(c: vec3<f32>) -> vec3<f32> {
|
||||
let mx = max(max(c.x, c.y), c.z);
|
||||
let mn = min(min(c.x, c.y), c.z);
|
||||
let d = mx - mn;
|
||||
var h = 0.0;
|
||||
if d <= 1e-9 {
|
||||
h = 0.0;
|
||||
} else if mx == c.x {
|
||||
h = 60.0 * rem_euclid((c.y - c.z) / d, 6.0);
|
||||
} else if mx == c.y {
|
||||
h = 60.0 * ((c.z - c.x) / d + 2.0);
|
||||
} else {
|
||||
h = 60.0 * ((c.x - c.y) / d + 4.0);
|
||||
}
|
||||
return vec3(h, select(0.0, d / mx, mx > 1e-9), mx);
|
||||
}
|
||||
|
||||
fn hsv_to_rgb(hue: f32, s: f32, v: f32) -> vec3<f32> {
|
||||
let h = rem_euclid(hue, 360.0) / 60.0;
|
||||
let c = v * s;
|
||||
let x = c * (1.0 - abs((h % 2.0) - 1.0));
|
||||
var rgb: vec3<f32>;
|
||||
switch u32(h) {
|
||||
case 0u: { rgb = vec3(c, x, 0.0); }
|
||||
case 1u: { rgb = vec3(x, c, 0.0); }
|
||||
case 2u: { rgb = vec3(0.0, c, x); }
|
||||
case 3u: { rgb = vec3(0.0, x, c); }
|
||||
case 4u: { rgb = vec3(x, 0.0, c); }
|
||||
default: { rgb = vec3(c, 0.0, x); }
|
||||
}
|
||||
let m = v - c;
|
||||
return rgb + vec3(m);
|
||||
}
|
||||
|
||||
const HSL_BANDS = array<f32, 8>(0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 270.0, 300.0);
|
||||
|
||||
fn band(k: i32, row: i32) -> f32 {
|
||||
return P.hsl[row * 2 + k / 4][k % 4];
|
||||
}
|
||||
|
||||
// `band_weights` folded into the three sums.
|
||||
fn hsl_shift(h: f32) -> vec3<f32> {
|
||||
var bands = HSL_BANDS;
|
||||
var d = vec3(0.0);
|
||||
for (var k = 0; k < 8; k++) {
|
||||
let c = bands[k];
|
||||
let prev = bands[(k + 7) % 8];
|
||||
let next = bands[(k + 1) % 8];
|
||||
let diff = ((h - c + 540.0) % 360.0) - 180.0;
|
||||
let span = select(rem_euclid(c - prev, 360.0), rem_euclid(next - c, 360.0), diff >= 0.0);
|
||||
let w = max(1.0 - abs(diff) / max(span, 1e-3), 0.0);
|
||||
d += w * vec3(band(k, 0), band(k, 1), band(k, 2));
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// `photo_util::hash01`: 64-bit words as (low, high).
|
||||
fn mul_wide(a: u32, b: u32) -> vec2<u32> {
|
||||
let a0 = a & 0xffffu;
|
||||
let a1 = a >> 16u;
|
||||
let b0 = b & 0xffffu;
|
||||
let b1 = b >> 16u;
|
||||
let p00 = a0 * b0;
|
||||
let p01 = a0 * b1;
|
||||
let p10 = a1 * b0;
|
||||
let p11 = a1 * b1;
|
||||
let mid = (p00 >> 16u) + (p01 & 0xffffu) + (p10 & 0xffffu);
|
||||
return vec2((p00 & 0xffffu) | (mid << 16u), p11 + (p01 >> 16u) + (p10 >> 16u) + (mid >> 16u));
|
||||
}
|
||||
|
||||
fn mul64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
|
||||
let w = mul_wide(a.x, b.x);
|
||||
return vec2(w.x, w.y + a.x * b.y + a.y * b.x);
|
||||
}
|
||||
|
||||
fn shr64(z: vec2<u32>, n: u32) -> vec2<u32> {
|
||||
return vec2((z.x >> n) | (z.y << (32u - n)), z.y >> n);
|
||||
}
|
||||
|
||||
fn i64_(v: i32) -> vec2<u32> {
|
||||
return vec2(bitcast<u32>(v), select(0u, 0xffffffffu, v < 0));
|
||||
}
|
||||
|
||||
fn hash01(x: i32, y: i32, seed: vec2<u32>) -> f32 {
|
||||
var z = mul64(i64_(x), vec2(0x7f4a7c15u, 0x9e3779b9u)) ^ mul64(i64_(y), vec2(0x27d4eb4fu, 0xc2b2ae3du)) ^ mul64(seed, vec2(0x9e3779f9u, 0x165667b1u));
|
||||
z = mul64(z ^ shr64(z, 30u), vec2(0x1ce4e5b9u, 0xbf58476du));
|
||||
z = mul64(z ^ shr64(z, 27u), vec2(0x133111ebu, 0x94d049bbu));
|
||||
z = z ^ shr64(z, 31u);
|
||||
return f32(z.y >> 8u) / 16777216.0;
|
||||
}
|
||||
|
||||
fn value_noise(x: f32, y: f32, seed: vec2<u32>) -> f32 {
|
||||
let xi = floor(x);
|
||||
let yi = floor(y);
|
||||
let tx = x - xi;
|
||||
let ty = y - yi;
|
||||
let sx = tx * tx * (3.0 - 2.0 * tx);
|
||||
let sy = ty * ty * (3.0 - 2.0 * ty);
|
||||
let ix = i32(xi);
|
||||
let iy = i32(yi);
|
||||
let h00 = hash01(ix, iy, seed);
|
||||
let h10 = hash01(ix + 1, iy, seed);
|
||||
let h01 = hash01(ix, iy + 1, seed);
|
||||
let h11 = hash01(ix + 1, iy + 1, seed);
|
||||
let top = h00 + (h10 - h00) * sx;
|
||||
let bot = h01 + (h11 - h01) * sx;
|
||||
return top + (bot - top) * sy;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let p = vec2<i32>(floor(pos.xy));
|
||||
let q = textureLoad(src, p, 0);
|
||||
var c = q.rgb;
|
||||
// 1. White balance and exposure.
|
||||
if on(GAINS) {
|
||||
for (var i = 0; i < 3; i++) {
|
||||
c[i] = linear_to_srgb(max(srgb_to_linear(c[i]) * P.gains[i], 0.0));
|
||||
}
|
||||
}
|
||||
// 3. Contrast (without Texture and Clarity).
|
||||
if on(CONTRAST) {
|
||||
let l0 = max(luma(c), 0.0);
|
||||
let lc = min(l0, 1.0);
|
||||
var lv = lc;
|
||||
let xx = clamp(lv, 0.0, 1.0);
|
||||
lv += -P.gains.w * sin(TAU * xx) / TAU;
|
||||
lv = max(lv, 0.0) + (l0 - lc);
|
||||
if l0 > 1e-5 {
|
||||
c = c * (lv / l0);
|
||||
} else {
|
||||
c = c + vec3(lv - l0);
|
||||
}
|
||||
}
|
||||
// 3b. Whites and Blacks.
|
||||
if on(WHITES) || on(BLACKS) {
|
||||
c = whites_blacks(c);
|
||||
}
|
||||
// 4. Vibrance and Saturation.
|
||||
if on(PRESENCE) {
|
||||
let l = luma(c);
|
||||
let mx = max(max(c.x, c.y), c.z);
|
||||
let mn = min(min(c.x, c.y), c.z);
|
||||
let sat = select(0.0, (mx - mn) / mx, mx > 1e-6);
|
||||
let hue = rgb_to_hsv(c).x;
|
||||
let skin = clamp(1.0 - (abs(hue - 25.0) / 25.0), 0.0, 1.0) * 0.5;
|
||||
let k = 1.0 + P.presence.y + P.presence.x * (1.0 - sat) * (1.0 - skin);
|
||||
c = vec3(l) + (c - vec3(l)) * max(k, 0.0);
|
||||
}
|
||||
// 5. Tone curves.
|
||||
if on(PARAMETRIC) {
|
||||
c = vec3(lut_eval(0, c.x), lut_eval(0, c.y), lut_eval(0, c.z));
|
||||
}
|
||||
if on(MASTER) {
|
||||
c = vec3(lut_eval(1, c.x), lut_eval(1, c.y), lut_eval(1, c.z));
|
||||
}
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if on(RED + u32(i)) {
|
||||
c[i] = lut_eval(2 + i, c[i]);
|
||||
}
|
||||
}
|
||||
// 6. Color Mixer.
|
||||
if on(HSL) {
|
||||
let hsv = rgb_to_hsv(max(c, vec3(0.0)));
|
||||
let s = hsv.y;
|
||||
if s > 1e-4 {
|
||||
let d = hsl_shift(hsv.x);
|
||||
let nh = hsv.x + d.x / 100.0 * 30.0;
|
||||
let ns = clamp(s * (1.0 + d.y / 100.0), 0.0, 1.0);
|
||||
var out = hsv_to_rgb(nh, ns, hsv.z);
|
||||
let l0 = luma(c);
|
||||
let l1 = luma(out);
|
||||
if l1 > 1e-6 {
|
||||
out = out * l0 / l1;
|
||||
}
|
||||
let k = exp2(d.z / 100.0 * 1.0 * s);
|
||||
c = out * k;
|
||||
}
|
||||
}
|
||||
// 7. Color Grading.
|
||||
if on(GRADE) {
|
||||
let l = clamp(luma(c), 0.0, 1.0);
|
||||
let blend = P.grade.x;
|
||||
let bal = P.grade.y;
|
||||
let sh = 1.0 - smoothstep_(0.0, 0.5 + bal + blend, l);
|
||||
let hi = smoothstep_(0.5 + bal - blend, 1.0, l);
|
||||
let mid = max(1.0 - sh - hi, 0.0);
|
||||
let masks = vec4(sh, mid, hi, 1.0);
|
||||
for (var k = 0; k < 4; k++) {
|
||||
c += masks[k] * P.tints[k].xyz;
|
||||
if P.tints[k].w != 0.0 {
|
||||
c = c * exp2(masks[k] * P.tints[k].w / 100.0 * 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 9. Vignette.
|
||||
if on(VIGNETTE) {
|
||||
let amt = P.vignette[0].x;
|
||||
let hl = P.vignette[0].y;
|
||||
let style = u32(P.vignette[0].z);
|
||||
let pw = P.vignette[0].w;
|
||||
let centre = P.vignette[1].xy;
|
||||
let axes = P.vignette[1].zw;
|
||||
let dx = abs((f32(p.x) + 0.5 - centre.x) / axes.x);
|
||||
let dy = abs((f32(p.y) + 0.5 - centre.y) / axes.y);
|
||||
let r = pow(pow(dx, pw) + pow(dy, pw), 1.0 / pw);
|
||||
let m = smoothstep_(P.vignette[2].x, P.vignette[2].y, r);
|
||||
if m > 0.0 {
|
||||
let l = clamp(luma(c), 0.0, 1.0);
|
||||
if style == 2u {
|
||||
let goal = select(1.0, 0.0, amt < 0.0);
|
||||
let k = m * abs(amt);
|
||||
c += (vec3(goal) - c) * k;
|
||||
} else {
|
||||
var k = exp2(amt * 2.0 * m);
|
||||
if amt < 0.0 && hl > 0.0 {
|
||||
let protect = smoothstep_(0.6, 1.0, l) * hl;
|
||||
k += (1.0 - k) * protect;
|
||||
}
|
||||
if style == 1u {
|
||||
let nl = l * k;
|
||||
c += vec3(nl - l);
|
||||
} else {
|
||||
c = c * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 9. Grain, in full-resolution coordinates.
|
||||
if on(GRAIN) {
|
||||
let cell = P.grain.x;
|
||||
let rough = P.grain.y;
|
||||
let fx = f32(p.x) * P.grain.w / cell;
|
||||
let fy = f32(p.y) * P.grain.w / cell;
|
||||
let n = value_noise(fx, fy, P.seeds.xy) * (1.0 - rough) + (value_noise(fx * 2.3, fy * 2.3, P.seeds.zw) - 0.0) * rough;
|
||||
let l = clamp(luma(c), 0.0, 1.0);
|
||||
let g = (n - 0.5) * 2.0 * P.grain.z * (0.4 + 2.4 * l * (1.0 - l));
|
||||
c += vec3(g);
|
||||
}
|
||||
if on(CLAMP) {
|
||||
c = clamp(c, vec3(0.0), vec3(1.0));
|
||||
}
|
||||
return vec4(c, q.a);
|
||||
}
|
||||
@@ -34,10 +34,14 @@
|
||||
//! (Multichannel documents, patterns larger than the texture limit, uncached gradients with
|
||||
//! coincident colour or opacity stops) returns [`Unsupported`];
|
||||
//! callers fall back to the CPU compositor.
|
||||
//!
|
||||
//! [`CameraRawDevelop`] (`camera_raw.rs`) is a separate pass, not part of the compositor: Camera
|
||||
//! Raw's per-pixel develop stages for the dialog's preview, built the first time the dialog opens.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod bounds;
|
||||
mod camera_raw;
|
||||
mod fx;
|
||||
pub mod health;
|
||||
pub mod plan;
|
||||
@@ -54,6 +58,7 @@ use photocraft_doc::{DocId, Document, LayerContent, LayerId, Pattern};
|
||||
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::{Surface, Tile};
|
||||
|
||||
pub use camera_raw::{CAMERA_RAW_SHADER, CameraRawDevelop};
|
||||
pub use health::{DeviceHealth, Fault};
|
||||
pub use plan::{Kernel, Plan, Role, Unsupported, plan};
|
||||
pub use timing::{GpuTimer, GpuTiming};
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Camera Raw's GPU preview pass against the CPU develop (the oracle), stage by stage, at 8, 16
|
||||
//! and 32 bits. Skips, with a message, when there is no adapter or device or it can't render
|
||||
//! `Rgba32Float`; with `WGPU_BACKEND` set those fail instead, as in `parity.rs`. A pipeline that
|
||||
//! doesn't build on an adapter that can run it fails. The shader itself is validated without a
|
||||
//! GPU ([`the_shader_validates_without_a_gpu`]), so a WGSL error fails on every runner.
|
||||
|
||||
use photocraft_algo::camera_raw::{CameraRaw, Wheel, develop, per_pixel, white_black_points};
|
||||
use photocraft_gpu::{CameraRawDevelop, DeviceHealth, Fault};
|
||||
|
||||
/// Largest difference from the CPU develop, per channel. The pass evaluates the same
|
||||
/// expressions in f32, so what remains is the GPU's own `pow`, `exp2` and `sin` (a few ulps) and
|
||||
/// how they move a value across a table entry; measured at most about 1e-5 on NVIDIA (Vulkan,
|
||||
/// DX12) and WARP. 1/4096 keeps a margin and stays well under one 8-bit step (the compositor's
|
||||
/// tolerance is 1/255).
|
||||
const TOL: f32 = 1.0 / 4096.0;
|
||||
|
||||
struct Gpu {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
dev: CameraRawDevelop,
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
fn gpu() -> Option<Gpu> {
|
||||
let lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let required = std::env::var_os("WGPU_BACKEND").is_some();
|
||||
// `WGPU_BACKEND` picks the backend; `PHOTOCRAFT_TEST_FALLBACK_ADAPTER=1` the software one (WARP).
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
|
||||
let fallback = std::env::var_os("PHOTOCRAFT_TEST_FALLBACK_ADAPTER").is_some();
|
||||
let options = wgpu::RequestAdapterOptions { force_fallback_adapter: fallback, ..Default::default() };
|
||||
let adapter = match pollster::block_on(instance.request_adapter(&options)) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
assert!(!required, "WGPU_BACKEND is set but has no adapter: {e}");
|
||||
eprintln!("skipping Camera Raw GPU tests: no adapter ({e})");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let info = adapter.get_info();
|
||||
if !CameraRawDevelop::supported(&adapter) {
|
||||
assert!(!required, "WGPU_BACKEND is set but {} can't render Rgba32Float", info.name);
|
||||
eprintln!("skipping Camera Raw GPU tests: adapter can't render Rgba32Float");
|
||||
return None;
|
||||
}
|
||||
let (device, queue) = match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
assert!(!required, "WGPU_BACKEND is set but {} has no device: {e}", info.name);
|
||||
eprintln!("skipping Camera Raw GPU tests: no device ({e})");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
eprintln!("Camera Raw GPU tests on {:?} ({})", info.backend, info.name);
|
||||
// The adapter can run the pass: a pipeline that doesn't build is a bug in it, not a skip.
|
||||
let dev = CameraRawDevelop::new(&device).unwrap_or_else(|e| panic!("the Camera Raw pipeline didn't build on {}: {}", info.name, e.0));
|
||||
Some(Gpu { device, queue, dev, _lock: lock })
|
||||
}
|
||||
|
||||
/// The Camera Raw shader parses and validates (naga, as wgpu does before any driver sees it),
|
||||
/// with no adapter: the GPU tests below skip on runners without one.
|
||||
#[test]
|
||||
fn the_shader_validates_without_a_gpu() {
|
||||
let src = photocraft_gpu::CAMERA_RAW_SHADER;
|
||||
let module = wgpu::naga::front::wgsl::parse_str(src).unwrap_or_else(|e| panic!("{}", e.emit_to_string(src)));
|
||||
wgpu::naga::valid::Validator::new(wgpu::naga::valid::ValidationFlags::all(), wgpu::naga::valid::Capabilities::empty())
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(src)));
|
||||
assert!(!module.entry_points.is_empty());
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Depth {
|
||||
Eight,
|
||||
Sixteen,
|
||||
Float,
|
||||
}
|
||||
|
||||
const DEPTHS: [Depth; 3] = [Depth::Eight, Depth::Sixteen, Depth::Float];
|
||||
|
||||
fn hash(i: usize, c: u64) -> f32 {
|
||||
let mut z = (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ c.wrapping_mul(0xC2B2_AE3D_27D4_EB4F);
|
||||
z = (z ^ (z >> 29)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
(z >> 40) as f32 / (1u64 << 24) as f32
|
||||
}
|
||||
|
||||
/// Gradients, noise and saturated patches (every Color Mixer band), quantized to `depth`. The
|
||||
/// 32-bit image also has values above 1 and a few below 0.
|
||||
fn image(w: usize, h: usize, depth: Depth) -> Vec<[f32; 4]> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32 / w as f32, (i / w) as f32 / h as f32);
|
||||
let mut c = if (i / w).is_multiple_of(4) {
|
||||
// A hue sweep at full and half saturation.
|
||||
let hue = x * 6.0;
|
||||
let f = |n: f32| (((hue + n) % 6.0 - 3.0).abs() - 1.0).clamp(0.0, 1.0);
|
||||
let s = if (i / w).is_multiple_of(8) { 1.0 } else { 0.5 };
|
||||
[f(0.0), f(4.0), f(2.0)].map(|v| (1.0 - s) * 0.6 + s * v)
|
||||
} else {
|
||||
[0.1 + 0.8 * x, 0.2 + 0.6 * y, 0.5 - 0.4 * x * y].map(|v| v + (hash(i, 1) - 0.5) * 0.15)
|
||||
};
|
||||
if let Depth::Float = depth {
|
||||
c = c.map(|v| v * (1.0 + 1.5 * y) - 0.02 * hash(i, 2));
|
||||
}
|
||||
let q = |v: f32| match depth {
|
||||
Depth::Eight => (v.clamp(0.0, 1.0) * 255.0).round() / 255.0,
|
||||
Depth::Sixteen => (v.clamp(0.0, 1.0) * 32768.0).round() / 32768.0,
|
||||
Depth::Float => v,
|
||||
};
|
||||
[q(c[0]), q(c[1]), q(c[2]), 1.0]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Develops `p` both ways and returns the largest channel difference.
|
||||
fn compare(g: &mut Gpu, p: &CameraRaw, depth: Depth) -> f32 {
|
||||
let (w, h) = (171, 113);
|
||||
let float = matches!(depth, Depth::Float);
|
||||
let src = image(w, h, depth);
|
||||
let mut cpu = src.clone();
|
||||
develop(&mut cpu, w, h, p, float);
|
||||
let points = white_black_points(&src, w, h, p);
|
||||
let stages = per_pixel(p, w, h, float, points).unwrap_or_else(|| panic!("{p:?} has a spatial stage"));
|
||||
let out = g.dev.develop(&g.device, &g.queue, None, &src, None, [w as u32, h as u32], &stages).unwrap();
|
||||
assert_eq!(out.len(), cpu.len());
|
||||
let mut worst = (0.0f32, 0usize);
|
||||
for (i, (a, b)) in out.iter().zip(&cpu).enumerate() {
|
||||
assert!(a.iter().all(|v| v.is_finite()), "{p:?} {depth:?}: non-finite GPU pixel {i}: {a:?}");
|
||||
assert_eq!(a[3], b[3], "alpha is kept");
|
||||
for c in 0..3 {
|
||||
let d = (a[c] - b[c]).abs();
|
||||
if d > worst.0 {
|
||||
worst = (d, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (d, i) = worst;
|
||||
assert!(d <= TOL, "{p:?} {depth:?}: GPU {:?} vs CPU {:?} at pixel {i} (source {:?}), off by {d}", out[i], cpu[i], src[i]);
|
||||
if !float {
|
||||
assert!(out.iter().all(|q| q[..3].iter().all(|v| (0.0..=1.0).contains(v))), "{p:?} {depth:?}: clipped like the CPU");
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn check(name: &str, sets: &[CameraRaw]) {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
for p in sets {
|
||||
for depth in DEPTHS {
|
||||
let d = compare(&mut g, p, depth);
|
||||
eprintln!("{name} {depth:?}: max |GPU - CPU| = {d:.2e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_balance_and_exposure() {
|
||||
check(
|
||||
"white balance",
|
||||
&[
|
||||
CameraRaw { temperature: 40.0, tint: -25.0, ..Default::default() },
|
||||
CameraRaw { temperature: -70.0, tint: 60.0, exposure: -1.2, ..Default::default() },
|
||||
CameraRaw { exposure: 1.5, ..Default::default() },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_without_texture_or_clarity() {
|
||||
check("contrast", &[CameraRaw { contrast: 60.0, ..Default::default() }, CameraRaw { contrast: -80.0, exposure: 0.4, ..Default::default() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whites_and_blacks() {
|
||||
check(
|
||||
"whites/blacks",
|
||||
&[
|
||||
CameraRaw { whites: 70.0, blacks: -45.0, ..Default::default() },
|
||||
CameraRaw { whites: -60.0, blacks: 50.0, temperature: 20.0, ..Default::default() },
|
||||
CameraRaw { whites: 30.0, ..Default::default() },
|
||||
CameraRaw { blacks: -100.0, exposure: -0.5, ..Default::default() },
|
||||
// ProPhoto's power-law encoding.
|
||||
CameraRaw { whites: 50.0, blacks: -30.0, encoding_gamma: 1.8, ..Default::default() },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_curves() {
|
||||
check(
|
||||
"tone curves",
|
||||
&[
|
||||
CameraRaw {
|
||||
curve_highlights: -60.0,
|
||||
curve_lights: 30.0,
|
||||
curve_darks: 40.0,
|
||||
curve_shadows: -20.0,
|
||||
curve_splits: [20.0, 45.0, 80.0],
|
||||
..Default::default()
|
||||
},
|
||||
CameraRaw { point_curve: vec![[0.0, 20.0], [64.0, 50.0], [192.0, 220.0], [255.0, 245.0]], ..Default::default() },
|
||||
CameraRaw {
|
||||
point_curve_red: vec![[0.0, 0.0], [128.0, 160.0], [255.0, 255.0]],
|
||||
point_curve_green: vec![[0.0, 10.0], [255.0, 230.0]],
|
||||
point_curve_blue: vec![[0.0, 0.0], [100.0, 70.0], [255.0, 255.0]],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vibrance_and_saturation() {
|
||||
check(
|
||||
"presence",
|
||||
&[
|
||||
CameraRaw { vibrance: 70.0, ..Default::default() },
|
||||
CameraRaw { saturation: -60.0, ..Default::default() },
|
||||
CameraRaw { vibrance: -40.0, saturation: 50.0, ..Default::default() },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_mixer() {
|
||||
let ramp = |k: f32| std::array::from_fn(|i| ((i as f32 * 37.0 + k) % 200.0) - 100.0);
|
||||
check(
|
||||
"color mixer",
|
||||
&[
|
||||
CameraRaw { hsl_hue: ramp(0.0), ..Default::default() },
|
||||
CameraRaw { hsl_sat: ramp(50.0), ..Default::default() },
|
||||
CameraRaw { hsl_lum: ramp(120.0), ..Default::default() },
|
||||
CameraRaw { hsl_hue: ramp(10.0), hsl_sat: ramp(80.0), hsl_lum: ramp(150.0), ..Default::default() },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_grading() {
|
||||
check(
|
||||
"color grading",
|
||||
&[
|
||||
CameraRaw { grade_shadows: Wheel { hue: 220.0, sat: 70.0, lum: 20.0 }, ..Default::default() },
|
||||
CameraRaw {
|
||||
grade_midtones: Wheel { hue: 120.0, sat: 30.0, lum: -15.0 },
|
||||
grade_highlights: Wheel { hue: 40.0, sat: 50.0, lum: 10.0 },
|
||||
grade_global: Wheel { hue: 300.0, sat: 15.0, lum: 5.0 },
|
||||
grade_blending: 80.0,
|
||||
grade_balance: -40.0,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vignette() {
|
||||
check(
|
||||
"vignette",
|
||||
&[
|
||||
CameraRaw { vignette_amount: -70.0, ..Default::default() },
|
||||
CameraRaw { vignette_amount: -50.0, vignette_roundness: -60.0, vignette_feather: 20.0, vignette_highlights: 70.0, ..Default::default() },
|
||||
CameraRaw {
|
||||
vignette_amount: 60.0,
|
||||
vignette_roundness: 80.0,
|
||||
vignette_midpoint: 20.0,
|
||||
vignette_style: "colorPriority".into(),
|
||||
..Default::default()
|
||||
},
|
||||
CameraRaw { vignette_amount: -40.0, vignette_style: "paintOverlay".into(), ..Default::default() },
|
||||
CameraRaw { vignette_amount: 40.0, vignette_style: "paintOverlay".into(), ..Default::default() },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grain_in_full_resolution_coordinates() {
|
||||
check(
|
||||
"grain",
|
||||
&[
|
||||
CameraRaw { grain_amount: 60.0, seed: 3, ..Default::default() },
|
||||
// A preview proxy: the noise lattice is sampled at full-resolution positions.
|
||||
CameraRaw { grain_amount: 80.0, grain_size: 60.0, grain_roughness: 90.0, seed: u32::MAX, pixel_scale: 0.143, ..Default::default() },
|
||||
CameraRaw { grain_amount: 25.0, grain_size: 0.0, grain_roughness: 0.0, seed: 77, pixel_scale: 0.5, ..Default::default() },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_per_pixel_stage_at_once() {
|
||||
let mut hsl = [0.0; 8];
|
||||
hsl[1] = 35.0;
|
||||
hsl[5] = -50.0;
|
||||
check(
|
||||
"all stages",
|
||||
&[CameraRaw {
|
||||
temperature: 25.0,
|
||||
tint: 10.0,
|
||||
exposure: 0.3,
|
||||
contrast: 25.0,
|
||||
whites: 20.0,
|
||||
blacks: -15.0,
|
||||
vibrance: 30.0,
|
||||
saturation: 10.0,
|
||||
curve_darks: 20.0,
|
||||
point_curve: vec![[0.0, 8.0], [255.0, 250.0]],
|
||||
hsl_hue: hsl,
|
||||
hsl_sat: hsl,
|
||||
hsl_lum: hsl,
|
||||
grade_shadows: Wheel { hue: 210.0, sat: 25.0, lum: 0.0 },
|
||||
vignette_amount: -30.0,
|
||||
grain_amount: 20.0,
|
||||
pixel_scale: 0.2,
|
||||
..Default::default()
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_leaves_pixels_alone_and_spatial_settings_have_no_pass() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let p = CameraRaw::default();
|
||||
let src = image(64, 32, Depth::Float);
|
||||
let stages = per_pixel(&p, 64, 32, false, None).unwrap();
|
||||
let out = g.dev.develop(&g.device, &g.queue, None, &src, Some(1), [64, 32], &stages).unwrap();
|
||||
assert_eq!(out, src, "the CPU develop returns early, unclipped");
|
||||
for spatial in [
|
||||
CameraRaw { dehaze: 10.0, ..Default::default() },
|
||||
CameraRaw { shadows: 10.0, ..Default::default() },
|
||||
CameraRaw { highlights: -10.0, ..Default::default() },
|
||||
CameraRaw { texture: 10.0, ..Default::default() },
|
||||
CameraRaw { clarity: 10.0, ..Default::default() },
|
||||
CameraRaw { sharpen_amount: 10.0, ..Default::default() },
|
||||
CameraRaw { noise_luminance: 10.0, ..Default::default() },
|
||||
CameraRaw { noise_color: 10.0, ..Default::default() },
|
||||
] {
|
||||
assert!(spatial.spatial() && per_pixel(&spatial, 64, 32, false, Some((0.0, 1.0))).is_none(), "{spatial:?}");
|
||||
}
|
||||
// Whites and Blacks need the image's points.
|
||||
assert!(per_pixel(&CameraRaw { whites: 10.0, ..Default::default() }, 64, 32, false, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cached_source_is_reused_and_a_new_key_uploads() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let p = CameraRaw { exposure: 0.5, ..Default::default() };
|
||||
let stages = per_pixel(&p, 64, 32, false, None).unwrap();
|
||||
let a = image(64, 32, Depth::Eight);
|
||||
let b: Vec<[f32; 4]> = a.iter().map(|q| [q[2], q[0], q[1], 1.0]).collect();
|
||||
let first = g.dev.develop(&g.device, &g.queue, None, &a, Some(7), [64, 32], &stages).unwrap();
|
||||
// Same key: the uploaded source stays (callers key by the proxy's identity).
|
||||
assert_eq!(g.dev.develop(&g.device, &g.queue, None, &b, Some(7), [64, 32], &stages).unwrap(), first);
|
||||
let mut cpu = b.clone();
|
||||
develop(&mut cpu, 64, 32, &p, false);
|
||||
let other = g.dev.develop(&g.device, &g.queue, None, &b, Some(8), [64, 32], &stages).unwrap();
|
||||
assert!(other.iter().zip(&cpu).all(|(x, y)| (0..3).all(|c| (x[c] - y[c]).abs() <= TOL)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_sizes_and_faulted_devices_are_errors() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let stages = per_pixel(&CameraRaw { exposure: 0.5, ..Default::default() }, 4, 4, false, None).unwrap();
|
||||
let px = vec![[0.5f32; 4]; 16];
|
||||
for size in [[0, 4], [4, 0], [5, 4], [3, 3], [u32::MAX, 1]] {
|
||||
assert!(g.dev.develop(&g.device, &g.queue, None, &px, None, size, &stages).is_err(), "{size:?}");
|
||||
}
|
||||
let health = DeviceHealth::new();
|
||||
health.mark(Fault::Lost("test".into()));
|
||||
assert!(g.dev.develop(&g.device, &g.queue, Some(&health), &px, None, [4, 4], &stages).is_err());
|
||||
// And the pass still works afterwards.
|
||||
assert!(g.dev.develop(&g.device, &g.queue, Some(&DeviceHealth::new()), &px, None, [4, 4], &stages).is_ok());
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
/// creates a device takes this lock and holds it until the device is dropped.
|
||||
static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
mod camera_raw;
|
||||
mod device_loss;
|
||||
mod parity;
|
||||
mod pipeline_errors;
|
||||
|
||||
@@ -56,3 +56,7 @@ photocraft-psd = { workspace = true, features = ["testgen"] }
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
photocraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
# Lossless JPEG XL encoder for the JPEG XL DNG preview test (tests only), as in photocraft-raw.
|
||||
zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] }
|
||||
zune-core = { workspace = true }
|
||||
tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] }
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! `x:xmpmeta/rdf:RDF/rdf:Description`, as attributes or as child elements.
|
||||
//!
|
||||
//! Loading applies only the settings the file holds, over the current ones, as Camera Raw's
|
||||
//! Load Settings does; settings PhotoCraft has no control for (lens corrections, calibration,
|
||||
//! geometry, defringe, …) are ignored and named in the report. Saving writes every supported
|
||||
//! Load Settings does; settings PhotoCraft has no control for (lens corrections, geometry,
|
||||
//! defringe, …) are ignored and named in the report. The same reader fills the dialog from a
|
||||
//! raw file's sidecar (`IMG_0001.xmp` beside `IMG_0001.CR2`), and the writer keeps it. Saving writes every supported
|
||||
//! control, so loading the file in Photoshop reproduces the dialog.
|
||||
//!
|
||||
//! White balance: a Camera Raw Filter on a rendered image uses the relative
|
||||
@@ -100,6 +101,14 @@ const SCALARS: &[(&str, Field, f32, f32)] = &[
|
||||
("PostCropVignetteRoundness", |p| &mut p.vignette_roundness, -100.0, 100.0),
|
||||
("PostCropVignetteFeather", |p| &mut p.vignette_feather, 0.0, 100.0),
|
||||
("PostCropVignetteHighlightContrast", |p| &mut p.vignette_highlights, 0.0, 100.0),
|
||||
// Calibration
|
||||
("ShadowTint", |p| &mut p.shadow_tint, -100.0, 100.0),
|
||||
("RedHue", |p| &mut p.calibration_hue[0], -100.0, 100.0),
|
||||
("RedSaturation", |p| &mut p.calibration_sat[0], -100.0, 100.0),
|
||||
("GreenHue", |p| &mut p.calibration_hue[1], -100.0, 100.0),
|
||||
("GreenSaturation", |p| &mut p.calibration_sat[1], -100.0, 100.0),
|
||||
("BlueHue", |p| &mut p.calibration_hue[2], -100.0, 100.0),
|
||||
("BlueSaturation", |p| &mut p.calibration_sat[2], -100.0, 100.0),
|
||||
];
|
||||
|
||||
type Curve = fn(&mut CameraRaw) -> &mut Vec<[f32; 2]>;
|
||||
@@ -265,6 +274,25 @@ fn escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
/// The XMP toolkit [`save`] names on `x:xmpmeta`.
|
||||
const TOOLKIT: &str = "PhotoCraft";
|
||||
const META_NS: &str = "adobe:ns:meta/";
|
||||
|
||||
/// Did [`save`] write this file? True when its `x:xmpmeta` names PhotoCraft as the toolkit
|
||||
/// (`x:xmptk`). Lightroom, Bridge and Camera Raw put their own toolkit there when they write a
|
||||
/// file, so a sidecar another application touched since reads false. Never panics.
|
||||
pub fn is_photocraft_xmp(bytes: &[u8]) -> bool {
|
||||
if bytes.len() > MAX_XMP_BYTES {
|
||||
return false;
|
||||
}
|
||||
let Ok(text) = std::str::from_utf8(bytes) else { return false };
|
||||
let Ok(doc) = roxmltree::Document::parse(text.trim_start_matches('\u{feff}')) else { return false };
|
||||
doc.descendants()
|
||||
.find(|n| n.is_element() && n.tag_name().namespace() == Some(META_NS) && n.tag_name().name() == "xmpmeta")
|
||||
.and_then(|n| n.attribute((META_NS, "xmptk")))
|
||||
== Some(TOOLKIT)
|
||||
}
|
||||
|
||||
/// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads.
|
||||
pub fn save(params: &CameraRaw) -> String {
|
||||
let mut p = params.clone();
|
||||
@@ -357,7 +385,8 @@ mod tests {
|
||||
assert_eq!(p.point_curve_red, vec![[0.0, 0.0], [128.0, 140.0], [255.0, 255.0]]);
|
||||
assert_eq!(p.dehaze, 12.0);
|
||||
// Lens vignetting, calibration and geometry have no counterpart.
|
||||
assert_eq!(r.ignored, vec!["VignetteAmount".to_string(), "RedHue".to_string()]);
|
||||
assert_eq!(r.ignored, vec!["VignetteAmount".to_string()], "the Calibration panel's keys load now");
|
||||
assert_eq!(p.calibration_hue[0], -5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -420,4 +449,24 @@ mod tests {
|
||||
assert_eq!(p, before);
|
||||
assert_eq!(r.ignored, vec!["Contrast2012".to_string(), "ToneCurvePV2012".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_photocraft_xmp_reads_the_toolkit_marker() {
|
||||
let own = save(&CameraRaw { contrast: 15.0, ..CameraRaw::default() });
|
||||
assert!(is_photocraft_xmp(own.as_bytes()));
|
||||
let wrapped = format!("\u{feff}<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n{own}<?xpacket end=\"w\"?>");
|
||||
assert!(is_photocraft_xmp(wrapped.as_bytes()), "an xpacket wrapper and a byte order mark");
|
||||
assert!(!is_photocraft_xmp(FIXTURE), "Camera Raw's own toolkit");
|
||||
for other in [
|
||||
&b""[..],
|
||||
b"<x:xmpmeta",
|
||||
b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>",
|
||||
b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" xmptk=\"PhotoCraft\"/>",
|
||||
b"<x:xmpmeta xmlns:x=\"other:ns/\" x:xmptk=\"PhotoCraft\"/>",
|
||||
b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"PhotoCraft 2\"/>",
|
||||
b"\xff\xfe",
|
||||
] {
|
||||
assert!(!is_photocraft_xmp(other), "{}", String::from_utf8_lossy(other));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
//! one 13-byte record per extra channel: colour space (u16), four u16 colour components, opacity
|
||||
//! (u16, 0..100) and kind (u8: 0 = colour indicates selected areas, 1 = masked ("protected")
|
||||
//! areas, 2 = spot channel). Resource 1007 has the same records without the version and the spot
|
||||
//! kind, each followed by a padding byte (14 bytes).
|
||||
//! kind, each followed by a padding byte (14 bytes). Photoshop 27's TIFFs use the same 13-byte
|
||||
//! 1077 records.
|
||||
|
||||
use photocraft_color::{Color, ColorMode};
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::{AlphaChannel, ColorIndicates};
|
||||
|
||||
pub(crate) const DISPLAY_INFO: u16 = 1077;
|
||||
pub(crate) const DISPLAY_INFO_OLD: u16 = 1007;
|
||||
/// Quick Mask information: channel id (u16) and an "initially empty" flag (u8).
|
||||
pub(crate) const QUICK_MASK_INFO: u16 = 1022;
|
||||
/// Alpha channel identifiers (Photoshop 6): one big-endian u32 per extra channel, no count prefix.
|
||||
pub(crate) const ALPHA_IDENTIFIERS: u16 = 1053;
|
||||
|
||||
const SPACE_RGB: u16 = 0;
|
||||
const SPACE_HSB: u16 = 1;
|
||||
@@ -20,6 +23,33 @@ const SPACE_CMYK: u16 = 2;
|
||||
const SPACE_LAB: u16 = 7;
|
||||
const SPACE_GRAY: u16 = 8;
|
||||
|
||||
/// Converts one spot channel's plane (native-endian samples) between the document's ink density
|
||||
/// (1 = solid ink) and the way Photoshop stores a spot channel (0 = solid ink, the maximum =
|
||||
/// bare paper; every PSD and TIFF Photoshop 27.11 saves stores it so). The conversion is its own
|
||||
/// inverse, so it serves both open and save. Every reader and writer of Photoshop-polarity spot
|
||||
/// planes goes through here: a change to the in-memory convention changes only this function.
|
||||
/// Alpha channels are stored straight (the maximum = selected) and never come here.
|
||||
pub(crate) fn photoshop_spot_plane(plane: &mut [u8], sample: SampleType) {
|
||||
crate::pixels::invert_plane(plane, sample);
|
||||
}
|
||||
|
||||
/// Resource 1053 for `count` channels: one big-endian u32 identifier each, with no length prefix
|
||||
/// (Photoshop, ag-psd and psd-tools read u32s until the resource ends). Photoshop 27.11 numbers a
|
||||
/// new document's channels 3, 4, 5, … in every colour mode (RGB, CMYK, Gray and Lab, in its PSDs
|
||||
/// and TIFFs alike), so these do too. `transparency`: the merged image's transparency channel
|
||||
/// comes first, with identifier 0 (Photoshop's transparent PSD lists `0, 3, 4, 5`).
|
||||
pub(crate) fn alpha_identifiers(transparency: bool, count: usize) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(count.saturating_add(1).saturating_mul(4));
|
||||
if transparency {
|
||||
out.extend_from_slice(&0u32.to_be_bytes());
|
||||
}
|
||||
for i in 0..count {
|
||||
let id = 3u32.saturating_add(u32::try_from(i).unwrap_or(u32::MAX));
|
||||
out.extend_from_slice(&id.to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Resource 1077 for `channels` (in file order): a version, then 13 bytes per channel (colour
|
||||
/// space, four components, opacity, kind). Every corpus file, Photoshop's included, uses 13;
|
||||
/// 14-byte records (the older 1007 layout) shift every channel after the first.
|
||||
@@ -46,12 +76,33 @@ pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Ve
|
||||
out
|
||||
}
|
||||
|
||||
/// The records of a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version;
|
||||
/// else the older 1007 with 14-byte records). A trailing partial record is left out.
|
||||
fn display_records(data: &[u8], versioned: bool) -> std::slice::ChunksExact<'_, u8> {
|
||||
let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) };
|
||||
body.chunks_exact(size)
|
||||
}
|
||||
|
||||
/// Which of the extra channels are spot channels (kind 2), after skipping `skip` records (the
|
||||
/// merged transparency channel's), in the same records [`apply_display_info`] reads. A channel
|
||||
/// without a record is not a spot.
|
||||
pub(crate) fn spot_flags(data: &[u8], versioned: bool, skip: usize) -> Vec<bool> {
|
||||
display_records(data, versioned).skip(skip).map(|rec| rec.get(12) == Some(&2)).collect()
|
||||
}
|
||||
|
||||
/// Whether a DisplayInfo resource 1077 marks any channel as a spot (kind 2), in 13-byte records
|
||||
/// (Photoshop's, and PhotoCraft's since 0.8.2) or in the 14-byte padded records PhotoCraft wrote
|
||||
/// up to 0.7.0.
|
||||
pub(crate) fn display_info_marks_spot(data: &[u8]) -> bool {
|
||||
let body = data.get(4..).unwrap_or_default();
|
||||
[13, 14].into_iter().any(|size| body.chunks_exact(size).any(|rec| rec.get(12) == Some(&2)))
|
||||
}
|
||||
|
||||
/// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the
|
||||
/// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip`
|
||||
/// records (the merged transparency channel's).
|
||||
pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, channels: &mut [AlphaChannel]) {
|
||||
let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) };
|
||||
for (c, rec) in channels.iter_mut().zip(body.chunks_exact(size).skip(skip)) {
|
||||
for (c, rec) in channels.iter_mut().zip(display_records(data, versioned).skip(skip)) {
|
||||
let u = |i: usize| u16::from_be_bytes([rec[i], rec[i + 1]]);
|
||||
let comps = [u(2), u(4), u(6), u(8)];
|
||||
let color = decode_color(u(0), comps);
|
||||
@@ -149,6 +200,15 @@ mod tests {
|
||||
assert!((back[0].opacity - 0.3).abs() < 1e-6);
|
||||
assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas);
|
||||
assert_eq!(back[1].spot, s.spot);
|
||||
assert_eq!(spot_flags(&bytes, true, 0), [false, true]);
|
||||
assert!(display_info_marks_spot(&bytes));
|
||||
assert!(!display_info_marks_spot(&display_info(&[&a], true)), "alpha and transparency records only");
|
||||
assert!(!display_info_marks_spot(&bytes[..10]));
|
||||
// Photoshop 27 writes a 13-byte record (no pad): red spot, solidity 60.
|
||||
let ps = [0, 0, 0, 1, 0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0x3c, 2];
|
||||
let mut spot = vec![AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))];
|
||||
apply_display_info(&ps, true, 0, &mut spot);
|
||||
assert_eq!(spot[0].spot.map(|(c, s)| (c.to_rgb(), (s * 100.0).round() as u16)), Some(([1.0, 0.0, 0.0], 60)));
|
||||
// With the merged transparency's record first, the channels still get their own.
|
||||
let with_t = display_info(&[&a, &s], true);
|
||||
assert_eq!(with_t.len(), 4 + 3 * 13);
|
||||
@@ -156,10 +216,79 @@ mod tests {
|
||||
apply_display_info(&with_t, true, 1, &mut back);
|
||||
assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas);
|
||||
assert_eq!(back[1].spot, s.spot);
|
||||
assert_eq!(spot_flags(&with_t, true, 1), [false, true]);
|
||||
// Truncated data is ignored rather than misread.
|
||||
let mut c = vec![AlphaChannel::new("x", Surface::new(PixelFormat::GRAY8))];
|
||||
apply_display_info(&bytes[..10], true, 0, &mut c);
|
||||
assert_eq!(c[0].opacity, 0.5);
|
||||
assert!(spot_flags(&bytes[..10], true, 0).is_empty());
|
||||
assert!(spot_flags(&bytes, true, 5).is_empty());
|
||||
|
||||
// 14 unpadded records are a multiple of 14 as well (14×13 = 13×14). Resource 1077
|
||||
// still reads them as 13-byte records; the last spot's solidity must survive.
|
||||
let mut many = Vec::new();
|
||||
for i in 0..14 {
|
||||
let mut ch = AlphaChannel::new(format!("{i}"), Surface::new(PixelFormat::GRAY8));
|
||||
ch.spot = Some((Color::rgb(1.0, 0.0, 0.0), (i as f32) / 14.0));
|
||||
many.push(ch);
|
||||
}
|
||||
let refs: Vec<&AlphaChannel> = many.iter().collect();
|
||||
let packed = display_info(&refs, false);
|
||||
assert_eq!(packed.len(), 4 + 14 * 13);
|
||||
let mut got: Vec<AlphaChannel> = (0..14).map(|i| AlphaChannel::new(format!("{i}"), Surface::new(PixelFormat::GRAY8))).collect();
|
||||
apply_display_info(&packed, true, 0, &mut got);
|
||||
assert_eq!(got[13].spot.map(|(_, s)| (s * 100.0).round() as u16), many[13].spot.map(|(_, s)| (s * 100.0).round() as u16));
|
||||
}
|
||||
|
||||
/// Resource 1053 as Photoshop 27.11 numbers a new document's channels: 3, 4, 5 in every colour
|
||||
/// mode, with the merged transparency first as 0 (its transparent PSD lists `0, 3, 4, 5`).
|
||||
#[test]
|
||||
fn alpha_identifiers_are_numbered_as_photoshop_does() {
|
||||
let ids = |transparency, count| alpha_identifiers(transparency, count).as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect::<Vec<_>>();
|
||||
assert_eq!(ids(false, 3), [3, 4, 5]);
|
||||
assert_eq!(ids(true, 3), [0, 3, 4, 5]);
|
||||
assert_eq!(ids(false, 1), [3]);
|
||||
assert!(ids(false, 0).is_empty());
|
||||
assert_eq!(ids(true, 0), [0]);
|
||||
assert_eq!(alpha_identifiers(false, 2), [0, 0, 0, 3, 0, 0, 0, 4]);
|
||||
}
|
||||
|
||||
/// PhotoCraft 0.1.0 to 0.7.0 padded each 1077 record to 14 bytes, so a spot after the first
|
||||
/// record sits where a 13-byte reader finds no kind 2; it is still recognised.
|
||||
#[test]
|
||||
fn a_spot_is_found_in_padded_records() {
|
||||
let alpha = |kind: u8| [0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, kind, 0];
|
||||
let mut old = 1u32.to_be_bytes().to_vec();
|
||||
old.extend_from_slice(&alpha(1));
|
||||
old.extend_from_slice(&alpha(0));
|
||||
old.extend_from_slice(&alpha(2));
|
||||
assert_eq!(spot_flags(&old, true, 0), [false, false, false], "13-byte records misread the padded ones");
|
||||
assert!(display_info_marks_spot(&old));
|
||||
old.truncate(old.len() - 14);
|
||||
assert!(!display_info_marks_spot(&old), "two alpha records");
|
||||
}
|
||||
|
||||
/// Photoshop's spot ink (0 = solid) and the document's density (1 = solid) at every depth,
|
||||
/// both ways: the 25 % / 75 % columns of the Photoshop oracles store 191 / 64.
|
||||
#[test]
|
||||
fn photoshop_spot_plane_converts_both_ways() {
|
||||
let mut p8 = vec![255u8, 64, 191, 0];
|
||||
photoshop_spot_plane(&mut p8, SampleType::U8);
|
||||
assert_eq!(p8, [0, 191, 64, 255]);
|
||||
photoshop_spot_plane(&mut p8, SampleType::U8);
|
||||
assert_eq!(p8, [255, 64, 191, 0]);
|
||||
let mut p16: Vec<u8> = [65535u16, 16448, 0].iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
photoshop_spot_plane(&mut p16, SampleType::U16);
|
||||
let back: Vec<u16> = p16.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).collect();
|
||||
assert_eq!(back, [0, 49087, 65535]);
|
||||
let mut p32: Vec<u8> = [1.0f32, 0.25].iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
photoshop_spot_plane(&mut p32, SampleType::F32);
|
||||
let back: Vec<f32> = p32.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes(*c)).collect();
|
||||
assert_eq!(back, [0.0, 0.75]);
|
||||
// A trailing partial sample is left alone rather than misread.
|
||||
let mut odd = vec![0u8, 0, 7];
|
||||
photoshop_spot_plane(&mut odd, SampleType::U16);
|
||||
assert_eq!(odd, [0xff, 0xff, 7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+33
-5
@@ -280,6 +280,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
|
||||
bg
|
||||
};
|
||||
doc.layers.push(layer);
|
||||
warnings.extend(crate::tiff_layers::channels_from_extras(&mut doc, img));
|
||||
doc.icc_profile = img.icc.clone().map(Arc::new);
|
||||
doc.metadata.exif = img.meta.exif.clone().map(Arc::new);
|
||||
doc.metadata.xmp = img.meta.xmp.as_deref().map(Arc::from);
|
||||
@@ -668,6 +669,12 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -706,7 +713,7 @@ fn tga_image(doc: &Document, img: Image, bits: Option<TgaBits>, warnings: &mut V
|
||||
warnings.push("transparency composited over white; a 24-bit Targa has no alpha".into());
|
||||
matte_over_white(&img)
|
||||
}
|
||||
(TgaBits::Bits32, [channel]) => tga_with_alpha_channel(doc, img, channel, warnings),
|
||||
(TgaBits::Bits32, [channel]) => with_alpha_channel(doc, img, channel, "Targa", warnings),
|
||||
(TgaBits::Bits32, alphas) => {
|
||||
if !alphas.is_empty() {
|
||||
warnings.push("Targa holds one alpha channel; with several, none was written (as in Photoshop)".into());
|
||||
@@ -719,11 +726,32 @@ fn tga_image(doc: &Document, img: Image, bits: Option<TgaBits>, warnings: &mut V
|
||||
}
|
||||
}
|
||||
|
||||
/// A 32-bit Targa's image: the colours (composited over white where the layers are transparent)
|
||||
/// and `channel`'s values as the alpha.
|
||||
fn tga_with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, warnings: &mut Vec<String>) -> Result<Image, IoError> {
|
||||
/// The image a DDS texture holds (#8563): as with a 32-bit Targa, the document's alpha channel
|
||||
/// becomes the texture's alpha when it has exactly one (spot channels don't count), the colours
|
||||
/// composited over white where the layers are transparent; BC1 keeps it as on/off, BC2 and BC3
|
||||
/// in full. With none, the layers' transparency is the alpha as before; with several, none of
|
||||
/// them is written. Grayscale textures keep their transparency.
|
||||
fn dds_image(doc: &Document, img: Image, warnings: &mut Vec<String>) -> Result<Image, IoError> {
|
||||
let alphas: Vec<&AlphaChannel> = doc.channels.iter().filter(|c| c.spot.is_none()).collect();
|
||||
match alphas.as_slice() {
|
||||
[] => Ok(img),
|
||||
[channel] if img.layout().is_rgb() => with_alpha_channel(doc, img, channel, "texture", warnings),
|
||||
[_] => {
|
||||
warnings.push("a grayscale DDS keeps the layers' transparency; the alpha channel was not saved".into());
|
||||
Ok(img)
|
||||
}
|
||||
_ => {
|
||||
warnings.push("a DDS texture holds one alpha channel; with several, none was written".into());
|
||||
Ok(img)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 32-bit Targa's or a texture's image: the colours (composited over white where the layers are
|
||||
/// transparent) and `channel`'s values as the alpha. `what` names the file in the warning.
|
||||
fn with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, what: &str, warnings: &mut Vec<String>) -> Result<Image, IoError> {
|
||||
let img = if img.layout().has_alpha() {
|
||||
warnings.push("transparency composited over white; the alpha channel is the Targa's alpha".into());
|
||||
warnings.push(format!("transparency composited over white; the alpha channel is the {what}'s alpha"));
|
||||
matte_over_white(&img)?
|
||||
} else {
|
||||
img
|
||||
|
||||
@@ -21,6 +21,14 @@ pub fn sample_for_depth(depth: u16) -> SampleType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Complements every native-endian sample of one plane (`max − v`, `1 − v` for float). A
|
||||
/// trailing partial sample is left unchanged.
|
||||
pub(crate) fn invert_plane(bytes: &mut [u8], s: SampleType) {
|
||||
for c in bytes.chunks_exact_mut(s.bytes().max(1)) {
|
||||
invert_sample(c, s);
|
||||
}
|
||||
}
|
||||
|
||||
fn invert_sample(bytes: &mut [u8], s: SampleType) {
|
||||
match s {
|
||||
SampleType::U8 => bytes[0] = 255 - bytes[0],
|
||||
|
||||
+152
-9
@@ -1270,9 +1270,12 @@ pub(crate) fn pascal_names_resource(names: &[&str]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
for n in names {
|
||||
let b = legacy_name(n);
|
||||
v.push(b.len() as u8);
|
||||
v.extend_from_slice(&b);
|
||||
let n = b.len().min(255);
|
||||
v.push(n as u8);
|
||||
v.extend_from_slice(&b[..n]);
|
||||
}
|
||||
// No per-string pad: Photoshop's resource 1006 for "Spot Color 1" is 13 bytes
|
||||
// (length byte + 12 characters). The 8BIM container pads an odd resource separately.
|
||||
v
|
||||
}
|
||||
|
||||
@@ -1824,7 +1827,11 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
};
|
||||
for a in &extra {
|
||||
let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() };
|
||||
let bytes = s.to_interleaved(canvas);
|
||||
let mut bytes = s.to_interleaved(canvas);
|
||||
// Spot ink as Photoshop stores it (0 = solid); an alpha channel stays straight.
|
||||
if a.spot.is_some() {
|
||||
crate::channel_map::photoshop_spot_plane(&mut bytes, sample);
|
||||
}
|
||||
planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0));
|
||||
}
|
||||
let channels = (cc + usize::from(has_alpha) + extra.len()) as u16;
|
||||
@@ -1856,6 +1863,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
resources.push(ImageResource::new(1006, pascal_names_resource(&names)));
|
||||
resources.push(ImageResource::new(1045, unicode_names_resource(&names)));
|
||||
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha)));
|
||||
// Photoshop writes 1053 in every file with an extra channel. Its absence is how a file from
|
||||
// an earlier PhotoCraft is told apart (`psd_import::earlier_photocraft_spot_planes`).
|
||||
resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(has_alpha, extra.len())));
|
||||
}
|
||||
if let Some(id) = quick_mask_id {
|
||||
let mut data = id.to_be_bytes().to_vec();
|
||||
@@ -1907,7 +1917,13 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") {
|
||||
continue;
|
||||
}
|
||||
global_blocks.push(global_block(block_signature(sig, key, version), key, &data));
|
||||
let block = global_block(block_signature(sig, key, version), key, &data);
|
||||
// A kept merged-transparency marker describes the opened file's composite. Over an opaque
|
||||
// one it would make the first alpha channel read back as the transparency, and vanish.
|
||||
if !has_alpha && block.is_merged_transparency_marker() {
|
||||
continue;
|
||||
}
|
||||
global_blocks.push(block);
|
||||
}
|
||||
if let Some(txt2) = txt2 {
|
||||
let mut tb = TaggedBlock::new(*b"Txt2", txt2.clone());
|
||||
@@ -1932,6 +1948,12 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
if !keep_imported_resource(doc, *id, data) {
|
||||
continue;
|
||||
}
|
||||
// 1053 is written afresh with the extra channels. A kept copy (from a Multichannel file, or
|
||||
// a document saved by an earlier version) would be a second one, or list channels that
|
||||
// are gone.
|
||||
if *id == crate::channel_map::ALPHA_IDENTIFIERS {
|
||||
continue;
|
||||
}
|
||||
let mut r = ImageResource::new(*id, data.to_vec());
|
||||
r.name = legacy_name(name);
|
||||
resources.push(r);
|
||||
@@ -1948,10 +1970,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
resources.push(version_info_resource(real_merged));
|
||||
|
||||
let has_layers = !ex.records.is_empty();
|
||||
// Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`) before the layer
|
||||
// block, and won't open a file whose `Lr32` comes first (#5068): the layer block goes right
|
||||
// after the last kept marker, else first.
|
||||
let index = global_blocks.iter().rposition(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")).map_or(0, |i| i + 1);
|
||||
// Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`; `MTrn` in a TIFF)
|
||||
// before the layer block, and won't open a file whose `Lr32` comes first (#5068): the layer
|
||||
// block goes right after the last kept marker, else first.
|
||||
let index = global_blocks.iter().rposition(TaggedBlock::is_merged_transparency_marker).map_or(0, |i| i + 1);
|
||||
let placement = match (has_layers, sample) {
|
||||
(true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr16", padding: None },
|
||||
(true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr32", padding: None },
|
||||
@@ -2059,6 +2081,100 @@ mod tests {
|
||||
assert!(back.layers.len() == 1 && bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks);
|
||||
}
|
||||
|
||||
/// A layered document with a spot and an alpha channel. Each channel holds 100 %, 25 %, 75 %
|
||||
/// and 0 % in four columns, the layout of the Photoshop spot oracles.
|
||||
fn spot_and_alpha_document() -> Document {
|
||||
let mut doc = document(4, 1);
|
||||
doc.layers.push(Layer::group("Group", vec![Layer::raster("Layer 1", doc.pixel_format())]));
|
||||
let channel = |name: &str| {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
s.write_interleaved(doc.bounds(), &[255, 64, 191, 0]);
|
||||
photocraft_doc::AlphaChannel::new(name, s)
|
||||
};
|
||||
let mut spot = channel("Spot Color 1");
|
||||
spot.spot = Some((photocraft_doc::Color::rgb(0.0, 0.6, 1.0), 0.0));
|
||||
let alpha = channel("Alpha 1");
|
||||
doc.channels.extend([spot, alpha]);
|
||||
doc
|
||||
}
|
||||
|
||||
/// The extra planes after the colour channels (and the merged transparency) of `file`.
|
||||
fn extra_planes(file: &PsdFile, count: usize) -> Vec<u8> {
|
||||
let planes = file.decode_merged().unwrap();
|
||||
let plane = (file.header.width * file.header.height) as usize * usize::from(file.header.depth / 8);
|
||||
planes[planes.len() - count * plane..].to_vec()
|
||||
}
|
||||
|
||||
/// Resource 1053's identifiers.
|
||||
fn alpha_ids(file: &PsdFile) -> Option<Vec<u32>> {
|
||||
file.resource(crate::channel_map::ALPHA_IDENTIFIERS).map(|r| r.data.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect())
|
||||
}
|
||||
|
||||
/// A PSD stores spot ink as Photoshop's own PSDs do (0 = solid ink: the 100, 25, 75 and 0 %
|
||||
/// columns are 0, 191, 64, 255) and an alpha channel straight (255 = selected). The global
|
||||
/// layer mask stays empty and flag bit 3 stays on the layers only, not on the group's closing
|
||||
/// divider: Photoshop opens those as they are, and 509 of the corpus's 721 Photoshop PSDs have
|
||||
/// an empty mask too. Opening the file gives the same ink back.
|
||||
#[test]
|
||||
fn psd_stores_spot_ink_as_photoshop_does() {
|
||||
let doc = spot_and_alpha_document();
|
||||
let file = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap();
|
||||
assert_eq!(extra_planes(&file, 2), [0, 191, 64, 255, 255, 64, 191, 0], "spot, then alpha");
|
||||
assert_eq!(file.global_layer_mask.as_ref().map(|m| m.data.len()), Some(0));
|
||||
let flags: Vec<u8> = file.layers().iter().map(|l| l.flags.0 & LayerFlags::BIT4_USEFUL).collect();
|
||||
assert_eq!(flags, [0, LayerFlags::BIT4_USEFUL, LayerFlags::BIT4_USEFUL], "closing divider, layer, group");
|
||||
let back = crate::import("spot.psd", &file.to_bytes().unwrap()).unwrap().document;
|
||||
assert!(back.channels[0].spot.is_some() && back.channels[1].spot.is_none());
|
||||
for c in &back.channels {
|
||||
assert_eq!(c.surface.to_interleaved(back.bounds()), [255, 64, 191, 0], "{}", c.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource 1053 numbers the extra channels as Photoshop 27.11 does in a new document: 3, 4, 5
|
||||
/// in every colour mode, after 0 for the merged transparency when there is one (its
|
||||
/// transparent PSD lists `0, 3, 4, 5`). A Quick Mask is an extra channel too. Without extra
|
||||
/// channels there is none.
|
||||
#[test]
|
||||
fn psd_alpha_identifiers_number_the_channels_as_photoshop_does() {
|
||||
use photocraft_color::Color;
|
||||
let size = photocraft_geom::Size::new(4, 1);
|
||||
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] {
|
||||
let mut doc = Document::with_background("bg", size, mode, SampleType::U8, Color::rgb(1.0, 1.0, 1.0));
|
||||
assert_eq!(alpha_ids(&document_to_psd(&doc)), None, "{mode:?} without channels");
|
||||
doc.channels = spot_and_alpha_document().channels;
|
||||
let file = document_to_psd(&doc);
|
||||
assert!(!file.merged_has_alpha(), "{mode:?}");
|
||||
assert_eq!(alpha_ids(&file), Some(vec![3, 4]), "{mode:?}");
|
||||
doc.quick_mask = Some(photocraft_doc::AlphaChannel::new("Quick Mask", Surface::new(PixelFormat::GRAY8)));
|
||||
assert_eq!(alpha_ids(&document_to_psd(&doc)), Some(vec![3, 4, 5]), "{mode:?} with a Quick Mask");
|
||||
}
|
||||
// The empty layer leaves the composite transparent.
|
||||
let file = document_to_psd(&spot_and_alpha_document());
|
||||
assert!(file.merged_has_alpha());
|
||||
assert_eq!(alpha_ids(&file), Some(vec![0, 3, 4]));
|
||||
}
|
||||
|
||||
/// A save writes one fresh 1053 for the channels the document has. A Photoshop file's own is
|
||||
/// not kept on open (it would be new metadata after every round trip), and one a document
|
||||
/// carries anyway (kept by an earlier version) is never written: next to the fresh one it
|
||||
/// would be a second, and without channels it would list channels that are gone. The
|
||||
/// identifiers here, 3 and 2, are those of psd-tools' `cmyk-alpha-spot.psd` (Photoshop 24.7).
|
||||
#[test]
|
||||
fn a_kept_alpha_identifiers_resource_is_not_written() {
|
||||
let kept = || (crate::channel_map::ALPHA_IDENTIFIERS, String::new(), std::sync::Arc::new([0, 0, 0, 3, 0, 0, 0, 2].to_vec()));
|
||||
let mut file = document_to_psd(&spot_and_alpha_document());
|
||||
file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS);
|
||||
file.resources.push(ImageResource::new(kept().0, kept().2.to_vec()));
|
||||
let mut opened = crate::import("ps.psd", &file.to_bytes().unwrap()).unwrap().document;
|
||||
assert!(!opened.metadata.psd_resources.iter().any(|(id, ..)| *id == crate::channel_map::ALPHA_IDENTIFIERS));
|
||||
opened.metadata.psd_resources.push(kept());
|
||||
let again = PsdFile::from_bytes(&crate::export(&opened, "x.psd", &crate::ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
assert_eq!(again.resources.iter().filter(|r| r.id == crate::channel_map::ALPHA_IDENTIFIERS).count(), 1);
|
||||
assert_eq!(alpha_ids(&again), Some(vec![0, 3, 4]));
|
||||
opened.channels.clear();
|
||||
assert_eq!(alpha_ids(&document_to_psd(&opened)), None, "no channels left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_divider_id_wraps_without_colliding_with_imported_layer_ids() {
|
||||
let mut doc = document(8, 8);
|
||||
@@ -2085,7 +2201,12 @@ mod tests {
|
||||
/// `transparentbg.psd` re-saved both ways). The layer block follows a kept marker.
|
||||
#[test]
|
||||
fn layer_block_follows_a_kept_merged_transparency_marker() {
|
||||
for (depth, key, marker) in [(SampleType::F32, *b"Lr32", *b"Mt32"), (SampleType::U16, *b"Lr16", *b"Mt16"), (SampleType::F32, *b"Lr32", *b"Mtrn")] {
|
||||
for (depth, key, marker) in [
|
||||
(SampleType::F32, *b"Lr32", *b"Mt32"),
|
||||
(SampleType::U16, *b"Lr16", *b"Mt16"),
|
||||
(SampleType::F32, *b"Lr32", *b"Mtrn"),
|
||||
(SampleType::F32, *b"Lr32", *b"MTrn"),
|
||||
] {
|
||||
let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth);
|
||||
let mut s = Surface::new(doc.pixel_format());
|
||||
s.fill_rect(photocraft_geom::Rect::new(1, 1, 5, 4), &[0.5, 0.2, 0.9, 1.0]);
|
||||
@@ -2105,6 +2226,28 @@ mod tests {
|
||||
assert!(matches!(saved.layer_info_placement, LayerInfoPlacement::GlobalBlock { index: 0, .. }));
|
||||
}
|
||||
|
||||
/// A merged-transparency marker kept from the opened file describes that file's composite.
|
||||
/// Saved with an opaque composite, it would make the first alpha channel read back as the
|
||||
/// merged transparency, and the channel would be gone: the save drops it.
|
||||
#[test]
|
||||
fn an_opaque_save_drops_a_kept_merged_transparency_marker() {
|
||||
for (depth, marker) in [(SampleType::U8, *b"MTrn"), (SampleType::U8, *b"Mtrn"), (SampleType::U16, *b"Mt16"), (SampleType::F32, *b"Mt32")] {
|
||||
let case = format!("{depth:?} {}", String::from_utf8_lossy(&marker));
|
||||
let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth);
|
||||
let mut s = Surface::new(doc.pixel_format());
|
||||
s.fill_rect(photocraft_geom::Rect::new(0, 0, 8, 6), &[0.5, 0.2, 0.9, 1.0]);
|
||||
doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s)));
|
||||
doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", Surface::new(PixelFormat::GRAY8)));
|
||||
doc.metadata.psd_global_blocks = vec![(*b"8BIM", marker, std::sync::Arc::new(Vec::new()))];
|
||||
let file = document_to_psd(&doc);
|
||||
assert!(!file.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker), "{case}");
|
||||
assert!(!file.merged_has_alpha(), "{case}");
|
||||
let back = crate::import("a.psd", &file.to_bytes().unwrap()).unwrap().document;
|
||||
let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["Alpha 1"], "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_lr16_warns_on_import_and_is_not_saved_twice() {
|
||||
// #2700: a 16-bit file whose Lr16 block claims five layers but holds none.
|
||||
|
||||
+357
-3
@@ -430,10 +430,17 @@ impl Ctx<'_> {
|
||||
})
|
||||
.flatten();
|
||||
if let Some(mut t) = parsed {
|
||||
let mut path_error = None;
|
||||
if let Some(txt2) = &self.txt2 {
|
||||
photocraft_text::psd::apply_txt2(&mut t, &block.data, txt2);
|
||||
// Type on a path keeps its path only in its `Txt2` frame (#7962).
|
||||
path_error = photocraft_text::psd::apply_txt2_path(&mut t, &block.data, txt2).err();
|
||||
photocraft_text::psd::apply_txt2_frame(&mut t, &block.data, txt2);
|
||||
}
|
||||
if let Some(e) = path_error {
|
||||
let shape = if matches!(t.shape, photocraft_doc::text::TextShape::Box { .. }) { "paragraph" } else { "point" };
|
||||
self.warn(format!("layer \"{name}\": its type path could not be read ({e}); it opens as {shape} text"));
|
||||
}
|
||||
// Photoshop's pixels stay until edited. Writers without type pixels are
|
||||
// rendered by text_import::prepare instead of retaining an empty cache.
|
||||
let cache = self.record_surface(rec, &name);
|
||||
@@ -724,7 +731,7 @@ fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec<u8> {
|
||||
interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false])
|
||||
}
|
||||
|
||||
fn unicode_names(data: &[u8]) -> Vec<String> {
|
||||
pub(crate) fn unicode_names(data: &[u8]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0;
|
||||
while at + 4 <= data.len() {
|
||||
@@ -876,6 +883,69 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
|
||||
psd_to_document_decoded(file, ctl, None)
|
||||
}
|
||||
|
||||
/// Whether `file` stores spot planes the way earlier PhotoCraft versions (releases 0.1.0 to
|
||||
/// 0.16.0) wrote them: straight (the maximum = solid ink), the opposite of Photoshop, which
|
||||
/// stores 0 = solid ink in every PSD and TIFF it saves. Their files are recognised by three
|
||||
/// things together:
|
||||
///
|
||||
/// - DisplayInfo (1077) marks at least one spot channel;
|
||||
/// - there is no Alpha Identifiers resource (1053), or one that lists fewer channels than the
|
||||
/// file has ([`photoshop_identified_channels`]). Photoshop has written it, one identifier per
|
||||
/// extra channel, in every file with an extra channel since version 6, and PhotoCraft writes it
|
||||
/// now. No earlier PhotoCraft wrote one of its own, but each kept the 1053 of a Photoshop file
|
||||
/// it opened (a transparent document's `[0]`, for one) and wrote it back unchanged, after the
|
||||
/// channels added in it, which it stored straight;
|
||||
/// - the version info (1057) is byte for byte what those versions wrote: writer "Adobe Photoshop"
|
||||
/// with reader "Adobe Photoshop CS6", both NUL-terminated (0.1.0 to 0.7.0), or reader "Adobe
|
||||
/// Photoshop 2026" without a NUL (0.8.2 on), with either merged-data flag.
|
||||
///
|
||||
/// Any other file is read in Photoshop's polarity. psd-tools' `cmyk-spot.psd`, for one, also has
|
||||
/// spots and no 1053, but names psd-tools in 1057 and stores Photoshop's polarity. A Photoshop
|
||||
/// file that an earlier PhotoCraft opened and saved again kept Photoshop's 1053 and its planes as
|
||||
/// Photoshop stored them, so it is read in Photoshop's polarity too; when channels were added
|
||||
/// there, only the planes after the ones the kept 1053 lists are read straight.
|
||||
///
|
||||
/// Keeping these files' ink is the only reason this exists: deleting it (and its one caller)
|
||||
/// reads every file in Photoshop's polarity, at the cost of inverting the spot ink of PSDs saved
|
||||
/// by those versions.
|
||||
pub(crate) fn earlier_photocraft_spot_planes(file: &PsdFile) -> bool {
|
||||
use photocraft_psd::descriptor::UnicodeString;
|
||||
use photocraft_psd::resources::VersionInfo;
|
||||
|
||||
let marks_spot = file.resource(crate::channel_map::DISPLAY_INFO).is_some_and(|r| crate::channel_map::display_info_marks_spot(&r.data));
|
||||
let identifies_every_channel = match file.header.color_mode.color_channels() {
|
||||
Some(color) => {
|
||||
let channels = file.header.channels.saturating_sub(color).saturating_sub(u16::from(file.merged_has_alpha()));
|
||||
photoshop_identified_channels(file) >= usize::from(channels)
|
||||
}
|
||||
// Multichannel has no fixed number of colour channels to count from.
|
||||
None => file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_some(),
|
||||
};
|
||||
if !marks_spot || identifies_every_channel {
|
||||
return false;
|
||||
}
|
||||
let Some(r) = file.resource(ids::VERSION_INFO) else { return false };
|
||||
// Byte for byte: a resource with anything after the fields is someone else's.
|
||||
let Ok(v) = VersionInfo::from_bytes(&r.data) else { return false };
|
||||
if v.to_bytes() != r.data {
|
||||
return false;
|
||||
}
|
||||
let writers = [
|
||||
(UnicodeString::new_nul("Adobe Photoshop"), UnicodeString::new_nul("Adobe Photoshop CS6")),
|
||||
(UnicodeString::new("Adobe Photoshop"), UnicodeString::new("Adobe Photoshop 2026")),
|
||||
];
|
||||
v.version == 1 && v.file_version == 1 && writers.iter().any(|(writer, reader)| v.writer == *writer && v.reader == *reader)
|
||||
}
|
||||
|
||||
/// How many extra channels, after the merged transparency, the Alpha Identifiers resource (1053)
|
||||
/// lists: its identifiers other than 0, which Photoshop gives the merged transparency (first, and
|
||||
/// in some files with no transparency plane at all). 0 without a 1053. In a file from an earlier
|
||||
/// PhotoCraft ([`earlier_photocraft_spot_planes`]) these are the channels of the Photoshop file
|
||||
/// it opened, stored as Photoshop stored them; the channels after them were added there.
|
||||
pub(crate) fn photoshop_identified_channels(file: &PsdFile) -> usize {
|
||||
file.resource(crate::channel_map::ALPHA_IDENTIFIERS).map_or(0, |r| r.data.as_chunks::<4>().0.iter().filter(|id| **id != [0; 4]).count())
|
||||
}
|
||||
|
||||
/// Whether the import reads the merged composite: for a flattened or multichannel file (it *is*
|
||||
/// the image), a file without layers, or extra alpha/spot channels behind the colour ones.
|
||||
pub(crate) fn merged_needed(file: &PsdFile) -> bool {
|
||||
@@ -965,6 +1035,10 @@ pub(crate) fn psd_to_document_decoded_integrity(
|
||||
doc.clipping_path = Some(photocraft_doc::ClippingPath { name, flatness: 0.0 });
|
||||
doc.metadata.psd_resources.push((r.id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone())));
|
||||
}
|
||||
// Resource 1053 is written afresh with the extra channels on every save, so a kept copy
|
||||
// would be new metadata after each round trip. Multichannel has no such rewrite: the
|
||||
// imported bytes are the channel ids, copied back while they still match.
|
||||
crate::channel_map::ALPHA_IDENTIFIERS if !multichannel => {}
|
||||
id if MAPPED_RESOURCES.contains(&id) => {}
|
||||
id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))),
|
||||
}
|
||||
@@ -1197,13 +1271,29 @@ pub(crate) fn psd_to_document_decoded_integrity(
|
||||
// Photoshop lists every extra channel, the merged transparency first when there is one.
|
||||
let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc));
|
||||
let names = names.get(skip..).unwrap_or_default();
|
||||
// Which planes hold spot ink in Photoshop's polarity (0 = solid): the DisplayInfo records
|
||||
// the channel options are read from below, the transparency's first when `skip` is 1.
|
||||
// A file from an earlier PhotoCraft stores them straight and is read as it was written,
|
||||
// past the channels of the Photoshop file it opened, if any.
|
||||
let straight_from = if earlier_photocraft_spot_planes(file) { photoshop_identified_channels(file) } else { usize::MAX };
|
||||
let photoshop_spots = if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) {
|
||||
crate::channel_map::spot_flags(&r.data, true, skip)
|
||||
} else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) {
|
||||
crate::channel_map::spot_flags(&r.data, false, skip)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for (k, idx) in (first..usize::from(h.channels)).enumerate() {
|
||||
let Some(p) = merged_plane(all, idx, plane) else {
|
||||
cx.warn_unreadable_data(format!("merged extra channel {idx} is incomplete and was not imported"));
|
||||
break;
|
||||
};
|
||||
let mut raw = be_to_ne_plane(p, depth, n);
|
||||
if k < straight_from && photoshop_spots.get(k).copied().unwrap_or(false) {
|
||||
crate::channel_map::photoshop_spot_plane(&mut raw, depth);
|
||||
}
|
||||
let mut s = Surface::new(cx.mask_fmt);
|
||||
s.write_interleaved(canvas, &be_to_ne_plane(p, depth, n));
|
||||
s.write_interleaved(canvas, &raw);
|
||||
s.prune();
|
||||
let name = names.get(k).cloned().unwrap_or_else(|| format!("Alpha {}", k + 1));
|
||||
doc.channels.push(AlphaChannel::new(name, s));
|
||||
@@ -1245,7 +1335,9 @@ pub(crate) fn psd_to_document_decoded_integrity(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_psd::{Compression, PsdError, Version, testgen};
|
||||
use photocraft_psd::descriptor::UnicodeString;
|
||||
use photocraft_psd::resources::VersionInfo;
|
||||
use photocraft_psd::{Compression, ImageData, ImageResource, PsdError, Version, testgen};
|
||||
|
||||
/// #5200: the Multichannel fallback checks its RGBA output against the budget before
|
||||
/// allocating it (here a 2x2 image, 16 bytes, against caps of 15 and 16 bytes).
|
||||
@@ -1349,4 +1441,266 @@ mod tests {
|
||||
assert_eq!(merged_plane(&all, 2, 2), None);
|
||||
assert_eq!(merged_plane(&all, usize::MAX, 2), None);
|
||||
}
|
||||
|
||||
/// Resource 1057's data.
|
||||
fn version_info(writer: UnicodeString, reader: UnicodeString, has_real_merged_data: bool) -> Vec<u8> {
|
||||
VersionInfo { version: 1, has_real_merged_data, writer, reader, file_version: 1 }.to_bytes()
|
||||
}
|
||||
|
||||
/// The 1057 PhotoCraft 0.1.0 to 0.7.0 wrote: Photoshop CS6's names, NUL-terminated.
|
||||
fn version_info_cs6(has_real_merged_data: bool) -> Vec<u8> {
|
||||
version_info(UnicodeString::new_nul("Adobe Photoshop"), UnicodeString::new_nul("Adobe Photoshop CS6"), has_real_merged_data)
|
||||
}
|
||||
|
||||
/// The 1057 PhotoCraft writes since 0.8.2: Photoshop 2026's bytes, no NUL.
|
||||
fn version_info_2026(has_real_merged_data: bool) -> Vec<u8> {
|
||||
photocraft_psd::resources::version_info_resource(has_real_merged_data).data
|
||||
}
|
||||
|
||||
/// An opaque 4 × 1 RGB document with the Photoshop spot oracles' channels in their order:
|
||||
/// "Spot A", "Alpha 1" and "Spot B", each holding 100, 25, 75 and 0 % in four columns.
|
||||
fn channels_document(depth: SampleType) -> Document {
|
||||
let mut doc = Document::with_background("old", Size::new(4, 1), ColorMode::Rgb, depth, photocraft_color::Color::rgb(1.0, 1.0, 1.0));
|
||||
let gray = PixelFormat::new(ColorMode::Grayscale, depth, false);
|
||||
let canvas = doc.bounds();
|
||||
let channel = |name: &str, spot: Option<(photocraft_color::Color, f32)>| {
|
||||
let mut s = Surface::new(gray);
|
||||
s.write_region(canvas, &[1.0, 0.25, 0.75, 0.0]);
|
||||
AlphaChannel { spot, ..AlphaChannel::new(name, s) }
|
||||
};
|
||||
doc.channels.push(channel("Spot A", Some((photocraft_color::Color::rgb(0.0, 0.627, 1.0), 0.0))));
|
||||
doc.channels.push(channel("Alpha 1", None));
|
||||
doc.channels.push(channel("Spot B", Some((photocraft_color::Color::rgb(1.0, 0.0, 0.627), 0.5))));
|
||||
doc
|
||||
}
|
||||
|
||||
/// Each channel's values on an 8-bit scale.
|
||||
fn ink(doc: &Document) -> Vec<Vec<u8>> {
|
||||
doc.channels.iter().map(|c| c.surface.read_region(doc.bounds()).iter().map(|v| (v * 255.0).round() as u8).collect()).collect()
|
||||
}
|
||||
|
||||
fn spots(doc: &Document) -> Vec<bool> {
|
||||
doc.channels.iter().map(|c| c.spot.is_some()).collect()
|
||||
}
|
||||
|
||||
/// `doc` (opaque, 8 or 16-bit) saved the way PhotoCraft 0.1.0 to 0.16.0 saved it: spot planes
|
||||
/// straight (the maximum = solid ink), no 1053, and `version_info` as resource 1057. `padded`
|
||||
/// lays DisplayInfo out in the 14-byte records of 0.1.0 to 0.7.0.
|
||||
fn as_earlier_photocraft_saved(doc: &Document, version_info: Vec<u8>, padded: bool) -> Vec<u8> {
|
||||
let mut file = crate::psd_export::document_to_psd(doc);
|
||||
assert!(!file.merged_has_alpha());
|
||||
file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS);
|
||||
for r in &mut file.resources {
|
||||
match r.id {
|
||||
ids::VERSION_INFO => r.data = version_info.clone(),
|
||||
crate::channel_map::DISPLAY_INFO if padded => {
|
||||
let (version, records) = r.data.split_at(4);
|
||||
r.data = version.iter().copied().chain(records.chunks(13).flat_map(|rec| rec.iter().copied().chain([0]))).collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut planes = file.decode_merged().unwrap();
|
||||
let plane = planes.len() / usize::from(file.header.channels);
|
||||
for (k, c) in doc.channels.iter().enumerate() {
|
||||
if c.spot.is_some() {
|
||||
// At 8 and 16 bits the complement of a sample is that of each of its bytes.
|
||||
planes[(3 + k) * plane..(4 + k) * plane].iter_mut().for_each(|b| *b = !*b);
|
||||
}
|
||||
}
|
||||
file.image_data = ImageData::encode(Compression::Raw, &planes, &file.header).unwrap();
|
||||
file.to_bytes().unwrap()
|
||||
}
|
||||
|
||||
/// PSDs that PhotoCraft 0.1.0 to 0.16.0 saved with spot channels store them straight. They
|
||||
/// open with the ink they had in those versions (which read every plane straight), whichever
|
||||
/// 1057 they carry. Saved again, they get Photoshop's polarity and a 1053, and reopen with the
|
||||
/// same ink. (A 0.1.0 to 0.7.0 file's padded records misread every channel's options after
|
||||
/// the first, here Spot B's kind, as those versions' successors always have; its ink is kept.)
|
||||
#[test]
|
||||
fn spot_planes_from_earlier_photocraft_keep_their_ink() {
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
let doc = channels_document(depth);
|
||||
let cases = [
|
||||
("0.1.0 to 0.7.0", version_info_cs6(true), true),
|
||||
("0.8.2 on", version_info_2026(true), false),
|
||||
("0.8.2 on, Maximize Compatibility off", version_info_2026(false), false),
|
||||
("0.1.0 to 0.7.0, 13-byte records", version_info_cs6(true), false),
|
||||
];
|
||||
for (what, version_info, padded) in cases {
|
||||
let bytes = as_earlier_photocraft_saved(&doc, version_info, padded);
|
||||
assert!(earlier_photocraft_spot_planes(&PsdFile::from_bytes(&bytes).unwrap()), "{what}");
|
||||
let opened = crate::import("old.psd", &bytes).unwrap().document;
|
||||
assert_eq!(ink(&opened), ink(&doc), "{depth:?} {what}: the ink earlier versions opened");
|
||||
assert!(spots(&opened)[0], "{depth:?} {what}");
|
||||
if !padded {
|
||||
assert_eq!(spots(&opened), spots(&doc), "{depth:?} {what}");
|
||||
}
|
||||
let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap();
|
||||
let file = PsdFile::from_bytes(&saved.bytes).unwrap();
|
||||
assert!(file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_some() && !earlier_photocraft_spot_planes(&file), "{what}");
|
||||
let again = crate::import("again.psd", &saved.bytes).unwrap().document;
|
||||
assert_eq!(ink(&again), ink(&doc), "{depth:?} {what}: saved again");
|
||||
assert_eq!(spots(&again), spots(&opened), "{depth:?} {what}: saved again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 0.1.0 to 0.7.0 file whose first extra channel is an alpha channel, spots after it. In its
|
||||
/// 14-byte DisplayInfo records the spots' kind bytes sit where a 13-byte reader (main's, and
|
||||
/// this one's) doesn't look, so only the padded reading finds a spot for the rule. Every plane
|
||||
/// is then read straight, as those versions read it, and keeps its ink. The 13-byte reader
|
||||
/// opens each channel's options from shifted bytes, as on main: here every channel opens as
|
||||
/// an alpha channel. A spot of 2 % solidity puts a 2 where that reader looks for the kind, so
|
||||
/// that channel opens as a spot: without the rule its plane would be inverted. Saved again and
|
||||
/// reopened, every channel keeps its ink and kind.
|
||||
#[test]
|
||||
fn an_early_file_with_an_alpha_channel_first_keeps_its_ink() {
|
||||
for solidity in [0.0, 0.02] {
|
||||
let mut doc = channels_document(SampleType::U8);
|
||||
doc.channels.swap(0, 1);
|
||||
doc.channels[1].spot = Some((photocraft_color::Color::rgb(0.0, 0.627, 1.0), solidity));
|
||||
let names: Vec<&str> = doc.channels.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["Alpha 1", "Spot A", "Spot B"]);
|
||||
let bytes = as_earlier_photocraft_saved(&doc, version_info_cs6(true), true);
|
||||
let file = PsdFile::from_bytes(&bytes).unwrap();
|
||||
let info = &file.resource(crate::channel_map::DISPLAY_INFO).unwrap().data;
|
||||
let misread = solidity > 0.0;
|
||||
assert_eq!(crate::channel_map::spot_flags(info, true, 0), [false, misread, false], "{solidity}: 13-byte records");
|
||||
assert!(earlier_photocraft_spot_planes(&file), "{solidity}: found in the 14-byte records");
|
||||
let opened = crate::import("old.psd", &bytes).unwrap().document;
|
||||
assert_eq!(ink(&opened), ink(&doc), "{solidity}: the ink 0.7.0 opened");
|
||||
assert_eq!(spots(&opened), [false, misread, false], "{solidity}: kinds as main reads them");
|
||||
let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap();
|
||||
let again = crate::import("again.psd", &saved.bytes).unwrap().document;
|
||||
assert_eq!(ink(&again), ink(&doc), "{solidity}: saved again");
|
||||
assert_eq!(spots(&again), spots(&opened), "{solidity}: saved again");
|
||||
}
|
||||
}
|
||||
|
||||
/// A file with spots and no 1053 that no earlier PhotoCraft wrote is read in Photoshop's
|
||||
/// polarity (0 = solid ink). psd-tools' `cmyk-spot.psd` is one: its 1057 names psd-tools as
|
||||
/// writer and reader. So is a file with no 1057, with Photoshop CS6's names without the NUL,
|
||||
/// or with bytes after PhotoCraft's.
|
||||
#[test]
|
||||
fn other_files_without_alpha_identifiers_use_photoshop_polarity() {
|
||||
let doc = channels_document(SampleType::U8);
|
||||
let mut trailing = version_info_2026(true);
|
||||
trailing.extend([0, 0]);
|
||||
let cases = [
|
||||
("psd-tools", Some(version_info(UnicodeString::new("psd-tools 1.10.0"), UnicodeString::new("psd-tools 1.10.0"), true))),
|
||||
("no 1057", None),
|
||||
("CS6 without a NUL", Some(version_info(UnicodeString::new("Adobe Photoshop"), UnicodeString::new("Adobe Photoshop CS6"), true))),
|
||||
("bytes after PhotoCraft's 1057", Some(trailing)),
|
||||
];
|
||||
for (what, info) in cases {
|
||||
// Photoshop's polarity, as this version writes it, without 1053.
|
||||
let mut file = crate::psd_export::document_to_psd(&doc);
|
||||
file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS && r.id != ids::VERSION_INFO);
|
||||
file.resources.extend(info.map(|d| ImageResource::new(ids::VERSION_INFO, d)));
|
||||
let bytes = file.to_bytes().unwrap();
|
||||
assert!(!earlier_photocraft_spot_planes(&PsdFile::from_bytes(&bytes).unwrap()), "{what}");
|
||||
let opened = crate::import("x.psd", &bytes).unwrap().document;
|
||||
assert_eq!(ink(&opened), ink(&doc), "{what}");
|
||||
assert_eq!(spots(&opened), spots(&doc), "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule needs a spot in DisplayInfo and no 1053, besides PhotoCraft's 1057.
|
||||
#[test]
|
||||
fn the_earlier_photocraft_rule_needs_a_spot_and_no_alpha_identifiers() {
|
||||
let mut doc = channels_document(SampleType::U8);
|
||||
let old = PsdFile::from_bytes(&as_earlier_photocraft_saved(&doc, version_info_2026(true), false)).unwrap();
|
||||
assert!(earlier_photocraft_spot_planes(&old));
|
||||
let mut with_ids = old.clone();
|
||||
with_ids.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, 3)));
|
||||
assert!(!earlier_photocraft_spot_planes(&with_ids), "a 1053");
|
||||
let mut no_info = old.clone();
|
||||
no_info.resources.retain(|r| r.id != crate::channel_map::DISPLAY_INFO);
|
||||
assert!(!earlier_photocraft_spot_planes(&no_info), "no DisplayInfo");
|
||||
doc.channels.retain(|c| c.spot.is_none());
|
||||
let alpha_only = PsdFile::from_bytes(&as_earlier_photocraft_saved(&doc, version_info_2026(true), false)).unwrap();
|
||||
assert!(!earlier_photocraft_spot_planes(&alpha_only), "alpha channels only");
|
||||
}
|
||||
|
||||
/// `doc` as an earlier PhotoCraft saved a Photoshop file it had opened with the first `kept`
|
||||
/// of `doc`'s channels: it wrote back the Photoshop file's 1053, `stale_ids`, unchanged and
|
||||
/// those channels' planes as Photoshop stored them (it had read and written them straight),
|
||||
/// and stored the channels added after them straight.
|
||||
fn as_earlier_photocraft_resaved(doc: &Document, stale_ids: &[u32], kept: usize) -> Vec<u8> {
|
||||
let mut file = crate::psd_export::document_to_psd(doc);
|
||||
file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS && r.id != ids::VERSION_INFO);
|
||||
file.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, stale_ids.iter().flat_map(|id| id.to_be_bytes()).collect()));
|
||||
file.resources.push(ImageResource::new(ids::VERSION_INFO, version_info_2026(true)));
|
||||
let mut planes = file.decode_merged().unwrap();
|
||||
let plane = planes.len() / usize::from(file.header.channels);
|
||||
let first = usize::from(file.header.channels) - doc.channels.len();
|
||||
for (k, c) in doc.channels.iter().enumerate().skip(kept) {
|
||||
if c.spot.is_some() {
|
||||
planes[(first + k) * plane..(first + k + 1) * plane].iter_mut().for_each(|b| *b = !*b);
|
||||
}
|
||||
}
|
||||
file.image_data = ImageData::encode(Compression::Raw, &planes, &file.header).unwrap();
|
||||
file.to_bytes().unwrap()
|
||||
}
|
||||
|
||||
/// An earlier PhotoCraft kept the 1053 of a Photoshop file it opened and wrote it back
|
||||
/// unchanged, so a spot channel added there sits behind a 1053 that doesn't list it: a
|
||||
/// transparent Photoshop document's `[0]` (the white plate of a DTF print, added before
|
||||
/// saving), the `[0, 3]` of one with a spot channel already, or a `[0]` kept through a
|
||||
/// flatten that removed the transparency. The added spot is stored straight and keeps its
|
||||
/// ink; the Photoshop file's own spot keeps Photoshop's polarity. Saved again, both reopen
|
||||
/// the same.
|
||||
#[test]
|
||||
fn a_spot_added_behind_a_kept_alpha_identifiers_resource_keeps_its_ink() {
|
||||
let transparent = |names: &[&str]| {
|
||||
let mut doc = channels_document(SampleType::U8);
|
||||
doc.layers = vec![Layer::raster("Layer 1", doc.pixel_format())];
|
||||
doc.channels.retain(|c| names.contains(&c.name.as_str()));
|
||||
doc
|
||||
};
|
||||
let mut flattened = channels_document(SampleType::U8);
|
||||
flattened.channels.retain(|c| c.name == "Spot A");
|
||||
let cases = [
|
||||
("transparent, one spot added", transparent(&["Spot A"]), vec![0], 0),
|
||||
("transparent, a Photoshop spot and one added", transparent(&["Spot A", "Spot B"]), vec![0, 3], 1),
|
||||
("flattened, one spot added", flattened, vec![0], 0),
|
||||
];
|
||||
for (what, doc, stale_ids, kept) in cases {
|
||||
let bytes = as_earlier_photocraft_resaved(&doc, &stale_ids, kept);
|
||||
let file = PsdFile::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(file.merged_has_alpha(), what.starts_with("transparent"), "{what}");
|
||||
assert!(earlier_photocraft_spot_planes(&file), "{what}");
|
||||
let opened = crate::import("old.psd", &bytes).unwrap().document;
|
||||
assert_eq!(ink(&opened), ink(&doc), "{what}: the ink earlier versions opened");
|
||||
assert_eq!(spots(&opened), spots(&doc), "{what}");
|
||||
let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap();
|
||||
let again = crate::import("again.psd", &saved.bytes).unwrap().document;
|
||||
assert_eq!(ink(&again), ink(&doc), "{what}: saved again");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 1053 that lists every extra channel, as Photoshop and this version write it, keeps the
|
||||
/// file in Photoshop's polarity, with PhotoCraft's (Photoshop 2026's) 1057 too: a transparent
|
||||
/// file with a spot, and Photoshop's own `[0]` in a file without a transparency plane.
|
||||
#[test]
|
||||
fn a_complete_alpha_identifiers_resource_keeps_photoshop_polarity() {
|
||||
let mut doc = channels_document(SampleType::U8);
|
||||
doc.layers = vec![Layer::raster("Layer 1", doc.pixel_format())];
|
||||
let bytes = as_earlier_photocraft_resaved(&doc, &[0, 3, 4, 5], 3);
|
||||
let file = PsdFile::from_bytes(&bytes).unwrap();
|
||||
assert!(file.merged_has_alpha());
|
||||
assert!(!earlier_photocraft_spot_planes(&file));
|
||||
assert_eq!(ink(&crate::import("ps.psd", &bytes).unwrap().document), ink(&doc));
|
||||
let mut opaque = channels_document(SampleType::U8);
|
||||
opaque.channels.clear();
|
||||
let mut file = PsdFile::from_bytes(&as_earlier_photocraft_resaved(&opaque, &[0], 0)).unwrap();
|
||||
file.resources.retain(|r| r.id != crate::channel_map::DISPLAY_INFO);
|
||||
file.resources.push(ImageResource::new(
|
||||
crate::channel_map::DISPLAY_INFO,
|
||||
crate::channel_map::display_info(&channels_document(SampleType::U8).channels.iter().collect::<Vec<_>>(), false),
|
||||
));
|
||||
assert_eq!(photoshop_identified_channels(&file), 0, "0 is the transparency's");
|
||||
assert!(!earlier_photocraft_spot_planes(&file), "no channel left unlisted");
|
||||
}
|
||||
}
|
||||
|
||||
+42
-15
@@ -280,25 +280,52 @@ pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Resul
|
||||
let summary = format!("{DEVELOPED_NOTE} ({} demosaic, as-shot white balance)", opts.demosaic.id());
|
||||
developed_document(name, bytes, format, dev, &summary)
|
||||
}
|
||||
Err(RawError::Unsupported(reason)) => match photocraft_raw::embedded_preview(bytes) {
|
||||
Some(p) => {
|
||||
let img = upright_preview(bytes, p.jpeg, &opts.limits)?;
|
||||
let mut r = image_to_document(name, &img)?;
|
||||
r.warnings.insert(
|
||||
0,
|
||||
format!(
|
||||
"{format}: {reason} is not supported yet; opened the camera's embedded {}x{} JPEG preview instead (8-bit, not the raw sensor data)",
|
||||
p.width, p.height
|
||||
),
|
||||
);
|
||||
Ok(r)
|
||||
}
|
||||
None => Err(IoError::Raw(RawError::Unsupported(reason))),
|
||||
},
|
||||
Err(RawError::Unsupported(reason)) => {
|
||||
let jpeg = photocraft_raw::embedded_preview(bytes);
|
||||
// A DNG 1.7 converter keeps only its small thumbnail as JPEG and writes the larger
|
||||
// previews as JPEG XL, like the raw data (#8125): open the largest of either kind.
|
||||
let jpeg_area = jpeg.map_or(0, |p| u64::from(p.width) * u64::from(p.height));
|
||||
let jxl = photocraft_raw::embedded_jxl_preview(bytes, &opts.limits).filter(|p| u64::from(p.width) * u64::from(p.height) > jpeg_area);
|
||||
let (img, kind, (w, h)) = match (jxl, jpeg) {
|
||||
(Some(p), _) => {
|
||||
let size = (p.width, p.height);
|
||||
(jxl_preview_image(bytes, p)?, "JPEG XL", size)
|
||||
}
|
||||
(None, Some(p)) => (upright_preview(bytes, p.jpeg, &opts.limits)?, "JPEG", (p.width, p.height)),
|
||||
(None, None) => return Err(IoError::Raw(RawError::Unsupported(reason))),
|
||||
};
|
||||
let mut r = image_to_document(name, &img)?;
|
||||
let depth = if img.sample_type() == SampleType::U8 { "8-bit" } else { "16-bit" };
|
||||
r.warnings.insert(
|
||||
0,
|
||||
format!(
|
||||
"{format}: {reason} is not supported yet; opened the camera's embedded {w}x{h} {kind} preview instead ({depth}, not the raw sensor data)"
|
||||
),
|
||||
);
|
||||
Ok(r)
|
||||
}
|
||||
Err(e) => Err(IoError::Raw(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoded JPEG XL preview as an image: in its stated colour space (sRGB when it states
|
||||
/// none), turned upright by the raw's orientation (DNG previews are stored as the raw is).
|
||||
fn jxl_preview_image(raw: &[u8], p: photocraft_raw::DecodedPreview) -> Result<Image, IoError> {
|
||||
let img = if p.bits == 8 {
|
||||
Image::from_u8(p.width, p.height, ChannelLayout::Rgb, p.rgb.iter().map(|&v| v.min(255) as u8).collect())?
|
||||
} else {
|
||||
let max = f64::from((1u32 << p.bits.clamp(1, 16)) - 1);
|
||||
let rgb: Vec<u16> = p.rgb.iter().map(|&v| (f64::from(v).min(max) * 65535.0 / max).round() as u16).collect();
|
||||
Image::from_u16(p.width, p.height, ChannelLayout::Rgb, &rgb)?
|
||||
};
|
||||
let icc = match p.color_space {
|
||||
Some(PreviewColorSpace::AdobeRgb) => Some(Builtin::AdobeRgbCompat.profile().to_bytes().to_vec()),
|
||||
Some(PreviewColorSpace::ProPhoto) => Some(Builtin::ProPhotoCompat.profile().to_bytes().to_vec()),
|
||||
Some(PreviewColorSpace::Srgb) | None => None,
|
||||
};
|
||||
Ok(img.with_icc(icc).oriented(photocraft_raw::orientation(raw).unwrap_or(1))?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+664
-17
@@ -11,11 +11,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CSample};
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{AlphaChannel, Document};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_psd::resources::ids;
|
||||
use photocraft_psd::tiff::{ByteOrder, ImageSourceData, resources_from_bytes, resources_to_bytes};
|
||||
use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, ImageResource, ResolutionInfo};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use crate::flat::{csample, encode_image, image_to_document, layout_for, single_layer, try_buffer};
|
||||
use crate::psd_export::PsdExportOptions;
|
||||
@@ -101,13 +103,36 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result<I
|
||||
CSample::F16 | CSample::F32 => 32,
|
||||
};
|
||||
let (w, h) = img.dimensions();
|
||||
let channels = u16::try_from(img.layout().channels()).map_err(|_| IoError::Unsupported("too many channels".into()))?;
|
||||
let mut data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?;
|
||||
let bps = img.sample_type().bytes();
|
||||
let plane_len = (w as usize).saturating_mul(h as usize).saturating_mul(bps);
|
||||
let mut added = 0usize;
|
||||
for e in img.meta.extra_samples.iter().take(codecs::MAX_EXTRA_SAMPLES) {
|
||||
match native_plane_to_be(&e.data, bps) {
|
||||
Ok(be) if be.len() == plane_len => {
|
||||
data.extend(be);
|
||||
added += 1;
|
||||
}
|
||||
_ => warnings.push("an extra TIFF sample did not match the image and was dropped".into()),
|
||||
}
|
||||
}
|
||||
let channels = u16::try_from(img.layout().channels().saturating_add(added)).map_err(|_| IoError::Unsupported("too many channels".into()))?;
|
||||
let header = Header::new(isd.version, w, h, channels, depth, mode);
|
||||
let data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?;
|
||||
let image_data = ImageData { compression: Compression::Raw, data };
|
||||
let file = isd.into_psd(header, resources, image_data);
|
||||
let mut file = isd.into_psd(header, resources, image_data);
|
||||
// Empty `Patt` and the default filter mask are written so Photoshop's block list matches
|
||||
// a file it saved. They carry nothing the document stores, so they are not kept: a
|
||||
// round trip would otherwise grow `metadata.psd_global_blocks` on every open.
|
||||
file.global_blocks.retain(|b| {
|
||||
let empty_pattern = matches!(&b.key, b"Patt" | b"Pat2" | b"Pat3") && b.data.is_empty();
|
||||
let default_filter_mask = &b.key == b"FMsk" && b.data.as_slice() == FILTER_MASK_DEFAULT;
|
||||
!empty_pattern && !default_filter_mask
|
||||
});
|
||||
// The same nesting limit as opening a PSD (#2940).
|
||||
crate::psd_import::check_group_depth(&file)?;
|
||||
// Spot planes are read in Photoshop's polarity (0 = solid ink), as from a PSD. The importer's
|
||||
// rule for PSDs from earlier PhotoCraft versions never applies: those versions stored no
|
||||
// extra samples in a TIFF, and a TIFF with them carries 1053.
|
||||
let (mut doc, w) = psd_to_document(&file);
|
||||
warnings.extend(w);
|
||||
doc.name = name.to_string();
|
||||
@@ -200,19 +225,24 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
let big = doc.size.width > 30_000 || doc.size.height > 30_000;
|
||||
// The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF
|
||||
// keeps straight alpha) and uncompressed (its planes are interleaved below).
|
||||
// The PSD exporter renders the composite we need for the TIFF itself, uncompressed (its
|
||||
// planes are interleaved below). A spot (or any user channel) is saved the way Photoshop's
|
||||
// TIFF dialog does with "Save Transparency" off: the composite is colour plus those
|
||||
// channels, matted on white, and each layer keeps its own transparency. With no user
|
||||
// channel the composite stays unmatted, straight RGBA, which is what a plain transparent
|
||||
// TIFF already round-trips as.
|
||||
// Layer data is written PSD-style ("Adobe Photoshop Document Data Block", 4-byte lengths), as
|
||||
// Photoshop writes it: Photoshop does not read the PSB-style header ("... Data V0002") in a
|
||||
// TIFF and opens only the composite, as one locked Background. The exporter's size estimate
|
||||
// counts layer planes uncompressed, so a document with many canvas-sized layers passes 2 GB
|
||||
// while its real layer data is a small fraction of that; it is not used here. PSB-style data
|
||||
// is written only past 30000 px, when asked for, or when a length really overflows 32 bits.
|
||||
let user_channels = !doc.channels.is_empty() || doc.quick_mask.is_some();
|
||||
let force_psb = opts.force_psb || big;
|
||||
let psd_opts = PsdExportOptions {
|
||||
force_psb,
|
||||
size_limit_psb: false,
|
||||
merged_matte: false,
|
||||
merged_matte: user_channels,
|
||||
merged_compression: Compression::Raw,
|
||||
maximize_compatibility: true,
|
||||
document_path: opts.document_path.clone(),
|
||||
@@ -234,7 +264,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
|
||||
ctl.check().map_err(|_| IoError::Cancelled)?;
|
||||
warnings.extend(w);
|
||||
let order = if codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big };
|
||||
let (layers, w) = match ImageSourceData::from_psd(&file).to_bytes(order) {
|
||||
let (layers, w) = match tiff_image_source(doc, &file).to_bytes(order) {
|
||||
Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => {
|
||||
let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts };
|
||||
let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl));
|
||||
@@ -242,7 +272,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
|
||||
warnings.extend(w);
|
||||
warnings.push("layer data exceeds 4 GB; written with PSB-sized lengths, which some readers do not open".into());
|
||||
file = psb;
|
||||
ImageSourceData::from_psd(&file).to_bytes(order)?
|
||||
tiff_image_source(doc, &file).to_bytes(order)?
|
||||
}
|
||||
r => r?,
|
||||
};
|
||||
@@ -252,24 +282,60 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
|
||||
// The profile and XMP are written as TIFF tags, not as resources. EXIF stays a resource
|
||||
// (1058), which the PSD importer reads back unchanged; a block rebuilt from the TIFF's own
|
||||
// Exif directory keeps only the Exif, GPS and descriptive tags.
|
||||
let resources: Vec<ImageResource> = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect();
|
||||
let resources = resources_to_bytes(&resources)?;
|
||||
let mut resources: Vec<ImageResource> = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect();
|
||||
|
||||
let fmt = doc.pixel_format();
|
||||
let cc = fmt.mode.color_channels();
|
||||
let has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha);
|
||||
let channels = cc + usize::from(has_alpha);
|
||||
let extra = usize::from(file.header.channels).saturating_sub(channels);
|
||||
if extra > 0 {
|
||||
warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one transparency channel only)"));
|
||||
let extra_n = usize::from(file.header.channels).saturating_sub(channels);
|
||||
let kept = extra_n.min(codecs::MAX_EXTRA_SAMPLES);
|
||||
if extra_n > kept {
|
||||
warnings.push(format!("{extra_n} extra channels; TIFF keeps the first {kept}"));
|
||||
}
|
||||
// With spot or alpha channels, the IFD image is colour + those channels. The merged
|
||||
// transparency plane stays out of the IFD (see above); a file with no extra channels keeps
|
||||
// it, so a plain transparent TIFF still opens with alpha.
|
||||
let write_alpha = has_alpha && kept == 0;
|
||||
// One identifier per extra sample actually stored. The PSD model's 1053 lists the merged
|
||||
// transparency too, which the IFD leaves out next to extra samples, so it is not copied.
|
||||
resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS);
|
||||
if kept > 0 {
|
||||
resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, kept)));
|
||||
}
|
||||
// Resource 1022 names a channel of the PSD merged image, which counts the transparency plane.
|
||||
// The IFD below does not, so the id moves back by one.
|
||||
if has_alpha
|
||||
&& kept > 0
|
||||
&& let Some(r) = resources.iter_mut().find(|r| r.id == crate::channel_map::QUICK_MASK_INFO)
|
||||
&& r.data.len() >= 2
|
||||
{
|
||||
let id = u16::from_be_bytes([r.data[0], r.data[1]]).saturating_sub(1);
|
||||
let be = id.to_be_bytes();
|
||||
r.data[0] = be[0];
|
||||
r.data[1] = be[1];
|
||||
}
|
||||
// The name and DisplayInfo resources list the merged transparency plane first. The IFD
|
||||
// leaves that plane out, so the resources must too: Photoshop pairs each name with the
|
||||
// next sample past the colour channels.
|
||||
if has_alpha && !write_alpha {
|
||||
drop_merged_transparency(&mut resources);
|
||||
}
|
||||
// Past MAX_EXTRA_SAMPLES the TIFF stores only the first `kept` channels: the name and
|
||||
// DisplayInfo resources describe just those, and a Quick Mask among the rest is dropped.
|
||||
if extra_n > kept {
|
||||
keep_first_channels(&mut resources, kept, cc);
|
||||
}
|
||||
let resources = resources_to_bytes(&resources)?;
|
||||
let ifd_channels = cc + usize::from(write_alpha);
|
||||
let sample = csample(fmt.sample);
|
||||
let (w, h) = (doc.size.width, doc.size.height);
|
||||
let cmyk = fmt.mode == ColorMode::Cmyk;
|
||||
let data = interleave(&file.image_data.data, w, h, channels, cc, sample, cmyk)?;
|
||||
let data = interleave(&file.image_data.data, w, h, ifd_channels, cc, sample, cmyk)?;
|
||||
let extras = native_extra_planes(&file.image_data.data, w, h, channels, kept, sample)?;
|
||||
ctl.check().map_err(|_| IoError::Cancelled)?;
|
||||
ctl.progress(TIFF_COMPOSITE_DONE);
|
||||
let layout = layout_for(fmt.mode, has_alpha);
|
||||
let layout = layout_for(fmt.mode, write_alpha);
|
||||
let mut img = Image::from_raw(w, h, layout, sample, data)?;
|
||||
img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec());
|
||||
img.meta = codecs::Metadata {
|
||||
@@ -282,11 +348,180 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
|
||||
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
|
||||
photoshop_resources: Some(resources),
|
||||
photoshop_layers: Some(layers),
|
||||
extra_samples: extras,
|
||||
..Default::default()
|
||||
};
|
||||
encode_image(&img, Format::Tiff, opts, warnings)
|
||||
}
|
||||
|
||||
/// The layer data (`ImageSourceData`) of a layered TIFF saved from `file`, adjusted the way
|
||||
/// Photoshop writes it.
|
||||
fn tiff_image_source(doc: &Document, file: &photocraft_psd::PsdFile) -> ImageSourceData {
|
||||
// Photoshop pairs alpha-name resources with samples past the colour channels and ignores
|
||||
// the ExtraSamples kind. The merged transparency must not occupy that first slot when a
|
||||
// spot is also stored. Layer transparency stays in each layer's channel −1.
|
||||
let mut isd = ImageSourceData::from_psd(file);
|
||||
let spot_extras = file.header.channels as usize > {
|
||||
let cc = doc.pixel_format().mode.color_channels();
|
||||
let alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha);
|
||||
cc + usize::from(alpha)
|
||||
};
|
||||
// Positive layer count: the merged image's first extra sample is not transparency
|
||||
// (Photoshop's own spot TIFF does this; a negative count would claim the spot plane is alpha).
|
||||
if spot_extras && let Some(info) = &mut isd.layer_info {
|
||||
info.merged_alpha = false;
|
||||
}
|
||||
// A merged-transparency marker kept from an opened file (Photoshop's `MTrn`) would make the
|
||||
// first spot read back as transparency. The PSD export already drops it over an opaque
|
||||
// composite; a transparent one keeps it there, but the TIFF leaves that sample out here.
|
||||
if spot_extras {
|
||||
isd.global_blocks.retain(|b| !b.is_merged_transparency_marker());
|
||||
}
|
||||
if let Some(info) = &mut isd.layer_info {
|
||||
for layer in &mut info.layers {
|
||||
// Photoshop sets flag bit 3 on every record it writes, group dividers included (299 of
|
||||
// the 305 dividers in the corpus's Photoshop files). PhotoCraft's PSDs set it on
|
||||
// layers only, unchanged here.
|
||||
layer.flags.0 |= photocraft_psd::layer::LayerFlags::BIT4_USEFUL;
|
||||
photoshop_layer_blocks(&mut layer.blocks);
|
||||
}
|
||||
}
|
||||
// Photoshop reads a fixed 14-byte user-mask record from `LMsk` and an empty `Patt`
|
||||
// before the filter mask. A zero-length `LMsk` is what makes it report that the
|
||||
// layer data could not be read.
|
||||
if let Some(mask) = &mut isd.global_layer_mask
|
||||
&& mask.data.len() < 14
|
||||
{
|
||||
*mask = photocraft_psd::GlobalLayerMask::photoshop_default();
|
||||
}
|
||||
if !isd.global_blocks.iter().any(|b| matches!(&b.key, b"Patt" | b"Pat2" | b"Pat3")) {
|
||||
isd.global_blocks.insert(0, photocraft_psd::TaggedBlock::new(*b"Patt", Vec::new()));
|
||||
}
|
||||
if !isd.global_blocks.iter().any(|b| &b.key == b"FMsk") {
|
||||
isd.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"FMsk", FILTER_MASK_DEFAULT.to_vec()));
|
||||
}
|
||||
isd
|
||||
}
|
||||
|
||||
/// Removes the leading transparency entry from the channel-name resources (1006, 1045) and
|
||||
/// from DisplayInfo (1077). Each is left unchanged when it is too short to hold that entry.
|
||||
fn drop_merged_transparency(resources: &mut [ImageResource]) {
|
||||
for r in resources {
|
||||
r.data = match r.id {
|
||||
1006 => drop_first_pascal(&r.data),
|
||||
1045 => drop_first_unicode(&r.data),
|
||||
id if id == crate::channel_map::DISPLAY_INFO => drop_first_display_record(&r.data),
|
||||
_ => continue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn drop_first_pascal(data: &[u8]) -> Vec<u8> {
|
||||
let Some(&n) = data.first() else { return data.to_vec() };
|
||||
let end = 1usize.saturating_add(usize::from(n));
|
||||
data.get(end..).unwrap_or(data).to_vec()
|
||||
}
|
||||
|
||||
fn drop_first_unicode(data: &[u8]) -> Vec<u8> {
|
||||
let Some(nbe) = data.get(..4) else { return data.to_vec() };
|
||||
let n = u32::from_be_bytes([nbe[0], nbe[1], nbe[2], nbe[3]]) as usize;
|
||||
let end = 4usize.saturating_add(n.saturating_mul(2));
|
||||
data.get(end..).unwrap_or(data).to_vec()
|
||||
}
|
||||
|
||||
/// Trims the channel-name resources (1006, 1045) and DisplayInfo (1077) to their first `kept`
|
||||
/// entries, and drops Quick Mask info (1022) when its channel is not one of the `kept` extra
|
||||
/// samples, which start at sample `first_extra`. A resource too short for `kept` entries is
|
||||
/// left as it is.
|
||||
fn keep_first_channels(resources: &mut Vec<ImageResource>, kept: usize, first_extra: usize) {
|
||||
for r in resources.iter_mut() {
|
||||
r.data = match r.id {
|
||||
1006 => first_pascal_names(&r.data, kept),
|
||||
1045 => first_unicode_names(&r.data, kept),
|
||||
id if id == crate::channel_map::DISPLAY_INFO => first_display_records(&r.data, kept),
|
||||
_ => continue,
|
||||
};
|
||||
}
|
||||
let end = first_extra.saturating_add(kept);
|
||||
resources.retain(|r| r.id != crate::channel_map::QUICK_MASK_INFO || r.data.get(..2).is_none_or(|b| usize::from(u16::from_be_bytes([b[0], b[1]])) < end));
|
||||
}
|
||||
|
||||
fn first_pascal_names(data: &[u8], n: usize) -> Vec<u8> {
|
||||
let mut at = 0usize;
|
||||
for _ in 0..n {
|
||||
let Some(&len) = data.get(at) else { return data.to_vec() };
|
||||
at = at.saturating_add(1).saturating_add(usize::from(len));
|
||||
}
|
||||
data.get(..at).unwrap_or(data).to_vec()
|
||||
}
|
||||
|
||||
fn first_unicode_names(data: &[u8], n: usize) -> Vec<u8> {
|
||||
let mut at = 0usize;
|
||||
for _ in 0..n {
|
||||
let Some(b) = data.get(at..at.saturating_add(4)) else { return data.to_vec() };
|
||||
let len = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize;
|
||||
at = at.saturating_add(4).saturating_add(len.saturating_mul(2));
|
||||
}
|
||||
data.get(..at).unwrap_or(data).to_vec()
|
||||
}
|
||||
|
||||
/// Resource 1077: a 4-byte version, then 13-byte records. Keeps the first `n` records.
|
||||
fn first_display_records(data: &[u8], n: usize) -> Vec<u8> {
|
||||
let end = 4usize.saturating_add(n.saturating_mul(13));
|
||||
data.get(..end).unwrap_or(data).to_vec()
|
||||
}
|
||||
|
||||
/// Resource 1077: a 4-byte version, then 13-byte records. Drops the first record.
|
||||
fn drop_first_display_record(data: &[u8]) -> Vec<u8> {
|
||||
const RECORD: usize = 13;
|
||||
if data.len() < 4 + RECORD {
|
||||
return data.to_vec();
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len() - RECORD);
|
||||
out.extend_from_slice(&data[..4]);
|
||||
out.extend_from_slice(&data[4 + RECORD..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Filter-mask block (`FMsk`) Photoshop writes next to a layered TIFF: the same red 50%
|
||||
/// overlay as [`photocraft_psd::GlobalLayerMask::photoshop_default`], without the kind byte.
|
||||
const FILTER_MASK_DEFAULT: &[u8] = &[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50];
|
||||
|
||||
/// Blocks Photoshop writes on every layer of a layered TIFF, when the layer does not
|
||||
/// already carry them. Defaults match a plain pixel layer (clipped-as-group on, nothing
|
||||
/// locked, no label).
|
||||
fn photoshop_layer_blocks(blocks: &mut Vec<photocraft_psd::TaggedBlock>) {
|
||||
let has = |k: &[u8; 4]| blocks.iter().any(|b| &b.key == k);
|
||||
let mut add = Vec::new();
|
||||
if !has(b"lnsr") {
|
||||
add.push(photocraft_psd::TaggedBlock::name_source(*b"layr"));
|
||||
}
|
||||
if !has(b"clbl") {
|
||||
add.push(photocraft_psd::TaggedBlock::blend_clipped_as_group(true));
|
||||
}
|
||||
if !has(b"infx") {
|
||||
add.push(photocraft_psd::TaggedBlock::blend_interior_elements(false));
|
||||
}
|
||||
if !has(b"knko") {
|
||||
add.push(photocraft_psd::TaggedBlock::knockout(0));
|
||||
}
|
||||
if !has(b"lspf") {
|
||||
add.push(photocraft_psd::TaggedBlock::protection(0));
|
||||
}
|
||||
if !has(b"lclr") {
|
||||
add.push(photocraft_psd::TaggedBlock::sheet_color(0));
|
||||
}
|
||||
let at = blocks
|
||||
.iter()
|
||||
.position(|b| &b.key == b"lyid")
|
||||
.map(|i| i + 1)
|
||||
.or_else(|| blocks.iter().position(|b| &b.key == b"luni").map(|i| i + 1))
|
||||
.unwrap_or(blocks.len());
|
||||
for (i, block) in add.into_iter().enumerate() {
|
||||
blocks.insert(at.saturating_add(i), block);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the blocks a document kept verbatim from a little-endian TIFF because their layout
|
||||
/// is unknown ([`photocraft_psd::TaggedBlock::is_foreign_order`]) before `file` is written as a
|
||||
/// PSD: their bytes are in the wrong order for a PSD and cannot be converted. Returns a warning
|
||||
@@ -316,6 +551,418 @@ pub fn strip_foreign_order_blocks(file: &mut photocraft_psd::PsdFile) -> Vec<Str
|
||||
warnings
|
||||
}
|
||||
|
||||
/// Big-endian planar samples (a PSD merged-image plane) to native endian, or the reverse.
|
||||
fn swap_plane(src: &[u8], bps: usize, to_be: bool) -> Result<Vec<u8>, IoError> {
|
||||
if bps == 1 {
|
||||
return Ok(src.to_vec());
|
||||
}
|
||||
if !matches!(bps, 2 | 4) || !src.len().is_multiple_of(bps) {
|
||||
return Err(IoError::Unsupported("an extra channel plane is incomplete".into()));
|
||||
}
|
||||
let mut out = Vec::with_capacity(src.len());
|
||||
for c in src.chunks_exact(bps) {
|
||||
match (bps, to_be, c) {
|
||||
(2, true, [a, b]) => {
|
||||
let v = u16::from_ne_bytes([*a, *b]);
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
(2, false, [a, b]) => {
|
||||
let v = u16::from_be_bytes([*a, *b]);
|
||||
out.extend_from_slice(&v.to_ne_bytes());
|
||||
}
|
||||
(4, true, [a, b, c, d]) => {
|
||||
let v = f32::from_ne_bytes([*a, *b, *c, *d]);
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
(4, false, [a, b, c, d]) => {
|
||||
let v = f32::from_be_bytes([*a, *b, *c, *d]);
|
||||
out.extend_from_slice(&v.to_ne_bytes());
|
||||
}
|
||||
_ => return Err(IoError::Unsupported("an extra channel plane is incomplete".into())),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Native-endian extra-sample plane → big-endian, for the PSD merged image a layered TIFF rebuilds.
|
||||
fn native_plane_to_be(src: &[u8], bps: usize) -> Result<Vec<u8>, IoError> {
|
||||
swap_plane(src, bps, true)
|
||||
}
|
||||
|
||||
/// Extra planes of a PSD merged image (`skip` colour-and-alpha planes already consumed), as
|
||||
/// native-endian TIFF extra samples. A spot plane is already Photoshop ink (0 = solid, max =
|
||||
/// paper); an alpha plane is already straight. Neither is inverted again here.
|
||||
fn native_extra_planes(planes: &[u8], w: u32, h: u32, skip: usize, count: usize, sample: CSample) -> Result<Vec<codecs::ExtraSample>, IoError> {
|
||||
let bps = sample.bytes();
|
||||
let n = (w as usize).checked_mul(h as usize).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?;
|
||||
let plane = n.checked_mul(bps).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?;
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let at = skip.checked_add(i).and_then(|k| k.checked_mul(plane)).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?;
|
||||
let Some(src) = planes.get(at..at.saturating_add(plane)) else {
|
||||
return Err(IoError::Unsupported("the merged image is missing an extra channel".into()));
|
||||
};
|
||||
if src.len() != plane {
|
||||
return Err(IoError::Unsupported("the merged image is missing an extra channel".into()));
|
||||
}
|
||||
out.push(codecs::ExtraSample { kind: 0, data: swap_plane(src, bps, false)? });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One grayscale channel as native-endian samples in `img`'s sample type.
|
||||
fn channel_plane(channel: &AlphaChannel, canvas: Rect, sample: CSample) -> Result<Vec<u8>, String> {
|
||||
let depth = match sample {
|
||||
CSample::U8 => SampleType::U8,
|
||||
CSample::U16 => SampleType::U16,
|
||||
CSample::F16 | CSample::F32 => SampleType::F32,
|
||||
};
|
||||
let fmt = PixelFormat::new(ColorMode::Grayscale, depth, false);
|
||||
let owned;
|
||||
let surface = if channel.surface.format() == fmt {
|
||||
&channel.surface
|
||||
} else {
|
||||
owned = channel.surface.convert(fmt);
|
||||
&owned
|
||||
};
|
||||
let mut bytes = surface.to_interleaved(canvas);
|
||||
let n = (canvas.width() as usize).saturating_mul(canvas.height() as usize);
|
||||
let expect = n.saturating_mul(depth.bytes());
|
||||
if bytes.len() != expect {
|
||||
return Err(format!("channel \"{}\" does not cover the canvas", channel.name));
|
||||
}
|
||||
if channel.spot.is_some() {
|
||||
crate::channel_map::photoshop_spot_plane(&mut bytes, depth);
|
||||
}
|
||||
if sample == CSample::F16 {
|
||||
let mut out = Vec::with_capacity(n.saturating_mul(2));
|
||||
for arr in bytes.as_chunks::<4>().0 {
|
||||
let v = f32::from_ne_bytes(*arr);
|
||||
out.extend_from_slice(&codecs::f16::from_f32(v).to_ne_bytes());
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Composites straight alpha over white and drops it. Photoshop's TIFF save with
|
||||
/// "Save Transparency" off stores that matte in the colour samples and keeps transparency
|
||||
/// only inside each layer. Fully opaque pixels are copied unchanged, so they stay identical
|
||||
/// to a composite that was quantized before the matte. `false` when there is no alpha or
|
||||
/// the buffer is not one sample per channel.
|
||||
fn matte_and_drop_alpha(img: &mut Image) -> bool {
|
||||
if !img.layout().has_alpha() {
|
||||
return false;
|
||||
}
|
||||
let (w, h) = img.dimensions();
|
||||
let sample = img.sample_type();
|
||||
let dst = img.layout().without_alpha();
|
||||
let bps = sample.bytes();
|
||||
let keep = dst.channels().saturating_mul(bps);
|
||||
let stride = img.layout().channels().saturating_mul(bps);
|
||||
if stride == 0 || keep == 0 || bps == 0 || stride <= keep || !img.data().len().is_multiple_of(stride) {
|
||||
return false;
|
||||
}
|
||||
let cmyk = img.layout().is_cmyk();
|
||||
let white_byte: u8 = if cmyk { 0 } else { 255 };
|
||||
let white_u16: u16 = if cmyk { 0 } else { u16::MAX };
|
||||
let white_f: f32 = if cmyk { 0.0 } else { 1.0 };
|
||||
// Lab's white is L* 100 with a* and b* 0, which PhotoCraft holds mid-range (`(0 + 128) / 255`).
|
||||
let lab = img.layout().is_lab();
|
||||
let mut out = Vec::with_capacity(img.data().len() / stride * keep);
|
||||
for px in img.data().chunks_exact(stride) {
|
||||
let Some(color) = px.get(..keep) else { return false };
|
||||
let Some(alpha) = px.get(keep..keep.saturating_add(bps)) else { return false };
|
||||
let ok = if lab {
|
||||
let (Some(l), Some(ab)) = (color.get(..bps), color.get(bps..)) else { return false };
|
||||
matte_pixel(sample, l, alpha, white_byte, white_u16, white_f, &mut out) && matte_pixel(sample, ab, alpha, 128, 128 * 257, 128.0 / 255.0, &mut out)
|
||||
} else {
|
||||
matte_pixel(sample, color, alpha, white_byte, white_u16, white_f, &mut out)
|
||||
};
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let icc = img.icc.clone();
|
||||
let meta = img.meta.clone();
|
||||
let warnings = img.warnings.clone();
|
||||
let Ok(mut next) = Image::from_raw(w, h, dst, sample, out) else { return false };
|
||||
next.icc = icc;
|
||||
next.meta = meta;
|
||||
next.warnings = warnings;
|
||||
*img = next;
|
||||
true
|
||||
}
|
||||
|
||||
/// One pixel of [`matte_and_drop_alpha`]. Opaque samples are copied; a clear pixel becomes
|
||||
/// `white`; anything in between is `c·a + white·(1−a)` in normalized space.
|
||||
fn matte_pixel(sample: CSample, color: &[u8], alpha: &[u8], white_byte: u8, white_u16: u16, white_f: f32, out: &mut Vec<u8>) -> bool {
|
||||
match sample {
|
||||
CSample::U8 => {
|
||||
let Some(&a) = alpha.first() else { return false };
|
||||
if a == 255 {
|
||||
out.extend_from_slice(color);
|
||||
} else if a == 0 {
|
||||
out.resize(out.len().saturating_add(color.len()), white_byte);
|
||||
} else {
|
||||
let af = f32::from(a) / 255.0;
|
||||
for &c in color {
|
||||
let m = crate::pixels::matte(f32::from(c) / 255.0, af, white_f);
|
||||
out.push((m.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
CSample::U16 => {
|
||||
let Some(a) = ne_u16(alpha) else { return false };
|
||||
if a == u16::MAX {
|
||||
out.extend_from_slice(color);
|
||||
} else if a == 0 {
|
||||
for _ in 0..color.len() / 2 {
|
||||
out.extend_from_slice(&white_u16.to_ne_bytes());
|
||||
}
|
||||
} else {
|
||||
let af = f32::from(a) / 65535.0;
|
||||
for pair in color.as_chunks::<2>().0 {
|
||||
let c = u16::from_ne_bytes(*pair);
|
||||
let m = crate::pixels::matte(f32::from(c) / 65535.0, af, white_f);
|
||||
out.extend_from_slice(&((m.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
CSample::F32 => {
|
||||
let Some(a) = ne_f32(alpha) else { return false };
|
||||
if a == 1.0 {
|
||||
out.extend_from_slice(color);
|
||||
} else if a == 0.0 || !a.is_finite() {
|
||||
for _ in 0..color.len() / 4 {
|
||||
out.extend_from_slice(&white_f.to_ne_bytes());
|
||||
}
|
||||
} else {
|
||||
let af = a.clamp(0.0, 1.0);
|
||||
for quad in color.as_chunks::<4>().0 {
|
||||
let m = crate::pixels::matte(f32::from_ne_bytes(*quad), af, white_f);
|
||||
out.extend_from_slice(&m.to_ne_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
CSample::F16 => {
|
||||
let Some(bits) = ne_u16(alpha) else { return false };
|
||||
let a = codecs::f16::from_bits(bits).to_f32();
|
||||
if a == 1.0 {
|
||||
out.extend_from_slice(color);
|
||||
} else if a == 0.0 || !a.is_finite() {
|
||||
let w = codecs::f16::from_f32(white_f).to_bits().to_ne_bytes();
|
||||
for _ in 0..color.len() / 2 {
|
||||
out.extend_from_slice(&w);
|
||||
}
|
||||
} else {
|
||||
let af = a.clamp(0.0, 1.0);
|
||||
for pair in color.as_chunks::<2>().0 {
|
||||
let c = codecs::f16::from_bits(u16::from_ne_bytes(*pair)).to_f32();
|
||||
let m = codecs::f16::from_f32(crate::pixels::matte(c, af, white_f)).to_bits();
|
||||
out.extend_from_slice(&m.to_ne_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn ne_u16(b: &[u8]) -> Option<u16> {
|
||||
let arr: [u8; 2] = b.get(..2)?.try_into().ok()?;
|
||||
Some(u16::from_ne_bytes(arr))
|
||||
}
|
||||
|
||||
fn ne_f32(b: &[u8]) -> Option<f32> {
|
||||
let arr: [u8; 4] = b.get(..4)?.try_into().ok()?;
|
||||
Some(f32::from_ne_bytes(arr))
|
||||
}
|
||||
|
||||
/// Writes alpha and spot channels (and a Quick Mask) into a flat TIFF: extra samples in the
|
||||
/// IFD, plus Photoshop resources for the name, ink colour and solidity. At most
|
||||
/// [`codecs::MAX_EXTRA_SAMPLES`] are kept. A TIFF with no extra channels is left unchanged.
|
||||
/// When any are written, the composite's own transparency is left out of the IFD: readers pair
|
||||
/// the channel resources with every sample past the colour channels, in order.
|
||||
pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec<String> {
|
||||
let total = doc.channels.len().saturating_add(usize::from(doc.quick_mask.is_some()));
|
||||
if total == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
let (w, h) = img.dimensions();
|
||||
if w != doc.size.width || h != doc.size.height {
|
||||
warnings.push("extra channels were not written: the image size differs from the document".into());
|
||||
return warnings;
|
||||
}
|
||||
let max = codecs::MAX_EXTRA_SAMPLES;
|
||||
let mut picked: Vec<&AlphaChannel> = doc.channels.iter().take(max).collect();
|
||||
if let Some(q) = &doc.quick_mask
|
||||
&& picked.len() < max
|
||||
{
|
||||
picked.push(q);
|
||||
}
|
||||
if total > picked.len() {
|
||||
warnings.push(format!("{total} extra channels; TIFF keeps the first {}", picked.len()));
|
||||
}
|
||||
let canvas = Rect::new(0, 0, w as i32, h as i32);
|
||||
let mut planes = Vec::with_capacity(picked.len());
|
||||
for ch in &picked {
|
||||
match channel_plane(ch, canvas, img.sample_type()) {
|
||||
Ok(data) => planes.push(data),
|
||||
Err(msg) => {
|
||||
warnings.push(msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
picked.truncate(planes.len());
|
||||
if picked.is_empty() {
|
||||
return warnings;
|
||||
}
|
||||
let names: Vec<&str> = picked.iter().map(|c| c.name.as_str()).collect();
|
||||
let quick_last = matches!((picked.last(), doc.quick_mask.as_ref()), (Some(p), Some(q)) if std::ptr::eq(*p, q));
|
||||
let color_channels = img.layout().color_channels();
|
||||
// Save Transparency off: the colour samples are the composite over white, and the
|
||||
// alpha plane is not an extra sample (it would be named as the first spot). The
|
||||
// composite transparency is gone after this; say so, because the user did not pick
|
||||
// that option themselves.
|
||||
if img.layout().has_alpha() {
|
||||
if !matte_and_drop_alpha(img) {
|
||||
warnings.push("extra channels were not written: the composite transparency could not be separated from them".into());
|
||||
return warnings;
|
||||
}
|
||||
warnings.push(
|
||||
"transparency composited over white; a TIFF with alpha or spot channels stores them as extra samples and drops image transparency (Save Transparency off)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let quick_id = if quick_last { u16::try_from(color_channels.saturating_add(picked.len()).saturating_sub(1)).ok() } else { None };
|
||||
match channel_resources(img.meta.photoshop_resources.as_deref(), &names, &picked, quick_id) {
|
||||
Ok(bytes) => img.meta.photoshop_resources = Some(bytes),
|
||||
Err(e) => warnings.push(format!("spot channel options were not written to the TIFF: {e}")),
|
||||
}
|
||||
img.meta.extra_samples = planes.into_iter().map(|data| codecs::ExtraSample { kind: 0, data }).collect();
|
||||
warnings
|
||||
}
|
||||
|
||||
/// Existing Photoshop resources with the channel-name, display-info and quick-mask blocks replaced.
|
||||
fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&AlphaChannel], quick_id: Option<u16>) -> Result<Vec<u8>, IoError> {
|
||||
let mut resources = match existing.filter(|b| !b.is_empty()) {
|
||||
Some(b) => resources_from_bytes(b)?.0,
|
||||
None => Vec::new(),
|
||||
};
|
||||
resources.retain(|r| {
|
||||
!matches!(r.id, 1006 | 1045 | crate::channel_map::DISPLAY_INFO | crate::channel_map::QUICK_MASK_INFO | crate::channel_map::ALPHA_IDENTIFIERS)
|
||||
});
|
||||
resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(names)));
|
||||
resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(names)));
|
||||
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(channels, false)));
|
||||
resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, names.len())));
|
||||
if let Some(id) = quick_id {
|
||||
let mut data = id.to_be_bytes().to_vec();
|
||||
data.push(0);
|
||||
resources.push(ImageResource::new(crate::channel_map::QUICK_MASK_INFO, data));
|
||||
}
|
||||
Ok(resources_to_bytes(&resources)?)
|
||||
}
|
||||
|
||||
/// Extra samples of a flat TIFF become alpha / spot channels. Layered TIFFs rebuild those
|
||||
/// through the PSD importer instead, so this is only for a file with no layer tag.
|
||||
pub(crate) fn channels_from_extras(doc: &mut Document, img: &Image) -> Vec<String> {
|
||||
if img.meta.extra_samples.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
let (w, h) = img.dimensions();
|
||||
if w != doc.size.width || h != doc.size.height {
|
||||
warnings.push("extra TIFF samples were dropped: their size differs from the image".into());
|
||||
return warnings;
|
||||
}
|
||||
let parsed = img.meta.photoshop_resources.as_deref().filter(|b| !b.is_empty()).and_then(|b| resources_from_bytes(b).ok());
|
||||
let none = Vec::new();
|
||||
let resources = parsed.as_ref().map(|(r, _)| r.as_slice()).unwrap_or(&none);
|
||||
let all_names = resources.iter().find(|r| r.id == 1045).map(|r| crate::psd_import::unicode_names(&r.data)).unwrap_or_default();
|
||||
// As in the PSD importer: Photoshop lists the image transparency first ("Transparency", with
|
||||
// its own display record), ahead of the channels the extra samples hold.
|
||||
let skip = usize::from(img.layout().has_alpha() && all_names.len() == img.meta.extra_samples.len().saturating_add(1));
|
||||
let names = all_names.get(skip..).unwrap_or_default();
|
||||
let spots = if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO) {
|
||||
crate::channel_map::spot_flags(&r.data, true, skip)
|
||||
} else if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO_OLD) {
|
||||
crate::channel_map::spot_flags(&r.data, false, skip)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let start = doc.channels.len();
|
||||
let n_px = (w as usize).saturating_mul(h as usize);
|
||||
for (k, e) in img.meta.extra_samples.iter().take(codecs::MAX_EXTRA_SAMPLES).enumerate() {
|
||||
let spot = spots.get(k).copied().unwrap_or(false);
|
||||
match mask_from_plane(&e.data, w, h, n_px, img.sample_type(), doc.depth, spot) {
|
||||
Ok(surface) => {
|
||||
let name = names.get(k).cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("Alpha {}", k + 1));
|
||||
doc.channels.push(AlphaChannel::new(name, surface));
|
||||
}
|
||||
Err(msg) => warnings.push(msg),
|
||||
}
|
||||
}
|
||||
if doc.channels.len() == start {
|
||||
return warnings;
|
||||
}
|
||||
{
|
||||
let slice = &mut doc.channels[start..];
|
||||
if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO) {
|
||||
crate::channel_map::apply_display_info(&r.data, true, skip, slice);
|
||||
} else if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO_OLD) {
|
||||
crate::channel_map::apply_display_info(&r.data, false, skip, slice);
|
||||
}
|
||||
}
|
||||
let first = img.layout().color_channels().saturating_add(usize::from(img.layout().has_alpha()));
|
||||
if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::QUICK_MASK_INFO)
|
||||
&& r.data.len() >= 2
|
||||
&& let Some(k) = usize::from(u16::from_be_bytes([r.data[0], r.data[1]])).checked_sub(first)
|
||||
&& start.saturating_add(k) < doc.channels.len()
|
||||
{
|
||||
doc.quick_mask = Some(doc.channels.remove(start + k));
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// Native-endian extra-sample bytes → a grayscale surface at the document depth.
|
||||
/// `photoshop_spot`: the sample is a spot channel in Photoshop's polarity (0 = solid ink).
|
||||
fn mask_from_plane(data: &[u8], w: u32, h: u32, n: usize, from: CSample, depth: SampleType, photoshop_spot: bool) -> Result<Surface, String> {
|
||||
let rect = Rect::new(0, 0, w as i32, h as i32);
|
||||
let mut bytes = match (from, depth) {
|
||||
(CSample::U8, SampleType::U8) | (CSample::U16, SampleType::U16) | (CSample::F32, SampleType::F32) => {
|
||||
if data.len() != n.saturating_mul(from.bytes()) {
|
||||
return Err("an extra TIFF sample did not match the image and was dropped".into());
|
||||
}
|
||||
data.to_vec()
|
||||
}
|
||||
(CSample::F16, SampleType::F32) => {
|
||||
if data.len() != n.saturating_mul(2) {
|
||||
return Err("an extra TIFF sample did not match the image and was dropped".into());
|
||||
}
|
||||
let mut out = Vec::with_capacity(n.saturating_mul(4));
|
||||
for arr in data.as_chunks::<2>().0 {
|
||||
let v = codecs::f16::from_bits(u16::from_ne_bytes(*arr)).to_f32();
|
||||
out.extend_from_slice(&v.to_ne_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => return Err(format!("an extra TIFF sample ({from:?}) does not match the document depth and was dropped")),
|
||||
};
|
||||
if photoshop_spot {
|
||||
crate::channel_map::photoshop_spot_plane(&mut bytes, depth);
|
||||
}
|
||||
if bytes.len() != n.saturating_mul(depth.bytes()) {
|
||||
return Err("an extra TIFF sample did not match the image and was dropped".into());
|
||||
}
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
|
||||
s.write_interleaved(rect, &bytes);
|
||||
s.prune();
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use photocraft_doc::{Color, Document, Fill, FillCache, Layer, LayerContent};
|
||||
|
||||
@@ -154,7 +154,7 @@ const MIXED: Source = Source { label: "io corpus", dir: "corpus/psd", group_dept
|
||||
/// 226 vs the merged image + 10 vs the thumbnail (Advanced Blending knockouts: +7).
|
||||
const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 249, roundtrip_floor: 308 };
|
||||
|
||||
/// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus),
|
||||
/// Our Photoshop-authored oracles (258 files; https://github.com/storytold/photocraft-corpus),
|
||||
/// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/<mode><bits>`).
|
||||
const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 190, roundtrip_floor: 258 };
|
||||
|
||||
|
||||
@@ -1191,3 +1191,39 @@ fn a_stereo_png_keeps_its_stereo_mark_on_save() {
|
||||
let plain = export(&single(ColorMode::Rgb, SampleType::U8, false), "png", &ExportOptions::default()).unwrap();
|
||||
assert_eq!(photocraft_codecs::png_stereo(&plain.bytes), None);
|
||||
}
|
||||
|
||||
/// #8563: a DDS export writes the document's alpha channel as the texture's alpha, as a 32-bit
|
||||
/// Targa does: exactly in an uncompressed texture, to 4 bits in BC2, interpolated in BC3, and on
|
||||
/// or off in BC1. With several alpha channels none is written, with a warning.
|
||||
#[test]
|
||||
fn dds_writes_the_alpha_channel_as_the_texture_alpha() {
|
||||
use photocraft_codecs::DdsCompression;
|
||||
let d = with_alpha_channel(single(ColorMode::Rgb, SampleType::U8, false), "Alpha 1");
|
||||
for compression in DdsCompression::ALL {
|
||||
let mut opts = ExportOptions::default();
|
||||
opts.encode.dds.compression = compression;
|
||||
let r = export(&d, "a.dds", &opts).unwrap();
|
||||
let img = photocraft_codecs::decode(&r.bytes).expect("decode dds");
|
||||
assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgba, "{compression:?}");
|
||||
let (w, _) = img.dimensions();
|
||||
let tolerance = match compression {
|
||||
DdsCompression::None => 1.0,
|
||||
DdsCompression::Bc2 => 9.0,
|
||||
_ => 18.0,
|
||||
};
|
||||
for (i, px) in img.data().as_chunks::<4>().0.iter().enumerate() {
|
||||
let x = i as u32 % w;
|
||||
let want = alpha_value(x) * 255.0;
|
||||
if compression == DdsCompression::Bc1 && x == 4 {
|
||||
continue; // half opacity sits on BC1's on/off threshold
|
||||
}
|
||||
assert!((f32::from(px[3]) - want).abs() <= tolerance, "{compression:?} pixel {i}: alpha {} want {want}", px[3]);
|
||||
}
|
||||
}
|
||||
// Two alpha channels: Photoshop's rule for Targa, none of them is written.
|
||||
let two = with_alpha_channel(d.clone(), "Alpha 2");
|
||||
let r = export(&two, "a.dds", &ExportOptions::default()).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("several")), "{:?}", r.warnings);
|
||||
let img = photocraft_codecs::decode(&r.bytes).expect("decode dds");
|
||||
assert!(!img.layout().has_alpha() || img.data().as_chunks::<4>().0.iter().all(|p| p[3] == 255));
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ fn resave_keeps_a_name_marked_merged_transparency() {
|
||||
// transparency named in the alpha channel names.
|
||||
let mut ps = ours.clone();
|
||||
ps.layer_info.as_mut().expect("layers").merged_alpha = false;
|
||||
ps.global_blocks.retain(|b| !matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32"));
|
||||
ps.global_blocks.retain(|b| !b.is_merged_transparency_marker());
|
||||
assert!(!ps.merged_has_alpha(), "{case}: nothing marks it yet");
|
||||
let name: Vec<u8> = [0u8, 0, 0, 13].into_iter().chain("Transparency\0".encode_utf16().flat_map(u16::to_be_bytes)).collect();
|
||||
ps.resources.retain(|r| !matches!(r.id, 1006 | 1045));
|
||||
|
||||
@@ -22,6 +22,28 @@ fn dng_opens_as_16_bit_prophoto() {
|
||||
assert!(r.warnings.iter().any(|w| w.contains("DNG") && w.contains("ProPhoto")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// #8125: a lossy DNG's floating-point LinearRaw data, JPEG XL-compressed, is developed (not
|
||||
/// replaced by a preview). The test codestream is an integer one the reader takes as floats.
|
||||
#[test]
|
||||
fn floating_point_jpeg_xl_dng_is_developed() {
|
||||
use photocraft_raw::testgen::DngStorage;
|
||||
use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions};
|
||||
let (w, h) = (24, 16);
|
||||
let data: Vec<u16> = scene(w, h).iter().flat_map(|p| p.map(|v| (v * 20000.0) as u16)).collect();
|
||||
let bytes: Vec<u8> = data.iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
let mut segment = Vec::new();
|
||||
zune_jpegxl::JxlSimpleEncoder::new(&bytes, EncoderOptions::new(w, h, ColorSpace::RGB, BitDepth::Sixteen)).encode(&mut segment).unwrap();
|
||||
let mut spec = DngSpec::cfa(w, h, data);
|
||||
spec.samples = 3;
|
||||
spec.white = 0;
|
||||
spec.storage = DngStorage::JxlStrips { rows: h };
|
||||
spec.jxl_segments = vec![segment];
|
||||
spec.raw_extra = vec![(339, Val::Short(vec![3; 3]))];
|
||||
let r = import("lossy.dng", &spec.build()).unwrap();
|
||||
assert_eq!((r.document.size.width, r.document.size.height, r.document.depth), (24, 16, SampleType::U16));
|
||||
assert!(r.warnings.iter().all(|w| !w.contains("preview")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// #3497: a monochrome (one-sample LinearRaw) DNG, as VueScan writes film scans, opens as a
|
||||
/// 16-bit RGB document (neutral grey) instead of failing as unsupported.
|
||||
#[test]
|
||||
@@ -194,6 +216,79 @@ fn unsupported_raw_falls_back_to_the_embedded_preview() {
|
||||
assert!(r.warnings.first().is_some_and(|w| w.contains("Nikon compressed NEF") && w.contains("embedded")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A DNG 1.7 laid out as converters write one with JPEG XL raw data: an 8×6 JPEG thumbnail in
|
||||
/// IFD0, raw data this reader refuses (here floating-point LinearRaw), and a 40×30 preview
|
||||
/// compressed with JPEG XL, stored as the raw is (IFD0 Orientation 6).
|
||||
fn dng_with_jxl_preview() -> Vec<u8> {
|
||||
use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions};
|
||||
let thumb = Image::from_u8(8, 6, ChannelLayout::Rgb, vec![90; 8 * 6 * 3]).unwrap();
|
||||
let thumb = photocraft_codecs::encode(&thumb, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let (w, h) = (40usize, 30usize);
|
||||
let rgb: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 6) as u8, (i / w * 8) as u8, 120]).collect();
|
||||
let mut preview = Vec::new();
|
||||
zune_jpegxl::JxlSimpleEncoder::new(&rgb, EncoderOptions::new(w, h, ColorSpace::RGB, BitDepth::Eight)).encode(&mut preview).unwrap();
|
||||
let mut t = TiffBuilder::default();
|
||||
let raw_strip = t.blob(vec![0; 4 * 4 * 3 * 2]);
|
||||
let raw = t.ifd(vec![
|
||||
(254, Val::Long(vec![0])),
|
||||
(256, Val::Long(vec![4])),
|
||||
(257, Val::Long(vec![4])),
|
||||
(258, Val::Short(vec![16, 16, 16])),
|
||||
(259, Val::Short(vec![1])),
|
||||
(262, Val::Short(vec![34892])),
|
||||
(273, Val::Blobs(vec![raw_strip])),
|
||||
(277, Val::Short(vec![3])),
|
||||
(278, Val::Long(vec![4])),
|
||||
(279, Val::Long(vec![4 * 4 * 3 * 2])),
|
||||
(339, Val::Short(vec![3, 3, 3])),
|
||||
]);
|
||||
let preview_len = preview.len() as u32;
|
||||
let preview_strip = t.blob(preview);
|
||||
let preview = t.ifd(vec![
|
||||
(254, Val::Long(vec![1])),
|
||||
(256, Val::Long(vec![w as u32])),
|
||||
(257, Val::Long(vec![h as u32])),
|
||||
(258, Val::Short(vec![8, 8, 8])),
|
||||
(259, Val::Short(vec![52546])),
|
||||
(262, Val::Short(vec![2])),
|
||||
(273, Val::Blobs(vec![preview_strip])),
|
||||
(277, Val::Short(vec![3])),
|
||||
(278, Val::Long(vec![h as u32])),
|
||||
(279, Val::Long(vec![preview_len])),
|
||||
(50970, Val::Long(vec![2])),
|
||||
]);
|
||||
let thumb_len = thumb.len() as u32;
|
||||
let thumb_strip = t.blob(thumb);
|
||||
let ifd0 = t.ifd(vec![
|
||||
(254, Val::Long(vec![1])),
|
||||
(256, Val::Long(vec![8])),
|
||||
(257, Val::Long(vec![6])),
|
||||
(258, Val::Short(vec![8, 8, 8])),
|
||||
(259, Val::Short(vec![7])),
|
||||
(262, Val::Short(vec![6])),
|
||||
(271, Val::Ascii("Synthetic".into())),
|
||||
(273, Val::Blobs(vec![thumb_strip])),
|
||||
(274, Val::Short(vec![6])),
|
||||
(277, Val::Short(vec![3])),
|
||||
(278, Val::Long(vec![6])),
|
||||
(279, Val::Long(vec![thumb_len])),
|
||||
(330, Val::Ifds(vec![raw, preview])),
|
||||
(50706, Val::Byte(vec![1, 7, 0, 0])),
|
||||
]);
|
||||
t.chain = vec![ifd0];
|
||||
t.build()
|
||||
}
|
||||
|
||||
/// #8125: a DNG whose raw data isn't supported opens its full JPEG XL preview, upright, not
|
||||
/// the small JPEG thumbnail.
|
||||
#[test]
|
||||
fn unsupported_dng_falls_back_to_its_jpeg_xl_preview() {
|
||||
let r = import("photo.dng", &dng_with_jxl_preview()).unwrap();
|
||||
assert_eq!((r.document.size.width, r.document.size.height), (30, 40), "the 40×30 preview, turned upright: {:?}", r.warnings);
|
||||
let w = r.warnings.first().map_or("", String::as_str);
|
||||
assert!(w.contains("floating-point") && w.contains("40x30 JPEG XL preview"), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_raw_without_preview_is_a_clear_error() {
|
||||
let mut cr3 = vec![0, 0, 0, 24];
|
||||
|
||||
@@ -169,6 +169,107 @@ fn corpus_photoshop_txt2_survives_an_unedited_save() {
|
||||
println!("Txt2 kept byte-for-byte in {checked} Photoshop type files");
|
||||
}
|
||||
|
||||
/// #7962: Photoshop's type on a path (`Txt2` frames, EngineData calls it a text box) opens on its
|
||||
/// path, at the anchors the generator gave Photoshop; the circle re-renders where Photoshop drew
|
||||
/// it; and a save after an edit (new text, a moved path) reopens on the layer's path.
|
||||
#[cfg(feature = "corpus")]
|
||||
#[test]
|
||||
fn corpus_photoshop_path_text_opens_on_its_path() {
|
||||
use photocraft_doc::text::TextShape;
|
||||
use photocraft_geom::Point;
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/text");
|
||||
let k = 0.5522847498 * 96.0;
|
||||
let cases: [(&str, bool, Vec<[f64; 2]>); 2] = [
|
||||
// Photoshop sets the type from the closed path's last anchor (the bottom).
|
||||
("path-circle.psd", true, vec![[128.0, 224.0], [32.0, 128.0], [128.0, 32.0], [224.0, 128.0]]),
|
||||
("path-wave-open.psd", false, vec![[16.0, 100.0], [160.0, 80.0], [304.0, 60.0]]),
|
||||
];
|
||||
let mut engine = photocraft_text::TextEngine::with_system_fonts();
|
||||
for (file, closed, anchors) in cases {
|
||||
let f = root.join(file);
|
||||
let bytes = std::fs::read(&f).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --photoshop`", f.display()));
|
||||
let imp = photocraft_io::import(file, &bytes).unwrap();
|
||||
assert!(!imp.warnings.iter().any(|w| w.contains("type path")), "{file}: {:?}", imp.warnings);
|
||||
let doc = imp.document;
|
||||
let mut all = Vec::new();
|
||||
walk(&doc.layers, &mut all);
|
||||
let layer = all.iter().find(|l| matches!(l.content, LayerContent::Text(_))).unwrap();
|
||||
let LayerContent::Text(t) = &layer.content else { unreachable!() };
|
||||
let doc_anchors = |t: &photocraft_doc::TextLayer| -> Vec<Point> {
|
||||
let TextShape::Path { path, start, flip } = &t.shape else { panic!("{file}: not path text: {:?}", t.shape) };
|
||||
assert_eq!((path.closed, *start, *flip), (closed, 0.0, false), "{file}");
|
||||
path.knots.iter().map(|k| t.transform.apply(k.anchor)).collect()
|
||||
};
|
||||
let got = doc_anchors(t);
|
||||
assert_eq!(got.len(), anchors.len(), "{file}");
|
||||
for (g, [x, y]) in got.iter().zip(&anchors) {
|
||||
assert!((g.x - x).abs() < 1e-3 && (g.y - y).abs() < 1e-3, "{file}: anchor {g:?}, want ({x}, {y})");
|
||||
}
|
||||
if closed {
|
||||
// The circle's handles are the generator's (kappa × r), so the path is the circle.
|
||||
let TextShape::Path { path, .. } = &t.shape else { unreachable!() };
|
||||
let h = t.transform.apply(path.knots[0].out_ctrl);
|
||||
assert!((h.x - (128.0 - k)).abs() < 1e-3 && (h.y - 224.0).abs() < 1e-3, "{file}: handle {h:?}");
|
||||
}
|
||||
|
||||
// Photoshop's pixels (the wave's EngineData text is blank: Photoshop drew nothing).
|
||||
if let Some(ps) = t.cache.as_ref().filter(|c| !c.content_bounds().is_empty()) {
|
||||
let ps_rect = ps.content_bounds();
|
||||
let (_, ours) = engine.render(t, doc.resolution_dpi, doc.pixel_format());
|
||||
let our_rect = ours.surface.content_bounds();
|
||||
let u = Rect::new(ps_rect.x0.min(our_rect.x0), ps_rect.y0.min(our_rect.y0), ps_rect.x1.max(our_rect.x1), ps_rect.y1.max(our_rect.y1));
|
||||
let (a, b) = (alpha(ps, u), alpha(&ours.surface, u));
|
||||
let inter: f32 = a.iter().zip(&b).map(|(x, y)| x.min(*y)).sum();
|
||||
let union: f32 = a.iter().zip(&b).map(|(x, y)| x.max(*y)).sum();
|
||||
let iou = if union > 0.0 { inter / union } else { 1.0 };
|
||||
let edges = [ps_rect.x0 - our_rect.x0, ps_rect.y0 - our_rect.y0, ps_rect.x1 - our_rect.x1, ps_rect.y1 - our_rect.y1];
|
||||
println!("{file}: ps={ps_rect:?} ours={our_rect:?} iou={iou:.2} edges={edges:?}");
|
||||
// Where the type starts on the path and how far it reaches across it hold with any
|
||||
// font; the far end and the glyph overlap need the font Photoshop used (a wider
|
||||
// fallback runs further around the circle).
|
||||
let [x0, y0, _, y1] = edges;
|
||||
assert!([x0, y0, y1].iter().all(|d| d.abs() <= 8), "{file}: ink bounds {our_rect:?}, Photoshop's {ps_rect:?}");
|
||||
let family = t.runs.first().map(|r| r.style.font_family.clone()).unwrap_or_default();
|
||||
if engine.fonts.has_family(&family) {
|
||||
assert!(edges.iter().all(|d| d.abs() <= 8), "{file}: ink bounds {our_rect:?}, Photoshop's {ps_rect:?}");
|
||||
assert!(iou > 0.2, "{file}: the glyphs sit where Photoshop drew them (IoU {iou:.2})");
|
||||
} else {
|
||||
eprintln!("{file}: {family:?} not installed, far edge and overlap comparison skipped");
|
||||
}
|
||||
}
|
||||
|
||||
// Edited: new text and the path moved 10 px right; saved and opened again.
|
||||
let mut edited = doc.clone();
|
||||
let id = layer.id;
|
||||
for l in edited.layers.iter_mut() {
|
||||
if l.id != id {
|
||||
continue;
|
||||
}
|
||||
let LayerContent::Text(t) = &mut l.content else { continue };
|
||||
t.text = format!("{} again", t.text);
|
||||
if let Some(r) = t.runs.last_mut() {
|
||||
r.len += " again".len();
|
||||
}
|
||||
t.sync_summary();
|
||||
if let TextShape::Path { path, .. } = &mut t.shape {
|
||||
for k in &mut path.knots {
|
||||
*k = k.transform(&photocraft_geom::Affine::translate(10.0, 0.0));
|
||||
}
|
||||
}
|
||||
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::build_tysh(t, edited.resolution_dpi, None)));
|
||||
}
|
||||
let out = photocraft_io::export(&edited, file, &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import(file, &out).unwrap().document;
|
||||
let mut all = Vec::new();
|
||||
walk(&back.layers, &mut all);
|
||||
let LayerContent::Text(bt) = &all.iter().find(|l| matches!(l.content, LayerContent::Text(_))).unwrap().content else { unreachable!() };
|
||||
assert!(bt.text.ends_with(" again"), "{file}: {:?}", bt.text);
|
||||
for (g, [x, y]) in doc_anchors(bt).iter().zip(&anchors) {
|
||||
assert!((g.x - (x + 10.0)).abs() < 1e-3 && (g.y - y).abs() < 1e-3, "{file}: saved anchor {g:?}, want ({}, {y})", x + 10.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A text layer created in PhotoCraft (no PSD data) exports as an editable type layer and
|
||||
/// imports back with the same model.
|
||||
#[test]
|
||||
|
||||
@@ -34,19 +34,18 @@ fn roundtrip(doc: &Document) -> Document {
|
||||
// The TIFF is a plain image to any other reader: the composite at the document's size.
|
||||
let flat = photocraft_codecs::decode(&r.bytes).expect("flat decode");
|
||||
assert_eq!(flat.dimensions(), (doc.size.width, doc.size.height));
|
||||
// A TIFF holds one transparency channel: extra alpha channels (and a Quick Mask) are not
|
||||
// kept, and the export says so.
|
||||
// Extra alpha channels and a Quick Mask travel as TIFF extra samples (at most MAX_EXTRA_SAMPLES).
|
||||
let extra = doc.channels.len() + usize::from(doc.quick_mask.is_some());
|
||||
assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > 0, "{:?}", r.warnings);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("not written")), "{:?}", r.warnings);
|
||||
if extra > 0 && extra <= photocraft_codecs::MAX_EXTRA_SAMPLES {
|
||||
assert_eq!(flat.meta.extra_samples.len(), extra, "{:?}", r.warnings);
|
||||
}
|
||||
import("x.tif", &r.bytes).expect("import").document
|
||||
}
|
||||
|
||||
/// `doc` as a layered TIFF round trip returns it: without extra alpha channels or a Quick Mask.
|
||||
/// `doc` as a layered TIFF round trip returns it, including extra channels.
|
||||
fn expected(doc: &Document) -> Document {
|
||||
let mut d = doc.clone();
|
||||
d.channels.clear();
|
||||
d.quick_mask = None;
|
||||
d
|
||||
doc.clone()
|
||||
}
|
||||
|
||||
macro_rules! rt_case {
|
||||
@@ -202,6 +201,47 @@ fn transparency_is_straight_in_the_composite() {
|
||||
assert_docs_eq(&d, &back);
|
||||
}
|
||||
|
||||
/// Photoshop (27.11) saves a layered TIFF with Save Transparency as a composite with a
|
||||
/// transparency sample, a positive layer count and an `MTrn` block first in the layer data
|
||||
/// (the specification's key is `Mtrn`; `nrTM` in an `II` file). That sample is the merged
|
||||
/// transparency: it opens as no alpha channel, and a PSD save doesn't write it twice.
|
||||
#[test]
|
||||
fn photoshop_mtrn_marks_the_merged_transparency_of_a_layered_tiff() {
|
||||
use photocraft_psd::{PsdFile, TaggedBlock};
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
let mut d = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth);
|
||||
let fmt = d.pixel_format();
|
||||
d.layers.push(raster("shape", fmt, photocraft_geom::Rect::new(1, 1, 5, 4), 3, true));
|
||||
let r = tiff(&d, true);
|
||||
let mut img = photocraft_codecs::decode(&r.bytes).expect("decode");
|
||||
assert!(img.layout().has_alpha(), "{depth:?}: the composite has a transparency sample");
|
||||
let (isd, _) = ImageSourceData::from_bytes(&tags(&r.bytes).1.expect("layer data")).expect("parse");
|
||||
for order in [ByteOrder::Little, ByteOrder::Big] {
|
||||
let case = format!("{depth:?} {order:?}");
|
||||
// Photoshop's form of the same layer data.
|
||||
let mut ps = isd.clone();
|
||||
ps.layer_info.as_mut().expect("layers").merged_alpha = false;
|
||||
ps.global_blocks.insert(0, TaggedBlock::new(*b"MTrn", Vec::new()));
|
||||
let layers = ps.to_bytes(order).expect("layer data").0;
|
||||
if order == ByteOrder::Little {
|
||||
assert!(layers.windows(12).any(|w| w == b"MIB8nrTM\0\0\0\0"), "{case}: stored as Photoshop stores it");
|
||||
}
|
||||
img.meta.photoshop_layers = Some(layers);
|
||||
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).expect("encode");
|
||||
|
||||
let opened = import("x.tif", &bytes).expect("import").document;
|
||||
let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect();
|
||||
assert!(names.is_empty(), "{case}: no alpha channel from the merged transparency: {names:?}");
|
||||
// The marker is kept for the next save.
|
||||
let mut want = d.clone();
|
||||
want.metadata.psd_global_blocks = vec![(*b"8BIM", *b"MTrn", std::sync::Arc::new(Vec::new()))];
|
||||
assert_docs_eq(&want, &opened);
|
||||
let psd = PsdFile::from_bytes(&export(&opened, "x.psd", &ExportOptions::default()).expect("PSD save").bytes).expect("parse");
|
||||
assert_eq!(psd.header.channels, 4, "{case}: colour + merged transparency");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_documents_use_the_psb_signature() {
|
||||
// Forcing PSB stands in for a > 30000 px document.
|
||||
@@ -261,12 +301,11 @@ fn psdtags_fixtures_open_with_their_layers() {
|
||||
// tifffile writes a Software tag; nothing else is worth a warning.
|
||||
assert!(r.warnings.iter().all(|w| w.contains("text metadata")), "{name}: {:?}", r.warnings);
|
||||
// psdtags wrote the composite's alpha sample without the transparency flag, so by the
|
||||
// PSD rules it is an alpha channel (which a re-save cannot keep). Re-saving keeps
|
||||
// everything else; the file is then in the encoder's byte order.
|
||||
// PSD rules it is an alpha channel. A re-save keeps it as a TIFF extra sample.
|
||||
assert_eq!(d.channels.len(), 1, "{name}");
|
||||
let out = tiff(d, true);
|
||||
let back = import(name, &out.bytes).unwrap().document;
|
||||
assert_docs_eq(&expected(d), &back);
|
||||
assert_docs_eq(d, &back);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +401,622 @@ fn damaged_layer_data_opens_flattened() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every sample of a TIFF, read by the `tiff` crate rather than PhotoCraft's decoder.
|
||||
///
|
||||
/// That decoder drops unspecified extra samples when the photometric interpretation is RGB, so
|
||||
/// the file is retagged MinIsBlack first. The result is then a multiband image of every sample,
|
||||
/// which is what libtiff's `TIFFReadScanline` returns and what a RIP walks.
|
||||
fn independent_samples(bytes: &[u8]) -> (u16, Vec<u16>, Vec<u16>, u32, Vec<u8>) {
|
||||
let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).expect("tiff tags");
|
||||
let spp: u16 = tags.get_tag_unsigned(tiff::tags::Tag::SamplesPerPixel).expect("spp");
|
||||
let bps = tags.get_tag_u16_vec(tiff::tags::Tag::BitsPerSample).expect("bps");
|
||||
let extra = tags.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap_or_default();
|
||||
let rows = tags.find_tag_unsigned::<u32>(tiff::tags::Tag::RowsPerStrip).ok().flatten().unwrap_or(0);
|
||||
let patched = photometric_min_is_black(bytes);
|
||||
let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(patched.as_slice())).expect("tiff decode");
|
||||
let tiff::decoder::DecodingResult::U8(samples) = dec.read_image().expect("read image") else {
|
||||
panic!("expected 8-bit samples");
|
||||
};
|
||||
(spp, bps, extra, rows, samples)
|
||||
}
|
||||
|
||||
/// Classic-TIFF photometric tag 262 → 1 (MinIsBlack), value stored inline in the IFD entry.
|
||||
fn photometric_min_is_black(src: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = src.to_vec();
|
||||
let le = bytes.first().copied() == Some(b'I');
|
||||
let ru16 = |b: &[u8], i: usize| -> Option<u16> {
|
||||
let s = b.get(i..i + 2)?;
|
||||
Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) })
|
||||
};
|
||||
let ru32 = |b: &[u8], i: usize| -> Option<u32> {
|
||||
let s = b.get(i..i + 4)?;
|
||||
Some(if le { u32::from_le_bytes(s.try_into().ok()?) } else { u32::from_be_bytes(s.try_into().ok()?) })
|
||||
};
|
||||
let Some(ifd) = ru32(&bytes, 4) else { return bytes };
|
||||
let Some(n) = ru16(&bytes, ifd as usize) else { return bytes };
|
||||
for e in 0..n as usize {
|
||||
let at = ifd as usize + 2 + e * 12;
|
||||
if ru16(&bytes, at) == Some(262) {
|
||||
let value = if le { 1u16.to_le_bytes() } else { 1u16.to_be_bytes() };
|
||||
if let Some(slot) = bytes.get_mut(at + 8..at + 10) {
|
||||
slot.copy_from_slice(&value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
fn assert_plane(samples: &[u8], spp: usize, plane: usize, expected: &[u8]) {
|
||||
assert_eq!(samples.len(), expected.len().saturating_mul(spp));
|
||||
let mut bad = 0usize;
|
||||
let mut first = None;
|
||||
for (i, want) in expected.iter().enumerate() {
|
||||
let got = samples.get(i * spp + plane).copied();
|
||||
if got != Some(*want) {
|
||||
bad += 1;
|
||||
first.get_or_insert((i, got));
|
||||
}
|
||||
}
|
||||
assert_eq!(bad, 0, "{bad} mismatches, first {first:?}");
|
||||
}
|
||||
|
||||
/// A multi-layer transparent document, large enough for several TIFF strips and several
|
||||
/// raster tiles, with a spot whose every pixel is a known value.
|
||||
#[test]
|
||||
fn dtf_like_spot_is_written_in_full() {
|
||||
use photocraft_color::{Color, PixelFormat};
|
||||
use photocraft_doc::{AlphaChannel, Layer};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_psd::tiff::resources_from_bytes;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
let (w, h) = (900u32, 360u32);
|
||||
let mut doc = Document::new("dtf", Size::new(w, h), ColorMode::Rgb, SampleType::U8);
|
||||
let fmt = doc.pixel_format();
|
||||
let mut art = Surface::new(fmt);
|
||||
let px = photocraft_raster::from_rgba(&fmt, [0.2, 0.5, 0.9, 1.0]);
|
||||
art.fill_rect(Rect::new(30, 20, 870, 340), &px);
|
||||
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
|
||||
let mut ink = Surface::new(fmt);
|
||||
let px = photocraft_raster::from_rgba(&fmt, [0.9, 0.1, 0.2, 0.5]);
|
||||
ink.fill_rect(Rect::new(80, 40, 700, 300), &px);
|
||||
let mut layer = Layer::new("Ink", LayerContent::Raster(ink));
|
||||
layer.opacity = 0.8;
|
||||
doc.layers.push(layer);
|
||||
|
||||
let n = (w as usize) * (h as usize);
|
||||
let mut spot_bytes = vec![0u8; n];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
spot_bytes[y * w as usize + x] = ((x * 3 + y * 17) % 251) as u8;
|
||||
}
|
||||
}
|
||||
// Outside the artwork the composite is transparent, but the spot is not zero: a reader that
|
||||
// binds "Spot Color 1" to the alpha plane fails here.
|
||||
assert_eq!(spot_bytes[10 * w as usize + 10], 200);
|
||||
let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false);
|
||||
let spot_surface = Surface::from_interleaved(gray, Rect::new(0, 0, w as i32, h as i32), &spot_bytes);
|
||||
let mut channel = AlphaChannel::new("Spot Color 1", spot_surface);
|
||||
channel.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6));
|
||||
doc.channels.push(channel);
|
||||
|
||||
let mut seen = Vec::new();
|
||||
for (name, layers) in [("flat", false), ("layered", true)] {
|
||||
let opts = ExportOptions { tiff_layers: layers, ..ExportOptions::default() };
|
||||
let r = export(&doc, "dtf.tif", &opts).unwrap();
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("not written")), "{name}: {:?}", r.warnings);
|
||||
let img = photocraft_codecs::decode(&r.bytes).unwrap();
|
||||
assert!(!img.layout().has_alpha(), "{name}: {:?}", img.layout());
|
||||
assert_eq!(img.layout().color_channels(), 3, "{name}");
|
||||
assert_eq!(img.meta.extra_samples.len(), 1, "{name}");
|
||||
assert_eq!(img.meta.extra_samples[0].kind, 0, "{name}");
|
||||
// Photoshop stores spot ink inverted: document 255 (solid) is file 0.
|
||||
let stored_spot: Vec<u8> = spot_bytes.iter().map(|b| 255 - b).collect();
|
||||
assert_eq!(img.meta.extra_samples[0].data, stored_spot, "{name} own decoder");
|
||||
|
||||
let (spp, bps, extra, rows, samples) = independent_samples(&r.bytes);
|
||||
assert_eq!(spp, 4, "{name}");
|
||||
assert_eq!(bps, vec![8, 8, 8, 8], "{name}");
|
||||
assert_eq!(extra, vec![0], "{name} ExtraSamples");
|
||||
assert!(rows > 0 && rows < h, "{name} rows_per_strip {rows}");
|
||||
assert_plane(&samples, 4, 3, &stored_spot);
|
||||
seen.push(samples.clone());
|
||||
|
||||
// Outside the artwork the composite is matted to white (Save Transparency off). The
|
||||
// spot at that pixel is not zero, so a reader that binds the name to the alpha fails.
|
||||
let at = (10 * w as usize + 10) * 4;
|
||||
assert_eq!(&samples[at..at + 3], &[255, 255, 255], "{name} matted rgb");
|
||||
let (resources, layer_tag) = tags(&r.bytes);
|
||||
let resources = resources.expect("photoshop resources");
|
||||
let (blocks, warnings) = resources_from_bytes(&resources).unwrap();
|
||||
assert!(warnings.is_empty(), "{name} {warnings:?}");
|
||||
let block = |id: u16| blocks.iter().find(|b| b.id == id).unwrap_or_else(|| panic!("{name} missing {id}"));
|
||||
// "Spot Color 1" is 12 characters: Photoshop stores 13 bytes and pads the 8BIM block, not the string.
|
||||
assert_eq!(block(1006).data, b"\x0cSpot Color 1", "{name}");
|
||||
assert_eq!(block(1045).data.len(), 30, "{name}");
|
||||
let info = &block(1077).data;
|
||||
assert_eq!(info.len(), 17, "{name}");
|
||||
assert_eq!(u16::from_be_bytes([info[14], info[15]]), 60, "{name} solidity");
|
||||
assert_eq!(info[16], 2, "{name} spot kind");
|
||||
// Photoshop numbers a new document's channels from 3.
|
||||
assert_eq!(block(1053).data, 3u32.to_be_bytes(), "{name} channel id");
|
||||
|
||||
let back = import("dtf.tif", &r.bytes).unwrap().document;
|
||||
assert_eq!(back.channels.len(), 1, "{name}");
|
||||
assert_eq!(back.channels[0].name, "Spot Color 1");
|
||||
let (ink, sol) = back.channels[0].spot.expect("spot");
|
||||
assert_eq!(ink.to_rgb(), [1.0, 0.0, 0.0], "{name}");
|
||||
assert!((sol - 0.6).abs() < 1e-4, "{name}");
|
||||
let area = Rect::new(0, 0, w as i32, h as i32);
|
||||
assert_eq!(back.channels[0].surface.to_interleaved(area), spot_bytes, "{name} reimport");
|
||||
if layers {
|
||||
let layer_tag = layer_tag.expect("layer tag");
|
||||
let (isd, w) = ImageSourceData::from_bytes(&layer_tag).unwrap();
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let info = isd.layer_info.expect("layers");
|
||||
assert!(!info.merged_alpha, "{name}");
|
||||
assert!(info.layers.iter().all(|l| l.flags.0 & 0x08 != 0), "{name} photoshop flag");
|
||||
assert!(info.layers.iter().all(|l| l.blocks.iter().any(|b| &b.key == b"lnsr")), "{name} lnsr");
|
||||
let mask = isd.global_layer_mask.expect("LMsk");
|
||||
assert_eq!(mask.data.len(), 14, "{name}");
|
||||
assert_eq!(mask.opacity(), Some(50));
|
||||
assert_eq!(mask.kind(), Some(128));
|
||||
assert_photoshop_layered_tiff(&r.bytes, true);
|
||||
assert_eq!(back.layers.len(), 2, "{name}");
|
||||
let ink = back.layers.iter().find(|l| l.name == "Ink").expect("ink layer");
|
||||
let px = ink.surface().expect("ink pixels").pixel(100, 100);
|
||||
assert!((px[3] - 0.5).abs() < 0.02, "{name} layer alpha {px:?}");
|
||||
let outside = ink.surface().expect("ink pixels").pixel(0, 0);
|
||||
assert!(outside[3] < 0.02, "{name} transparent pixel {outside:?}");
|
||||
} else {
|
||||
assert!(layer_tag.is_none(), "{name}");
|
||||
}
|
||||
}
|
||||
assert_eq!(seen[0], seen[1], "flat and layered IFD samples differ");
|
||||
}
|
||||
|
||||
/// A transparent document with no user channels still stores image alpha.
|
||||
#[test]
|
||||
fn transparent_tiff_without_channels_keeps_image_alpha() {
|
||||
use photocraft_doc::Layer;
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
let mut doc = Document::new("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8);
|
||||
let fmt = doc.pixel_format();
|
||||
let mut art = Surface::new(fmt);
|
||||
art.fill_rect(Rect::new(1, 1, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5]));
|
||||
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
|
||||
for layers in [false, true] {
|
||||
let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap();
|
||||
let img = photocraft_codecs::decode(&r.bytes).unwrap();
|
||||
assert!(img.layout().has_alpha(), "{layers}");
|
||||
assert!(img.meta.extra_samples.is_empty(), "{layers}");
|
||||
let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(r.bytes.as_slice())).unwrap();
|
||||
let extra = dec.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap();
|
||||
assert_eq!(extra, vec![2], "{layers}");
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("Save Transparency")), "{layers}: {:?}", r.warnings);
|
||||
if layers {
|
||||
assert_photoshop_layered_tiff(&r.bytes, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat TIFF that also stores an alpha or spot channel mattes the composite on white
|
||||
/// and drops image transparency. The export says so.
|
||||
#[test]
|
||||
fn flat_tiff_warns_when_extra_channels_matte_transparency() {
|
||||
use photocraft_color::{Color, PixelFormat};
|
||||
use photocraft_doc::{AlphaChannel, Layer};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8);
|
||||
let fmt = doc.pixel_format();
|
||||
let mut art = Surface::new(fmt);
|
||||
// (0, 0) stays transparent. (1, 1) is half-red, so the matte is halfway to white.
|
||||
art.fill_rect(Rect::new(1, 1, 2, 2), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5]));
|
||||
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
|
||||
let mut spot = AlphaChannel::new("Spot Color 1", Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)));
|
||||
spot.spot = Some((Color::rgb(0.0, 1.0, 0.0), 1.0));
|
||||
doc.channels.push(spot);
|
||||
|
||||
let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: false, ..ExportOptions::default() }).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("composited over white") && w.contains("Save Transparency")), "{:?}", r.warnings);
|
||||
let img = photocraft_codecs::decode(&r.bytes).unwrap();
|
||||
assert!(!img.layout().has_alpha(), "{:?}", img.layout());
|
||||
assert_eq!(img.meta.extra_samples.len(), 1);
|
||||
let (_spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes);
|
||||
assert_eq!(extra, vec![0]);
|
||||
// 4 samples per pixel: RGB + spot. (0, 0) was transparent and is now white.
|
||||
assert_eq!(&samples[..3], &[255, 255, 255]);
|
||||
// Half-red over white is about (255, 128, 128). Pixel (1, 1), 4 samples per pixel.
|
||||
let (x, y, width, spp) = (1usize, 1usize, 4usize, 4usize);
|
||||
let at = (y * width + x) * spp;
|
||||
let rgb = &samples[at..at + 3];
|
||||
assert!(rgb[0] > 250 && (rgb[1] as i16 - 128).abs() <= 2 && (rgb[2] as i16 - 128).abs() <= 2, "{rgb:?}");
|
||||
}
|
||||
|
||||
/// A 4×4 RGB document whose one layer is transparent outside its middle, plus a "White" spot
|
||||
/// channel with solid ink in its left half: the shape of a UV print's underbase.
|
||||
fn transparent_art_with_white_spot() -> Document {
|
||||
use photocraft_color::{Color, PixelFormat};
|
||||
use photocraft_doc::{AlphaChannel, Layer};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8);
|
||||
let fmt = doc.pixel_format();
|
||||
let mut art = Surface::new(fmt);
|
||||
art.fill_rect(Rect::new(1, 1, 3, 3), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));
|
||||
doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(art)));
|
||||
let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false);
|
||||
let mut plate = Surface::new(gray);
|
||||
plate.fill_rect(Rect::new(0, 0, 2, 4), &photocraft_raster::from_rgba(&gray, [1.0, 1.0, 1.0, 1.0]));
|
||||
let mut white = AlphaChannel::new("White", plate);
|
||||
white.spot = Some((Color::rgb(0.8, 0.2, 0.2), 1.0));
|
||||
doc.channels.push(white);
|
||||
doc
|
||||
}
|
||||
|
||||
/// The first channel's ink at (0, 0), inked, and at (3, 0), bare.
|
||||
fn ink(d: &Document) -> (f32, f32) {
|
||||
(d.channels[0].surface.pixel(0, 0)[0], d.channels[0].surface.pixel(3, 0)[0])
|
||||
}
|
||||
|
||||
/// `doc` laid out as Photoshop 25.12 saves a TIFF with Save Transparency on: R, G, B,
|
||||
/// transparency and spot samples, with "Transparency" first in the channel names and display
|
||||
/// records. With `layers`, the layer data has a positive layer count and an `MTrn` block (the
|
||||
/// specification's key is `Mtrn`); without, the file is flat. Built from the PSD model of `doc`.
|
||||
fn photoshop_transparency_and_spot_tiff(doc: &Document, layers: bool) -> Vec<u8> {
|
||||
use photocraft_psd::{Compression, TaggedBlock};
|
||||
|
||||
let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, ..Default::default() };
|
||||
let (file, _) = document_to_psd_with(doc, &opts);
|
||||
let planes = file.decode_merged().unwrap();
|
||||
let n = 16;
|
||||
assert_eq!(planes.len(), 5 * n);
|
||||
let rgba: Vec<u8> = (0..n).flat_map(|i| (0..4).map(move |c| (i, c))).map(|(i, c)| planes[c * n + i]).collect();
|
||||
let mut img = photocraft_codecs::Image::from_raw(4, 4, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, rgba).unwrap();
|
||||
img.meta.extra_samples = vec![photocraft_codecs::ExtraSample { kind: 0, data: planes[4 * n..].to_vec() }];
|
||||
img.meta.photoshop_resources = Some(photocraft_psd::tiff::resources_to_bytes(&file.resources).unwrap());
|
||||
if layers {
|
||||
let mut isd = ImageSourceData::from_psd(&file);
|
||||
if let Some(info) = &mut isd.layer_info {
|
||||
info.merged_alpha = false;
|
||||
}
|
||||
isd.global_blocks.insert(0, TaggedBlock::new(*b"MTrn", Vec::new()));
|
||||
let order = if photocraft_codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big };
|
||||
img.meta.photoshop_layers = Some(isd.to_bytes(order).unwrap().0);
|
||||
}
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// Photoshop's layered TIFF with transparency next to a spot channel opens with the spot as the
|
||||
/// only channel. A layered re-save leaves the transparency sample out next to the spot, so it
|
||||
/// must also drop the `MTrn` marker, or the spot would read back as transparency.
|
||||
#[test]
|
||||
fn photoshop_mtrn_tiff_opens_without_a_transparency_channel() {
|
||||
use photocraft_psd::TaggedBlock;
|
||||
|
||||
let bytes = photoshop_transparency_and_spot_tiff(&transparent_art_with_white_spot(), true);
|
||||
let opened = import("t.tif", &bytes).unwrap().document;
|
||||
let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["White"]);
|
||||
assert!(opened.channels[0].spot.is_some());
|
||||
assert_eq!(ink(&opened), (1.0, 0.0));
|
||||
|
||||
let r = tiff(&opened, true);
|
||||
let (isd, _) = ImageSourceData::from_bytes(&tags(&r.bytes).1.unwrap()).unwrap();
|
||||
assert!(!isd.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker));
|
||||
let back = import("t.tif", &r.bytes).unwrap().document;
|
||||
let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["White"]);
|
||||
assert_eq!(ink(&back), (1.0, 0.0));
|
||||
}
|
||||
|
||||
/// The same Photoshop layout in a flat TIFF (no layer data): the image keeps its transparency,
|
||||
/// and the spot gets its own name and display record, not the leading "Transparency" ones, so
|
||||
/// it opens as a spot with its ink the right way round.
|
||||
#[test]
|
||||
fn photoshop_flat_tiff_with_transparency_opens_its_spot_as_a_spot() {
|
||||
let bytes = photoshop_transparency_and_spot_tiff(&transparent_art_with_white_spot(), false);
|
||||
assert!(tags(&bytes).1.is_none(), "flat: no layer data");
|
||||
let opened = import("t.tif", &bytes).unwrap().document;
|
||||
let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["White"]);
|
||||
assert!(opened.channels[0].spot.is_some(), "a spot, not an alpha channel");
|
||||
assert_eq!(ink(&opened), (1.0, 0.0));
|
||||
let LayerContent::Raster(s) = &opened.layers[0].content else { panic!("raster") };
|
||||
assert_eq!(s.pixel(0, 0)[3], 0.0, "the image keeps its transparency");
|
||||
}
|
||||
|
||||
/// A Lab document with a spot channel keeps the spot as a CIELab extra sample, and its
|
||||
/// transparency is matted on Lab white (L* 100, a* and b* 0), not on the maximum of every sample.
|
||||
#[test]
|
||||
fn lab_tiff_keeps_spot_and_mattes_on_lab_white() {
|
||||
use photocraft_color::{Color, PixelFormat};
|
||||
use photocraft_doc::{AlphaChannel, Layer};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
for sample in [SampleType::U8, SampleType::U16] {
|
||||
let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Lab, sample);
|
||||
let fmt = doc.pixel_format();
|
||||
let mut art = Surface::new(fmt);
|
||||
// (0, 0) stays transparent.
|
||||
art.fill_rect(Rect::new(1, 1, 2, 2), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));
|
||||
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
|
||||
let mut plate = Surface::new(PixelFormat::new(ColorMode::Grayscale, sample, false));
|
||||
plate.fill_rect(Rect::new(0, 0, 2, 4), &photocraft_raster::from_rgba(&plate.format(), [1.0, 1.0, 1.0, 1.0]));
|
||||
let mut spot = AlphaChannel::new("Spot Color 1", plate);
|
||||
spot.spot = Some((Color::rgb(0.0, 1.0, 0.0), 1.0));
|
||||
doc.channels.push(spot);
|
||||
|
||||
// Lab is always saved flat, whether layers were asked for or not.
|
||||
for layers in [false, true] {
|
||||
let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap();
|
||||
let img = photocraft_codecs::decode(&r.bytes).unwrap();
|
||||
assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Lab, "{sample:?} {layers}");
|
||||
assert_eq!(img.meta.extra_samples.len(), 1, "{sample:?} {layers}");
|
||||
let white = &img.to_normalized()[..3];
|
||||
assert!((white[0] - 1.0).abs() < 1e-3 && (white[1] - 128.0 / 255.0).abs() < 1e-3 && (white[2] - 128.0 / 255.0).abs() < 1e-3, "{white:?}");
|
||||
let back = import("t.tif", &r.bytes).unwrap().document;
|
||||
assert_eq!(back.pixel_format().mode, ColorMode::Lab, "{sample:?} {layers}");
|
||||
assert_eq!(back.channels.len(), 1, "{sample:?} {layers}");
|
||||
assert_eq!(back.channels[0].name, "Spot Color 1");
|
||||
assert!(back.channels[0].spot.is_some(), "{sample:?} {layers}");
|
||||
let plate = |c: &AlphaChannel| c.surface.to_interleaved(Rect::new(0, 0, 4, 4));
|
||||
assert_eq!(plate(&back.channels[0]), plate(&doc.channels[0]), "{sample:?} {layers}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tag 37724 walked as raw bytes (not through [`ImageSourceData`]).
|
||||
///
|
||||
/// Matches a Photoshop 27 layered TIFF: classic 4-byte block lengths, `Layr` then a 14-byte
|
||||
/// `LMsk`, no `MTrn` (that block is only for a composite that keeps both transparency and a
|
||||
/// spot). A 4320×4320 RGB spot plate is 74_649_600 bytes and still fits those u32 lengths;
|
||||
/// this check uses the same layout on a smaller image.
|
||||
fn assert_photoshop_layered_tiff(bytes: &[u8], with_spot: bool) {
|
||||
assert!(bytes.starts_with(b"II"), "little-endian TIFF");
|
||||
let u16_at = |i: usize| u16::from_le_bytes([bytes[i], bytes[i + 1]]);
|
||||
let u32_at = |i: usize| u32::from_le_bytes(bytes[i..i + 4].try_into().unwrap());
|
||||
let ifd = u32_at(4) as usize;
|
||||
let n = u16_at(ifd) as usize;
|
||||
let mut layers = None;
|
||||
let mut spp = 0u16;
|
||||
let mut extra = Vec::new();
|
||||
for e in 0..n {
|
||||
let at = ifd + 2 + e * 12;
|
||||
let tag = u16_at(at);
|
||||
let typ = u16_at(at + 2);
|
||||
let count = u32_at(at + 4) as usize;
|
||||
let val = u32_at(at + 8) as usize;
|
||||
let unit: usize = match typ {
|
||||
3 => 2,
|
||||
4 => 4,
|
||||
_ => 1,
|
||||
};
|
||||
let size = unit.saturating_mul(count);
|
||||
let inline = at + 8;
|
||||
let raw = if size <= 4 { &bytes[inline..inline + size] } else { &bytes[val..val + size] };
|
||||
match tag {
|
||||
277 => spp = u16::from_le_bytes([raw[0], raw[1]]),
|
||||
338 => extra = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(),
|
||||
37724 => layers = Some(raw),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let isd = layers.expect("37724");
|
||||
let sig = b"Adobe Photoshop Document Data Block\0";
|
||||
assert!(isd.starts_with(sig), "PSB-sized lengths");
|
||||
let mut pos = sig.len();
|
||||
let mut keys = Vec::new();
|
||||
let mut layr: Option<Vec<u8>> = None;
|
||||
while pos + 12 <= isd.len() {
|
||||
assert_eq!(&isd[pos..pos + 4], b"MIB8", "block at {pos}");
|
||||
let key = [isd[pos + 7], isd[pos + 6], isd[pos + 5], isd[pos + 4]];
|
||||
let len = u32::from_le_bytes(isd[pos + 8..pos + 12].try_into().unwrap()) as usize;
|
||||
let body = &isd[pos + 12..pos + 12 + len];
|
||||
if &key == b"Layr" {
|
||||
layr = Some(body.to_vec());
|
||||
}
|
||||
if &key == b"LMsk" {
|
||||
assert_eq!(body, &[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0x32, 0, 0x80, 0]);
|
||||
}
|
||||
keys.push(key);
|
||||
pos += 12 + len;
|
||||
let rest = &isd[pos..];
|
||||
let pad = (0..=3).find(|&k| k == rest.len() || rest.get(k..k + 4) == Some(b"MIB8")).unwrap_or(0);
|
||||
pos += pad;
|
||||
}
|
||||
assert_eq!(pos, isd.len(), "trailing bytes after the last block");
|
||||
assert_eq!(keys.first(), Some(b"Layr"));
|
||||
assert_eq!(keys.get(1), Some(b"LMsk"));
|
||||
assert!(!keys.iter().any(|k| k == b"MTrn" || k == b"Mtrn"));
|
||||
let layr = layr.expect("Layr");
|
||||
let count = i16::from_le_bytes([layr[0], layr[1]]);
|
||||
if with_spot {
|
||||
assert!(count > 0, "layer count {count}");
|
||||
assert_eq!((spp, extra.as_slice()), (4, [0u16].as_slice()));
|
||||
} else {
|
||||
assert!(count < 0, "layer count {count}");
|
||||
assert_eq!(extra, vec![2]);
|
||||
}
|
||||
let nch = u16::from_le_bytes([layr[18], layr[19]]) as usize;
|
||||
let mut ids = Vec::new();
|
||||
let mut p = 20;
|
||||
for _ in 0..nch {
|
||||
ids.push(i16::from_le_bytes([layr[p], layr[p + 1]]));
|
||||
p += 6;
|
||||
}
|
||||
assert!(ids.contains(&-1), "channel ids {ids:?}");
|
||||
}
|
||||
|
||||
/// Photoshop 27's Kato-Karate TIFF stores spot ink inverted: every non-white artwork pixel
|
||||
/// has spot 0, and the white background is spot 255. An alpha channel is the other way
|
||||
/// around (255 = selected). Photoshop's PSDs store both the same way, and so does a PSD saved
|
||||
/// here. Everything round-trips, PSD → TIFF → PSD included.
|
||||
#[test]
|
||||
fn spot_ink_is_stored_as_zero_and_alpha_is_not() {
|
||||
use photocraft_color::{Color, PixelFormat};
|
||||
use photocraft_doc::{AlphaChannel, Layer};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_psd::PsdFile;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let (w, h) = (8u32, 8u32);
|
||||
let mut doc = Document::new("pol", Size::new(w, h), ColorMode::Rgb, depth);
|
||||
let fmt = doc.pixel_format();
|
||||
let mut art = Surface::new(fmt);
|
||||
art.fill_rect(Rect::new(2, 2, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.2, 0.1, 1.0]));
|
||||
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
|
||||
let gray = PixelFormat::new(ColorMode::Grayscale, depth, false);
|
||||
let n = (w as usize) * (h as usize);
|
||||
let mut ink = vec![0.0f32; n];
|
||||
for y in 2..6 {
|
||||
for x in 2..6 {
|
||||
ink[y * w as usize + x] = 1.0;
|
||||
}
|
||||
}
|
||||
let mut spot = AlphaChannel::new("Spot Color 1", Surface::new(gray));
|
||||
spot.surface.write_region(Rect::new(0, 0, w as i32, h as i32), &ink);
|
||||
spot.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6));
|
||||
doc.channels.push(spot);
|
||||
let mut alpha = AlphaChannel::new("Alpha 1", Surface::new(gray));
|
||||
alpha.surface.write_region(Rect::new(0, 0, w as i32, h as i32), &ink);
|
||||
doc.channels.push(alpha);
|
||||
|
||||
let psd = export(&doc, "pol.psd", &ExportOptions::default()).unwrap();
|
||||
let file = PsdFile::from_bytes(&psd.bytes).unwrap();
|
||||
let merged = file.decode_merged().unwrap();
|
||||
let bps = depth.bytes();
|
||||
let plane = n * bps;
|
||||
let first = 3 + usize::from(file.merged_has_alpha());
|
||||
let spot_plane = &merged[first * plane..(first + 1) * plane];
|
||||
let alpha_plane = &merged[(first + 1) * plane..(first + 2) * plane];
|
||||
// Background (0,0) and artwork (3,3). Spot: 0 = solid ink, max = paper.
|
||||
// Alpha: max = selected, 0 = empty.
|
||||
assert_stored(spot_plane, 0, depth, false, "psd spot background");
|
||||
assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, true, "psd spot art");
|
||||
assert_stored(alpha_plane, 0, depth, true, "psd alpha background");
|
||||
assert_stored(alpha_plane, (3 * w as usize + 3) * bps, depth, false, "psd alpha art");
|
||||
// The merged transparency (0), then the spot and the alpha channel (3, 4).
|
||||
assert_eq!(file.resource(1053).map(|r| identifiers(&r.data)), Some(vec![0, 3, 4]), "{depth:?}");
|
||||
let back = import("pol.psd", &psd.bytes).unwrap().document;
|
||||
let area = Rect::new(0, 0, w as i32, h as i32);
|
||||
assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{depth:?} psd spot");
|
||||
assert_eq!(back.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{depth:?} psd alpha");
|
||||
assert!(back.channels[0].spot.is_some(), "{depth:?}");
|
||||
assert!(back.channels[1].spot.is_none(), "{depth:?}");
|
||||
|
||||
if depth == SampleType::U8 {
|
||||
for layers in [false, true] {
|
||||
// The document as opened from the PSD, so the PSD's own resources come along.
|
||||
let r = export(&back, "pol.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap();
|
||||
let (spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes);
|
||||
assert_eq!((spp, extra.as_slice()), (5, [0u16, 0].as_slice()), "{layers}");
|
||||
// Reference TIFF: artwork spot sample is 0, background spot sample is 255.
|
||||
assert_eq!(samples[3], 255, "{layers} background spot");
|
||||
assert_eq!(samples[(3 * w as usize + 3) * 5 + 3], 0, "{layers} art spot");
|
||||
assert_eq!(samples[4], 0, "{layers} background alpha");
|
||||
assert_eq!(samples[(3 * w as usize + 3) * 5 + 4], 255, "{layers} art alpha");
|
||||
// One identifier per extra sample, numbered from 3 (no transparency sample).
|
||||
assert_eq!(tiff_alpha_identifiers(&r.bytes), [3, 4], "{layers}");
|
||||
let from_tiff = import("pol.tif", &r.bytes).unwrap().document;
|
||||
assert_eq!(from_tiff.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}");
|
||||
assert_eq!(from_tiff.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}");
|
||||
assert!(from_tiff.channels[0].spot.is_some() && from_tiff.channels[1].spot.is_none(), "{layers}");
|
||||
// Saved as PSD again: the first PSD's planes, and one 1053 of its own. The flat TIFF's
|
||||
// composite was matted on white, so only the layered one brings transparency back.
|
||||
let psd = PsdFile::from_bytes(&export(&from_tiff, "pol.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
let again = psd.decode_merged().unwrap();
|
||||
assert_eq!(again[again.len() - 2 * plane..], merged[(first * plane)..(first + 2) * plane], "{layers} tiff to psd");
|
||||
assert_eq!(psd.merged_has_alpha(), layers, "{layers} tiff to psd");
|
||||
let want: Vec<u32> = if layers { vec![0, 3, 4] } else { vec![3, 4] };
|
||||
assert_eq!(psd.resources.iter().filter(|r| r.id == 1053).map(|r| identifiers(&r.data)).collect::<Vec<_>>(), [want], "{layers} tiff to psd");
|
||||
let reopened = import("pol.psd", &psd.to_bytes().unwrap()).unwrap().document;
|
||||
assert_eq!(reopened.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}");
|
||||
assert_eq!(reopened.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The big-endian u32s of a resource 1053.
|
||||
fn identifiers(data: &[u8]) -> Vec<u32> {
|
||||
data.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()
|
||||
}
|
||||
|
||||
/// The identifiers in a TIFF's resource 1053 (none without one).
|
||||
fn tiff_alpha_identifiers(bytes: &[u8]) -> Vec<u32> {
|
||||
let resources = tags(bytes).0.expect("photoshop resources");
|
||||
let (blocks, _) = photocraft_psd::tiff::resources_from_bytes(&resources).unwrap();
|
||||
blocks.iter().find(|b| b.id == 1053).map(|b| identifiers(&b.data)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `at` addresses one sample of a big-endian PSD plane. `zero` expects 0; otherwise the
|
||||
/// maximum sample (255 / 65535 / 1.0).
|
||||
fn assert_stored(plane: &[u8], at: usize, depth: SampleType, zero: bool, what: &str) {
|
||||
match depth {
|
||||
SampleType::U8 => {
|
||||
let v = plane[at];
|
||||
assert_eq!(v, if zero { 0 } else { 255 }, "{what} {v}");
|
||||
}
|
||||
SampleType::U16 => {
|
||||
let v = u16::from_be_bytes([plane[at], plane[at + 1]]);
|
||||
assert_eq!(v, if zero { 0 } else { 65535 }, "{what} {v}");
|
||||
}
|
||||
SampleType::F32 => {
|
||||
let v = f32::from_be_bytes([plane[at], plane[at + 1], plane[at + 2], plane[at + 3]]);
|
||||
assert_eq!(v, if zero { 0.0 } else { 1.0 }, "{what} {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick Mask stays the last channel after the merged transparency plane is omitted.
|
||||
#[test]
|
||||
fn spot_tiff_keeps_quick_mask_when_composite_alpha_is_omitted() {
|
||||
use photocraft_color::{Color, PixelFormat};
|
||||
use photocraft_doc::{AlphaChannel, Layer};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
let mut doc = Document::new("q", Size::new(8, 8), ColorMode::Rgb, SampleType::U8);
|
||||
let fmt = doc.pixel_format();
|
||||
let mut art = Surface::new(fmt);
|
||||
art.fill_rect(Rect::new(1, 1, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5]));
|
||||
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
|
||||
let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false);
|
||||
let area = Rect::new(0, 0, 8, 8);
|
||||
let mut spot_bytes = vec![9u8; 64];
|
||||
spot_bytes[0] = 3;
|
||||
let mut channel = AlphaChannel::new("Spot Color 1", Surface::from_interleaved(gray, area, &spot_bytes));
|
||||
channel.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6));
|
||||
doc.channels.push(channel);
|
||||
let mut qbytes = vec![7u8; 64];
|
||||
qbytes[1] = 4;
|
||||
doc.quick_mask = Some(AlphaChannel::new("Quick Mask", Surface::from_interleaved(gray, area, &qbytes)));
|
||||
|
||||
for layers in [false, true] {
|
||||
let r = export(&doc, "q.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap();
|
||||
let (spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes);
|
||||
assert_eq!((spp, extra.as_slice()), (5, [0, 0].as_slice()), "{layers}");
|
||||
let stored_spot: Vec<u8> = spot_bytes.iter().map(|b| 255 - b).collect();
|
||||
assert_plane(&samples, 5, 3, &stored_spot);
|
||||
// A Quick Mask is an alpha channel: stored straight, not as spot ink.
|
||||
assert_plane(&samples, 5, 4, &qbytes);
|
||||
let back = import("q.tif", &r.bytes).unwrap().document;
|
||||
assert_eq!(back.channels.len(), 1, "{layers}");
|
||||
assert_eq!(back.channels[0].surface.to_interleaved(area), spot_bytes, "{layers}");
|
||||
let qm = back.quick_mask.as_ref().unwrap_or_else(|| panic!("{layers}: no quick mask"));
|
||||
assert_eq!(qm.surface.to_interleaved(area), qbytes, "{layers}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_export_options_write_a_flat_tiff() {
|
||||
// Scripted and agent saves (CLI, batch, MCP) build on the defaults: layers only on request.
|
||||
@@ -399,3 +1054,70 @@ fn layered_tiff_nested_past_the_cap_is_rejected() {
|
||||
let r = import("hundred.tif", &tiff_nested(100)).unwrap();
|
||||
assert_eq!(r.document.max_group_depth(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_tiff_writes_photoshops_global_layer_mask() {
|
||||
// #9095: Photoshop reads only the composite of a layered TIFF whose `LMsk` block is empty
|
||||
// ("There was a problem reading the layer data"); it writes 14 bytes there itself: overlay
|
||||
// colour space 0, colour (65535, 0, 0, 0), opacity 50, kind 128, one filler byte.
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
let d = gen_doc(ColorMode::Rgb, depth, Features::PIXELS);
|
||||
let (_, layers) = tags(&tiff(&d, true).bytes);
|
||||
let (isd, _) = ImageSourceData::from_bytes(&layers.expect("layer data")).expect("parse layer data");
|
||||
let mask = isd.global_layer_mask.expect("LMsk block");
|
||||
assert_eq!(mask.data, [0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0], "{depth:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Past MAX_EXTRA_SAMPLES (16), a layered TIFF stores the first 16 extra channels. Its channel
|
||||
/// resources must describe those 16 and no more: 16 names in 1006 and 1045, 16 DisplayInfo
|
||||
/// records and 16 identifiers, and no Quick Mask record for a Quick Mask that wasn't stored.
|
||||
#[test]
|
||||
fn channels_past_sixteen_leave_no_resources_behind() {
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_doc::{AlphaChannel, Layer};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
let mut doc = Document::new("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8);
|
||||
let fmt = doc.pixel_format();
|
||||
let mut art = Surface::new(fmt);
|
||||
art.fill_rect(Rect::new(0, 0, 8, 8), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));
|
||||
doc.layers.push(Layer::new("Art", LayerContent::Raster(art)));
|
||||
let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false);
|
||||
for i in 1..=18 {
|
||||
doc.channels.push(AlphaChannel::new(format!("Alpha {i}"), Surface::new(gray)));
|
||||
}
|
||||
doc.quick_mask = Some(AlphaChannel::new("Quick Mask", Surface::new(gray)));
|
||||
|
||||
let r = tiff(&doc, true);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("19 extra channels") && w.contains("first 16")), "{:?}", r.warnings);
|
||||
let (resources, _) = tags(&r.bytes);
|
||||
let (resources, _) = photocraft_psd::tiff::resources_from_bytes(&resources.expect("resources")).expect("parse resources");
|
||||
let data = |id: u16| resources.iter().find(|r| r.id == id).map(|r| r.data.clone()).unwrap_or_default();
|
||||
let pascal = data(1006);
|
||||
let (mut at, mut names) = (0usize, 0usize);
|
||||
while let Some(&len) = pascal.get(at) {
|
||||
at += 1 + usize::from(len);
|
||||
names += 1;
|
||||
}
|
||||
assert_eq!(names, 16, "1006");
|
||||
let unicode = data(1045);
|
||||
let (mut at, mut names) = (0usize, 0usize);
|
||||
while let Some(b) = unicode.get(at..at + 4) {
|
||||
at += 4 + 2 * u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize;
|
||||
names += 1;
|
||||
}
|
||||
assert_eq!(names, 16, "1045");
|
||||
assert_eq!(data(1077).len(), 4 + 16 * 13, "1077");
|
||||
assert_eq!(data(1053).len(), 16 * 4, "1053");
|
||||
assert!(resources.iter().all(|r| r.id != 1022), "the Quick Mask was not stored, so its record goes");
|
||||
|
||||
let flat = photocraft_codecs::decode(&r.bytes).expect("decode");
|
||||
assert_eq!(flat.meta.extra_samples.len(), 16);
|
||||
let back = import("x.tif", &r.bytes).expect("import").document;
|
||||
let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect();
|
||||
let want: Vec<String> = (1..=16).map(|i| format!("Alpha {i}")).collect();
|
||||
assert_eq!(names, want.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
assert!(back.quick_mask.is_none());
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@ use photocraft_doc::text::{CharStyle, TextRun};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, Size, TextLayer};
|
||||
use photocraft_geom::Affine;
|
||||
use photocraft_io::{ExportOptions, PsdExportOptions, document_to_psd_with, export, import, psd_to_document};
|
||||
use photocraft_psd::descriptor::{Value, VersionedDescriptor};
|
||||
|
||||
fn document() -> Document {
|
||||
let mut doc = Document::new("Synthetic type", Size::new(640, 400), ColorMode::Rgb, SampleType::U8);
|
||||
document_in(ColorMode::Rgb, SampleType::U8)
|
||||
}
|
||||
|
||||
fn document_in(mode: ColorMode, depth: SampleType) -> Document {
|
||||
let mut doc = Document::new("Synthetic type", Size::new(640, 400), mode, depth);
|
||||
doc.resolution_dpi = 300.0;
|
||||
let mut t = TextLayer {
|
||||
text: "Synthetic".into(),
|
||||
@@ -46,6 +51,49 @@ fn photoshop_mixed_order_tiff_import_keeps_editable_geometry_and_pixels() {
|
||||
assert_eq!(rerendered.content_bounds(), after.cache.as_ref().unwrap().content_bounds());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_tiff_export_keeps_tysh_text_descriptor_big_endian() {
|
||||
for (mode, depth) in [
|
||||
(ColorMode::Rgb, SampleType::U8),
|
||||
(ColorMode::Rgb, SampleType::U16),
|
||||
(ColorMode::Rgb, SampleType::F32),
|
||||
(ColorMode::Grayscale, SampleType::U8),
|
||||
(ColorMode::Grayscale, SampleType::U16),
|
||||
(ColorMode::Grayscale, SampleType::F32),
|
||||
(ColorMode::Cmyk, SampleType::U8),
|
||||
(ColorMode::Cmyk, SampleType::U16),
|
||||
] {
|
||||
let doc = document_in(mode, depth);
|
||||
let (psd, _) = document_to_psd_with(&doc, &PsdExportOptions::default());
|
||||
let canonical = &psd.layers()[0].block(b"TySh").unwrap().data;
|
||||
let encoded = export(&doc, "test.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap();
|
||||
let (_, layers) = photocraft_codecs::tiff_photoshop_tags(&encoded.bytes);
|
||||
let layers = layers.expect("layered TIFF tag 37724");
|
||||
let block_at = layers.windows(8).position(|window| window == b"MIB8hSyT").expect("little-endian TySh block");
|
||||
let length_at = block_at + 8;
|
||||
let length_bytes: [u8; 4] = layers.get(length_at..length_at + 4).expect("TySh length").try_into().expect("four-byte length");
|
||||
let length = usize::try_from(u32::from_le_bytes(length_bytes)).expect("test block length fits usize");
|
||||
let data_at = length_at + 4;
|
||||
let payload = layers.get(data_at..data_at + length).expect("TySh payload");
|
||||
let (_, descriptor_len) = VersionedDescriptor::parse_prefix(canonical.get(52..).unwrap()).unwrap();
|
||||
let text_section_len = 2 + descriptor_len;
|
||||
assert_eq!(payload.get(50..52), Some(&50u16.to_be_bytes()[..]), "{mode:?} {depth:?}");
|
||||
assert_eq!(payload.get(52..56), Some(&16u32.to_be_bytes()[..]), "{mode:?} {depth:?}");
|
||||
assert_eq!(payload.get(50..50 + text_section_len), canonical.get(50..50 + text_section_len), "{mode:?} {depth:?}");
|
||||
let descriptor = VersionedDescriptor::parse_prefix(payload.get(52..).unwrap()).unwrap().0;
|
||||
assert!(matches!(descriptor.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == doc.layers[0].name));
|
||||
|
||||
let opened = import("photoshop.tif", &encoded.bytes).unwrap();
|
||||
assert!(!opened.warnings.iter().any(|warning| warning.contains("TySh")), "{:?}", opened.warnings);
|
||||
let LayerContent::Text(before) = &doc.layers[0].content else { panic!("source type") };
|
||||
let LayerContent::Text(after) = &opened.document.layers[0].content else { panic!("imported type") };
|
||||
assert_eq!(after.transform, before.transform, "{mode:?} {depth:?}");
|
||||
assert_eq!(after.size_pt, before.size_pt, "{mode:?} {depth:?}");
|
||||
assert_eq!(after.text, before.text, "{mode:?} {depth:?}");
|
||||
assert_eq!(after.cache.as_ref().unwrap().content_bounds(), before.cache.as_ref().unwrap().content_bounds(), "{mode:?} {depth:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_or_unsafe_type_keeps_cached_pixels_without_editable_geometry() {
|
||||
let doc = document();
|
||||
|
||||
@@ -13,5 +13,10 @@ photocraft-color = { workspace = true }
|
||||
photocraft-raster = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
# Large tips rasterize, accumulate and composite in parallel (the same pixels as serially).
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rayon = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
photocraft-cms = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Where a large brush's time goes: one dab of a 2000 px tip, by stage.
|
||||
//! `cargo run --release -p photocraft-paint --example big_dab_bench [size] [hardness]`
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_paint::{BrushContext, BrushSettings, Dab, StrokePoint, StrokeRenderer};
|
||||
use photocraft_raster::Surface;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let size: f32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(2000.0);
|
||||
let hardness: f32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0);
|
||||
let brush = BrushSettings { size, hardness, spacing: 0.25, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() };
|
||||
let fmt = PixelFormat::RGBA8;
|
||||
let mut pre = Surface::new(fmt);
|
||||
pre.fill_rect(Rect::new(0, 0, 4928, 3280), &[0.5, 0.5, 0.5, 1.0]);
|
||||
let mut target = pre.clone();
|
||||
// Stage 1: the tip's coverage alone.
|
||||
let ctx = BrushContext::new(&brush);
|
||||
let dab = Dab::round(photocraft_geom::Point::new(2464.0, 1640.0), size / 2.0, 1.0);
|
||||
let rect = ctx.dab_rect(&dab, false);
|
||||
let mut cov = Vec::new();
|
||||
let t = Instant::now();
|
||||
ctx.rasterize(&dab, false, rect, &mut cov);
|
||||
let raster = t.elapsed();
|
||||
println!("tip {size} px, hardness {hardness}: dab rect {}x{} = {:.1} MP", rect.width(), rect.height(), rect.width() as f64 * rect.height() as f64 / 1e6);
|
||||
println!(" rasterize (coverage): {:>7.1} ms", raster.as_secs_f64() * 1e3);
|
||||
// Stage 2+3: push through the renderer (rasterize + accumulate), then composite.
|
||||
let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0);
|
||||
let t = Instant::now();
|
||||
// Two points a pixel apart: the generator places the first dab at once (a lone point waits).
|
||||
r.push(&[StrokePoint::new(2464.0, 1640.0, 1.0), StrokePoint::new(2465.0, 1640.0, 1.0)]);
|
||||
let push = t.elapsed();
|
||||
println!(" push (rasterize+accumulate): {:>5.1} ms (accumulate ≈ {:.1} ms)", push.as_secs_f64() * 1e3, push.saturating_sub(raster).as_secs_f64() * 1e3);
|
||||
let t = Instant::now();
|
||||
let dmg = r.composite(&pre, &mut target, None, false, false);
|
||||
let comp = t.elapsed();
|
||||
println!(" composite (dirty tiles): {:>7.1} ms over {}x{}", comp.as_secs_f64() * 1e3, dmg.width(), dmg.height());
|
||||
// A second dab half a spacing away, as a stroke does: the same again.
|
||||
let t = Instant::now();
|
||||
r.push(&[StrokePoint::new(2464.0 + f64::from(size) * 0.25, 1640.0, 1.0), StrokePoint::new(2466.0 + f64::from(size) * 0.25, 1640.0, 1.0)]);
|
||||
let dmg = r.composite(&pre, &mut target, None, false, false);
|
||||
println!(" next dab push+composite: {:>7.1} ms over {}x{}", t.elapsed().as_secs_f64() * 1e3, dmg.width(), dmg.height());
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! A long stroke of one tip, timed per dab: a zigzag over a 4928×3280 RGBA8 document that sweeps
|
||||
//! the whole page, `push` then `composite` after every point, as painting does per frame. Small
|
||||
//! tips are the everyday case; a long stroke grows the stroke's tile map to thousands of tiles,
|
||||
//! and the time per dab must not grow with it.
|
||||
//! `cargo run --release -p photocraft-paint --example stroke_bench -- [size] [hardness] [dabs]`
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_paint::{BrushSettings, StrokePoint, StrokeRenderer};
|
||||
use photocraft_raster::Surface;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let size: f32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(20.0);
|
||||
let hardness: f32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0);
|
||||
let dabs: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(6000);
|
||||
let (w, h) = (4928.0f64, 3280.0f64);
|
||||
let brush = BrushSettings { size, hardness, spacing: 0.25, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() };
|
||||
let fmt = PixelFormat::RGBA8;
|
||||
let mut pre = Surface::new(fmt);
|
||||
pre.fill_rect(Rect::new(0, 0, w as i32, h as i32), &[0.5, 0.5, 0.5, 1.0]);
|
||||
let mut target = pre.clone();
|
||||
// Segments from the top margin to the bottom one and back, advancing across the width so that
|
||||
// `dabs` dabs at the brush's spacing sweep the document once.
|
||||
let step = f64::from(size) * 0.25;
|
||||
let margin = f64::from(size);
|
||||
let span = h - 2.0 * margin;
|
||||
let segments = ((dabs as f64 * step) / span).ceil().max(1.0) as usize;
|
||||
let dx = (w - 2.0 * margin) / segments as f64;
|
||||
let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0);
|
||||
let (mut t_push, mut t_comp) = (0.0f64, 0.0f64);
|
||||
let t_all = Instant::now();
|
||||
let mut pos = (margin, margin);
|
||||
r.push(&[StrokePoint::new(pos.0, pos.1, 1.0)]);
|
||||
let mut pushes = 0usize;
|
||||
'stroke: for seg in 0..segments {
|
||||
let (x0, y0) = pos;
|
||||
let (x1, y1) = (margin + (seg + 1) as f64 * dx, if seg % 2 == 0 { h - margin } else { margin });
|
||||
let n = (((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt() / step).ceil().max(1.0) as usize;
|
||||
for i in 1..=n {
|
||||
let f = i as f64 / n as f64;
|
||||
let t = Instant::now();
|
||||
r.push(&[StrokePoint::new(x0 + (x1 - x0) * f, y0 + (y1 - y0) * f, 1.0)]);
|
||||
let tp = t.elapsed().as_secs_f64();
|
||||
r.composite(&pre, &mut target, None, false, false);
|
||||
t_comp += t.elapsed().as_secs_f64() - tp;
|
||||
t_push += tp;
|
||||
pushes += 1;
|
||||
if r.dab_count() >= dabs {
|
||||
break 'stroke;
|
||||
}
|
||||
}
|
||||
pos = (x1, y1);
|
||||
}
|
||||
let total = t_all.elapsed().as_secs_f64();
|
||||
let placed = r.dab_count();
|
||||
println!(
|
||||
"tip {size} px, hardness {hardness}: {placed} dabs in {pushes} pushes over {segments} segments: {:.0} ms (push {:.0}, composite {:.0}), {:.1} µs per dab",
|
||||
total * 1e3,
|
||||
t_push * 1e3,
|
||||
t_comp * 1e3,
|
||||
total * 1e6 / placed.max(1) as f64
|
||||
);
|
||||
}
|
||||
@@ -278,6 +278,58 @@ fn stroke_has_no_seams() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_round_presets_have_smooth_large_stroke_outlines() {
|
||||
// #8842: the centre can be solid while widely spaced discs scallop the outline.
|
||||
// Measure the rendered 50% edge of a 150 px stroke, including its sub-pixel position.
|
||||
for preset in crate::presets::builtin().into_iter().filter(|p| p.name.starts_with("Hard Round")) {
|
||||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
for smoothing in [0.0, 0.49] {
|
||||
let mut b = preset.brush.clone();
|
||||
b.size = 150.0;
|
||||
b.smoothing.amount = smoothing;
|
||||
let mut s = Surface::new(fmt);
|
||||
render_stroke(&mut s, &b, &line(100.0, 800.0, 120.0), None, false, 1.0);
|
||||
let (mut min, mut max) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
for x in 220..680 {
|
||||
let edge = (0..120)
|
||||
.find_map(|y| {
|
||||
let (a, next) = (s.rgba(x, y)[3], s.rgba(x, y + 1)[3]);
|
||||
(a <= 0.5 && next > 0.5).then(|| y as f32 + (0.5 - a) / (next - a))
|
||||
})
|
||||
.expect("a full-pressure stroke has a half-coverage edge");
|
||||
min = min.min(edge);
|
||||
max = max.max(edge);
|
||||
}
|
||||
assert!(max - min < 0.25, "{} {fmt:?} smoothing {smoothing}: scalloped edge spans {} px", preset.name, max - min);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_round_presets_have_continuous_pressure_opacity_tapers() {
|
||||
// #8842 also shows nested disc boundaries when pressure controls opacity and size.
|
||||
// A smooth input ramp must not turn into several-percent steps between adjacent pixels.
|
||||
let points: Vec<_> =
|
||||
(0..=100).map(|i| StrokePoint { time: i as f64 * 8.0, ..StrokePoint::new(100.0 + i as f64 * 7.0, 120.0, 0.95 - i as f32 * 0.009) }).collect();
|
||||
for preset in crate::presets::builtin().into_iter().filter(|p| p.name.starts_with("Hard Round")) {
|
||||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
for smoothing in [0.0, 0.49] {
|
||||
let mut b = preset.brush.clone();
|
||||
b.size = 150.0;
|
||||
b.pressure_opacity = true;
|
||||
b.smoothing.amount = smoothing;
|
||||
let mut s = Surface::new(fmt);
|
||||
render_stroke(&mut s, &b, &points, None, false, 1.0);
|
||||
assert!(s.rgba(200, 120)[3] > 0.7 && s.rgba(699, 120)[3] < 0.3, "{}: the stroke must still taper", preset.name);
|
||||
let max_jump = (200..699).map(|x| (s.rgba(x + 1, 120)[3] - s.rgba(x, 120)[3]).abs()).fold(0.0f32, f32::max);
|
||||
assert!(max_jump < 0.02, "{} {fmt:?} smoothing {smoothing}: opacity jumps by {max_jump}", preset.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- dynamics ----------
|
||||
|
||||
#[test]
|
||||
@@ -1688,6 +1740,106 @@ fn an_aliased_stroke_at_partial_opacity_is_one_flat_colour() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Large tips rasterize, accumulate and composite in parallel (rows of a dab, tiles of a stroke);
|
||||
/// the pixels are the ones the serial path gives, with 8-bit dithering, a selection, Dissolve and
|
||||
/// per-dab colour included.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn large_tips_render_the_same_in_parallel_and_serially() {
|
||||
let mut sel = Surface::new(PixelFormat::GRAY8);
|
||||
sel.fill_rect(Rect::new(0, 0, 900, 700), &[1.0]);
|
||||
sel.fill_rect(Rect::new(300, 0, 400, 700), &[0.0]);
|
||||
let strokes: Vec<(BrushSettings, Vec<StrokePoint>)> = vec![
|
||||
(BrushSettings { size: 700.0, hardness: 0.0, flow: 0.6, color: [0.9, 0.2, 0.1, 1.0], ..brush() }, line(200.0, 700.0, 350.0)),
|
||||
(
|
||||
BrushSettings { size: 420.0, hardness: 1.0, opacity: 0.5, mode: BlendMode::Dissolve, color: [0.1, 0.5, 0.9, 1.0], ..brush() },
|
||||
line(150.0, 750.0, 300.0),
|
||||
),
|
||||
(
|
||||
BrushSettings {
|
||||
size: 500.0,
|
||||
hardness: 0.3,
|
||||
color_dynamics: ColorDynamics { enabled: true, hue_jitter: 0.5, per_tip: true, ..Default::default() },
|
||||
color: [0.2, 0.8, 0.3, 1.0],
|
||||
..brush()
|
||||
},
|
||||
line(100.0, 800.0, 400.0),
|
||||
),
|
||||
];
|
||||
let render = |parallel_from: usize| -> Vec<Vec<f32>> {
|
||||
crate::render::set_parallel_min_pixels(parallel_from);
|
||||
strokes
|
||||
.iter()
|
||||
.map(|(b, pts)| {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 900, 700), &[0.5, 0.5, 0.5, 1.0]);
|
||||
let (mut with_sel, mut plain) = (s.clone(), s);
|
||||
render_stroke(&mut with_sel, b, pts, Some(&sel), false, 1.0);
|
||||
render_stroke(&mut plain, b, pts, None, false, 1.0);
|
||||
let mut px = with_sel.read_region(Rect::new(0, 0, 900, 700));
|
||||
px.extend(plain.read_region(Rect::new(0, 0, 900, 700)));
|
||||
px
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// The threshold is process-wide: put the default back however the test ends, a panic
|
||||
// inside a render included.
|
||||
struct Restore;
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
crate::render::set_parallel_min_pixels(crate::render::DEFAULT_PARALLEL_MIN_PIXELS);
|
||||
}
|
||||
}
|
||||
let _restore = Restore;
|
||||
let parallel = render(0);
|
||||
let serial = render(usize::MAX);
|
||||
for (i, (p, s)) in parallel.iter().zip(&serial).enumerate() {
|
||||
assert_eq!(p.len(), s.len());
|
||||
let diff = p.iter().zip(s).filter(|(a, b)| a != b).count();
|
||||
assert_eq!(diff, 0, "stroke {i}: {diff} pixels differ between the parallel and the serial render");
|
||||
assert!(p.iter().any(|v| (*v - 0.5).abs() > 0.1), "stroke {i} painted something");
|
||||
}
|
||||
}
|
||||
|
||||
/// A CMYK document with its own profile: blend modes other than Normal convert each pixel through
|
||||
/// that profile, which is active per thread, so a stroke composited in parallel gives the pixels the
|
||||
/// serial path gives (the workers enter the caller's CMYK space too).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_parallel_composite_converts_through_the_documents_cmyk_profile() {
|
||||
use photocraft_cms::synth::{CmykParams, cmyk_profile};
|
||||
use photocraft_color::convert::{CmykSpace, with_cmyk_space};
|
||||
let profile =
|
||||
cmyk_profile(&CmykParams { description: "Brush Test CMYK".into(), tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() });
|
||||
let bytes = profile.to_bytes();
|
||||
let Some(space) = CmykSpace::for_profile(Some(&bytes)) else { panic!("the synthetic profile is a CMYK space") };
|
||||
let fmt = PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true);
|
||||
// A 40 px Multiply line over 1100 px dirties more than 16 tiles: the composite's batch is
|
||||
// worth the threads at the default threshold, and always at threshold 0.
|
||||
let b = BrushSettings { size: 40.0, hardness: 0.5, mode: BlendMode::Multiply, color: [0.2, 0.4, 0.9, 1.0], ..brush() };
|
||||
let pts = line(40.0, 1140.0, 60.0);
|
||||
let full = Rect::new(0, 0, 1200, 120);
|
||||
let render = |parallel_from: usize| -> Vec<f32> {
|
||||
crate::render::set_parallel_min_pixels(parallel_from);
|
||||
let mut s = Surface::new(fmt);
|
||||
s.fill_rect(full, &[0.1, 0.5, 0.3, 0.05, 1.0]);
|
||||
with_cmyk_space(Some(&space), || render_stroke(&mut s, &b, &pts, None, false, 1.0));
|
||||
s.read_region(full)
|
||||
};
|
||||
struct Restore;
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
crate::render::set_parallel_min_pixels(crate::render::DEFAULT_PARALLEL_MIN_PIXELS);
|
||||
}
|
||||
}
|
||||
let _restore = Restore;
|
||||
let parallel = render(0);
|
||||
let serial = render(usize::MAX);
|
||||
let diff = parallel.iter().zip(&serial).filter(|(a, b)| a != b).count();
|
||||
assert_eq!(diff, 0, "{diff} samples differ between the parallel and the serial composite in a profiled CMYK document");
|
||||
assert!(serial.chunks(5).any(|p| (p[1] - 0.5).abs() > 0.05), "the stroke painted something");
|
||||
}
|
||||
|
||||
/// Photoshop (measured): a 100 px brush with a 20 px Dual Brush tip resized to 50 / 2 / 1 px has a
|
||||
/// 10 / 0.4 / 0.2 px dual tip, unrounded and with no floor, and back at 100 px it is 20 px again; past
|
||||
/// 5000 px the dual tip is capped there and the proportion is lost (#4809).
|
||||
|
||||
+189
-56
@@ -6,7 +6,7 @@
|
||||
//! weight. The reservoir amount drains per dab unless `load` is 100 %, so low Load strokes dry out.
|
||||
//! A dry brush (`wet = 0`) paints only reservoir colour; an empty, wet brush smears canvas colour.
|
||||
|
||||
use photocraft_raster::{Surface, from_rgba, to_rgba};
|
||||
use photocraft_raster::{Surface, from_rgba, from_rgba_into, to_rgba};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Stroke;
|
||||
@@ -27,6 +27,7 @@ pub struct MixerSettings {
|
||||
/// Pick up colour from all visible layers instead of the target layer only.
|
||||
pub sample_all_layers: bool,
|
||||
/// Load the brush with the stroke color after each stroke (the automatic Load option).
|
||||
#[serde(alias = "reloadAfterStroke")]
|
||||
pub load_after_stroke: bool,
|
||||
/// Empty the brush after each stroke (the automatic Clean option).
|
||||
pub clean_after_stroke: bool,
|
||||
@@ -34,7 +35,7 @@ pub struct MixerSettings {
|
||||
|
||||
impl Default for MixerSettings {
|
||||
fn default() -> Self {
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, load_after_stroke: true, clean_after_stroke: true }
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, clean_after_stroke: true, load_after_stroke: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +49,8 @@ pub struct MixerState {
|
||||
pub amount: f32,
|
||||
/// Colour picked up from the canvas (straight RGBA), None = clean.
|
||||
pub pickup: Option<[f32; 4]>,
|
||||
/// A bounded 64 x 64 spatial load in tip-local coordinates. Empty cells carry no paint.
|
||||
pub spatial: Option<Vec<[f32; 4]>>,
|
||||
/// The color recorded by the last stroke (its color) or the last Load Brush or Clean Brush
|
||||
/// (the foreground). None = no stroke, load or clean yet.
|
||||
pub color: Option<[f32; 4]>,
|
||||
@@ -56,11 +59,13 @@ pub struct MixerState {
|
||||
impl MixerState {
|
||||
/// Load Brush: fill the reservoir with a color (full).
|
||||
pub fn load(&mut self, color: [f32; 4]) {
|
||||
self.spatial = None;
|
||||
self.reservoir = Some(color);
|
||||
self.amount = 1.0;
|
||||
}
|
||||
/// Clean Brush: empty the reservoir and the pickup well.
|
||||
pub fn clean(&mut self) {
|
||||
self.spatial = None;
|
||||
self.reservoir = None;
|
||||
self.amount = 0.0;
|
||||
self.pickup = None;
|
||||
@@ -76,11 +81,16 @@ impl MixerState {
|
||||
/// After a stroke in `color`: the automatic Clean empties the brush, then the automatic Load
|
||||
/// fills the reservoir again.
|
||||
pub fn end_stroke(&mut self, color: [f32; 4], clean: bool, load: bool) {
|
||||
// Automatic Load replenishes a sampled tip as well as a solid-color load.
|
||||
// Move its bounded buffer through Clean instead of allocating a copy.
|
||||
let spatial = if load { self.spatial.take() } else { None };
|
||||
let paint = if spatial.is_some() { self.reservoir.unwrap_or(color) } else { color };
|
||||
if clean {
|
||||
self.clean();
|
||||
}
|
||||
if load {
|
||||
self.load(color);
|
||||
self.load(paint);
|
||||
self.spatial = spatial;
|
||||
}
|
||||
self.color = Some(color);
|
||||
}
|
||||
@@ -91,6 +101,34 @@ impl MixerState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample a spatial load at a dab pixel using the dab's rotation, aspect and flips.
|
||||
/// Keeping the reservoir in tip coordinates lets it follow subsequent brush poses.
|
||||
pub fn load_at(load: &[[f32; 4]], dab: &crate::Dab, x: f32, y: f32) -> [f32; 4] {
|
||||
let mut dx = x - dab.center.x as f32;
|
||||
let mut dy = y - dab.center.y as f32;
|
||||
if dab.proj_scale < 0.999 {
|
||||
let (sin, cos) = dab.proj_angle.sin_cos();
|
||||
let along = (dx * cos - dy * sin) * (1.0 / dab.proj_scale.max(0.05) - 1.0);
|
||||
dx += along * cos;
|
||||
dy -= along * sin;
|
||||
}
|
||||
let (sin, cos) = dab.angle.sin_cos();
|
||||
let mut u = (dx * cos - dy * sin) / dab.radius.max(0.01);
|
||||
let mut v = (dx * sin + dy * cos) / (dab.radius * dab.roundness).max(0.5);
|
||||
if dab.flip_x {
|
||||
u = -u;
|
||||
}
|
||||
if dab.flip_y {
|
||||
v = -v;
|
||||
}
|
||||
if !(-1.0..1.0).contains(&u) || !(-1.0..1.0).contains(&v) {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let col = ((u + 1.0) * 32.0) as usize;
|
||||
let row = ((v + 1.0) * 32.0) as usize;
|
||||
load.get(row * 64 + col).copied().unwrap_or([0.0; 4])
|
||||
}
|
||||
|
||||
/// Paint a Mixer Brush stroke. `sample` is the surface colour is picked up from (the layer itself
|
||||
/// when None; a flattened composite for Sample All Layers). Returns the damaged rectangle.
|
||||
pub fn apply_mixer_stroke(
|
||||
@@ -103,6 +141,50 @@ pub fn apply_mixer_stroke(
|
||||
lock_transparency: bool,
|
||||
) -> Rect {
|
||||
let fmt = target.format();
|
||||
let mut st = stroke.clone();
|
||||
st.brush.opacity = 1.0;
|
||||
apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| {
|
||||
apply_mixer_dab(work, fp, fmt, sample, m, state, lock_transparency);
|
||||
})
|
||||
}
|
||||
|
||||
/// One incremental wet-paint dab, shared by recorded strokes and the live document transaction.
|
||||
pub fn apply_mixer_dab(
|
||||
work: &mut crate::retouch::Region,
|
||||
fp: &crate::retouch::Footprint,
|
||||
fmt: photocraft_color::PixelFormat,
|
||||
sample: Option<&Surface>,
|
||||
m: &MixerSettings,
|
||||
state: &mut MixerState,
|
||||
lock_transparency: bool,
|
||||
) {
|
||||
let sample = sample.map(|surface| {
|
||||
let source = crate::retouch::Region::read(surface, work.rect);
|
||||
if surface.format().mode == fmt.mode && surface.format().alpha == fmt.alpha {
|
||||
source
|
||||
} else {
|
||||
let mut native = crate::retouch::Region::new(work.rect, fmt.channels());
|
||||
for (src, dst) in source.data.chunks_exact(source.ch).zip(native.data.chunks_exact_mut(native.ch)) {
|
||||
from_rgba_into(&fmt, to_rgba(&surface.format(), src), dst);
|
||||
}
|
||||
native
|
||||
}
|
||||
});
|
||||
apply_mixer_dab_region(work, fp, fmt, sample.as_ref(), m, state, lock_transparency);
|
||||
}
|
||||
|
||||
/// Contiguous native-channel sample variant. `sample` must have the same rectangle and
|
||||
/// channel layout as `work`. The live compositor supplies it directly, avoiding a tiled
|
||||
/// temporary surface and a heap allocation for every sampled/deposited pixel.
|
||||
pub fn apply_mixer_dab_region(
|
||||
work: &mut crate::retouch::Region,
|
||||
fp: &crate::retouch::Footprint,
|
||||
fmt: photocraft_color::PixelFormat,
|
||||
sample: Option<&crate::retouch::Region>,
|
||||
m: &MixerSettings,
|
||||
state: &mut MixerState,
|
||||
lock_transparency: bool,
|
||||
) {
|
||||
let n = fmt.channels();
|
||||
let a = alpha_index(&fmt);
|
||||
let nc = fmt.mode.color_channels();
|
||||
@@ -117,70 +199,81 @@ pub fn apply_mixer_stroke(
|
||||
let mut amount = if res.is_some() { state.amount.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let mut pickup: Option<Vec<f32>> = state.pickup.map(native);
|
||||
let (wet, mix, flow, load) = (m.wet.clamp(0.0, 1.0), m.mix.clamp(0.0, 1.0), m.flow.clamp(0.0, 1.0), m.load.clamp(0.0, 1.0));
|
||||
let mut st = stroke.clone();
|
||||
st.brush.opacity = 1.0;
|
||||
let mut acc = vec![0.0f32; n];
|
||||
let mut paint = vec![0.0f32; n];
|
||||
let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| {
|
||||
// Transfer › Wetness / Mix Jitter scale the settings per dab.
|
||||
let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0));
|
||||
// Sample the canvas under the dab.
|
||||
acc.iter_mut().for_each(|v| *v = 0.0);
|
||||
let (mut wsum, mut asum) = (0.0f32, 0.0f32);
|
||||
let r = fp.rect.intersect(&work.rect);
|
||||
// Transfer › Wetness / Mix Jitter scale the settings per dab.
|
||||
let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0));
|
||||
// Sample the canvas under the dab.
|
||||
acc.iter_mut().for_each(|v| *v = 0.0);
|
||||
let (mut wsum, mut asum) = (0.0f32, 0.0f32);
|
||||
let r = fp.rect.intersect(&work.rect);
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let w = fp.at(x, y);
|
||||
if w <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let px = sample.unwrap_or(work).px(x, y);
|
||||
let al = a.map_or(1.0, |ai| px[ai]);
|
||||
for c in 0..nc {
|
||||
acc[c] += px[c] * w * al;
|
||||
}
|
||||
wsum += w;
|
||||
asum += w * al;
|
||||
}
|
||||
}
|
||||
if asum > 1e-6 && wet > 0.0 {
|
||||
let ca = asum / wsum.max(1e-6);
|
||||
let canvas: Vec<f32> = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect();
|
||||
pickup = Some(match pickup.take() {
|
||||
None => canvas,
|
||||
Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(),
|
||||
});
|
||||
}
|
||||
let wr = if res.is_some() { amount * (1.0 - mix * wet) } else { 0.0 };
|
||||
let wp = if pickup.is_some() { mix * wet } else { 0.0 };
|
||||
let total = wr + wp;
|
||||
if total > 1e-6 {
|
||||
for c in 0..n {
|
||||
let rv = res.as_ref().map_or(0.0, |v| v[c]);
|
||||
let pv = pickup.as_ref().map_or(0.0, |v| v[c]);
|
||||
paint[c] = (rv * wr + pv * wp) / total;
|
||||
}
|
||||
let dep = total.min(1.0) * flow;
|
||||
let mut local = vec![0.0; n];
|
||||
let mut mixed = vec![0.0; n];
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let w = fp.at(x, y);
|
||||
if w <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let px: Vec<f32> = match sample {
|
||||
Some(s) => {
|
||||
let v = s.pixel(x, y);
|
||||
if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) }
|
||||
}
|
||||
None => work.px(x, y).to_vec(),
|
||||
};
|
||||
let al = a.map_or(1.0, |ai| px[ai]);
|
||||
for c in 0..nc {
|
||||
acc[c] += px[c] * w * al;
|
||||
}
|
||||
wsum += w;
|
||||
asum += w * al;
|
||||
}
|
||||
}
|
||||
if asum > 1e-6 && wet > 0.0 {
|
||||
let ca = asum / wsum.max(1e-6);
|
||||
let canvas: Vec<f32> = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect();
|
||||
pickup = Some(match pickup.take() {
|
||||
None => canvas,
|
||||
Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(),
|
||||
});
|
||||
}
|
||||
let wr = if res.is_some() { amount * (1.0 - mix * wet) } else { 0.0 };
|
||||
let wp = if pickup.is_some() { mix * wet } else { 0.0 };
|
||||
let total = wr + wp;
|
||||
if total > 1e-6 {
|
||||
for c in 0..n {
|
||||
let rv = res.as_ref().map_or(0.0, |v| v[c]);
|
||||
let pv = pickup.as_ref().map_or(0.0, |v| v[c]);
|
||||
paint[c] = (rv * wr + pv * wp) / total;
|
||||
}
|
||||
let dep = total.min(1.0) * flow;
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let k = fp.at(x, y) * dep;
|
||||
if k > 0.0 {
|
||||
let k = fp.at(x, y) * dep;
|
||||
if k > 0.0 {
|
||||
if let Some(load) = &state.spatial {
|
||||
let loaded = load_at(load, &fp.dab, x as f32 + 0.5, y as f32 + 0.5);
|
||||
from_rgba_into(&fmt, loaded, &mut local);
|
||||
let local_wr = wr * loaded[3];
|
||||
let local_total = local_wr + wp;
|
||||
if local_total > 1e-6 {
|
||||
for (c, value) in mixed.iter_mut().enumerate() {
|
||||
*value = (local.get(c).copied().unwrap_or(0.0) * local_wr
|
||||
+ pickup.as_ref().and_then(|p| p.get(c)).copied().unwrap_or(0.0) * wp)
|
||||
/ local_total;
|
||||
}
|
||||
if let Some(ai) = a
|
||||
&& let Some(alpha) = mixed.get_mut(ai)
|
||||
{
|
||||
*alpha = 1.0;
|
||||
}
|
||||
over_native(&fmt, work.px_mut(x, y), &mixed, fp.at(x, y) * local_total.min(1.0) * flow, lock_transparency);
|
||||
}
|
||||
} else {
|
||||
over_native(&fmt, work.px_mut(x, y), &paint, k, lock_transparency);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
amount *= 1.0 - 0.08 * (1.0 - load);
|
||||
});
|
||||
}
|
||||
amount *= 1.0 - 0.08 * (1.0 - load);
|
||||
state.amount = amount;
|
||||
state.pickup = pickup.map(|p| to_rgba(&fmt, &p));
|
||||
dmg
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -284,6 +377,42 @@ mod tests {
|
||||
assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, None));
|
||||
}
|
||||
|
||||
/// Spatial paint reservoirs honor independent automatic clean and reload choices.
|
||||
|
||||
#[test]
|
||||
|
||||
fn spatial_load_obeys_automatic_clean_and_load() {
|
||||
let foreground = [0.0, 0.0, 1.0, 1.0];
|
||||
let sampled = [1.0, 0.0, 0.0, 1.0];
|
||||
let tip = vec![sampled; 4096];
|
||||
let mut state = MixerState::default();
|
||||
state.load(sampled);
|
||||
state.spatial = Some(tip.clone());
|
||||
state.color = Some(foreground);
|
||||
state.amount = 0.1;
|
||||
state.end_stroke(foreground, true, true);
|
||||
assert_eq!(state.spatial.as_ref(), Some(&tip));
|
||||
assert_eq!((state.reservoir, state.amount, state.pickup), (Some(sampled), 1.0, None));
|
||||
state.begin_stroke(foreground);
|
||||
assert_eq!(state.spatial.as_ref(), Some(&tip));
|
||||
state.end_stroke(foreground, true, false);
|
||||
assert!(state.spatial.is_none() && state.reservoir.is_none());
|
||||
state.begin_stroke(foreground);
|
||||
assert!(state.reservoir.is_none(), "Clean stays empty until a new color or Load");
|
||||
}
|
||||
|
||||
/// Older serialized reload settings remain compatible with the automatic load field.
|
||||
|
||||
#[test]
|
||||
|
||||
fn earlier_reload_setting_deserializes_as_automatic_load() {
|
||||
let settings: MixerSettings = serde_json::from_value(serde_json::json!({"reloadAfterStroke":false})).unwrap();
|
||||
assert!(!settings.load_after_stroke);
|
||||
let saved = serde_json::to_value(settings).unwrap();
|
||||
assert_eq!(saved["loadAfterStroke"], false);
|
||||
assert!(saved.get("reloadAfterStroke").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_empty_brush_paints_nothing() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
@@ -313,3 +442,7 @@ mod tests {
|
||||
assert!(run(1.0).is_some_and(|p| p[2] > 0.2));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mixer_reference_tests.rs"]
|
||||
mod reference_tests;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Frozen pre-optimization dab kernel. Keep independent of the optimized implementation.
|
||||
use super::*;
|
||||
/// Keep the allocation-heavy scalar kernel independent so optimizations cannot silently change pickup or blending.
|
||||
fn reference(
|
||||
work: &mut crate::retouch::Region,
|
||||
fp: &crate::retouch::Footprint,
|
||||
fmt: photocraft_color::PixelFormat,
|
||||
sample: Option<&Surface>,
|
||||
m: &MixerSettings,
|
||||
state: &mut MixerState,
|
||||
lock_transparency: bool,
|
||||
) {
|
||||
let n = fmt.channels();
|
||||
let a = alpha_index(&fmt);
|
||||
let nc = fmt.mode.color_channels();
|
||||
let native = |c: [f32; 4]| {
|
||||
let mut v = from_rgba(&fmt, c);
|
||||
if let Some(ai) = a {
|
||||
v[ai] = 1.0;
|
||||
}
|
||||
v
|
||||
};
|
||||
let res = state.reservoir.map(native);
|
||||
let mut amount = if res.is_some() { state.amount.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let mut pickup: Option<Vec<f32>> = state.pickup.map(native);
|
||||
let (wet, mix, flow, load) = (m.wet.clamp(0.0, 1.0), m.mix.clamp(0.0, 1.0), m.flow.clamp(0.0, 1.0), m.load.clamp(0.0, 1.0));
|
||||
let mut acc = vec![0.0f32; n];
|
||||
let mut paint = vec![0.0f32; n];
|
||||
// Transfer › Wetness / Mix Jitter scale the settings per dab.
|
||||
let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0));
|
||||
// Sample the canvas under the dab.
|
||||
acc.iter_mut().for_each(|v| *v = 0.0);
|
||||
let (mut wsum, mut asum) = (0.0f32, 0.0f32);
|
||||
let r = fp.rect.intersect(&work.rect);
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let w = fp.at(x, y);
|
||||
if w <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let px: Vec<f32> = match sample {
|
||||
Some(s) => {
|
||||
let v = s.pixel(x, y);
|
||||
if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) }
|
||||
}
|
||||
None => work.px(x, y).to_vec(),
|
||||
};
|
||||
let al = a.map_or(1.0, |ai| px[ai]);
|
||||
for c in 0..nc {
|
||||
acc[c] += px[c] * w * al;
|
||||
}
|
||||
wsum += w;
|
||||
asum += w * al;
|
||||
}
|
||||
}
|
||||
if asum > 1e-6 && wet > 0.0 {
|
||||
let ca = asum / wsum.max(1e-6);
|
||||
let canvas: Vec<f32> = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect();
|
||||
pickup = Some(match pickup.take() {
|
||||
None => canvas,
|
||||
Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(),
|
||||
});
|
||||
}
|
||||
let wr = if res.is_some() { amount * (1.0 - mix * wet) } else { 0.0 };
|
||||
let wp = if pickup.is_some() { mix * wet } else { 0.0 };
|
||||
let total = wr + wp;
|
||||
if total > 1e-6 {
|
||||
for c in 0..n {
|
||||
let rv = res.as_ref().map_or(0.0, |v| v[c]);
|
||||
let pv = pickup.as_ref().map_or(0.0, |v| v[c]);
|
||||
paint[c] = (rv * wr + pv * wp) / total;
|
||||
}
|
||||
let dep = total.min(1.0) * flow;
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let k = fp.at(x, y) * dep;
|
||||
if k > 0.0 {
|
||||
if let Some(load) = &state.spatial {
|
||||
let loaded = load_at(load, &fp.dab, x as f32 + 0.5, y as f32 + 0.5);
|
||||
let local = from_rgba(&fmt, loaded);
|
||||
let local_wr = wr * loaded[3];
|
||||
let local_total = local_wr + wp;
|
||||
if local_total > 1e-6 {
|
||||
let mut mixed = paint.clone();
|
||||
for (c, value) in mixed.iter_mut().enumerate() {
|
||||
*value = (local.get(c).copied().unwrap_or(0.0) * local_wr
|
||||
+ pickup.as_ref().and_then(|p| p.get(c)).copied().unwrap_or(0.0) * wp)
|
||||
/ local_total;
|
||||
}
|
||||
if let Some(ai) = a
|
||||
&& let Some(alpha) = mixed.get_mut(ai)
|
||||
{
|
||||
*alpha = 1.0;
|
||||
}
|
||||
over_native(&fmt, work.px_mut(x, y), &mixed, fp.at(x, y) * local_total.min(1.0) * flow, lock_transparency);
|
||||
}
|
||||
} else {
|
||||
over_native(&fmt, work.px_mut(x, y), &paint, k, lock_transparency);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
amount *= 1.0 - 0.08 * (1.0 - load);
|
||||
state.amount = amount;
|
||||
state.pickup = pickup.map(|p| to_rgba(&fmt, &p));
|
||||
}
|
||||
|
||||
/// The incremental kernel matches the frozen reference across color models, bit depths and spatial loads.
|
||||
|
||||
#[test]
|
||||
|
||||
fn contiguous_mixer_matches_frozen_kernel_across_models_depths_and_spatial_loads() {
|
||||
use crate::retouch::Region;
|
||||
use crate::{BrushContext, BrushSettings, Stroke, StrokePoint};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
let rect = Rect::new(-16, -16, 32, 32);
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for alpha in [false, true] {
|
||||
let fmt = PixelFormat::new(mode, depth, alpha);
|
||||
let mut surface = Surface::new(fmt);
|
||||
let values: Vec<_> = (0..rect.width() * rect.height()).flat_map(|i| from_rgba(&fmt, [(i % 31) as f32 / 30.0, 0.3, 0.7, 0.6])).collect();
|
||||
surface.write_region(rect, &values);
|
||||
let mut source = Surface::new(PixelFormat::RGBA32F);
|
||||
source.fill_rect(rect, &[0.8, 0.2, 0.1, 0.75]);
|
||||
let brush = BrushSettings { size: 24.0, angle: 33.0, roundness: 0.6, pressure_size: false, ..Default::default() };
|
||||
let dabs = crate::dabs(&Stroke { brush: brush.clone(), points: vec![StrokePoint::new(4.0, 4.0, 1.0)] });
|
||||
let ctx = BrushContext::new(&brush);
|
||||
for spatial in [false, true] {
|
||||
for sample in [None, Some(&source), Some(&surface)] {
|
||||
for lock in [false, true] {
|
||||
let mut state = MixerState::default();
|
||||
state.load([0.1, 0.8, 0.2, 1.0]);
|
||||
if spatial {
|
||||
state.spatial =
|
||||
Some((0..4096).map(|i| [if i % 64 < 32 { 1.0 } else { 0.0 }, 0.2, 0.8, if i % 7 == 0 { 0.0 } else { 1.0 }]).collect());
|
||||
}
|
||||
let mut expected_state = state.clone();
|
||||
let mut actual = Region::read(&surface, rect);
|
||||
let mut expected = actual.clone();
|
||||
let settings = MixerSettings::default();
|
||||
for (index, dab) in dabs.iter().enumerate() {
|
||||
let fp = ctx.footprint(dab, index);
|
||||
reference(&mut expected, &fp, fmt, sample, &settings, &mut expected_state, lock);
|
||||
apply_mixer_dab(&mut actual, &fp, fmt, sample, &settings, &mut state, lock);
|
||||
}
|
||||
assert_eq!(actual, expected, "{mode:?} {depth:?} alpha={alpha} spatial={spatial} lock={lock}");
|
||||
assert_eq!(state, expected_state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,19 +36,18 @@ fn paper(seed: u32, depth: f32) -> Texture {
|
||||
|
||||
fn general(base: &BrushSettings) -> Vec<BrushPreset> {
|
||||
let g = |n: &str, b: BrushSettings| preset("General", n, b);
|
||||
// Dense dabs keep large hard-round outlines and pressure-opacity tapers continuous (#8842).
|
||||
let hard_round = BrushSettings { spacing: 0.05, ..base.clone() };
|
||||
vec![
|
||||
g("Hard Round", BrushSettings { size: 30.0, hardness: 1.0, ..base.clone() }),
|
||||
g("Hard Round", BrushSettings { size: 30.0, hardness: 1.0, ..hard_round.clone() }),
|
||||
g("Soft Round", BrushSettings { size: 45.0, hardness: 0.0, ..base.clone() }),
|
||||
g("Hard Round Fine", BrushSettings { size: 5.0, hardness: 1.0, spacing: 0.1, ..base.clone() }),
|
||||
g("Hard Round Fine", BrushSettings { size: 5.0, hardness: 1.0, ..hard_round.clone() }),
|
||||
g("Soft Round Large", BrushSettings { size: 300.0, hardness: 0.0, spacing: 0.15, ..base.clone() }),
|
||||
g("Hard Round Pressure Size", BrushSettings { size: 30.0, hardness: 1.0, shape_dynamics: size_dyn(pressure()), ..base.clone() }),
|
||||
g("Hard Round Pressure Size", BrushSettings { size: 30.0, hardness: 1.0, shape_dynamics: size_dyn(pressure()), ..hard_round.clone() }),
|
||||
g("Soft Round Pressure Size", BrushSettings { size: 45.0, hardness: 0.0, shape_dynamics: size_dyn(pressure()), ..base.clone() }),
|
||||
g("Hard Round Pressure Opacity", BrushSettings { size: 30.0, hardness: 1.0, transfer: transfer(pressure(), Dynamic::default()), ..base.clone() }),
|
||||
g("Hard Round Pressure Opacity", BrushSettings { size: 30.0, hardness: 1.0, transfer: transfer(pressure(), Dynamic::default()), ..hard_round.clone() }),
|
||||
g("Soft Round Pressure Opacity", BrushSettings { size: 45.0, hardness: 0.0, transfer: transfer(pressure(), Dynamic::default()), ..base.clone() }),
|
||||
g(
|
||||
"Hard Round Pressure Opacity and Flow",
|
||||
BrushSettings { size: 30.0, hardness: 0.9, transfer: transfer(pressure(), pressure_min(0.2)), ..base.clone() },
|
||||
),
|
||||
g("Hard Round Pressure Opacity and Flow", BrushSettings { size: 30.0, hardness: 0.9, transfer: transfer(pressure(), pressure_min(0.2)), ..hard_round }),
|
||||
g("Airbrush Soft", BrushSettings { size: 80.0, hardness: 0.0, flow: 0.1, spacing: 0.1, build_up: true, build_up_rate: 25.0, ..base.clone() }),
|
||||
g(
|
||||
"Airbrush Pressure Flow",
|
||||
|
||||
+295
-114
@@ -39,6 +39,57 @@ fn dither8(px: &mut [f32], x: i32, y: i32) {
|
||||
|
||||
/// Edge of a stroke-buffer tile.
|
||||
pub const COV_TILE: i32 = 64;
|
||||
|
||||
/// Work on at least this many pixels runs in parallel (rows of a dab, tiles of a stroke): below
|
||||
/// it the threads cost more than they save. A large soft tip is tens of megapixels per dab, and
|
||||
/// every stage of it is independent per row or tile, so the pixels come out the same.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) const DEFAULT_PARALLEL_MIN_PIXELS: usize = 256 * 256;
|
||||
|
||||
/// The threshold in use: [`DEFAULT_PARALLEL_MIN_PIXELS`] (tests move it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static PARALLEL_MIN_PIXELS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(DEFAULT_PARALLEL_MIN_PIXELS);
|
||||
|
||||
/// Whether `pixels` of independent work are worth the thread pool (the web has none: there every
|
||||
/// stage takes its serial path).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn parallel(pixels: usize) -> bool {
|
||||
pixels >= PARALLEL_MIN_PIXELS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Tests: the pixel count from which work runs in parallel (`usize::MAX` keeps everything serial).
|
||||
/// The static is process-wide, so brush tests on other threads see the change too; that is
|
||||
/// harmless, because every stage gives the same pixels serially and in parallel (the point of
|
||||
/// the test that uses this), so no test can tell which way it ran.
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn set_parallel_min_pixels(n: usize) {
|
||||
PARALLEL_MIN_PIXELS.store(n, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// `f` over `items`, in parallel when `pixels` are worth it.
|
||||
fn for_each_item<T: Send>(items: &mut [T], pixels: usize, f: impl Fn(&mut T) + Sync) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if parallel(pixels) {
|
||||
use rayon::prelude::*;
|
||||
items.par_iter_mut().for_each(&f);
|
||||
return;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = pixels;
|
||||
items.iter_mut().for_each(&f);
|
||||
}
|
||||
|
||||
/// `f` over `items`, collecting its results in order, in parallel when `pixels` are worth it.
|
||||
fn map_items<T: Sync, R: Send>(items: &[T], pixels: usize, f: impl Fn(&T) -> R + Sync) -> Vec<R> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if parallel(pixels) {
|
||||
use rayon::prelude::*;
|
||||
return items.par_iter().map(&f).collect();
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = pixels;
|
||||
items.iter().map(&f).collect()
|
||||
}
|
||||
/// Computed tips with a smaller radius (in pixels) are rasterised by area, not at pixel centres.
|
||||
const SMALL_TIP_RADIUS: f32 = 3.0;
|
||||
/// Sub-pixel sample offsets (a 4×4 grid) for the area coverage of small tips.
|
||||
@@ -466,61 +517,62 @@ impl CoverageMap {
|
||||
let (t0x, t0y) = (rect.x0.div_euclid(COV_TILE), rect.y0.div_euclid(COV_TILE));
|
||||
let (t1x, t1y) = ((rect.x1 - 1).div_euclid(COV_TILE), (rect.y1 - 1).div_euclid(COV_TILE));
|
||||
let want_col = color.is_some() && nc > 0;
|
||||
// The tiles this dab touches (a tile with no value in it is skipped), each independent of
|
||||
// the others: a large dab's tiles accumulate in parallel, to the same values.
|
||||
let mut keys: Vec<((i32, i32), Rect)> = Vec::new();
|
||||
for ty in t0y..=t1y {
|
||||
for tx in t0x..=t1x {
|
||||
let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&rect);
|
||||
if tr.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Skip tiles this dab doesn't touch.
|
||||
let any = (tr.y0..tr.y1).any(|y| {
|
||||
let row = (y - rect.y0) as usize * w;
|
||||
vals[row + (tr.x0 - rect.x0) as usize..row + (tr.x1 - rect.x0) as usize].iter().any(|&v| v > 0.0)
|
||||
});
|
||||
if !any {
|
||||
continue;
|
||||
}
|
||||
let tile = self.tiles.entry((tx, ty)).or_insert_with(|| CovTile {
|
||||
cov: vec![0.0; (COV_TILE * COV_TILE) as usize],
|
||||
depth: Vec::new(),
|
||||
col: if want_col { vec![0.0; (COV_TILE * COV_TILE) as usize * nc] } else { Vec::new() },
|
||||
});
|
||||
keys.push(((tx, ty), tr));
|
||||
}
|
||||
}
|
||||
let touched = map_items(&keys, keys.len() * (COV_TILE * COV_TILE) as usize, |(_, tr)| {
|
||||
(tr.y0..tr.y1).any(|y| {
|
||||
let row = (y - rect.y0) as usize * w;
|
||||
vals[row + (tr.x0 - rect.x0) as usize..row + (tr.x1 - rect.x0) as usize].iter().any(|&v| v > 0.0)
|
||||
})
|
||||
});
|
||||
// The touched tiles leave the map while they accumulate and go back after: the dab's cost
|
||||
// follows the tiles under it (one to four for a small tip), not the size of the map, which a
|
||||
// long stroke grows to thousands of tiles.
|
||||
let n = (COV_TILE * COV_TILE) as usize;
|
||||
let mut tiles: Vec<(Rect, (i32, i32), CovTile)> = keys
|
||||
.into_iter()
|
||||
.zip(touched)
|
||||
.filter(|(_, t)| *t)
|
||||
.map(|((key, tr), _)| {
|
||||
let mut tile = self.tiles.remove(&key).unwrap_or_else(|| CovTile { cov: vec![0.0; n], depth: Vec::new(), col: Vec::new() });
|
||||
if want_col && tile.col.is_empty() {
|
||||
tile.col = vec![0.0; (COV_TILE * COV_TILE) as usize * nc];
|
||||
tile.col = vec![0.0; n * nc];
|
||||
}
|
||||
if depth.is_some() && tile.depth.is_empty() {
|
||||
tile.depth = vec![0.0; (COV_TILE * COV_TILE) as usize];
|
||||
}
|
||||
self.dirty.insert((tx, ty));
|
||||
for y in tr.y0..tr.y1 {
|
||||
let row = (y - rect.y0) as usize * w;
|
||||
let trow = ((y - ty * COV_TILE) * COV_TILE) as usize;
|
||||
for x in tr.x0..tr.x1 {
|
||||
let v = vals[row + (x - rect.x0) as usize];
|
||||
if v <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let ti = trow + (x - tx * COV_TILE) as usize;
|
||||
if let (Some(dv), Some(slot)) = (depth.and_then(|dp| dp.get(row + (x - rect.x0) as usize)), tile.depth.get_mut(ti)) {
|
||||
*slot = slot.max(*dv);
|
||||
}
|
||||
let c = tile.cov[ti];
|
||||
let nv = if ceil > c { c + v * (ceil - c) } else { c };
|
||||
if nv <= c {
|
||||
continue;
|
||||
}
|
||||
if let (true, Some(col)) = (want_col, color) {
|
||||
let k = if c < 1.0 { (nv - c) / (1.0 - c) } else { 0.0 };
|
||||
let p = &mut tile.col[ti * nc..(ti + 1) * nc];
|
||||
for i in 0..nc {
|
||||
p[i] = col[i] * k + p[i] * (1.0 - k);
|
||||
}
|
||||
}
|
||||
tile.cov[ti] = nv;
|
||||
}
|
||||
tile.depth = vec![0.0; n];
|
||||
}
|
||||
self.dirty.insert(key);
|
||||
(tr, key, tile)
|
||||
})
|
||||
.collect();
|
||||
let dab = DabValues { vals, rect, w, ceil, color: if want_col { color } else { None }, depth, nc };
|
||||
// The serial path stays a plain loop over an inlined step (see `rasterize_round`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if parallel(tiles.len() * n) {
|
||||
use rayon::prelude::*;
|
||||
tiles.par_iter_mut().for_each(|(tr, key, tile)| dab.tile(*tr, *key, tile));
|
||||
} else {
|
||||
for (tr, key, tile) in &mut tiles {
|
||||
dab.tile(*tr, *key, tile);
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (tr, key, tile) in &mut tiles {
|
||||
dab.tile(*tr, *key, tile);
|
||||
}
|
||||
for (_, key, tile) in tiles {
|
||||
self.tiles.insert(key, tile);
|
||||
}
|
||||
}
|
||||
|
||||
fn take_dirty(&mut self) -> Vec<(i32, i32)> {
|
||||
@@ -587,6 +639,55 @@ impl UnionLive {
|
||||
}
|
||||
}
|
||||
|
||||
/// One dab's coverage (`vals` over `rect`, `w` wide) as [`CoverageMap::accumulate`] adds it to
|
||||
/// a tile: the dab's opacity ceiling, its colour when the stroke has per-dab colour (`nc` channels),
|
||||
/// its Wet Edges depth.
|
||||
#[derive(Clone, Copy)]
|
||||
struct DabValues<'a> {
|
||||
vals: &'a [f32],
|
||||
rect: Rect,
|
||||
w: usize,
|
||||
ceil: f32,
|
||||
color: Option<&'a [f32]>,
|
||||
depth: Option<&'a [f32]>,
|
||||
nc: usize,
|
||||
}
|
||||
|
||||
impl DabValues<'_> {
|
||||
/// Adds the dab over `tr` (within tile `(tx, ty)`) to `tile`.
|
||||
#[inline(always)]
|
||||
fn tile(self, tr: Rect, (tx, ty): (i32, i32), tile: &mut CovTile) {
|
||||
let DabValues { vals, rect, w, ceil, color, depth, nc } = self;
|
||||
for y in tr.y0..tr.y1 {
|
||||
let row = (y - rect.y0) as usize * w;
|
||||
let trow = ((y - ty * COV_TILE) * COV_TILE) as usize;
|
||||
for x in tr.x0..tr.x1 {
|
||||
let v = vals[row + (x - rect.x0) as usize];
|
||||
if v <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let ti = trow + (x - tx * COV_TILE) as usize;
|
||||
if let (Some(dv), Some(slot)) = (depth.and_then(|dp| dp.get(row + (x - rect.x0) as usize)), tile.depth.get_mut(ti)) {
|
||||
*slot = slot.max(*dv);
|
||||
}
|
||||
let c = tile.cov[ti];
|
||||
let nv = if ceil > c { c + v * (ceil - c) } else { c };
|
||||
if nv <= c {
|
||||
continue;
|
||||
}
|
||||
if let Some(col) = color {
|
||||
let k = if c < 1.0 { (nv - c) / (1.0 - c) } else { 0.0 };
|
||||
let p = &mut tile.col[ti * nc..(ti + 1) * nc];
|
||||
for i in 0..nc {
|
||||
p[i] = col[i] * k + p[i] * (1.0 - k);
|
||||
}
|
||||
}
|
||||
tile.cov[ti] = nv;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental stroke rendering: feed points, composite dirty tiles from the pre-stroke pixels.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StrokeRenderer {
|
||||
@@ -603,6 +704,19 @@ pub struct StrokeRenderer {
|
||||
dab_buf: Vec<Dab>,
|
||||
dual_buf: Vec<Dab>,
|
||||
all_dabs: Option<Vec<Dab>>,
|
||||
regions: RegionPool,
|
||||
}
|
||||
|
||||
/// The tile buffers [`StrokeRenderer::composite`] works in, kept from one composite to the next:
|
||||
/// a small stroke's few tiles reuse theirs instead of growing and trimming the heap every frame
|
||||
/// (that cost a 50 px stroke a third of its time). A clone starts with none.
|
||||
#[derive(Debug, Default)]
|
||||
struct RegionPool(Vec<Vec<f32>>);
|
||||
|
||||
impl Clone for RegionPool {
|
||||
fn clone(&self) -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl StrokeRenderer {
|
||||
@@ -695,6 +809,7 @@ impl StrokeRenderer {
|
||||
dab_buf: Vec::new(),
|
||||
dual_buf: Vec::new(),
|
||||
all_dabs: None,
|
||||
regions: RegionPool::default(),
|
||||
})
|
||||
}
|
||||
Some(m) => {
|
||||
@@ -746,6 +861,7 @@ impl StrokeRenderer {
|
||||
dab_buf: Vec::new(),
|
||||
dual_buf: Vec::new(),
|
||||
all_dabs: None,
|
||||
regions: RegionPool::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -876,6 +992,7 @@ impl StrokeRenderer {
|
||||
dab_buf: dabs,
|
||||
dual_buf: duals,
|
||||
all_dabs: None,
|
||||
regions: RegionPool::default(),
|
||||
};
|
||||
t.raster_pending();
|
||||
// Tiles whose coverage the tail doesn't change still composite the same: redraw them all.
|
||||
@@ -989,77 +1106,106 @@ impl StrokeRenderer {
|
||||
// colour, as in Photoshop, not grain that a tolerance-0 Magic Wand or Paint Bucket trips on.
|
||||
let dither = fmt.sample == SampleType::U8 && !b.aliased;
|
||||
let mut dmg = Rect::EMPTY;
|
||||
let mut region = Vec::new();
|
||||
for (tx, ty) in keys {
|
||||
let Some(tile) = self.cov.tiles.get(&(tx, ty)) else { continue };
|
||||
let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&bounds);
|
||||
if tr.is_empty() {
|
||||
continue;
|
||||
}
|
||||
pre.read_region_into(tr, &mut region);
|
||||
let sel = selection.map(|s| (s.channels(), s.read_region(tr)));
|
||||
let w = tr.width() as usize;
|
||||
for y in tr.y0..tr.y1 {
|
||||
for x in tr.x0..tr.x1 {
|
||||
let ti = ((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize;
|
||||
let c = tile.cov[ti];
|
||||
if c <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let i = (y - tr.y0) as usize * w + (x - tr.x0) as usize;
|
||||
let m = self.ctx.stroke_mask_wet(c, tile.depth.get(ti).copied(), self.dual.as_ref().map(|d| d.get(x, y)), x, y);
|
||||
let s = sel.as_ref().map_or(1.0, |(sc, v)| v[i * sc]);
|
||||
let mut k = (m * opacity * s).min(1.0);
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if b.mode == BlendMode::Dissolve {
|
||||
k = if hash2(x, y, b.seed) < k { 1.0 } else { 0.0 };
|
||||
if k == 0.0 {
|
||||
// Each tile composites into a buffer of its own (a large stroke's tiles in parallel, to
|
||||
// the same pixels); the writes to the target go one by one, in order. Batches keep the
|
||||
// pending buffers few, and the pool keeps them from one composite to the next.
|
||||
const BATCH: usize = 256;
|
||||
let mut pool = std::mem::take(&mut self.regions.0);
|
||||
// The document's CMYK profile is active per thread: the workers enter the caller's, or a
|
||||
// blend mode other than Normal would convert their tiles through the built-in coated CMYK.
|
||||
let cmyk = photocraft_color::convert::active_cmyk_space();
|
||||
for batch in keys.chunks(BATCH) {
|
||||
let mut items: Vec<((i32, i32), Rect, Vec<f32>)> = batch.iter().map(|&key| (key, Rect::EMPTY, pool.pop().unwrap_or_default())).collect();
|
||||
let composite_tile = |(key, done, region): &mut ((i32, i32), Rect, Vec<f32>)| {
|
||||
let (tx, ty) = *key;
|
||||
let Some(tile) = self.cov.tiles.get(&(tx, ty)) else { return };
|
||||
let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&bounds);
|
||||
if tr.is_empty() {
|
||||
return;
|
||||
}
|
||||
pre.read_region_into(tr, region);
|
||||
let sel = selection.map(|s| (s.channels(), s.read_region(tr)));
|
||||
// Per-dab colour rewrites the source per pixel, so each tile works on its own copy
|
||||
// that starts from the brush colour. (Before the tiles ran in parallel, one `src`
|
||||
// carried over from tile to tile; a tile without per-dab colour then painted with
|
||||
// whatever colour the previous tile's last pixel had left in it. Such a tile only
|
||||
// occurs when the stroke has no per-dab colour at all, and then `src` is never
|
||||
// rewritten, so the pixels are the same either way; the copy per tile is the
|
||||
// intended reading.)
|
||||
let mut src = src;
|
||||
let w = tr.width() as usize;
|
||||
for y in tr.y0..tr.y1 {
|
||||
for x in tr.x0..tr.x1 {
|
||||
let ti = ((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize;
|
||||
let c = tile.cov[ti];
|
||||
if c <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let px = &mut region[i * n..(i + 1) * n];
|
||||
if b.erase {
|
||||
if let (Some(a), false) = (a_idx, lock_transparency) {
|
||||
px[a] *= 1.0 - k;
|
||||
let i = (y - tr.y0) as usize * w + (x - tr.x0) as usize;
|
||||
let m = self.ctx.stroke_mask_wet(c, tile.depth.get(ti).copied(), self.dual.as_ref().map(|d| d.get(x, y)), x, y);
|
||||
let s = sel.as_ref().map_or(1.0, |(sc, v)| v[i * sc]);
|
||||
let mut k = (m * opacity * s).min(1.0);
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if b.mode == BlendMode::Dissolve {
|
||||
k = if hash2(x, y, b.seed) < k { 1.0 } else { 0.0 };
|
||||
if k == 0.0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let px = &mut region[i * n..(i + 1) * n];
|
||||
if b.erase {
|
||||
if let (Some(a), false) = (a_idx, lock_transparency) {
|
||||
px[a] *= 1.0 - k;
|
||||
}
|
||||
if dither {
|
||||
dither8(px, x, y);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if lock_transparency && a_idx.is_some_and(|a| px[a] <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
if self.per_dab_color && !tile.col.is_empty() {
|
||||
let p = &tile.col[ti * nc..(ti + 1) * nc];
|
||||
for ch in 0..nc {
|
||||
src[ch] = p[ch] / c;
|
||||
}
|
||||
}
|
||||
if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) {
|
||||
over_native(&fmt, px, &src[..n], k, lock_transparency);
|
||||
} else {
|
||||
let d = to_rgba(&fmt, px);
|
||||
let sr = to_rgba(&fmt, &src[..n]);
|
||||
let o = if lock_transparency {
|
||||
photocraft_color::blend::composite_atop(b.mode, d, sr, k)
|
||||
} else {
|
||||
photocraft_color::blend::composite(b.mode, d, sr, k)
|
||||
};
|
||||
let mut enc = [0.0f32; 8];
|
||||
from_rgba_into(&fmt, o, &mut enc);
|
||||
px.copy_from_slice(&enc[..n]);
|
||||
}
|
||||
if dither {
|
||||
dither8(px, x, y);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if lock_transparency && a_idx.is_some_and(|a| px[a] <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
if self.per_dab_color && !tile.col.is_empty() {
|
||||
let p = &tile.col[ti * nc..(ti + 1) * nc];
|
||||
for ch in 0..nc {
|
||||
src[ch] = p[ch] / c;
|
||||
}
|
||||
}
|
||||
if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) {
|
||||
over_native(&fmt, px, &src[..n], k, lock_transparency);
|
||||
} else {
|
||||
let d = to_rgba(&fmt, px);
|
||||
let sr = to_rgba(&fmt, &src[..n]);
|
||||
let o = if lock_transparency {
|
||||
photocraft_color::blend::composite_atop(b.mode, d, sr, k)
|
||||
} else {
|
||||
photocraft_color::blend::composite(b.mode, d, sr, k)
|
||||
};
|
||||
let mut enc = [0.0f32; 8];
|
||||
from_rgba_into(&fmt, o, &mut enc);
|
||||
px.copy_from_slice(&enc[..n]);
|
||||
}
|
||||
if dither {
|
||||
dither8(px, x, y);
|
||||
}
|
||||
}
|
||||
*done = tr;
|
||||
};
|
||||
for_each_item(&mut items, batch.len() * (COV_TILE * COV_TILE) as usize, |item| {
|
||||
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || composite_tile(item))
|
||||
});
|
||||
for (_, tr, region) in items {
|
||||
if !tr.is_empty() {
|
||||
target.write_region(tr, ®ion);
|
||||
dmg = dmg.union(&tr);
|
||||
}
|
||||
pool.push(region);
|
||||
}
|
||||
target.write_region(tr, ®ion);
|
||||
dmg = dmg.union(&tr);
|
||||
}
|
||||
self.regions.0 = pool;
|
||||
if all { bounds } else { dmg }
|
||||
}
|
||||
}
|
||||
@@ -1090,22 +1236,57 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased:
|
||||
let ro = d.roundness.max(0.5 / d.radius).min(1.0);
|
||||
let rm = (d.radius * ro).max(0.5);
|
||||
let w = rect.width() as usize;
|
||||
if w == 0 {
|
||||
return;
|
||||
}
|
||||
let reach = if aliased { rm } else { tip_reach(rm, hardness).max(rm) };
|
||||
let reach2 = (reach + 0.5) * (reach + 0.5);
|
||||
let tip = RoundTip { cx, cy, ro, rm, reach2: (reach + 0.5) * (reach + 0.5), hardness, alpha: d.alpha, aliased, x0: rect.x0, y0: rect.y0 };
|
||||
// Rows are independent: a large tip's rows run in parallel, to the same values. The serial
|
||||
// path stays a plain loop over an inlined row: the same code as before the threads (a closure
|
||||
// passed to a helper cost a small tip half its rasterization time).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if parallel(out.len()) {
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_exact_mut(w).enumerate().for_each(|(yy, row)| tip.row(yy, row));
|
||||
return;
|
||||
}
|
||||
for (yy, row) in out.chunks_exact_mut(w).enumerate() {
|
||||
let dy = (rect.y0 + yy as i32) as f32 + 0.5 - cy;
|
||||
if aliased {
|
||||
tip.row(yy, row);
|
||||
}
|
||||
}
|
||||
|
||||
/// A round tip's coverage, computed row by row (see [`rasterize_round`]).
|
||||
#[derive(Clone, Copy)]
|
||||
struct RoundTip {
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
ro: f32,
|
||||
rm: f32,
|
||||
reach2: f32,
|
||||
hardness: f32,
|
||||
alpha: f32,
|
||||
aliased: bool,
|
||||
x0: i32,
|
||||
y0: i32,
|
||||
}
|
||||
|
||||
impl RoundTip {
|
||||
/// Row `yy` of the tip's rectangle into `row`.
|
||||
#[inline(always)]
|
||||
fn row(self, yy: usize, row: &mut [f32]) {
|
||||
let dy = (self.y0 + yy as i32) as f32 + 0.5 - self.cy;
|
||||
if self.aliased {
|
||||
for (xx, dst) in row.iter_mut().enumerate() {
|
||||
let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx;
|
||||
let d2 = (dx * ro).powi(2) + dy * dy;
|
||||
*dst = if d2 < reach2 && d2.sqrt() <= rm { d.alpha } else { 0.0 };
|
||||
let dx = (self.x0 + xx as i32) as f32 + 0.5 - self.cx;
|
||||
let d2 = (dx * self.ro).powi(2) + dy * dy;
|
||||
*dst = if d2 < self.reach2 && d2.sqrt() <= self.rm { self.alpha } else { 0.0 };
|
||||
}
|
||||
} else {
|
||||
for (xx, dst) in row.iter_mut().enumerate() {
|
||||
let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx;
|
||||
let d2 = (dx * ro).powi(2) + dy * dy;
|
||||
let val = if d2 >= reach2 { 0.0 } else { tip_falloff(d2.sqrt(), rm, hardness) };
|
||||
*dst = if val <= 0.0 { 0.0 } else { val * d.alpha };
|
||||
let dx = (self.x0 + xx as i32) as f32 + 0.5 - self.cx;
|
||||
let d2 = (dx * self.ro).powi(2) + dy * dy;
|
||||
let val = if d2 >= self.reach2 { 0.0 } else { tip_falloff(d2.sqrt(), self.rm, self.hardness) };
|
||||
*dst = if val <= 0.0 { 0.0 } else { val * self.alpha };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +348,11 @@ fn a_fifo_in_place_of_a_module_file_does_not_block_loading() {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("evil.wasm");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
rustix::fs::mknodat(rustix::fs::CWD, &path, rustix::fs::FileType::Fifo, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR, 0).unwrap();
|
||||
// `rustix::fs::mknodat` is unavailable on Apple targets. `mkfifo` is the
|
||||
// POSIX utility for creating this special file and keeps this regression
|
||||
// test available on macOS as well as other Unix platforms.
|
||||
let status = std::process::Command::new("mkfifo").arg(&path).status().unwrap();
|
||||
assert!(status.success(), "mkfifo failed with {status}");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let p = path.clone();
|
||||
std::thread::spawn(move || {
|
||||
|
||||
+38
-7
@@ -39,6 +39,14 @@ pub struct GlobalLayerMask {
|
||||
}
|
||||
|
||||
impl GlobalLayerMask {
|
||||
/// The mask Photoshop writes for a layered file that has no global mask of its own:
|
||||
/// an RGB red overlay at 50% opacity, kind 128 (use the per-layer setting), plus the
|
||||
/// filler byte. A zero-length `LMsk` block in a TIFF makes Photoshop give up on the
|
||||
/// layer data (it reads this 14-byte record from the block).
|
||||
pub fn photoshop_default() -> Self {
|
||||
GlobalLayerMask { data: vec![0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0] }
|
||||
}
|
||||
|
||||
fn be16(&self, at: usize) -> Option<u16> {
|
||||
self.data.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]]))
|
||||
}
|
||||
@@ -352,12 +360,12 @@ impl PsdFile {
|
||||
}
|
||||
|
||||
/// `true` if the merged image carries a transparency channel: its first extra channel, when
|
||||
/// the layer count was stored negative or a "Saving Merged Transparency" global block (`Mtrn`,
|
||||
/// `Mt16`, `Mt32`) is present. Photoshop marks 32-bit documents with `Mt32` and a positive
|
||||
/// layer count (psd-tools transparentbg.psd). Photoshop also names the merged transparency
|
||||
/// "Transparency" and lists it first among the alpha channel names, and some of its 16-bit
|
||||
/// PSBs carry no other mark (#4654): when the layers sit in a global `Lr16`/`Lr32` block, that
|
||||
/// name with one name per extra channel marks it too.
|
||||
/// the layer count was stored negative or a "Saving Merged Transparency" global block (see
|
||||
/// [`TaggedBlock::is_merged_transparency_marker`]) is present. Photoshop marks 32-bit documents
|
||||
/// with `Mt32` and a positive layer count (psd-tools transparentbg.psd). Photoshop also names
|
||||
/// the merged transparency "Transparency" and lists it first among the alpha channel names,
|
||||
/// and some of its 16-bit PSBs carry no other mark (#4654): when the layers sit in a global
|
||||
/// `Lr16`/`Lr32` block, that name with one name per extra channel marks it too.
|
||||
pub fn merged_has_alpha(&self) -> bool {
|
||||
let Some(color) = self.header.color_mode.color_channels() else {
|
||||
return false;
|
||||
@@ -365,7 +373,7 @@ impl PsdFile {
|
||||
if self.header.channels <= color {
|
||||
return false;
|
||||
}
|
||||
let marked = self.global_blocks.iter().any(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32"));
|
||||
let marked = self.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker);
|
||||
match &self.layer_info {
|
||||
Some(li) => {
|
||||
let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. });
|
||||
@@ -583,4 +591,27 @@ mod write_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A positive layer count plus a merged-transparency marker still makes the first extra
|
||||
/// channel the transparency, for the specification's keys and for the `MTrn` Photoshop
|
||||
/// writes in a layered TIFF.
|
||||
#[test]
|
||||
fn merged_transparency_markers_mark_the_first_extra_channel() {
|
||||
let mut file = testgen::small(Version::Psd, Compression::Raw);
|
||||
if let Some(li) = &mut file.layer_info {
|
||||
li.merged_alpha = false;
|
||||
}
|
||||
assert!(!file.merged_has_alpha());
|
||||
for key in [*b"Mtrn", *b"MTrn", *b"Mt16", *b"Mt32"] {
|
||||
let mut marked = file.clone();
|
||||
marked.global_blocks.push(TaggedBlock::new(key, Vec::new()));
|
||||
assert!(marked.merged_has_alpha(), "{}", String::from_utf8_lossy(&key));
|
||||
}
|
||||
// Other keys, and the marker's letters in another case, do not.
|
||||
for key in [*b"MTRN", *b"mtrn", *b"Lr16"] {
|
||||
let mut other = file.clone();
|
||||
other.global_blocks.push(TaggedBlock::new(key, Vec::new()));
|
||||
assert!(!other.merged_has_alpha(), "{}", String::from_utf8_lossy(&key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,14 @@ impl TaggedBlock {
|
||||
Self::is_foreign_order_signature(&self.signature)
|
||||
}
|
||||
|
||||
/// `true` for a "Saving Merged Transparency" marker: the merged image's first extra channel
|
||||
/// is its transparency. The specification's keys are `Mtrn`, `Mt16` and `Mt32`; Photoshop
|
||||
/// (27.11) writes `MTrn`, with a positive layer count, in a layered TIFF saved with Save
|
||||
/// Transparency next to spot and alpha channels.
|
||||
pub fn is_merged_transparency_marker(&self) -> bool {
|
||||
matches!(&self.key, b"Mtrn" | b"MTrn" | b"Mt16" | b"Mt32")
|
||||
}
|
||||
|
||||
/// Key as a string (lossy).
|
||||
pub fn key_str(&self) -> String {
|
||||
String::from_utf8_lossy(&self.key).into_owned()
|
||||
|
||||
+130
-4
@@ -1162,16 +1162,16 @@ pub mod transcode {
|
||||
fn type_layer(&mut self) -> Result<()> {
|
||||
self.u16()?;
|
||||
self.f64s(6)?;
|
||||
self.type_descriptor(50)?;
|
||||
self.type_descriptor(1)?;
|
||||
self.type_descriptor(50, ByteOrder::Big)?;
|
||||
self.type_descriptor(1, self.to)?;
|
||||
self.i32s(4)
|
||||
}
|
||||
|
||||
fn type_descriptor(&mut self, version: u16) -> Result<()> {
|
||||
fn type_descriptor(&mut self, version: u16, to: ByteOrder) -> Result<()> {
|
||||
let [hi, lo] = version.to_be_bytes();
|
||||
let from = if self.from.is_little() && self.r.peek_rest().starts_with(&[hi, lo, 0, 0, 0, 16]) { ByteOrder::Big } else { self.from };
|
||||
let data = self.r.peek_rest();
|
||||
let mut descriptor = Tx::new(data, self.version, from, self.to);
|
||||
let mut descriptor = Tx::new(data, self.version, from, to);
|
||||
descriptor.depth = self.depth;
|
||||
descriptor.u16()?;
|
||||
descriptor.versioned_descriptor()?;
|
||||
@@ -1836,6 +1836,132 @@ mod tests {
|
||||
block_rt(*b"lfx2", data);
|
||||
}
|
||||
|
||||
fn type_layer_data() -> Vec<u8> {
|
||||
let mut data = Vec::new();
|
||||
data.put_u16(1);
|
||||
for value in [1.5, 0.0, -0.25, 1.75, 12.5, -3.25] {
|
||||
data.put_f64(value);
|
||||
}
|
||||
data.put_u16(50);
|
||||
let text = Descriptor::new("TxLr").with("Txt ", Value::Text(UnicodeString::new_nul("Synthetic Ω 🦀")));
|
||||
data.extend(VersionedDescriptor::new(text).to_bytes());
|
||||
data.put_u16(1);
|
||||
data.extend(VersionedDescriptor::new(Descriptor::new("warp")).to_bytes());
|
||||
for value in [11, -22, 333, 4_444] {
|
||||
data.put_i32(value);
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
fn tagged_payload<'a>(bytes: &'a [u8], signature_and_key: &[u8; 8], little: bool) -> &'a [u8] {
|
||||
let block_at = bytes.windows(8).position(|window| window == signature_and_key).expect("tagged block");
|
||||
let length_at = block_at + 8;
|
||||
let length_bytes: [u8; 4] = bytes.get(length_at..length_at + 4).expect("tagged block length").try_into().expect("four-byte length");
|
||||
let length = if little { u32::from_le_bytes(length_bytes) } else { u32::from_be_bytes(length_bytes) };
|
||||
let data_at = length_at + 4;
|
||||
let data_end = data_at + usize::try_from(length).expect("test block length fits usize");
|
||||
bytes.get(data_at..data_end).expect("tagged block payload")
|
||||
}
|
||||
|
||||
fn legacy_little_endian_type_block() -> Vec<u8> {
|
||||
let mut tysh = Vec::new();
|
||||
tysh.extend_from_slice(&1u16.to_le_bytes());
|
||||
for value in [1.5f64, 0.0, -0.25, 1.75, 12.5, -3.25] {
|
||||
tysh.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
tysh.extend_from_slice(&50u16.to_le_bytes());
|
||||
// Independently encode a little-endian `VersionedDescriptor` with a single TEXT value.
|
||||
tysh.extend_from_slice(&16u32.to_le_bytes());
|
||||
tysh.extend_from_slice(&0u32.to_le_bytes()); // empty descriptor name
|
||||
tysh.extend_from_slice(&0u32.to_le_bytes()); // four-character class id
|
||||
tysh.extend_from_slice(b"rLxT"); // little-endian four-character class id
|
||||
tysh.extend_from_slice(&1u32.to_le_bytes()); // one item
|
||||
tysh.extend_from_slice(&0u32.to_le_bytes()); // four-character key
|
||||
tysh.extend_from_slice(b" txT"); // little-endian four-character key
|
||||
tysh.extend_from_slice(b"TXET"); // little-endian OSType
|
||||
let mut units: Vec<u16> = "Synthetic Ω 🦀".encode_utf16().collect();
|
||||
units.push(0); // `new_nul`, matching the canonical fixture
|
||||
tysh.extend_from_slice(&u32::try_from(units.len()).expect("short test string").to_le_bytes());
|
||||
for unit in units {
|
||||
tysh.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
tysh.extend_from_slice(&1u16.to_le_bytes());
|
||||
// Empty warp descriptor, also encoded independently in little-endian order.
|
||||
tysh.extend_from_slice(&16u32.to_le_bytes());
|
||||
tysh.extend_from_slice(&0u32.to_le_bytes()); // empty descriptor name
|
||||
tysh.extend_from_slice(&4u32.to_le_bytes()); // `warp` is a four-byte string id
|
||||
tysh.extend_from_slice(b"warp");
|
||||
tysh.extend_from_slice(&0u32.to_le_bytes()); // no items
|
||||
for value in [11i32, -22, 333, 4_444] {
|
||||
tysh.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
let mut image_source = super::SIGNATURE.to_vec();
|
||||
image_source.extend_from_slice(b"MIB8hSyT");
|
||||
image_source.extend_from_slice(&u32::try_from(tysh.len()).expect("short test block").to_le_bytes());
|
||||
image_source.extend_from_slice(&tysh);
|
||||
let padding = (4 - tysh.len() % 4) % 4;
|
||||
image_source.resize(image_source.len() + padding, 0);
|
||||
image_source
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_block_write_keeps_text_descriptor_in_psd_order() {
|
||||
let canonical = type_layer_data();
|
||||
let source = ImageSourceData { global_blocks: vec![TaggedBlock::new(*b"TySh", canonical.clone())], ..Default::default() };
|
||||
|
||||
let (little, warnings) = source.to_bytes(ByteOrder::Little).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let payload = tagged_payload(&little, b"MIB8hSyT", true);
|
||||
assert_eq!(payload.get(..2), Some(&[1, 0][..]), "TySh header stays in TIFF order");
|
||||
for (index, expected) in [1.5f64, 0.0, -0.25, 1.75, 12.5, -3.25].into_iter().enumerate() {
|
||||
let start = 2 + index * 8;
|
||||
let bytes: [u8; 8] = payload.get(start..start + 8).unwrap().try_into().unwrap();
|
||||
assert_eq!(f64::from_le_bytes(bytes), expected, "matrix element {index}");
|
||||
}
|
||||
|
||||
let (text_descriptor, descriptor_len) = VersionedDescriptor::parse_prefix(canonical.get(52..).unwrap()).unwrap();
|
||||
let text_section_len = 2 + descriptor_len;
|
||||
assert_eq!(payload.get(50..52), Some(&50u16.to_be_bytes()[..]));
|
||||
assert_eq!(payload.get(52..56), Some(&16u32.to_be_bytes()[..]));
|
||||
assert_eq!(payload.get(50..50 + text_section_len), canonical.get(50..50 + text_section_len));
|
||||
assert!(matches!(text_descriptor.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == "Synthetic Ω 🦀"));
|
||||
let text_descriptor_from_output = VersionedDescriptor::parse_prefix(payload.get(52..).unwrap()).unwrap().0;
|
||||
assert!(matches!(text_descriptor_from_output.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == "Synthetic Ω 🦀"));
|
||||
|
||||
let warp_at = 50 + text_section_len;
|
||||
assert_eq!(payload.get(warp_at..warp_at + 2), Some(&1u16.to_le_bytes()[..]));
|
||||
let (_, warp_len) = VersionedDescriptor::parse_prefix(canonical.get(warp_at + 2..).unwrap()).unwrap();
|
||||
let warp_descriptor_at = warp_at + 2;
|
||||
assert_eq!(payload.get(warp_descriptor_at..warp_descriptor_at + 4), Some(&16u32.to_le_bytes()[..]));
|
||||
assert_eq!(payload.get(warp_descriptor_at + 4..warp_descriptor_at + 8), Some(&0u32.to_le_bytes()[..]));
|
||||
assert_eq!(payload.get(warp_descriptor_at + 8..warp_descriptor_at + 12), Some(&4u32.to_le_bytes()[..]));
|
||||
assert_eq!(payload.get(warp_descriptor_at + 12..warp_descriptor_at + 16), Some(b"warp".as_slice()));
|
||||
assert_eq!(payload.get(warp_descriptor_at + 16..warp_descriptor_at + 20), Some(&0u32.to_le_bytes()[..]));
|
||||
let bounds_at = warp_descriptor_at + warp_len;
|
||||
for (index, expected) in [11i32, -22, 333, 4_444].into_iter().enumerate() {
|
||||
let start = bounds_at + index * 4;
|
||||
assert_eq!(payload.get(start..start + 4), Some(&expected.to_le_bytes()[..]), "bound {index}");
|
||||
}
|
||||
|
||||
let (big, warnings) = source.to_bytes(ByteOrder::Big).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(tagged_payload(&big, b"8BIMTySh", false), canonical);
|
||||
|
||||
let (reopened, warnings) = ImageSourceData::from_bytes(&little).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(reopened.global_blocks[0].data, canonical);
|
||||
assert_eq!(reopened.to_bytes(ByteOrder::Big).unwrap().0, big);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_little_endian_type_descriptor_still_imports() {
|
||||
let input = legacy_little_endian_type_block();
|
||||
let (reopened, warnings) = ImageSourceData::from_bytes(&input).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(reopened.global_blocks[0].data, type_layer_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_block_layout() {
|
||||
let mut data = Vec::new();
|
||||
|
||||
@@ -232,6 +232,7 @@ fn global_mask_accessors() {
|
||||
assert_eq!(gm.opacity(), Some(50));
|
||||
assert_eq!(gm.kind(), Some(128));
|
||||
assert_eq!(GlobalLayerMask::default().kind(), None);
|
||||
assert_eq!(GlobalLayerMask::photoshop_default().data, gm.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user