Merge main into the integration batch

Main split photocraft-cli into modules, so PR #8807's skip of Deselect with nothing selected is applied in document.rs and batch.rs, with its two tests in tests/it/cli.rs.
This commit is contained in:
Federico Olivares committed 2026-10-24 15:43:40 -03:00
commit 2fdce3931a
93 files changed
+6773 -1848

No files matched your search

Generated
+115
View File
@@ -241,12 +241,56 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
]
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -993,6 +1037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
@@ -1001,8 +1046,31 @@ version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_complete"
version = "4.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
@@ -1011,6 +1079,16 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "clap_mangen"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4"
dependencies = [
"clap",
"roff",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -1079,6 +1157,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "colored"
version = "2.2.0"
@@ -3066,6 +3150,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.10.5"
@@ -4403,6 +4493,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oorandom"
version = "11.1.5"
@@ -4643,6 +4739,7 @@ dependencies = [
"arboard",
"async-io",
"chrono",
"clap",
"core-graphics2",
"dispatch2",
"eframe",
@@ -4673,6 +4770,7 @@ dependencies = [
"ron",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"ureq",
"winit",
"winresource",
@@ -4714,6 +4812,7 @@ version = "0.15.1"
dependencies = [
"base64 0.22.1",
"cap-std",
"clap",
"getrandom 0.3.4",
"photocraft-codecs",
"photocraft-compose",
@@ -4735,6 +4834,9 @@ dependencies = [
name = "photocraft-cli"
version = "0.15.1"
dependencies = [
"clap",
"clap_complete",
"clap_mangen",
"photocraft-automation",
"photocraft-codecs",
"photocraft-engine",
@@ -4742,6 +4844,7 @@ dependencies = [
"photocraft-io",
"photocraft-raw",
"serde_json",
"thiserror 2.0.21",
"tokio",
"winresource",
]
@@ -5959,6 +6062,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "roff"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]]
name = "ron"
version = "0.12.2"
@@ -7544,6 +7653,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.26.1"
+3
View File
@@ -44,6 +44,9 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4.6.11"
clap_mangen = { version = "0.3.3", features = ["env"] }
arc-swap = "1"
bytemuck = { version = "1", features = ["derive"] }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
+6
View File
@@ -28,12 +28,18 @@ photocraft-engine = { workspace = true }
photocraft-io = { workspace = true }
photocraft-format = { path = "../../crates/format" }
photocraft-codecs = { workspace = true }
clap = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[dev-dependencies]
# Synthetic camera raws (#7789).
photocraft-raw = { workspace = true, features = ["testgen"] }
# Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in
# the binary.
clap_complete = { workspace = true }
clap_mangen = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows.
@@ -0,0 +1,41 @@
//! Writes photocraft-cli's shell completions ([`clap_complete`]: bash, zsh, fish, elvish,
//! PowerShell) and man pages ([`clap_mangen`]: `photocraft-cli.1` and one per subcommand, such as
//! `photocraft-cli-convert.1`) into a directory. The Linux and FreeBSD packages ship them
//! (`packaging/linux/package.sh`, `packaging/freebsd/package.sh`):
//!
//! ```sh
//! cargo run -p photocraft-cli --example man-and-completions -- <out-dir>
//! ```
use std::path::Path;
use std::process::ExitCode;
use clap::ValueEnum;
use clap_complete::Shell;
fn main() -> ExitCode {
let mut args = std::env::args_os().skip(1);
let (Some(dir), None) = (args.next(), args.next()) else {
eprintln!("usage: man-and-completions <out-dir>");
return ExitCode::from(2);
};
let dir = Path::new(&dir);
match generate(dir) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {}: {e}", dir.display());
ExitCode::FAILURE
}
}
}
fn generate(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let mut cmd = photocraft_cli::command();
for &shell in Shell::value_variants() {
clap_complete::generate_to(shell, &mut cmd, "photocraft-cli", dir)?;
}
// A fresh command: `clap_complete::generate_to` built `cmd`, adding the `help` subcommand,
// which `clap_mangen` would otherwise give pages of its own (`photocraft-cli-help-convert.1`).
clap_mangen::generate_to(photocraft_cli::command(), dir)
}
+116
View File
@@ -0,0 +1,116 @@
use std::path::{Path, PathBuf};
use photocraft_engine::actions_cmds::Action;
use serde_json::{Value, json};
/// Why an actions file can't be played.
#[derive(Debug, thiserror::Error)]
pub enum ActionsError {
#[error("actions JSON: {0}")]
Json(#[source] serde_json::Error),
/// The steps don't parse ([`photocraft_engine::automate_cmds::parse_action`]).
#[error(transparent)]
Steps(#[from] photocraft_engine::EngineError),
#[error("--action picks an action from an Actions set ({{\"actions\": [{{\"name\", \"steps\"}}…]}}), but this file holds one action's steps")]
NotASet,
#[error("Actions set: {0}")]
Set(#[source] serde_json::Error),
#[error("no action named `{name}` in the set (it holds {})", quoted(names))]
NoSuchAction { name: String, names: Vec<String> },
#[error("the Actions set holds {} actions: pick one with --action <name> ({})", names.len(), quoted(names))]
PickOne { names: Vec<String> },
#[error("{}: {source}", path.display())]
Read { path: PathBuf, source: std::io::Error },
#[error("{}: {source}", path.display())]
Utf8 { path: PathBuf, source: std::string::FromUtf8Error },
/// A Photoshop Actions file that doesn't import ([`photocraft_engine::atn_cmds::import`]).
#[error("{}: {message}", path.display())]
Atn { path: PathBuf, message: String },
#[error("no action named `{name}` in the set `{set}` (it holds {})", quoted(names))]
NoSuchAtnAction { name: String, set: String, names: Vec<String> },
#[error("{}: the set holds no actions", path.display())]
EmptyAtn { path: PathBuf },
#[error("the set `{set}` holds {} actions: pick one with --action <name> ({})", names.len(), quoted(names))]
PickOneAtn { set: String, names: Vec<String> },
}
/// `` `a`, `b` ``: the action names an error lists.
fn quoted(names: &[String]) -> String {
names.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", ")
}
fn names(actions: &[Action]) -> Vec<String> {
actions.iter().map(|a| a.name.clone()).collect()
}
/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs,
/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, ActionsError> {
let v: Value = serde_json::from_str(text).map_err(ActionsError::Json)?;
steps_of(&v)
}
fn steps_of(v: &Value) -> Result<Steps, ActionsError> {
Ok(photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions")?)
}
/// Engine commands to run in order: `(id, params)`.
pub type Steps = Vec<(String, Value)>;
/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the
/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays
/// the action `name` picks (optional when the set holds one) with the whole set loaded, as
/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786).
pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>), ActionsError> {
let v: Value = serde_json::from_str(text).map_err(ActionsError::Json)?;
let list = v.get("actions").unwrap_or(&v);
let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string)));
if !is_set {
if name.is_some() {
return Err(ActionsError::NotASet);
}
return Ok((steps_of(&v)?, Vec::new()));
}
let set: Vec<Action> = serde_json::from_value(list.clone()).map_err(ActionsError::Set)?;
let action = match (name, set.as_slice()) {
(Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAction { name: n.to_owned(), names: names(&set) })?,
(None, [only]) => only,
(None, _) => return Err(ActionsError::PickOne { names: names(&set) }),
};
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, set))
}
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
/// its version word) is converted first; the played action's steps with no PhotoCraft
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
pub fn load_actions_file(path: &Path, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), ActionsError> {
let bytes = std::fs::read(path).map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?;
let is_atn = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
if !is_atn {
let text = String::from_utf8(bytes).map_err(|source| ActionsError::Utf8 { path: path.to_path_buf(), source })?;
let (steps, set) = load_actions(&text, name)?;
return Ok((steps, set, Vec::new()));
}
let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|message| ActionsError::Atn { path: path.to_path_buf(), message })?;
let action = match (name, imp.actions.as_slice()) {
(Some(n), _) => imp.actions.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAtnAction {
name: n.to_owned(),
set: imp.set.clone(),
names: names(&imp.actions),
})?,
(None, [only]) => only,
(None, []) => return Err(ActionsError::EmptyAtn { path: path.to_path_buf() }),
(None, _) => return Err(ActionsError::PickOneAtn { set: imp.set.clone(), names: names(&imp.actions) }),
};
let warnings = imp
.unsupported
.iter()
.filter(|u| u.action == action.name)
.map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event))
.chain(imp.warnings.iter().cloned())
.collect();
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, imp.actions, warnings))
}
+401
View File
@@ -0,0 +1,401 @@
//! The command line, as clap types. Values are parsed into their types here, so a bad one is a
//! usage error naming its flag (exit 2) and the handlers never see it (#7071). A flag a
//! subcommand doesn't take is a usage error too, so a typo such as `--fromat` can't be silently
//! ignored (#423), and so is a repeated one: `--quality 80 --quality 60` is more likely a mistake
//! than a choice. A value starting with `-` is written `--out=-image.png` (or `./-image.png`), so a
//! value flag never takes the next flag as its value; negative numbers are kept where they mean
//! something.
use std::ffi::OsString;
use std::fmt;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use clap::error::ErrorKind;
use clap::parser::ValueSource;
use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand};
use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars};
use photocraft_codecs::{JpegFormat, TiffCompression};
use photocraft_io::{DdsCompression, DpxBits, TgaBits};
use serde_json::Value;
/// photocraft-cli: headless PhotoCraft
#[derive(Parser)]
#[command(name = "photocraft-cli", version = photocraft_engine::build_info::long_version_static(), arg_required_else_help = true)]
pub(crate) struct Cli {
#[command(subcommand)]
pub(crate) command: Command,
}
#[derive(Subcommand)]
pub(crate) enum Command {
Convert(ConvertArgs),
Info(InfoArgs),
Run(RunArgs),
Batch(BatchArgs),
Droplet(DropletArgs),
Commands(CommandsArgs),
Mcp(McpArgs),
Serve(ServeArgs),
}
// How an image is written: shared by `convert`, `run` and `batch`.
#[derive(Args)]
pub(crate) struct ExportArgs {
/// Output format, overriding the output's extension (png, jpg, tif, webp, …)
#[arg(long, value_name = "EXT")]
pub(crate) format: Option<String>,
/// JPEG or WebP quality, 1-100. A WebP written with a quality is lossy, without one lossless
// allow_negative_numbers makes `--quality -1` an out-of-range value rather than an unexpected argument.
#[arg(long, value_name = "1-100", value_parser = clap::value_parser!(u8).range(1..=100), allow_negative_numbers = true)]
pub(crate) quality: Option<u8>,
/// Keep the layers in TIFF output (Photoshop layer data); TIFF output is flat without it
#[arg(long)]
pub(crate) tiff_layers: bool,
/// Compress TIFF output: none (the default), lzw or zip
#[arg(long, value_name = "none|lzw|zip", value_parser = tiff_compression)]
pub(crate) tiff_compression: Option<TiffCompression>,
/// A TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its
/// alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24
#[arg(long, value_name = "24|32", value_parser = tga_bits)]
pub(crate) tga_bits: Option<TgaBits>,
/// A DPX's bits per sample. Without it: 8 for an 8-bit document, else 16
#[arg(long, value_name = "8|10|12|16", value_parser = dpx_bits)]
pub(crate) dpx_bits: Option<DpxBits>,
/// A DDS texture's compression: none (uncompressed, the default), bc1 (DXT1: alpha on or
/// off), bc2 (DXT3) or bc3 (DXT5)
#[arg(long, value_name = "none|bc1|bc2|bc3", value_parser = dds_compression)]
pub(crate) dds_compression: Option<DdsCompression>,
/// Also write a DDS texture's full mip chain
#[arg(long)]
pub(crate) dds_mipmaps: bool,
/// A JPEG's scans, as JPEG Options picks them: baseline (the default), optimized (Huffman
/// tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so only Gray
/// and CMYK shrink) or progressive
#[arg(long, value_name = "baseline|optimized|progressive", value_parser = jpeg_format)]
pub(crate) jpeg_format: Option<JpegFormat>,
}
/// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …)
///
/// An <OUT> that is the <IN> file is refused, as the result would replace the original;
/// --in-place allows it.
#[derive(Args)]
pub(crate) struct ConvertArgs {
/// The file to read
#[arg(value_name = "IN")]
pub(crate) input: PathBuf,
/// The file to write; its extension picks the format unless --format is given
#[arg(value_name = "OUT")]
pub(crate) output: PathBuf,
/// Allow an <OUT> that is the <IN> file, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) export: ExportArgs,
}
/// Print the document as JSON (size, mode, depth, layer tree)
#[derive(Args)]
pub(crate) struct InfoArgs {
pub(crate) file: PathBuf,
/// One line of JSON instead of pretty-printed
#[arg(long)]
pub(crate) compact: bool,
}
/// Open a file (or start a new document), run engine commands in order, save the result
///
/// Prints each command's JSON result. Give exactly one of <FILE> or --new, and at least one --cmd
/// or --out. An --out that is the <FILE> is refused, as the result would replace the original;
/// --in-place allows it.
#[derive(Args)]
#[command(group(ArgGroup::new("source").args(["file", "new"]).required(true)))]
#[command(group(ArgGroup::new("work").args(["cmd", "out"]).required(true).multiple(true)))]
pub(crate) struct RunArgs {
/// The document to open
pub(crate) file: Option<PathBuf>,
/// Start from a new document with these JSON parameters instead of opening a file
#[arg(long, value_name = "JSON", value_parser = json)]
pub(crate) new: Option<Value>,
/// An engine command to run; repeat for several, run in order
#[arg(long, value_name = "ID", action = ArgAction::Append)]
pub(crate) cmd: Vec<String>,
/// JSON parameters for the --cmd before it (default {})
#[arg(long, value_name = "JSON", action = ArgAction::Append, value_parser = json)]
pub(crate) params: Vec<Value>,
/// Save the result to this file
#[arg(long, value_name = "FILE")]
pub(crate) out: Option<PathBuf>,
/// Allow an --out that is the <FILE>, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) export: ExportArgs,
/// `--cmd` and `--params` values in command-line order, set by [`parse`].
#[arg(skip)]
pub(crate) steps: Vec<Step>,
}
/// One `--cmd` or `--params` of `run`, in command-line order.
pub(crate) enum Step {
Cmd(String),
Params(Value),
}
/// Apply an action list to every image in a directory
///
/// A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps
/// are [id, params] pairs, {"command": id, "params": {…}} objects or bare ids, as a recorded
/// action or droplet stores them (a list, or wrapped in {"actions": …}, {"steps": …} or a
/// droplet). An Actions set (the Actions panel's actions.json, {"actions": [{"name", "steps"}…]})
/// plays the action --action names (needed when the set holds more than one) with the whole set
/// loaded, so its steps can play other actions of the set.
#[derive(Args)]
pub(crate) struct BatchArgs {
/// The action list: actions.json, a droplet, or a Photoshop Actions file (.atn)
#[arg(long, value_name = "FILE")]
pub(crate) actions: PathBuf,
/// Which action of an Actions set to play
#[arg(long, value_name = "NAME")]
pub(crate) action: Option<String>,
/// The folder of images to process
#[arg(long = "in", value_name = "DIR")]
pub(crate) in_dir: PathBuf,
/// The folder to write the results to, as <stem>.<ext>
#[arg(long, value_name = "DIR")]
pub(crate) out: PathBuf,
/// An --out folder that is the --in folder is refused, as the results would replace the
/// originals; --in-place allows it
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) export: ExportArgs,
}
/// Run a droplet (File › Automate › Create Droplet) on images and folders
#[derive(Args)]
pub(crate) struct DropletArgs {
/// The droplet to run
#[arg(value_name = "FILE.pcdroplet")]
pub(crate) file: String,
/// Images and folders to run it on
#[arg(value_name = "FILE-OR-DIR", required = true)]
pub(crate) inputs: Vec<String>,
/// The folder to write the results to
#[arg(long, value_name = "DIR")]
pub(crate) out: Option<String>,
}
/// List the engine command registry
#[derive(Args)]
pub(crate) struct CommandsArgs {
/// Print the entries as JSON
#[arg(long)]
pub(crate) json: bool,
/// Only the commands matching this text
#[arg(long, value_name = "TEXT", allow_negative_numbers = true)]
pub(crate) filter: Option<String>,
}
/// Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control <port>`)
#[derive(Args)]
pub(crate) struct McpArgs {
/// Bridge to the PhotoCraft app listening here instead of a headless engine; a bare port means
/// 127.0.0.1:<PORT>
#[arg(long, value_name = "127.0.0.1:PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = bridge)]
pub(crate) bridge: Option<Bridge>,
/// `--bridge` came from PHOTOCRAFT_CONTROL_PORT, set by [`parse`].
#[arg(skip)]
pub(crate) bridge_from_env: bool,
#[command(flatten)]
pub(crate) control: ControlArgs,
#[command(flatten)]
pub(crate) automation: AutomationArgs,
}
/// Keep one headless session open and answer JSON lines on stdio, or on 127.0.0.1:<PORT>
///
/// Each request is a JSON line {"id", "method", "params"}. Methods: engine.execute,
/// jobs.list/cancel, engine.commands,
/// doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close,
/// session.list, batch, methods (docs/control-protocol.md#headless-server).
#[derive(Args)]
pub(crate) struct ServeArgs {
/// Listen on 127.0.0.1:<PORT> instead of stdio
#[arg(long, value_name = "PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = control_port)]
pub(crate) port: Option<u16>,
/// `--port` came from PHOTOCRAFT_CONTROL_PORT, set by [`parse`].
#[arg(skip)]
pub(crate) port_from_env: bool,
#[command(flatten)]
pub(crate) control: ControlArgs,
#[command(flatten)]
pub(crate) automation: AutomationArgs,
}
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
enum ValueError {
#[error("expected 24 or 32")]
TgaBits,
#[error("expected 8, 10, 12 or 16")]
DpxBits,
#[error("expected none, bc1, bc2 or bc3")]
DdsCompression,
#[error("expected none, lzw or zip")]
TiffCompression,
#[error("expected baseline, optimized or progressive")]
JpegFormat,
/// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused.
#[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:<PORT>)")]
Bridge,
}
fn tga_bits(s: &str) -> Result<TgaBits, ValueError> {
s.parse().ok().and_then(TgaBits::from_bits).ok_or(ValueError::TgaBits)
}
fn dpx_bits(s: &str) -> Result<DpxBits, ValueError> {
s.parse().ok().and_then(DpxBits::from_bits).ok_or(ValueError::DpxBits)
}
fn dds_compression(s: &str) -> Result<DdsCompression, ValueError> {
DdsCompression::from_name(s).ok_or(ValueError::DdsCompression)
}
fn tiff_compression(s: &str) -> Result<TiffCompression, ValueError> {
match s {
"none" => Ok(TiffCompression::None),
"lzw" => Ok(TiffCompression::Lzw),
"zip" => Ok(TiffCompression::Deflate),
_ => Err(ValueError::TiffCompression),
}
}
fn jpeg_format(s: &str) -> Result<JpegFormat, ValueError> {
JpegFormat::parse(s).ok_or(ValueError::JpegFormat)
}
/// Where `mcp --bridge` connects. Shown as the address: `127.0.0.1:<PORT>` for a port.
#[derive(Clone)]
pub(crate) enum Bridge {
/// A bare port: the app on 127.0.0.1.
Port(u16),
/// `host:port`, as given. Whether the host is loopback is the bridge's to say.
Addr(String),
}
impl fmt::Display for Bridge {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Bridge::Port(port) => SocketAddr::from((Ipv4Addr::LOCALHOST, *port)).fmt(f),
Bridge::Addr(addr) => f.write_str(addr),
}
}
}
/// `mcp --bridge`, or PHOTOCRAFT_CONTROL_PORT, which holds a port.
fn bridge(s: &str) -> Result<Bridge, ValueError> {
match control_port(s) {
Ok(port) => Ok(Bridge::Port(port)),
Err(_) if s.contains(':') => Ok(Bridge::Addr(s.to_owned())),
Err(_) => Err(ValueError::Bridge),
}
}
/// `--new` and `--params`: bad JSON is a usage error, before any work. Whether the value is an
/// object is the command's to say.
fn json(s: &str) -> Result<Value, serde_json::Error> {
serde_json::from_str(s)
}
/// `--cmd` and `--params` values back in command-line order: clap keeps each flag's values apart,
/// their indices say how they interleave.
fn steps(m: &ArgMatches) -> Vec<Step> {
let mut steps: Vec<(usize, Step)> = Vec::new();
if let (Some(at), Ok(Some(ids))) = (m.indices_of("cmd"), m.try_get_many::<String>("cmd")) {
steps.extend(at.zip(ids).map(|(i, id)| (i, Step::Cmd(id.clone()))));
}
if let (Some(at), Ok(Some(params))) = (m.indices_of("params"), m.try_get_many::<Value>("params")) {
steps.extend(at.zip(params).map(|(i, p)| (i, Step::Params(p.clone()))));
}
steps.sort_by_key(|s| s.0);
steps.into_iter().map(|(_, s)| s).collect()
}
/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a path need not
/// be valid Unicode (#1108), text must. A flag left out falls back to its environment variable,
/// unless that is empty, and a bad value from one is a usage error naming the variable (#2491).
pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
let mut cmd = Cli::command().mut_subcommands(|sub| sub.mut_args(blank_env_is_unset));
let argv = std::iter::once(OsString::from("photocraft-cli")).chain(args.iter().cloned());
let matches = match cmd.try_get_matches_from_mut(argv) {
// Help where a flag's value should be (`--out --help`) is still help: clap reports the
// missing value first.
Err(e) if e.kind() == ErrorKind::InvalidValue && args.iter().take_while(|a| *a != "--").any(|a| *a == "--help" || *a == "-h") => {
let help = [OsString::from("photocraft-cli"), args.first().cloned().unwrap_or_default(), OsString::from("--help")];
return Err(Cli::command().try_get_matches_from(help).err().unwrap_or(e));
}
matches => matches.map_err(|e| name_env_vars(e, &cmd, args))?,
};
let mut cli = Cli::from_arg_matches(&matches)?;
let from_env = |m: &ArgMatches, id: &str| m.value_source(id) == Some(ValueSource::EnvVariable);
match (&mut cli.command, matches.subcommand()) {
(Command::Run(r), Some((_, m))) => r.steps = steps(m),
(Command::Mcp(a), Some((name, m))) => {
a.bridge_from_env = from_env(m, "bridge");
// The variable holds a port, as for the desktop app's --control: only the flag takes
// host:port, which `bridge` lets through.
if a.bridge_from_env
&& let Some(Bridge::Addr(v)) = &a.bridge
{
return Err(cmd.error(ErrorKind::ValueValidation, format!("invalid value '{v}' for 'PHOTOCRAFT_CONTROL_PORT': {}", ValueError::Bridge)));
}
if let Some(sub) = cmd.find_subcommand_mut(name) {
// Only the bridge reads a token, so a stale exported pair doesn't stop a headless
// run.
if a.bridge.is_some() {
conflict(sub, m, "control_token", "control_token_file")?;
}
// The roots are the headless engine's: the variable switching to the bridge would
// drop them.
if a.bridge_from_env {
conflict(sub, m, "automation_read_root", "bridge")?;
conflict(sub, m, "automation_write_root", "bridge")?;
}
}
check_server(cmd.find_subcommand_mut(name), m, "bridge")?;
}
(Command::Serve(a), Some((name, m))) => {
a.port_from_env = from_env(m, "port");
// Only the TCP server reads a token, so a stale exported pair doesn't stop stdio.
if a.port.is_some()
&& let Some(sub) = cmd.find_subcommand_mut(name)
{
conflict(sub, m, "control_token", "control_token_file")?;
}
check_server(cmd.find_subcommand_mut(name), m, "port")?;
}
_ => {}
}
Ok(cli)
}
/// Refuse `mcp` and `serve` launch options that would otherwise be silently ignored (#8035): a
/// token flag without the bridge or port that reads it (flag or variable; a token variable alone
/// may be a stale export), and bridge roots, which belong to the desktop process: accepting them
/// here promises authority the bridge cannot set.
fn check_server(sub: Option<&mut clap::Command>, m: &ArgMatches, transport: &str) -> Result<(), clap::Error> {
let Some(sub) = sub else { return Ok(()) };
for (id, flag) in [("control_token", "--control-token"), ("control_token_file", "--control-token-file")] {
if m.value_source(id) == Some(ValueSource::CommandLine) && !m.contains_id(transport) {
return Err(sub.error(ErrorKind::MissingRequiredArgument, format!("{flag} requires --{transport}")));
}
}
if transport == "bridge" && m.contains_id(transport) && (m.contains_id("automation_read_root") || m.contains_id("automation_write_root")) {
let message = "configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode";
return Err(sub.error(ErrorKind::ArgumentConflict, message));
}
Ok(())
}
+82
View File
@@ -0,0 +1,82 @@
use std::io::Write;
use std::net::{Ipv4Addr, SocketAddr};
use serde_json::Value;
use photocraft_automation::{Headless, PhotocraftMcp, security};
use crate::{
Result,
args::{CommandsArgs, McpArgs, ServeArgs},
output::{JsonStyle, print_json},
};
pub(crate) fn commands(a: &CommandsArgs, out: &mut dyn Write) -> Result {
let h = Headless::new();
let list = h.command_list();
let needle = a.filter.as_deref().map(str::to_lowercase);
let items: Vec<&Value> = list
.as_array()
.map(|v| v.iter().collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)))
.collect();
if a.json {
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), JsonStyle::Pretty);
}
for c in items {
let menu = c["menu"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" > ")).unwrap_or_default();
writeln!(out, "{:<44} {:<32} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""), menu)?;
}
Ok(())
}
pub(crate) fn serve(a: &ServeArgs, err: &mut dyn Write) -> Result {
use std::sync::{Arc, Mutex};
let h = Arc::new(Mutex::new(Headless::with_workspace(a.automation.workspace()?)));
match a.port {
Some(port) => {
if a.port_from_env {
// Name the mode switch: an exported variable changes what this command does, and
// unsetting it (or setting it empty) is the way back to stdio.
let _ = writeln!(err, "photocraft-cli: PHOTOCRAFT_CONTROL_PORT={port} is set; serving on TCP instead of stdio (unset it to serve stdio)");
}
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)).to_string();
let (supplied, token_file) = (a.control.control_token.as_deref(), a.control.control_token_file.as_deref());
let token = security::server_token(supplied, token_file)?;
if let Some(path) = token_file {
let _ = writeln!(err, "photocraft-cli control token file: {}", path.display());
} else if supplied.is_none() {
let _ = writeln!(err, "photocraft-cli control token: {token}");
}
photocraft_automation::rpc::serve_tcp(&addr, h, token, |local| {
let _ = writeln!(err, "photocraft-cli serving on {local}");
})?;
Ok(())
}
None => {
let stdin = std::io::stdin();
Ok(photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout())?)
}
}
}
pub(crate) fn mcp(a: &McpArgs, err: &mut dyn Write) -> Result {
let server = match &a.bridge {
Some(addr) => {
if a.bridge_from_env {
// Name the mode switch, as `serve` does: the way back to headless is to unset it.
let _ = writeln!(
err,
"photocraft-cli: PHOTOCRAFT_CONTROL_PORT is set; bridging to the app on {addr} instead of a headless engine (unset it to run headless)"
);
}
let token = security::client_token(a.control.control_token.as_deref(), a.control.control_token_file.as_deref())?;
PhotocraftMcp::bridge(&addr.to_string(), &token)?
}
None => PhotocraftMcp::headless_with_workspace(a.automation.workspace()?),
};
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
Ok(rt.block_on(server.serve_stdio())?)
}
+213
View File
@@ -0,0 +1,213 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use photocraft_automation::Headless;
use photocraft_engine::actions_cmds::Action;
use photocraft_io::ExportOptions;
use serde_json::json;
use crate::{
CliError, Result,
actions::load_actions_file,
args::{BatchArgs, DropletArgs},
output::warn_all,
};
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
/// trailing separator, through a symbolic link).
fn same_dir(a: &Path, b: &Path) -> bool {
matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
}
pub(crate) fn is_input(p: &Path) -> bool {
let known = |e: &str| {
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub")
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
};
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
}
/// Drops the Save and view steps the desktop records from `action` (Batch saves each result
/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860).
fn strip_shell_steps(action: &mut Action) {
use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command};
let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new());
for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() {
if shell_save_command(&id) || shell_view_command(&id) {
continue;
}
if action.disabled.contains(&i) {
disabled.insert(steps.len());
}
if action.dialogs.contains(&i) {
dialogs.insert(steps.len());
}
steps.push((id, p));
}
(action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs);
}
pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result {
let (in_dir, out_dir) = (a.in_dir.as_path(), a.out.as_path());
let (actions, mut set, warnings) = load_actions_file(&a.actions, a.action.as_deref())?;
set.iter_mut().for_each(strip_shell_steps);
warn_all(err, &warnings);
let opts = ExportOptions::from(&a.export);
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
if !a.in_place && same_dir(in_dir, out_dir) {
return Err(CliError::OutIsIn(out_dir.to_path_buf()));
}
std::fs::create_dir_all(out_dir)?;
let mut inputs: Vec<PathBuf> = std::fs::read_dir(in_dir)
.map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?
.flatten()
.map(|e| e.path())
.filter(|p| is_input(p))
.collect();
inputs.sort();
let (mut ok, mut failed) = (0, 0);
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
let format = a.export.format.as_deref().map(|f| f.strip_prefix('.').unwrap_or(f));
for input in &inputs {
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let target = out_dir.join(format!("{stem}.{ext}"));
let target_text = target.to_string_lossy();
let r = (|| -> Result<Vec<String>> {
written.check(&target_text).map_err(CliError::OutputClaimed)?;
let mut h = Headless::trusted_local();
// Steps that play another action find it in the set; the session's playback stack
// stops an action that calls itself.
h.session.actions.list = set.clone();
h.open(input)?;
for (id, p) in &actions {
if photocraft_engine::actions_cmds::shell_view_command(id)
|| photocraft_engine::actions_cmds::shell_save_command(id)
|| photocraft_engine::actions_cmds::noop_step(&h.session, id)
{
continue;
}
let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?;
// A failed step inside a called action makes this file an error, not a saved result.
if let Some(message) = photocraft_engine::actions_cmds::nested_failure(id, &r) {
return Err(CliError::NestedFailure { id: id.clone(), message });
}
}
let r = h.save(None, Some(&target), Some(&ext), &opts)?;
Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
})();
match r {
Ok(ws) => {
ok += 1;
written.record(&target_text, &input.to_string_lossy());
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
warn_all(err, &ws);
}
Err(e) => {
failed += 1;
let _ = writeln!(err, "FAIL {}: {e}", input.display());
}
}
}
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
if failed > 0 { Err(CliError::FilesFailed(failed)) } else { Ok(()) }
}
pub(crate) fn droplet(a: &DropletArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result {
let (file, inputs) = (&a.file, &a.inputs);
let mut p = json!({"droplet": file, "input": inputs});
if let Some(o) = &a.out {
p["output"] = json!(o);
}
let mut s = photocraft_engine::Session::new();
let r = s.execute("file.automate.runDroplet", p)?;
for f in r["files"].as_array().into_iter().flatten() {
let _ = writeln!(out, "ok {}", f.as_str().unwrap_or_default());
}
let errors = r["errors"].as_array().cloned().unwrap_or_default();
for e in &errors {
let _ = writeln!(err, "FAIL {}: {}", e["file"].as_str().unwrap_or_default(), e["error"].as_str().unwrap_or_default());
}
if errors.is_empty() { Ok(()) } else { Err(CliError::FilesFailed(errors.len())) }
}
#[cfg(test)]
mod batch_action_set_tests {
use super::*;
use crate::test_support::{Invoke, invoke};
use serde_json::Value;
/// A batched action that plays another action of its set finds it in every file's session; a
/// failure inside the called action fails the file, and a self-call stops instead of looping.
#[test]
fn batch_plays_an_action_that_calls_another_action_of_its_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
for name in ["a", "b"] {
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]);
assert_eq!(code, 0, "{stderr}");
}
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]},
{"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]},
{"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]},
{"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]},
{"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
let Invoke { code, stdout, stderr } = batch("Outer");
assert_eq!(code, 0, "{stdout}{stderr}");
for name in ["a", "b"] {
let Invoke { code, stdout: info, stderr } = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action");
}
for action in ["Calls bad", "Loop"] {
let Invoke { code, stdout, stderr } = batch(action);
assert_ne!(code, 0, "{stdout}");
assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}");
assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Save and view steps recorded inside a batched action set are skipped, also in a called
/// action, and unchecked steps stay unchecked once they are gone (#6860).
#[test]
fn batch_skips_save_and_view_steps_inside_an_action_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
assert_eq!(code, 0, "{stderr}");
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]},
{"name": "Rotate", "steps": [
["file.save", {}],
["image.imageRotation.90cw", {}],
["view.fitOnScreen", {}],
["image.imageRotation.90cw", {}],
["file.saveAs", {}],
], "disabled": [3], "dialogs": [1]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
for action in ["Outer", "Rotate"] {
let Invoke { code, stdout, stderr } =
invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
assert_eq!(code, 0, "{stdout}{stderr}");
let Invoke { code, stdout: info, stderr } = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+231
View File
@@ -0,0 +1,231 @@
use std::io::Write;
use std::path::Path;
use photocraft_automation::{Headless, files};
use serde_json::{Value, json};
use photocraft_engine::type_extra_cmds::missing_font_warnings;
use photocraft_io::ExportOptions;
use crate::{
CliError, Result,
args::{ConvertArgs, InfoArgs, RunArgs, Step},
output::{JsonStyle, print_json, warn_all},
};
/// An output that is the input file itself, however it is spelt (relative, with `./`, through a
/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it
/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in`
/// folder (#7437). The paths are compared when the command starts and the save opens `output`
/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards
/// against mistakes, it is not a security boundary.
fn refuse_overwriting_input(in_place: bool, input: &Path, output: &Path) -> Result {
if !in_place && photocraft_engine::file_cmds::same_file(input, output) {
return Err(CliError::OutIsInput(output.to_path_buf()));
}
Ok(())
}
pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write) -> Result {
let (input, output) = (&a.input, &a.output);
let opts = ExportOptions::from(&a.export);
refuse_overwriting_input(a.in_place, input, output)?;
let mut o = files::open(input)?;
// Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw.
photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document);
warn_all(err, &o.warnings);
warn_all(err, &missing_font_warnings(&o.document));
let ws = files::save(&o.document, output, a.export.format.as_deref(), &opts, None)?;
warn_all(err, &ws);
Ok(())
}
pub(crate) fn info(a: &InfoArgs, out: &mut dyn Write) -> Result {
let file = &a.file;
let mut h = Headless::trusted_local();
let opened = h.open(file)?;
let mut doc = h.inspect(None)?;
let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
if let Some(st) = h.session.active() {
warnings.extend(missing_font_warnings(&st.doc).into_iter().map(Value::String));
}
doc["warnings"] = Value::Array(warnings);
doc["file"] = json!(file.to_string_lossy());
// Drop per-session noise.
if let Value::Object(m) = &mut doc {
for k in ["history", "canUndo", "canRedo", "revision"] {
m.remove(k);
}
}
print_json(out, &doc, if a.compact { JsonStyle::Compact } else { JsonStyle::Pretty })
}
/// `(id, params)` pairs from repeated `--cmd`/`--params` flags.
fn command_list(a: &RunArgs) -> Result<Vec<(String, Value)>> {
let mut cmds: Vec<(String, Value)> = Vec::new();
for step in &a.steps {
match step {
Step::Cmd(id) => cmds.push((id.clone(), json!({}))),
Step::Params(p) => cmds.last_mut().ok_or(CliError::ParamsBeforeCmd)?.1 = p.clone(),
}
}
Ok(cmds)
}
pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result {
let opts = ExportOptions::from(&a.export);
let mut h = Headless::trusted_local();
// Opening, running commands and exporting can all notice the same missing font:
// one `run` invocation reports each warning line at most once.
let mut reported_fonts = std::collections::HashSet::<String>::new();
// clap's `source` group gives exactly one of the two, and its `work` group a --cmd or --out.
if let Some(new) = &a.new {
h.command_run("file.new", new.clone()).map_err(CliError::New)?;
} else if let Some(file) = &a.file {
// Refused before the file is read or any command runs.
if let Some(out) = &a.out {
refuse_overwriting_input(a.in_place, file, out)?;
}
let o = h.open(file)?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
reported_fonts.extend(ws);
}
let cmds = command_list(a)?;
for (id, params) in cmds {
// Deselect with nothing selected is a silent no-op in a script, as in action playback (#8802).
if photocraft_engine::actions_cmds::noop_step(&h.session, &id) {
print_json(out, &json!({"command": id, "result": null}), JsonStyle::Compact)?;
continue;
}
let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?;
if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(CliError::NestedFailure { id, message });
}
let new_warnings: Vec<String> =
h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &new_warnings);
let mut result = json!({"command": id, "result": r});
if !new_warnings.is_empty() {
result["warnings"] = json!(new_warnings);
}
print_json(out, &result, JsonStyle::Compact)?;
}
// A `run` with only `--out` still reports missing fonts before exporting.
let remaining: Vec<String> =
h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &remaining);
if let Some(o) = a.out.as_deref() {
let r = h.save(None, Some(o), a.export.format.as_deref(), &opts)?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
let ws: Vec<String> = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &ws);
}
Ok(())
}
#[cfg(test)]
mod missing_font_warning_tests {
use super::*;
use crate::{
batch::is_input,
test_support::{Invoke, invoke},
};
#[test]
fn missing_typeface_is_reported_by_run_info_and_convert() {
let family = "DefinitelyMissingPhotoCraftTypeface1251";
let folder = std::env::temp_dir().join(format!("photocraft-cli-font-warnings-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("missing.pcraft");
let png = folder.join("missing.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string();
let Invoke { code, stdout, stderr } = invoke(&[
"run",
"--new",
r#"{"width":256,"height":96,"background":"white"}"#,
"--cmd",
"type.create",
"--params",
&params,
"--cmd",
"type.resolveMissingFonts",
"--out",
&native_str,
]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(stderr.matches(family).count(), 1, "multi-command runs should not repeat the same missing font warning");
let commands: Vec<Value> = stdout.lines().map(|s| serde_json::from_str(s).unwrap()).collect();
assert_eq!(commands.len(), 2, "{stdout}");
assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported");
let Invoke { code, stdout, stderr } = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr");
let Invoke { code, stderr, .. } = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}");
assert!(png.is_file());
// Even with no commands, run --out checks the opened document before export.
let Invoke { code, stdout, stderr } = invoke(&["run", &native_str, "--out", &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.is_empty());
assert_eq!(stderr.matches(family).count(), 1);
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn convert_reads_and_writes_openraster() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-ora-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let path = |name: &str| folder.join(name).to_string_lossy().to_string();
let (native, ora, png) = (path("layers.pcraft"), path("layers.ora"), path("back.png"));
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]);
assert_eq!(code, 0, "{stderr}");
let Invoke { code, stderr, .. } = invoke(&["convert", &native, &ora]);
assert_eq!(code, 0, "{stderr}");
let Invoke { code, stdout, stderr } = invoke(&["info", &ora, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(info["layers"].as_array().map(Vec::len), Some(2), "{info}");
let Invoke { code, stderr, .. } = invoke(&["convert", &ora, &png]);
assert_eq!(code, 0, "{stderr}");
assert!(is_input(std::path::Path::new(&ora)));
// Krita documents are batch inputs too (import-only).
for name in ["sketch.kra", "old.KRZ"] {
std::fs::write(folder.join(name), b"").unwrap();
assert!(is_input(&folder.join(name)), "{name}");
}
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn no_type_layers_do_not_produce_missing_font_warnings() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-no-font-warning-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("blank.pcraft");
let png = folder.join("blank.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let Invoke { code, stdout, stderr } = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '")));
let Invoke { code, stderr, .. } = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let _ = std::fs::remove_dir_all(&folder);
}
}
+42
View File
@@ -0,0 +1,42 @@
use std::path::PathBuf;
use photocraft_automation::AutomationError;
use crate::actions::ActionsError;
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
#[derive(Debug, thiserror::Error)]
pub(crate) enum CliError {
#[error(transparent)]
Automation(#[from] AutomationError),
#[error(transparent)]
Engine(#[from] photocraft_engine::EngineError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Actions(#[from] ActionsError),
#[error("{}: {source}", path.display())]
ReadDir { path: PathBuf, source: std::io::Error },
#[error("--params must follow a --cmd")]
ParamsBeforeCmd,
#[error("--new: {0}")]
New(#[source] AutomationError),
#[error("`{id}`: {source}")]
Command { id: String, source: AutomationError },
/// A step inside an action the command played failed ([`photocraft_engine::actions_cmds::nested_failure`]).
#[error("`{id}`: {message}")]
NestedFailure { id: String, message: String },
#[error("{} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it", .0.display())]
OutIsInput(PathBuf),
#[error("--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them", .0.display())]
OutIsIn(PathBuf),
/// Another input already wrote this output ([`photocraft_engine::file_cmds::OutputClaims::check`]).
#[error("{0}")]
OutputClaimed(String),
#[error("{0} file(s) failed")]
FilesFailed(usize),
}
pub(crate) type Result<T = (), E = CliError> = std::result::Result<T, E>;
File diff suppressed because it is too large. Load diff
+3 -4
View File
@@ -1,9 +1,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be a
// usage error, not a panic before anything is printed (issue #1108).
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let code = photocraft_cli::run_os(&args, &mut std::io::stdout(), &mut std::io::stderr());
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must reach clap,
// not panic before anything is printed (issue #1108).
let code = photocraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
std::process::exit(code);
}
+51
View File
@@ -0,0 +1,51 @@
use photocraft_io::ExportOptions;
use serde_json::Value;
use std::io::Write;
use crate::{Result, args::ExportArgs};
/// The `--format` family's options. A bad value never gets here: clap refused it.
impl From<&ExportArgs> for ExportOptions {
fn from(a: &ExportArgs) -> Self {
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
let mut o = ExportOptions { tiff_layers: a.tiff_layers, tga_bits: a.tga_bits, ..Default::default() };
if let Some(q) = a.quality {
o.encode.jpeg_quality = q;
// Asking for a quality asks for a lossy WebP; the default WebP stays lossless.
o.encode.webp_quality = q;
o.encode.webp_lossless = false;
}
o.encode.dpx_bits = a.dpx_bits;
if let Some(c) = a.tiff_compression {
o.encode.tiff_compression = c;
}
if let Some(f) = a.jpeg_format {
o.encode.jpeg_format = f;
}
if let Some(c) = a.dds_compression {
o.encode.dds.compression = c;
}
o.encode.dds.mipmaps = a.dds_mipmaps;
o
}
}
pub(crate) fn warn_all(err: &mut dyn Write, ws: &[String]) {
for w in ws {
let _ = writeln!(err, "warning: {w}");
}
}
/// How [`print_json`] lays the JSON out.
pub(crate) enum JsonStyle {
Pretty,
Compact,
}
pub(crate) fn print_json(out: &mut dyn Write, v: &Value, style: JsonStyle) -> Result {
let s = match style {
JsonStyle::Pretty => serde_json::to_string_pretty(v),
JsonStyle::Compact => serde_json::to_string(v),
}?;
Ok(writeln!(out, "{s}")?)
}
+478 -119
View File
@@ -6,10 +6,17 @@ use std::process::{Command, Stdio};
use serde_json::{Value, json};
/// The binary, without the variables `mcp` and `serve` fall back to: a test that wants one sets it.
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
let mut cmd = Command::new(env!("CARGO_BIN_EXE_photocraft-cli"));
for var in ENV_VARS {
cmd.env_remove(var);
}
cmd
}
const ENV_VARS: [&str; 3] = ["PHOTOCRAFT_CONTROL_PORT", "PHOTOCRAFT_CONTROL_TOKEN", "PHOTOCRAFT_CONTROL_TOKEN_FILE"];
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pc-cli-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
@@ -23,23 +30,33 @@ fn write_png(path: &Path, w: u32, h: u32, seed: u8) {
std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
}
fn ok(cmd: &mut Command) -> (String, String) {
/// What a successful run printed.
struct Printed {
stdout: String,
stderr: String,
}
fn ok(cmd: &mut Command) -> Printed {
let o = cmd.output().unwrap();
let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status);
(out, err)
let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
assert!(o.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", o.status);
Printed { stdout, stderr }
}
#[test]
fn usage_and_unknown_command() {
let o = bin().output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("USAGE"));
assert!(String::from_utf8_lossy(&o.stderr).contains("convert"));
let o = bin().arg("frobnicate").output().unwrap();
assert_eq!(o.status.code(), Some(2));
let (out, _) = ok(bin().arg("--help"));
// `help` names a subcommand or nothing: anything else is a usage error too.
let o = bin().args(["help", "frobnicate"]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(!o.stderr.is_empty());
let out = ok(bin().arg("--help")).stdout;
assert!(out.contains("convert"));
let (out, _) = ok(bin().arg("--version"));
let out = ok(bin().arg("--version")).stdout;
assert!(out.starts_with("photocraft-cli "));
}
@@ -53,14 +70,14 @@ fn every_subcommand_answers_help() {
let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap();
let out = String::from_utf8_lossy(&o.stdout);
assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr));
assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}");
assert!(out.contains(&format!("photocraft-cli {sub}")), "{sub} {help}: {out}");
}
}
// Help where a flag's value should be is still help, not a missing value or a file named `-h`.
for args in [&["run", "x.png", "--out", "--help"][..], &["commands", "--filter", "-h"], &["mcp", "--bridge", "-h"], &["serve", "--port", "--help"]] {
let o = bin().current_dir(&d).args(args).stdin(Stdio::null()).output().unwrap();
assert_eq!(o.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&o.stderr));
assert!(String::from_utf8_lossy(&o.stdout).contains("USAGE"), "{args:?}");
assert!(String::from_utf8_lossy(&o.stdout).contains(&format!("photocraft-cli {}", args[0])), "{args:?}");
}
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing");
}
@@ -79,7 +96,7 @@ fn unknown_flags_are_usage_errors() {
let o = cmd.output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains(&format!("unknown flag {flag}")), "{err}");
assert!(err.contains(flag), "{err}");
};
for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] {
fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat");
@@ -92,7 +109,7 @@ fn unknown_flags_are_usage_errors() {
fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json");
let o = bin().args(["commands", "--json=yes"]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value"));
assert!(String::from_utf8_lossy(&o.stderr).contains("--json"));
// A flag can't be a value: a missing value is reported, not filled with the next flag.
let o = bin()
.args(["batch", "--actions"])
@@ -105,16 +122,16 @@ fn unknown_flags_are_usage_errors() {
.output()
.unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--format needs a value"));
assert!(String::from_utf8_lossy(&o.stderr).contains("a value is required for '--format"));
assert!(!out_dir.exists(), "nothing written");
// `--flag=value` still passes a value that starts with `--`.
ok(bin().args(["commands", "--filter=--json"]));
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
let out = ok(bin().args(["commands", "--json", "--filter=gaussian"])).stdout;
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
for needle in ["ellipse", "marquee"] {
let (out, _) = ok(bin().args(["commands", "--filter", needle]));
let out = ok(bin().args(["commands", "--filter", needle])).stdout;
assert!(out.contains("select.rect"), "{needle}: {out}");
}
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
@@ -123,7 +140,7 @@ fn unknown_flags_are_usage_errors() {
"80",
]));
assert!(out_dir.join("a.jpg").exists());
let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact"));
let out = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact")).stdout;
assert!(!out.trim().contains('\n'), "compact JSON is one line");
}
@@ -138,24 +155,22 @@ fn server_usage_error(args: &[&str], expected: &str) {
#[test]
fn server_modes_reject_ignored_or_conflicting_options() {
for sub in ["mcp", "serve"] {
server_usage_error(&[sub, "stray.psd"], "does not take positional arguments");
server_usage_error(&[sub, "stray.psd"], "unexpected argument 'stray.psd'");
for flag in ["--control-token", "--control-token-file"] {
server_usage_error(&[sub, flag, "unused"], if sub == "mcp" { "requires --bridge" } else { "requires --port" });
}
for flag in ["--automation-read-root", "--automation-write-root"] {
server_usage_error(&[sub, flag, ".", flag, "."], "may only be specified once");
server_usage_error(&[sub, &format!("{flag}=")], "needs a non-empty value");
server_usage_error(&[sub, flag, "--automation-write-root", "."], &format!("{flag} needs a value"));
server_usage_error(&[sub, flag, ".", flag, "."], "cannot be used multiple times");
server_usage_error(&[sub, &format!("{flag}=")], &format!("'{flag} "));
server_usage_error(&[sub, flag, "--automation-write-root", "."], &format!("a value is required for '{flag} "));
}
let transport = if sub == "mcp" { "--bridge" } else { "--port" };
for flag in [transport, "--control-token", "--control-token-file"] {
server_usage_error(&[sub, flag, "one", &format!("{flag}=two")], "may only be specified once");
server_usage_error(&[sub, flag, ""], "needs a non-empty value");
// Valid values, so the repeat is what is refused (a transport value is parsed first).
server_usage_error(&[sub, flag, "7878", &format!("{flag}=7879")], "cannot be used multiple times");
server_usage_error(&[sub, flag, ""], &format!("'{flag} "));
}
server_usage_error(
&[sub, transport, "7878", "--control-token", "unused", "--control-token-file", "unused"],
"use either --control-token or --control-token-file",
);
server_usage_error(&[sub, transport, "7878", "--control-token", "unused", "--control-token-file", "unused"], "cannot be used with");
}
for flag in ["--automation-read-root", "--automation-write-root"] {
server_usage_error(&["mcp", "--bridge", "127.0.0.1:7878", flag, "."], "configure automation roots on the desktop app");
@@ -225,7 +240,7 @@ fn convert_errors() {
let o = bin().args(["convert", "/missing/in.png"]).arg(d.join("x.png")).output().unwrap();
assert_eq!(o.status.code(), Some(1));
let o = bin().args(["convert", "only-one"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert_eq!(o.status.code(), Some(2));
std::fs::remove_dir_all(d).unwrap();
}
@@ -234,14 +249,14 @@ fn info_prints_layer_tree_json() {
let d = tmp("info");
let a = d.join("a.png");
write_png(&a, 10, 6, 3);
let (out, _) = ok(bin().arg("info").arg(&a));
let out = ok(bin().arg("info").arg(&a)).stdout;
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["width"], 10);
assert_eq!(v["height"], 6);
// An RGBA PNG opens as a normal "Layer 0", not the locked Background (as in Photoshop).
assert_eq!(v["layers"][0]["name"], "Layer 0");
assert!(v.get("history").is_none());
let (out, _) = ok(bin().arg("info").arg(&a).arg("--compact"));
let out = ok(bin().arg("info").arg(&a).arg("--compact")).stdout;
assert_eq!(out.trim().lines().count(), 1);
std::fs::remove_dir_all(d).unwrap();
}
@@ -270,10 +285,10 @@ fn info_and_convert_report_decode_warnings() {
for (name, bytes, warning, export) in cases {
let file = d.join(name);
std::fs::write(&file, bytes).unwrap();
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
let out = ok(bin().arg("info").arg(&file).arg("--compact")).stdout;
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
let err = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png")))).stderr;
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
assert_eq!(
err.trim(),
@@ -291,18 +306,19 @@ fn info_and_convert_report_decode_warnings() {
fn run_commands_and_save() {
let d = tmp("run");
let out_pc = d.join("r.pcraft");
let (out, _) = ok(bin()
let out = ok(bin()
.args(["run", "--new", r#"{"width":40,"height":30,"name":"Run"}"#])
.args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#])
.args(["--cmd", "paint.stroke", "--params", r##"{"points":[[2,2,1],[30,20,1]],"size":5,"color":"#00ff00"}"##])
.args(["--cmd", "document.inspect"])
.arg("--out")
.arg(&out_pc));
.arg(&out_pc))
.stdout;
let lines: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[2]["command"], "document.inspect");
assert_eq!(lines[2]["result"]["name"], "Run");
let (info, _) = ok(bin().arg("info").arg(&out_pc));
let info = ok(bin().arg("info").arg(&out_pc)).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
// Saving a native document gives the reopened bundle its output filename.
assert_eq!(v["name"], "r.pcraft");
@@ -343,13 +359,14 @@ fn run_stops_after_failed_action_without_exporting() {
fn run_continues_after_successful_action_and_exports() {
let d = tmp("run-successful-action");
let out = d.join("result.png");
let (stdout, _) = ok(bin()
let stdout = ok(bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out));
.arg(&out))
.stdout;
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
assert_eq!(playback["result"]["ran"], 1);
@@ -364,10 +381,13 @@ fn run_errors() {
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown command"));
// Neither a --cmd nor --out, and JSON that doesn't parse, are usage errors naming the flag.
let o = bin().args(["run", "--new", "{}", "--params", "{}"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--cmd"));
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--new"));
// Params must be a JSON object, for `--new` and for each `--params`.
let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
@@ -407,10 +427,10 @@ fn batch_applies_actions_to_directory() {
std::fs::write(inp.join("notes.txt"), "ignored").unwrap();
let actions = d.join("actions.json");
std::fs::write(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap();
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"]));
let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"])).stdout;
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
for n in ["one", "two"] {
let (info, _) = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft"))));
let info = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft")))).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!(v["layers"][0]["name"], "Batch");
}
@@ -444,7 +464,7 @@ fn batch_skips_recorded_view_and_save_steps() {
assert!(o.status.success(), "{name}: {stdout}\n{stderr}");
assert!(stdout.contains("1 succeeded, 0 failed"), "{name}: {stdout}");
let output = d.join(name).join("a.png");
let (info, _) = ok(bin().arg("info").arg(&output));
let info = ok(bin().arg("info").arg(&output)).stdout;
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(12)), "{name}: {info}");
assert_eq!(std::fs::read(&source).unwrap(), original, "{name} changed its input");
@@ -530,8 +550,8 @@ fn batch_format_with_leading_dot() {
}
}
/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size,
/// and nothing is written.
/// #491: `--quality` outside 1-100 is a usage error for every subcommand, whatever the number's
/// size, and nothing is written.
#[test]
fn quality_outside_1_to_100_is_refused() {
let d = tmp("quality");
@@ -547,12 +567,12 @@ fn quality_outside_1_to_100_is_refused() {
run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]);
for cmd in [&mut convert, &mut run] {
let o = cmd.output().unwrap();
assert_eq!(o.status.code(), Some(1), "--quality {q}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --quality `{q}`: expected a whole number from 1 to 100")), "--quality {q}");
assert_eq!(o.status.code(), Some(2), "--quality {q}");
assert!(String::from_utf8_lossy(&o.stderr).contains("--quality"), "--quality {q}");
assert!(!out.exists(), "--quality {q} wrote a file");
}
let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]);
assert_eq!(o.status.code(), Some(1), "batch --quality {q}");
assert_eq!(o.status.code(), Some(2), "batch --quality {q}");
assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder");
}
// The documented range still encodes, lower quality making a smaller file.
@@ -568,7 +588,7 @@ fn quality_outside_1_to_100_is_refused() {
}
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is a usage error (#1049).
#[test]
fn dpx_converts_both_ways_with_the_bits_asked_for() {
let d = tmp("dpx");
@@ -585,7 +605,7 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
let bytes = std::fs::read(&out).unwrap();
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
}
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
let info = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact")).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
@@ -595,15 +615,16 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
for bad in ["24", "0", "abc"] {
let out = d.join("bad.dpx");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--dpx-bits {bad}");
assert!(err.contains("--dpx-bits") && err.contains("expected 8, 10, 12 or 16"), "--dpx-bits {bad}: {err}");
assert!(!out.exists(), "--dpx-bits {bad} wrote a file");
}
}
/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes
/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that
/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327).
/// isn't none, bc1, bc2 or bc3 is a usage error and nothing is written (#1327).
#[test]
fn dds_converts_both_ways_with_the_compression_asked_for() {
let d = tmp("dds");
@@ -628,14 +649,14 @@ fn dds_converts_both_ways_with_the_compression_asked_for() {
for bad in ["bc7", "dxt2", ""] {
let out = d.join("bad.dds");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}");
assert_eq!(o.status.code(), Some(2), "--dds-compression {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains("--dds-compression"), "--dds-compression {bad}");
assert!(!out.exists(), "--dds-compression {bad} wrote a file");
}
}
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is a
/// usage error and nothing is written.
#[test]
fn tga_bits_choose_the_targa_depth() {
let d = tmp("tga-bits");
@@ -654,8 +675,9 @@ fn tga_bits_choose_the_targa_depth() {
for bad in ["16", "0", "abc", ""] {
let out = d.join("bad.tga");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--tga-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--tga-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --tga-bits `{bad}`: expected 24 or 32")), "--tga-bits {bad}");
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--tga-bits {bad}");
assert!(err.contains("--tga-bits") && err.contains("expected 24 or 32"), "--tga-bits {bad}: {err}");
assert!(!out.exists(), "--tga-bits {bad} wrote a file");
}
}
@@ -695,13 +717,13 @@ fn run_refuses_to_write_over_its_input() {
// `--in-place` opts in: the inverted result replaces the original, which still opens.
ok(bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out", "in.png", "--in-place"]));
assert_ne!(std::fs::read(d.join("in.png")).unwrap(), before, "--in-place left the original unchanged");
let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
let out = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"])).stdout;
let info: Value = serde_json::from_str(&out).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
}
/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it
/// doesn't name is refused and nothing is written.
/// doesn't name is a usage error and nothing is written.
#[test]
fn jpeg_format_chooses_the_scans() {
let d = tmp("jpeg-format");
@@ -721,8 +743,9 @@ fn jpeg_format_chooses_the_scans() {
for bad in ["Progressive", "1", ""] {
let out = d.join("bad.jpg");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}");
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--jpeg-format {bad}");
assert!(err.contains("--jpeg-format") && err.contains("expected baseline, optimized or progressive"), "--jpeg-format {bad}: {err}");
assert!(!out.exists(), "--jpeg-format {bad} wrote a file");
}
}
@@ -762,7 +785,7 @@ fn batch_refuses_to_write_over_its_inputs() {
}
}
// A separate folder runs as before, leaving the originals alone.
let (out, _) = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"]));
let out = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"])).stdout;
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
assert_eq!(snapshot(), before);
// `--in-place` opts in: each result replaces its original.
@@ -804,16 +827,16 @@ fn convert_refuses_to_write_over_its_input() {
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before);
// `--in-place` opts in: the result replaces the original, which still opens.
ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"]));
let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
let Printed { stdout: out, .. } = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
let info: Value = serde_json::from_str(&out).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
}
#[test]
fn commands_listing() {
let (out, _) = ok(bin().arg("commands"));
let out = ok(bin().arg("commands")).stdout;
assert!(out.contains("file.new"));
let (out, _) = ok(bin().args(["commands", "--json", "--filter", "layer.new"]));
let out = ok(bin().args(["commands", "--json", "--filter", "layer.new"])).stdout;
let v: Value = serde_json::from_str(&out).unwrap();
assert!(
v.as_array()
@@ -827,7 +850,7 @@ fn commands_listing() {
fn in_process_run_matches_binary() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = photocraft_cli::run(&["commands".into(), "--filter".into(), "file.new".into()], &mut out, &mut err);
let code = photocraft_cli::run(["commands", "--filter", "file.new"], &mut out, &mut err);
assert_eq!(code, 0);
assert!(String::from_utf8(out).unwrap().contains("file.new"));
}
@@ -837,20 +860,8 @@ fn mcp_stdio_handshake_and_tool_call() {
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let mut send = |v: Value| {
writeln!(stdin, "{v}").unwrap();
stdin.flush().unwrap();
};
let mut recv = |id: u64| -> Value {
loop {
let mut line = String::new();
assert!(stdout.read_line(&mut line).unwrap() > 0, "server closed");
let v: Value = serde_json::from_str(&line).unwrap();
if v["id"] == id {
return v;
}
}
};
let mut send = |v: Value| mcp_send(&mut stdin, v);
let mut recv = |id: u64| mcp_recv(&mut stdout, id);
send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}));
let init = recv(1);
@@ -870,6 +881,61 @@ fn mcp_stdio_handshake_and_tool_call() {
let _ = child.wait();
}
/// Send one JSON-RPC line to an `mcp` child.
fn mcp_send(stdin: &mut impl Write, v: Value) {
writeln!(stdin, "{v}").unwrap();
stdin.flush().unwrap();
}
/// Read an `mcp` child's lines up to the reply to `id`.
fn mcp_recv(stdout: &mut impl BufRead, id: u64) -> Value {
loop {
let mut line = String::new();
assert!(stdout.read_line(&mut line).unwrap() > 0, "server closed");
let v: Value = serde_json::from_str(&line).unwrap();
if v["id"] == id {
return v;
}
}
}
/// `mcp --bridge` takes a port or anything with a `:`; anything else is a usage error. A bare port
/// means 127.0.0.1, and the bridge connects at the first call, so with no app there the call fails
/// naming that address.
#[test]
fn mcp_bridge_takes_a_port_or_an_address() {
let o = bin().args(["mcp", "--bridge", "nope"]).stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains("invalid value 'nope' for '--bridge"), "{err}");
assert!(err.contains("expected a port, 0-65535 (--bridge also takes 127.0.0.1:<PORT>)"), "{err}");
// Nothing listens on port 1 in a test run. The token only has to be well-formed.
let token = "a".repeat(64);
let mut child = bin()
.args(["mcp", "--bridge", "1", "--control-token", token.as_str()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
mcp_send(
&mut stdin,
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
);
mcp_recv(&mut stdout, 1);
mcp_send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
mcp_send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"session_list","arguments":{}}}));
let r = mcp_recv(&mut stdout, 2);
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(r["result"]["content"][0]["text"].as_str().unwrap_or_default().contains("127.0.0.1:1"), "{r}");
let _ = child.kill();
let _ = child.wait();
}
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
#[test]
fn batch_fails_inputs_that_share_an_output_name() {
@@ -898,7 +964,7 @@ fn batch_fails_inputs_that_share_an_output_name() {
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
// Keeping each file's format, every input gets its own output.
let out_dir = d.join("same");
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir));
let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir)).stdout;
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
}
@@ -914,7 +980,7 @@ fn droplet_runs_an_action_on_files() {
)
.unwrap();
let out_dir = d.join("out");
let (out, _) = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir));
let out = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir)).stdout;
assert_eq!(out.lines().filter(|l| l.starts_with("ok")).count(), 2, "{out}");
let img = photocraft_codecs::decode(&std::fs::read(out_dir.join("a.png")).unwrap()).unwrap();
assert_eq!(img.dimensions(), (4, 8));
@@ -926,7 +992,7 @@ fn droplet_runs_an_action_on_files() {
fn tiff_output_is_flat_unless_tiff_layers_is_given() {
let d = tmp("tiff-layers");
let layer_count = |path: &std::path::Path| -> usize {
let (info, _) = ok(bin().arg("info").arg(path));
let info = ok(bin().arg("info").arg(path)).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
v["layers"].as_array().unwrap().len()
};
@@ -952,19 +1018,38 @@ fn tiff_output_is_flat_unless_tiff_layers_is_given() {
}
/// Issue #1108: a non-Unicode argument (a Latin-1 file name on Linux) used to panic in
/// `std::env::args()` before anything was printed. It is a usage error naming the argument.
/// `std::env::args()` before anything was printed. clap reads the arguments as they are: a path
/// takes one, and text (`--filter`) refuses it with a usage error.
#[cfg(unix)]
#[test]
fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
fn a_non_unicode_path_is_taken_and_non_unicode_text_is_a_usage_error() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png");
for args in [vec![std::ffi::OsStr::new("info"), name], vec![std::ffi::OsStr::new("convert"), name, std::ffi::OsStr::new("out.png")]] {
let o = bin().args(&args).output().unwrap();
let d = tmp("non-unicode");
let name = OsStr::from_bytes(b"caf\xe9.png");
// No such file yet: the handler runs and fails on it (exit 1), no usage error.
for args in [vec![OsStr::new("info"), name], vec![OsStr::new("convert"), name, OsStr::new("out.png")]] {
let o = bin().current_dir(&d).args(&args).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains("argument 2 is not valid Unicode") && err.contains("caf\u{fffd}.png"), "{err}");
assert_eq!(o.status.code(), Some(1), "{args:?}: {err}");
assert!(!err.contains("panicked"), "{err}");
}
// Linux file systems take the name (macOS ones refuse it). `convert` reads it; `info` opens
// through the automation layer, which still needs UTF-8 paths, and says so (exit 1).
if cfg!(target_os = "linux") {
write_png(&d.join(name), 8, 4, 3);
ok(bin().current_dir(&d).arg("convert").arg(name).arg("out.png"));
assert!(d.join("out.png").is_file());
let o = bin().current_dir(&d).arg("info").arg(name).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(1), "{err}");
assert!(err.contains("UTF-8"), "{err}");
}
let o = bin().args([OsStr::new("commands"), OsStr::new("--filter"), name]).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!((o.status.code(), o.stdout.is_empty()), (Some(2), true), "{err}");
assert!(err.contains("UTF-8") && !err.contains("panicked"), "{err}");
std::fs::remove_dir_all(d).unwrap();
}
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
@@ -987,7 +1072,7 @@ fn batch_plays_a_photoshop_actions_file() {
let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap();
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr));
let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir));
let err = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir)).stderr;
assert!(out_dir.join("a.png").exists());
assert!(!err.contains("warning"), "{err}");
// The unsupported step is warned about, and fails the file as Photoshop would stop on it.
@@ -997,31 +1082,40 @@ fn batch_plays_a_photoshop_actions_file() {
assert!(!o.status.success());
}
/// `photocraft_cli::run` in-process: `(exit code, stdout, stderr)`.
fn invoke(args: &[&str]) -> (i32, String, String) {
let (mut out, mut err) = (Vec::new(), Vec::new());
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = photocraft_cli::run(&args, &mut out, &mut err);
(code, String::from_utf8(out).unwrap(), String::from_utf8(err).unwrap())
/// What `photocraft_cli::run` returned and wrote, in-process.
struct Invoke {
code: i32,
stdout: String,
stderr: String,
}
fn invoke(args: &[&str]) -> Invoke {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let code = photocraft_cli::run(args, &mut stdout, &mut stderr);
Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() }
}
const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
/// Pins top-level dispatch: help goes to stdout (exit 0), no arguments is a usage error on stderr.
/// Pins top-level dispatch: help and the version go to stdout (exit 0), no arguments is a usage
/// error on stderr. There is no `version` subcommand.
#[test]
fn top_level_help_version_and_no_arguments() {
let (code, out, err) = invoke(&[]);
let Invoke { code, stdout: out, stderr: err } = invoke(&[]);
assert_eq!((code, out.is_empty(), err.is_empty()), (2, true, false));
for help in ["--help", "-h", "help"] {
let (code, out, err) = invoke(&[help]);
let Invoke { code, stdout: out, stderr: err } = invoke(&[help]);
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}");
}
for version in ["--version", "version"] {
let (code, out, err) = invoke(&[version]);
for version in ["--version", "-V"] {
let Invoke { code, stdout: out, stderr: err } = invoke(&[version]);
assert_eq!((code, err.as_str()), (0, ""), "{version}");
assert!(out.starts_with("photocraft-cli "), "{version}: {out}");
assert!(out.starts_with("photocraft-cli ") && out.ends_with('\n') && out.lines().count() == 1, "{version}: {out}");
}
let (code, out, err) = invoke(&["frobnicate"]);
let Invoke { code, stdout: out, stderr: err } = invoke(&["version"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("version"), "{err}");
let Invoke { code, stdout: out, stderr: err } = invoke(&["frobnicate"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("frobnicate"), "{err}");
}
@@ -1032,7 +1126,7 @@ fn top_level_help_version_and_no_arguments() {
fn every_subcommand_help_returns_on_stdout() {
for sub in SUBCOMMANDS {
for help in ["--help", "-h"] {
let (code, out, err) = invoke(&[sub, help]);
let Invoke { code, stdout: out, stderr: err } = invoke(&[sub, help]);
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{sub} {help}: {err}");
// `commands` would also exit 0 with output if it ran; its list names `file.new`, help doesn't.
assert!(!out.contains("file.new"), "{sub} {help} ran instead of printing help: {out}");
@@ -1046,34 +1140,47 @@ fn every_subcommand_rejects_an_unknown_flag() {
for sub in SUBCOMMANDS {
for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] {
let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect();
let (code, out, err) = invoke(&args);
let Invoke { code, stdout: out, stderr: err } = invoke(&args);
assert_eq!((code, out.is_empty()), (2, true), "{args:?}: {err}");
assert!(err.contains("--fromat"), "{args:?}: {err}");
}
}
}
/// `--flag=value`, a bare flag given a value, a value flag without one, and repeated flags.
/// `--flag=value`, a bare flag given a value, a value flag without one, repeated flags, and values
/// that start with `-`.
#[test]
fn flag_value_forms_and_repeats() {
let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::<Vec<_>>();
let (code, eq, err) = invoke(&["commands", "--filter=file.new"]);
let Invoke { code, stdout: eq, stderr: err } = invoke(&["commands", "--filter=file.new"]);
assert_eq!(code, 0, "{err}");
let (_, spaced, _) = invoke(&["commands", "--filter", "file.new"]);
let spaced = invoke(&["commands", "--filter", "file.new"]).stdout;
assert_eq!(eq, spaced);
assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}");
// The last of a repeated value flag wins, whichever order they come in.
let (code, out, err) = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]);
// A repeated flag is a usage error naming it, more likely a mistake than a choice.
for repeat in [&["commands", "--filter", "zzz-no-match", "--filter", "file.new"][..], &["commands", "--json", "--json"][..]] {
let Invoke { code, stdout: out, stderr: err } = invoke(repeat);
assert_eq!((code, out.is_empty()), (2, true), "{repeat:?}");
assert!(err.contains(repeat[1]) && err.contains("cannot be used multiple times"), "{repeat:?}: {err}");
}
let Invoke { code, stderr: err, .. } = invoke(&["convert", "a.png", "b.jpg", "--quality", "80", "--quality", "60"]);
assert_eq!(code, 2);
assert!(err.contains("--quality"), "{err}");
// A value starting with `-` needs `=`; without it the token is a flag, never the value. A
// negative number is still a value.
let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter=-zzz-no-match"]);
assert_eq!((code, out.as_str()), (0, ""), "{err}");
for value in ["-zzz-no-match", "--json"] {
let Invoke { code, stdout: out, .. } = invoke(&["commands", "--filter", value]);
assert_eq!((code, out.is_empty()), (2, true), "--filter {value}");
}
let Invoke { code, stderr: err, .. } = invoke(&["commands", "--filter", "-1"]);
assert_eq!(code, 0, "{err}");
assert_eq!(out, eq);
let (code, out, err) = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]);
assert_eq!(code, 0, "{err}");
assert!(out.is_empty(), "{out}");
// A bare flag takes no value; a value flag needs one.
let (code, out, err) = invoke(&["commands", "--json=1"]);
let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--json=1"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("--json"), "{err}");
let (code, out, err) = invoke(&["commands", "--filter"]);
let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("--filter"), "{err}");
}
@@ -1082,7 +1189,7 @@ fn flag_value_forms_and_repeats() {
/// `--params` ahead of every `--cmd` fails naming `--params`.
#[test]
fn run_pairs_params_with_the_preceding_cmd() {
let (code, out, err) = invoke(&[
let Invoke { code, stdout: out, stderr: err } = invoke(&[
"run",
"--new",
r#"{"width":8,"height":8}"#,
@@ -1110,7 +1217,7 @@ fn run_pairs_params_with_the_preceding_cmd() {
// Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params
// with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead.
assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}");
let (code, _, err) = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]);
let Invoke { code, stderr: err, .. } = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]);
assert_ne!(code, 0);
assert!(err.contains("--params"), "{err}");
}
@@ -1129,3 +1236,255 @@ fn convert_opens_a_raw_at_the_raw_defaults_resolution() {
assert_eq!(doc.resolution_dpi, 300.0);
std::fs::remove_dir_all(d).unwrap();
}
/// `--out=-image.png` writes `-image.png`; `--out -image.png` is a usage error and writes nothing.
#[test]
fn a_value_starting_with_a_dash_is_written_with_equals() {
let d = tmp("dash-value");
let o = bin().current_dir(&d).args(["run", "--new", r#"{"width":4,"height":4}"#, "--out", "-image.png"]).output().unwrap();
assert_eq!(o.status.code(), Some(2), "{}", String::from_utf8_lossy(&o.stderr));
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0);
ok(bin().current_dir(&d).args(["run", "--new", r#"{"width":4,"height":4}"#, "--out=-image.png"]));
assert!(d.join("-image.png").is_file());
std::fs::remove_dir_all(d).unwrap();
}
/// `run` takes exactly one of `<FILE>` or `--new`, and `mcp`/`serve` one of `--control-token` or
/// `--control-token-file`: anything else is a usage error naming the flags, before any work.
#[test]
fn either_or_flags_are_usage_errors() {
let Invoke { code, stderr: err, .. } = invoke(&["run", "a.png", "--new", "{}", "--cmd", "layer.new.layer"]);
assert_eq!(code, 2);
assert!(err.contains("--new") && err.contains("cannot be used with"), "{err}");
let Invoke { code, stderr: err, .. } = invoke(&["run", "--cmd", "layer.new.layer"]);
assert_eq!(code, 2);
assert!(err.contains("--new"), "{err}");
let Invoke { code, stderr: err, .. } = invoke(&["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "{"]);
assert_eq!(code, 2);
assert!(err.contains("--params"), "{err}");
let token = "a".repeat(64);
for mode in [["mcp", "--bridge", "1"], ["serve", "--port", "0"]] {
let o = bin().args(mode).args(["--control-token", token.as_str(), "--control-token-file", "token"]).stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{mode:?}: {err}");
assert!(err.contains("--control-token-file") && err.contains("cannot be used with"), "{mode:?}: {err}");
}
for port in ["70000", "x"] {
let o = bin().args(["serve", "--port", port]).stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--port {port}: {err}");
assert!(err.contains("--port"), "--port {port}: {err}");
}
}
/// How a run of the binary ended.
struct Exited {
code: Option<i32>,
stderr: String,
}
/// The binary run with `args` and one variable set, stdin at its end.
fn with_var(args: &[&str], var: &str, value: &str) -> Exited {
let o = bin().args(args).env(var, value).stdin(Stdio::null()).output().unwrap();
Exited { code: o.status.code(), stderr: String::from_utf8_lossy(&o.stderr).into_owned() }
}
/// A port nothing listens on, for a `serve` to take.
fn free_port() -> u16 {
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port()
}
/// `serve` on TCP never exits: read its stderr until it says it is serving (or its stderr closes),
/// then kill it.
fn serve_tcp_stderr(cmd: &mut Command) -> String {
use std::io::Read as _;
let mut child = cmd.stdin(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
// Read on a thread, so a `serve` that stays silent with stderr open fails the test after the
// deadline instead of hanging it.
let mut stderr = child.stderr.take().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
while let Ok(n) = stderr.read(&mut buf) {
if n == 0 || tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut err = String::new();
while !err.contains("photocraft-cli serving on") {
match rx.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) {
Ok(chunk) => err.push_str(&String::from_utf8_lossy(&chunk)),
Err(_) => break,
}
}
let _ = child.kill();
let _ = child.wait();
err
}
/// #2491: `PHOTOCRAFT_CONTROL_PORT` turns `serve` from stdio into a TCP listener and `mcp` from a
/// headless engine into a bridge, and both say so on stderr. A flag wins, an empty value counts as
/// unset, and a bad value is a usage error naming the variable.
#[test]
fn control_port_env_switches_modes_with_a_notice_and_flags_win() {
let port = free_port();
let err = serve_tcp_stderr(bin().arg("serve").env("PHOTOCRAFT_CONTROL_PORT", port.to_string()));
assert!(err.contains(&format!("PHOTOCRAFT_CONTROL_PORT={port} is set; serving on TCP instead of stdio")), "{err}");
assert!(err.contains("unset it to serve stdio") && err.contains("photocraft-cli serving on"), "{err}");
// No token is given, so the bridge fails after the notice.
let err = with_var(&["mcp"], "PHOTOCRAFT_CONTROL_PORT", &port.to_string()).stderr;
assert!(err.contains(&format!("PHOTOCRAFT_CONTROL_PORT is set; bridging to the app on 127.0.0.1:{port}")), "{err}");
assert!(err.contains("unset it to run headless"), "{err}");
// The flag wins, so the variable's bad value is never parsed.
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(port.to_string()).env("PHOTOCRAFT_CONTROL_PORT", "not-a-port"));
assert!(err.contains("photocraft-cli serving on") && !err.contains("PHOTOCRAFT_CONTROL_PORT"), "{err}");
// Empty or blank counts as unset: `serve` reads stdio (at its end, so it exits at once) and
// `mcp` stays headless.
// A root on the command line is the headless engine's: the variable can't switch to a bridge
// that would drop it.
for root in ["--automation-read-root", "--automation-write-root"] {
let Exited { code, stderr: err } = with_var(&["mcp", root, "."], "PHOTOCRAFT_CONTROL_PORT", &port.to_string());
assert_eq!(code, Some(2), "{root}: {err}");
assert!(err.contains(root) && err.contains("cannot be used with 'PHOTOCRAFT_CONTROL_PORT'") && !err.contains("bridging"), "{root}: {err}");
}
for empty in ["", " "] {
let Exited { code, stderr: err } = with_var(&["serve"], "PHOTOCRAFT_CONTROL_PORT", empty);
assert!(code == Some(0) && !err.contains("PHOTOCRAFT_CONTROL_PORT"), "{empty:?}: {err}");
let err = with_var(&["mcp"], "PHOTOCRAFT_CONTROL_PORT", empty).stderr;
assert!(!err.contains("bridging"), "{empty:?}: {err}");
}
for (sub, bad) in [
("serve", "not-a-port"),
("serve", "70000"),
("mcp", "nope"),
("mcp", "70000"),
// `--bridge` takes host:port, the variable only a port.
("mcp", "127.0.0.1:7878"),
("mcp", ":7878"),
("mcp", "bad:port"),
] {
let Exited { code, stderr: err } = with_var(&[sub], "PHOTOCRAFT_CONTROL_PORT", bad);
assert_eq!(code, Some(2), "{sub} {bad}: {err}");
assert!(err.contains(&format!("invalid value '{bad}' for 'PHOTOCRAFT_CONTROL_PORT'")), "{sub} {bad}: {err}");
}
}
/// `--control-token` and `--control-token-file` fall back to `PHOTOCRAFT_CONTROL_TOKEN` and
/// `PHOTOCRAFT_CONTROL_TOKEN_FILE`. One from the environment and the other as a flag is the
/// either/or usage error, naming the variable, when a token is used: a stale exported pair doesn't
/// stop headless `mcp` or stdio `serve`. Help names the token's variable, never its value.
#[test]
fn control_token_env_vars_are_the_flags_fallbacks() {
let token = "a".repeat(64);
for mode in [["mcp", "--bridge", "1"], ["serve", "--port", "0"]] {
let Exited { code, stderr: err } = with_var(&[&mode[..], &["--control-token", token.as_str()][..]].concat(), "PHOTOCRAFT_CONTROL_TOKEN_FILE", "token");
assert_eq!(code, Some(2), "{mode:?}: {err}");
assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN_FILE") && err.contains("cannot be used with"), "{mode:?}: {err}");
let Exited { code, stderr: err } = with_var(&[&mode[..], &["--control-token-file", "token"][..]].concat(), "PHOTOCRAFT_CONTROL_TOKEN", &token);
assert_eq!(code, Some(2), "{mode:?}: {err}");
assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN") && err.contains("cannot be used with") && !err.contains(&token), "{mode:?}: {err}");
}
for sub in ["mcp", "serve"] {
let o = bin().arg(sub).env("PHOTOCRAFT_CONTROL_TOKEN", &token).env("PHOTOCRAFT_CONTROL_TOKEN_FILE", "token").stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert!(!err.contains("cannot be used with"), "{sub}: {err}");
// stdio `serve` exits 0 at the end of its input; how headless `mcp` ends there isn't
// pinned, so it only must not be the usage error.
if sub == "serve" {
assert_eq!(o.status.code(), Some(0), "{sub}: {err}");
} else {
assert_ne!(o.status.code(), Some(2), "{sub}: {err}");
}
let o = bin().args([sub, "--help"]).env("PHOTOCRAFT_CONTROL_TOKEN", &token).output().unwrap();
let out = String::from_utf8_lossy(&o.stdout);
assert!(out.contains("[env: PHOTOCRAFT_CONTROL_TOKEN]") && !out.contains(&token), "{sub}: {out}");
}
// The server takes the variable's token, so it neither generates nor prints one; an empty
// variable counts as unset.
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", &token));
assert!(err.contains("photocraft-cli serving on") && !err.contains("control token"), "{err}");
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", ""));
assert!(err.contains("photocraft-cli serving on") && err.contains("photocraft-cli control token: "), "{err}");
}
/// #8802: Deselect with nothing selected is skipped in a `run`, as action playback does (#6744),
/// so a script that deselects after Reveal Selection still finishes and saves.
#[test]
fn run_skips_deselect_with_nothing_selected() {
let folder = tmp("deselect-run");
let native = folder.join("out.pcraft").to_string_lossy().to_string();
let Invoke { code, stdout, stderr } = invoke(&[
"run",
"--new",
r#"{"width":16,"height":16}"#,
"--cmd",
"select.rect",
"--params",
r#"{"x":0,"y":0,"width":8,"height":16}"#,
"--cmd",
"layer.new.layer",
"--cmd",
"layer.layerMask.revealSelection",
"--cmd",
"select.deselect",
"--cmd",
"layer.new.layer",
"--params",
r#"{"name":"after"}"#,
"--out",
&native,
]);
assert_eq!(code, 0, "{stderr}");
let results: Vec<Value> = stdout.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(results.len(), 5, "every command reports a result: {stdout}");
assert_eq!(results[3], json!({"command": "select.deselect", "result": null}));
let Invoke { code, stdout, stderr } = invoke(&["info", &native, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(info["layers"][0]["name"], "after", "the steps after Deselect ran: {info}");
// Deselect with a selection still deselects: Inverse afterwards finds nothing to invert.
let Invoke { code, stderr, .. } = invoke(&[
"run",
"--new",
r#"{"width":16,"height":16}"#,
"--cmd",
"select.all",
"--cmd",
"select.deselect",
"--cmd",
"select.inverse",
"--out",
&native,
]);
assert_eq!(code, 1, "{stderr}");
assert!(stderr.contains("select.inverse") && stderr.contains("no selection"), "{stderr}");
let _ = std::fs::remove_dir_all(&folder);
}
/// #8802: a batched action that starts with Deselect plays on files with nothing selected, as the
/// same action does in the Actions panel (#6744).
#[test]
fn batch_skips_deselect_with_nothing_selected() {
let dir = tmp("deselect-batch");
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
assert_eq!(code, 0, "{stderr}");
std::fs::write(dir.join("steps.json"), json!([["select.deselect", {}], ["image.imageRotation.90cw", {}]]).to_string()).unwrap();
let Invoke { code, stdout, stderr } = invoke(&["batch", "--actions", &path("steps.json"), "--in", &path("in"), "--out", &path("out")]);
assert_eq!(code, 0, "{stdout}{stderr}");
assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}");
let Invoke { code, stdout, stderr } = invoke(&["info", &path("out/a.png"), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "the step after Deselect ran: {info}");
let _ = std::fs::remove_dir_all(&dir);
}
+1
View File
@@ -1,4 +1,5 @@
//! The crate's integration tests, linked into one test binary.
mod cli;
mod man_and_completions;
mod orientation;
@@ -0,0 +1,55 @@
//! The clap definition is consistent, and every shell's completions and every man page
//! (`examples/man-and-completions.rs`) can be generated from it.
use clap::ValueEnum;
use clap_complete::Shell;
#[test]
fn command_is_consistent() {
photocraft_cli::command().debug_assert();
}
#[test]
fn completions_generate_for_every_shell() {
for &shell in Shell::value_variants() {
let mut out = Vec::new();
clap_complete::generate(shell, &mut photocraft_cli::command(), "photocraft-cli", &mut out);
assert!(!out.is_empty(), "{shell}");
}
}
#[test]
fn man_pages_render_for_the_command_and_every_subcommand() {
let cmd = photocraft_cli::command();
for page in std::iter::once(&cmd).chain(cmd.get_subcommands()) {
let mut out = Vec::new();
clap_mangen::Man::new(page.clone()).render(&mut out).unwrap();
assert!(!out.is_empty(), "{}", page.get_name());
}
}
/// The example's two `generate_to` calls, repeated here, write the five completion files under the
/// names `packaging/linux/package.sh` and `packaging/freebsd/package.sh` install, `photocraft-cli.1`
/// and a page per subcommand, and no pages for the `help` subcommand.
#[test]
fn the_example_writes_the_files_the_packages_install() {
let dir = std::env::temp_dir().join(format!("pc-cli-{}-man-and-completions", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut cmd = photocraft_cli::command();
for &shell in Shell::value_variants() {
clap_complete::generate_to(shell, &mut cmd, "photocraft-cli", &dir).unwrap();
}
clap_mangen::generate_to(photocraft_cli::command(), &dir).unwrap();
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
written.sort();
let completions = ["photocraft-cli.bash", "_photocraft-cli", "photocraft-cli.fish", "photocraft-cli.elv", "_photocraft-cli.ps1"];
let subcommands = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
let mut expected: Vec<String> = completions.map(String::from).into();
expected.push("photocraft-cli.1".into());
expected.extend(subcommands.iter().map(|s| format!("photocraft-cli-{s}.1")));
expected.sort();
assert_eq!(written, expected);
std::fs::remove_dir_all(&dir).unwrap();
}
+2
View File
@@ -34,6 +34,8 @@ egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
+340
View File
@@ -0,0 +1,340 @@
//! The desktop app's command line, as clap types (#7071): `photocraft --help` comes from these doc
//! comments, so the help and the parser can't disagree. Anything they don't declare is a usage
//! error naming it (exit status 2), so a typo such as `--safegpu` doesn't open the window and then
//! fail to open the flag as a file (#6431); so is a flag without its value, rather than a silent
//! fall back to a generated token or to no root (#6429), a repeated flag, and a bad
//! `--present-mode` or `--frame-latency`. A value never starts with `-` (write `--opt=-value`),
//! except for negative numbers, and a file whose name starts with `-` goes after `--`.
//!
//! The control and automation flags are `photocraft-automation`'s, shared with `photocraft-cli`.
//! Only the desktop app reads the automation roots from `PHOTOCRAFT_AUTOMATION_*_ROOT` (#1551).
use std::ffi::OsString;
use std::fmt;
use std::path::PathBuf;
use clap::builder::FalseyValueParser;
use clap::parser::ValueSource;
use clap::{Command, CommandFactory, FromArgMatches, Parser};
use eframe::wgpu::PresentMode;
use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars};
use crate::presentation;
/// Opens PhotoCraft with the given files
#[derive(Parser)]
#[command(name = "photocraft", version = photocraft_engine::build_info::long_version_static(), after_help = ENVIRONMENT)]
pub(crate) struct LaunchArgs {
/// Files to open. On Windows and Linux they open as new tabs in the PhotoCraft already running.
/// Put `--` before a name that starts with `-`
pub(crate) files: Vec<PathBuf>,
/// Start with the CPU renderer for this launch (after a graphics driver crash)
#[arg(long)]
pub(crate) safe_gpu: bool,
/// macOS: keep the menus inside the window instead of the menu bar (the variable: 0, false, no
/// or off is off; empty is unset)
#[arg(long, env = "PHOTOCRAFT_IN_WINDOW_MENUS", value_parser = FalseyValueParser::new())]
pub(crate) in_window_menus: bool,
/// Windows and Linux: start a separate PhotoCraft instead of opening the files in the one
/// already running
#[arg(long)]
pub(crate) new_instance: bool,
/// How frames reach the display: fifo, autovsync or novsync (also immediate or mailbox).
/// Without it, PHOTOCRAFT_PRESENT_MODE
#[arg(long, value_name = "MODE", value_parser = present_mode)]
pub(crate) present_mode: Option<PresentMode>,
/// Frames queued for the display, 1 to 3. Without it, PHOTOCRAFT_FRAME_LATENCY
// allow_negative_numbers makes `--frame-latency -1` a bad value rather than an unexpected argument.
#[arg(long, value_name = "1-3", value_parser = frame_latency, allow_negative_numbers = true)]
pub(crate) frame_latency: Option<u32>,
/// Time each brush stroke's frames: stderr prints a summary per stroke, a CSV file path also
/// records every frame. Without it, PHOTOCRAFT_STROKE_TRACE
#[arg(long, value_name = "stderr|FILE.csv")]
pub(crate) stroke_trace: Option<String>,
/// Start the localhost JSON-lines control server on this port
#[arg(long, value_name = "PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = control_port, allow_negative_numbers = true)]
pub(crate) control: Option<u16>,
#[command(flatten)]
pub(crate) auth: ControlArgs,
#[command(flatten)]
pub(crate) automation: AutomationArgs,
/// The per-process options given on the command line, set by [`parse`].
#[arg(skip)]
pub(crate) launch_options: Vec<PerProcess>,
}
/// The variables that aren't a flag's fallback, for the end of `--help`.
const ENVIRONMENT: &str = "\
Environment: PHOTOCRAFT_NEW_INSTANCE (anything but 0 or empty: as --new-instance), PHOTOCRAFT_CONFIG_DIR (the settings folder)";
/// An option that applies only to the process it's given to: a launch with any of them keeps its
/// own process instead of handing its files to the running one (`single_instance`). Shown as the
/// flag, or the variable.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PerProcess {
AutomationReadRoot,
AutomationWriteRoot,
SafeGpu,
InWindowMenus,
PresentMode,
FrameLatency,
StrokeTrace,
/// PHOTOCRAFT_GPU_ADAPTER, when startup will act on it (`gpu_startup`). Not a flag.
GpuAdapterEnv,
}
impl PerProcess {
/// The flags, in the order a log line lists them. A flag's variable asks for the same thing, so
/// it counts too (#7242); the variables that aren't a flag's fallback are counted by
/// `single_instance::env_launch_options`.
pub(crate) const FLAGS: [PerProcess; 7] =
[Self::AutomationReadRoot, Self::AutomationWriteRoot, Self::SafeGpu, Self::InWindowMenus, Self::PresentMode, Self::FrameLatency, Self::StrokeTrace];
/// The flag's clap argument id.
fn id(self) -> Option<&'static str> {
Some(match self {
Self::AutomationReadRoot => "automation_read_root",
Self::AutomationWriteRoot => "automation_write_root",
Self::SafeGpu => "safe_gpu",
Self::InWindowMenus => "in_window_menus",
Self::PresentMode => "present_mode",
Self::FrameLatency => "frame_latency",
Self::StrokeTrace => "stroke_trace",
Self::GpuAdapterEnv => return None,
})
}
}
impl fmt::Display for PerProcess {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::AutomationReadRoot => "--automation-read-root",
Self::AutomationWriteRoot => "--automation-write-root",
Self::SafeGpu => "--safe-gpu",
Self::InWindowMenus => "--in-window-menus",
Self::PresentMode => "--present-mode",
Self::FrameLatency => "--frame-latency",
Self::StrokeTrace => "--stroke-trace",
Self::GpuAdapterEnv => "PHOTOCRAFT_GPU_ADAPTER",
})
}
}
/// The files to open, as [`LaunchArgs::file_names`] sorts them.
#[derive(Debug, PartialEq)]
pub(crate) struct FileNames {
pub(crate) openable: Vec<String>,
/// Names that are not valid Unicode, shown lossily: reported, not opened.
pub(crate) unreadable: Vec<String>,
}
impl LaunchArgs {
/// The files to open, and those whose names are not valid Unicode (#1108): a file manager can
/// hand over a Latin-1 name on Linux. Those are reported in a notice once the app is up.
pub(crate) fn file_names(&self) -> FileNames {
let mut names = FileNames { openable: Vec::new(), unreadable: Vec::new() };
for f in &self.files {
match f.to_str() {
Some(s) => names.openable.push(s.to_owned()),
None => names.unreadable.push(f.to_string_lossy().into_owned()),
}
}
names
}
}
/// The command line, with the desktop app's variables for the automation roots. An empty or blank
/// variable counts as unset.
pub(crate) fn command() -> Command {
LaunchArgs::command()
.mut_arg("automation_read_root", |a| a.env("PHOTOCRAFT_AUTOMATION_READ_ROOT"))
.mut_arg("automation_write_root", |a| a.env("PHOTOCRAFT_AUTOMATION_WRITE_ROOT"))
.mut_args(blank_env_is_unset)
}
/// Parse the launch arguments (without the binary name) with `cmd`, from [`command`]. Old macOS
/// passes a process serial number (`-psn_…`) when launched from Finder: it is dropped first, unless
/// it comes after `--`. A bad value from a variable is a usage error naming the variable.
pub(crate) fn parse(mut cmd: Command, args: impl IntoIterator<Item = OsString>) -> Result<LaunchArgs, clap::Error> {
let mut options = true;
let args: Vec<OsString> = args
.into_iter()
.filter(|a| {
options &= a != "--";
!(options && a.to_str().is_some_and(|s| s.starts_with("-psn_")))
})
.collect();
let argv = std::iter::once(OsString::from("photocraft")).chain(args.iter().cloned());
let matches = cmd.try_get_matches_from_mut(argv).map_err(|e| name_env_vars(e, &cmd, &args))?;
let mut launch = LaunchArgs::from_arg_matches(&matches)?;
// Only the control server reads a token, so a stale exported pair doesn't stop a launch.
if launch.control.is_some() {
conflict(&mut cmd, &matches, "control_token", "control_token_file")?;
}
// `PHOTOCRAFT_IN_WINDOW_MENUS=0` (or off, false, no) is a variable that turns the menus off.
let menus = launch.in_window_menus;
let given = |p: &PerProcess| match p.id().and_then(|id| matches.value_source(id)) {
Some(ValueSource::CommandLine) => true,
Some(ValueSource::EnvVariable) => *p != PerProcess::InWindowMenus || menus,
_ => false,
};
launch.launch_options = PerProcess::FLAGS.into_iter().filter(given).collect();
Ok(launch)
}
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
enum ValueError {
#[error("expected fifo, autovsync or novsync (or immediate, mailbox)")]
PresentMode,
#[error("expected 1, 2 or 3")]
FrameLatency,
}
fn present_mode(s: &str) -> Result<PresentMode, ValueError> {
presentation::present_mode(s).ok_or(ValueError::PresentMode)
}
fn frame_latency(s: &str) -> Result<u32, ValueError> {
presentation::frame_latency(s).ok_or(ValueError::FrameLatency)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::error::ErrorKind;
/// Parse without the variables, so one exported where the tests run can't change them (the
/// process tests in `tests/command_line_usage.rs` cover the variables).
fn parse_args(args: &[&str]) -> Result<LaunchArgs, clap::Error> {
parse(command().mut_args(|a| a.env(None::<&str>)), args.iter().map(OsString::from))
}
fn usage_error(args: &[&str]) -> String {
let Err(e) = parse_args(args) else { panic!("{args:?} parsed") };
assert_eq!(e.exit_code(), 2, "{args:?}: {e}");
e.to_string()
}
#[test]
fn control_port_rejects_unparseable_values_naming_them() {
// Out of range, negative and empty values fail, naming the value (#701); a following
// flag is never taken as the value.
assert!(usage_error(&["--control", "78787"]).contains("invalid value '78787' for '--control <PORT>'"));
assert!(usage_error(&["--control", "-1"]).contains("invalid value '-1'"));
assert!(usage_error(&["--control="]).contains("invalid value '' for '--control <PORT>'"));
assert!(usage_error(&["--control", "--safe-gpu"]).contains("a value is required for '--control <PORT>'"));
}
#[test]
fn flags_without_a_value_are_usage_errors() {
// #6429: a missing value fails the launch, rather than falling back to a generated token
// or to no root. The tuning flags too, now (#7071).
for flag in [
"--control",
"--control-token",
"--control-token-file",
"--automation-read-root",
"--automation-write-root",
"--present-mode",
"--frame-latency",
"--stroke-trace",
] {
let err = usage_error(&[flag]);
assert!(err.contains(&format!("a value is required for '{flag} <")), "{flag}: {err}");
}
}
#[test]
fn bad_tuning_values_and_repeats_are_usage_errors() {
assert!(usage_error(&["--present-mode", "fast"]).contains("expected fifo, autovsync or novsync"));
for latency in ["0", "4", "-1", "one"] {
let err = usage_error(&["--frame-latency", latency]);
assert!(err.contains(&format!("invalid value '{latency}' for '--frame-latency <1-3>'")), "{err}");
}
assert!(usage_error(&["--safe-gpu", "--safe-gpu"]).contains("cannot be used multiple times"));
assert!(usage_error(&["--control", "1", "--control", "2"]).contains("cannot be used multiple times"));
// A token and a token file are either/or for the control server, and ignored without it.
let pair = ["--control-token", "abcd", "--control-token-file", "/tmp/token"];
assert!(usage_error(&[&["--control", "1"][..], &pair[..]].concat()).contains("cannot be used with"));
assert!(parse_args(&pair).is_ok());
}
#[test]
fn values_are_parsed_and_per_process_options_recorded() {
let parsed = parse_args(&[
"--stroke-trace",
"stderr",
"--control",
"50494",
"--control-token",
"abcd",
"--automation-read-root",
"/r",
"--automation-write-root",
"/w",
"--safe-gpu",
"--in-window-menus",
"--new-instance",
"--present-mode",
"Mailbox",
"--frame-latency",
"1",
"photo.psd",
])
.unwrap();
assert_eq!(parsed.control, Some(50494));
assert_eq!(parsed.auth.control_token.as_deref(), Some("abcd"));
assert_eq!(parsed.auth.control_token_file, None);
assert_eq!(parsed.automation.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
assert_eq!(parsed.automation.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
assert!(parsed.safe_gpu && parsed.in_window_menus && parsed.new_instance);
assert_eq!(parsed.present_mode, Some(PresentMode::AutoNoVsync));
assert_eq!(parsed.frame_latency, Some(1));
assert_eq!(parsed.stroke_trace.as_deref(), Some("stderr"));
// In a fixed order: `single_instance` only lists them in a log line.
assert_eq!(parsed.launch_options, PerProcess::FLAGS);
assert_eq!(parsed.file_names(), FileNames { openable: vec!["photo.psd".to_string()], unreadable: vec![] });
let parsed = parse_args(&["--control-token-file", "/tmp/token", "--new-instance"]).unwrap();
assert_eq!(parsed.auth.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token")));
assert!(parsed.launch_options.is_empty() && !parsed.in_window_menus, "{:?}", parsed.launch_options);
}
#[test]
fn version_stops_parsing() {
let e = parse_args(&["--version", "--control"]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayVersion, 0));
assert_eq!(e.to_string(), format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
}
#[test]
fn help_stops_parsing_and_double_dash_ends_options() {
let e = parse_args(&["-h", "--safegpu", "--control"]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayHelp, 0));
assert!(usage_error(&["--safegpu", "--", "--help"]).contains("unexpected argument '--safegpu'"));
let parsed = parse_args(&["a.psd", "--", "--help", "-x"]).unwrap();
assert_eq!(parsed.file_names().openable, ["a.psd", "--help", "-x"]);
}
#[test]
fn a_finder_process_serial_number_is_dropped() {
let parsed = parse_args(&["-psn_0_12345", "a.psd"]).unwrap();
assert_eq!(parsed.file_names().openable, ["a.psd"]);
let parsed = parse_args(&["--", "-psn_x.psd"]).unwrap();
assert_eq!(parsed.file_names().openable, ["-psn_x.psd"]);
}
/// #1108: a name that isn't valid Unicode is reported, not opened; a token must be text.
#[cfg(unix)]
#[test]
fn non_unicode_file_names_are_reported_and_tokens_rejected() {
use std::os::unix::ffi::OsStringExt as _;
let latin1 = OsString::from_vec(b"caf\xe9.psd".to_vec());
let parsed = parse(command().mut_args(|a| a.env(None::<&str>)), [latin1.clone()]).unwrap();
assert_eq!(parsed.file_names(), FileNames { openable: vec![], unreadable: vec!["caf\u{fffd}.psd".to_string()] });
let e = parse(command().mut_args(|a| a.env(None::<&str>)), [OsString::from("--control-token"), latin1]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::InvalidUtf8, 2));
}
}
+28 -362
View File
@@ -1,14 +1,7 @@
//! PhotoCraft desktop app.
//!
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [--new-instance] [--] [files…]`
//! (`photocraft --help` prints [`USAGE`]; `--version` prints the version).
//!
//! Any other argument starting with `-` is a usage error (exit status 2, issue #6431), so a typo
//! such as `--safegpu` doesn't open the window and then fail to open the flag as a file. A file
//! whose name starts with `-` goes after `--`.
//! Usage: `photocraft [options] [--] [files…]`; `photocraft --help` lists the options, which are
//! declared, with what makes a usage error (exit status 2), in `launch_args`.
//!
//! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new
//! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any
@@ -31,7 +24,8 @@
//! `{"id":1,"ok":true,"result":…}`. See `photocraft_ui_egui::control` for the methods.
// Release builds on Windows are GUI-subsystem apps, so launching from the Start Menu or Explorer
// doesn't open a console window. (`--version` output then only shows when redirected.)
// doesn't open a console window. (`--help`, `--version` and usage errors then only show when
// redirected.)
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -49,6 +43,7 @@ mod file_dialog_probe;
mod gpu_startup;
#[cfg(target_os = "linux")]
mod icon_theme;
mod launch_args;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
@@ -148,360 +143,30 @@ fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilde
builder
}
/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that
/// names the value (issue #701): silently running with no control server leaves a launcher or
/// agent unable to tell a typo from a successful grant.
fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
}
/// Process exit status for the collected usage errors (a bad `--control` /
/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a
/// command-line usage error), never 0.
fn usage_exit_code(errors: &[String]) -> Option<i32> {
if errors.is_empty() { None } else { Some(2) }
}
/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs.
const USAGE: &str = "\
Usage: photocraft [options] [--] [files…]
Opens PhotoCraft with the given files.
Options:
-h, --help Print this help and exit
--version Print the version, commit and build date and exit
--safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash)
--in-window-menus macOS: keep the menus inside the window instead of the menu bar
--new-instance Windows and Linux: start a separate PhotoCraft instead of opening
the files in the one already running
--present-mode <mode> How frames reach the display: fifo, autovsync or novsync
--frame-latency <n> Frames queued for the display: 1 to 3
--stroke-trace <stderr|file.csv>
Time each brush stroke's frames
--control <port> Start the localhost JSON-lines control server on this port
--control-token <64-hex> Control server token (default: a new random one, printed)
--control-token-file <path> Read the control server token from this file, or create it
with a new one
--automation-read-root <dir> Directory the control server may read files from
--automation-write-root <dir>
Directory the control server may write files to
-- Treat every later argument as a file, even one starting with `-`
A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored.
Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE,
PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE,
PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE,
PHOTOCRAFT_CONFIG_DIR.
";
/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when
/// stdout is closed or a pipe (`photocraft --help | head -1`).
fn print_stdout(text: &str) {
use std::io::Write as _;
let mut out = std::io::stdout().lock();
let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush());
}
/// Everything the desktop launcher takes from the command line, on top of the environment
/// defaults handed in to `parse_launch_args`.
#[derive(Debug, Default, PartialEq, Eq)]
struct LaunchArgs {
control_port: Option<u16>,
control_token: Option<String>,
control_token_file: Option<std::path::PathBuf>,
automation_read_root: Option<std::path::PathBuf>,
automation_write_root: Option<std::path::PathBuf>,
files: Vec<String>,
/// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
/// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
/// reported in a notice once the app is up; a control token is a usage error like a bad port.
unreadable_paths: Vec<String>,
safe_gpu: bool,
new_instance: bool,
/// Options that apply only to the process they're given to; a launch with any keeps its own.
launch_options: Vec<&'static str>,
present_mode_arg: Option<String>,
frame_latency_arg: Option<String>,
stroke_trace_arg: Option<String>,
in_window_menus: bool,
/// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there.
help: bool,
/// `--version`: print the version and exit 0; parsing stops there.
version: bool,
/// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty
/// list fails the launch with exit status 2 (issues #701, #6429, #6431).
errors: Vec<String>,
}
/// Parse the desktop launch arguments over the environment defaults in `out`. A control or
/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent
/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2.
/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`.
fn parse_launch_args(args: impl Iterator<Item = std::ffi::OsString>, mut out: LaunchArgs) -> LaunchArgs {
let mut args = args;
// After `--`, every argument is a file, even one that starts with `-`.
let mut only_files = false;
while let Some(a) = args.next() {
if only_files {
match a.into_string() {
Ok(s) => out.files.push(s),
Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()),
}
continue;
}
match a.to_str() {
Some("--") => only_files = true,
Some("--help" | "-h") => {
out.help = true;
break;
}
Some("--control") => match args.next() {
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
Ok(port) => out.control_port = Some(port),
Err(error) => out.errors.push(error),
},
None => out.errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
},
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(token)) => out.control_token = Some(token),
Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
None => out.errors.push("--control-token: missing token value (expected `--control-token <token>`)".to_string()),
},
Some("--control-token-file") => match args.next() {
Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file <path>`)".to_string()),
},
Some("--automation-read-root") => {
out.launch_options.push("--automation-read-root");
match args.next() {
Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root <dir>`)".to_string()),
}
}
Some("--automation-write-root") => {
out.launch_options.push("--automation-write-root");
match args.next() {
Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root <dir>`)".to_string()),
}
}
Some("--safe-gpu") => {
out.launch_options.push("--safe-gpu");
out.safe_gpu = true;
}
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
out.launch_options.push(match flag {
"--present-mode" => "--present-mode",
"--frame-latency" => "--frame-latency",
_ => "--stroke-trace",
});
let flag = flag.to_string();
match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(v)) => match flag.as_str() {
"--present-mode" => out.present_mode_arg = Some(v),
"--frame-latency" => out.frame_latency_arg = Some(v),
_ => out.stroke_trace_arg = Some(v),
},
// A bad value is reported and ignored; it never fails the launch.
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
}
}
Some("--in-window-menus") => {
out.launch_options.push("--in-window-menus");
out.in_window_menus = true;
}
Some("--new-instance") => out.new_instance = true,
Some("--version") => {
out.version = true;
break;
}
// Old macOS passes a process serial number when launched from Finder.
Some(s) if s.starts_with("-psn_") => {}
// A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
// and then fail to open the flag as a file (#6431).
Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
Some(s) => out.files.push(s.to_owned()),
None => out.unreadable_paths.push(a.to_string_lossy().into_owned()),
}
}
out
}
#[cfg(test)]
mod control_port_tests {
use super::{parse_control_port, usage_exit_code};
#[test]
fn control_port_errors_exit_non_zero() {
assert_eq!(usage_exit_code(&[]), None);
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
assert_eq!(usage_exit_code(&errors), Some(2));
}
#[test]
fn control_port_accepts_valid_numbers() {
assert_eq!(parse_control_port("50494", "--control"), Ok(50494));
assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080));
assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0));
}
#[test]
fn control_port_rejects_unparseable_values_naming_them() {
// Out of range, a following flag eaten as the value, and an empty value all fail,
// naming the offending value in the message.
let err = parse_control_port("78787", "--control").unwrap_err();
assert!(err.contains("78787"), "{err}");
assert!(parse_control_port("--safe-gpu", "--control").is_err());
assert!(parse_control_port("", "--control").is_err());
}
}
#[cfg(test)]
mod launch_args_tests {
use super::{LaunchArgs, parse_launch_args, usage_exit_code};
use std::ffi::OsString;
fn parse(values: &[&str]) -> LaunchArgs {
parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default())
}
#[test]
fn flags_without_a_value_are_usage_errors() {
// Issue #6429: a missing value must fail the launch, not silently fall back to a
// generated token or to no root.
for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] {
let parsed = parse(&[flag]);
assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors);
assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]);
assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]);
assert_eq!(usage_exit_code(&parsed.errors), Some(2));
}
}
#[test]
fn missing_value_does_not_clear_an_environment_default() {
// `--automation-read-root` with no value must not drop a root set through the env var.
let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() };
let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults);
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read")));
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write")));
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
}
#[test]
fn values_are_parsed_and_no_errors_remain() {
let parsed = parse(&[
"--control",
"50494",
"--control-token",
"abcd",
"--control-token-file",
"/tmp/token",
"--automation-read-root",
"/r",
"--automation-write-root",
"/w",
"--safe-gpu",
"--in-window-menus",
"--new-instance",
"--present-mode",
"mailbox",
"photo.psd",
]);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
assert_eq!(parsed.control_port, Some(50494));
assert_eq!(parsed.control_token.as_deref(), Some("abcd"));
assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token")));
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
assert!(parsed.safe_gpu);
assert!(parsed.in_window_menus);
assert!(parsed.new_instance);
assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox"));
assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]);
assert_eq!(parsed.files, vec!["photo.psd".to_string()]);
}
#[test]
fn version_stops_parsing() {
let parsed = parse(&["--version", "--control"]);
assert!(parsed.version);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
}
#[test]
fn help_stops_parsing_and_double_dash_ends_options() {
let parsed = parse(&["-h", "--safegpu", "--control"]);
assert!(parsed.help);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
let parsed = parse(&["--safegpu", "--", "--help", "-x"]);
assert!(!parsed.help);
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]);
assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]);
}
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
crash_guard::install_hook();
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
photocraft_ui_egui::type_tool::warm_fonts();
let mut launch = LaunchArgs {
automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from),
automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from),
in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"),
..Default::default()
};
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
Ok(port) => launch.control_port = Some(port),
Err(error) => launch.errors.push(error),
}
}
launch = parse_launch_args(std::env::args_os().skip(1), launch);
if launch.help {
print_stdout(USAGE);
return Ok(());
}
if launch.version {
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
return Ok(());
}
// A malformed control port must not silently drop the control server (issue #701), a control
// or automation flag without its value must not silently fall back to a generated token or
// to no root (issue #6429), and an unknown option must not open the window (#6431): name the
// bad argument and fail the launch, like `photocraft-cli` does for the same typos. Exit
// status 2 is the usual command-line usage error, so a launcher sees the failure.
if let Some(code) = usage_exit_code(&launch.errors) {
for error in &launch.errors {
eprintln!("photocraft: {error}");
}
eprintln!("Run `photocraft --help` for usage.");
std::process::exit(code);
}
let LaunchArgs {
control_port,
control_token,
control_token_file,
automation_read_root,
automation_write_root,
files,
unreadable_paths,
// `--help` and `--version` print to standard output and exit 0. A usage error (an unknown
// option, a missing or bad value, a bad PHOTOCRAFT_CONTROL_PORT: issues #701, #6429, #6431)
// names the argument on standard error and exits 2, so a launcher sees the failure. Neither
// panics when the stream is closed or a pipe (`photocraft --help | head -1`).
let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit());
let launch_args::FileNames { openable: files, unreadable: unreadable_paths } = launch.file_names();
let launch_args::LaunchArgs {
files: _,
safe_gpu,
new_instance,
mut launch_options,
present_mode_arg,
frame_latency_arg,
stroke_trace_arg,
in_window_menus,
help: _,
version: _,
errors: _,
new_instance,
present_mode,
frame_latency,
stroke_trace,
control: control_port,
auth,
automation,
mut launch_options,
} = launch;
// The log file lives under the settings directory; opened after the arguments, so `--version`
@@ -517,7 +182,7 @@ fn main() -> eframe::Result {
// An adapter named in the environment is chosen at startup, which a forwarded launch never reaches.
let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1;
if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) {
launch_options.push("PHOTOCRAFT_GPU_ADAPTER");
launch_options.push(launch_args::PerProcess::GpuAdapterEnv);
}
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
@@ -598,7 +263,7 @@ fn main() -> eframe::Result {
}
let control = if let Some(port) = control_port {
let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file);
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()) {
Ok(token) => token,
Err(e) => {
@@ -613,7 +278,7 @@ fn main() -> eframe::Result {
} else {
eprintln!("photocraft: using supplied control token");
}
let workspace = match photocraft_automation::AuthorizedWorkspace::new(automation_read_root.as_deref(), automation_write_root.as_deref()) {
let workspace = match automation.workspace() {
Ok(workspace) => workspace,
Err(error) => {
eprintln!("photocraft: cannot configure automation workspace: {error}");
@@ -702,7 +367,7 @@ fn main() -> eframe::Result {
options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync;
}
// Testers' present mode and frame latency overrides (#1343), after the preference.
let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref());
let present = presentation::Overrides::from_args_or_env(present_mode, frame_latency);
options.wgpu_options.surface = present.apply(options.wgpu_options.surface);
let present_desc = presentation::describe(&options.wgpu_options.surface, &present);
// Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out.
@@ -780,7 +445,7 @@ fn main() -> eframe::Result {
let info = &mut app.perf.gpu_info;
info.preference = pref.name().to_string();
info.present = present_desc.clone();
if let Some(v) = &stroke_trace_arg {
if let Some(v) = &stroke_trace {
app.stroke_trace.set(v);
}
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
@@ -938,9 +603,10 @@ fn main() -> eframe::Result {
s.finish();
}
if let Ok(exe) = std::env::current_exe() {
// `--safe-gpu` first: after a `--`, it would be a file name.
let launched = std::process::Command::new(exe)
.args(std::env::args_os().skip(1))
.arg("--safe-gpu")
.args(std::env::args_os().skip(1))
.env_remove("WGPU_BACKEND")
.env("PHOTOCRAFT_GPU_STARTUP_FAILURE", &reason)
.spawn();
+39 -28
View File
@@ -2,8 +2,10 @@
//! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`,
//! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo
//! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may
//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a
//! warning in the log, never a failed start; a mode the driver may not offer is never requested.
//! queue). Unset, the platform default applies. A variable's value that isn't one of these is ignored
//! with a warning in the log, never a failed start; the `--present-mode` and `--frame-latency` flags
//! take the same values, and a bad one is a usage error (#7071). A mode the driver may not offer is
//! never requested.
//! The result is logged at start and shown in the stroke trace.
use eframe::egui_wgpu::SurfaceConfig;
@@ -16,45 +18,54 @@ pub struct Overrides {
pub latency: Option<u32>,
}
/// A `--present-mode` / `PHOTOCRAFT_PRESENT_MODE` value, or `None` when it isn't one.
pub fn present_mode(v: &str) -> Option<PresentMode> {
match v.trim().to_ascii_lowercase().as_str() {
// Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or
// FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without
// vsync, wgpu picks Immediate, else Mailbox, else Fifo.
"fifo" | "vsync" => Some(PresentMode::Fifo),
"autovsync" => Some(PresentMode::AutoVsync),
"novsync" | "immediate" | "mailbox" | "autonovsync" => Some(PresentMode::AutoNoVsync),
_ => None,
}
}
/// A `--frame-latency` / `PHOTOCRAFT_FRAME_LATENCY` value (1 to 3), or `None` when it isn't one.
pub fn frame_latency(v: &str) -> Option<u32> {
v.trim().parse().ok().filter(|n| (1..=3).contains(n))
}
/// Parse the two variables' values. Returns the overrides and a warning per rejected value.
pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec<String>) {
let mut warnings = Vec::new();
let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| {
let m = match v.to_ascii_lowercase().as_str() {
// Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or
// FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without
// vsync, wgpu picks Immediate, else Mailbox, else Fifo.
"fifo" | "vsync" => PresentMode::Fifo,
"autovsync" => PresentMode::AutoVsync,
"novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync,
_ => {
warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored"));
return None;
}
};
Some(m)
});
let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::<u32>() {
Ok(n @ 1..=3) => Some(n),
_ => {
warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored"));
None
let m = present_mode(v);
if m.is_none() {
warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored"));
}
m
});
let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| {
let n = frame_latency(v);
if n.is_none() {
warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored"));
}
n
});
(Overrides { mode, latency }, warnings)
}
impl Overrides {
/// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` /
/// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged.
pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self {
let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok();
let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok();
let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref()));
/// `--present-mode` / `--frame-latency` when given (checked by the command line), else
/// `PHOTOCRAFT_PRESENT_MODE` / `PHOTOCRAFT_FRAME_LATENCY`, whose rejected values are logged.
pub fn from_args_or_env(mode: Option<PresentMode>, latency: Option<u32>) -> Self {
let env = |given: bool, name: &str| if given { None } else { std::env::var(name).ok() };
let (o, warnings) = parse(env(mode.is_some(), "PHOTOCRAFT_PRESENT_MODE").as_deref(), env(latency.is_some(), "PHOTOCRAFT_FRAME_LATENCY").as_deref());
for w in warnings {
log::warn!("{w}");
}
o
Self { mode: mode.or(o.mode), latency: latency.or(o.latency) }
}
/// `surface` with the overrides applied.
+30 -47
View File
@@ -41,6 +41,8 @@ use photocraft_automation::security::{LineRead, generate_token, read_bounded_lin
use photocraft_ui_egui::{OsEvent, OsEventsFn};
use serde_json::{Value, json};
use crate::launch_args::PerProcess;
const LOCK_FILE: &str = "instance.lock";
const INFO_FILE: &str = "instance.json";
/// The Unix socket a sandboxed primary listens on, in the app's runtime directory.
@@ -131,20 +133,19 @@ pub struct LaunchContext<'a> {
pub unreadable_paths: bool,
/// Options that only apply to the process they're given to (`--safe-gpu`, …), including the
/// ones this launch's environment sets (see [`env_launch_options`]).
pub launch_options: &'a [&'static str],
pub launch_options: &'a [PerProcess],
}
/// Variables documented as the equivalent of a per-launch flag, each with the flag it stands for
/// Variables documented as the equivalent of a per-launch flag, each with the option it stands for
/// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`) and the rule that says whether its value asks for
/// that option at all. Setting one asks for the same thing as giving the flag, so it belongs to this
/// process alone.
const ENV_LAUNCH_OPTIONS: [(&str, &str, ValueRule); 6] = [
("PHOTOCRAFT_PRESENT_MODE", "--present-mode", present_mode_is_on),
("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency", frame_latency_is_on),
("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace", stroke_trace_is_on),
("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus", flag_is_on),
("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root", path_is_set),
("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root", path_is_set),
/// process alone. These are read where they take effect, not by the command line; the variables
/// that are a flag's fallback (`PHOTOCRAFT_IN_WINDOW_MENUS`, the automation roots) are counted by
/// [`crate::launch_args::parse`], as clap reads them.
const ENV_LAUNCH_OPTIONS: [(&str, PerProcess, ValueRule); 3] = [
("PHOTOCRAFT_PRESENT_MODE", PerProcess::PresentMode, present_mode_is_on),
("PHOTOCRAFT_FRAME_LATENCY", PerProcess::FrameLatency, frame_latency_is_on),
("PHOTOCRAFT_STROKE_TRACE", PerProcess::StrokeTrace, stroke_trace_is_on),
];
/// Whether a variable's value asks this launch for its own option. Each rule matches how that
@@ -169,29 +170,15 @@ fn stroke_trace_is_on(value: &str) -> bool {
!matches!(value.trim(), "" | "0" | "off" | "false")
}
/// Any value except empty and exactly `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is
/// why `off` means on there, why this rule is not shared with the stroke trace above, and why it does
/// not trim: a padded `" 0 "` enables the menus there, so it keeps a process here too.
fn flag_is_on(value: &str) -> bool {
!value.is_empty() && value != "0"
}
/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. An empty
/// root is read as no root unless `--control` is given, and that flag keeps its own process already.
fn path_is_set(value: &str) -> bool {
!value.is_empty()
}
/// The per-process options this launch's environment sets, named as the flags they stand for. A
/// launch that sets only variables would otherwise hand its files to the running instance, which
/// never sees the override, and exit.
pub fn env_launch_options() -> Vec<&'static str> {
/// The per-process options this launch's environment sets. A launch that sets only variables would
/// otherwise hand its files to the running instance, which never sees the override, and exit.
pub fn env_launch_options() -> Vec<PerProcess> {
env_launch_options_from(|name| std::env::var_os(name))
}
/// [`env_launch_options`] with the reader given, so the rule is testable without the environment.
fn env_launch_options_from(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Vec<&'static str> {
ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, flag, _)| *flag).collect()
fn env_launch_options_from(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Vec<PerProcess> {
ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, option, _)| *option).collect()
}
/// Why this launch runs as its own instance, or `None` to take part in single-instance.
@@ -209,7 +196,8 @@ pub fn skip_reason(c: &LaunchContext) -> Option<String> {
return Some("some paths aren't valid Unicode".into());
}
if !c.launch_options.is_empty() {
return Some(format!("{} only applies to a new process", c.launch_options.join(", ")));
let options: Vec<String> = c.launch_options.iter().map(ToString::to_string).collect();
return Some(format!("{} only applies to a new process", options.join(", ")));
}
None
}
@@ -898,8 +886,8 @@ mod tests {
assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some(""), ..base }), None);
assert!(skip_reason(&LaunchContext { control: true, ..base }).is_some());
assert!(skip_reason(&LaunchContext { unreadable_paths: true, ..base }).is_some());
let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base });
assert!(reason.is_some_and(|r| r.contains("--safe-gpu")));
let reason = skip_reason(&LaunchContext { launch_options: &[PerProcess::SafeGpu, PerProcess::GpuAdapterEnv], ..base });
assert_eq!(reason.as_deref(), Some("--safe-gpu, PHOTOCRAFT_GPU_ADAPTER only applies to a new process"));
}
/// #1540: inside a Flatpak sandbox the hand-off runs over a Unix socket in the app's runtime
@@ -983,30 +971,25 @@ mod tests {
#[test]
fn an_environment_override_counts_as_a_process_option() {
let with = |values: &[(&str, &str)]| env_launch_options_from(|n| values.iter().find(|(k, _)| *k == n).map(|(_, v)| (*v).into()));
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec!["--present-mode"]);
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec![PerProcess::PresentMode]);
assert_eq!(
with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]),
vec!["--stroke-trace", "--automation-write-root"]
with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_FRAME_LATENCY", "2")]),
vec![PerProcess::FrameLatency, PerProcess::StrokeTrace]
);
// Nothing set, or a value that means off, is an ordinary launch. Each variable is off the way
// it is read where it takes effect, not by one rule shared across all six.
// it is read where it takes effect, not by one rule shared across all three.
assert!(with(&[]).is_empty());
assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "0"), ("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty());
assert!(with(&[("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty());
// The trace is off at `off` and `false` as well, and a value the presentation parser rejects
// is ignored there, so neither keeps a process of its own here.
assert!(with(&[("PHOTOCRAFT_STROKE_TRACE", "off"), ("PHOTOCRAFT_PRESENT_MODE", "maybe"), ("PHOTOCRAFT_FRAME_LATENCY", "4")]).is_empty());
assert!(with(&[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "")]).is_empty());
// Spaces are part of the value only where the reader keeps them: the trace and the two
// presentation values are trimmed where they take effect, the menu flag and a path are not.
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), vec!["--present-mode", "--stroke-trace"]);
// The trace and the two presentation values are trimmed where they take effect.
assert_eq!(
with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " 0 "), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", " /tmp/out ")]),
vec!["--in-window-menus", "--automation-write-root"]
with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]),
vec![PerProcess::PresentMode, PerProcess::StrokeTrace]
);
// A whitespace-only menu value is not empty, so it enables the menus there and here too.
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " ")]), vec!["--in-window-menus"]);
// `PHOTOCRAFT_IN_WINDOW_MENUS=off` means menus in the window there, so it means it here too.
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "off")]), vec!["--in-window-menus"]);
// The menus and the automation roots are flags' fallbacks: `launch_args::parse` counts them.
assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "1"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]).is_empty());
let base = LaunchContext {
per_user_settings: true,
new_instance_flag: false,
+101 -39
View File
@@ -4,36 +4,73 @@
//!
//! These run the real binary, and every case must exit before the window opens: Windows and
//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out.
//! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the
//! other usage errors, so stderr shows whether those arguments were taken as unknown options.
//! Cases whose arguments are valid add a usage error before them: `--control-token` with
//! `--control-token-file` is only checked once every argument is read (and only with `--control`),
//! so stderr shows how the other arguments were taken.
/// Run `photocraft` with `args` in an isolated config dir; return the exit
/// status, stdout and stderr.
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) {
/// The variables the command line reads: a test that wants one sets it.
const VARS: [&str; 6] = [
"PHOTOCRAFT_CONTROL_PORT",
"PHOTOCRAFT_CONTROL_TOKEN",
"PHOTOCRAFT_CONTROL_TOKEN_FILE",
"PHOTOCRAFT_AUTOMATION_READ_ROOT",
"PHOTOCRAFT_AUTOMATION_WRITE_ROOT",
"PHOTOCRAFT_IN_WINDOW_MENUS",
];
/// A usage error found only after every argument is read.
const LATE_ERROR: [&str; 6] = ["--control", "0", "--control-token", "abcd", "--control-token-file", "token"];
/// How a run of `photocraft` ended.
struct Run {
status: std::process::ExitStatus,
stdout: String,
stderr: String,
}
/// Run `photocraft` with `args` and `vars` in an isolated config dir.
fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"));
for var in VARS {
cmd.env_remove(var);
}
let output = cmd
.args(args)
.envs(vars.iter().copied())
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
.env_remove("PHOTOCRAFT_CONTROL_PORT")
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.output()
.expect("failed to run photocraft binary");
std::fs::remove_dir_all(&dir).ok();
(output.status, String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned())
Run { status: output.status, stdout: String::from_utf8_lossy(&output.stdout).into_owned(), stderr: String::from_utf8_lossy(&output.stderr).into_owned() }
}
fn run_photocraft(args: &[&str]) -> Run {
run_with(args, &[])
}
/// [`LATE_ERROR`] then `args`: exit 2, and the stderr.
fn late_error(args: &[&str], vars: &[(&str, &str)]) -> String {
let Run { status, stdout, stderr } = run_with(&[&LATE_ERROR[..], args].concat(), vars);
assert_eq!(status.code(), Some(2), "{args:?}: {stderr}");
assert!(stderr.contains("cannot be used with"), "{args:?}: {stderr}");
assert!(stdout.is_empty(), "{args:?}: {stdout}");
stderr
}
#[test]
fn help_prints_usage_and_exits_0() {
for flag in ["--help", "-h"] {
let (status, stdout, stderr) = run_photocraft(&[flag]);
let Run { status, stdout, stderr } = run_photocraft(&[flag]);
assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}");
assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}");
for option in ["--safe-gpu", "--new-instance", "--control <port>", "--version", "--"] {
assert!(stdout.contains("Usage: photocraft"), "{flag} stdout: {stdout}");
for option in ["--safe-gpu", "--new-instance", "--control <PORT>", "--version", "`--`", "PHOTOCRAFT_AUTOMATION_READ_ROOT", "PHOTOCRAFT_NEW_INSTANCE"] {
assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}");
}
assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}");
@@ -42,54 +79,79 @@ fn help_prints_usage_and_exits_0() {
#[test]
fn help_wins_over_later_arguments() {
let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]);
let Run { status, stdout, .. } = run_photocraft(&["--help", "picture.psd", "--safegpu"]);
assert_eq!(status.code(), Some(0));
assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}");
assert!(stdout.contains("Usage: photocraft"), "stdout: {stdout}");
}
#[test]
fn version_prints_the_long_version() {
let Run { status, stdout, .. } = run_photocraft(&["--version", "--safegpu"]);
assert_eq!(status.code(), Some(0));
assert_eq!(stdout, format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
}
#[test]
fn unknown_options_exit_2_naming_them() {
for flag in ["--safegpu", "-x", "--help=yes"] {
let (status, stdout, stderr) = run_photocraft(&[flag]);
assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}");
assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}");
assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}");
for (args, named) in [
(&["--safegpu"][..], "unexpected argument '--safegpu'"),
(&["-x"][..], "unexpected argument '-x'"),
(&["--help=yes"][..], "unexpected value 'yes' for '--help'"),
// An unknown flag after a bad value is reported first: clap drops the pending value's error.
(&["--control", "nope", "--safegpu"][..], "unexpected argument '--safegpu'"),
(&["--control", "nope"][..], "invalid value 'nope' for '--control <PORT>'"),
] {
let Run { status, stdout, stderr } = run_photocraft(args);
assert_eq!(status.code(), Some(2), "{args:?} must be a usage error, stderr: {stderr}");
assert!(stderr.contains(named), "stderr: {stderr}");
assert!(stderr.contains("--help"), "stderr should point at --help: {stderr}");
assert!(stdout.is_empty(), "stdout: {stdout}");
}
}
#[test]
fn unknown_option_errors_are_reported_with_control_port_errors() {
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--safegpu"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}");
}
#[test]
fn arguments_after_double_dash_are_files() {
// `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage
// nor is an unknown option; only the bad port is reported.
let (status, stdout, stderr) = run_photocraft(&["--control", "nope", "--", "--help", "-x"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stdout.contains("Usage:"), "stdout: {stdout}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
// nor is an unknown option; only the late error is reported.
let stderr = late_error(&["--", "--help", "-x"], &[]);
assert!(!stderr.contains("unexpected"), "stderr: {stderr}");
}
#[test]
fn macos_process_serial_number_is_still_ignored() {
// Old macOS passes `-psn_<n>` when launching from Finder; it is neither a file nor an error.
let (status, _, stderr) = run_photocraft(&["--control", "nope", "-psn_0_12345"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
let stderr = late_error(&["-psn_0_12345"], &[]);
assert!(!stderr.contains("unexpected"), "stderr: {stderr}");
}
#[test]
fn new_instance_is_a_known_option() {
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--new-instance"]);
let stderr = late_error(&["--new-instance"], &[]);
assert!(!stderr.contains("unexpected"), "stderr: {stderr}");
}
/// The variables stand in for their flags: a flag wins, an empty variable counts as unset, a bad
/// value is a usage error naming the variable, and a flag without its value is still an error
/// when its variable is set (#6429).
#[test]
fn variables_are_the_flags_fallbacks() {
let Run { status, stderr, .. } = run_with(&[], &[("PHOTOCRAFT_CONTROL_PORT", "nope")]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
assert!(stderr.contains("invalid value 'nope' for 'PHOTOCRAFT_CONTROL_PORT'"), "stderr: {stderr}");
// With the flag (in LATE_ERROR), the variable is never read.
let stderr = late_error(&[], &[("PHOTOCRAFT_CONTROL_PORT", "nope")]);
assert!(!stderr.contains("PHOTOCRAFT_CONTROL_PORT"), "stderr: {stderr}");
// The variable turns the control server on as the flag does, so the token pair is checked.
let Run { status, stderr, .. } = run_with(&LATE_ERROR[2..], &[("PHOTOCRAFT_CONTROL_PORT", "0")]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("cannot be used with"), "stderr: {stderr}");
let Run { status, stderr, .. } = run_with(&["--automation-read-root"], &[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "/env/read")]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("a value is required for '--automation-read-root <DIR>'"), "stderr: {stderr}");
// The other half of the token pair from a variable is the same either/or error, naming it
// but never showing the token.
let token = "a".repeat(64);
let Run { status, stderr, .. } = run_with(&["--control", "0", "--control-token-file", "token"], &[("PHOTOCRAFT_CONTROL_TOKEN", token.as_str())]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("PHOTOCRAFT_CONTROL_TOKEN") && stderr.contains("cannot be used with") && !stderr.contains(&token), "stderr: {stderr}");
}
+33 -9
View File
@@ -9,10 +9,21 @@
/// workspace check instead of failing on token validation first.
const VALID_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/// How a run of `photocraft` ended.
struct Run {
status: std::process::ExitStatus,
stderr: String,
}
/// Run `photocraft` with the given arguments in an isolated config dir and
/// return the exit status and stderr. Each call gets its own config dir (tests run in
/// return how it ended. Each call gets its own config dir (tests run in
/// parallel), and no display variables, so a regression fails fast instead of opening a window.
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) {
fn run_photocraft(args: &[&str]) -> Run {
run_with(args, &[])
}
/// [`run_photocraft`], with `vars` set.
fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id()));
@@ -29,37 +40,40 @@ fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) {
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.envs(vars.iter().copied())
.output()
.expect("failed to run photocraft binary");
std::fs::remove_dir_all(&dir).ok();
(output.status, String::from_utf8_lossy(&output.stderr).into_owned())
Run { status: output.status, stderr: String::from_utf8_lossy(&output.stderr).into_owned() }
}
#[test]
fn invalid_control_token_exits_with_status_2() {
// `validate_token` rejects a token that is not exactly 64 hex characters.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", "invalid"]);
let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", "invalid"]);
assert_eq!(status.code(), Some(2), "invalid control token must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
}
#[test]
fn both_token_and_token_file_exits_with_status_2() {
// `server_token` rejects the combination of a supplied token and a token file.
// The command line rejects a supplied token together with a token file (#7071), before
// `server_token` would.
let dir = std::env::temp_dir().join(format!("photocraft-token-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let token_file = dir.join("token.txt");
std::fs::write(&token_file, VALID_TOKEN).unwrap();
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]);
let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(status.code(), Some(2), "token + token file must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
assert!(stderr.contains("cannot be used with"), "stderr: {stderr}");
}
#[test]
fn missing_automation_read_root_exits_with_status_2() {
// `AuthorizedWorkspace::new` fails when the read root does not exist.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]);
let Run { status, stderr } =
run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]);
assert_eq!(status.code(), Some(2), "missing read root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
}
@@ -67,7 +81,17 @@ fn missing_automation_read_root_exits_with_status_2() {
#[test]
fn missing_automation_write_root_exits_with_status_2() {
// `AuthorizedWorkspace::new` fails when the write root does not exist.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]);
let Run { status, stderr } =
run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]);
assert_eq!(status.code(), Some(2), "missing write root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
}
#[test]
fn automation_roots_come_from_the_environment_too() {
for var in ["PHOTOCRAFT_AUTOMATION_READ_ROOT", "PHOTOCRAFT_AUTOMATION_WRITE_ROOT"] {
let Run { status, stderr } = run_with(&["--control", "0", "--control-token", VALID_TOKEN], &[(var, "/nonexistent/photocraft-test-path")]);
assert_eq!(status.code(), Some(2), "{var}: missing root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "{var}: stderr: {stderr}");
}
}
+18 -4
View File
@@ -63,9 +63,23 @@ layers because that would lose their group's blending semantics.
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment.
CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters.
CLI parsing checks the flags and their values before any work starts. A flag the subcommand doesn't take (a typo such as `--fromat`), a flag given twice (only `--cmd` and `--params` may repeat), a value out of range (`--quality 0`), malformed `--new` or `--params` JSON, `<FILE>` together with `--new`, and `--control-token` together with `--control-token-file` are all usage errors (exit status 2), rather than being ignored or failing later. A value flag never takes the next flag as its value, so a value starting with `-` is written with `=` (`--out=-image.png`) or as `./-image.png`; negative numbers such as `--filter -1` still work. A path that is not valid Unicode reaches the command instead of being a usage error: `convert` reads and writes it, while `info`, `run` and `batch` open files through the automation layer, which still needs UTF-8 paths and fails with exit status 1 (`droplet` takes its arguments as text, so it refuses such a path as a usage error). `photocraft-cli --version` (or `-V`) prints the version; there is no `version` subcommand.
`mcp` and `serve` reject positional arguments, repeated options, missing or empty values and
options for the wrong mode before starting a server. Explicit control-token flags require
`mcp --bridge` or `serve --port`. Configure automation roots on the desktop process when using
`mcp` and `serve` reject positional arguments, empty values and options for the wrong mode before
starting a server. Explicit control-token flags require `mcp --bridge` or `serve --port` (or
`PHOTOCRAFT_CONTROL_PORT`, below). Configure automation roots on the desktop process when using
bridge mode; the CLI rejects root flags there. See the [MCP launch guide](mcp.md) for examples.
`mcp` and `serve` fall back to environment variables: `PHOTOCRAFT_CONTROL_TOKEN` and `PHOTOCRAFT_CONTROL_TOKEN_FILE` for the token flags, and `PHOTOCRAFT_CONTROL_PORT` for `serve --port` and `mcp --bridge` (which then bridges to `127.0.0.1:<port>`). The automation roots are flags only: the desktop app's `PHOTOCRAFT_AUTOMATION_READ_ROOT` and `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` grant the CLI nothing. A flag wins over its variable, an empty variable counts as unset, and a bad value is a usage error naming the variable. When `PHOTOCRAFT_CONTROL_PORT` switches `serve` to TCP or `mcp` to bridging, the command says so on standard error. A token together with a token file is a usage error only where a token is read (`serve` on TCP, `mcp` bridging), so a stale exported pair doesn't stop stdio `serve` or headless `mcp`. An automation root given to `mcp` while the variable would switch it to bridging is a usage error too: the roots are the headless engine's, and the bridge would drop them. `<subcommand> --help` lists each variable (unless it is set but empty), never with the token's value. A variable holds a port, so `PHOTOCRAFT_CONTROL_PORT=127.0.0.1:7878` is refused; only `--bridge` takes `host:port`.
This checking is not a security policy layer. Engine commands remain responsible for validating their own parameters.
### Shell completions and man pages
The Linux `.deb` and `.rpm` packages install `man photocraft-cli` (plus a page per subcommand, such as `man photocraft-cli-convert`) and completions for bash, zsh and fish (zsh's in `/usr/share/zsh/site-functions`, which the `.deb` also links from Debian's `/usr/share/zsh/vendor-completions`); the Linux tarball and the FreeBSD tarball carry the same files, the FreeBSD one under `man/man1` and `share/`. The pages list each flag's environment variable, as `--help` does. Without a package, generate them from a source checkout:
```sh
cargo run -p photocraft-cli --example man-and-completions -- out/
```
`out/` then holds `photocraft-cli.bash`, `_photocraft-cli` (zsh), `photocraft-cli.fish`, `photocraft-cli.elv` (elvish), `_photocraft-cli.ps1` (PowerShell) and the `.1` man pages. For example, copy `photocraft-cli.bash` to `~/.local/share/bash-completion/completions/photocraft-cli`, `photocraft-cli.fish` into `~/.config/fish/completions/`, `_photocraft-cli` into a directory on zsh's `$fpath`, and read a page with `man -l out/photocraft-cli.1`. The desktop app `photocraft` has neither.
+6 -1
View File
@@ -1,5 +1,6 @@
//! Full filter timing, including tile reads, filtering, writeback and pruning.
//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- facet 6000 4000 u8 3 output.bin
//! Also: crystallize, crystallize-large, pointillize, pointillize-large.
//! Optional final argument after output.bin: rgb (default), gray, cmyk or lab.
//! Run the same example at the base commit and the PR commit, then compare output.bin with cmp.
use photocraft_algo::{FilterParams, apply_in};
@@ -29,11 +30,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
_ => return Err("depth must be u8, u16 or f32".into()),
};
let params = match case {
"crystallize" => FilterParams::Crystallize { cell_size: 10.0, seed: 37 },
"crystallize-large" => FilterParams::Crystallize { cell_size: 100.0, seed: 37 },
"pointillize" => FilterParams::Pointillize { cell_size: 5.0, seed: 37, background: [1.0; 4] },
"pointillize-large" => FilterParams::Pointillize { cell_size: 100.0, seed: 37, background: [1.0; 4] },
"facet" => FilterParams::Facet,
"halftone" => FilterParams::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] },
"halftone-small" => FilterParams::ColorHalftone { max_radius: 2.0, angles: [108.0, 162.0, 90.0, 45.0] },
"halftone-large" => FilterParams::ColorHalftone { max_radius: 64.0, angles: [108.0, 162.0, 90.0, 45.0] },
_ => return Err("case must be facet, halftone, halftone-small or halftone-large".into()),
_ => return Err("unknown pixelate benchmark case".into()),
};
let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") {
"rgb" => ColorMode::Rgb,
+32
View File
@@ -0,0 +1,32 @@
//! Timing and output hash for Content-Aware Scale's seam carving core (P28).
//!
//! `cargo run --release -p photocraft-algo --example bench_seam [width height new_width channels]`
//!
//! Prints the carve time and an FNV-1a hash of the output's raw f32 bits, so two builds can be
//! compared with `cmp`-level strictness without writing tens of megabytes of pixels to disk.
use std::time::Instant;
fn main() {
let a: Vec<String> = std::env::args().skip(1).collect();
let num = |i: usize, d: usize| a.get(i).and_then(|s| s.parse().ok()).unwrap_or(d);
let (w, h, new_w, ch) = (num(0, 6000), num(1, 4000), num(2, 5400), num(3, 4));
if !(1..=4).contains(&ch) {
eprintln!("channels must be 1..=4");
std::process::exit(2);
}
// Deterministic photo-ish content: gradients with fine detail, so the energy map carries
// structure instead of one flat value per row.
let img: Vec<f32> = (0..w * h)
.flat_map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let d = ((x * 0.13).sin() * (y * 0.07).cos()).abs() * 0.3;
[(x / w as f32) * 0.6 + d, (y / h as f32) * 0.9 - d, d, 1.0].into_iter().take(ch)
})
.collect();
let t = Instant::now();
let out = photocraft_algo::seam::carve_width(w, h, ch, &img, None, new_w);
let ms = t.elapsed().as_secs_f64() * 1e3;
let hash =
out.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, v| v.to_bits().to_le_bytes().iter().fold(h, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100_0000_01b3)));
println!("{w}x{h}x{ch} -> {new_w}: {ms:.1} ms, {} samples, fnv {hash:016x}", out.len());
}
+44 -8
View File
@@ -1142,16 +1142,43 @@ fn apply_tiled_with_impl(
// Tiles finished so far, for progress reported per tile (from any worker thread).
let finished = std::sync::atomic::AtomicUsize::new(0);
let total = tiles.len().max(1);
let run = |t: &Rect| -> (Rect, Vec<f32>) {
enum TileData {
Samples(Vec<f32>),
Encoded(Vec<u8>),
}
// Cell filters become writeback-bound once their geometry is reused. Encode
// on the tile workers; committing the group then only copies native bytes.
let finish = |data: Vec<f32>| {
if matches!(params, FilterParams::Crystallize { .. } | FilterParams::Pointillize { .. })
&& let Some(bytes) = pixelate::cells::encode(fmt, &data)
{
TileData::Encoded(bytes)
} else {
TileData::Samples(data)
}
};
let run = |t: &Rect| -> (Rect, TileData) {
// Cancelled: skip the remaining tiles of the group (the result is discarded).
if ctl.cancelled() {
return (*t, Vec::new());
return (*t, TileData::Samples(Vec::new()));
}
let tick = || {
let n = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are the writes after each group.
ctl.progress(0.98 * n as f32 / total as f32);
};
let sparse = match *params {
FilterParams::Crystallize { cell_size, seed } => pixelate::cells::tile(surface, *t, &ctx, cell_size, seed, None, extent),
FilterParams::Pointillize { cell_size, seed, background } => pixelate::cells::tile(surface, *t, &ctx, cell_size, seed, Some(background), extent),
_ => None,
};
if let Some((src, mut data)) = sparse {
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, &src, fmt.alpha);
}
tick();
return (*t, finish(data));
}
let owned;
let src = match (&shared, halo) {
(Some(s), _) => s,
@@ -1180,13 +1207,13 @@ fn apply_tiled_with_impl(
_ => Some(kernel(params, src, *t, &ctx)),
},
}) else {
return (*t, Vec::new());
return (*t, TileData::Samples(Vec::new()));
};
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, src, fmt.alpha);
}
tick();
(*t, data)
(*t, finish(data))
};
// Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each
// group is written before the next starts, so a huge layer never holds all its results as
@@ -1202,24 +1229,33 @@ fn apply_tiled_with_impl(
return None;
}
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<(Rect, Vec<f32>)> = {
let results: Vec<(Rect, TileData)> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<f32>)> = chunk.iter().map(run).collect();
let results: Vec<(Rect, TileData)> = chunk.iter().map(run).collect();
if ctl.cancelled() {
return None;
}
// Checked filters can reject malformed geometry or an allocation failure.
// Do not hand an incomplete tile to Surface::write_region.
if (shape.is_some() || matches!(params, FilterParams::UnsharpMask { .. }))
&& results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels())
&& results.iter().any(|(t, data)| {
let pixels = t.width() as usize * t.height() as usize;
match data {
TileData::Samples(v) => v.len() != pixels * fmt.channels(),
TileData::Encoded(v) => v.len() != pixels * fmt.bytes_per_pixel(),
}
})
{
return None;
}
for (t, data) in results {
out.write_region(t, &data);
match data {
TileData::Samples(data) => out.write_region(t, &data),
TileData::Encoded(data) => out.write_interleaved(t, &data),
}
}
}
out.prune();
+11 -2
View File
@@ -5,6 +5,7 @@
use photocraft_geom::Rect;
pub(crate) mod cells;
mod facet;
#[cfg(test)]
mod reference;
@@ -120,7 +121,7 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32,
/// being distressed) keeps hard edges and its weight instead of greying where cells straddle
/// an edge. A cell whose point falls outside the image takes the (premultiplied) average
/// colour of its pixels.
pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
fn crystallize_direct(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
let n = src.ch;
let cell = cell_size.max(2.0);
let b = ctx.bounds;
@@ -320,7 +321,7 @@ pub(crate) fn mezzotint(src: &Image, out: Rect, ctx: &Ctx, kind: MezzotintType,
/// Pointillize: randomly sized dots, coloured from the image at their centre,
/// on a canvas of the background colour.
pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec<f32> {
fn pointillize_direct(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec<f32> {
let n = src.ch;
let cell = cell_size.max(2.0);
let b = ctx.bounds;
@@ -374,3 +375,11 @@ pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see
}
res
}
pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
cells::crystallize(src, out, ctx, cell_size, seed).unwrap_or_else(|| crystallize_direct(src, out, ctx, cell_size, seed))
}
pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec<f32> {
cells::pointillize(src, out, ctx, cell_size, seed, background).unwrap_or_else(|| pointillize_direct(src, out, ctx, cell_size, seed, background))
}
+571
View File
@@ -0,0 +1,571 @@
//! Reuse the jittered lattice in cell space. Raster work stays in the original
//! row/candidate order, including f32 ties and boundary-cell average additions.
use super::*;
#[derive(Clone, Copy)]
struct Site {
point: (f32, f32),
color: [f32; MAXC],
radius: f32,
sampled: bool,
}
struct Grid {
x0: i32,
y0: i32,
width: usize,
sites: Vec<Site>,
}
fn key(p: i32, origin: f32, cell: f32) -> i32 {
((p as f32 + 0.5 - origin) / cell).floor() as i32
}
impl Grid {
fn new(src: &Image, area: Rect, ctx: &Ctx, cell: f32, seed: u32, dots: bool) -> Option<Self> {
if src.data.len() != (src.rect.width() as usize).checked_mul(src.rect.height() as usize)?.checked_mul(src.ch)? {
return None;
}
Self::new_with(area, ctx, src.ch, cell, seed, dots, |sx, sy, color| {
for (c, value) in color.iter_mut().enumerate() {
*value = src.get(sx, sy, c);
}
src.rect.contains(sx, sy) && ctx.bounds.contains(sx, sy)
})
}
#[allow(clippy::too_many_arguments)]
fn new_with(area: Rect, ctx: &Ctx, n: usize, cell: f32, seed: u32, dots: bool, sample: impl Fn(i32, i32, &mut [f32]) -> bool) -> Option<Self> {
if area.is_empty() || !cell.is_finite() || cell < 2.0 || n == 0 || n > MAXC {
return None;
}
let origin = (ctx.bounds.x0 as f32, ctx.bounds.y0 as f32);
let x0 = key(area.x0, origin.0, cell).checked_sub(1)?;
let y0 = key(area.y0, origin.1, cell).checked_sub(1)?;
let x1 = key(area.x1.checked_sub(1)?, origin.0, cell).checked_add(1)?;
let y1 = key(area.y1.checked_sub(1)?, origin.1, cell).checked_add(1)?;
let width = usize::try_from(x1.checked_sub(x0)?.checked_add(1)?).ok()?;
let height = usize::try_from(y1.checked_sub(y0)?.checked_add(1)?).ok()?;
let len = width.checked_mul(height)?;
// Optional tile-local scratch, at most 16 MiB even for a direct kernel call.
if len.checked_mul(std::mem::size_of::<Site>())? > 16 * 1024 * 1024 {
return None;
}
let mut sites = Vec::new();
sites.try_reserve_exact(len).ok()?;
let cc = ncol(ctx, n);
let sub = subtractive(ctx);
for gy in y0..=y1 {
for gx in x0..=x1 {
let point = cell_point(gx, gy, cell, seed, origin);
let (sx, sy) = (point.0.floor() as i32, point.1.floor() as i32);
let mut color = [0.0; MAXC];
let sampled = sample(sx, sy, color.get_mut(..n)?);
let radius = if dots {
let j = (hash01(gx, gy, 17, seed) - 0.5) * 0.08;
for value in color.iter_mut().take(cc) {
*value = (*value + if sub { -j } else { j }).clamp(0.0, 1.0);
}
cell * (0.45 + 0.35 * hash01(gx, gy, 13, seed))
} else {
0.0
};
sites.push(Site { point, color, radius, sampled });
}
}
Some(Self { x0, y0, width, sites })
}
fn neighbours(&self, cx: i32, cy: i32) -> Option<[usize; 9]> {
let x = usize::try_from(cx.checked_sub(1)?.checked_sub(self.x0)?).ok()?;
let y = usize::try_from(cy.checked_sub(1)?.checked_sub(self.y0)?).ok()?;
if x.checked_add(2)? >= self.width {
return None;
}
let start = y.checked_mul(self.width)?.checked_add(x)?;
let last = start.checked_add(2 * self.width)?.checked_add(2)?;
self.sites.get(last)?;
Some(std::array::from_fn(|i| start + i / 3 * self.width + i % 3))
}
}
/// Contiguous x runs with the same base cell. Evaluate the old f32 mapping once
/// per column, rather than use an inverse mapping that could round differently.
fn runs(area: Rect, origin: f32, cell: f32) -> Vec<(i32, i32, i32)> {
let mut runs: Vec<(i32, i32, i32)> = Vec::new();
for x in area.x0..area.x1 {
let cx = key(x, origin, cell);
if let Some(last) = runs.last_mut()
&& last.0 == cx
{
last.2 = x + 1;
} else {
runs.push((cx, x, x + 1));
}
}
runs
}
fn nearest(grid: &Grid, neighbours: &[usize; 9], x: i32, y: i32) -> Option<usize> {
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let mut best = (f32::MAX, *neighbours.get(4)?);
for &i in neighbours {
let p = grid.sites.get(i)?.point;
let d = (p.0 - fx).powi(2) + (p.1 - fy).powi(2);
if d < best.0 {
best = (d, i);
}
}
Some(best.1)
}
/// Bounds on the *same f32 score* used by the scalar comparison. Along a
/// scanline, squared x distance has its maximum at an endpoint and its lower
/// bound at the clamped site centre. IEEE subtraction, square, addition and
/// sqrt are monotone on these nonnegative inputs, so these bounds also cover
/// rounding. No estimated bisectors or epsilon decide ownership.
fn score_range<const DOTS: bool>(site: &Site, x0: i32, x1: i32, y: i32) -> (f32, f32) {
let left = x0 as f32 + 0.5;
let right = (x1 - 1) as f32 + 0.5;
let dy2 = (site.point.1 - (y as f32 + 0.5)).powi(2);
let near = (site.point.0 - site.point.0.clamp(left, right)).powi(2) + dy2;
let far = (site.point.0 - left).powi(2).max((site.point.0 - right).powi(2)) + dy2;
if DOTS { (site.radius - far.sqrt(), site.radius - near.sqrt()) } else { (-far, -near) }
}
fn score<const DOTS: bool>(site: &Site, x: i32, y: i32) -> f32 {
let d = (site.point.0 - (x as f32 + 0.5)).powi(2) + (site.point.1 - (y as f32 + 0.5)).powi(2);
if DOTS { site.radius - d.sqrt() } else { -d }
}
/// Certify a whole run when one candidate's worst score beats every other's
/// best. Ambiguous runs split down to scalar pixels. Depth is bounded by the
/// output tile width (at most 11 splits for a 2048-pixel application tile).
fn certified_span<const DOTS: bool>(grid: &Grid, ids: &[usize; 9], x0: i32, y: i32, owners: &mut [u32], depths: &mut [f32]) -> Option<()> {
let len = owners.len();
if len == 0 {
return Some(());
}
let x1 = x0.checked_add(i32::try_from(len).ok()?)?;
let middle = x0 + (len / 2) as i32;
let mut best = (if DOTS { -0.5 } else { -f32::MAX }, None);
for &i in ids {
let value = score::<DOTS>(grid.sites.get(i)?, middle, y);
if value > best.0 {
best = (value, Some(i));
}
}
if let Some(i) = best.1 {
let site = grid.sites.get(i)?;
let lower = score_range::<DOTS>(site, x0, x1, y).0;
let certified = (!DOTS || lower > -0.5)
&& ids.iter().filter(|&&j| j != i).all(|&j| grid.sites.get(j).is_some_and(|other| lower > score_range::<DOTS>(other, x0, x1, y).1));
if len == 1 || certified {
owners.fill(i as u32);
if DOTS {
if lower >= 0.5 {
depths.fill(0.5);
} else {
for (offset, depth) in depths.iter_mut().enumerate() {
*depth = score::<true>(site, x0 + offset as i32, y);
}
}
}
return Some(());
}
} else if len == 1 || ids.iter().all(|&i| grid.sites.get(i).is_some_and(|site| score_range::<DOTS>(site, x0, x1, y).1 <= -0.5)) {
owners.fill(u32::MAX);
return Some(());
}
let half = len / 2;
let (left, right) = owners.split_at_mut(half);
let (ld, rd) = depths.split_at_mut(half);
certified_span::<DOTS>(grid, ids, x0, y, left, ld)?;
certified_span::<DOTS>(grid, ids, x0 + half as i32, y, right, rd)
}
pub(super) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Option<Vec<f32>> {
let cell = cell_size.max(2.0);
let n = src.ch;
let b = ctx.bounds;
// Match the scalar averaging window, including floor/ceil at negative origins.
// Decline extreme geometry before its i32 arithmetic; the scalar fallback owns it.
let c0 = (
(((i64::from(out.x0) - i64::from(b.x0)) as f32 / cell).floor() as i32).checked_sub(2)?,
(((i64::from(out.y0) - i64::from(b.y0)) as f32 / cell).floor() as i32).checked_sub(2)?,
);
let c1 = (
(((i64::from(out.x1) - i64::from(b.x0)) as f32 / cell).floor() as i32).checked_add(2)?,
(((i64::from(out.y1) - i64::from(b.y0)) as f32 / cell).floor() as i32).checked_add(2)?,
);
let win = Rect::new(
b.x0.checked_add((c0.0 as f32 * cell).floor() as i32)?,
b.y0.checked_add((c0.1 as f32 * cell).floor() as i32)?,
b.x0.checked_add((c1.0.checked_add(1)? as f32 * cell).ceil() as i32)?,
b.y0.checked_add((c1.1.checked_add(1)? as f32 * cell).ceil() as i32)?,
)
.intersect(&src.rect)
.intersect(&b);
let mut grid = Grid::new(src, out.union(&win), ctx, cell, seed, false)?;
let origin = (b.x0 as f32, b.y0 as f32);
if grid.sites.iter().any(|s| !s.sampled) {
let mut acc = Vec::new();
let count = grid.sites.len().checked_mul(n.checked_add(1)?)?;
acc.try_reserve_exact(count).ok()?;
acc.resize(count, 0.0f32);
let columns = runs(win, origin.0, cell);
for y in win.y0..win.y1 {
let cy = key(y, origin.1, cell);
for &(cx, x0, x1) in &columns {
let neighbours = grid.neighbours(cx, cy)?;
// An interior cell's average is never used. Skip the entire run if
// all nine possible winners have an on-image colour sample.
if neighbours.iter().all(|&i| grid.sites.get(i).is_some_and(|s| s.sampled)) {
continue;
}
for x in x0..x1 {
let i = nearest(&grid, &neighbours, x, y)?;
if grid.sites.get(i)?.sampled {
continue;
}
let gx = grid.x0.checked_add((i % grid.width) as i32)?;
let gy = grid.y0.checked_add((i / grid.width) as i32)?;
if gx < c0.0 || gy < c0.1 || gx > c1.0 || gy > c1.1 {
continue;
}
let sum = acc.get_mut(i.checked_mul(n + 1)?..i.checked_add(1)?.checked_mul(n + 1)?)?;
let alpha = if ctx.alpha { src.get(x, y, n - 1) } else { 1.0 };
for (c, value) in sum.iter_mut().enumerate().take(n) {
let s = src.get(x, y, c);
*value += if ctx.alpha && c + 1 < n { s * alpha } else { s };
}
*sum.get_mut(n)? += 1.0;
}
}
}
for (site, sum) in grid.sites.iter_mut().zip(acc.chunks_exact(n + 1)) {
let count = *sum.get(n)?;
if !site.sampled && count > 0.0 {
for (dst, value) in site.color.iter_mut().zip(sum).take(n) {
*dst = *value / count;
}
crate::fxutil::unpremul_px(site.color.get_mut(..n)?, ctx.alpha);
site.sampled = true;
}
}
}
crystallize_grid(src, out, ctx, cell, &grid)
}
fn crystallize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, grid: &Grid) -> Option<Vec<f32>> {
let n = src.ch;
let b = ctx.bounds;
let origin = (b.x0 as f32, b.y0 as f32);
let mut result = if src.rect == out { src.data.clone() } else { src.crop(out) };
let width = out.width() as usize;
let stride = width.checked_mul(n)?;
let columns = runs(out, origin.0, cell);
let mut distances = vec![f32::MAX; width];
let mut owners = vec![0u32; width];
for (row, pixels) in result.chunks_exact_mut(stride).enumerate() {
let y = out.y0.checked_add(i32::try_from(row).ok()?)?;
let fy = y as f32 + 0.5;
let cy = key(y, origin.1, cell);
distances.fill(f32::MAX);
for &(cx, x0, x1) in &columns {
let neighbours = grid.neighbours(cx, cy)?;
let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?;
if cell >= 32.0 && out.width() <= 2048 {
certified_span::<false>(grid, &neighbours, x0, y, owners.get_mut(start..end)?, distances.get_mut(start..end)?)?;
continue;
}
owners.get_mut(start..end)?.fill(*neighbours.get(4)? as u32);
for &i in &neighbours {
let site = grid.sites.get(i)?;
let dy2 = (site.point.1 - fy).powi(2);
for (offset, (distance, owner)) in distances.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() {
let fx = (x0 + offset as i32) as f32 + 0.5;
let d = (site.point.0 - fx).powi(2) + dy2;
if d < *distance {
*distance = d;
*owner = i as u32;
}
}
}
}
for (x, (pixel, &owner)) in pixels.chunks_exact_mut(n).zip(&owners).enumerate() {
if !b.contains(out.x0 + x as i32, y) {
continue;
}
let site = grid.sites.get(owner as usize)?;
if site.sampled {
pixel.copy_from_slice(site.color.get(..n)?);
}
}
}
Some(result)
}
/// A site may only compete in its own base column and its two neighbours.
/// Derive the inclusive pixel runs from the exact forward f32 cell mapping.
fn influence_columns(out: Rect, origin: f32, cell: f32, grid: &Grid) -> Option<Vec<(i32, i32)>> {
let columns = runs(out, origin, cell);
(0..grid.width)
.map(|column| {
let gx = grid.x0.checked_add(i32::try_from(column).ok()?)?;
let lo = columns.partition_point(|&(cx, _, _)| cx < gx - 1);
let hi = columns.partition_point(|&(cx, _, _)| cx <= gx + 1);
if lo == hi {
return Some((out.x0, out.x0));
}
Some((columns.get(lo)?.1, columns.get(hi - 1)?.2))
})
.collect()
}
pub(super) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Option<Vec<f32>> {
let cell = cell_size.max(2.0);
// This path uses integer pixel centres and conservative one-pixel support
// margins. Beyond exact half-integer f32 coordinates use the scalar kernel.
if cell > 4096.0 || [out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > 4_000_000) {
return None;
}
let grid = Grid::new(src, out, ctx, cell, seed, true)?;
pointillize_grid(src, out, ctx, cell, background, &grid)
}
fn pointillize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, background: [f32; 4], grid: &Grid) -> Option<Vec<f32>> {
let origin = (ctx.bounds.x0 as f32, ctx.bounds.y0 as f32);
let bg = native(ctx, background);
let n = src.ch;
let mut result = if src.rect == out { src.data.clone() } else { src.crop(out) };
let columns = influence_columns(out, origin.0, cell, grid)?;
let base_runs = runs(out, origin.0, cell);
let width = out.width() as usize;
let stride = width.checked_mul(n)?;
let mut depths = vec![-0.5f32; width];
let mut owners = vec![u32::MAX; width];
for (row, pixels) in result.chunks_exact_mut(stride).enumerate() {
let y = out.y0.checked_add(i32::try_from(row).ok()?)?;
let fy = y as f32 + 0.5;
let cy = key(y, origin.1, cell);
depths.fill(-0.5);
owners.fill(u32::MAX);
if cell >= 32.0 && out.width() <= 2048 {
for &(cx, x0, x1) in &base_runs {
let ids = grid.neighbours(cx, cy)?;
let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?;
certified_span::<true>(grid, &ids, x0, y, owners.get_mut(start..end)?, depths.get_mut(start..end)?)?;
}
} else {
for gy in cy.checked_sub(1)?..=cy.checked_add(1)? {
let row = usize::try_from(gy.checked_sub(grid.y0)?).ok()?.checked_mul(grid.width)?;
// Global gy/gx order is the original dy/dx tie order at every pixel.
for (column, &(x0, x1)) in columns.iter().enumerate() {
if x0 >= x1 {
continue;
}
let i = row.checked_add(column)?;
let site = grid.sites.get(i)?;
let dy2 = (site.point.1 - fy).powi(2);
let reach = f64::from(site.radius) + 1.5;
let square = reach * reach - f64::from(dy2);
if square < 0.0 {
continue;
}
// The extra pixel covers subtraction/square/sqrt rounding for
// the bounded coordinates/radii above. The exact old depth
// comparison still decides every candidate that survives.
let reach_x = square.max(0.0).sqrt() + 1.0;
let lo = ((f64::from(site.point.0) - 0.5 - reach_x).floor() as i32).max(x0);
let hi = ((f64::from(site.point.0) - 0.5 + reach_x).ceil() as i32).min(x1);
if lo >= hi {
continue;
}
let start = usize::try_from(lo.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(hi.checked_sub(out.x0)?).ok()?;
for (offset, (depth, owner)) in depths.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() {
let fx = (lo + offset as i32) as f32 + 0.5;
let d = ((site.point.0 - fx).powi(2) + dy2).sqrt();
let candidate = site.radius - d;
if candidate > *depth {
*depth = candidate;
*owner = i as u32;
}
}
}
}
}
for ((pixel, &depth), &owner) in pixels.chunks_exact_mut(n).zip(&depths).zip(&owners) {
let a_orig = if ctx.alpha { *pixel.get(n - 1)? } else { 1.0 };
let mut canvas = bg;
if ctx.alpha {
*canvas.get_mut(n - 1)? = a_orig.max(bg.get(n - 1)?.min(1.0));
}
if owner != u32::MAX {
let i = owner as usize;
let k = (depth + 0.5).clamp(0.0, 1.0);
let dot = grid.sites.get(i)?;
for (c, value) in canvas.iter_mut().enumerate().take(n) {
*value += (*dot.color.get(c)? - *value) * k;
}
}
pixel.copy_from_slice(canvas.get(..n)?);
}
}
Some(result)
}
/// The filters sample cell centres, not a dense neighbourhood. On ordinary
/// tiles read those sparse samples directly; keep only the original output
/// pixels for alpha and selection mixing. Crystallize boundary tiles still
/// use the dense path, whose row-order f32 averages are part of its result.
#[allow(clippy::too_many_arguments)]
pub(crate) fn tile(
surface: &photocraft_raster::Surface,
out: Rect,
ctx: &Ctx,
cell: f32,
seed: u32,
background: Option<[f32; 4]>,
extent: Option<Rect>,
) -> Option<(Image, Vec<f32>)> {
if !cell.is_finite()
|| !(2.0..=300.0).contains(&cell)
|| out.is_empty()
|| out.width() > 2048
|| out.height() > 2048
|| [out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > 4_000_000)
|| extent.is_some_and(|e| e.is_empty() || out.intersect(&e) != out)
{
return None;
}
let dots = background.is_some();
let grid = Grid::new_with(out, ctx, surface.channels(), cell, seed, dots, |sx, sy, color| {
let (x, y) = match extent {
Some(e) => (sx.clamp(e.x0, e.x1 - 1), sy.clamp(e.y0, e.y1 - 1)),
None => (sx, sy),
};
surface.read_pixel(x, y, color);
ctx.bounds.contains(sx, sy)
})?;
if !dots && grid.sites.iter().any(|s| !s.sampled) {
return None;
}
let original = Image::read(surface, out);
let data = match background {
Some(bg) => pointillize_grid(&original, out, ctx, cell, bg, &grid)?,
None => crystallize_grid(&original, out, ctx, cell, &grid)?,
};
Some((original, data))
}
/// Same rounding and native byte order as Surface::write_region, before the
/// worker hands its result back. Bounds the allocation and retains float data
/// if an optional encode buffer cannot be reserved.
pub(crate) fn encode(format: photocraft_color::PixelFormat, samples: &[f32]) -> Option<Vec<u8>> {
use photocraft_color::SampleType;
let mut bytes = Vec::new();
let len = samples.len().checked_mul(format.sample.bytes())?;
bytes.try_reserve_exact(len).ok()?;
bytes.resize(len, 0);
match format.sample {
SampleType::U8 => {
for (dst, value) in bytes.iter_mut().zip(samples) {
*dst = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
}
SampleType::U16 => {
for (dst, value) in bytes.as_chunks_mut::<2>().0.iter_mut().zip(samples) {
dst.copy_from_slice(&((value.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).to_ne_bytes());
}
}
SampleType::F32 => {
for (dst, value) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(samples) {
dst.copy_from_slice(&value.to_ne_bytes());
}
}
}
Some(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
#[test]
fn span_certificates_keep_discrete_winners_and_antialias_coverage() {
for origin in [-16_777_210, -3_999_000, -37, 0, 3_999_000, 16_777_210] {
let out = Rect::new(origin, -3, origin + 601, 4);
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
let src = Image::new(out, 4);
for cell in [32.0, 33.25, 100.0, 300.0, 4096.0] {
for seed in 0..32 {
let grid = Grid::new(&src, out, &ctx, cell, seed, true).unwrap();
for y in out.y0..out.y1 {
let cy = key(y, ctx.bounds.y0 as f32, cell);
for (cx, x0, x1) in runs(out, ctx.bounds.x0 as f32, cell) {
let ids = grid.neighbours(cx, cy).unwrap();
for dots in [false, true] {
let mut owners = vec![u32::MAX; (x1 - x0) as usize];
let mut depths = vec![-0.5; owners.len()];
if dots {
certified_span::<true>(&grid, &ids, x0, y, &mut owners, &mut depths).unwrap();
} else {
certified_span::<false>(&grid, &ids, x0, y, &mut owners, &mut depths).unwrap();
}
for (offset, (&owner, &depth)) in owners.iter().zip(&depths).enumerate() {
let x = x0 + offset as i32;
let mut best = (if dots { -0.5 } else { -f32::MAX }, u32::MAX);
for &i in &ids {
let value = if dots { score::<true>(&grid.sites[i], x, y) } else { score::<false>(&grid.sites[i], x, y) };
if value > best.0 {
best = (value, i as u32);
}
}
assert_eq!(owner, best.1, "x={x} y={y} cell={cell} seed={seed}");
if dots && owner != u32::MAX {
assert_eq!((depth + 0.5).clamp(0.0, 1.0).to_bits(), (best.0 + 0.5).clamp(0.0, 1.0).to_bits());
}
}
}
}
}
}
}
}
}
#[test]
fn worker_encoding_is_surface_write_region_at_every_depth() {
let values = [f32::NAN, f32::NEG_INFINITY, -100.0, -0.0, f32::from_bits(1), 0.5 / 65535.0, 0.5 / 255.0, 0.5, 1.0, 1.1, f32::INFINITY];
let area = Rect::new(-17, -9, 281, 2);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let format = PixelFormat::new(mode, sample, alpha);
let data: Vec<_> = (0..area.width() as usize * area.height() as usize * format.channels()).map(|i| values[i % values.len()]).collect();
let mut surface = Surface::new(format);
surface.write_region(area, &data);
assert_eq!(encode(format, &data).unwrap(), surface.to_interleaved(area));
}
}
}
}
#[test]
fn optional_grid_rejects_unbounded_or_incomplete_scratch() {
let area = Rect::new(0, 0, 100_000, 100_000);
let ctx = Ctx { bounds: area, mode: ColorMode::Rgb, alpha: true };
let image = Image { rect: area, ch: 4, data: Vec::new() };
assert!(Grid::new(&image, area, &ctx, 2.0, 0, true).is_none());
assert!(Grid::new_with(area, &ctx, 4, 2.0, 0, true, |_, _, _| false).is_none());
assert!(Grid::new_with(area, &ctx, 0, 2.0, 0, true, |_, _, _| false).is_none());
assert!(Grid::new_with(area, &ctx, MAXC + 1, 2.0, 0, true, |_, _, _| false).is_none());
assert!(Grid::new_with(area, &ctx, 4, f32::INFINITY, 0, true, |_, _, _| false).is_none());
}
}
+76
View File
@@ -191,3 +191,79 @@ fn facet_scratch_rejects_large_or_incomplete_storage() {
let src = Image { rect: out.inflate(3), ch: 4, data: Vec::new() };
assert!(facet::run(&src, out, &ctx).is_none());
}
#[test]
fn cell_filters_match_scalar_models_edges_and_fractional_cells() {
let bounds = Rect::new(-19, -13, 32, 29);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Multichannel] {
for alpha in [false, true] {
let ctx = Ctx { bounds, mode, alpha };
for pattern in 0..5 {
for halo in [0, 7] {
let src = fixture(bounds.inflate(halo), &ctx, pattern);
for out in [bounds, Rect::new(-23, -15, 37, 31), Rect::new(-7, 2, 18, 13)] {
for cell in [2.0, 3.75, 10.0, 100.0, 300.0] {
for seed in [0, 37, u32::MAX] {
let got = cells::crystallize(&src, out, &ctx, cell, seed).unwrap();
assert_bits(&got, &crystallize_direct(&src, out, &ctx, cell, seed));
for bg in [[1.0; 4], [0.1, 0.8, 0.4, 0.25]] {
let got = cells::pointillize(&src, out, &ctx, cell, seed, bg).unwrap();
assert_bits(&got, &pointillize_direct(&src, out, &ctx, cell, seed, bg));
}
}
}
}
}
}
}
}
}
#[test]
fn cell_filters_tiled_storage_matches_scalar_with_soft_selection() {
use crate::{FilterParams, apply_tiled};
let bounds = Rect::new(-17, -9, 86, 78);
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 ctx = Ctx { bounds, mode, alpha };
let src = fixture(bounds, &ctx, 2);
let mut surface = Surface::new(PixelFormat::new(mode, depth, alpha));
surface.write_region(bounds, &src.data);
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false));
let coverage: Vec<_> = (0..bounds.width() * bounds.height()).map(|i| (i % 7) as f32 / 6.0).collect();
selection.write_region(bounds, &coverage);
for params in [
FilterParams::Crystallize { cell_size: 3.75, seed: 37 },
FilterParams::Crystallize { cell_size: 10.0, seed: 37 },
FilterParams::Pointillize { cell_size: 3.75, seed: 37, background: [1.0; 4] },
FilterParams::Pointillize { cell_size: 10.0, seed: 37, background: [0.1, 0.8, 0.4, 0.25] },
] {
for extent in [None, Some(bounds)] {
assert!(matches!(params.halo(), crate::Halo::Radius(_)));
let halo = match params.halo() {
crate::Halo::Radius(r) => r,
_ => 0,
};
let image = match extent {
Some(e) => Image::read_clamped(&surface, bounds.inflate(halo), e),
None => Image::read(&surface, bounds.inflate(halo)),
};
let mut expected_data = match params {
FilterParams::Crystallize { cell_size, seed } => crystallize_direct(&image, bounds, &ctx, cell_size, seed),
FilterParams::Pointillize { cell_size, seed, background } => pointillize_direct(&image, bounds, &ctx, cell_size, seed, background),
_ => Vec::new(),
};
crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha);
let mut expected = surface.clone();
expected.write_region(bounds, &expected_data);
for tile in [1, 7, 17, 256] {
let got = apply_tiled(&surface, &params, bounds, bounds, Some(&selection), tile, extent);
assert_bits(&got.read_region(bounds), &expected.read_region(bounds));
}
}
}
}
}
}
}
+63 -11
View File
@@ -359,19 +359,18 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect:
});
let seam_count = w - new_w;
let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, true);
let mut removed = Vec::with_capacity(seam_count);
// Removing a seam shifts the panel left, so a panel column is not an original one. Tracking
// the original column per pixel costs a third buffer that every seam has to shift (24 MB a
// seam at 24 MP); counting the columns already taken from that row is exact and costs a
// binary search per removed column.
let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, false);
let mut removed_by_row: Vec<Vec<usize>> = vec![Vec::new(); proxy_h];
let mut taken = 0usize;
while carver.cw > new_w {
ctl.check()?;
removed.push(carver.remove_seam());
ctl.progress(removed.len() as f32 / seam_count as f32);
}
let mut removed_by_row = Vec::with_capacity(proxy_h);
for proxy_y in 0..proxy_h {
let mut columns: Vec<usize> = removed.iter().map(|path| path[proxy_y]).collect();
columns.sort_unstable();
removed_by_row.push(columns);
record_removed(&mut removed_by_row, &carver.remove_seam());
taken += 1;
ctl.progress(taken as f32 / seam_count as f32);
}
let mut out = Vec::with_capacity(new_w * h * ch);
let mut columns = vec![0; seam_count];
@@ -403,6 +402,32 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect:
Ok(out)
}
/// Add one seam, given in panel columns, to each row's sorted list of removed original columns.
/// For the `i`-th column already taken from a row, `o - i` is the panel column it occupied, and
/// that never decreases along the list, so the taken columns at or left of panel column `x` are a
/// prefix found by binary search. `x` plus that prefix's length is the original column, and every
/// removal the search passed is left of it, so it inserts where the search stopped. The rows are
/// independent, so they update in parallel like the pixel shift.
fn record_removed(removed_by_row: &mut [Vec<usize>], seam: &[usize]) {
// The entries are the original columns still taken from one row, sorted, so entry `i` is at
// least `i` and `o - i` cannot underflow.
let add = |(row, &x): (&mut Vec<usize>, &usize)| {
let (mut lo, mut hi) = (0, row.len());
while lo < hi {
let mid = lo + (hi - lo) / 2;
if row[mid] - mid <= x { lo = mid + 1 } else { hi = mid }
}
row.insert(lo, x + lo);
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
removed_by_row.par_iter_mut().zip(seam.par_iter()).for_each(add);
}
#[cfg(target_arch = "wasm32")]
removed_by_row.iter_mut().zip(seam.iter()).for_each(add);
}
/// Content-aware resize of `img` to `new_w × new_h` (width first, then height by transposing).
pub fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec<f32> {
// Never cancelled; the fallback is unreachable.
@@ -761,6 +786,33 @@ mod tests {
assert_eq!(carve_width_with(w, h, ch, &img, None, 64, &Interrupt::cancel_only(&cancelled)), Err(Cancelled));
}
/// The proxy path counts each removed original column instead of shifting an index map, so a
/// tracking and a counting carver must agree: run both side by side down to one column and
/// check every row's list after every seam (tie-heavy noise gives rows different histories;
/// 1-4 channels, with and without protection).
#[test]
fn counted_removed_columns_match_the_tracked_map() {
let cases: [(usize, usize, usize, u32, bool); 5] =
[(37, 9, 1, 2, false), (40, 12, 2, 3, true), (53, 7, 3, 255, false), (29, 16, 4, 1000, true), (64, 5, 3, 2, true)];
for (i, &(w, h, ch, levels, prot)) in cases.iter().enumerate() {
let img = noise(w * h * ch, levels, 31 + i as u32);
let protect = prot.then(|| noise(w * h, 3, 77 + i as u32));
let mut tracked = Carver::new(w, h, ch, img.clone(), protect.clone(), true);
let mut counted = Carver::new(w, h, ch, img, protect, false);
let mut expected: Vec<Vec<usize>> = vec![Vec::new(); h];
let mut got: Vec<Vec<usize>> = vec![Vec::new(); h];
while tracked.cw > 1 {
let seam = tracked.remove_seam();
for (row, &x) in expected.iter_mut().zip(seam.iter()) {
let at = row.partition_point(|&o| o < x);
row.insert(at, x);
}
record_removed(&mut got, &counted.remove_seam());
assert_eq!(got, expected, "case {i} after {} seams", w - tracked.cw);
}
}
}
#[test]
fn enlarging_duplicates_flat_areas() {
let (w, h) = (30, 8);
+1
View File
@@ -25,6 +25,7 @@ base64 = "0.22"
schemars = "1"
getrandom = { workspace = true }
cap-std = "4"
clap = { workspace = true }
[dev-dependencies]
# Synthetic camera raws (#7789).
+123
View File
@@ -0,0 +1,123 @@
//! The control and automation launch options, as clap types: `photocraft-cli mcp` and `serve`
//! flatten them, and so does the desktop app (#7071), so each flag and its environment variable
//! are declared once. A flag wins over its variable.
//!
//! Parse with [`blank_env_is_unset`] applied to the arguments, so an empty variable counts as unset,
//! and pass a parse error through [`name_env_vars`], so a bad value names the variable it came from.
//! Check the token pair with [`conflict`] only where a token is used, so a stale exported pair
//! doesn't stop a launch that needs none.
use std::ffi::OsString;
use std::path::PathBuf;
use clap::builder::NonEmptyStringValueParser;
use clap::error::{ContextKind, ContextValue, ErrorKind};
use clap::parser::ValueSource;
use clap::{Arg, ArgMatches, Args, Command};
use crate::{AuthorizedWorkspace, AutomationError};
/// The control token, for a server or for a client of one.
#[derive(Args)]
pub struct ControlArgs {
/// The control token (64 hex digits)
#[arg(long, value_name = "64-HEX", env = "PHOTOCRAFT_CONTROL_TOKEN", hide_env_values = true, value_parser = NonEmptyStringValueParser::new())]
pub control_token: Option<String>,
/// A file holding the control token, used instead of --control-token (a server creates it
/// when missing)
#[arg(long, value_name = "PATH", env = "PHOTOCRAFT_CONTROL_TOKEN_FILE")]
pub control_token_file: Option<PathBuf>,
}
/// The folders automation may touch. Without a root, that direction of access is denied. No
/// variable stands in for them here: one exported for the desktop app must not widen headless
/// `photocraft-cli` access (#1551); the desktop app adds its own.
#[derive(Args)]
pub struct AutomationArgs {
/// The folder automation may read from (none without it)
#[arg(long, value_name = "DIR")]
pub automation_read_root: Option<PathBuf>,
/// The folder automation may write to (none without it)
#[arg(long, value_name = "DIR")]
pub automation_write_root: Option<PathBuf>,
}
impl AutomationArgs {
/// The roots, opened as capabilities.
pub fn workspace(&self) -> Result<AuthorizedWorkspace, AutomationError> {
AuthorizedWorkspace::new(self.automation_read_root.as_deref(), self.automation_write_root.as_deref())
}
}
/// A control port, for clap's `value_parser`: `photocraft --control`, `photocraft-cli serve --port`
/// and PHOTOCRAFT_CONTROL_PORT, which may carry spaces.
pub fn control_port(s: &str) -> Result<u16, NotAPort> {
s.trim().parse().map_err(|_| NotAPort)
}
/// A [`control_port`] value that isn't a port.
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
#[error("expected a port, 0-65535")]
pub struct NotAPort;
/// For `Command::mut_args`: an empty or blank variable counts as unset, so `PHOTOCRAFT_CONTROL_PORT=`
/// turns a fallback off without `unset` (#2491). clap would pass the empty value to the parser.
pub fn blank_env_is_unset(arg: Arg) -> Arg {
let blank = arg.get_env().and_then(std::env::var_os).is_some_and(|v| v.to_str().is_some_and(|v| v.trim().is_empty()));
if blank { arg.env(None::<&str>) } else { arg }
}
/// clap names the flag when a value from its variable is bad or conflicts with another, as if the
/// flag had been given: name the variable instead when the flag isn't among `args` (without the
/// binary name; a first one naming a subcommand selects whose arguments to look in) (#2491).
pub fn name_env_vars(mut error: clap::Error, cmd: &Command, args: &[OsString]) -> clap::Error {
// `--flag value` or `--flag=value`.
let given = |long: &str| args.iter().filter_map(|a| a.to_str()?.strip_prefix("--")?.strip_prefix(long)).any(|r| r.is_empty() || r.starts_with('='));
let sub = args.first().and_then(|name| cmd.find_subcommand(name)).unwrap_or(cmd);
for kind in [ContextKind::InvalidArg, ContextKind::PriorArg] {
let Some(ContextValue::String(shown)) = error.get(kind) else { continue };
let Some(arg) = sub.get_arguments().find(|a| a.to_string() == *shown) else { continue };
let (Some(long), Some(var)) = (arg.get_long(), arg.get_env()) else { continue };
if !given(long) {
error.insert(kind, ContextValue::String(var.to_string_lossy().into_owned()));
}
}
error
}
/// clap's either/or usage error when `m`, parsed by `cmd`, has both `a` and `b`, each named as it was
/// given: the flag, or its variable. For a conflict that depends on the mode, such as the token pair
/// (only a server or bridge reads a token), which clap's `conflicts_with` can't express.
pub fn conflict(cmd: &mut Command, m: &ArgMatches, a: &str, b: &str) -> Result<(), clap::Error> {
if m.value_source(a).is_none() || m.value_source(b).is_none() {
return Ok(());
}
let shown = |id: &str| {
let arg = cmd.get_arguments().find(|arg| arg.get_id() == id);
match (m.value_source(id), arg.and_then(Arg::get_env)) {
(Some(ValueSource::EnvVariable), Some(var)) => var.to_string_lossy().into_owned(),
_ => arg.map_or_else(|| id.to_owned(), |arg| arg.to_string()),
}
};
let message = format!("the argument '{}' cannot be used with '{}'", shown(a), shown(b));
Err(cmd.error(ErrorKind::ArgumentConflict, message))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_port_accepts_valid_numbers() {
assert_eq!(control_port("50494"), Ok(50494));
assert_eq!(control_port(" 8080 "), Ok(8080));
assert_eq!(control_port("0"), Ok(0));
}
#[test]
fn control_port_rejects_anything_else() {
for bad in ["78787", "-1", "", "x"] {
assert_eq!(control_port(bad), Err(NotAPort), "{bad:?}");
}
}
}
+1
View File
@@ -16,6 +16,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod args;
pub mod bridge;
pub mod budgets;
pub mod files;
+7 -4
View File
@@ -1048,13 +1048,16 @@ mod tests {
fn workspace_psd_saves_embed_no_file_a_linked_smart_object_names() {
use photocraft_engine::doc::{LayerContent, SmartSource};
const MARKER: &[u8] = b"PHOTOCRAFT-LINKED-0001";
let has_marker = |bytes: &[u8]| bytes.windows(MARKER.len()).any(|w| w == MARKER);
// An image PhotoCraft can open (other linked files are never embedded): an embedded copy
// carries these exact bytes, while pixels written for the layer don't.
let image = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, (0..192u8).collect()).unwrap();
let marker = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
let has_marker = |bytes: &[u8]| bytes.windows(marker.len()).any(|w| w == marker.as_slice());
let base = std::env::temp_dir().join(format!("pc-psd-linked-embed-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let outside = base.join("outside.txt");
std::fs::write(&outside, MARKER).unwrap();
let outside = base.join("outside.png");
std::fs::write(&outside, &marker).unwrap();
// A document whose smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
+1 -8
View File
@@ -2,7 +2,7 @@
use std::fs::OpenOptions;
use std::io::{BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
@@ -308,13 +308,6 @@ impl Read for AuthDeadlineStream {
}
}
/// Environment-aware token inputs shared by the desktop app and CLI.
pub fn token_inputs(supplied: Option<String>, token_file: Option<PathBuf>) -> (Option<String>, Option<PathBuf>) {
let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok());
let token_file = token_file.or_else(|| std::env::var_os("PHOTOCRAFT_CONTROL_TOKEN_FILE").map(PathBuf::from));
(supplied, token_file)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -5,3 +5,5 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc a795914e4492bef1e662315563d48d54f09030c48e046df95329465c86dde4b4 # shrinks to s = Spec { w: 4, h: 6, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [1.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 1.0105422, 0.0, 0.7] }), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }] }
cc e14b02709a87770da2637c28678c4fa761b5628f402e0c6d60fe66f1aa4d4bfa # shrinks to s = Spec { w: 3, h: 2, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Group { children: [Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] }), blend: Dissolve, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.8972109, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] }
cc 4eeba1f8db987228717b932b82d043d61e82fd897a33ea9e2ce68d6c61d7f491 # shrinks to s = Spec { w: 3, h: 5, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 0.026626235, clipped: false, visible: true, mask: None }, Pixels { kind: Adjust(Invert), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] }
+30 -6
View File
@@ -402,12 +402,24 @@ fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) {
/// mask; coverage is unchanged (the compositor's adjustment mix).
fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) {
let opacity = l.opacity * l.fill_opacity;
let mv = mask_vals(l, base.rect, cx);
let rect = base.rect;
let w = (rect.width() as usize).max(1);
let mv = mask_vals(l, rect, cx);
for (i, (p, a)) in base.px.iter_mut().zip(&adjusted.px).enumerate() {
let k = opacity * mask_at(&mv, i);
if k <= 0.0 {
continue;
}
if l.blend == BlendMode::Dissolve {
// As on the RGB compositor (#3050): opacity and mask are the chance a pixel takes the
// adjustment fully, by the same noise.
let x = rect.x0.saturating_add((i % w) as i32);
let y = rect.y0.saturating_add((i / w) as i32);
if super::dissolve_noise(x, y) < k {
*p = [a[0], a[1], a[2], a[3], p[4]];
}
continue;
}
let bl = blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]);
for (v, b) in p.iter_mut().zip(bl) {
*v = if k >= 1.0 { b } else { *v + (b - *v) * k };
@@ -416,10 +428,10 @@ fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ct
}
/// Rounds to the document's levels after an adjustment layer, as the compositor does (on the
/// channel brightness the document stores, and coverage).
fn quantize_inks(buf: &mut CmykBuffer, q: f32) {
/// channel brightness the document stores, and coverage). Also used by [`super::native`].
pub(crate) fn quantize_inks(px: &mut [Cmyka], q: f32) {
let round = |v: f32| (v * q + 0.5).floor() / q;
for p in &mut buf.px {
for p in px {
for v in p.iter_mut().take(4) {
// Whole levels of ink: exactly the stored values, rounding ties as on brightness.
*v = (q - ((1.0 - *v) * q + 0.5).floor()) / q;
@@ -441,11 +453,23 @@ fn composite_clipped(clipped: &[Layer], base: &mut CmykBuffer, cx: &Ctx) {
let content = layer_content(c, base.rect, cx);
let opacity = c.opacity * c.fill_opacity;
let gamma = super::text_gamma(c);
for (p, s) in base.px.iter_mut().zip(&content.px) {
let rect = base.rect;
let w = (rect.width() as usize).max(1);
for (i, (p, s)) in base.px.iter_mut().zip(&content.px).enumerate() {
let alpha = p[4];
if alpha <= 0.0 {
continue;
}
if c.blend == BlendMode::Dissolve {
// As on the RGB compositor (#3052): the base keeps its alpha and each pixel takes
// the clipped layer fully or not at all, by the same noise.
let x = rect.x0.saturating_add((i % w) as i32);
let y = rect.y0.saturating_add((i / w) as i32);
if super::dissolve_noise(x, y) < s[4] * opacity {
*p = [s[0], s[1], s[2], s[3], alpha];
}
continue;
}
let r = composite_ink(c.blend, [p[0], p[1], p[2], p[3], 1.0], *s, opacity, gamma);
*p = [r[0], r[1], r[2], r[3], alpha];
}
@@ -477,7 +501,7 @@ fn composite_stack(layers: &[Layer], backdrop: &mut CmykBuffer, cx: &Ctx) {
if base.visible {
composite_layer(base, clipped, backdrop, cx);
if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, super::adjustment_quantum(cx.depth)) {
quantize_inks(backdrop, q);
quantize_inks(&mut backdrop.px, q);
}
}
}
+102 -1
View File
@@ -219,6 +219,10 @@ fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], p: Cmyka, x: i32
a = ref_clip(clipped, a, x, y);
}
let k = l.opacity * l.fill_opacity * ref_mask(l, x, y);
if l.blend == BlendMode::Dissolve {
// Opacity and mask are the chance the pixel takes the adjustment fully.
return if k > 0.0 && crate::dissolve_noise(x, y) < k { [a[0], a[1], a[2], a[3], p[4]] } else { p };
}
let mut o = p;
for c in 0..4 {
o[c] = p[c] + (ref_blend(l.blend, p[c], a[c]) - p[c]) * k;
@@ -233,7 +237,7 @@ fn ref_clip(clipped: &[Layer], mut content: Cmyka, x: i32, y: i32) -> Cmyka {
content = ref_adjusted(c, adj, &[], content, x, y);
} else if content[4] > 0.0 {
let a = content[4];
let r = over_layer(c, [content[0], content[1], content[2], content[3], 1.0], ref_own(c, x, y), c.opacity * c.fill_opacity, None);
let r = over_layer(c, [content[0], content[1], content[2], content[3], 1.0], ref_own(c, x, y), c.opacity * c.fill_opacity, Some((x, y)));
content = [r[0], r[1], r[2], r[3], a];
}
}
@@ -1697,3 +1701,100 @@ fn rgb_colours_become_ink_through_the_document_profile() {
assert!(!supports(&overlay) && split::supports(&overlay));
check(&overlay, "colour overlay");
}
/// The split route and the exact path give the same inks for `s`, alpha included.
fn assert_split_matches_exact(s: &Spec) {
let mut d = doc(s.w as u32, s.h as u32);
d.layers = s.layers.iter().map(build).collect();
assert!(supports(&d) && split::supports(&d));
let via = split_render(&d);
let exact = render_cmyk(&d, via.rect).unwrap_or_else(|| panic!("supported"));
for (i, (g, w)) in via.px.iter().zip(&exact.px).enumerate() {
assert!((0..5).all(|c| (g[c] - w[c]).abs() <= 1e-5), "pixel {i}: {g:?} vs {w:?}");
}
}
fn raster_spec(blend: BlendMode, fill: (Rect, Cmyka), opacity: f32, clipped: bool) -> LayerSpec {
LayerSpec::Pixels { kind: Kind::Raster, blend, fills: vec![fill], opacity, fill: 1.0, clipped, visible: true, mask: None }
}
fn group_spec(children: Vec<LayerSpec>, pass_through: bool) -> LayerSpec {
LayerSpec::Group { children, pass_through, blend: BlendMode::Normal, opacity: 1.0, fill: 1.0, mask: None }
}
/// A clipped Dissolve layer shows each pixel fully or not at all on the exact path too, as on the
/// RGB compositor (#9033; it used to mix like Normal: K 0.81 where the noise hides the layer).
#[test]
fn a_clipped_dissolve_layer_dissolves_on_the_exact_path() {
let s = Spec {
w: 4,
h: 5,
layers: vec![group_spec(
vec![
raster_spec(BlendMode::Normal, (Rect::new(0, 0, 2, 1), [0.0, 0.0, 0.0, 1.0, 1.0]), 1.0, false),
raster_spec(BlendMode::Dissolve, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 0.189_737_02, true),
],
false,
)],
};
assert_split_matches_exact(&s);
}
/// A Dissolve adjustment layer takes each pixel fully or not at all on the exact path too (#9033;
/// it used to mix by opacity: cyan 0.267 where the noise picks the adjusted 0.298).
#[test]
fn a_dissolve_adjustment_dissolves_on_the_exact_path() {
let levels = AdjSpec::Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] };
let adjust = LayerSpec::Pixels {
kind: Kind::Adjust(levels),
blend: BlendMode::Dissolve,
fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])],
opacity: 0.897_210_9,
fill: 1.0,
clipped: false,
visible: true,
mask: None,
};
let s = Spec {
w: 3,
h: 2,
layers: vec![
group_spec(vec![raster_spec(BlendMode::Normal, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 1.0, false)], false),
group_spec(vec![adjust], true),
],
};
assert_split_matches_exact(&s);
}
/// After an adjustment layer, the display route ([`crate::native`]) rounds to the document's
/// levels like the export path and the RGB compositor, at any opacity (#9033: in a pass-through
/// group its coverage stayed at 41.32% where the others store 105/255).
#[test]
fn every_cmyk_route_rounds_after_an_adjustment_layer() {
for opacity in [0.0, 0.5] {
let adjust = LayerSpec::Pixels {
kind: Kind::Adjust(AdjSpec::Invert),
blend: BlendMode::Normal,
fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])],
opacity,
fill: 1.0,
clipped: false,
visible: true,
mask: None,
};
let s = Spec {
w: 8,
h: 5,
layers: vec![group_spec(vec![raster_spec(BlendMode::Normal, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 1.0, false), adjust], true)],
};
let mut d = doc(s.w as u32, s.h as u32);
d.layers = s.layers.iter().map(build).collect();
if let LayerContent::Group(g) = &mut d.layers[0].content {
g.children[0].fill_opacity = 0.413_248_66;
}
let rect = Rect::new(0, 0, 1, 1);
let display = crate::render(&d, rect).px[0][3];
let export = render_cmyk(&d, rect).unwrap_or_else(|| panic!("supported")).px[0][4];
assert_eq!((display, export), (105.0 / 255.0, 105.0 / 255.0), "opacity {opacity}");
}
}
+9
View File
@@ -214,7 +214,13 @@ impl<'a> InkStack<'a> {
}
}
Item::Adjust(op, opacity, mask) => {
// The result is rounded to the document's levels, as the compositor and the
// export path do after every adjustment layer, at any opacity (#9033).
let quantum = crate::adjustment_quantum(self.depth);
if *opacity <= 0.0 {
if let Some(q) = quantum {
crate::cmyk::quantize_inks(acc, q);
}
continue;
}
masked(*mask, mv);
@@ -228,6 +234,9 @@ impl<'a> InkStack<'a> {
d[c] += (v - d[c]) * mix;
}
}
if let Some(q) = quantum {
crate::cmyk::quantize_inks(acc, q);
}
}
}
}
@@ -0,0 +1,99 @@
//! Time the actual menu command on a 24 MP layer, including history commit.
//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- crystallize 10 5 8
//! Arguments: crystallize|pointillize, cell size, repeats (1..9), depth (8|16|32), layer|background.
//! Run the same example at the base and changed revisions; compare the output hashes.
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_engine::Session;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::json;
use std::time::Instant;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
let case = args.first().map(String::as_str).unwrap_or("crystallize");
let cell: f32 = args.get(1).map(|v| v.parse()).transpose()?.unwrap_or(10.0);
let reps: usize = args.get(2).map(|v| v.parse()).transpose()?.unwrap_or(5);
let depth: u32 = args.get(3).map(|v| v.parse()).transpose()?.unwrap_or(8);
if !matches!(case, "crystallize" | "pointillize") || !(3.0..=300.0).contains(&cell) || !(1..=9).contains(&reps) {
return Err("case, cell size or repeat count out of range".into());
}
let kind = args.get(4).map(String::as_str).unwrap_or("layer");
if !matches!(kind, "layer" | "background") {
return Err("kind must be layer or background".into());
}
let (w, h) = (6000, 4000);
let bounds = Rect::new(0, 0, w, h);
let sample = match depth {
8 => SampleType::U8,
16 => SampleType::U16,
32 => SampleType::F32,
_ => return Err("invalid depth".into()),
};
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
for tile in bounds.tiles() {
let r = tile.rect().intersect(&bounds);
let mut data = Vec::new();
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let seed = (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b);
for c in 0..4 {
let value = seed.rotate_left(c * 7).wrapping_add(c * 71) % 65536;
data.push(if c == 3 && kind == "background" {
1.0
} else if c == 3 && seed.is_multiple_of(5) {
0.0
} else {
value as f32 / 65535.0
});
}
}
}
source.write_region(r, &data);
}
let id = format!("filter.pixelate.{case}");
let mut times = Vec::new();
let hash = |surface: &Surface| {
let mut hash = blake3::Hasher::new();
for tile in bounds.tiles() {
hash.update(&surface.to_interleaved(tile.rect().intersect(&bounds)));
}
hash.finalize()
};
let source_hash = hash(&source);
let mut output_hash = None;
for run in 0..reps {
let mut s = Session::new();
s.execute("file.new", json!({"width":w,"height":h,"depth":depth,"background":if kind == "background" { "white" } else { "transparent" }}))?;
s.edit("fixture", |doc, _| {
let layer = doc.layers.first_mut().ok_or(photocraft_engine::EngineError::Other("no layer".into()))?;
let surface = layer.surface_mut().ok_or(photocraft_engine::EngineError::Other("no surface".into()))?;
*surface = source.clone();
Ok(())
})?;
let start = Instant::now();
s.execute(&id, json!({"cellSize":cell,"seed":37,"background":[1.0,1.0,1.0,1.0]}))?;
let seconds = start.elapsed().as_secs_f64();
println!("COMMAND {case} cell={cell} depth={depth} kind={kind} run={} seconds={seconds:.6}", run + 1);
times.push(seconds);
if run + 1 == reps {
let doc = s.active().ok_or("no document")?;
let layer = doc.doc.layers.first().ok_or("no layer")?;
let surface = layer.surface().ok_or("no surface")?;
let filtered_hash = hash(surface);
s.execute("edit.undo", json!({}))?;
let restored = s.active().and_then(|d| d.doc.layers.first()).and_then(|l| l.surface()).ok_or("no undo surface")?;
if hash(restored) != source_hash {
return Err("undo changed source pixels".into());
}
s.execute("edit.redo", json!({}))?;
let redone = s.active().and_then(|d| d.doc.layers.first()).and_then(|l| l.surface()).ok_or("no redo surface")?;
if hash(redone) != filtered_hash {
return Err("redo changed filtered pixels".into());
}
output_hash = Some(filtered_hash);
}
}
times.sort_by(f64::total_cmp);
println!("MEDIAN {} HASH {}", times.get(times.len() / 2).ok_or("no runs")?, output_hash.ok_or("no output hash")?);
Ok(())
}
@@ -0,0 +1,87 @@
//! Release cost of a live sequential-dab stroke on a 24 MP layer, in release mode: the commit
//! rendering the stroke again (no offer) against taking the live pixels (`offer_live_dab`, which
//! runs the finishing dabs, plus the commit). Also the live stroke's per-frame cost while drawn.
use std::time::Instant;
use photocraft_engine::{EngineError, Session, paint::StrokePoint, retouch_cmds::LiveDab};
use photocraft_geom::Rect;
use serde_json::json;
const FRAMES: usize = 60;
fn ms(t: Instant) -> f64 {
t.elapsed().as_secs_f64() * 1e3
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut s = Session::new();
s.execute("file.new", json!({"width":6000,"height":4000,"depth":8,"mode":"rgb"}))?;
s.execute("layer.new.layer", json!({}))?;
s.edit("stripes", |doc, active| {
let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?;
let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?;
for x in (0..6000).step_by(64) {
let c = if x % 128 == 0 { [0.9, 0.3, 0.1, 1.0] } else { [0.1, 0.4, 0.8, 1.0] };
surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &c);
}
Ok(())
})?;
// ~1000 px/s at 60 fps: 16 px of drag per frame, in four samples.
let pts: Vec<[f64; 2]> = std::iter::once([1000.0, 2000.0])
.chain((1..=FRAMES).flat_map(|f| (1..=4).map(move |k| [1000.0 + (f as f64 - 1.0) * 16.0 + k as f64 * 4.0, 2000.0])))
.collect();
for cmd in ["paint.blur", "paint.smudge", "paint.dodge"] {
for size in [50, 150, 300, 600] {
let p = |v: &[[f64; 2]]| json!({"points": v, "size": size, "hardness": 0, "strength": 50, "spacing": 25});
let draw = |s: &Session| -> Result<(LiveDab, Vec<f64>), Box<dyn std::error::Error>> {
let mut live = LiveDab::begin(s, cmd, &p(&pts[..1]))?;
let mut frames = Vec::new();
for batch in pts[1..].chunks(4) {
let b: Vec<_> = batch.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect();
let t = Instant::now();
live.push(&b)?;
frames.push(ms(t));
}
Ok((live, frames))
};
let (mut before, mut after) = (Vec::new(), Vec::new());
let mut frames = Vec::new();
for _ in 0..3 {
let (_, f) = draw(&s)?;
frames.extend(f);
let t = Instant::now();
s.execute(cmd, p(&pts))?;
before.push(ms(t));
s.execute("edit.undo", json!({}))?;
let (live, _) = draw(&s)?;
let t = Instant::now();
s.offer_live_dab(live)?;
let r = s.execute(cmd, p(&pts))?;
after.push(ms(t));
if r.get("fromLive").is_none() {
return Err("the commit didn't take the live pixels".into());
}
s.execute("edit.undo", json!({}))?;
}
before.sort_by(f64::total_cmp);
after.sort_by(f64::total_cmp);
frames.sort_by(f64::total_cmp);
let total: f64 = frames.iter().sum::<f64>() / 3.0;
println!(
"{cmd:12} size={size:4} release: render again={:7.1} ms take live={:6.1} ms | live frames: max={:6.2} ms total={:7.1} ms",
before[1],
after[1],
frames.last().copied().unwrap_or(0.0),
total
);
}
}
Ok(())
}
fn main() {
if let Err(e) = run() {
eprintln!("{e}");
std::process::exit(1);
}
}
+14
View File
@@ -100,6 +100,12 @@ pub struct ActionState {
/// Number of steps executed by the current top-level playback tree.
#[serde(skip)]
pub playback_steps: u64,
/// How many scripts (`file.scripts.browse`) are running inside one another.
#[serde(skip)]
pub script_depth: u8,
/// Steps run by the current top-level script and everything it calls.
#[serde(skip)]
pub script_steps: u64,
/// Bumped whenever `list` changes, so the preset store can skip unchanged writes.
#[serde(skip)]
pub rev: u64,
@@ -150,6 +156,14 @@ pub fn save_target_closed(s: &Session, id: &str, target: Option<photocraft_doc::
/// Charge one step against the complete nested playback tree.
pub fn consume_playback_step(s: &mut Session) -> bool {
// Inside a script the step also counts against the script's total: each top-level playback
// resets its own counter, so a script playing actions in a row would never reach the limit.
if s.actions.script_depth > 0 {
if s.actions.script_steps >= crate::automate_cmds::MAX_SCRIPT_STEPS {
return false;
}
s.actions.script_steps += 1;
}
if s.actions.playback_steps >= MAX_PLAYBACK_STEPS {
false
} else {
+1 -1
View File
@@ -355,7 +355,7 @@ pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option<
} else {
let mode = surf.format().mode;
let adj = crate::adjust_params::from_params(kind, p, None, mode).ok()?;
crate::pixels::adjust_surface(&mut out, &adj, None, mode);
crate::pixels::adjust_surface(&mut out, &adj, None, mode).ok()?;
}
out.prune();
Some(out)
+14 -5
View File
@@ -1949,7 +1949,8 @@ fn conditional_from(d: &Descriptor) -> Mapped {
return Mapped::Unsupported { harmless: false };
}
let branch = |key: &str| match get_ref(d, key).map(target) {
Some(Target::Name(n)) => Some(n),
// Clipped like the stored action names, so the load-time rename map finds it.
Some(Target::Name(n)) => Some(clip_name(&n)),
_ => None,
};
step("actions.conditional", json!({"if": condition, "then": branch("then"), "else": branch("else")}))
@@ -2468,7 +2469,7 @@ fn apply_comp_to(_: &str, p: &Value) -> Option<Descriptor> {
fn play_from(d: &Descriptor) -> Mapped {
match get_ref(d, "null").map(target) {
Some(Target::Name(n)) => step("actions.play", json!({"action": n})),
Some(Target::Name(n)) => step("actions.play", json!({"action": clip_name(&n)})),
_ => Mapped::Unsupported { harmless: false },
}
}
@@ -3877,10 +3878,18 @@ fn normalise(v: &mut Value) {
}
}
/// Longest action or set name kept (Photoshop's own limit is 255). A file's names are lengths it
/// chooses, and each is copied into every action and every unsupported-step diagnostic.
const MAX_NAME_CHARS: usize = 1024;
fn clip_name(s: &str) -> String {
s.chars().take(MAX_NAME_CHARS).collect()
}
fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec<Unsupported>) -> Action {
let name = a.name.to_string_lossy();
let name = clip_name(&a.name.to_string_lossy());
let mut action = Action::new(name.clone(), Vec::new());
action.set = Some(set.to_string());
action.set = Some(clip_name(set));
for (i, item) in a.items.iter().enumerate() {
let mapped = match (event_for(&item.event), &item.descriptor) {
(Some(e), Some(d)) => (e.from)(d),
@@ -3914,7 +3923,7 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec<Unsupported>)
pub fn import(bytes: &[u8]) -> std::result::Result<Imported, String> {
let file = atn::parse(bytes).map_err(|e| e.to_string())?;
let set = file.name.to_string_lossy();
let set = if set.trim().is_empty() { DEFAULT_SET.to_string() } else { set };
let set = if set.trim().is_empty() { DEFAULT_SET.to_string() } else { clip_name(&set) };
let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), ..Default::default() };
for a in &file.actions {
let action = convert_action(a, &set, &mut out.unsupported);
+37
View File
@@ -315,6 +315,25 @@ fn load_renames_each_play_target_once() {
assert_eq!(s.actions.list[1].steps[0].1, json!({"action": "A"}), "B still plays the A it had");
}
/// A name over the 1,024-character limit is clipped for the action and for the steps that call it,
/// so a rename still finds its Play target.
#[test]
fn load_renames_play_targets_longer_than_the_name_limit() {
let mut s = Session::new();
let long = "x".repeat(2000);
let clipped = "x".repeat(1024);
let mut mine = Action::new(clipped.clone(), vec![]);
mine.set = Some("Mine".into());
s.actions.list.push(mine);
let a = Action::new(long.clone(), vec![]);
let caller = Action::new("Caller", vec![("actions.play".into(), json!({"action": long}))]);
let (bytes, _) = export("File", &[&a, &caller], &[], false).unwrap();
s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap();
let renamed = format!("{clipped} 2");
let caller = s.actions.list.iter().find(|a| a.name == "Caller").unwrap();
assert_eq!(caller.steps[0].1["action"], renamed.as_str());
}
#[test]
fn load_many_duplicate_names_get_sequential_suffixes() {
// Several actions share one base name. Deduplication must stay O(n) overall and produce
@@ -3082,3 +3101,21 @@ fn saves_round_trip_through_photoshop_events() {
let odd = Action::new("B", vec![("file.saveAs".to_string(), json!({"path": "/out/result.xcf"}))]);
assert!(export("Saves", &[&odd], &[], false).is_err());
}
#[test]
fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() {
let long = "N".repeat(5000);
let unknown = |n: usize| (0..n).map(|_| item("zzzz", "Mystery", None)).collect::<Vec<_>>();
let file = AtnFile {
name: UnicodeString::new_nul(&long),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul(&long), items: unknown(50), ..Default::default() }],
warnings: vec![],
};
let imp = import(&atn::write(&file).unwrap()).unwrap();
assert_eq!(imp.set.chars().count(), MAX_NAME_CHARS);
assert_eq!(imp.actions[0].name.chars().count(), MAX_NAME_CHARS);
assert_eq!(imp.actions[0].set.as_deref().map(|s| s.chars().count()), Some(MAX_NAME_CHARS));
assert_eq!(imp.unsupported.len(), 50);
assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS));
}
+24 -1
View File
@@ -119,12 +119,27 @@ fn check_known(steps: &[(String, Value)], cmd: &str) -> Result<()> {
}
}
/// Scripts nested in scripts, and steps one top-level script (with everything it calls) may run:
/// a script can name others, so a graph that calls each of the previous ones twice is exponential.
const MAX_SCRIPT_DEPTH: u8 = 16;
pub(crate) const MAX_SCRIPT_STEPS: u64 = crate::actions_cmds::MAX_PLAYBACK_STEPS;
/// A script run from a script reports its own failure inside an `Ok`; it stops the caller too.
fn nested_script_failure(id: &str, r: &Value) -> Option<String> {
(id == "file.scripts.browse" && r.get("ok") == Some(&Value::Bool(false))).then(|| "a script it runs failed".to_string())
}
fn run_steps(s: &mut Session, steps: &[(String, Value)]) -> Vec<Value> {
let mut results = Vec::new();
for (id, params) in steps {
s.actions.script_steps = s.actions.script_steps.saturating_add(1);
if s.actions.script_steps > MAX_SCRIPT_STEPS {
results.push(json!({"command": id, "error": format!("the script ran more than {MAX_SCRIPT_STEPS} steps in all (scripts calling scripts)")}));
break;
}
match s.execute(id, params.clone()) {
// A called action reports a failed step inside an `Ok`; it stops the script too.
Ok(r) => match crate::actions_cmds::nested_failure(id, &r) {
Ok(r) => match crate::actions_cmds::nested_failure(id, &r).or_else(|| nested_script_failure(id, &r)) {
Some(error) => {
results.push(json!({"command": id, "result": r, "error": error}));
break;
@@ -156,7 +171,15 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "give \"path\" (a script file), \"script\" (its text) or \"steps\"")),
};
check_known(&steps, cmd)?;
if s.actions.script_depth >= MAX_SCRIPT_DEPTH {
return Err(bad(cmd, format!("scripts are nested more than {MAX_SCRIPT_DEPTH} deep")));
}
if s.actions.script_depth == 0 {
s.actions.script_steps = 0;
}
s.actions.script_depth += 1;
let results = run_steps(s, &steps);
s.actions.script_depth -= 1;
let ok = results.iter().all(|r| r.get("error").is_none());
Ok(json!({"steps": steps.len(), "ok": ok, "results": results}))
}
+43
View File
@@ -484,3 +484,46 @@ fn load_files_into_stack_aligns_before_the_smart_object() {
s.execute("file.scripts.loadFilesIntoStack", json!({"paths": [files[0].clone()], "align": true})).unwrap();
assert_eq!(s.active().unwrap().doc.layers.len(), 1);
}
/// A script file of `lines` (one `file.scripts.browse` call per entry) in `dir`.
fn script_calling(dir: &str, name: &str, callees: &[String]) -> String {
let path = format!("{dir}/{name}.txt");
let body: String = callees.iter().map(|c| format!("file.scripts.browse {}\n", json!({"path": c}))).collect();
std::fs::write(&path, body).unwrap();
path
}
#[test]
fn scripts_calling_scripts_share_one_step_budget() {
let dir = tmp("fanout");
// Eight levels, each calling the one below six times: 6^8 (1.7 million) calls of the leaf.
let mut below = script_calling(&dir, "leaf", &[]);
std::fs::write(&below, "select.deselect {}\n").unwrap();
for level in 0..8 {
below = script_calling(&dir, &format!("level{level}"), &vec![below; 6]);
}
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let started = std::time::Instant::now();
let r = s.execute("file.scripts.browse", json!({"path": below})).unwrap();
assert_eq!(r["ok"], false, "the budget stops the run: {r}");
assert!(started.elapsed().as_secs() < 60, "{:?}", started.elapsed());
// The budget restarts with the next top-level script.
s.execute("file.scripts.browse", json!({"script": "select.deselect {}"})).unwrap();
}
#[test]
fn scripts_nested_deeper_than_the_limit_fail() {
let dir = tmp("deep");
let mut below = script_calling(&dir, "leaf", &[]);
std::fs::write(&below, "select.deselect {}\n").unwrap();
for level in 0..20 {
below = script_calling(&dir, &format!("level{level}"), &[below]);
}
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let r = s.execute("file.scripts.browse", json!({"path": below})).unwrap();
assert_eq!(r["ok"], false, "{r}");
assert!(r.to_string().contains("nested more than"), "{r}");
assert_eq!(s.actions.script_depth, 0, "the depth is released");
}
+7
View File
@@ -33,6 +33,12 @@ pub fn long_version() -> String {
format_long(VERSION, short_sha(), build_date())
}
/// [`long_version`] worked out once, for a `&'static str` such as clap's `version`.
pub fn long_version_static() -> &'static str {
static LONG: std::sync::OnceLock<String> = std::sync::OnceLock::new();
LONG.get_or_init(long_version)
}
fn format_long(version: &str, sha: Option<&str>, date: Option<&str>) -> String {
match (sha, date) {
(Some(s), Some(d)) => format!("{version} ({s}, {d})"),
@@ -58,6 +64,7 @@ mod tests {
fn version_is_the_workspace_version() {
assert!(!VERSION.is_empty());
assert!(long_version().starts_with(VERSION));
assert_eq!(long_version_static(), long_version());
if let Some(s) = short_sha() {
assert!(s.len() <= 9);
}
+2 -1
View File
@@ -590,7 +590,8 @@ pub fn resolve_profile(spec: &str, doc: Option<&Document>, mode: Option<ColorMod
}
let lower = spec.to_ascii_lowercase();
if lower.ends_with(".icc") || lower.ends_with(".icm") || spec.contains('/') || spec.contains('\\') {
let bytes = std::fs::read(spec).map_err(|e| EngineError::Other(format!("cannot read profile `{spec}`: {e}")))?;
// Regular files only: a device or FIFO path would be read until it ends, which it never does.
let bytes = crate::file_cmds::read_file(spec).map_err(|e| EngineError::Other(format!("cannot read profile `{spec}`: {e}")))?;
return Profile::parse(&bytes).map(Arc::new).map_err(cms_err);
}
let ids: Vec<&str> = Builtin::ALL.iter().map(|b| b.id()).collect();
+3 -3
View File
@@ -487,7 +487,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
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());
pixels::adjust_mask(&mut mask.surface, &adj, sel.as_ref()).map_err(EngineError::Other)?;
Ok(Value::Null)
});
}
@@ -497,7 +497,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
return s.edit(label, |doc, _| {
let sel = doc.selection.clone();
if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, None, p)? {
pixels::adjust_mask(surf, &adj, sel.as_ref());
pixels::adjust_mask(surf, &adj, sel.as_ref()).map_err(EngineError::Other)?;
}
Ok(Value::Null)
});
@@ -507,7 +507,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
let sel = doc.selection.clone();
let mode = doc.mode;
let surf = paint_surface(doc, id, &Value::Null)?;
pixels::adjust_surface(surf, &adj, sel.as_ref(), mode);
pixels::adjust_surface(surf, &adj, sel.as_ref(), mode).map_err(EngineError::Other)?;
Ok(())
})?;
Ok(Value::Null)
+93
View File
@@ -255,3 +255,96 @@ fn data_sets_and_footage_read_only_followed_files() {
assert!(err.contains("is not opened: the path is on another computer"), "{err}");
footage(&mut s, "blue.png").unwrap();
}
/// A link to a file PhotoCraft can't place (a text or settings file) is never embedded: a saved
/// PSD would otherwise carry bytes no reader can show as the layer's contents. The layer is
/// written as pixels; Convert to Embedded and Relink refuse.
#[test]
fn links_to_files_that_are_not_images_are_never_embedded() {
const TEXT: &[u8] = b"plain text, not an image
photocraft-text-marker
";
let d = dir("not-image");
std::fs::create_dir_all(d.join("notes")).unwrap();
std::fs::create_dir_all(d.join("Downloads")).unwrap();
std::fs::write(d.join("notes").join("readme"), TEXT).unwrap();
std::fs::write(d.join("Downloads").join(".settings"), TEXT).unwrap();
png(&d.join("red.png"), |_| [255, 0, 0]);
let (mut s, id) = document(&d, &d.join("red.png"));
s.active_mut().unwrap().path = Some(d.join("Downloads").join("doc.pcraft").to_string_lossy().into_owned());
let has_text = |bytes: &[u8]| bytes.windows(TEXT.len()).any(|w| w == TEXT);
for (n, path) in ["../notes/readme", ".settings"].into_iter().enumerate() {
relink(&mut s, id, path);
for ext in ["psd", "psb", "tif"] {
let out = d.join(format!("copy{n}.{ext}"));
let r = s.execute("file.saveACopy", json!({"path": out.to_string_lossy(), "tiffLayers": true})).unwrap();
assert!(!has_text(&std::fs::read(&out).unwrap()), "{path} embedded in the {ext}: {r}");
let warnings = r["warnings"].to_string();
assert!(warnings.contains("written as pixels"), "{path} {ext}: {warnings}");
}
assert!(s.execute("layer.smartObjects.convertToEmbedded", json!({"layer": id.0})).is_err(), "{path}");
let abs = d.join("notes").join("readme");
assert!(s.execute("layer.smartObjects.relinkToFile", json!({"layer": id.0, "path": abs.to_string_lossy()})).is_err(), "{path}");
}
}
/// Duplicates share their contents identity, so a linked and an embedded copy of the same bytes
/// meet in the export's conversion cache: each keeps its own rule, whichever is written first.
#[test]
fn a_linked_and_an_embedded_copy_of_the_same_bytes_keep_their_own_rule() {
const TEXT: &[u8] = b"plain text, not an image
photocraft-shared-marker
";
let d = dir("shared-contents");
std::fs::write(d.join("readme"), TEXT).unwrap();
png(&d.join("red.png"), |_| [255, 0, 0]);
let (mut s, linked) = document(&d, &d.join("red.png"));
s.execute("layer.duplicate", json!({})).unwrap();
let embedded = s.active().unwrap().active_layer.unwrap();
assert_ne!(linked, embedded);
relink(&mut s, linked, "readme");
s.edit("Embed", |doc, _| {
if let Some(LayerContent::Smart(sm)) = doc.layer_mut(embedded).map(|l| &mut l.content) {
sm.source = SmartSource::Embedded { file_name: "readme".into(), bytes: std::sync::Arc::new(TEXT.to_vec()) };
}
Ok(())
})
.unwrap();
for ext in ["psd", "psb", "tif"] {
let out = d.join(format!("copy.{ext}"));
let r = s.execute("file.saveACopy", json!({"path": out.to_string_lossy(), "tiffLayers": true})).unwrap();
let bytes = std::fs::read(&out).unwrap();
let copies = bytes.windows(TEXT.len()).filter(|w| *w == TEXT).count();
assert_eq!(copies, 1, "{ext}: only the embedded copy is kept: {r}");
assert!(r["warnings"].to_string().contains("written as pixels"), "{ext}: {r}");
}
}
/// Package copies only linked files PhotoCraft can open: a link to a text file is listed as
/// missing and no copy of it is written into the package's Links folder.
#[test]
fn package_copies_only_links_to_images() {
const TEXT: &[u8] = b"plain text, not an image
photocraft-package-marker
";
let d = dir("package-not-image");
std::fs::write(d.join("readme"), TEXT).unwrap();
png(&d.join("red.png"), |_| [255, 0, 0]);
let (mut s, id) = document(&d, &d.join("red.png"));
relink(&mut s, id, "readme");
let pkg = d.join("pkg");
let r = s.execute("file.package", json!({"dir": pkg.to_string_lossy()}));
let copies: Vec<_> = walkdir(&pkg).into_iter().filter(|f| std::fs::read(f).is_ok_and(|b| b == TEXT)).collect();
assert!(copies.is_empty(), "copied into the package: {copies:?} ({r:?})");
let r = r.unwrap();
assert_eq!(r["missing"], json!(["readme"]), "{r}");
}
fn walkdir(d: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
for e in std::fs::read_dir(d).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() { out.extend(walkdir(&p)) } else { out.push(p) }
}
out
}
+1 -1
View File
@@ -715,7 +715,7 @@ fn auto_adjust(s: &mut Session, kind: &str) -> Result<Value> {
let sel = doc.selection.clone();
let mode = doc.mode;
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
crate::pixels::adjust_surface(surf, &adj, sel.as_ref(), mode);
crate::pixels::adjust_surface(surf, &adj, sel.as_ref(), mode).map_err(EngineError::Other)?;
Ok(())
})?;
Ok(Value::Null)
+1 -1
View File
@@ -170,7 +170,7 @@ pub fn live_background_eraser(s: &Session, p: &Value) -> Result<crate::retouch_c
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
crate::extra_cmds::background_to_layer(&mut doc, id);
let stroke = background_eraser_stroke(&stroke);
crate::retouch_cmds::LiveDab::with_effect_on(doc, CMD, &json!({}), stroke, Some(id), 0, true, |pre| match background_eraser_effect(pre, &bs) {
crate::retouch_cmds::LiveDab::with_effect_on(doc, CMD, &json!({}), stroke, Some(id), 0, |pre| match background_eraser_effect(pre, &bs) {
Some(effect) => Box::new(effect),
// No alpha: the commit erases nothing either.
None => Box::new(|_: &mut photocraft_paint::retouch::Region, _: &photocraft_paint::retouch::Footprint| {}),
+69 -27
View File
@@ -229,7 +229,8 @@ pub(crate) fn stem(path: &str) -> String {
/// Whether `a` and `b` name the same file: the same text, or, when both exist, the same file on
/// disk (a hard link, a different case on a case-insensitive volume, `./` or `..` in one of them).
pub fn same_file(a: &str, b: &str) -> bool {
pub fn same_file(a: impl AsRef<std::path::Path>, b: impl AsRef<std::path::Path>) -> bool {
let (a, b) = (a.as_ref(), b.as_ref());
if a == b {
return true;
}
@@ -576,6 +577,17 @@ pub(crate) fn flattened(doc: &Document, fmt: PixelFormat) -> Surface {
photocraft_compose::flatten_to_surface(doc, fmt, None)
}
/// [`flattened`] for documents that came from a file (Save a Copy, Place, Load Files into Stack):
/// a sparse bundle declares its canvas, and one the size of the format allows would be rendered
/// in full, however little it holds. Refused past the compositor's own per-render limit.
pub(crate) fn flattened_checked(doc: &Document, fmt: PixelFormat, cmd: &str) -> Result<Surface> {
let b = doc.bounds();
if u64::from(b.width()) * u64::from(b.height()) > photocraft_compose::MAX_RENDER_PIXELS {
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("the document's {}×{} canvas is too large to flatten", b.width(), b.height()) });
}
Ok(flattened(doc, fmt))
}
// ---------- close / revert / save a copy / open as ----------
fn close_all(s: &mut Session) -> Result<Value> {
@@ -637,7 +649,7 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("layers").and_then(Value::as_bool) == Some(false) {
// "Layers" unchecked in Save a Copy: write the flattened image.
let fmt = doc.pixel_format();
let px = flattened(&doc, fmt);
let px = flattened_checked(&doc, fmt, "file.saveACopy")?;
doc.layers = vec![Layer::new("Background", LayerContent::Raster(px))];
}
let warnings = save_doc(&doc, &path, SaveOpts::from_params(s, p, "file.saveACopy")?)?;
@@ -744,7 +756,7 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec<u8>, linked: Option<S
_ => (cw / 2.0, ch / 2.0),
};
let (dx, dy) = ((center.0 - w * scale / 2.0).round(), (center.1 - h * scale / 2.0).round());
let mut px = flattened(&src, fmt);
let mut px = flattened_checked(&src, fmt, "file.place")?;
if (scale - 1.0).abs() > 1e-9 {
px = resize_surface(&px, scale, scale, Resample::Bicubic);
}
@@ -1455,42 +1467,72 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
list
}
};
let actions = crate::actions_cmds::ActionState { list, playing: s.actions.playing, playback_stack: s.actions.playback_stack.clone(), ..Default::default() };
// A batch inside a played action charges its inputs × steps (at least one each: an input is read,
// decoded and saved even with no steps) to the playback tree it is in and
// hands the count on, so a batch of batches can't multiply past the limit. A top-level batch
// is not limited by it.
let mut playback_steps = 0;
if s.actions.playing > 0 {
playback_steps = s.actions.playback_steps.saturating_add((steps.len().max(1) as u64).saturating_mul(inputs.len() as u64));
if playback_steps > crate::actions_cmds::MAX_PLAYBACK_STEPS {
return Err(EngineError::BadParams { cmd: cmd.into(), msg: "this batch would exceed the playback step limit of the action that runs it".into() });
}
s.actions.playback_steps = playback_steps;
}
let actions = crate::actions_cmds::ActionState {
list,
playing: s.actions.playing,
playback_stack: s.actions.playback_stack.clone(),
playback_steps,
..Default::default()
};
let names = batch_naming::plan(&inputs, &output, &format, p)?;
let save = SaveOpts::from_params(s, p, cmd)?;
let authorize = s.authorize;
// Each input runs in its own scratch session, so what the played steps spend is carried from
// one input to the next (failed or not) and back to this session, not restarted per input.
let carried = std::cell::Cell::new(playback_steps);
let carried = &carried;
let run = move |scratch: &mut Session, out: &str| {
scratch.authorize = authorize;
scratch.actions = actions.clone();
// The document as the last recorded save found it, written once the action has run so a
// failing step still writes nothing.
let mut saved: Option<Arc<Document>> = None;
for (id, params) in &steps {
if batch_save_point(id) {
saved = Some(scratch.active().ok_or(EngineError::NoDocument)?.doc.clone());
continue;
scratch.actions.playback_steps = carried.get();
let result = (|| {
// The document as the last recorded save found it, written once the action has run so a
// failing step still writes nothing.
let mut saved: Option<Arc<Document>> = None;
for (id, params) in &steps {
if batch_save_point(id) {
saved = Some(scratch.active().ok_or(EngineError::NoDocument)?.doc.clone());
continue;
}
let r = scratch.execute(id, params.clone())?;
// A failed step inside a called action makes this file an error, not a saved result.
if let Some(error) = crate::actions_cmds::nested_failure(id, &r) {
return Err(EngineError::Other(error));
}
}
let r = scratch.execute(id, params.clone())?;
// A failed step inside a called action makes this file an error, not a saved result.
if let Some(error) = crate::actions_cmds::nested_failure(id, &r) {
return Err(EngineError::Other(error));
// While the saved document is still open the steps after the save reach the result too
// (#3058), so it is saved after the action as usual. Closed after its save, or with another
// document left active, it is written as the save found it.
match saved {
Some(doc) if scratch.active().is_none_or(|d| d.doc.id != doc.id) => {
save_doc(&doc, out, save)?;
Ok(true)
}
_ => Ok(false),
}
}
// While the saved document is still open the steps after the save reach the result too
// (#3058), so it is saved after the action as usual. Closed after its save, or with another
// document left active, it is written as the save found it.
match saved {
Some(doc) if scratch.active().is_none_or(|d| d.doc.id != doc.id) => {
save_doc(&doc, out, save)?;
Ok(true)
}
_ => Ok(false),
}
})();
carried.set(scratch.actions.playback_steps);
result
};
let r = match names {
Some(names) => process_outputs(s, inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run),
None => process_tree(s, &inputs, &output, &format, save, "", &run),
};
if s.actions.playing > 0 {
s.actions.playback_steps = carried.get();
}
Ok(r)
}
@@ -1540,7 +1582,7 @@ fn load_files_into_stack(s: &mut Session, p: &Value) -> Result<Value> {
let fmt = stack.pixel_format();
// First file at the bottom, like Photoshop's script.
for (name, d) in &docs {
stack.layers.push(Layer::new(name.clone(), LayerContent::Raster(flattened(d, fmt))));
stack.layers.push(Layer::new(name.clone(), LayerContent::Raster(flattened_checked(d, fmt, cmd)?)));
}
let n = stack.layers.len();
let smart = p.get("createSmartObject").and_then(Value::as_bool).unwrap_or(false);
+2
View File
@@ -222,6 +222,8 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
new.push(id);
}
*active = above;
// Copied groups keep their own depth: repeated copies would otherwise nest without end.
crate::layer_multi_cmds::check_group_depth(doc, "Copy Layers")?;
Ok(new)
});
// Nothing changed: the source stays the active document.
@@ -342,3 +342,30 @@ fn a_named_duplicate_takes_the_name_in_one_step() {
let r = s.execute("layer.copyToDocument", json!({"document": 1, "name": "Ink"})).unwrap();
assert_eq!(doc(&s, 1).layer(copied(&r)).unwrap().name, "Ink");
}
#[test]
fn a_copied_group_cannot_nest_the_destination_past_the_group_depth_limit() {
use photocraft_doc::{Layer, MAX_GROUP_DEPTH};
let (mut s, paint) = two_docs(40, 30, 8);
// A group in the source, and a destination whose active layer sits at the deepest legal level.
s.execute("layer.groupLayers", json!({"layers": [paint.0]})).unwrap();
let group = s.active().unwrap().active_layer.unwrap();
s.set_active(1);
let mut inner = None;
s.edit("nest", |doc, active| {
let mut l = Layer::new("deep", LayerContent::Raster(Surface::new(PixelFormat::RGBA8)));
inner = Some(l.id);
for _ in 0..MAX_GROUP_DEPTH {
l = Layer::group("g", vec![l]);
}
doc.layers.push(l);
*active = inner;
Ok(())
})
.unwrap();
s.set_active(0);
let before = doc(&s, 1).clone();
let e = s.execute("layer.copyToDocument", json!({"document": 1, "layers": [group.0]})).unwrap_err();
assert!(e.to_string().contains("deeper than"), "{e}");
assert_eq!(*doc(&s, 1), before, "nothing was copied");
}
+11
View File
@@ -715,6 +715,17 @@ impl Session {
Ok(())
}
/// [`Self::offer_live_stroke`] for a live Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke,
/// taken by the command it was drawn for: the stroke's finishing dabs are run first. Refused
/// when the brush, tool options or layer changed during the stroke.
pub fn offer_live_dab(&mut self, live: retouch_cmds::LiveDab) -> Result<()> {
// A refused offer withdraws the previous one too.
self.live_commit = None;
let commit = live.into_commit(self)?;
self.live_commit = Some(commit);
Ok(())
}
/// Withdraw an offer the command didn't take.
pub fn clear_live_stroke(&mut self) {
self.live_commit = None;
+67 -11
View File
@@ -58,20 +58,55 @@ pub fn composite_layers_uncropped(solo: &Document, format: photocraft_color::Pix
photocraft_compose::composite_to_surface(solo, area, format, None)
}
/// Most bytes of tiles an adjustment may materialize beyond the ones a surface stores: two distant
/// tiles make the content bounds a grid of millions of tiles that hold nothing. Written tiles are
/// copies, so the peak is about twice this.
const MAX_UNSTORED_BYTES: u64 = 1 << 30;
/// Tiles in the grid that covers `r` (one extra per axis for a tile straddling its edge).
fn grid_tiles(r: Rect) -> u64 {
(u64::from(r.width()).div_ceil(TILE_SIZE as u64) + 1) * (u64::from(r.height()).div_ceil(TILE_SIZE as u64) + 1)
}
/// Bytes the tiles of the grid over `r` take beyond the `stored` ones, in format `fmt`.
fn unstored_bytes(r: Rect, stored: u64, fmt: &photocraft_color::PixelFormat) -> u64 {
grid_tiles(r).saturating_sub(stored).saturating_mul((TILE_SIZE as u64).pow(2)).saturating_mul(fmt.bytes_per_pixel() as u64)
}
/// The tile-sized pieces of `r`, each cut to `r`.
fn tile_rects(r: Rect) -> impl Iterator<Item = Rect> {
let t = i64::from(TILE_SIZE);
let cols = i64::from(r.x0).div_euclid(t)..=(i64::from(r.x1) - 1).div_euclid(t);
let rows = i64::from(r.y0).div_euclid(t)..=(i64::from(r.y1) - 1).div_euclid(t);
rows.flat_map(move |cy| cols.clone().map(move |cx| Rect::new((cx * t) as i32, (cy * t) as i32, ((cx + 1) * t) as i32, ((cy + 1) * t) as i32).intersect(&r)))
}
const TOO_SPARSE: &str = "the adjustment would have to fill too large an area around the pixels the layer holds";
/// Apply an adjustment destructively to a surface, weighted by an optional selection.
/// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the
/// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale).
/// Works tile by tile in parallel, so a large layer needs one tile's buffers per thread rather
/// than four full-layer copies, and the result is the same as one pass over the whole region.
pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) {
pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) -> Result<(), String> {
use rayon::prelude::*;
let r = s.content_bounds();
if r.is_empty() {
return;
return Ok(());
}
let fmt = s.format();
let done: Vec<_> = s
.take_tiles(r)
// Two distant tiles make the content bounds a grid of millions of tiles that hold nothing.
// Past a budget a transparent default needs only the stored tiles adjusted (the gaps stay
// transparent); an opaque default would change under the adjustment everywhere in that grid,
// so the adjustment is refused rather than left half applied.
let stored: Vec<_> = s.tiles().map(|(c, _)| *c).filter(|c| !c.rect().intersect(&r).is_empty()).collect();
let transparent = fmt.alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0);
let over = unstored_bytes(r, stored.len() as u64, &fmt) > MAX_UNSTORED_BYTES;
if over && !transparent {
return Err(TOO_SPARSE.into());
}
let taken = if over { stored.into_iter().flat_map(|c| s.take_tiles(c.rect())).collect() } else { s.take_tiles(r) };
let done: Vec<_> = taken
.into_par_iter()
.flat_map_iter(|(tc, tile)| {
// A one-tile surface, so `adjusted` reads and writes this tile only.
@@ -84,6 +119,7 @@ pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surf
})
.collect();
s.put_tiles(done);
Ok(())
}
#[cfg(test)]
@@ -96,26 +132,46 @@ mod tests;
/// Applies `adj` to a layer mask through the selection. Untouched mask pixels count (they read as
/// the mask's default value), and without a selection the default changes too, since a mask
/// reaches past the canvas: Invert turns a reveal-all mask into a hide-all one (#780).
pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>) {
pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>) -> Result<(), String> {
let mode = photocraft_color::ColorMode::Grayscale;
if let Some(sel) = selection {
let r = sel.content_bounds();
if !r.is_empty() {
let out = adjusted(s, r, adj, Some(sel), mode);
s.write_region(r, &out);
// Tile by tile, and only where the selection stores something (it is unselected, so
// unchanged, elsewhere): its content bounds can span a grid of mostly nothing.
let sparse = sel.default_pixel().iter().all(|v| *v == 0.0);
let tiles: Vec<Rect> = if sparse {
sel.tiles().map(|(c, _)| c.rect().intersect(&r)).filter(|t| !t.is_empty()).collect()
} else if r.is_empty() {
Vec::new()
} else {
// Unselected pixels count too, so every tile of the span is adjusted, one at a time
// (not one read of the whole span), and the span is bounded by what that writes.
if unstored_bytes(r, 0, &s.format()) > MAX_UNSTORED_BYTES {
return Err(TOO_SPARSE.into());
}
tile_rects(r).collect()
};
for t in &tiles {
let out = adjusted(s, *t, adj, Some(sel), mode);
s.write_region(*t, &out);
}
if !tiles.is_empty() {
s.prune();
}
return;
return Ok(());
}
let fmt = s.format();
let unit = Rect::from_xywh(0, 0, 1, 1);
let mut out = Surface::with_default(fmt, &adjusted(&Surface::with_default(fmt, &s.default_pixel()), unit, adj, None, mode));
let r = s.tile_bounds();
if !r.is_empty() {
// Everything the mask doesn't store reads as its default, which `out` already holds adjusted.
// So only the stored tiles are adjusted, not the (possibly huge) rectangle around them.
let stored: Vec<Rect> = s.tiles().map(|(c, _)| c.rect()).collect();
for r in stored {
out.write_region(r, &adjusted(s, r, adj, None, mode));
}
out.prune();
*s = out;
Ok(())
}
/// `s`'s pixels over `r` (untouched ones included) with `adj` applied through the selection, encoded.
+53 -4
View File
@@ -110,7 +110,7 @@ fn tiled_adjustment_matches_one_pass_over_the_content() {
for sel in [None, Some(&soft)] {
let want = one_pass(&s, &adj, sel, fmt.mode);
let mut got = s.clone();
adjust_surface(&mut got, &adj, sel, fmt.mode);
adjust_surface(&mut got, &adj, sel, fmt.mode).unwrap();
assert!(want != s, "{kind} {fmt:?}: the adjustment changes the layer");
assert!(got == want, "{kind} {fmt:?} selection {}: tiled result differs", sel.is_some());
}
@@ -121,7 +121,7 @@ fn tiled_adjustment_matches_one_pass_over_the_content() {
#[test]
fn an_empty_layer_is_left_alone() {
let mut s = Surface::new(PixelFormat::RGBA8);
adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb);
adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb).unwrap();
assert_eq!(s.tile_count(), 0);
}
@@ -263,7 +263,7 @@ fn invert_in_a_lab_document_mirrors_l_a_and_b() {
let mut s = Surface::new(fmt);
let r = Rect::new(0, 0, 2, 2);
s.write_region(r, &(0..4).flat_map(|_| from_rgba(&fmt, [c[0], c[1], c[2], 1.0])).collect::<Vec<_>>());
adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Lab);
adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Lab).unwrap();
let px = s.read_region(Rect::new(1, 1, 2, 2));
let rgba = photocraft_raster::to_rgba(&fmt, &px);
let got = srgb_to_lab([rgba[0], rgba[1], rgba[2]]);
@@ -318,7 +318,7 @@ fn adjusted_pixel(fmt: PixelFormat, ch: &[f32], adj: &Adjustment, k: Option<f32>
if let Some(k) = k {
sel.write_region(unit, &[k]);
}
adjust_surface(&mut s, adj, k.map(|_| &sel), fmt.mode);
adjust_surface(&mut s, adj, k.map(|_| &sel), fmt.mode).unwrap();
s.read_region(unit)
}
@@ -404,3 +404,52 @@ fn hue_saturation_and_color_balance_keep_a_cmyk_layers_black() {
assert!((0..4).all(|c| (half[c] - (ink[c] + (want[0][c] - ink[c]) * k)).abs() < 1e-3), "{}: half a selection", adj.label());
}
}
/// Two small blocks two billion pixels apart: the content bounds are a grid of 6·10¹³ tiles
/// that holds nothing, which no adjustment may materialize.
fn distant_blocks(fmt: PixelFormat, default: f32) -> Surface {
let mut s = Surface::with_default(fmt, &vec![default; fmt.channels()]);
s.fill_rect(Rect::new(0, 0, 10, 10), &vec![0.25; fmt.channels()]);
s.fill_rect(Rect::new(2_000_000_000, 2_000_000_000, 2_000_000_010, 2_000_000_010), &vec![0.25; fmt.channels()]);
s
}
#[test]
fn adjusting_a_layer_of_distant_blocks_visits_only_its_tiles() {
let mut s = distant_blocks(PixelFormat::RGBA8, 0.0);
adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb).unwrap();
let p = s.read_region(Rect::new(2_000_000_005, 2_000_000_005, 2_000_000_006, 2_000_000_006));
// Straight colour inverted, alpha untouched.
assert!((p[0] - 0.75).abs() < 0.01 && (p[3] - 0.25).abs() < 0.01, "{p:?}");
assert_eq!(s.tile_count(), 2);
}
#[test]
fn adjusting_a_mask_of_distant_blocks_visits_only_its_tiles() {
let mut m = distant_blocks(PixelFormat::GRAY8, 1.0);
adjust_mask(&mut m, &Adjustment::Invert, None).unwrap();
assert!(m.default_pixel()[0] < 0.01, "the default is inverted: {:?}", m.default_pixel());
let p = m.read_region(Rect::new(2_000_000_005, 2_000_000_005, 2_000_000_006, 2_000_000_006));
assert!((p[0] - 0.75).abs() < 0.01, "{p:?}");
// Through a selection of its own distant tiles, too.
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(Rect::new(2_000_000_000, 2_000_000_000, 2_000_000_010, 2_000_000_010), &[1.0]);
adjust_mask(&mut m, &Adjustment::Invert, Some(&sel)).unwrap();
let p = m.read_region(Rect::new(2_000_000_005, 2_000_000_005, 2_000_000_006, 2_000_000_006));
assert!((p[0] - 0.25).abs() < 0.01, "{p:?}");
}
#[test]
fn an_opaque_layer_of_distant_blocks_is_refused_not_half_adjusted() {
let mut s = distant_blocks(PixelFormat::RGBA8, 1.0);
let before = s.clone();
assert!(adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb).is_err());
assert!(s == before, "a refused adjustment leaves the layer as it was");
}
#[test]
fn a_selection_with_a_nonzero_default_over_distant_blocks_is_refused() {
let mut m = Surface::with_default(PixelFormat::GRAY8, &[0.5]);
let sel = distant_blocks(PixelFormat::GRAY8, 1.0);
assert!(adjust_mask(&mut m, &Adjustment::Invert, Some(&sel)).is_err());
}
+21 -1
View File
@@ -1929,7 +1929,11 @@ impl Session {
/// Batches defer colour validation until their complete candidate is staged. Returns
/// whether colour settings were touched, including whole-section/root-object updates.
fn set_pref(&mut self, path: &str, value: Value, validate_color: bool) -> std::result::Result<bool, String> {
if let Some(rest) = path.strip_prefix("colorSettings") {
// The section itself or a path inside it: `colorSettingsworkingRgb`, `colorSettings.` and
// `colorSettings..workingRgb` are none, and the authorization policy doesn't treat them as one.
if let Some(rest) =
path.strip_prefix("colorSettings").filter(|r| r.is_empty() || r.strip_prefix('.').is_some_and(|c| c.split('.').all(|part| !part.is_empty())))
{
let mut cur = serde_json::to_value(&self.color.settings).map_err(|e| e.to_string())?;
let rest = rest.trim_start_matches('.');
if rest.is_empty() {
@@ -2735,3 +2739,19 @@ pub fn specs() -> Vec<CommandSpec> {
#[cfg(test)]
#[path = "prefs/tests.rs"]
mod tests;
#[cfg(test)]
mod color_settings_path_tests {
use crate::Session;
use serde_json::json;
#[test]
fn a_path_that_only_starts_with_color_settings_is_not_in_that_section() {
let mut s = Session::new();
let before = s.color.settings.clone();
for path in ["colorSettingsworkingRgb", "colorSettings.", "colorSettings..workingRgb"] {
assert!(s.execute("prefs.set", json!({"path": path, "value": "/dev/zero"})).is_err(), "{path}");
}
assert_eq!(s.color.settings, before);
}
}
+9
View File
@@ -146,11 +146,20 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io
Ok(json!({ "group": group, "imported": names, "count": names.len(), "folders": folders, "version": imp.version, "warnings": imp.warnings }))
}
/// Largest canvas (268 MP) a brush may be made from.
const MAX_BRUSH_SOURCE_PIXELS: u64 = 1 << 28;
/// The image's paint amount (darkness × alpha of its composite, as Define Brush Preset reads a
/// selection), cropped to the painted extent. The composite is rendered in bands twice: once to
/// find the painted bounds, then only those, so a large mostly-white image never needs a
/// full-size buffer.
fn image_paint(doc: &photocraft_doc::Document, cmd: &str) -> Result<(Vec<f32>, usize, usize)> {
// The whole canvas is rendered (twice) to find the painted part: a sparse native file declares
// its canvas, so refuse one far past any brush source before rendering it.
let canvas = doc.bounds();
if u64::from(canvas.width()) * u64::from(canvas.height()) > MAX_BRUSH_SOURCE_PIXELS {
return Err(bad(cmd, format!("the image's {}×{} canvas is too large to make a brush from", canvas.width(), canvas.height())));
}
let paint = |c: [f32; 4]| ((1.0 - photocraft_color::blend::lum([c[0], c[1], c[2]])) * c[3]).clamp(0.0, 1.0);
let mut bounds: Option<photocraft_geom::Rect> = None;
let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> std::result::Result<(), ()> {
+4 -3
View File
@@ -1156,13 +1156,14 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
missing.push(path.clone());
continue;
};
match read_file(&file.to_string_lossy()) {
Ok(bytes) => {
// Only a file PhotoCraft can place is copied; anything else is packaged as missing.
match read_file(&file.to_string_lossy()).ok().filter(|bytes| crate::file_cmds::import(&fname, bytes).is_ok()) {
Some(bytes) => {
write_file(&t, &bytes)?;
copied.push((path.clone(), t.clone()));
t
}
Err(_) => {
None => {
missing.push(path.clone());
continue;
}
+223 -66
View File
@@ -981,7 +981,7 @@ fn tone_range(p: &Value, cmd: &str) -> Result<ToneRange> {
/// A sequential-dab tool's settings (Dodge, Burn, Sponge, Blur, Sharpen, Smudge), parsed once and
/// turned into its per-dab effect, for the commit and the live preview alike.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
enum DabTool {
Tone { burn: bool, range: ToneRange, exposure: f32, protect: bool },
Sponge { saturate: bool, vibrance: bool },
@@ -992,6 +992,37 @@ enum DabTool {
/// One dab's edit of the working copy.
pub(crate) type DabEffect = Box<dyn FnMut(&mut Region, &Footprint)>;
/// Pixels an effect reads beyond the dab it writes. Smudge's pickup is a fixed-size square,
/// independent of pressure, roundness and the dab's current angle.
#[derive(Clone, Copy)]
enum DabNeighborhood {
Halo(i32),
Smudge(i32),
}
impl DabNeighborhood {
fn halo(self) -> i32 {
match self {
Self::Halo(radius) | Self::Smudge(radius) => radius,
}
}
fn read_rect(self, rect: Rect, dab: &photocraft_paint::Dab) -> Rect {
match self {
Self::Halo(radius) => rect.inflate(radius),
Self::Smudge(radius) => {
let (x, y) = (dab.center.x.floor() as i32, dab.center.y.floor() as i32);
rect.union(&Rect::new(
x.saturating_sub(radius),
y.saturating_sub(radius),
x.saturating_add(radius).saturating_add(1),
y.saturating_add(radius).saturating_add(1),
))
}
}
}
}
impl DabTool {
/// The tool for `cmd` and its history label.
fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> {
@@ -1033,10 +1064,11 @@ impl DabTool {
}
/// How far around each dab the effect reads (Blur and Sharpen read a neighbourhood).
fn halo(&self) -> i32 {
fn neighborhood(&self) -> DabNeighborhood {
match self {
Self::Focus { sigma, .. } => (sigma * 3.0).ceil() as i32 + 1,
_ => 0,
Self::Focus { sigma, .. } => DabNeighborhood::Halo((sigma * 3.0).ceil() as i32 + 1),
Self::Smudge { max_size, .. } => DabNeighborhood::Smudge(Smudge::sampling_radius(*max_size)),
_ => DabNeighborhood::Halo(0),
}
}
@@ -1096,67 +1128,110 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let (stroke, id) = parse_brush(s, p, cmd)?;
let (tool, label) = DabTool::parse(s, cmd, p, &stroke)?;
let all = matches!(tool, DabTool::Focus { .. } | DabTool::Smudge { .. }) && sample_all_layers(p);
// The live stroke the canvas already drew for exactly this commit (`LiveDab::into_commit`):
// take its pixels instead of running every dab again (the hitch on release).
let live = s.live_commit.take().filter(|l| l.matches(s, cmd, p, &stroke.points));
let from_live = live.is_some();
let dmg = run_stroke(s, label, id, p, |pre, surf, sel, lock| {
if let Some(l) = live {
*surf = l.surface;
return Ok(l.bounds);
}
let fmt = surf.format();
let mut effect = tool.effect(fmt, stroke.brush.spacing);
let mut sampler = all.then(|| AllLayers::new(pre, fmt));
Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.halo(), |work, fp| match &mut sampler {
Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.neighborhood().halo(), |work, fp| match &mut sampler {
Some(all) => all.dab(work, fp, |w, f| effect(w, f)),
None => effect(work, fp),
}))
})?;
Ok(json!({ "damage": damage_json(dmg) }))
let mut r = json!({ "damage": damage_json(dmg) });
if from_live {
r["fromLive"] = json!(true);
}
Ok(r)
}
/// The working pixels of a [`LiveDab`]: a copy of the target in its own depth, or (`precise`)
/// float pixels copied from it a tile at a time as dabs first reach them, so only the stroke's
/// area is ever copied.
struct WorkPixels {
surf: Surface,
/// Tiles (`TILE` px) copied from the target so far; `None` when `surf` is a full copy.
filled: Option<std::collections::HashSet<(i32, i32)>>,
/// Side of the tiles a live sequential-dab stroke keeps its working pixels in ([`WorkTiles`]).
const WORK_TILE: i32 = 128;
/// The working copy of a live sequential-dab stroke: the target's native channels as `f32`, in
/// tiles copied from the pre-stroke surface the first time a dab writes to them. Unlike a
/// `Surface`, it isn't rounded to the document's depth between dabs, so each dab sees what the
/// commit's working copy (`apply_dab_stroke`) holds and the preview is the commit's pixels.
struct WorkTiles {
ch: usize,
tiles: std::collections::HashMap<(i32, i32), Vec<f32>>,
}
impl WorkPixels {
const TILE: i32 = 64;
fn new(pre: &Surface, precise: bool) -> Self {
if precise {
let f = pre.format();
let fmt = PixelFormat::new(f.mode, SampleType::F32, f.alpha);
Self { surf: Surface::new(fmt), filled: Some(std::collections::HashSet::new()) }
} else {
Self { surf: pre.clone(), filled: None }
}
impl WorkTiles {
fn new(ch: usize) -> Self {
Self { ch, tiles: std::collections::HashMap::new() }
}
/// The working pixels of `r` (`pre` = the target before the stroke).
fn read(&mut self, pre: &Surface, r: Rect) -> Region {
if let Some(filled) = &mut self.filled
&& !r.is_empty()
{
let t = Self::TILE;
for ty in r.y0.div_euclid(t)..=(r.y1 - 1).div_euclid(t) {
for tx in r.x0.div_euclid(t)..=(r.x1 - 1).div_euclid(t) {
if filled.insert((tx, ty)) {
let cell = Rect::new(tx * t, ty * t, tx * t + t, ty * t + t);
// Normalised samples, stored unrounded as floats.
self.surf.write_region(cell, &pre.read_region(cell));
}
fn tile_rect((tx, ty): (i32, i32)) -> Rect {
Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE)
}
/// The tiles `r` overlaps.
fn coords(r: Rect) -> impl Iterator<Item = (i32, i32)> {
let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE));
let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE));
let empty = r.is_empty();
(y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty)))
}
/// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet.
fn read(&self, pre: &Surface, r: Rect) -> Region {
let mut out = Region::new(r, self.ch);
let n = self.ch;
let mut buf = Vec::new();
for c in Self::coords(r) {
let tr = Self::tile_rect(c);
let part = r.intersect(&tr);
let w = part.width() as usize * n;
let tile = self.tiles.get(&c);
if tile.is_none() {
pre.read_region_into(part, &mut buf);
}
for y in part.y0..part.y1 {
let src = match tile {
Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)),
None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)),
};
let dst = out.index(part.x0, y) * n;
if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) {
d.copy_from_slice(s);
}
}
}
Region::read(&self.surf, r)
out
}
fn write(&mut self, region: &Region) {
region.write(&mut self.surf);
/// Store `region` as the working pixels of its rectangle.
fn write(&mut self, pre: &Surface, region: &Region) {
let (r, n) = (region.rect, self.ch);
for c in Self::coords(r) {
let tr = Self::tile_rect(c);
let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr));
let part = r.intersect(&tr);
let w = part.width() as usize * n;
for y in part.y0..part.y1 {
let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n;
let src = region.index(part.x0, y) * n;
if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) {
d.copy_from_slice(s);
}
}
}
}
}
/// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab
/// runs the tool's effect on a working copy of the target, in order, and the changed area is
/// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release.
/// The working copy keeps full precision ([`WorkTiles`]), so the pixels are the commit's, and the
/// commit takes them over ([`Session::offer_live_dab`]) instead of running every dab again.
/// Sample All Layers isn't previewed (its composite spans the whole stroke).
pub struct LiveDab {
/// The active document with the stroke so far.
@@ -1165,9 +1240,9 @@ pub struct LiveDab {
renderer: photocraft_paint::StrokeRenderer,
done: usize,
effect: DabEffect,
halo: i32,
neighborhood: DabNeighborhood,
pre_surf: Surface,
work: WorkPixels,
work: WorkTiles,
id: Option<LayerId>,
params: Value,
opacity: f32,
@@ -1181,6 +1256,15 @@ pub struct LiveDab {
preview_tail: bool,
/// Where the shown tail is.
tail: Rect,
/// The tool and brush the stroke started with, which its commit must still have: the
/// document revision doesn't cover the session's brush or foreground colour. `None` for
/// strokes whose commit renders itself (Color Replacement).
tool: Option<(DabTool, Box<BrushSettings>)>,
/// The document and revision the stroke started on, for strokes started from the session's
/// active document ([`Self::with_effect`]); `None` for strokes on a prepared copy.
base: Option<(photocraft_doc::DocId, u64)>,
/// Every point rendered so far, in order.
points: Vec<StrokePoint>,
}
impl LiveDab {
@@ -1192,8 +1276,11 @@ impl LiveDab {
}
let (stroke, id) = parse_brush(s, p, cmd)?;
let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?;
let (halo, spacing) = (tool.halo(), stroke.brush.spacing);
Self::with_effect(s, cmd, p, stroke, id, halo, |pre| tool.effect(pre.format(), spacing))
let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone()));
let effect = tool.clone();
let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre| effect.effect(pre.format(), spacing))?;
live.tool = Some((tool, brush));
Ok(live)
}
/// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke)
@@ -1207,23 +1294,47 @@ impl LiveDab {
halo: i32,
effect: impl FnOnce(&Surface) -> DabEffect,
) -> Result<Self> {
let doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone();
Self::with_effect_on(doc, cmd, p, stroke, id, halo, false, effect)
Self::with_neighborhood(s, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect)
}
fn with_neighborhood(
s: &Session,
cmd: &str,
p: &Value,
stroke: Stroke,
id: Option<LayerId>,
neighborhood: DabNeighborhood,
effect: impl FnOnce(&Surface) -> DabEffect,
) -> Result<Self> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let base = (st.doc.id, st.revision);
let doc = (*st.doc).clone();
let mut live = Self::with_neighborhood_on(doc, cmd, p, stroke, id, neighborhood, effect)?;
live.base = Some(base);
Ok(live)
}
/// [`Self::with_effect`] on `doc`, a copy of the active document with what the commit's own
/// edit does first (the Background Eraser's Background turned into a normal layer, say). With
/// `precise` the working pixels between dabs are kept as floats, as the commit keeps them, so
/// the preview is the commit at any depth (else they are stored in the target's depth).
#[allow(clippy::too_many_arguments)]
/// edit does first (the Background Eraser's Background turned into a normal layer, say).
pub(crate) fn with_effect_on(
mut doc: Document,
doc: Document,
cmd: &str,
p: &Value,
stroke: Stroke,
id: Option<LayerId>,
halo: i32,
precise: bool,
effect: impl FnOnce(&Surface) -> DabEffect,
) -> Result<Self> {
Self::with_neighborhood_on(doc, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect)
}
fn with_neighborhood_on(
mut doc: Document,
cmd: &str,
p: &Value,
stroke: Stroke,
id: Option<LayerId>,
neighborhood: DabNeighborhood,
effect: impl FnOnce(&Surface) -> DabEffect,
) -> Result<Self> {
let sel = doc.selection.clone();
@@ -1238,8 +1349,8 @@ impl LiveDab {
renderer,
done: 0,
effect,
halo,
work: WorkPixels::new(&pre_surf, precise),
neighborhood,
work: WorkTiles::new(pre_surf.channels()),
pre_surf,
id,
params: p.clone(),
@@ -1249,6 +1360,9 @@ impl LiveDab {
bounds: Rect::EMPTY,
preview_tail: false,
tail: Rect::EMPTY,
tool: None,
base: None,
points: Vec::new(),
};
live.push(&stroke.points)?;
Ok(live)
@@ -1266,11 +1380,9 @@ impl LiveDab {
self.bounds
}
/// Render more points; returns the rectangle that changed.
/// Invalid coordinates reject the whole batch without changing the preview.
pub fn push(&mut self, pts: &[StrokePoint]) -> Result<Rect> {
crate::brush_cmds::check_coords(pts, &self.cmd)?;
self.renderer.push(pts);
/// Run the dabs the renderer has generated since the last call on the working copy; returns
/// the rectangle they changed.
fn run_new_dabs(&mut self) -> Rect {
let ctx = self.renderer.ctx.clone();
let new: Vec<_> = self.renderer.dabs().get(self.done..).unwrap_or_default().to_vec();
let mut dmg = Rect::EMPTY;
@@ -1279,19 +1391,39 @@ impl LiveDab {
if r.is_empty() {
continue;
}
let mut region = self.work.read(&self.pre_surf, r.inflate(self.halo));
let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d));
(self.effect)(&mut region, &ctx.footprint(d, self.done + k));
self.work.write(&region.crop(r));
self.work.write(&self.pre_surf, &region.crop(r));
dmg = dmg.union(&r);
}
self.done += new.len();
dmg
}
/// Mix the working pixels of `r` back over the original into the shown document.
fn show(&mut self, r: Rect, work: &Region) -> Result<()> {
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
photocraft_paint::retouch::mix_back(surf, &Region::read(&self.pre_surf, r), work, r, self.opacity, self.sel.as_ref(), self.lock);
Ok(())
}
/// Render more points; returns the rectangle that changed.
/// Invalid coordinates reject the whole batch without changing the preview.
/// Only the position and pressure of `pts` are used: the commands take no tilt or times.
pub fn push(&mut self, pts: &[StrokePoint]) -> Result<Rect> {
crate::brush_cmds::check_coords(pts, &self.cmd)?;
let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect();
self.points.extend_from_slice(&pts);
self.renderer.push(&pts);
let dmg = self.run_new_dabs();
let ctx = self.renderer.ctx.clone();
// The finishing dabs, run on a scratch copy of the working pixels.
let mut tail = None;
if self.preview_tail {
let ds = self.renderer.tail_dabs();
let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false)));
if !r.is_empty() {
let mut region = self.work.read(&self.pre_surf, r.inflate(self.halo));
let mut region = self.work.read(&self.pre_surf, r.inflate(self.neighborhood.halo()));
for (k, d) in ds.iter().enumerate() {
(self.effect)(&mut region, &ctx.footprint(d, self.done + k));
}
@@ -1306,17 +1438,42 @@ impl LiveDab {
return Ok(Rect::EMPTY);
}
self.bounds = self.bounds.union(&dmg).union(&tail_rect);
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
if !shown.is_empty() {
let (orig, work) = (Region::read(&self.pre_surf, shown), self.work.read(&self.pre_surf, shown));
photocraft_paint::retouch::mix_back(surf, &orig, &work, shown, self.opacity, self.sel.as_ref(), self.lock);
let work = self.work.read(&self.pre_surf, shown);
self.show(shown, &work)?;
}
if let Some((r, work)) = tail {
photocraft_paint::retouch::mix_back(surf, &Region::read(&self.pre_surf, r), &work, r, self.opacity, self.sel.as_ref(), self.lock);
self.show(r, &work)?;
self.tail = r;
}
Ok(shown.union(&tail_rect))
}
/// The finished stroke as its commit would take it over ([`Session::offer_live_dab`]): the
/// finishing dabs are run, and the pixels are the ones the command with the same params and
/// points paints. Only while the brush, tool settings and layer are still the ones the stroke
/// started with: the document revision doesn't cover those.
pub(crate) fn into_commit(mut self, s: &Session) -> Result<crate::brush_cmds::LiveCommit> {
let (Some((tool, brush)), Some(base)) = (self.tool.take(), self.base) else {
return Err(bad(&self.cmd, "this stroke's commit renders itself"));
};
let (stroke, id) = parse_brush(s, &self.params, &self.cmd)?;
let (now, _) = DabTool::parse(s, &self.cmd, &self.params, &stroke)?;
if now != tool || stroke.brush != *brush || id != self.id {
return Err(bad(&self.cmd, "the brush, tool options or layer changed during the stroke"));
}
self.renderer.finish();
let dmg = self.run_new_dabs();
let shown = dmg.union(&self.tail);
if !shown.is_empty() {
let work = self.work.read(&self.pre_surf, shown);
self.show(shown, &work)?;
}
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 })
}
}
/// "Sample All Layers" for Spot Healing, Blur, Sharpen and Smudge (layer pixels only: a mask or
@@ -119,7 +119,7 @@ pub fn live_impressionist(s: &Session, p: &Value) -> Result<super::LiveDab> {
}
let (stroke, id) = (st.stroke.clone(), st.id);
let doc = (*s.active().ok_or(crate::EngineError::NoDocument)?.doc).clone();
super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, true, |pre| st.impressionist_effect(pre.format()))?.with_tail_preview()
super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, |pre| st.impressionist_effect(pre.format()))?.with_tail_preview()
}
/// The pattern under destination `rect` in `fmt` (native channels): what an aligned stroke paints
+116
View File
@@ -1769,6 +1769,122 @@ fn live_dodge_matches_the_commit_across_cells() {
assert!(worst <= 2e-4, "live and commit differ by {worst}");
}
/// The sequential-dab tools' options for the live hand-off tests.
fn dab_cases() -> [(&'static str, Value); 6] {
[
("paint.dodge", json!({"exposure": 60, "range": "midtones"})),
("paint.burn", json!({"exposure": 40, "range": "shadows", "protectTones": false})),
("paint.sponge", json!({"mode": "saturate"})),
("paint.blur", json!({"strength": 80})),
("paint.sharpen", json!({"strength": 60})),
("paint.smudge", json!({"strength": 70})),
]
}
/// A stroke's params: `points`, a 40 px brush of `hardness`, and the tool's options.
fn dab_opts(points: &[[f64; 2]], hardness: u32, extra: &Value) -> Value {
let mut o = json!({"points": points, "size": 40, "hardness": hardness, "spacing": 25});
for (k, v) in extra.as_object().unwrap() {
o[k] = v.clone();
}
o
}
/// The active layer's pixels over the whole canvas.
fn layer_pixels(s: &Session) -> Vec<f32> {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds())
}
/// Draw `points` live as the canvas does (the first with `begin`, the rest in pen batches that
/// carry tilt and times, which the commands don't take) and offer the stroke to its commit.
fn offer_live(s: &mut Session, cmd: &str, points: &[[f64; 2]], hardness: u32, extra: &Value) {
let mut live = LiveDab::begin(s, cmd, &dab_opts(&points[..1], hardness, extra)).unwrap();
for (i, batch) in points[1..].chunks(2).enumerate() {
let pts: Vec<_> = batch
.iter()
.map(|q| {
let mut p = StrokePoint::new(q[0], q[1], 1.0);
(p.tilt_x, p.time) = (25.0, 16.0 * i as f64);
p
})
.collect();
live.push(&pts).unwrap();
}
s.offer_live_dab(live).unwrap();
}
/// Releasing a live Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke commits the pixels it
/// already drew instead of running every dab again, and they are exactly what the command paints
/// on its own: at every depth (the live working copy isn't rounded between dabs), soft and hard,
/// over semi-transparent pixels and where the stroke doubles back.
#[test]
fn live_dab_strokes_commit_their_pixels_and_they_are_the_commands() {
let points = [[10.0, 40.0], [100.0, 60.0], [180.0, 40.0], [60.0, 30.0], [150.0, 70.0], [151.0, 70.5], [-15.0, 85.0]];
for depth in DEPTHS {
let mut s = session(200, 90, depth, "rgb");
s.execute("layer.new.layer", json!({})).unwrap();
paint_layer(&mut s, |x, y| {
let mut c = texture(x, y);
c[3] = if (x / 23 + y / 17) % 4 == 0 { 0.0 } else { 0.35 + 0.065 * ((x * 7 + y * 3) % 11) as f32 };
c
});
let before = layer_pixels(&s);
for (cmd, extra) in dab_cases() {
for hardness in [0, 100] {
let rendered = s.execute(cmd, dab_opts(&points, hardness, &extra)).unwrap();
let want = layer_pixels(&s);
assert_ne!(want, before, "{cmd} {depth} {hardness}: the stroke changes pixels");
s.execute("edit.undo", json!({})).unwrap();
assert!(rendered.get("fromLive").is_none());
offer_live(&mut s, cmd, &points, hardness, &extra);
let taken = s.execute(cmd, dab_opts(&points, hardness, &extra)).unwrap();
assert_eq!(taken["fromLive"], json!(true), "{cmd} {depth} {hardness}");
assert_eq!(taken["damage"], rendered["damage"], "{cmd} {depth} {hardness}");
assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit, hardness {hardness}: the live pixels aren't the command's");
s.execute("edit.undo", json!({})).unwrap();
assert!(layer_pixels(&s) == before, "{cmd} {depth} {hardness}: undo restores the layer");
}
}
}
}
/// The offer is only taken by its own stroke: another stroke, a brush or option changed during
/// the stroke, or a stroke whose commit renders itself (Color Replacement) render as before.
#[test]
fn live_dab_offers_are_refused_or_dropped_when_they_dont_match() {
let points = [[10.0, 40.0], [100.0, 60.0], [180.0, 40.0]];
let blur = json!({"strength": 80});
let mut s = session(200, 90, 8, "rgb");
paint_layer(&mut s, texture);
// Another stroke drops the offer and renders itself.
offer_live(&mut s, "paint.blur", &points, 0, &blur);
let other = [[10.0, 40.0], [100.0, 61.0], [180.0, 40.0]];
let r = s.execute("paint.blur", dab_opts(&other, 0, &blur)).unwrap();
assert!(r.get("fromLive").is_none());
assert!(s.live_commit.is_none(), "the offer is used up");
s.execute("edit.undo", json!({})).unwrap();
// The session brush (not in the params) changed during the stroke; the refusal also
// withdraws an earlier offer.
offer_live(&mut s, "paint.blur", &points, 0, &blur);
let live = LiveDab::begin(&s, "paint.blur", &dab_opts(&points, 0, &blur)).unwrap();
let roundness = s.tools.brush.roundness;
s.tools.brush.roundness = roundness * 0.5;
assert!(s.offer_live_dab(live).is_err());
assert!(s.live_commit.is_none(), "a refused offer leaves no earlier one behind");
s.tools.brush.roundness = roundness;
// Finger Painting's colour changed during the stroke.
let finger = json!({"strength": 70, "fingerPainting": true});
let live = LiveDab::begin(&s, "paint.smudge", &dab_opts(&points, 0, &finger)).unwrap();
s.tools.foreground = [0.9, 0.1, 0.1, 1.0];
assert!(s.offer_live_dab(live).is_err());
// Color Replacement's live stroke runs on `LiveDab` too, but its commit renders itself.
let live = crate::brush_cmds::live_color_replacement(&s, &json!({"points": points, "color": [1.0, 0.0, 0.0, 1.0]})).unwrap();
assert!(s.offer_live_dab(live).is_err());
assert!(s.live_commit.is_none());
}
#[test]
fn retouch_strokes_refuse_absurd_point_coordinates() {
// #976: a segment to (1e300, 0) has an infinite length and the dab walker would never stop, so
+48 -8
View File
@@ -119,6 +119,10 @@ fn new_blank(s: &mut Session, _p: &Value) -> Result<Value> {
}
let fps = doc.timeline.as_ref().map_or(30.0, |t| t.fps);
let n = doc.timeline.as_ref().map_or(1, |t| t.duration).max(1);
// A loaded timeline's duration bypasses the validation of timeline.create / setProps.
if n > crate::timeline_cmds::MAX_DURATION {
return Err(EngineError::Other(format!("the timeline is {n} frames; a video layer is limited to {} frames", crate::timeline_cmds::MAX_DURATION)));
}
let frames: Vec<Surface> = (0..n).map(|_| Surface::new(fmt)).collect();
let name = doc.next_layer_name("Video Layer");
let mut l = Layer::raster(name, fmt);
@@ -256,6 +260,9 @@ type ImportFrames = fn(
&mut dyn FnMut(photocraft_doc::Document, u32) -> std::result::Result<(), photocraft_io::IoError>,
) -> std::result::Result<usize, photocraft_io::IoError>;
/// Frame storage one footage import may keep (whole tiles, all frames together).
const MAX_FOOTAGE_BYTES: u64 = 2 << 30;
/// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF or WebP), or a
/// directory of images (sorted = an image sequence). Each becomes a full raster frame in the
/// document's format.
@@ -281,8 +288,23 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result<Footage
if files.is_empty() {
return Err(EngineError::Other(format!("no images in `{path}`")));
}
let mut frames = Vec::with_capacity(files.len());
let mut frames: Vec<Surface> = Vec::with_capacity(files.len());
let mut delays = Vec::new();
// Each frame stores whole tiles however little it draws, so count the tiles kept so far.
let mut kept_bytes = 0u64;
let mut keep = |frames: &mut Vec<Surface>, surface: Surface| -> std::result::Result<(), String> {
// A frame of fully transparent pixels stores no tiles, so the count needs its own limit.
if frames.len() >= crate::timeline_cmds::MAX_DURATION {
return Err(format!("the footage has more than {} frames", crate::timeline_cmds::MAX_DURATION));
}
let tile = u64::from(photocraft_geom::TILE_SIZE.unsigned_abs()).pow(2) * surface.format().bytes_per_pixel() as u64;
kept_bytes = kept_bytes.saturating_add(tile.saturating_mul(surface.tile_count() as u64));
if kept_bytes > MAX_FOOTAGE_BYTES {
return Err(format!("the footage needs more than {} MiB of frames", MAX_FOOTAGE_BYTES >> 20));
}
frames.push(surface);
Ok(())
};
for f in &files {
let bytes = std::fs::read(f).map_err(|e| EngineError::Other(format!("read `{}`: {e}", f.display())))?;
let name = f.to_string_lossy();
@@ -293,12 +315,19 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result<Footage
_ => None,
};
if let Some(import) = animation {
import(&name, &bytes, &mut |doc, delay_ms| {
frames.push(crate::file_cmds::flattened(&doc, fmt));
let mut over = None;
let done = import(&name, &bytes, &mut |doc, delay_ms| {
if let Err(msg) = keep(&mut frames, crate::file_cmds::flattened(&doc, fmt)) {
over = Some(msg);
return Err(photocraft_io::IoError::Cancelled);
}
delays.push(delay_ms);
Ok(())
})
.map_err(|e| EngineError::Other(format!("`{}`: {e}", f.display())))?;
});
if let Some(msg) = over {
return Err(EngineError::Other(format!("`{}`: {msg}", f.display())));
}
done.map_err(|e| EngineError::Other(format!("`{}`: {e}", f.display())))?;
continue;
}
let mut doc = crate::file_cmds::import(&name, &bytes)?;
@@ -306,7 +335,8 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result<Footage
if f.extension().is_some_and(|e| e.eq_ignore_ascii_case("tga")) {
photocraft_io::tga_alpha_channel_to_transparency(&mut doc);
}
frames.push(crate::file_cmds::flattened(&doc, fmt));
keep(&mut frames, crate::file_cmds::flattened_checked(&doc, fmt, "layer.videoLayers.newVideoLayerFromFile")?)
.map_err(|msg| EngineError::Other(format!("`{}`: {msg}", f.display())))?;
}
// Only a lone GIF or WebP says how fast it plays; a sequence mixing files has no single rate.
let fps = if files.len() == 1 { fps_from_delays(&delays) } else { None };
@@ -421,6 +451,15 @@ fn frames_to_layers(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layers": n}))
}
/// The document's name without its extension, as a file name: a native document keeps whatever
/// name its file declares, parent-directory components included, and the frames are written
/// beside the chosen folder, not wherever the name leads.
fn safe_stem(name: &str) -> String {
let leaf = name.rsplit(['/', '\\']).next().unwrap_or(name);
let stem = leaf.rsplit_once('.').map_or(leaf, |(a, _)| a).trim();
if stem.is_empty() || stem.chars().all(|c| c == '.') { "video".to_string() } else { stem.to_string() }
}
/// Export › Render Video: composite the whole document at each timeline frame and write an image
/// sequence (`{dir}/{stem}_NNNN.{format}`). Clean-room; no proprietary codec.
fn render_video(s: &mut Session, p: &Value) -> Result<Value> {
@@ -450,7 +489,7 @@ pub fn render_video_with(
// Only the GIF delay reads the rate (frames come from the duration); rates under 1 fps are
// valid timelines, so only an unusable loaded rate falls back to 30.
let fps = base.timeline.as_ref().map(|t| t.fps).filter(|f| f.is_finite() && *f > 0.0).unwrap_or(30.0);
let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string();
let stem = safe_stem(&base.name);
let fmt = base.pixel_format();
let bounds = base.bounds();
let (w, h) = (bounds.width() as usize, bounds.height() as usize);
@@ -495,7 +534,8 @@ pub fn render_video_with(
}
// Image sequence.
let opts = photocraft_io::ExportOptions::default();
// PSD / PSB frames would embed the files linked smart objects name: not for a restricted session.
let opts = photocraft_io::ExportOptions { read_linked_files: s.authorize.is_none(), ..Default::default() };
let mut files = Vec::new();
for f in 0..frames {
let mut doc = base.clone();
+11
View File
@@ -669,3 +669,14 @@ fn fps_from_delays_picks_most_common_and_breaks_ties_by_first_seen() {
many.push(20);
assert_eq!(fps_from_delays(&many), Some(50.0));
}
#[test]
fn render_video_names_frames_after_the_leaf_of_the_document_name() {
assert_eq!(safe_stem("clip.mov"), "clip");
assert_eq!(safe_stem("../../outside/clip.png"), "clip");
assert_eq!(safe_stem("C:\\Users\\x\\clip.v2.gif"), "clip.v2");
for hostile in ["..", "../..", "", "/", "a/..", ".hidden."] {
let stem = safe_stem(hostile);
assert!(!stem.contains(['/', '\\']) && !stem.is_empty() && stem.chars().any(|c| c != '.'), "{hostile:?} -> {stem:?}");
}
}
+2 -2
View File
@@ -513,13 +513,13 @@ fn same_file_sees_through_other_names_for_one_file() {
std::fs::write(&a, b"a").unwrap();
std::fs::write(&c, b"c").unwrap();
assert!(crate::file_cmds::same_file(&a, &a));
assert!(crate::file_cmds::same_file(&a, &format!("{dir}/./a.png")));
assert!(crate::file_cmds::same_file(&a, format!("{dir}/./a.png")));
assert!(!crate::file_cmds::same_file(&a, &c));
assert!(!crate::file_cmds::same_file(&a, &b), "a missing file is not the same file");
std::fs::hard_link(&a, &b).unwrap();
assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file");
#[cfg(windows)]
assert!(crate::file_cmds::same_file(&a, &format!("{dir}/A.PNG")), "NTFS names ignore case");
assert!(crate::file_cmds::same_file(&a, format!("{dir}/A.PNG")), "NTFS names ignore case");
}
#[test]
@@ -0,0 +1,216 @@
use photocraft_engine::{Session, retouch_cmds::LiveDab};
use photocraft_paint::StrokePoint;
use serde_json::json;
fn setup(depth: u32, mode: &str) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":200,"height":120,"depth":depth,"mode":mode})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("texture", |doc, active| {
let bounds = doc.bounds();
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let fmt = surf.format();
let mut px = Vec::new();
for y in 0..120 {
for x in 0..200 {
px.extend(photocraft_raster::from_rgba(
&fmt,
[((x * 7 + y * 3) % 31) as f32 / 31.0, x as f32 / 200.0, y as f32 / 120.0, 0.4 + ((x + y) % 7) as f32 / 12.0],
));
}
}
surf.write_region(bounds, &px);
Ok(())
})
.unwrap();
s
}
fn pixels(s: &Session) -> Vec<f32> {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds())
}
#[test]
fn pressure_shape_and_smoothing_match_replay() {
let mut failures = Vec::new();
for cmd in ["paint.smudge", "paint.blur", "paint.sharpen", "paint.dodge", "paint.burn", "paint.sponge"] {
for case in ["pressure", "locked", "ellipse", "smoothing", "scattering"] {
for depth in [8, 16, 32] {
let mut s = setup(depth, "rgb");
match case {
"locked" => {
s.edit("lock alpha", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().locks.transparency = true;
Ok(())
})
.unwrap();
}
"ellipse" => {
s.tools.brush.roundness = 0.2;
s.tools.brush.angle = 37.0;
}
"smoothing" => {
s.tools.brush.smoothing.amount = 0.6;
}
"scattering" => {
s.tools.brush.scattering.enabled = true;
s.tools.brush.scattering.scatter.jitter = 1.0;
}
_ => {}
}
let points = [[40.0, 40.0, 0.1], [65.0, 50.0, 0.4], [100.0, 60.0, 1.0], [140.0, 70.0, 0.7], [60.0, 40.0, 0.2]];
let params = json!({"points":points,"size":40,"hardness":70,"strength":80,"opacity":55,"spacing":25});
s.execute(cmd, params.clone()).unwrap();
let want = pixels(&s);
s.execute("edit.undo", json!({})).unwrap();
let mut start = params.clone();
start["points"] = json!([points[0]]);
let mut live = LiveDab::begin(&s, cmd, &start).unwrap();
for q in &points[1..] {
live.push(&[StrokePoint::new(q[0], q[1], q[2] as f32)]).unwrap();
}
s.offer_live_dab(live).unwrap();
let result = s.execute(cmd, params).unwrap();
assert_eq!(result["fromLive"], true);
let got = pixels(&s);
let count = got.iter().zip(&want).filter(|(a, b)| a != b).count();
let max = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
if count > 0 {
failures.push(format!("{cmd} {case} depth={depth}: {count} channels differ, max={max}"));
}
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn native_modes_selection_and_lock_match_replay() {
for mode in ["rgb", "gray", "cmyk", "lab"] {
for depth in [8, 16, 32] {
let mut s = setup(depth, mode);
s.edit("lock alpha", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().locks.transparency = true;
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x":30,"y":20,"width":110,"height":60})).unwrap();
let points = [[-10.0, 30.0], [80.0, 50.0], [160.0, 80.0], [60.0, 60.0]];
for cmd in ["paint.blur", "paint.sharpen", "paint.dodge", "paint.burn", "paint.sponge", "paint.smudge"] {
let params = json!({"points":points,"size":30,"hardness":40,"strength":70,"opacity":37});
let expected_damage = s.execute(cmd, params.clone()).unwrap()["damage"].clone();
let want = pixels(&s);
s.execute("edit.undo", json!({})).unwrap();
let mut start = params.clone();
start["points"] = json!([points[0]]);
let mut live = LiveDab::begin(&s, cmd, &start).unwrap();
for q in &points[1..] {
live.push(&[StrokePoint::new(q[0], q[1], 1.0)]).unwrap();
}
s.offer_live_dab(live).unwrap();
let result = s.execute(cmd, params).unwrap();
assert_eq!(result["fromLive"], true);
assert_eq!(result["damage"], expected_damage);
assert_eq!(pixels(&s), want, "{mode} {depth} {cmd}");
s.execute("edit.undo", json!({})).unwrap();
}
}
}
}
#[test]
fn changed_document_refuses_live_pixels() {
let mut s = setup(8, "rgb");
let p = json!({"points":[[40,40],[80,60]],"size":30});
let live = LiveDab::begin(&s, "paint.blur", &p).unwrap();
s.execute("paint.stroke", json!({"points":[[40,40],[80,60]],"color":"#ffffff","size":20})).unwrap();
let mut replay = Session::new();
replay.add_document((*s.active().unwrap().doc).clone(), None);
replay.tools.brush = s.tools.brush.clone();
s.offer_live_dab(live).unwrap();
assert!(s.execute("paint.blur", p.clone()).unwrap().get("fromLive").is_none());
replay.execute("paint.blur", p).unwrap();
assert_eq!(pixels(&s), pixels(&replay));
}
/// An independent paint-level oracle gives Smudge a generously padded working region. Pixels
/// picked up by the initial small dab must not depend on the final stroke's bounding rectangle.
#[test]
fn smudge_pickup_matches_full_neighborhood_when_pressure_expands() {
use photocraft_paint::{
Stroke,
retouch::{Smudge, apply_dab_stroke},
};
let points = [[40.0, 40.0, 0.1], [65.0, 50.0, 0.4], [100.0, 60.0, 1.0], [140.0, 70.0, 0.7], [60.0, 40.0, 0.2]];
for depth in [8, 16, 32] {
let mut s = setup(depth, "rgb");
let params = json!({"points":points,"size":40,"hardness":70,"strength":80,"opacity":55,"spacing":25});
let (mut expected, lock) = {
let st = s.active().unwrap();
let id = st.active_layer.unwrap();
(st.doc.layer(id).unwrap().surface().unwrap().clone(), st.doc.effective_locks(id).transparency)
};
let stroke = Stroke {
brush: photocraft_engine::retouch_cmds::stroke_brush(&s.tools.brush, &params),
points: points.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect(),
};
let mut effect = Smudge::new(0.8, None, 40.0).with_alpha(Some(3));
apply_dab_stroke(&mut expected, &stroke, None, lock, 40, |work, fp| effect.dab(work, fp));
let bounds = s.active().unwrap().doc.bounds();
let want = expected.read_region(bounds);
s.execute("paint.smudge", params.clone()).unwrap();
assert!(pixels(&s) == want, "command must read the whole pickup square at depth {depth}");
s.execute("edit.undo", json!({})).unwrap();
let live = LiveDab::begin(&s, "paint.smudge", &params).unwrap();
s.offer_live_dab(live).unwrap();
assert_eq!(s.execute("paint.smudge", params).unwrap()["fromLive"], true);
assert!(pixels(&s) == want, "a single batch uses the same pickup square at depth {depth}");
}
}
#[test]
fn pressure_sized_live_dabs_commit_only_the_requested_mask_or_channel() {
use photocraft_raster::Surface;
let fill = |surface: &mut Surface| {
let values: Vec<f32> = (0..120).flat_map(|y| (0..200).map(move |x| ((x * 7 + y * 3) % 31) as f32 / 31.0)).collect();
surface.write_region(photocraft_geom::Rect::new(0, 0, 200, 120), &values);
};
for depth in [8, 16, 32] {
let mut s = setup(depth, "rgb");
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("channel.new", json!({"fill":"white"})).unwrap();
s.execute("select.editInQuickMaskMode", json!({"on":true})).unwrap();
s.edit("mask texture", |doc, active| {
fill(&mut doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap().surface);
fill(&mut doc.channels[0].surface);
fill(&mut doc.quick_mask.as_mut().unwrap().surface);
Ok(())
})
.unwrap();
let before = s.active().unwrap().doc.clone();
for target in [json!("mask"), json!({"channel":0}), json!("quickMask")] {
for cmd in ["paint.smudge", "paint.blur", "paint.sharpen", "paint.dodge", "paint.burn", "paint.sponge"] {
let points = [[40.0, 40.0, 0.1], [65.0, 50.0, 0.4], [100.0, 60.0, 1.0], [140.0, 70.0, 0.7], [60.0, 40.0, 0.2]];
let params = json!({"target":target,"points":points,"size":40,"hardness":70,"strength":80,"opacity":55,"spacing":25});
s.execute(cmd, params.clone()).unwrap();
let want = s.active().unwrap().doc.clone();
s.execute("edit.undo", json!({})).unwrap();
let mut start = params.clone();
start["points"] = json!([points[0]]);
let mut live = LiveDab::begin(&s, cmd, &start).unwrap();
for q in &points[1..] {
live.push(&[StrokePoint::new(q[0], q[1], q[2] as f32)]).unwrap();
}
s.offer_live_dab(live).unwrap();
assert_eq!(s.execute(cmd, params).unwrap()["fromLive"], true);
assert_eq!(s.active().unwrap().doc, want, "depth={depth} {cmd} target={target}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
}
}
}
}
+10 -2
View File
@@ -57,7 +57,7 @@ pub fn map_file(f: &AbrFile, group: &str) -> AbrImport {
/// [`map_file`] that checks `ctl` before each brush.
pub fn map_file_with(f: &AbrFile, group: &str, ctl: &photocraft_raster::Interrupt) -> Result<AbrImport, photocraft_raster::Cancelled> {
let mut out = AbrImport { version: f.version, warnings: f.warnings.clone(), ..Default::default() };
let mut m = Mapper { file: f, warnings: BTreeSet::new(), unknown: BTreeSet::new() };
let mut m = Mapper { file: f, warnings: BTreeSet::new(), unknown: BTreeSet::new(), tips: Default::default() };
let total = f.legacy.len().max(f.samples.len()).max(f.presets.len()).max(1) as f32;
let step = |i: usize| -> Result<(), photocraft_raster::Cancelled> {
ctl.check()?;
@@ -255,18 +255,26 @@ struct Mapper<'a> {
file: &'a AbrFile,
warnings: BTreeSet<String>,
unknown: BTreeSet<String>,
/// Tips already converted, by sample: presets can name one sample thousands of times, and a
/// tile clones by sharing its data.
tips: std::collections::HashMap<*const AbrSample, (photocraft_paint::tile::GrayTile, f32)>,
}
impl Mapper<'_> {
/// A sampled tip (downsampled to [`MAX_TIP_EDGE`]) and its size (larger side).
fn tip(&mut self, s: &AbrSample) -> (TipShape, f32) {
if let Some((tile, size)) = self.tips.get(&std::ptr::from_ref(s)) {
return (TipShape::Sampled(tile.clone()), *size);
}
let v = s.to_unit();
let (w, h, v) = fit(s.width, s.height, v, MAX_TIP_EDGE);
if (w, h) != (s.width, s.height) {
self.warnings.insert(format!("tips larger than {MAX_TIP_EDGE} px were downsampled"));
}
let data = v.iter().map(|x| (x.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect();
(TipShape::Sampled(GrayTile { width: w, height: h, data }), s.width.max(s.height) as f32)
let (tile, size) = (GrayTile { width: w, height: h, data }, s.width.max(s.height) as f32);
self.tips.insert(std::ptr::from_ref(s), (tile.clone(), size));
(TipShape::Sampled(tile), size)
}
fn dynamic(&mut self, d: Option<&Descriptor>) -> Dynamic {
+11 -1
View File
@@ -443,7 +443,17 @@ impl Builder {
})
});
let mut sh = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() };
sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas));
// The pixel cache is optional (the shape renders from its path without it), so charge the
// area it would cover to the import's pixel budget and go without it when that is spent:
// geometry across a 300000 px canvas would otherwise be rasterized for every shape.
let reach = sh.stroke.as_ref().map_or(0, |s| (s.width.ceil() as i32).saturating_add(1));
let area = frame.inflate(reach).intersect(&self.canvas);
let cost = u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(self.format.bytes_per_pixel() as u64);
if charge(&mut self.pixel_budget, cost) {
sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas));
} else {
self.warn("shapes covering more pixels than the import's memory allows were left without a pixel cache");
}
sh
}
+1 -1
View File
@@ -112,7 +112,7 @@ impl Reader<'_, '_> {
if file.pixel_size != space.pixel_size() {
return Err(invalid(format!("{name}: pixel size {} does not match its colour space", file.pixel_size)));
}
let cost = file.decoded_bytes(fmt.bytes_per_pixel())?;
let cost = file.decoded_bytes(fmt.bytes_per_pixel(), dx, dy)?;
self.budget = self.budget.checked_sub(cost).ok_or_else(|| invalid("decoded layers exceed the 1 GiB limit"))?;
let mut surface = match self.default_pixel(name, space)? {
Some(px) if px.iter().any(|v| *v != 0.0) => Surface::with_default(fmt, &space.to_format(&px, fmt)),
+49 -3
View File
@@ -216,9 +216,27 @@ impl<'a> TileFile<'a> {
Ok(TileFile { width, height, pixel_size, tiles })
}
/// Bytes the decoded tiles take at `bpp` bytes per pixel.
pub(crate) fn decoded_bytes(&self, bpp: usize) -> Result<usize> {
self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(|| invalid("tile size overflow"))
/// Bytes the decoded tiles take at `bpp` bytes per pixel: the larger of their own pixels and
/// the whole 256×256 backing tiles they land in (a surface stores no less than a tile, so
/// sparse tiles at distinct positions retain far more than they hold).
pub(crate) fn decoded_bytes(&self, bpp: usize, dx: i32, dy: i32) -> Result<usize> {
let overflow = || invalid("tile size overflow");
let logical = self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(overflow)?;
let side = photocraft_geom::TILE_SIZE;
let (w, h) = (i32::try_from(self.width.max(1)).map_err(|_| overflow())?, i32::try_from(self.height.max(1)).map_err(|_| overflow())?);
let mut backing = std::collections::HashSet::new();
for &(x, y, _) in &self.tiles {
// The tile lands at its offset position and covers every backing tile its box overlaps.
let (x, y) = (x.checked_add(dx).ok_or_else(overflow)?, y.checked_add(dy).ok_or_else(overflow)?);
let (x1, y1) = (x.checked_add(w - 1).ok_or_else(overflow)?, y.checked_add(h - 1).ok_or_else(overflow)?);
for c in x.div_euclid(side)..=x1.div_euclid(side) {
for r in y.div_euclid(side)..=y1.div_euclid(side) {
backing.insert((c, r));
}
}
}
let tile_bytes = (side as usize).checked_mul(side as usize).and_then(|n| n.checked_mul(bpp)).ok_or_else(overflow)?;
Ok(logical.max(backing.len().checked_mul(tile_bytes).ok_or_else(overflow)?))
}
/// Writes every tile into `surface` (format `surface.format()`), offset by (`dx`, `dy`).
@@ -260,3 +278,31 @@ impl<'a> TileFile<'a> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn file(positions: &[(i32, i32)]) -> TileFile<'static> {
TileFile { width: 64, height: 64, pixel_size: 4, tiles: positions.iter().map(|&(x, y)| (x, y, &[][..])).collect() }
}
#[test]
fn sparse_tiles_are_charged_for_the_backing_tiles_they_land_in() {
// Sixteen 64×64 tiles that fill one 256×256 backing tile cost what they hold.
let dense: Vec<_> = (0..4).flat_map(|y| (0..4).map(move |x| (x * 64, y * 64))).collect();
assert_eq!(file(&dense).decoded_bytes(4, 0, 0).unwrap(), 16 * 64 * 64 * 4);
// The same number far apart each take a whole backing tile: 16 × 256 × 256 × 4.
let sparse: Vec<_> = (0..16).map(|i| (i * 10_240, 0)).collect();
assert_eq!(file(&sparse).decoded_bytes(4, 0, 0).unwrap(), 16 * 256 * 256 * 4);
}
#[test]
fn the_placement_offset_and_tile_extent_decide_the_backing_tiles() {
// One 64×64 tile straddling a backing-tile corner once offset touches four.
assert_eq!(file(&[(0, 0)]).decoded_bytes(4, 224, 224).unwrap(), 4 * 256 * 256 * 4);
// A tile wider than a backing tile reaches the ones between its corners.
let wide = TileFile { width: 1024, height: 1, pixel_size: 4, tiles: vec![(0, 0, &[][..])] };
assert_eq!(wide.decoded_bytes(4, 0, 0).unwrap(), 4 * 256 * 256 * 4);
}
}
+19 -3
View File
@@ -520,6 +520,20 @@ fn png(w: u32, h: u32, px: &[[f32; 4]], sample: CSample) -> Result<Vec<u8>> {
/// `px` (row-major over `rect`) cut down to the pixels that aren't fully transparent, with
/// transparent pixels zeroed, so masked-out or empty margins aren't written.
/// Most bytes of straight RGBA floats one layer may take while it is written. A layer is one PNG
/// of its content's bounding box, so a few pixels far apart make a box of mostly nothing; refuse
/// that rather than allocate it. 1.5 × [`MAX_ENTRY`] of floats is at most 8 B/px × a third of that
/// raw, so the PNG stays within what [`Reader::png`] will read back.
const MAX_EXPORT_LAYER_BYTES: u64 = 3 * MAX_ENTRY as u64 / 2;
fn admit_layer(rect: Rect) -> Result<()> {
let bytes = u64::from(rect.width()).saturating_mul(u64::from(rect.height())).saturating_mul(std::mem::size_of::<[f32; 4]>() as u64);
if bytes > MAX_EXPORT_LAYER_BYTES {
return Err(IoError::Unsupported(format!("a {}×{} layer is too large to save to OpenRaster", rect.width(), rect.height())));
}
Ok(())
}
fn crop(rect: Rect, mut px: Vec<[f32; 4]>) -> (Rect, Vec<[f32; 4]>) {
let w = rect.width() as usize;
if w == 0 {
@@ -609,11 +623,12 @@ impl Writer<'_> {
}
/// The layer's own pixels (straight RGBA floats) and their bounds; its pixel mask applied.
fn pixels(&mut self, layer: &Layer) -> (Rect, Vec<[f32; 4]>) {
fn pixels(&mut self, layer: &Layer) -> Result<(Rect, Vec<[f32; 4]>)> {
let canvas = self.doc.bounds();
let (rect, mut buf) = match &layer.content {
LayerContent::Raster(s) => {
let r = s.content_bounds();
admit_layer(r)?;
(r, photocraft_compose::surface_to_buffer(s, r).px)
}
_ => {
@@ -627,6 +642,7 @@ impl Writer<'_> {
solo.mask = None;
solo.effects = Default::default();
let r = photocraft_compose::composite_bounds(&solo, canvas).unwrap_or(canvas).intersect(&canvas);
admit_layer(r)?;
(r, photocraft_compose::render_layer(&solo, r).px)
}
};
@@ -642,7 +658,7 @@ impl Writer<'_> {
p[3] *= layer.fill_opacity.clamp(0.0, 1.0);
}
}
crop(rect, buf)
Ok(crop(rect, buf))
}
fn layers(&mut self, layers: &[Layer], depth: usize) -> Result<()> {
@@ -671,7 +687,7 @@ impl Writer<'_> {
if self.dry {
continue;
}
let (rect, px) = self.pixels(layer);
let (rect, px) = self.pixels(layer)?;
let (rect, px) = if rect.is_empty() { (Rect::from_xywh(0, 0, 1, 1), vec![[0.0; 4]]) } else { (rect, px) };
let src = format!("data/layer{}.png", self.next);
self.next += 1;
+30 -11
View File
@@ -21,6 +21,10 @@ use crate::psd_import::REGENERATED;
const RASTER_STALE_KEYS: [&[u8; 4]; 8] = [b"SoCo", b"GdFl", b"PtFl", b"SoLd", b"PlLd", b"SoLE", b"TySh", b"vscg"];
const PSD_MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024;
/// Most bytes of one layer's interleaved samples the exporter gathers. The interleaved bytes and
/// the channel planes made from them (or the band parts and the joined buffer) are alive together,
/// so the peak is about twice this; it is also what fits in a file of at most [`PSD_MAX_FILE_SIZE`].
const MAX_LAYER_GATHER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 };
const PSD_ESTIMATE_FIXED_OVERHEAD: u64 = 1024 * 1024;
const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024;
@@ -165,8 +169,9 @@ struct SmartOut {
prune_ok: bool,
/// Files to embed.
add: Vec<crate::linked::LinkedFile>,
/// Sources converted so far, by contents identity and bytes (`Err` = can't be embedded).
converted: std::collections::HashMap<(SmartContentsId, [u8; 32]), Result<Source, String>>,
/// Sources converted so far, by contents identity, bytes and whether they were read from a
/// link (`Err` = can't be embedded).
converted: std::collections::HashMap<(SmartContentsId, [u8; 32], bool), Result<Source, String>>,
/// Each placed layer has its own id, even when it duplicates an imported layer.
placed_used: std::collections::HashSet<String>,
/// Original instances reserve their imported placement regardless of layer order.
@@ -382,6 +387,13 @@ impl Ex {
if r.is_empty() {
return (PsdRect::default(), self.empty_channels(), warnings);
}
// A layer record is one dense rectangle, so a few pixels far apart make a box of mostly
// nothing, gathered and then copied again. Past the cap the layer is saved empty, said so.
let dense = u64::from(r.width()).saturating_mul(u64::from(r.height())).saturating_mul(s.format().bytes_per_pixel() as u64);
if dense > MAX_LAYER_GATHER_BYTES {
warnings.push(format!("layer \"{name}\" spans {}×{} px, too large to write: saved without its pixels", r.width(), r.height()));
return (PsdRect::default(), self.empty_channels(), warnings);
}
// The interleaved gather is the sequential pass for full-canvas layers, so it runs
// in row bands on all cores and the band results concatenate in order.
let row_len = r.width() as usize;
@@ -994,13 +1006,14 @@ impl Ex {
let file = crate::doc_path::resolve(path, self.document.as_deref()).map_err(|e| format!("the linked file {path} is not opened: {e}"))?;
let bytes = std::fs::read(file).map_err(|e| format!("the linked file {path} can't be read: {e}"))?;
let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string();
let source = self.embed(sm.contents_id, &name, &bytes, true)?;
self.warnings.push(format!("the linked smart object {name} was embedded (PSD export keeps no external links)"));
self.embed(sm.contents_id, &name, &bytes)
Ok(source)
}
#[cfg(target_arch = "wasm32")]
Err(format!("the linked file {path} can't be read here"))
}
SmartSource::Embedded { file_name, bytes } => self.embed(sm.contents_id, file_name, bytes),
SmartSource::Embedded { file_name, bytes } => self.embed(sm.contents_id, file_name, bytes, false),
}
}
@@ -1045,17 +1058,19 @@ impl Ex {
/// Adds `bytes` as an embedded file (converted to PSB when it is a `.pcraft` bundle), once
/// per contents identity and bytes. Equal bytes in independent objects stay independent.
fn embed(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8]) -> Result<Source, String> {
let key = (contents_id, *blake3::hash(bytes).as_bytes());
/// `linked`: the bytes were read from a path the document names, so only a file PhotoCraft
/// can open is embedded (a link to a text or settings file is written as pixels instead).
fn embed(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8], linked: bool) -> Result<Source, String> {
let key = (contents_id, *blake3::hash(bytes).as_bytes(), linked);
if let Some(r) = self.smart.converted.get(&key) {
return r.clone();
}
let r = self.convert_source(contents_id, file_name, bytes);
let r = self.convert_source(contents_id, file_name, bytes, linked);
self.smart.converted.insert(key, r.clone());
r
}
fn convert_source(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8]) -> Result<Source, String> {
fn convert_source(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8], linked: bool) -> Result<Source, String> {
let (name, data, size, dpi, nested) = if photocraft_format::is_pcraft(bytes) {
if self.smart.depth >= MAX_NESTING {
return Err(format!("smart objects nest more than {MAX_NESTING} deep"));
@@ -1073,9 +1088,13 @@ impl Ex {
let size = (f64::from(doc.size.width), f64::from(doc.size.height));
(format!("{stem}.psb"), data, size, f64::from(doc.resolution_dpi), Some(doc))
} else {
// Formats we can't decode (vector PDF/AI…) are still embedded; their size comes from the layer.
let (size, dpi) =
crate::linked::source_geometry(file_name, bytes, &photocraft_raster::Interrupt::NONE).unwrap_or(((0.0, 0.0), f64::from(self.dpi)));
// Embedded formats we can't decode (vector PDF/AI…) are kept; their size comes from the
// layer. A linked file we can't decode is not embedded.
let (size, dpi) = match crate::linked::source_geometry(file_name, bytes, &photocraft_raster::Interrupt::NONE) {
Ok(geometry) => geometry,
Err(e) if linked => return Err(format!("the linked file {file_name} is not an image PhotoCraft can open: {e}")),
Err(_) => ((0.0, 0.0), f64::from(self.dpi)),
};
(file_name.to_string(), bytes.to_vec(), size, dpi, None)
};
let uuid = contents_uuid(contents_id, &data);
+38 -3
View File
@@ -59,6 +59,13 @@ pub(crate) struct Ctx<'a> {
pub txt2: Option<photocraft_text::engine_data::Value>,
/// Smart-filter caches from the global `FEid`/`FXid` blocks (filter masks, by placed id).
pub filter_effects: Vec<photocraft_psd::filter_effects::FilterEffectsItem>,
/// Filter masks already decoded, by placed id: every layer of a source reads the same item, and
/// a surface clones by sharing its tiles.
pub filter_masks: std::collections::HashMap<String, Option<LayerMask>>,
/// The same for the unfiltered pixels the mask mixes back in.
pub unfiltered: std::collections::HashMap<String, Option<Surface>>,
/// Pixel bytes retained in [`Self::unfiltered`], held under [`MAX_UNFILTERED_BYTES`].
pub unfiltered_bytes: u64,
/// Placed layers naming the same source share editable contents, not their placement.
pub smart_contents: std::collections::HashMap<String, SmartContentsId>,
/// Source sizes of `PlLd`-only placed layers, by source id (read once per embedded file).
@@ -278,14 +285,19 @@ impl Ctx<'_> {
/// document coordinates, white beyond its bounds (`filterMaskExtendWithWhite`). An all-white
/// mask is no mask.
fn filter_mask(&mut self, placed: &str, stack: &crate::smart_map::FilterStack, name: &str) -> Option<LayerMask> {
if let Some(done) = self.filter_masks.get(placed) {
return done.clone();
}
let item = self.filter_effects.iter().find(|i| i.id == placed)?;
match crate::smart_map::mask_from_item(item, self.mask_fmt.sample, stack) {
let mask = match crate::smart_map::mask_from_item(item, self.mask_fmt.sample, stack) {
Ok(m) => m,
Err(e) => {
self.warn(format!("layer \"{name}\": {e}; the filter mask was ignored"));
None
}
}
};
self.filter_masks.insert(placed.to_string(), mask.clone());
mask
}
fn apply_common(&mut self, l: &mut Layer, rec: &LayerRecord, blend_override: Option<photocraft_psd::BlendMode>) {
@@ -459,7 +471,24 @@ impl Ctx<'_> {
let shown = stack.enabled && stack.filters.iter().any(|f| f.visible);
let cache = match (&placed, filter_mask.as_ref().filter(|m| m.enabled && shown)) {
(Some(p), Some(m)) => {
let unfiltered = self.filter_effects.iter().find(|i| i.id == p.placed).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt));
let unfiltered = match self.unfiltered.get(&p.placed) {
Some(done) => done.clone(),
None => {
// Every distinct source is kept until the import ends, so what they add up to is
// bounded: past the limit the layer keeps its stored pixels, unmixed.
let item = self.filter_effects.iter().find(|i| i.id == p.placed);
let cost = item
.and_then(|i| i.rect.size().ok())
.map_or(0, |(w, h)| (w as u64).saturating_mul(h as u64).saturating_mul(self.fmt.bytes_per_pixel() as u64));
let fits = self.unfiltered_bytes.saturating_add(cost) <= MAX_UNFILTERED_BYTES;
let made = item.filter(|_| fits).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt));
if made.is_some() {
self.unfiltered_bytes += cost;
}
self.unfiltered.insert(p.placed.clone(), made.clone());
made
}
};
match unfiltered {
Some(u) => photocraft_compose::filter_mask::mix(&u, &stored, m),
None => stored,
@@ -660,6 +689,9 @@ pub(crate) fn group_depth(file: &PsdFile) -> usize {
/// Additional-layer-information keys (Adobe PSD spec, plus `cust`, `vowv` and `SoLE` seen in
/// Photoshop's files) that are not an adjustment of their own: names, ids, locks, effects,
/// masks, metadata, and the fill, type, placed and shape content read before.
/// Most pixel bytes the smart-filter `FEid` sources may keep decoded across one import.
const MAX_UNFILTERED_BYTES: u64 = 1 << 30;
const LAYER_INFO_KEYS: [&[u8; 4]; 50] = [
b"luni", b"lyid", b"lnsr", b"lsct", b"lsdk", b"lspf", b"lclr", b"iOpa", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"brst", b"shmd", b"fxrp",
b"lfx2", b"lfxs", b"lmfx", b"lrFX", b"lyvr", b"sn2P", b"cust", b"CgEd", b"vmsk", b"vsms", b"vogk", b"vowv", b"vstk", b"vscg", b"artb", b"artd", b"abdd",
@@ -975,6 +1007,9 @@ pub(crate) fn psd_to_document_decoded_integrity(
dpi: doc.resolution_dpi,
txt2: file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)),
filter_effects: Vec::new(),
filter_masks: Default::default(),
unfiltered: Default::default(),
unfiltered_bytes: 0,
smart_contents: Default::default(),
plld_sizes: Default::default(),
ctl: *ctl,
+68 -3
View File
@@ -54,15 +54,42 @@ fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> {
/// Returns `Some(Some((w, h)))` when the format is PNG/WebP and its header parses cleanly,
/// `Some(None)` when the format is PNG/WebP (by MIME or magic bytes under `"text/plain"`)
/// and its header is malformed, or `None` for other formats.
/// A JPEG's size from its frame header (the first SOF marker), without decoding anything.
fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
let mut i = 2;
loop {
if *data.get(i)? != 0xFF {
return None;
}
let marker = *data.get(i + 1)?;
match marker {
0xFF => i += 1,
// SOF0..SOF15, but for DHT (C4), JPG (C8) and DAC (CC).
0xC0..=0xCF if !matches!(marker, 0xC4 | 0xC8 | 0xCC) => {
let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]);
let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]);
return Some((u32::from(w), u32::from(h)));
}
// Start of scan or end of image before any frame header.
0xD8..=0xDA => return None,
0x01 | 0xD0..=0xD7 => i += 2,
_ => i += 2 + usize::from(u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?])),
}
}
}
fn embedded_image_dimensions(mime: &str, data: &[u8]) -> Option<Option<(u32, u32)>> {
match mime {
"image/png" => Some(png_dimensions(data)),
"image/webp" => Some(photocraft_codecs::probe_size(data).ok()),
"image/webp" | "image/gif" => Some(photocraft_codecs::probe_size(data).ok()),
"image/jpeg" | "image/jpg" => Some(jpeg_dimensions(data)),
"text/plain" => {
if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
Some(png_dimensions(data))
} else if data.get(0..4) == Some(b"RIFF") && data.get(8..12) == Some(b"WEBP") {
} else if (data.get(0..4) == Some(b"RIFF") && data.get(8..12) == Some(b"WEBP")) || data.starts_with(b"GIF8") {
Some(photocraft_codecs::probe_size(data).ok())
} else if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some(jpeg_dimensions(data))
} else {
None
}
@@ -672,7 +699,25 @@ impl Conv {
_ => FillRule::NonZero,
};
let mut sh = ShapeLayer { path: Path { subpaths, fill_rule, inverted: false }, fill, stroke, ..Default::default() };
sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, self.canvas));
// The pixel cache is optional, so it is charged like any other retained raster and left
// out (the shape renders from its path) once the budget is spent: compact geometry can
// cover the whole canvas in every one of thousands of shapes.
let b = data.bounds();
let reach = sh.stroke.as_ref().map_or(0.0, |s| s.width);
let area = Rect::new(
(b.left() - reach).floor() as i32,
(b.top() - reach).floor() as i32,
(b.right() + reach).ceil() as i32,
(b.bottom() + reach).ceil() as i32,
)
.intersect(&self.canvas);
let pixels = u64::from(area.width()) * u64::from(area.height());
if self.can_retain_pixels(pixels) {
sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, self.canvas));
let bytes = pixels * self.fmt.bytes_per_pixel() as u64;
self.pixel_budget -= bytes;
self.retained_pixel_bytes += bytes;
}
Some(Layer::new(self.name(p.id(), 0), LayerContent::Shape(sh)))
}
@@ -1153,6 +1198,26 @@ mod tests {
assert!(resolve("text/plain", Arc::new(bad_webp), &opt).is_none());
}
#[test]
fn jpeg_dimensions_come_from_the_frame_header_and_oversized_jpegs_are_refused() {
// SOI, an APP0 segment of length 4, then SOF0 (length 11): 8 bits, height 40000, width 30000.
let jpeg = |h: u16, w: u16| {
let mut v = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0, 0xFF, 0xC0, 0x00, 0x0B, 8];
v.extend(h.to_be_bytes());
v.extend(w.to_be_bytes());
v.extend([1, 1, 0x11, 0]);
v
};
assert_eq!(jpeg_dimensions(&jpeg(40_000, 30_000)), Some((30_000, 40_000)));
assert_eq!(jpeg_dimensions(&[0xFF, 0xD8, 0xFF, 0xDA, 0, 2]), None);
assert_eq!(jpeg_dimensions(&[0xFF, 0xD8]), None);
let opt = options(Arc::new(usvg::fontdb::Database::new()));
let resolve = &opt.image_href_resolver.resolve_data;
// 1.2 billion pixels would be 4.8 GB of RGBA before resvg scaled anything down.
assert!(resolve("image/jpeg", Arc::new(jpeg(40_000, 30_000)), &opt).is_none());
assert!(resolve("text/plain", Arc::new(jpeg(40_000, 30_000)), &opt).is_none());
}
#[test]
fn resolve_data_rejects_nested_gzip_and_handles_text_plain_svgz() {
use std::io::Write;
+11 -1
View File
@@ -284,6 +284,10 @@ pub fn mix_back(target: &mut Surface, orig: &Region, work: &Region, rect: Rect,
let wv = work.px(x, y);
let i = out.index(x, y) * n;
let o = &mut out.data[i..i + n];
// Untouched by the dabs: keep the pixel exactly (the premultiplied mix can round it).
if *o == *wv {
continue;
}
match a {
// Alpha changes: mix premultiplied, so colour under transparent pixels doesn't bleed in.
Some(a) if !lock_transparency => mix_premultiplied(o, wv, a, k),
@@ -336,8 +340,14 @@ pub struct Smudge {
}
impl Smudge {
/// Radius of the carried-colour buffer, including pixels outside a pressure-sized dab.
/// Both the first pickup and every subsequent pickup need this entire neighbourhood.
pub fn sampling_radius(max_brush_size: f32) -> i32 {
((max_brush_size / 2.0).ceil() as i32).saturating_add(2)
}
pub fn new(strength: f32, finger: Option<Vec<f32>>, max_brush_size: f32) -> Self {
Self { strength: strength.clamp(0.0, 1.0), finger, radius: (max_brush_size / 2.0).ceil() as i32 + 2, alpha: None, carry: None }
Self { strength: strength.clamp(0.0, 1.0), finger, radius: Self::sampling_radius(max_brush_size), alpha: None, carry: None }
}
/// Mix in premultiplied space using alpha channel `a` (the format's [`alpha_index`]).
+22 -8
View File
@@ -992,16 +992,30 @@ pub(crate) fn live_retouch_points(app: &PhotocraftApp) -> Option<Vec<photocraft_
}
}
/// Offer the Clone Stamp's or History Brush's live stroke, with the drag points it hasn't rendered
/// yet, to the command `retouch_ui::finish_stroke` commits, which then takes its pixels.
/// Offer the Clone Stamp's or History Brush's live stroke, or a Dodge, Burn, Sponge, Blur, Sharpen
/// or Smudge one, with the drag points it hasn't rendered yet, to the command
/// `retouch_ui::finish_stroke` commits, which then takes its pixels instead of rendering the
/// stroke again.
fn offer_live_retouch(app: &mut PhotocraftApp, d: &Drag) {
if !matches!(d.tool, Tool::CloneStamp | Tool::HistoryBrush) {
let dab = crate::retouch_ui::dab_params(app, d.tool).is_some();
if !dab && !matches!(d.tool, Tool::CloneStamp | Tool::HistoryBrush) {
return;
}
let Some(LiveStroke { stroke: EngineStroke::Retouch(mut live), fed, .. }) = app.live_stroke.take() else { return };
let Some(LiveStroke { stroke, fed, .. }) = app.live_stroke.take() else { return };
let rest: Vec<_> = d.points.get(fed..).unwrap_or_default().iter().map(|q| photocraft_engine::paint::StrokePoint::new(q[0], q[1], q[2] as f32)).collect();
if rest.is_empty() || live.push(&rest).is_ok() {
let _ = app.session.offer_live_retouch(live);
// A refused offer (the brush changed mid-stroke, say) leaves the commit to render the stroke.
match stroke {
EngineStroke::Retouch(mut live) if !dab => {
if rest.is_empty() || live.push(&rest).is_ok() {
let _ = app.session.offer_live_retouch(live);
}
}
EngineStroke::Dab(mut live) if dab => {
if rest.is_empty() || live.push(&rest).is_ok() {
let _ = app.session.offer_live_dab(live);
}
}
_ => {}
}
}
@@ -6624,8 +6638,8 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
{
app.last_stroke_end = Some((st.doc.id, end));
}
// A live Clone Stamp preview ends here; the commit below replaces it (the Clone Stamp's and
// History Brush's with the pixels it already drew).
// A live retouching preview ends here; the commit below replaces it (the Clone Stamp's,
// History Brush's and sequential-dab tools' with the pixels it already drew).
offer_live_retouch(app, &d);
if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() || d.tool == Tool::ColorReplacement {
app.live_stroke = None;
+4
View File
@@ -723,6 +723,9 @@ pub struct PhotocraftApp {
/// A live stroke just committed with its own pixels: the canvas caches that showed it
/// refresh only what it drew after the step they showed (`canvas::damage_since`).
committed_stroke: Option<canvas::CommittedStroke>,
/// Whether the last retouching stroke's commit took the pixels its live stroke drew
/// (`"fromLive"`) instead of rendering the stroke again (`retouch_ui::finish_stroke`).
pub(crate) retouch_from_live: bool,
/// Footprint trail of a retouching drag (see `stroke_trail`).
trail: Option<stroke_trail::Trail>,
/// Remove tool strokes waiting to be applied together (Remove after each stroke off), the
@@ -1037,6 +1040,7 @@ impl PhotocraftApp {
tool_override: None,
live_stroke: None,
committed_stroke: None,
retouch_from_live: false,
trail: None,
remove_pending: Vec::new(),
remove_pending_target: None,
+3 -1
View File
@@ -160,7 +160,9 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m
p["points"] = pts;
// The layer mask, an alpha channel or the Quick Mask when targeted, as the Brush paints.
p["target"] = crate::canvas::paint_target(app);
match app.run(cmd, p) {
let result = app.run(cmd, p);
app.retouch_from_live = matches!(&result, Ok(r) if r.get("fromLive").and_then(Value::as_bool) == Some(true));
match result {
Ok(r) if matches!(tool, Tool::Healing | Tool::CloneStamp) => {
// Aligned: keep the offset for later strokes; non-aligned: every stroke restarts at the source.
app.ui.clone_offset = r.get("offset").and_then(|v| serde_json::from_value(v.clone()).ok()).filter(|_| o.clone_aligned);
+98
View File
@@ -457,3 +457,101 @@ fn releasing_a_clone_stamp_stroke_commits_what_a_replay_paints() {
assert_eq!(a.px.len(), b.px.len());
assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "live commit and replay differ");
}
/// A Blur, Smudge or Dodge stroke through the canvas: releasing it commits the pixels its live
/// stroke drew (`Session::offer_live_dab`), which are what replaying the journaled command paints.
#[test]
fn releasing_a_sequential_dab_stroke_commits_what_a_replay_paints() {
for (tool, cmd) in [(Tool::Blur, "paint.blur"), (Tool::Smudge, "paint.smudge"), (Tool::Dodge, "paint.dodge")] {
let setup = |run: &mut dyn FnMut(&str, Value)| {
run("tools.setBrush", json!({"brush": {"size": 24, "hardness": 0.2}}));
run("paint.stroke", json!({"points": [[10, 20], [190, 80]], "size": 30, "hardness": 0.5, "color": "#2060c0"}));
};
let mut h = harness();
setup(&mut |id, p| {
h.state_mut().run(id, p).unwrap();
});
h.state_mut().ui.tool = tool;
h.run_steps(1);
move_to(&mut h, 40.0, 30.0);
button(&mut h, 40.0, 30.0, true);
for (x, y) in [(70.0, 40.0), (100.0, 55.0), (130.0, 50.0), (160.0, 70.0)] {
move_to(&mut h, x, y);
}
assert!(h.state().live_stroke.is_some(), "{cmd}: a live stroke");
h.state_mut().retouch_from_live = false;
button(&mut h, 160.0, 70.0, false);
h.run_steps(2);
let app = h.state();
assert!(app.retouch_from_live, "{cmd}: the release took the live pixels instead of rendering the stroke again");
let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().expect("a committed stroke");
let mut fresh = photocraft_engine::Session::new();
fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap();
setup(&mut |id, p| {
fresh.execute(id, p).unwrap();
});
let replayed = fresh.execute(cmd, p).unwrap();
assert!(replayed.get("fromLive").is_none());
let st = app.session.active().unwrap();
let a = photocraft_compose::render(&st.doc, st.doc.bounds());
let b = photocraft_compose::render(&fresh.active().unwrap().doc, st.doc.bounds());
assert_eq!(a.px.len(), b.px.len());
assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "{cmd}: live commit and replay differ");
}
}
/// Pressure changes Smudge's painted footprint, but not the square of colour it carries.
/// Exercise the actual pen → live preview → release handoff → journal replay, then undo/redo.
#[test]
fn pressure_sized_smudge_release_matches_replay_and_undo_redo() {
use crate::stylus::PenSample;
let setup = |run: &mut dyn FnMut(&str, Value)| {
run("tools.setBrush", json!({"brush": {"size":40,"hardness":0.7,"pressureSize":true,"smoothing":{"amount":0.4}}}));
run("paint.stroke", json!({"points":[[10,20],[190,80]],"size":30,"color":"#2060c0"}));
};
let mut h = harness();
h.state_mut().ui.tool = Tool::Smudge;
h.run_steps(1);
setup(&mut |id, p| {
h.state_mut().run(id, p).unwrap();
});
let before = h.state().session.active().unwrap().doc.clone();
h.state_mut().ui.tool_options.strength = 80.0;
h.run_steps(1);
let pen = |pressure| Some(PenSample { pressure, ..Default::default() });
h.state().stylus.feed.set(pen(0.1));
move_to(&mut h, 40.0, 30.0);
button(&mut h, 40.0, 30.0, true);
for (x, y, pressure) in [(65.0, 40.0, 0.4), (100.0, 55.0, 1.0), (140.0, 70.0, 0.7), (60.0, 40.0, 0.2)] {
h.state().stylus.feed.set(pen(pressure));
move_to(&mut h, x, y);
}
assert!(h.state().live_stroke.is_some());
button(&mut h, 60.0, 40.0, false);
h.run_steps(2);
assert!(h.state().retouch_from_live, "release must actually reuse the live pixels");
let (_, params) = h.state().session.journal.iter().rev().find(|(id, _)| id == "paint.smudge").cloned().unwrap();
let points = params["points"].as_array().unwrap();
assert!(points.iter().any(|p| p[2].as_f64().unwrap() < 0.2));
assert!(points.iter().any(|p| p[2].as_f64().unwrap() > 0.9));
let mut fresh = photocraft_engine::Session::new();
fresh.execute("file.new", json!({"width":200,"height":100,"background":"white"})).unwrap();
setup(&mut |id, p| {
fresh.execute(id, p).unwrap();
});
assert_eq!(h.state().session.tools.brush, fresh.tools.brush, "replay uses the Smudge tool's actual brush");
assert!(
photocraft_compose::render(&before, before.bounds()).px == photocraft_compose::render(&fresh.active().unwrap().doc, before.bounds()).px,
"the replay fixture starts with identical pixels"
);
assert!(fresh.execute("paint.smudge", params).unwrap().get("fromLive").is_none());
let committed = h.state().session.active().unwrap().doc.clone();
let expected = photocraft_compose::render(&fresh.active().unwrap().doc, committed.bounds());
assert!(photocraft_compose::render(&committed, committed.bounds()).px == expected.px, "live commit and replay must paint identical pixels");
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.active().unwrap().doc, before);
h.state_mut().run("edit.redo", json!({})).unwrap();
assert_eq!(h.state().session.active().unwrap().doc, committed);
}
+7 -4
View File
@@ -15,7 +15,7 @@ photocraft --control 7878 --control-token-file /private/path/photocraft-control.
--automation-read-root /work/project --automation-write-root /work/project
```
If the file does not exist, PhotoCraft creates it with a fresh 256-bit token. On Unix the new file is mode `0600`; on Windows, protect it with an appropriate user-only ACL. An existing file is reused. If neither a token nor token file is configured, PhotoCraft generates a token for that launch and writes it to standard error. `PHOTOCRAFT_CONTROL_TOKEN` and `PHOTOCRAFT_CONTROL_TOKEN_FILE` are the environment-variable equivalents.
If the file does not exist, PhotoCraft creates it with a fresh 256-bit token. On Unix the new file is mode `0600`; on Windows, protect it with an appropriate user-only ACL. An existing file is reused. If neither a token nor token file is configured, PhotoCraft generates a token for that launch and writes it to standard error. `PHOTOCRAFT_CONTROL_TOKEN` and `PHOTOCRAFT_CONTROL_TOKEN_FILE` are the environment-variable equivalents. A flag wins over its variable and an empty variable counts as unset; with `--control` (or `PHOTOCRAFT_CONTROL_PORT`), a token together with a token file, from flags or variables, is a usage error (exit status 2). Without it no token is read, so the pair is ignored.
The first request on every TCP connection must authenticate. No other method is dispatched before this succeeds:
@@ -315,7 +315,7 @@ magenta alignment lines. `ui.pointer` drives the same code, so agents get identi
`photocraft-automation` provides an MCP server built on the official Rust SDK (`rmcp`). It runs in one of two modes:
- **Headless** (`photocraft-cli mcp`): an in-process `photocraft_engine::Session`. There is no window.
- **Bridge** (`photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file <path>`): every tool is forwarded to a running `photocraft --control 7878 --control-token-file <path>` over this protocol, so agents see and drive the live app.
- **Bridge** (`photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file <path>`): every tool is forwarded to a running `photocraft --control 7878 --control-token-file <path>` over this protocol, so agents see and drive the live app. `--bridge 7878` is short for `--bridge 127.0.0.1:7878`. With no `--bridge`, `PHOTOCRAFT_CONTROL_PORT` selects the bridge port (`127.0.0.1:<port>`), and the CLI says so on standard error; with neither (or the variable empty), the MCP runs headless. A value that is not a port is a usage error naming the variable. `--automation-read-root` or `--automation-write-root` with the variable set is a usage error, since the bridge would drop the roots.
The bridge keeps one authenticated TCP connection open, and drops and reconnects before a call once the connection has been idle long enough that the app (which closes idle connections after 30s) may have dropped it. A transport failure while sending a request or waiting for its reply, including a reply timeout, drops the connection and reports that the operation may have completed; inspect the document before retrying an edit. Edits are never automatically resent (#1007); a connection that could not be established is reported as "the request was not sent" instead. The MCP server opts its pure-read tools (`ui_inspect`, `doc_inspect`, `command_list`, `jobs_list`, `session_list`, `ui_screenshot`) into one automatic retry on a fresh connection, since a read applies nothing; clients driving `BridgeClient` directly opt in per call with `call_read`. The next separate call reconnects and authenticates, and reply lines whose `id` does not match the request are skipped. The client's call timeout is 55s, strictly below the server's 60s reply deadline, so the bridge's error is the one callers see; commands that can run longer should pass `"wait": false` and poll `jobs.list`. It only accepts loopback addresses, because the app only listens on loopback. Supply its bearer token with `--control-token-file`, `--control-token`, `PHOTOCRAFT_CONTROL_TOKEN_FILE`, or `PHOTOCRAFT_CONTROL_TOKEN`:
@@ -382,12 +382,15 @@ idle shutdown, including during a long command. Clients should reconnect by rest
`photocraft-cli serve` if they need a server after the idle deadline.
`photocraft-cli serve` keeps one headless engine session (no window, no GPU) and answers the same
JSON-lines envelope on stdio, or on `127.0.0.1:<port>` with `--port <port>` (loopback only; each
JSON-lines envelope on stdio, or on `127.0.0.1:<port>` with `--port <port>` or, without the flag,
`PHOTOCRAFT_CONTROL_PORT` (loopback only; each
authenticated connection shares the session). TCP uses the same first-frame `auth` exchange and
token options as desktop control. Stdio does not require this TCP handshake because access is
inherited from the process pipe. It is the fastest way for a script or agent to make many edits:
no MCP framing, no app start-up per command. Configure its file access with the same
`--automation-read-root` and `--automation-write-root` flags. Implementation:
`--automation-read-root` and `--automation-write-root` flags. When the
variable, not the flag, turns on TCP, `serve` says so on standard error; an empty value keeps
stdio, and a value that is not a port is a usage error naming the variable. Implementation:
`crates/automation/src/rpc.rs`.
| Method | Params |
+9 -5
View File
@@ -143,15 +143,18 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`.
## Environment variables
`photocraft --help` lists the desktop app's flags and, beside the control, automation-root and in-window-menu flags, the variable that stands in for each. A flag wins over its variable and an empty variable counts as unset. A bad flag value is a usage error naming the flag (exit status 2), as is an unknown option, a repeated flag or a flag without its value; a bad value in one of those listed variables is a usage error naming the variable. `PHOTOCRAFT_PRESENT_MODE` and `PHOTOCRAFT_FRAME_LATENCY` are not listed: a bad value there is logged and ignored. On Windows, release builds use the GUI subsystem and have no console, so `--help`, `--version` and usage errors print nothing in a terminal; redirect them to see the text (`photocraft --help | more` or `photocraft --help > help.txt`). The exit status is the same either way.
| Variable | Effect |
|---|---|
| `PHOTOCRAFT_CONTROL_PORT` | Same as `--control <port>` |
| `PHOTOCRAFT_CONTROL_PORT` | Same as `--control <port>`; also the port for `photocraft-cli serve --port` and `mcp --bridge` when the flag is absent. Empty is unset |
| `PHOTOCRAFT_NO_PRINT` | Set to anything but `0`: `file.print` / `file.printOneCopy` refuse to spool (`dryRun` still works). CI sets it, with `LPDEST=photocraft-no-such-printer` as a second net; set both when running the panic hunt or fuzzers locally |
| `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) |
| `PHOTOCRAFT_CONTROL_TOKEN` | 64-hex bearer token for control TCP (avoid on shared systems where environment inspection is possible) |
| `PHOTOCRAFT_CONTROL_TOKEN_FILE` | Read, or create for a server, the control bearer-token file |
| `PHOTOCRAFT_AUTOMATION_READ_ROOT` | Desktop app only: automation read root. Headless CLI modes require the `--automation-read-root` flag |
| `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Desktop app only: automation write root. Headless CLI modes require the `--automation-write-root` flag |
| `PHOTOCRAFT_AUTOMATION_READ_ROOT` | Desktop app only: automation read root (`--automation-read-root`). Headless CLI modes require the flag |
| `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Desktop app only: automation write root (`--automation-write-root`). Headless CLI modes require the flag |
| `PHOTOCRAFT_IN_WINDOW_MENUS=1` (or `--in-window-menus`) | macOS: keep the menus inside the window instead of the menu bar. `0`, `false`, `no`, `off` or empty is off |
| `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas |
| `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639). A pen is an input device with `BTN_TOOL_PEN` and the `ABS_X`/`ABS_Y` axes; virtual keyboards that claim every key are not pens (#1072) |
| `PHOTOCRAFT_GPU_ADAPTER=nvidia` | Linux: use the graphics adapter whose name contains this (case-insensitive); overrides `performance.gpuAdapter` for one launch |
@@ -164,8 +167,8 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`.
| `PHOTOCRAFT_LOCALE` | Override Auto's native UI language for this launch; unsupported tags use English. See [system language detection](localization.md#first-launch-and-system-language). |
| `PHOTOCRAFT_FX_TRACE=1` | Print the CPU time spent on GPU effect shapes and distance fields per rebuild |
| `PHOTOCRAFT_STROKE_TRACE=1` (or `=<file.csv>`; also `--stroke-trace <stderr|file.csv>`, or `ui.set {"strokeTrace": true|false}` at runtime, without a file) | Time every frame of each brush stroke; print a summary per stroke, and with a path also append each frame to that CSV (see [Stroke latency trace](#stroke-latency-trace)) |
| `PHOTOCRAFT_PRESENT_MODE=novsync` (or `--present-mode`) | Present mode for this launch: `fifo`, `autovsync`, or `novsync` (also `immediate`, `mailbox`: no vsync, the first of Immediate, Mailbox, Fifo the driver offers). Default: vsync, or no vsync when Preferences › Performance › Low-Latency Drawing is on (it ships off). For latency comparisons; shown in the stroke trace |
| `PHOTOCRAFT_FRAME_LATENCY=1` (or `--frame-latency`) | Frames the swap chain may queue, 1 to 3 (wgpu's GL backend ignores it). For latency comparisons; shown in the stroke trace |
| `PHOTOCRAFT_PRESENT_MODE=novsync` (or `--present-mode`) | Present mode for this launch: `fifo`, `autovsync`, or `novsync` (also `immediate`, `mailbox`: no vsync, the first of Immediate, Mailbox, Fifo the driver offers). Default: vsync, or no vsync when Preferences › Performance › Low-Latency Drawing is on (it ships off). For latency comparisons; shown in the stroke trace. A bad `--present-mode` is a usage error; a bad variable is logged and ignored |
| `PHOTOCRAFT_FRAME_LATENCY=1` (or `--frame-latency`) | Frames the swap chain may queue, 1 to 3 (wgpu's GL backend ignores it). For latency comparisons; shown in the stroke trace. A bad `--frame-latency` is a usage error; a bad variable is logged and ignored |
| `PHOTOCRAFT_PRINT_PORTAL=1` | Linux: File › Print always opens the system print dialog (the XDG print portal) instead of talking to CUPS; `0` never does. See [printing](printing.md). |
| `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change |
@@ -227,6 +230,7 @@ cargo run -p photocraft-cli -- run in.png --cmd layer.new.layer --params '{"name
cargo run -p photocraft-cli -- run --new '{"width":800,"height":600}' --cmd document.inspect
cargo run -p photocraft-cli -- batch --actions actions.json --in photos/ --out done/ --format jpg
cargo run -p photocraft-cli -- commands --filter blur # the command registry
cargo run -p photocraft-cli --example man-and-completions -- out/ # man pages + bash/zsh/fish/elvish/PowerShell completions
```
`actions.json` holds the steps of a recorded action: `[["<id>", {…}], …]`, `[{"command": "<id>", "params": {…}}, …]` or bare ids, as a list or wrapped in `{"actions": …}` or `{"steps": …}`; a droplet file works too. It can also be an Actions set (the Actions panel's `actions.json`, `{"actions": [{"name": …, "steps": […]}, …]}`): `--action <name>` picks the action to play (optional when the set holds one), and the whole set is loaded for each file, so a step that plays another action of the set works. `batch` refuses an `--out` folder that is the `--in` folder, since the results would replace the originals, unless `--in-place` is given. `run` prints one JSON line per command result.
+139
View File
@@ -0,0 +1,139 @@
# Crystallize and Pointillize performance
Measured on 2026-10-15, against `4e7a17cd5d5723313f459753663d0d155f047e19`.
The code revision is `3a527eb64e0d0108e44c70dfd200afc0ffd5fe67`.
## Whole command
The command bench calls `Session::execute` on a populated 6000 x 4000 RGB layer.
The table below uses 16 workers and 8-bit storage.
The clock includes filtering and the history commit. It excludes document setup,
GUI repaint and presentation. The last run also checks that Undo restores the
source and Redo restores the filtered result. Both checks happen outside the clock.
| Layer kind | Filter | Cell size | Before | After | Speedup |
|---|---|---:|---:|---:|---:|
| Layer | Crystallize | 10 | 560.8 ms | 137.5 ms | 4.08x |
| Layer | Pointillize | 5 | 430.5 ms | 146.4 ms | 2.94x |
| Layer | Crystallize | 100 | 1387.5 ms | 202.1 ms | 6.87x |
| Layer | Pointillize | 100 | 543.4 ms | 131.9 ms | 4.12x |
| Background | Crystallize | 10 | 891.5 ms | 441.1 ms | 2.02x |
| Background | Pointillize | 5 | 738.5 ms | 608.2 ms | 1.21x |
| Background | Crystallize | 100 | 1708.4 ms | 500.1 ms | 3.42x |
| Background | Pointillize | 100 | 851.4 ms | 439.0 ms | 1.94x |
Ordinary layers are unlocked and have varying alpha, including zero alpha.
Background layers are opaque and transparency-locked. The existing transparency
restoration pass limits their speedup, especially Pointillize at small cell sizes.
These are command timings, not input-to-display timings.
## Whole filter
The filter bench times `apply_in`: reading the input, filtering, converting the
result back to the document depth, writing the tiles and pruning. The table below uses 16 workers.
There is no selection in these timing cases. Correctness tests also cover soft selections.
The fixture has varying alpha, fully transparent pixels and a negative origin.
Every run filters the original surface again; no result cache survives a run.
| Format | Filter | Cell size | Before | After | Speedup |
|---|---|---:|---:|---:|---:|
| RGB u8 | Crystallize | 10 | 601.6 ms | 137.8 ms | 4.36x |
| RGB u8 | Pointillize | 5 | 449.9 ms | 147.3 ms | 3.05x |
| RGB u8 | Crystallize | 100 | 1340.5 ms | 175.1 ms | 7.65x |
| RGB u8 | Pointillize | 100 | 526.2 ms | 128.1 ms | 4.11x |
| RGB u16 | Crystallize | 10 | 647.3 ms | 214.0 ms | 3.02x |
| RGB u16 | Pointillize | 5 | 502.7 ms | 219.8 ms | 2.29x |
| RGB u16 | Crystallize | 100 | 1413.8 ms | 262.2 ms | 5.39x |
| RGB u16 | Pointillize | 100 | 580.5 ms | 209.3 ms | 2.77x |
| RGB f32 | Crystallize | 10 | 688.4 ms | 362.7 ms | 1.90x |
| RGB f32 | Pointillize | 5 | 551.5 ms | 360.9 ms | 1.53x |
| RGB f32 | Crystallize | 100 | 1495.2 ms | 412.1 ms | 3.63x |
| RGB f32 | Pointillize | 100 | 643.7 ms | 361.6 ms | 1.78x |
| CMYK u16 | Crystallize | 10 | 729.5 ms | 260.2 ms | 2.80x |
| CMYK u16 | Pointillize | 5 | 574.6 ms | 262.2 ms | 2.19x |
| CMYK u16 | Crystallize | 100 | 1560.3 ms | 329.1 ms | 4.74x |
| CMYK u16 | Pointillize | 100 | 675.0 ms | 255.6 ms | 2.64x |
| LAB f32 | Crystallize | 10 | 706.2 ms | 367.6 ms | 1.92x |
| LAB f32 | Pointillize | 5 | 550.4 ms | 361.9 ms | 1.52x |
| LAB f32 | Crystallize | 100 | 1469.7 ms | 404.9 ms | 3.63x |
| LAB f32 | Pointillize | 100 | 647.8 ms | 362.2 ms | 1.79x |
| GRAY u8 | Crystallize | 10 | 396.0 ms | 93.4 ms | 4.24x |
| GRAY u8 | Pointillize | 5 | 332.9 ms | 109.2 ms | 3.05x |
| GRAY u8 | Crystallize | 100 | 1020.5 ms | 105.1 ms | 9.71x |
| GRAY u8 | Pointillize | 100 | 352.9 ms | 76.7 ms | 4.60x |
All trials and output hashes are in [pixelate-cell-timings.json](pixelate-cell-timings.json).
Filter hashes are SHA-256 of the complete tile-ordered normalized f32 output.
Command hashes are BLAKE3 of the complete tile-ordered native storage bytes.
Fixture generation, output dumps and hashing are outside the measured interval.
## Where the time went
Both filters were rebuilding the seeded cell geometry for every pixel and every
neighbour. Pointillize also repeated the dot radius, source lookup and colour
jitter. Those values belong to the cell. The new grid prepares them once per cell
in a tile, reducing that setup work from roughly nine times the pixel count to
the number of cells.
Crystallize accumulated a premultiplied average for every cell, then usually threw
it away and used the pixel at the cell centre. Only cells whose centres are outside
the image need that average. The new path skips interior averages. Boundary cells
still add their samples in the original row order, with the original f32 rounding.
Larger cells also allow whole horizontal spans to share a winner. For each site,
the absolute horizontal distance has its maximum at an endpoint. Clamping the site
centre to the span gives a lower bound. The rounded coordinate map is monotone;
squaring nonnegative distances, adding the fixed vertical term and taking a square
root preserve these bounds through the same f32 operations. Subtracting distance
from a fixed dot radius reverses the endpoints for Pointillize. If one site's
worst score beats every other site's best score, it owns the span. Otherwise the span splits. Single pixels retain the original strict
comparison and tie order. A fully covered Pointillize span also has exactly unit
coverage, so it needs no per-pixel square roots.
The tile path reads sparse cell-centre samples directly from the surface.
Crystallize's boundary tiles retain the dense input for their averages. Extent
clamping and selection mixing follow the original path.
After these changes, profiling showed a serial tail: converting samples and
writing them back took about 160-180 ms of a roughly 260 ms operation. The two
filters now convert on their existing tile workers. Committing a group copies
encoded bytes. Integer rounding and native byte order are the same as
`Surface::write_region`; float bits are kept. The optional site grid is capped at
16 MiB. Failed grid/average reservations use the scalar kernel; a failed encode
reservation keeps the existing floating-point writeback.
## Reproduce
Machine: EC2 c7i.4xlarge, 16 vCPUs (8 cores with SMT), 32 GiB RAM, Intel Xeon
Platinum 8488C, Ubuntu 24.04, rustc 1.99.0. Release profile, thin LTO, one codegen
unit. No `target-cpu` override. Five trials per row; tables use the median.
One-worker rows are in the raw results too. Before and after use separate target
directories so Cargo cannot reuse a library from the other worktree. No builds
ran during the timed trials.
```sh
RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- crystallize 6000 4000 u8 5 /tmp/crystallize.bin rgb
RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- pointillize-large 6000 4000 u16 5 /tmp/pointillize.bin cmyk
RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- crystallize 10 5 8 layer
RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- pointillize 100 5 8 layer
RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- pointillize 5 5 8 background
```
For the before run, create a worktree at the base above and copy the two benchmark
examples into it. Build with a separate `CARGO_TARGET_DIR` and run there with the same arguments
and thread count.
The `-large` filter cases use cell size 100; the other cases use 10 for Crystallize
and 5 for Pointillize. The engine example takes the size explicitly.
## Validation
- 2,447 tests passed across algo and engine; 43 ignored.
- Clippy passed for both crates with all targets and warnings denied.
- Layer checks, wasm checks, scorecard freshness and the quick performance suite passed.
- Scalar comparisons cover RGB, Gray, CMYK, Lab and Multichannel, alpha on/off,
negative origins, fractional cells, boundary averages, HDR and nonfinite samples.
- Storage comparisons cover U8/U16/F32, soft selections, clamped extents and
several tile sizes. The span tests compare discrete winners and coverage,
including coordinates near f32 precision boundaries.
- All 44 measured before/after configurations have identical output hashes.
The 16 command configurations also pass Undo/Redo checks.
File diff suppressed because it is too large. Load diff
+9 -3
View File
@@ -192,8 +192,12 @@ Locally on Windows: `dotnet tool install -g wix --version 5.0.2`,
`packaging/linux/package.sh` stages one FHS tree and makes every format from it. The tree
holds both binaries, `ai.storyteller.photocraft.desktop`, hicolor icons from 16 px to 512 px
plus a scalable SVG, AppStream metainfo, and a shared-mime-info file for `.pcraft`, `.psb` and
`.qoi`.
plus a scalable SVG, AppStream metainfo, a shared-mime-info file for `.pcraft`, `.psb` and
`.qoi`, and `photocraft-cli`'s man pages and bash, zsh and fish completions. The
`man-and-completions` example, built with the binaries, writes those; a cross build runs it
through `EMULATOR` and leaves them out without one. The zsh completion is in
`/usr/share/zsh/site-functions`, which Fedora's and openSUSE's zsh read; the `.deb` also links it
from `/usr/share/zsh/vendor-completions`, which Debian's zsh reads instead.
Why these formats:
@@ -281,7 +285,9 @@ binaries with `CARGO_BUILD_JOBS=4` (more runs the 12 GB VM out of memory) and wr
`x86_64` like the other artifacts. The tarball is a `/usr/local`-style tree: `bin/photocraft`,
`bin/photocraft-cli`, and under `share/` the same desktop entry, MIME type, AppStream metainfo
and hicolor icons as Linux, plus the licences, `NOTICE` and `ATTRIBUTION.md` in
`share/doc/photocraft/`. Users install it with:
`share/doc/photocraft/`, and `photocraft-cli`'s man pages (`man/man1/`) and bash, zsh and fish
completions (`share/bash-completion`, `share/zsh/site-functions`, `share/fish`), written by the
`man-and-completions` example built with the binaries. Users install it with:
```sh
pkg install libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
+100
View File
@@ -0,0 +1,100 @@
# Content-Aware Scale performance
2026-10-22. Two things are recorded here: what this change saves, and a correction to the P28 row's
recorded number, which predates the seam-search proxies and reads as hundreds of times worse than
the code does today.
## The change
Content-Aware Scale shrinks an image by removing one seam at a time. On large images the seams are
searched on a vertically averaged copy (`carve_width_proxy_height`), and a panel column found there
is not an original column: each seam already removed has shifted the panel left. That was tracked
with an `idx` map — one `usize` per proxy pixel, built up front and shifted by every seam.
The original column is recoverable without the map: for the `i`-th column already taken from a row,
`o - i` is the panel column it occupied, so the taken columns at or left of panel column `x` are
those with `o - i <= x`, and the original column is `x` plus that count. `o - i` never decreases
along the row's list, so that count is a binary search and the original column inserts where the
search stopped; the rows are independent and update in parallel, like the pixel shift.
Counting it by scanning the row's list instead is a comparison per already-removed column, so its
cost grows with the square of the seam count and a deep shrink pays it on every row and every seam.
The binary search removes that term; what is left per removed column is one insert into a list of at
most `seam_count` entries, i.e. an element move rather than a comparison.
Output is unchanged: the same original columns reach the assembly pass, in the same order, so every
pixel written is the pixel the map produced. The 4-channel cases below hash identically (FNV-1a over
the output's raw `f32` bits); the 3-channel case is unchanged within timer noise.
## Timings
Windows 11, Intel i5-10300H (4 cores / 8 threads), rustc 1.98.1, `--release`, default rayon pool.
The timer covers `seam::carve_width` only: document read, tile writeback, history and the undo that
follows the command are outside it. Each cell is the same binary run before and after the change, in
the same session, nothing else running.
| Case | Before (ms) | After (ms) | Change | Output |
|---|---:|---:|---:|---|
| 4000 × 3000 → 3600, 4 ch | 1424.7 | 1132.9 | −20.5 % | identical |
| 6000 × 4000 → 5400, 4 ch | 3009.3 | 2559.4 | −15.0 % | identical |
| 7200 × 5000 → 6480, 4 ch | 4352.8 | 3766.9 | −13.5 % | identical |
Deep shrinks, where the counting scan was quadratic, on the same machine and the same binary with
only the recording swapped (three alternating runs per side, `min`/`median`):
| Case | Scan (ms) | Binary search (ms) | Change | Output |
|---|---:|---:|---:|---|
| 6000 × 4000 → 600, 4 ch (5 400 seams) | 16 640 / 16 757 | 13 915 / 14 066 | −16 % | identical, fnv `0f7eaa6af3cf1557` |
| 6000 × 4000 → 3000, 4 ch (3 000 seams) | 10 617 / 10 814 | 9 936 / 10 012 | −6 % | identical, fnv `1780ee9bc9077ee9` |
The element move that remains is still linear in the row's list length, so a shrink this deep is not
down to the index map's cost yet on four cores; a rank bitvector over the row would remove it, and is
not in this change.
The map was 24 MB next to a 36 MB pixel buffer at 24 MP, so the saving tracks how many bytes of row
the seams have to shift. A three-channel case was measured too and moved inside the timer noise, so
it is not reported as a win here. Further seam-search savings have to come from the dynamic program
itself, not from bookkeeping.
## P28's recorded number is stale
`perf/baseline.json` records P28 (Content-Aware Scale on 24 MP to 90 % width) as 954 192 ms from the
2026-10-05 baseline, which is why `perf/budgets.toml` carries `enforce = false` for that row. The
seam-search proxies and the per-seam energy update landed after that capture:
| Tree | 6000 × 4000 → 5400, 4 ch |
|---|---:|
| `f5d2bcb` (the 2026-10-05 baseline commit) | 266 039 ms |
| `1104b6f` (main, plus this change) | 2 559 ms |
`f5d2bcb` has no `PROXY_SHRINK_MIN_HEIGHT`: it carves at full height with a full energy recompute per
seam. The 954 s in the baseline was that code on a machine whose recorded load average was 226–290
on 14 cores. The two example runs above are 104× apart (266 039 ms against 2 559 ms); that is the
seam-search core alone, and it is not the same measurement as the row's 954 s, which also includes a
document read and writeback the timer above excludes.
The row itself is not flipped to `enforce = true` here: the budget is set for the macOS/aarch64
machine class and only a run on that class can vouch for it. The carving core above is 2.56 s at
24 MP on a 4-core laptop CPU; the budgeted machine is faster, but the row also includes the document
read and writeback this measurement excludes. Flip it in the PR whose nightly run shows the row
under 3 s, and delete this paragraph with it.
## Reproduce
```sh
cargo build --release -p photocraft-algo --example bench_seam
./target/release/examples/bench_seam 6000 4000 5400 4 # width height new_width channels (1..=4)
```
Build the base and the changed tree with separate `CARGO_TARGET_DIR`s and run the same line in both;
the printed FNV hash of the output must match, and the millisecond figure is the row above. The
example builds deterministic gradient-plus-detail content, so two runs of the same binary agree.
## Correctness and repository checks
`cargo test --release -p photocraft-algo seam` passes, including `carver_matches_reference`,
`output_bits_are_pinned`, `tall_proxy_shrink_preserves_full_resolution_salient_and_protected_pixels`
and the cancellation test. `cargo fmt --all --check` and
`cargo clippy -p photocraft-algo --all-targets -- -D warnings` are clean. No command, UI or format
contract changed; `Carver`'s `track` mode is still used by the enlargement path, which needs the
original columns it was written for.
+22 -3
View File
@@ -2,8 +2,8 @@
# Build and package PhotoCraft for FreeBSD:
#
# $DIST/photocraft-<version>-freebsd-x86_64.tar.gz a /usr/local-style tree:
# photocraft-<version>-freebsd-x86_64/{bin, share/applications, share/icons, share/mime,
# share/metainfo, share/doc/photocraft}
# photocraft-<version>-freebsd-x86_64/{bin, man/man1, share/applications, share/icons,
# share/mime, share/metainfo, share/doc/photocraft, share/{bash-completion,zsh,fish}}
#
# Install by copying the tree's contents into /usr/local:
# tar -xzf photocraft-<version>-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
@@ -46,8 +46,12 @@ echo "==> PhotoCraft $VERSION for FreeBSD $ARCH"
# The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory.
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}"
# The man-and-completions example writes photocraft-cli's man pages and shell completions below. It
# is built in its own cargo run: built with the binaries, its dev-dependencies' features would be
# unified into them.
if [[ "$SKIP_BUILD" = 0 ]]; then
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif)
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif --bins)
(cd "$ROOT" && cargo build --release --locked -p photocraft-cli --features heif --example man-and-completions)
fi
WORK="$CARGO_TARGET_DIR/freebsd-package"
STAGE="$WORK/$BASENAME"
@@ -83,6 +87,21 @@ copy_docs "$STAGE/share/doc/photocraft"
for f in NOTICE ATTRIBUTION.md; do
if [[ -f "$ROOT/$f" ]]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/photocraft/"; fi
done
# photocraft-cli's man pages and shell completions, from its clap definition, written by the
# example built above. A dry run builds nothing, so it leaves them out, as a --skip-build run whose
# prebuilt binaries came without the example does.
CLI_DOCS="$BIN/examples/man-and-completions"
if [[ "$DRY_RUN" = 0 ]] && [[ ! -x "$CLI_DOCS" ]]; then
warn "$CLI_DOCS is not built, so photocraft-cli's man pages and completions are left out"
elif [[ "$DRY_RUN" = 0 ]]; then
"$CLI_DOCS" "$WORK/cli-docs"
mkdir -p "$STAGE/man/man1" "$STAGE/share/bash-completion/completions" \
"$STAGE/share/zsh/site-functions" "$STAGE/share/fish/vendor_completions.d"
install -m 644 "$WORK"/cli-docs/*.1 "$STAGE/man/man1/"
install -m 644 "$WORK/cli-docs/photocraft-cli.bash" "$STAGE/share/bash-completion/completions/photocraft-cli"
install -m 644 "$WORK/cli-docs/_photocraft-cli" "$STAGE/share/zsh/site-functions/_photocraft-cli"
install -m 644 "$WORK/cli-docs/photocraft-cli.fish" "$STAGE/share/fish/vendor_completions.d/photocraft-cli.fish"
fi
for f in bin/photocraft bin/photocraft-cli "share/applications/$APP_ID.desktop" \
"share/icons/hicolor/256x256/apps/$APP_ID.png" "share/icons/hicolor/scalable/apps/$APP_ID.svg" \
+6
View File
@@ -75,6 +75,12 @@ contents:
- src: ${STAGE}/usr/share/
dst: /usr/share/
type: tree
# Debian's zsh reads vendor-completions, not /usr/share/zsh/site-functions as Fedora's and
# openSUSE's do (its debian/rules sets --enable-additional-fpath).
- src: /usr/share/zsh/site-functions/_photocraft-cli
dst: /usr/share/zsh/vendor-completions/_photocraft-cli
type: symlink
packager: deb
scripts:
postinstall: packaging/linux/postinst.sh
+34 -4
View File
@@ -16,7 +16,7 @@
#
# Cross builds (CI: riscv64, tar.gz only): set CROSS_ARCH (riscv64), CROSS_TARGET (the Rust
# triple), CROSS_COMPILE (binutils prefix, for strip) and optionally EMULATOR (e.g. qemu-riscv64)
# to smoke-test the CLI.
# to smoke-test the CLI and run the example that writes its man pages and completions.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
@@ -48,11 +48,16 @@ BASENAME="photocraft-$VERSION-linux-$ARCH"
echo "==> PhotoCraft $VERSION for Linux $ARCH ($FORMATS)"
# The man-and-completions example writes photocraft-cli's man pages and shell completions below. It
# is built in its own cargo run: built with the binaries, its dev-dependencies' features would be
# unified into them.
if [[ "$SKIP_BUILD" = 0 ]]; then
if [[ -n "${CROSS_TARGET:-}" ]]; then
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif --target "$CROSS_TARGET")
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif --bins --target "$CROSS_TARGET")
(cd "$ROOT" && cargo build --release --locked -p photocraft-cli --features heif --example man-and-completions --target "$CROSS_TARGET")
else
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif)
(cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif --bins)
(cd "$ROOT" && cargo build --release --locked -p photocraft-cli --features heif --example man-and-completions)
fi
fi
BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release"
@@ -73,6 +78,27 @@ mkdir -p "$STAGE/usr/share/icons"
cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/usr/share/icons/"
mkdir -p "$STAGE/usr/share/doc/photocraft"
copy_docs "$STAGE/usr/share/doc/photocraft"
# photocraft-cli's man pages and shell completions, from its clap definition, written by the
# example built above. A cross build runs it through $EMULATOR, like the --version check at the
# end, and leaves them out without one, as a --skip-build run whose prebuilt binaries came without
# the example does. zsh reads site-functions on Fedora and openSUSE; the deb also links the
# completion into Debian's vendor-completions (nfpm.yaml).
CLI_DOCS="$BIN/examples/man-and-completions"
if [[ -n "${CROSS_TARGET:-}" ]] && [[ -z "${EMULATOR:-}" ]]; then
warn "no EMULATOR to run the $CROSS_TARGET build, so photocraft-cli's man pages and completions are left out"
elif [[ ! -x "$CLI_DOCS" ]]; then
warn "$CLI_DOCS is not built, so photocraft-cli's man pages and completions are left out"
else
if [[ -n "${CROSS_TARGET:-}" ]]; then
"$EMULATOR" "$CLI_DOCS" "$WORK/cli-docs"
else
"$CLI_DOCS" "$WORK/cli-docs"
fi
install -Dm644 -t "$STAGE/usr/share/man/man1" "$WORK"/cli-docs/*.1
install -Dm644 "$WORK/cli-docs/photocraft-cli.bash" "$STAGE/usr/share/bash-completion/completions/photocraft-cli"
install -Dm644 "$WORK/cli-docs/_photocraft-cli" "$STAGE/usr/share/zsh/site-functions/_photocraft-cli"
install -Dm644 "$WORK/cli-docs/photocraft-cli.fish" "$STAGE/usr/share/fish/vendor_completions.d/photocraft-cli.fish"
fi
if command -v desktop-file-validate >/dev/null; then
desktop-file-validate "$STAGE/usr/share/applications/$APP_ID.desktop"
@@ -97,7 +123,11 @@ if has deb || has rpm; then
export VERSION
export NFPM_ARCH="$DEB_ARCH"
# nfpm expands env vars in fields like `version` and `arch`, but not in `contents[].src`.
sed "s|\${STAGE}|$STAGE|g" "$HERE/nfpm.yaml" >"$WORK/nfpm.yaml"
# Without the completions (a cross build with no EMULATOR) the deb's zsh symlink would dangle,
# so its entry is dropped too.
DROP_ZSH_LINK=
[[ -f "$STAGE/usr/share/zsh/site-functions/_photocraft-cli" ]] || DROP_ZSH_LINK='\|^ - src: /usr/share/zsh/site-functions/|,/packager: deb/d;'
sed "${DROP_ZSH_LINK}s|\${STAGE}|$STAGE|g" "$HERE/nfpm.yaml" >"$WORK/nfpm.yaml"
for fmt in deb rpm; do
if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$WORK/nfpm.yaml" -p "$fmt" -t "$DIST/$BASENAME.$fmt"); fi
done
+3 -1
View File
@@ -359,7 +359,9 @@ issue = 211
bench = "perf_scenarios"
row = "Content-Aware Scale 24 MP to 90 % width"
budget_ms = 3000
enforce = false # over budget in the 2026-10-05 baseline
enforce = false # 954 s in the 2026-10-05 baseline, which predates the seam-search proxies: the
# carving core is 2.6 s at 24 MP RGBA (docs/seam-carving-performance.md). Flip once a
# budgeted-class run shows the whole row under 3 s.
[[scenario]]
id = "P29"