mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-14 14:12:44 +00:00
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:
commit
2fdce3931a
93 files changed
+6773
-1848
No files matched your search
Generated
+115
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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())?)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
¶ms,
|
||||
"--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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
+57
-1002
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,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();
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms, bounds, bounds, Some(&selection), tile, extent);
|
||||
assert_bits(&got.read_region(bounds), &expected.read_region(bounds));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-11
@@ -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);
|
||||
|
||||
@@ -25,6 +25,7 @@ base64 = "0.22"
|
||||
schemars = "1"
|
||||
getrandom = { workspace = true }
|
||||
cap-std = "4"
|
||||
clap = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Synthetic camera raws (#7789).
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }] }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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| {}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<(), ()> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(®ion.crop(r));
|
||||
self.work.write(&self.pre_surf, ®ion.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms),
|
||||
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", ¶ms).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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user