Merge pull request #37 from storytold/ci/green
CI: green on all platforms (rustfmt config, clippy 1.99, FXC-safe adjust shader)
This commit is contained in:
commit
6f1d0dc080
|
|
@ -0,0 +1,3 @@
|
|||
# Bulk formatting commits, skipped by `git blame` (git config blame.ignoreRevsFile .git-blame-ignore-revs).
|
||||
# Adopt rustfmt.toml and format the workspace.
|
||||
f7eb6ea9e724dbcf97af2d8162785c8063089edc
|
||||
|
|
@ -38,34 +38,17 @@ struct Args {
|
|||
flags: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
const VALUE_FLAGS: &[&str] = &[
|
||||
"--format",
|
||||
"--quality",
|
||||
"--new",
|
||||
"--cmd",
|
||||
"--params",
|
||||
"--out",
|
||||
"--actions",
|
||||
"--in",
|
||||
"--filter",
|
||||
"--bridge",
|
||||
"--port",
|
||||
];
|
||||
const VALUE_FLAGS: &[&str] = &["--format", "--quality", "--new", "--cmd", "--params", "--out", "--actions", "--in", "--filter", "--bridge", "--port"];
|
||||
|
||||
fn parse(args: &[String]) -> Result<Args, String> {
|
||||
let mut a = Args {
|
||||
positional: Vec::new(),
|
||||
flags: Vec::new(),
|
||||
};
|
||||
let mut a = Args { positional: Vec::new(), flags: Vec::new() };
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let s = &args[i];
|
||||
if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) {
|
||||
a.flags.push((k.to_owned(), Some(v.to_owned())));
|
||||
} else if VALUE_FLAGS.contains(&s.as_str()) {
|
||||
let v = args
|
||||
.get(i + 1)
|
||||
.ok_or_else(|| format!("{s} needs a value"))?;
|
||||
let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?;
|
||||
a.flags.push((s.clone(), Some(v.clone())));
|
||||
i += 1;
|
||||
} else if s.starts_with("--") {
|
||||
|
|
@ -80,11 +63,7 @@ fn parse(args: &[String]) -> Result<Args, String> {
|
|||
|
||||
impl Args {
|
||||
fn get(&self, k: &str) -> Option<&str> {
|
||||
self.flags
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(f, _)| f == k)
|
||||
.and_then(|(_, v)| v.as_deref())
|
||||
self.flags.iter().rev().find(|(f, _)| f == k).and_then(|(_, v)| v.as_deref())
|
||||
}
|
||||
fn has(&self, k: &str) -> bool {
|
||||
self.flags.iter().any(|(f, _)| f == k)
|
||||
|
|
@ -120,11 +99,7 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
|||
return 0;
|
||||
}
|
||||
"--version" | "version" => {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"photocraft-cli {}",
|
||||
photocraft_engine::build_info::long_version()
|
||||
);
|
||||
let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version());
|
||||
return 0;
|
||||
}
|
||||
other => {
|
||||
|
|
@ -162,25 +137,13 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
|
|||
};
|
||||
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &o.warnings);
|
||||
let ws = files::save(
|
||||
&o.document,
|
||||
Path::new(output),
|
||||
a.get("--format"),
|
||||
&export_opts(a)?,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &export_opts(a)?, None).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &ws);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_json(out: &mut dyn Write, v: &Value, compact: bool) -> R {
|
||||
let s = if compact {
|
||||
serde_json::to_string(v)
|
||||
} else {
|
||||
serde_json::to_string_pretty(v)
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = if compact { serde_json::to_string(v) } else { serde_json::to_string_pretty(v) }.map_err(|e| e.to_string())?;
|
||||
writeln!(out, "{s}").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
|
|
@ -210,8 +173,7 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
|
|||
("--cmd", Some(id)) => cmds.push((id.clone(), json!({}))),
|
||||
("--params", Some(p)) => {
|
||||
let last = cmds.last_mut().ok_or("--params must follow a --cmd")?;
|
||||
last.1 = serde_json::from_str(p)
|
||||
.map_err(|e| format!("bad --params JSON for `{}`: {e}", last.0))?;
|
||||
last.1 = serde_json::from_str(p).map_err(|e| format!("bad --params JSON for `{}`: {e}", last.0))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -238,20 +200,11 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
|||
return Err("run needs at least one --cmd (or --out)".into());
|
||||
}
|
||||
for (id, params) in cmds {
|
||||
let r = h
|
||||
.command_run(&id, params)
|
||||
.map_err(|e| format!("`{id}`: {e}"))?;
|
||||
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
|
||||
print_json(out, &json!({"command": id, "result": r}), true)?;
|
||||
}
|
||||
if let Some(o) = a.get("--out") {
|
||||
let r = h
|
||||
.save(
|
||||
None,
|
||||
Some(Path::new(o)),
|
||||
a.get("--format"),
|
||||
&export_opts(a)?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &export_opts(a)?).map_err(|e| e.to_string())?;
|
||||
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
|
||||
warn_all(err, &ws);
|
||||
}
|
||||
|
|
@ -264,37 +217,21 @@ pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, String> {
|
|||
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
|
||||
let list = match &v {
|
||||
Value::Array(a) => a.clone(),
|
||||
Value::Object(m) => m
|
||||
.get("actions")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.ok_or("actions JSON: expected an array or {\"actions\": [...]}")?,
|
||||
Value::Object(m) => m.get("actions").and_then(Value::as_array).cloned().ok_or("actions JSON: expected an array or {\"actions\": [...]}")?,
|
||||
_ => return Err("actions JSON: expected an array".into()),
|
||||
};
|
||||
list.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| {
|
||||
let id = a
|
||||
.get("command")
|
||||
.or_else(|| a.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| format!("action {i}: missing \"command\""))?;
|
||||
Ok((
|
||||
id.to_owned(),
|
||||
a.get("params").cloned().unwrap_or_else(|| json!({})),
|
||||
))
|
||||
let id = a.get("command").or_else(|| a.get("id")).and_then(Value::as_str).ok_or_else(|| format!("action {i}: missing \"command\""))?;
|
||||
Ok((id.to_owned(), a.get("params").cloned().unwrap_or_else(|| json!({}))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_input(p: &Path) -> bool {
|
||||
let known = |e: &str| {
|
||||
matches!(e, "pcraft" | "psd" | "psb")
|
||||
|| 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()))
|
||||
let known = |e: &str| matches!(e, "pcraft" | "psd" | "psb") || 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()))
|
||||
}
|
||||
|
||||
fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
|
|
@ -305,35 +242,21 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
|||
let actions = parse_actions(&text)?;
|
||||
let opts = export_opts(a)?;
|
||||
std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
|
||||
let mut inputs: Vec<PathBuf> = std::fs::read_dir(&in_dir)
|
||||
.map_err(|e| format!("{}: {e}", in_dir.display()))?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| is_input(p))
|
||||
.collect();
|
||||
let mut inputs: Vec<PathBuf> =
|
||||
std::fs::read_dir(&in_dir).map_err(|e| format!("{}: {e}", in_dir.display()))?.flatten().map(|e| e.path()).filter(|p| is_input(p)).collect();
|
||||
inputs.sort();
|
||||
let (mut ok, mut failed) = (0, 0);
|
||||
for input in &inputs {
|
||||
let ext = a
|
||||
.get("--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 ext = a.get("--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 r = (|| -> Result<Vec<String>, String> {
|
||||
let mut h = Headless::new();
|
||||
h.open(input).map_err(|e| e.to_string())?;
|
||||
for (id, p) in &actions {
|
||||
h.command_run(id, p.clone())
|
||||
.map_err(|e| format!("`{id}`: {e}"))?;
|
||||
h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
|
||||
}
|
||||
let r = h
|
||||
.save(None, Some(&target), Some(&ext), &opts)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let r = h.save(None, Some(&target), Some(&ext), &opts).map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
|
||||
})();
|
||||
match r {
|
||||
|
|
@ -349,18 +272,11 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
|||
}
|
||||
}
|
||||
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
|
||||
if failed > 0 {
|
||||
Err(format!("{failed} file(s) failed"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
if failed > 0 { Err(format!("{failed} file(s) failed")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let (file, inputs) = a
|
||||
.positional
|
||||
.split_first()
|
||||
.ok_or("droplet needs <file.pcdroplet> and inputs")?;
|
||||
let (file, inputs) = a.positional.split_first().ok_or("droplet needs <file.pcdroplet> and inputs")?;
|
||||
if inputs.is_empty() {
|
||||
return Err("droplet needs at least one input file or folder".into());
|
||||
}
|
||||
|
|
@ -369,26 +285,15 @@ fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
|||
p["output"] = json!(o);
|
||||
}
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
let r = s
|
||||
.execute("file.automate.runDroplet", p)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let r = s.execute("file.automate.runDroplet", p).map_err(|e| e.to_string())?;
|
||||
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(format!("{} file(s) failed", errors.len()))
|
||||
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(format!("{} file(s) failed", errors.len())) }
|
||||
}
|
||||
|
||||
fn commands(a: &Args, out: &mut dyn Write) -> R {
|
||||
|
|
@ -401,42 +306,15 @@ fn commands(a: &Args, out: &mut dyn Write) -> R {
|
|||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
needle.as_ref().is_none_or(|n| {
|
||||
format!(
|
||||
"{} {}",
|
||||
c["id"].as_str().unwrap_or(""),
|
||||
c["label"].as_str().unwrap_or("")
|
||||
)
|
||||
.to_lowercase()
|
||||
.contains(n)
|
||||
})
|
||||
needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n))
|
||||
})
|
||||
.collect();
|
||||
if a.has("--json") {
|
||||
return print_json(
|
||||
out,
|
||||
&Value::Array(items.into_iter().cloned().collect()),
|
||||
false,
|
||||
);
|
||||
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
|
||||
}
|
||||
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
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
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).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -455,8 +333,7 @@ fn serve(a: &Args, err: &mut dyn Write) -> R {
|
|||
}
|
||||
None => {
|
||||
let stdin = std::io::stdin();
|
||||
photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout())
|
||||
.map_err(|e| e.to_string())
|
||||
photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout()).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -466,9 +343,6 @@ fn mcp(a: &Args) -> R {
|
|||
Some(addr) => PhotocraftMcp::bridge(addr).map_err(|e| e.to_string())?,
|
||||
None => PhotocraftMcp::headless(),
|
||||
};
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().map_err(|e| e.to_string())?;
|
||||
rt.block_on(server.serve_stdio()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,30 +18,15 @@ fn tmp(name: &str) -> PathBuf {
|
|||
}
|
||||
|
||||
fn write_png(path: &Path, w: u32, h: u32, seed: u8) {
|
||||
let data: Vec<u8> = (0..w * h * 4)
|
||||
.map(|i| (i as u8).wrapping_mul(seed).wrapping_add(seed))
|
||||
.collect();
|
||||
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
path,
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let data: Vec<u8> = (0..w * h * 4).map(|i| (i as u8).wrapping_mul(seed).wrapping_add(seed)).collect();
|
||||
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data).unwrap();
|
||||
std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn ok(cmd: &mut Command) -> (String, String) {
|
||||
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
|
||||
);
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -70,26 +55,15 @@ fn convert_png_pcraft_png_is_lossless() {
|
|||
let y = photocraft_codecs::decode(&std::fs::read(&c).unwrap()).unwrap();
|
||||
assert_eq!(x.to_rgba8(), y.to_rgba8());
|
||||
// Format override and lossy warning path.
|
||||
ok(bin()
|
||||
.args(["convert"])
|
||||
.arg(&a)
|
||||
.arg(d.join("out.bin"))
|
||||
.args(["--format", "jpg", "--quality", "60"]));
|
||||
assert!(
|
||||
photocraft_codecs::detect(&std::fs::read(d.join("out.bin")).unwrap())
|
||||
== Some(photocraft_codecs::Format::Jpeg)
|
||||
);
|
||||
ok(bin().args(["convert"]).arg(&a).arg(d.join("out.bin")).args(["--format", "jpg", "--quality", "60"]));
|
||||
assert!(photocraft_codecs::detect(&std::fs::read(d.join("out.bin")).unwrap()) == Some(photocraft_codecs::Format::Jpeg));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_errors() {
|
||||
let d = tmp("converr");
|
||||
let o = bin()
|
||||
.args(["convert", "/missing/in.png"])
|
||||
.arg(d.join("x.png"))
|
||||
.output()
|
||||
.unwrap();
|
||||
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));
|
||||
|
|
@ -119,65 +93,33 @@ fn run_commands_and_save() {
|
|||
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", "paint.stroke", "--params", r##"{"points":[[2,2,1],[30,20,1]],"size":5,"color":"#00ff00"}"##])
|
||||
.args(["--cmd", "document.inspect"])
|
||||
.arg("--out")
|
||||
.arg(&out_pc));
|
||||
let lines: Vec<Value> = out
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str(l).unwrap())
|
||||
.collect();
|
||||
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");
|
||||
let (info, _) = ok(bin().arg("info").arg(&out_pc));
|
||||
let v: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!(v["name"], "Run");
|
||||
let names: Vec<&str> = v["layers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|l| l["name"].as_str())
|
||||
.collect();
|
||||
let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Ink", "Background"]);
|
||||
// Continue from the saved file and export PNG.
|
||||
let png = d.join("r.png");
|
||||
ok(bin()
|
||||
.arg("run")
|
||||
.arg(&out_pc)
|
||||
.args(["--cmd", "layer.new.layer"])
|
||||
.arg("--out")
|
||||
.arg(&png));
|
||||
assert_eq!(
|
||||
photocraft_codecs::decode(&std::fs::read(&png).unwrap())
|
||||
.unwrap()
|
||||
.dimensions(),
|
||||
(40, 30)
|
||||
);
|
||||
ok(bin().arg("run").arg(&out_pc).args(["--cmd", "layer.new.layer"]).arg("--out").arg(&png));
|
||||
assert_eq!(photocraft_codecs::decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (40, 30));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_errors() {
|
||||
let o = bin()
|
||||
.args(["run", "--new", "{}", "--cmd", "no.such"])
|
||||
.output()
|
||||
.unwrap();
|
||||
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"));
|
||||
let o = bin()
|
||||
.args(["run", "--new", "{}", "--params", "{}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let o = bin().args(["run", "--new", "{}", "--params", "{}"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let o = bin()
|
||||
.args(["run", "--new", "{", "--cmd", "x"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
}
|
||||
|
||||
|
|
@ -190,19 +132,8 @@ fn batch_applies_actions_to_directory() {
|
|||
write_png(&inp.join("two.png"), 9, 9, 2);
|
||||
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"]));
|
||||
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"]));
|
||||
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"))));
|
||||
|
|
@ -211,15 +142,7 @@ fn batch_applies_actions_to_directory() {
|
|||
}
|
||||
// A failing action reports and exits 1.
|
||||
std::fs::write(&actions, r#"{"actions":[{"id":"no.such.command"}]}"#).unwrap();
|
||||
let o = bin()
|
||||
.args(["batch", "--actions"])
|
||||
.arg(&actions)
|
||||
.arg("--in")
|
||||
.arg(&inp)
|
||||
.arg("--out")
|
||||
.arg(&outp)
|
||||
.output()
|
||||
.unwrap();
|
||||
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("FAIL"));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
|
|
@ -227,21 +150,9 @@ fn batch_applies_actions_to_directory() {
|
|||
|
||||
#[test]
|
||||
fn parse_actions_formats() {
|
||||
let a =
|
||||
photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap();
|
||||
assert_eq!(
|
||||
a,
|
||||
vec![
|
||||
("a".to_string(), json!({"x":1})),
|
||||
("b".to_string(), json!({}))
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#)
|
||||
.unwrap()
|
||||
.len()
|
||||
== 1
|
||||
);
|
||||
let a = photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap();
|
||||
assert_eq!(a, vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))]);
|
||||
assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1);
|
||||
assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err());
|
||||
assert!(photocraft_cli::parse_actions("7").is_err());
|
||||
}
|
||||
|
|
@ -252,38 +163,26 @@ fn commands_listing() {
|
|||
assert!(out.contains("file.new"));
|
||||
let (out, _) = ok(bin().args(["commands", "--json", "--filter", "layer.new"]));
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert!(v.as_array().unwrap().iter().all(|c| {
|
||||
c["id"].as_str().unwrap().contains("layer.new")
|
||||
|| c["label"]
|
||||
.as_str()
|
||||
assert!(
|
||||
v.as_array()
|
||||
.unwrap()
|
||||
.to_lowercase()
|
||||
.contains("layer.new")
|
||||
}));
|
||||
.iter()
|
||||
.all(|c| { c["id"].as_str().unwrap().contains("layer.new") || c["label"].as_str().unwrap().to_lowercase().contains("layer.new") })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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".into(), "--filter".into(), "file.new".into()], &mut out, &mut err);
|
||||
assert_eq!(code, 0);
|
||||
assert!(String::from_utf8(out).unwrap().contains("file.new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 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| {
|
||||
|
|
@ -300,33 +199,19 @@ fn mcp_stdio_handshake_and_tool_call() {
|
|||
}
|
||||
}
|
||||
};
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
|
||||
);
|
||||
send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}));
|
||||
let init = recv(1);
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "photocraft", "{init}");
|
||||
send(json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
|
||||
send(json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}));
|
||||
let tools = recv(2);
|
||||
let names: Vec<&str> = tools["result"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|t| t["name"].as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
names.contains(&"command_run") && names.contains(&"doc_render_preview"),
|
||||
"{names:?}"
|
||||
);
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"doc_new","arguments":{"width":8,"height":8}}}),
|
||||
);
|
||||
let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().filter_map(|t| t["name"].as_str()).collect();
|
||||
assert!(names.contains(&"command_run") && names.contains(&"doc_render_preview"), "{names:?}");
|
||||
send(json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"doc_new","arguments":{"width":8,"height":8}}}));
|
||||
let r = recv(3);
|
||||
assert_ne!(r["result"]["isError"], true, "{r}");
|
||||
send(
|
||||
json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"doc_render_preview","arguments":{}}}),
|
||||
);
|
||||
send(json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"doc_render_preview","arguments":{}}}));
|
||||
let r = recv(4);
|
||||
assert_eq!(r["result"]["content"][0]["type"], "image", "{r}");
|
||||
let _ = child.kill();
|
||||
|
|
@ -345,25 +230,10 @@ 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));
|
||||
assert_eq!(
|
||||
out.lines().filter(|l| l.starts_with("ok")).count(),
|
||||
2,
|
||||
"{out}"
|
||||
);
|
||||
let (out, _) = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir));
|
||||
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));
|
||||
let o = bin()
|
||||
.arg("droplet")
|
||||
.arg(d.join("a.png"))
|
||||
.arg(d.join("b.png"))
|
||||
.output()
|
||||
.unwrap();
|
||||
let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap();
|
||||
assert!(!o.status.success(), "not a droplet");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,5 @@ fn main() {
|
|||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!(
|
||||
"photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web"
|
||||
);
|
||||
eprintln!("photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,8 @@ use wasm_bindgen::JsCast as _;
|
|||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
const IMAGE_EXTS: &[&str] = &[
|
||||
"psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi",
|
||||
"exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
|
||||
];
|
||||
const IMAGE_EXTS: &[&str] =
|
||||
&["psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
pub fn start() {
|
||||
|
|
@ -24,10 +22,7 @@ pub fn start() {
|
|||
log::error!("no document");
|
||||
return;
|
||||
};
|
||||
let Some(canvas) = document
|
||||
.get_element_by_id(CANVAS_ID)
|
||||
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
else {
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
|
|
@ -35,8 +30,7 @@ pub fn start() {
|
|||
let force_cpu = q.contains("cpu");
|
||||
let mut options = eframe::WebOptions::default();
|
||||
if q.contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) =
|
||||
&mut options.wgpu_options.wgpu_setup
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
|
|
@ -47,18 +41,12 @@ pub fn start() {
|
|||
Box::new(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
|
||||
let inbox: Inbox = Arc::default();
|
||||
let mut app = PhotocraftApp::new(
|
||||
Session::new(),
|
||||
services(inbox.clone(), cc.egui_ctx.clone()),
|
||||
);
|
||||
let mut app = PhotocraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
app.set_theme(&cc.egui_ctx, ThemeKind::Pro);
|
||||
if let Some(rs) = cc.wgpu_render_state.clone()
|
||||
&& !force_cpu
|
||||
{
|
||||
log::info!(
|
||||
"photocraft-web: wgpu backend {:?}",
|
||||
rs.adapter.get_info().backend
|
||||
);
|
||||
log::info!("photocraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
app.set_wgpu(rs);
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
|
|
@ -75,9 +63,7 @@ pub fn start() {
|
|||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window()
|
||||
.and_then(|w| w.location().search().ok())
|
||||
.unwrap_or_default()
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously,
|
||||
|
|
@ -94,17 +80,10 @@ impl eframe::App for WebShell {
|
|||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f
|
||||
.path()
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "dropped".into());
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push((name, bytes));
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
|
|
@ -122,68 +101,41 @@ impl eframe::App for WebShell {
|
|||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
Services {
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8]| {
|
||||
photocraft_io::import(name, bytes)
|
||||
.map(|r| r.document)
|
||||
.map_err(|e| e.to_string())
|
||||
})),
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| e.to_string()))),
|
||||
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
photocraft_io::export(doc, path, &opts)
|
||||
.map(|r| r.bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
photocraft_io::export(doc, path, &opts).map(|r| r.bytes).map_err(|e| e.to_string())
|
||||
})),
|
||||
pick_open: Some(Box::new(move || {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.add_filter("Images", IMAGE_EXTS)
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter("Images", IMAGE_EXTS).pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push((file.file_name(), bytes));
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
None
|
||||
})),
|
||||
// No save dialog on the web: the suggested name becomes the download name.
|
||||
pick_save: Some(Box::new(|suggested: &str| {
|
||||
let name = std::path::Path::new(suggested)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| suggested.to_string());
|
||||
let name = std::path::Path::new(suggested).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| suggested.to_string());
|
||||
Some(name)
|
||||
})),
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
|
||||
encode_png: Some(Box::new(|w, h, rgba| {
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec())
|
||||
.map_err(|e| e.to_string())?;
|
||||
photocraft_codecs::encode(
|
||||
&img,
|
||||
photocraft_codecs::Format::Png,
|
||||
&EncodeOptions::default(),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
// Preferences live in the browser's localStorage.
|
||||
load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())),
|
||||
save_prefs: Some(Box::new(|text: &str| {
|
||||
local_storage()
|
||||
.ok_or("no localStorage")?
|
||||
.set_item(PREFS_KEY, text)
|
||||
.map_err(|e| format!("{e:?}"))
|
||||
})),
|
||||
save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -197,10 +149,7 @@ fn local_storage() -> Option<web_sys::Storage> {
|
|||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let name = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "photocraft".into());
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photocraft".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
|
|
@ -208,11 +157,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
|||
opts.set_type(mime_for(&name));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
let a: web_sys::HtmlAnchorElement = document
|
||||
.create_element("a")
|
||||
.map_err(js)?
|
||||
.dyn_into()
|
||||
.map_err(|_| "not an anchor")?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(&name);
|
||||
a.style().set_property("display", "none").map_err(js)?;
|
||||
|
|
@ -224,19 +169,12 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
|||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
web_sys::Url::revoke_object_url(&url).ok();
|
||||
});
|
||||
window
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000)
|
||||
.map_err(js)?;
|
||||
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mime_for(name: &str) -> &'static str {
|
||||
match name
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref()
|
||||
{
|
||||
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("tif" | "tiff") => "image/tiff",
|
||||
|
|
|
|||
|
|
@ -50,7 +50,13 @@ fn main() -> eframe::Result {
|
|||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon())
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft").with_inner_size([1440.0, 900.0]).with_min_inner_size([760.0, 480.0]).with_drag_and_drop(true).with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([760.0, 480.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, Size};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_format::Autosaver;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_ui_egui::Services;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -12,7 +12,8 @@ use std::path::{Path, PathBuf};
|
|||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
const IMAGE_EXTS: &[&str] = &["psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"];
|
||||
const IMAGE_EXTS: &[&str] =
|
||||
&["psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"];
|
||||
|
||||
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else the platform convention
|
||||
/// (macOS `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux
|
||||
|
|
@ -68,7 +69,12 @@ pub fn native() -> Services {
|
|||
})),
|
||||
pick_save: Some(Box::new(|suggested: &str| {
|
||||
let p = std::path::Path::new(suggested);
|
||||
let mut d = rfd::FileDialog::new().add_filter("Photoshop", &["psd", "psb"]).add_filter("PNG", &["png"]).add_filter("JPEG", &["jpg"]).add_filter("TIFF", &["tif"]).add_filter("OpenEXR", &["exr"]);
|
||||
let mut d = rfd::FileDialog::new()
|
||||
.add_filter("Photoshop", &["psd", "psb"])
|
||||
.add_filter("PNG", &["png"])
|
||||
.add_filter("JPEG", &["jpg"])
|
||||
.add_filter("TIFF", &["tif"])
|
||||
.add_filter("OpenEXR", &["exr"]);
|
||||
if let Some(name) = p.file_name() {
|
||||
d = d.set_file_name(name.to_string_lossy());
|
||||
}
|
||||
|
|
@ -136,7 +142,13 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
|
|||
};
|
||||
let gray = matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA);
|
||||
let cmyk = matches!(img.layout(), ChannelLayout::Cmyk | ChannelLayout::CmykA);
|
||||
let mode = if gray { ColorMode::Grayscale } else if cmyk { ColorMode::Cmyk } else { ColorMode::Rgb };
|
||||
let mode = if gray {
|
||||
ColorMode::Grayscale
|
||||
} else if cmyk {
|
||||
ColorMode::Cmyk
|
||||
} else {
|
||||
ColorMode::Rgb
|
||||
};
|
||||
let target = match mode {
|
||||
ColorMode::Grayscale => ChannelLayout::GrayA,
|
||||
ColorMode::Cmyk => ChannelLayout::CmykA,
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ fn main() {
|
|||
let r = Rect::new(0, 0, w, h);
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
// Busy deterministic content (gradients plus texture) so edge-aware filters do real work.
|
||||
let bytes: Vec<u8> = (0..h)
|
||||
.flat_map(|y| (0..w).flat_map(move |x| [(x % 256) as u8, ((x * 3 + y) / 11 % 256) as u8, ((x ^ y) % 256) as u8, 255]))
|
||||
.collect();
|
||||
let bytes: Vec<u8> = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x % 256) as u8, ((x * 3 + y) / 11 % 256) as u8, ((x ^ y) % 256) as u8, 255])).collect();
|
||||
s.write_interleaved(r, &bytes);
|
||||
let cases = vec![
|
||||
FilterParams::OilPaint { stylization: 4.0, cleanliness: 5.0, scale: 1.0, bristle_detail: 5.0, lighting: true, angle: -60.0, shine: 1.0 },
|
||||
|
|
@ -43,7 +41,16 @@ fn main() {
|
|||
FilterParams::PathBlur { paths: vec![BlurPath::default()] },
|
||||
FilterParams::Crystallize { cell_size: 10.0, seed: 0 },
|
||||
FilterParams::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] },
|
||||
FilterParams::LightingEffects { lights: vec![Light::default()], gloss: 0.0, metallic: 0.0, exposure: 0.0, ambience: 8.0, texture: TextureChannel::Luminance, height: 50.0, white_is_high: true },
|
||||
FilterParams::LightingEffects {
|
||||
lights: vec![Light::default()],
|
||||
gloss: 0.0,
|
||||
metallic: 0.0,
|
||||
exposure: 0.0,
|
||||
ambience: 8.0,
|
||||
texture: TextureChannel::Luminance,
|
||||
height: 50.0,
|
||||
white_is_high: true,
|
||||
},
|
||||
];
|
||||
for p in cases {
|
||||
if only.as_deref().is_some_and(|o| !p.label().to_lowercase().contains(&o.to_lowercase())) {
|
||||
|
|
|
|||
|
|
@ -93,14 +93,24 @@ fn main() {
|
|||
|
||||
// Puppet warp on a 24 MP layer.
|
||||
let pw = PuppetWarp {
|
||||
pins: vec![PuppetPin { src: [1000.0, 2000.0], dst: [1000.0, 2000.0], rotate: None, depth: 0 }, PuppetPin { src: [5000.0, 2000.0], dst: [4800.0, 1200.0], rotate: None, depth: 0 }, PuppetPin { src: [3000.0, 3500.0], dst: [3100.0, 3500.0], rotate: None, depth: 0 }],
|
||||
pins: vec![
|
||||
PuppetPin { src: [1000.0, 2000.0], dst: [1000.0, 2000.0], rotate: None, depth: 0 },
|
||||
PuppetPin { src: [5000.0, 2000.0], dst: [4800.0, 1200.0], rotate: None, depth: 0 },
|
||||
PuppetPin { src: [3000.0, 3500.0], dst: [3100.0, 3500.0], rotate: None, depth: 0 },
|
||||
],
|
||||
mode: PuppetMode::Normal,
|
||||
density: PuppetDensity::Normal,
|
||||
expansion: 2.0,
|
||||
};
|
||||
let t = Instant::now();
|
||||
let (solver, v, order) = deform(&s, b, &pw, photocraft_algo::puppet::ITERATIONS);
|
||||
println!("puppet mesh {} verts / {} tris + ARAP ({} rounds): {:.1} ms", solver.mesh.verts.len(), solver.mesh.tris.len(), photocraft_algo::puppet::ITERATIONS, t.elapsed().as_secs_f64() * 1e3);
|
||||
println!(
|
||||
"puppet mesh {} verts / {} tris + ARAP ({} rounds): {:.1} ms",
|
||||
solver.mesh.verts.len(),
|
||||
solver.mesh.tris.len(),
|
||||
photocraft_algo::puppet::ITERATIONS,
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
);
|
||||
let dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect();
|
||||
let t = Instant::now();
|
||||
let _ = solver.solve(&dst, &[None; 3], PuppetMode::Normal, 4, Some(&v));
|
||||
|
|
|
|||
|
|
@ -46,7 +46,23 @@ fn main() {
|
|||
|
||||
time("camera raw: basic (wb, exposure, tone)", &only, || {
|
||||
let mut a = px.clone();
|
||||
develop(&mut a, w, h, &CameraRaw { temperature: 20.0, exposure: 0.3, contrast: 20.0, highlights: -40.0, shadows: 40.0, whites: 10.0, blacks: -10.0, vibrance: 20.0, ..Default::default() }, false);
|
||||
develop(
|
||||
&mut a,
|
||||
w,
|
||||
h,
|
||||
&CameraRaw {
|
||||
temperature: 20.0,
|
||||
exposure: 0.3,
|
||||
contrast: 20.0,
|
||||
highlights: -40.0,
|
||||
shadows: 40.0,
|
||||
whites: 10.0,
|
||||
blacks: -10.0,
|
||||
vibrance: 20.0,
|
||||
..Default::default()
|
||||
},
|
||||
false,
|
||||
);
|
||||
});
|
||||
time("camera raw: clarity + texture + dehaze", &only, || {
|
||||
let mut a = px.clone();
|
||||
|
|
@ -56,26 +72,59 @@ fn main() {
|
|||
let mut a = px.clone();
|
||||
let mut hs = [0.0; 8];
|
||||
hs[2] = 30.0;
|
||||
develop(&mut a, w, h, &CameraRaw { hsl_sat: hs, grade_shadows: Wheel { hue: 220.0, sat: 30.0, lum: 0.0 }, point_curve: vec![[0.0, 10.0], [128.0, 140.0], [255.0, 250.0]], ..Default::default() }, false);
|
||||
develop(
|
||||
&mut a,
|
||||
w,
|
||||
h,
|
||||
&CameraRaw {
|
||||
hsl_sat: hs,
|
||||
grade_shadows: Wheel { hue: 220.0, sat: 30.0, lum: 0.0 },
|
||||
point_curve: vec![[0.0, 10.0], [128.0, 140.0], [255.0, 250.0]],
|
||||
..Default::default()
|
||||
},
|
||||
false,
|
||||
);
|
||||
});
|
||||
time("camera raw: sharpen + NR + grain + vignette", &only, || {
|
||||
let mut a = px.clone();
|
||||
develop(&mut a, w, h, &CameraRaw { sharpen_amount: 40.0, noise_luminance: 30.0, noise_color: 25.0, grain_amount: 20.0, vignette_amount: -30.0, ..Default::default() }, false);
|
||||
develop(
|
||||
&mut a,
|
||||
w,
|
||||
h,
|
||||
&CameraRaw { sharpen_amount: 40.0, noise_luminance: 30.0, noise_color: 25.0, grain_amount: 20.0, vignette_amount: -30.0, ..Default::default() },
|
||||
false,
|
||||
);
|
||||
});
|
||||
time("lens correction (distortion, CA, vignette, persp)", &only, || {
|
||||
lens::correct(&s, r, &LensCorrection { profile: Some(lens::generic_profile(24.0)), distortion: 10.0, red_cyan: 20.0, vignette_amount: 30.0, vertical: -20.0, angle: 1.5, ..Default::default() })
|
||||
lens::correct(
|
||||
&s,
|
||||
r,
|
||||
&LensCorrection {
|
||||
profile: Some(lens::generic_profile(24.0)),
|
||||
distortion: 10.0,
|
||||
red_cyan: 20.0,
|
||||
vignette_amount: 30.0,
|
||||
vertical: -20.0,
|
||||
angle: 1.5,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
});
|
||||
time("adaptive wide angle (fisheye, 2 constraints)", &only, || {
|
||||
let p = WideAngle {
|
||||
model: WideModel::Fisheye,
|
||||
focal_length: 12.0,
|
||||
constraints: vec![Constraint { a: [500.0, 600.0], b: [5500.0, 600.0], orientation: Orientation::Horizontal }, Constraint { a: [700.0, 500.0], b: [700.0, 3500.0], orientation: Orientation::Vertical }],
|
||||
constraints: vec![
|
||||
Constraint { a: [500.0, 600.0], b: [5500.0, 600.0], orientation: Orientation::Horizontal },
|
||||
Constraint { a: [700.0, 500.0], b: [700.0, 3500.0], orientation: Orientation::Vertical },
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
wideangle::apply(&s, r, &p, Interp::Bicubic)
|
||||
});
|
||||
time("hdr: merge 3 exposures + local adaptation", &only, || {
|
||||
let shots: Vec<Vec<[f32; 4]>> = [0.5f32, 1.0, 2.0].iter().map(|k| px.iter().map(|q| [(q[0] * k).min(1.0), (q[1] * k).min(1.0), (q[2] * k).min(1.0), 1.0]).collect()).collect();
|
||||
let shots: Vec<Vec<[f32; 4]>> =
|
||||
[0.5f32, 1.0, 2.0].iter().map(|k| px.iter().map(|q| [(q[0] * k).min(1.0), (q[1] * k).min(1.0), (q[2] * k).min(1.0), 1.0]).collect()).collect();
|
||||
let refs: Vec<&[[f32; 4]]> = shots.iter().map(Vec::as_slice).collect();
|
||||
let t0 = Instant::now();
|
||||
let mut m = hdr::merge(w, h, &refs, &MergeOptions { exposures: vec![0.5, 1.0, 2.0], remove_ghosts: true, ghost_base: None, response: None });
|
||||
|
|
@ -85,16 +134,43 @@ fn main() {
|
|||
time("crop and straighten: detection", &only, || photocraft_algo::scancrop::find_photos(w, h, &px));
|
||||
time("panorama: 4 × 2 MP register + blend", &only, || {
|
||||
let (sw, sh) = (1700usize, 1200usize);
|
||||
let views: Vec<Vec<f32>> = (0..4).map(|k| (0..sw * sh).map(|i| { let q = px[(i / sw + 300) * w + i % sw + 1100 * k]; 0.3 * q[0] + 0.6 * q[1] + 0.1 * q[2] }).collect()).collect();
|
||||
let views: Vec<Vec<f32>> = (0..4)
|
||||
.map(|k| {
|
||||
(0..sw * sh)
|
||||
.map(|i| {
|
||||
let q = px[(i / sw + 300) * w + i % sw + 1100 * k];
|
||||
0.3 * q[0] + 0.6 * q[1] + 0.1 * q[2]
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let prepared: Vec<panorama::PreparedImage> = views.iter().map(|v| (sw, sh, v.clone(), None)).collect();
|
||||
let t0 = Instant::now();
|
||||
let feats = panorama::detect_many(&prepared, 800);
|
||||
let matches = panorama::match_pairs(&feats);
|
||||
let al = panorama::align(&feats, &matches, &AlignOptions { layout: Layout::Reposition, focal: None, reference: None, geometric: false });
|
||||
println!(" register {:.1} ms ({} pairs, placed {})", t0.elapsed().as_secs_f64() * 1000.0, matches.len(), al.as_ref().map_or(0, |a| a.placements.iter().flatten().count()));
|
||||
let rois: Vec<RoiImage> = views.iter().enumerate().map(|(k, v)| RoiImage { x0: 1100 * k as i32, y0: 0, w: sw, h: sh, ch: 1, px: v.clone(), alpha: vec![1.0; sw * sh] }).collect();
|
||||
println!(
|
||||
" register {:.1} ms ({} pairs, placed {})",
|
||||
t0.elapsed().as_secs_f64() * 1000.0,
|
||||
matches.len(),
|
||||
al.as_ref().map_or(0, |a| a.placements.iter().flatten().count())
|
||||
);
|
||||
let rois: Vec<RoiImage> = views
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, v)| RoiImage { x0: 1100 * k as i32, y0: 0, w: sw, h: sh, ch: 1, px: v.clone(), alpha: vec![1.0; sw * sh] })
|
||||
.collect();
|
||||
let cw = 1100 * 3 + sw;
|
||||
let wts: Vec<Vec<f32>> = (0..4i32).map(|k| (0..sw * sh).map(|i| { let x = (i % sw) as i32 + 1100 * k; if (x / 1100).min(3) == k { 1.0 } else { 0.0 } }).collect()).collect();
|
||||
let wts: Vec<Vec<f32>> = (0..4i32)
|
||||
.map(|k| {
|
||||
(0..sw * sh)
|
||||
.map(|i| {
|
||||
let x = (i % sw) as i32 + 1100 * k;
|
||||
if (x / 1100).min(3) == k { 1.0 } else { 0.0 }
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let t1 = Instant::now();
|
||||
let _ = panorama::multiband(cw, sh, &rois, &wts, panorama::blend_levels(cw, sh));
|
||||
println!(" multiband {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0);
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ fn main() {
|
|||
for d in [50usize, 100, 200, 300] {
|
||||
let n = d + 6;
|
||||
let (w, h, ch) = (n, n, 4);
|
||||
let img: Vec<f32> = (0..w * h).flat_map(|i| {
|
||||
let img: Vec<f32> = (0..w * h)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
let v = 0.5 + 0.2 * (x * 0.1).sin() * (y * 0.13).cos();
|
||||
[v, v * 0.8, 0.3, 1.0]
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
let src: Vec<f32> = img.iter().map(|v| v * 0.7).collect();
|
||||
let r = d as f32 / 2.0;
|
||||
let mask: Vec<bool> = (0..w * h).map(|i| ((i % w) as f32 - n as f32 / 2.0).hypot((i / w) as f32 - n as f32 / 2.0) < r).collect();
|
||||
|
|
@ -23,11 +25,13 @@ fn main() {
|
|||
// Completion on a region with a margin, like Spot Healing.
|
||||
let m = d + 16;
|
||||
let (cw, chh) = (d + 2 * m, d + 2 * m);
|
||||
let cimg: Vec<f32> = (0..cw * chh).flat_map(|i| {
|
||||
let cimg: Vec<f32> = (0..cw * chh)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = ((i % cw) as f32, (i / cw) as f32);
|
||||
let v = 0.5 + 0.2 * (x * 0.1).sin() * (y * 0.13).cos();
|
||||
[v, v * 0.8, 0.3, 1.0]
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
let c = cw as f32 / 2.0;
|
||||
let hole: Vec<bool> = (0..cw * chh).map(|i| ((i % cw) as f32 - c).hypot((i / cw) as f32 - c) < r + 2.0).collect();
|
||||
let t = Instant::now();
|
||||
|
|
|
|||
|
|
@ -97,7 +97,11 @@ pub const GALLERY_CATEGORIES: [&str; 6] = ["Artistic", "Brush Strokes", "Distort
|
|||
/// One parameter of a gallery filter.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum GalleryParamKind {
|
||||
Range { min: f32, max: f32, default: f32 },
|
||||
Range {
|
||||
min: f32,
|
||||
max: f32,
|
||||
default: f32,
|
||||
},
|
||||
/// Choices; the first is the default. Stored as the index.
|
||||
Choice(Vec<&'static str>),
|
||||
Bool(bool),
|
||||
|
|
|
|||
|
|
@ -673,7 +673,8 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
|||
let eb = g("edgeBrightness") / 50.0;
|
||||
for i in 0..win.c.len() {
|
||||
let k = clamp01(edge[i] * 6.0);
|
||||
win.c[i] = if eb >= 0.5 { map(win.c[i], |v| v + (1.0 - v) * k * (eb - 0.5) * 2.0) } else { map(win.c[i], |v| v * (1.0 - k * (0.5 - eb) * 2.0)) };
|
||||
win.c[i] =
|
||||
if eb >= 0.5 { map(win.c[i], |v| v + (1.0 - v) * k * (eb - 0.5) * 2.0) } else { map(win.c[i], |v| v * (1.0 - k * (0.5 - eb) * 2.0)) };
|
||||
}
|
||||
}
|
||||
F::AngledStrokes | F::Crosshatch => {
|
||||
|
|
@ -863,10 +864,12 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
|||
let r = (g("strokeLength") / 2.0) as usize;
|
||||
let bal = g("lightDarkBalance") / 100.0;
|
||||
let l = win.lum();
|
||||
let mut n: Vec<f32> = (0..win.c.len()).map(|i| {
|
||||
let mut n: Vec<f32> = (0..win.c.len())
|
||||
.map(|i| {
|
||||
let (x, y) = win.xy(i);
|
||||
hash01(x, y, 0, 151)
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
line_blur(&mut n, 1, w, h, direction(e.choice("strokeDirection")), r);
|
||||
let sd = 0.2887 / ((2 * r + 1) as f32).sqrt();
|
||||
for i in 0..win.c.len() {
|
||||
|
|
@ -1034,7 +1037,11 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
|||
let q = match ty {
|
||||
"soft" => [p[0] + hc(0) * it * 0.4, p[1] + hc(1) * it * 0.4, p[2] + hc(2) * it * 0.4],
|
||||
"sprinkles" => {
|
||||
if hash01(x, y, 3, 211) < it * 0.15 { bg } else { p }
|
||||
if hash01(x, y, 3, 211) < it * 0.15 {
|
||||
bg
|
||||
} else {
|
||||
p
|
||||
}
|
||||
}
|
||||
"clumped" => map(p, |v| v + (vnoise(x as f32 / 2.0, y as f32 / 2.0, 212) - 0.5) * it * 1.2),
|
||||
"contrasty" => map(p, |v| (v - 0.5) * (1.0 + it * 2.0) + 0.5 + hc(4) * it * 0.5),
|
||||
|
|
@ -1049,7 +1056,11 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
|||
"horizontal" => map(p, |v| v + (vnoise(x as f32 / 25.0, y as f32, 216) - 0.5) * it),
|
||||
"vertical" => map(p, |v| v + (vnoise(x as f32, y as f32 / 25.0, 217) - 0.5) * it),
|
||||
"speckle" => {
|
||||
if hash01(x, y, 6, 211) < it * 0.1 { mix(p, fg, 0.85) } else { p }
|
||||
if hash01(x, y, 6, 211) < it * 0.1 {
|
||||
mix(p, fg, 0.85)
|
||||
} else {
|
||||
p
|
||||
}
|
||||
}
|
||||
_ => [p[0] + hc(0) * it * 0.8, p[1] + hc(1) * it * 0.8, p[2] + hc(2) * it * 0.8],
|
||||
};
|
||||
|
|
@ -1191,7 +1202,14 @@ mod tests {
|
|||
s.fill_rect(Rect::new(0, y, w, y + 3), &[0.9, 0.8, 0.2, 1.0]);
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
std::hint::black_box(crate::apply_in(&s, &FilterParams::AddNoise { amount: 10.0, distribution: crate::Distribution::Uniform, monochromatic: true, seed: 1 }, b, b, None, b));
|
||||
std::hint::black_box(crate::apply_in(
|
||||
&s,
|
||||
&FilterParams::AddNoise { amount: 10.0, distribution: crate::Distribution::Uniform, monochromatic: true, seed: 1 },
|
||||
b,
|
||||
b,
|
||||
None,
|
||||
b,
|
||||
));
|
||||
println!("{:>18}: {:>7.0} ms", "(addNoise baseline)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
let mut total = 0.0;
|
||||
for f in crate::GalleryFilter::ALL {
|
||||
|
|
|
|||
|
|
@ -916,7 +916,13 @@ mod tests {
|
|||
let vib = run(CameraRaw { vibrance: 100.0, ..Default::default() });
|
||||
let spread = |q: [f32; 4]| q[0].max(q[1]).max(q[2]) - q[0].min(q[1]).min(q[2]);
|
||||
assert!(spread(vib[w * h / 2]) > spread(base[w * h / 2]));
|
||||
for p in [CameraRaw { clarity: 80.0, ..Default::default() }, CameraRaw { texture: 80.0, ..Default::default() }, CameraRaw { dehaze: 60.0, ..Default::default() }, CameraRaw { dehaze: -60.0, ..Default::default() }, CameraRaw { whites: 50.0, blacks: -50.0, ..Default::default() }] {
|
||||
for p in [
|
||||
CameraRaw { clarity: 80.0, ..Default::default() },
|
||||
CameraRaw { texture: 80.0, ..Default::default() },
|
||||
CameraRaw { dehaze: 60.0, ..Default::default() },
|
||||
CameraRaw { dehaze: -60.0, ..Default::default() },
|
||||
CameraRaw { whites: 50.0, blacks: -50.0, ..Default::default() },
|
||||
] {
|
||||
let out = run(p.clone());
|
||||
assert!(out.iter().all(|q| q[..3].iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))), "{p:?}");
|
||||
assert_ne!(out, base);
|
||||
|
|
@ -954,8 +960,16 @@ mod tests {
|
|||
develop(&mut c, w, h, &CameraRaw { point_curve: vec![[0.0, 30.0], [255.0, 255.0]], curve_shadows: 50.0, ..Default::default() }, false);
|
||||
assert!(c.iter().all(|q| q[1] >= 30.0 / 255.0 - 1e-4));
|
||||
// Noise reduction reduces variance; sharpening increases it; grain is deterministic.
|
||||
let noisy: Vec<[f32; 4]> = (0..w * h).map(|i| { let n = hash01(i as i64, 0, 3) * 0.2; [0.4 + n, 0.4 + n, 0.4 + n, 1.0] }).collect();
|
||||
let var = |a: &[[f32; 4]]| { let m = mean(a, 1); a.iter().map(|q| (q[1] - m).powi(2)).sum::<f32>() / a.len() as f32 };
|
||||
let noisy: Vec<[f32; 4]> = (0..w * h)
|
||||
.map(|i| {
|
||||
let n = hash01(i as i64, 0, 3) * 0.2;
|
||||
[0.4 + n, 0.4 + n, 0.4 + n, 1.0]
|
||||
})
|
||||
.collect();
|
||||
let var = |a: &[[f32; 4]]| {
|
||||
let m = mean(a, 1);
|
||||
a.iter().map(|q| (q[1] - m).powi(2)).sum::<f32>() / a.len() as f32
|
||||
};
|
||||
let mut nr = noisy.clone();
|
||||
develop(&mut nr, w, h, &CameraRaw { noise_luminance: 80.0, ..Default::default() }, false);
|
||||
assert!(var(&nr) < var(&noisy) * 0.5);
|
||||
|
|
|
|||
|
|
@ -136,7 +136,8 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid:
|
|||
let mut out = Vec::new();
|
||||
for c in cand {
|
||||
let (gx, gy) = ((c.x / cell) as usize, (c.y / cell) as usize);
|
||||
let near = (gy.saturating_sub(1)..=(gy + 1).min(gh - 1)).any(|yy| (gx.saturating_sub(1)..=(gx + 1).min(gw - 1)).any(|xx| grid[yy * gw + xx].iter().any(|&(px, py)| (px - c.x).hypot(py - c.y) < min_dist)));
|
||||
let near = (gy.saturating_sub(1)..=(gy + 1).min(gh - 1))
|
||||
.any(|yy| (gx.saturating_sub(1)..=(gx + 1).min(gw - 1)).any(|xx| grid[yy * gw + xx].iter().any(|&(px, py)| (px - c.x).hypot(py - c.y) < min_dist)));
|
||||
if near {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -311,7 +312,8 @@ pub fn fit(model: Model, src: &[[f64; 2]], dst: &[[f64; 2]]) -> Option<Homograph
|
|||
return None;
|
||||
}
|
||||
// x' = a·x − b·y + tx, y' = b·x + a·y + ty.
|
||||
let rows: Vec<(Vec<f64>, f64)> = src.iter().zip(dst).flat_map(|(s, d)| [(vec![s[0], -s[1], 1.0, 0.0], d[0]), (vec![s[1], s[0], 0.0, 1.0], d[1])]).collect();
|
||||
let rows: Vec<(Vec<f64>, f64)> =
|
||||
src.iter().zip(dst).flat_map(|(s, d)| [(vec![s[0], -s[1], 1.0, 0.0], d[0]), (vec![s[1], s[0], 0.0, 1.0], d[1])]).collect();
|
||||
let v = least_squares(&rows, 4)?;
|
||||
Some(Homography([v[0], -v[1], v[2], v[1], v[0], v[3], 0.0, 0.0, 1.0]))
|
||||
}
|
||||
|
|
@ -396,7 +398,15 @@ pub fn ransac(model: Model, src: &[[f64; 2]], dst: &[[f64; 2]], iters: usize, th
|
|||
/// Register `moving` onto `reference` (both `w × h` luminance; optional validity masks for
|
||||
/// transparent areas). Returns the model mapping `moving` coordinates to `reference`
|
||||
/// coordinates and the inlier count.
|
||||
pub fn register(w: usize, h: usize, reference: &[f32], moving: &[f32], valid_ref: Option<&[bool]>, valid_mov: Option<&[bool]>, model: Model) -> Option<(Homography, usize)> {
|
||||
pub fn register(
|
||||
w: usize,
|
||||
h: usize,
|
||||
reference: &[f32],
|
||||
moving: &[f32],
|
||||
valid_ref: Option<&[bool]>,
|
||||
valid_mov: Option<&[bool]>,
|
||||
model: Model,
|
||||
) -> Option<(Homography, usize)> {
|
||||
let upright = model == Model::Translation;
|
||||
let min_dist = (w.max(h) as f32 / 60.0).clamp(4.0, 24.0);
|
||||
let ca = harris(w, h, reference, 600, min_dist, valid_ref);
|
||||
|
|
@ -479,20 +489,26 @@ mod tests {
|
|||
fn fits_are_exact_on_clean_data() {
|
||||
let src = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [5.0, 3.0]];
|
||||
let truth = Homography([1.1, 0.05, 3.0, -0.02, 0.95, -2.0, 0.001, 0.0005, 1.0]);
|
||||
let dst: Vec<[f64; 2]> = src.iter().map(|p| {
|
||||
let dst: Vec<[f64; 2]> = src
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (x, y) = truth.apply(p[0], p[1]);
|
||||
[x, y]
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
let h = fit(Model::Homography, &src, &dst).unwrap();
|
||||
for p in &src {
|
||||
let (a, b) = (h.apply(p[0], p[1]), truth.apply(p[0], p[1]));
|
||||
assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6);
|
||||
}
|
||||
let sim = Homography([0.9, -0.2, 4.0, 0.2, 0.9, 1.0, 0.0, 0.0, 1.0]);
|
||||
let dst: Vec<[f64; 2]> = src.iter().map(|p| {
|
||||
let dst: Vec<[f64; 2]> = src
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (x, y) = sim.apply(p[0], p[1]);
|
||||
[x, y]
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
let h = fit(Model::Similarity, &src, &dst).unwrap();
|
||||
assert!(h.0.iter().zip(sim.0).all(|(a, b)| (a - b).abs() < 1e-9));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,10 +35,12 @@ fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f3
|
|||
let (ow, oh) = (out.width() as usize, out.height() as usize);
|
||||
// Level σs: 0, then smax / 2^(LEVELS-1) … smax.
|
||||
let lv: Vec<f32> = std::iter::once(0.0).chain((0..LEVELS).map(|k| smax / 2f32.powi((LEVELS - 1 - k) as i32))).collect();
|
||||
let sig: Vec<f32> = (0..ow * oh).map(|i| {
|
||||
let sig: Vec<f32> = (0..ow * oh)
|
||||
.map(|i| {
|
||||
let (x, y) = xy(out, i);
|
||||
sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax)
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
// Hat-function weight of level k for σ s.
|
||||
let weight = |k: usize, s: f32| -> f32 {
|
||||
let c = lv[k];
|
||||
|
|
@ -146,7 +148,8 @@ pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec
|
|||
return src.crop(out);
|
||||
}
|
||||
let smax = pins.iter().map(|p| sigma_of(p.blur)).fold(0.0, f32::max);
|
||||
let pts: Vec<(f32, f32, f32)> = pins.iter().map(|p| (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32, sigma_of(p.blur))).collect();
|
||||
let pts: Vec<(f32, f32, f32)> =
|
||||
pins.iter().map(|p| (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32, sigma_of(p.blur))).collect();
|
||||
variable_blur(src, out, ctx, smax, |x, y| {
|
||||
let (mut ws, mut s) = (0.0f32, 0.0f32);
|
||||
for &(px, py, sg) in &pts {
|
||||
|
|
|
|||
|
|
@ -459,7 +459,8 @@ mod tests {
|
|||
let r = est[k] / est[k - 1];
|
||||
assert!((2.0..8.0).contains(&r), "ratio {r} ({est:?})");
|
||||
}
|
||||
let m = merge(w, h, &refs, &MergeOptions { exposures: dts.iter().map(|d| *d as f64).collect(), remove_ghosts: false, ghost_base: None, response: None });
|
||||
let m =
|
||||
merge(w, h, &refs, &MergeOptions { exposures: dts.iter().map(|d| *d as f64).collect(), remove_ghosts: false, ghost_base: None, response: None });
|
||||
// Radiance proportional to the scene: check log-ratios across the 12-stop ramp.
|
||||
let probe = |x: usize| m.px[(h / 2) * w + x][1] as f64 / sc[(h / 2) * w + x][1] as f64;
|
||||
let k0 = probe(w / 2);
|
||||
|
|
@ -498,7 +499,12 @@ mod tests {
|
|||
#[test]
|
||||
fn mtb_finds_shifts() {
|
||||
let (w, h) = (128usize, 96usize);
|
||||
let img: Vec<f32> = (0..w * h).map(|i| { let (x, y) = ((i % w) as f32, (i / w) as f32); 0.5 + 0.4 * ((x * 0.21).sin() * (y * 0.17).cos()) }).collect();
|
||||
let img: Vec<f32> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
0.5 + 0.4 * ((x * 0.21).sin() * (y * 0.17).cos())
|
||||
})
|
||||
.collect();
|
||||
let (dx, dy) = (5i32, -3i32);
|
||||
let shifted: Vec<f32> = (0..w * h)
|
||||
.map(|i| {
|
||||
|
|
@ -515,7 +521,12 @@ mod tests {
|
|||
fn tone_mapping_methods_produce_display_range() {
|
||||
let (w, h) = (96usize, 32usize);
|
||||
let sc = scene(w, h);
|
||||
for method in [ToneMethod::LocalAdaptation(HdrToning { radius: 7.0, strength: 0.52, ..Default::default() }), ToneMethod::ExposureGamma { exposure: 0.0, gamma: 1.0 }, ToneMethod::HighlightCompression, ToneMethod::EqualizeHistogram] {
|
||||
for method in [
|
||||
ToneMethod::LocalAdaptation(HdrToning { radius: 7.0, strength: 0.52, ..Default::default() }),
|
||||
ToneMethod::ExposureGamma { exposure: 0.0, gamma: 1.0 },
|
||||
ToneMethod::HighlightCompression,
|
||||
ToneMethod::EqualizeHistogram,
|
||||
] {
|
||||
let mut px: Vec<[f32; 4]> = sc.iter().map(|r| [r[0], r[1], r[2], 1.0]).collect();
|
||||
tone_map(&mut px, w, h, &method);
|
||||
assert!(px.iter().all(|q| q[..3].iter().all(|v| (0.0..=1.0).contains(v))));
|
||||
|
|
|
|||
|
|
@ -234,9 +234,9 @@ pub fn auto_scale(lc: &LensCorrection, frame: Rect) -> f64 {
|
|||
let (x0, y0, x1, y1) = (frame.x0 as f64, frame.y0 as f64, frame.x1 as f64, frame.y1 as f64);
|
||||
(0..=40).all(|i| {
|
||||
let t = i as f64 / 40.0;
|
||||
[(x0 + t * (x1 - x0), y0), (x0 + t * (x1 - x0), y1), (x0, y0 + t * (y1 - y0)), (x1, y0 + t * (y1 - y0))].iter().all(|&(x, y)| {
|
||||
m.sample(x, y).is_some_and(|s| s.p[0] >= x0 - 0.5 && s.p[0] <= x1 + 0.5 && s.p[1] >= y0 - 0.5 && s.p[1] <= y1 + 0.5)
|
||||
})
|
||||
[(x0 + t * (x1 - x0), y0), (x0 + t * (x1 - x0), y1), (x0, y0 + t * (y1 - y0)), (x1, y0 + t * (y1 - y0))]
|
||||
.iter()
|
||||
.all(|&(x, y)| m.sample(x, y).is_some_and(|s| s.p[0] >= x0 - 0.5 && s.p[0] <= x1 + 0.5 && s.p[1] >= y0 - 0.5 && s.p[1] <= y1 + 0.5))
|
||||
})
|
||||
};
|
||||
if covers(lc.scale) {
|
||||
|
|
@ -366,7 +366,13 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
|
|||
let alpha = if sfmt.alpha { bicubic(n_in - 1, s.p).clamp(0.0, 1.0) } else { 1.0 };
|
||||
let mut col = [0.0f32; 8];
|
||||
for (c, v) in col.iter_mut().enumerate().take(cc) {
|
||||
let p = if rgb && c == 0 { s.pr } else if rgb && c == 2 { s.pb } else { s.p };
|
||||
let p = if rgb && c == 0 {
|
||||
s.pr
|
||||
} else if rgb && c == 2 {
|
||||
s.pb
|
||||
} else {
|
||||
s.p
|
||||
};
|
||||
let pm = bicubic(c, p);
|
||||
let straight = if sfmt.alpha { if alpha > 1e-6 { pm / alpha } else { 0.0 } } else { pm };
|
||||
let g = s.gain;
|
||||
|
|
|
|||
|
|
@ -21,53 +21,53 @@ mod artistic;
|
|||
mod artistic_fx;
|
||||
mod blur;
|
||||
mod blur2;
|
||||
pub mod camera_raw;
|
||||
pub mod content_aware;
|
||||
mod denoise;
|
||||
mod distort;
|
||||
mod distort2;
|
||||
pub mod exif;
|
||||
pub mod features;
|
||||
mod fxutil;
|
||||
mod gallery;
|
||||
pub mod hdr;
|
||||
mod image;
|
||||
pub mod inpaint;
|
||||
pub mod lens;
|
||||
pub mod liquify;
|
||||
pub mod matting;
|
||||
mod noise;
|
||||
mod oil;
|
||||
mod other;
|
||||
mod other2;
|
||||
pub mod paint;
|
||||
pub mod panorama;
|
||||
pub mod perspective;
|
||||
mod photo_util;
|
||||
mod pixelate;
|
||||
pub mod poisson;
|
||||
pub mod puppet;
|
||||
pub mod pyramid;
|
||||
pub mod quantize;
|
||||
mod render;
|
||||
pub mod render2;
|
||||
mod stylize2;
|
||||
mod video;
|
||||
mod noise;
|
||||
mod other;
|
||||
pub mod paint;
|
||||
pub mod resample;
|
||||
pub mod selection;
|
||||
pub mod segment;
|
||||
pub mod matting;
|
||||
pub mod quantize;
|
||||
pub mod stack;
|
||||
pub mod tone;
|
||||
pub mod trap;
|
||||
pub mod transform;
|
||||
pub mod warp;
|
||||
pub mod liquify;
|
||||
pub mod puppet;
|
||||
pub mod perspective;
|
||||
pub mod poisson;
|
||||
pub mod inpaint;
|
||||
pub mod retouch;
|
||||
pub mod seam;
|
||||
pub mod content_aware;
|
||||
pub mod features;
|
||||
pub mod pyramid;
|
||||
pub mod panorama;
|
||||
pub mod hdr;
|
||||
pub mod exif;
|
||||
pub mod lens;
|
||||
pub mod wideangle;
|
||||
pub mod scancrop;
|
||||
pub mod camera_raw;
|
||||
pub mod vanishing;
|
||||
mod photo_util;
|
||||
pub mod seam;
|
||||
pub mod segment;
|
||||
pub mod selection;
|
||||
mod sharpen;
|
||||
pub mod stack;
|
||||
mod stylize;
|
||||
mod stylize2;
|
||||
pub mod tone;
|
||||
pub mod transform;
|
||||
pub mod trap;
|
||||
pub mod vanishing;
|
||||
mod video;
|
||||
pub mod warp;
|
||||
pub mod wideangle;
|
||||
|
||||
pub use artistic::{GALLERY_CATEGORIES, GalleryEffect, GalleryFilter, GalleryParam, GalleryParamKind};
|
||||
pub use image::{Edge, Image};
|
||||
|
|
@ -173,23 +173,58 @@ pub enum PolarMode {
|
|||
#[serde(tag = "filter", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum FilterParams {
|
||||
/// Radius = Gaussian standard deviation in pixels (0.1–1000).
|
||||
GaussianBlur { radius: f32 },
|
||||
GaussianBlur {
|
||||
radius: f32,
|
||||
},
|
||||
/// Box of `2·radius + 1` pixels.
|
||||
BoxBlur { radius: f32 },
|
||||
MotionBlur { angle: f32, distance: f32 },
|
||||
BoxBlur {
|
||||
radius: f32,
|
||||
},
|
||||
MotionBlur {
|
||||
angle: f32,
|
||||
distance: f32,
|
||||
},
|
||||
/// Amount 1–100; centre as a fraction of the bounds (0.5, 0.5 = centre).
|
||||
RadialBlur { amount: f32, method: RadialMethod, center_x: f32, center_y: f32 },
|
||||
RadialBlur {
|
||||
amount: f32,
|
||||
method: RadialMethod,
|
||||
center_x: f32,
|
||||
center_y: f32,
|
||||
},
|
||||
/// Radius px, threshold in levels.
|
||||
SurfaceBlur { radius: f32, threshold: f32 },
|
||||
SurfaceBlur {
|
||||
radius: f32,
|
||||
threshold: f32,
|
||||
},
|
||||
/// Amount %, radius px, threshold levels.
|
||||
UnsharpMask { amount: f32, radius: f32, threshold: f32 },
|
||||
UnsharpMask {
|
||||
amount: f32,
|
||||
radius: f32,
|
||||
threshold: f32,
|
||||
},
|
||||
/// Amount %, radius px, noise reduction % (basic: Gaussian unsharp).
|
||||
SmartSharpen { amount: f32, radius: f32, reduce_noise: f32 },
|
||||
HighPass { radius: f32 },
|
||||
SmartSharpen {
|
||||
amount: f32,
|
||||
radius: f32,
|
||||
reduce_noise: f32,
|
||||
},
|
||||
HighPass {
|
||||
radius: f32,
|
||||
},
|
||||
/// Amount 0–400 %.
|
||||
AddNoise { amount: f32, distribution: Distribution, monochromatic: bool, seed: u32 },
|
||||
Median { radius: f32 },
|
||||
DustAndScratches { radius: f32, threshold: f32 },
|
||||
AddNoise {
|
||||
amount: f32,
|
||||
distribution: Distribution,
|
||||
monochromatic: bool,
|
||||
seed: u32,
|
||||
},
|
||||
Median {
|
||||
radius: f32,
|
||||
},
|
||||
DustAndScratches {
|
||||
radius: f32,
|
||||
threshold: f32,
|
||||
},
|
||||
/// Radius px; `preserve` is the dialog's Preserve (older params default to Squareness).
|
||||
Minimum {
|
||||
radius: f32,
|
||||
|
|
@ -201,20 +236,37 @@ pub enum FilterParams {
|
|||
#[serde(default)]
|
||||
preserve: Preserve,
|
||||
},
|
||||
Offset { horizontal: i32, vertical: i32, undefined: UndefinedAreas },
|
||||
Mosaic { cell_size: f32 },
|
||||
Offset {
|
||||
horizontal: i32,
|
||||
vertical: i32,
|
||||
undefined: UndefinedAreas,
|
||||
},
|
||||
Mosaic {
|
||||
cell_size: f32,
|
||||
},
|
||||
/// Angle degrees, height px, amount %.
|
||||
Emboss { angle: f32, height: f32, amount: f32 },
|
||||
Emboss {
|
||||
angle: f32,
|
||||
height: f32,
|
||||
amount: f32,
|
||||
},
|
||||
FindEdges,
|
||||
Solarize,
|
||||
Invert,
|
||||
Desaturate,
|
||||
/// Angle −999…999 degrees.
|
||||
Twirl { angle: f32 },
|
||||
Twirl {
|
||||
angle: f32,
|
||||
},
|
||||
/// Amount −100…100 %.
|
||||
Pinch { amount: f32 },
|
||||
Pinch {
|
||||
amount: f32,
|
||||
},
|
||||
/// Amount −100…100 %.
|
||||
Spherize { amount: f32, mode: SpherizeMode },
|
||||
Spherize {
|
||||
amount: f32,
|
||||
mode: SpherizeMode,
|
||||
},
|
||||
Wave {
|
||||
generators: u32,
|
||||
wavelength_min: f32,
|
||||
|
|
@ -226,32 +278,83 @@ pub enum FilterParams {
|
|||
seed: u32,
|
||||
},
|
||||
/// Amount −999…999 %.
|
||||
Ripple { amount: f32, size: RippleSize },
|
||||
PolarCoordinates { mode: PolarMode },
|
||||
Ripple {
|
||||
amount: f32,
|
||||
size: RippleSize,
|
||||
},
|
||||
PolarCoordinates {
|
||||
mode: PolarMode,
|
||||
},
|
||||
|
||||
// ---- Pixelate ----
|
||||
/// Max dot radius px (4–127); screen angles per channel in degrees.
|
||||
ColorHalftone { max_radius: f32, angles: [f32; 4] },
|
||||
ColorHalftone {
|
||||
max_radius: f32,
|
||||
angles: [f32; 4],
|
||||
},
|
||||
/// Cell size px (3–300).
|
||||
Crystallize { cell_size: f32, seed: u32 },
|
||||
Crystallize {
|
||||
cell_size: f32,
|
||||
seed: u32,
|
||||
},
|
||||
Facet,
|
||||
Fragment,
|
||||
Mezzotint { kind: MezzotintType, seed: u32 },
|
||||
Mezzotint {
|
||||
kind: MezzotintType,
|
||||
seed: u32,
|
||||
},
|
||||
/// Cell size px (3–300); `background` is straight sRGB RGBA.
|
||||
Pointillize { cell_size: f32, seed: u32, background: [f32; 4] },
|
||||
Pointillize {
|
||||
cell_size: f32,
|
||||
seed: u32,
|
||||
background: [f32; 4],
|
||||
},
|
||||
|
||||
// ---- Stylize ----
|
||||
Diffuse { mode: DiffuseMode, seed: u32 },
|
||||
Diffuse {
|
||||
mode: DiffuseMode,
|
||||
seed: u32,
|
||||
},
|
||||
/// Size px (2–255), depth (1–255).
|
||||
Extrude { kind: ExtrudeType, size: f32, depth: f32, level_based: bool, solid_front: bool, mask_incomplete: bool, seed: u32 },
|
||||
Extrude {
|
||||
kind: ExtrudeType,
|
||||
size: f32,
|
||||
depth: f32,
|
||||
level_based: bool,
|
||||
solid_front: bool,
|
||||
mask_incomplete: bool,
|
||||
seed: u32,
|
||||
},
|
||||
/// Photoshop's Oil Paint units: stylization 0.1–10, cleanliness 0–10, scale 0.1–10,
|
||||
/// bristle detail 0–10, lighting angle degrees, shine 0–10.
|
||||
OilPaint { stylization: f32, cleanliness: f32, scale: f32, bristle_detail: f32, lighting: bool, angle: f32, shine: f32 },
|
||||
OilPaint {
|
||||
stylization: f32,
|
||||
cleanliness: f32,
|
||||
scale: f32,
|
||||
bristle_detail: f32,
|
||||
lighting: bool,
|
||||
angle: f32,
|
||||
shine: f32,
|
||||
},
|
||||
/// Tiles along the shorter side (1–99), max offset % (1–99).
|
||||
Tiles { count: u32, max_offset: f32, fill: TileFill, foreground: [f32; 4], background: [f32; 4], seed: u32 },
|
||||
Tiles {
|
||||
count: u32,
|
||||
max_offset: f32,
|
||||
fill: TileFill,
|
||||
foreground: [f32; 4],
|
||||
background: [f32; 4],
|
||||
seed: u32,
|
||||
},
|
||||
/// Level 0–255; `upper` traces the side above the level.
|
||||
TraceContour { level: f32, upper: bool },
|
||||
Wind { method: WindMethod, from_right: bool, seed: u32 },
|
||||
TraceContour {
|
||||
level: f32,
|
||||
upper: bool,
|
||||
},
|
||||
Wind {
|
||||
method: WindMethod,
|
||||
from_right: bool,
|
||||
seed: u32,
|
||||
},
|
||||
|
||||
// ---- Distort ----
|
||||
/// Scales in % (−999…999; 100 % = 128 px at full map contrast). The map is
|
||||
|
|
@ -265,25 +368,63 @@ pub enum FilterParams {
|
|||
map: Option<std::sync::Arc<Image>>,
|
||||
},
|
||||
/// Curve points `[t, offset]`: t 0–1 top→bottom, offset −1…1 of half the width.
|
||||
Shear { points: Vec<[f32; 2]>, undefined: UndefinedAreas },
|
||||
Shear {
|
||||
points: Vec<[f32; 2]>,
|
||||
undefined: UndefinedAreas,
|
||||
},
|
||||
/// Amount −100…100, ridges 0–20.
|
||||
ZigZag { amount: f32, ridges: f32, style: ZigZagStyle },
|
||||
ZigZag {
|
||||
amount: f32,
|
||||
ridges: f32,
|
||||
style: ZigZagStyle,
|
||||
},
|
||||
|
||||
// ---- Render ----
|
||||
/// Variance 1–64, strength 1–64; colours straight sRGB RGBA.
|
||||
Fibers { variance: f32, strength: f32, seed: u32, foreground: [f32; 4], background: [f32; 4] },
|
||||
Fibers {
|
||||
variance: f32,
|
||||
strength: f32,
|
||||
seed: u32,
|
||||
foreground: [f32; 4],
|
||||
background: [f32; 4],
|
||||
},
|
||||
/// Brightness 10–300 %, centre as a fraction of the bounds.
|
||||
LensFlare { brightness: f32, center_x: f32, center_y: f32, lens: LensType },
|
||||
LensFlare {
|
||||
brightness: f32,
|
||||
center_x: f32,
|
||||
center_y: f32,
|
||||
lens: LensType,
|
||||
},
|
||||
/// Gloss/metallic/exposure/ambience −100…100; bump height 0–100.
|
||||
LightingEffects { lights: Vec<Light>, gloss: f32, metallic: f32, exposure: f32, ambience: f32, texture: TextureChannel, height: f32, white_is_high: bool },
|
||||
LightingEffects {
|
||||
lights: Vec<Light>,
|
||||
gloss: f32,
|
||||
metallic: f32,
|
||||
exposure: f32,
|
||||
ambience: f32,
|
||||
texture: TextureChannel,
|
||||
height: f32,
|
||||
white_is_high: bool,
|
||||
},
|
||||
|
||||
// ---- Noise ----
|
||||
/// Strength 0–10, the rest 0–100 %.
|
||||
ReduceNoise { strength: f32, preserve_details: f32, reduce_color_noise: f32, sharpen_details: f32, remove_jpeg_artifact: bool },
|
||||
ReduceNoise {
|
||||
strength: f32,
|
||||
preserve_details: f32,
|
||||
reduce_color_noise: f32,
|
||||
sharpen_details: f32,
|
||||
remove_jpeg_artifact: bool,
|
||||
},
|
||||
|
||||
// ---- Blur ----
|
||||
/// Radius 0.1–100 px, threshold 0.1–100 levels.
|
||||
SmartBlur { radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode },
|
||||
SmartBlur {
|
||||
radius: f32,
|
||||
threshold: f32,
|
||||
quality: BlurQuality,
|
||||
mode: SmartBlurMode,
|
||||
},
|
||||
/// Iris radius 0–100 px, `blades` 3–8, curvature 0–100, rotation degrees;
|
||||
/// focal distance 0–255; specular brightness 0–100 / threshold 0–255; noise 0–100.
|
||||
LensBlur {
|
||||
|
|
@ -305,27 +446,57 @@ pub enum FilterParams {
|
|||
depth_map: Option<std::sync::Arc<Image>>,
|
||||
},
|
||||
/// Radius 5–1000 px.
|
||||
ShapeBlur { radius: f32, shape: BlurShape },
|
||||
ShapeBlur {
|
||||
radius: f32,
|
||||
shape: BlurShape,
|
||||
},
|
||||
|
||||
// ---- Blur Gallery ----
|
||||
/// Centre fractions, angle degrees, focus/transition as fractions of the bounds' shorter side.
|
||||
TiltShift { blur: f32, center_x: f32, center_y: f32, angle: f32, focus: f32, transition: f32 },
|
||||
IrisBlur { pins: Vec<IrisPin> },
|
||||
FieldBlur { pins: Vec<FieldPin> },
|
||||
SpinBlur { pins: Vec<SpinPin> },
|
||||
PathBlur { paths: Vec<BlurPath> },
|
||||
TiltShift {
|
||||
blur: f32,
|
||||
center_x: f32,
|
||||
center_y: f32,
|
||||
angle: f32,
|
||||
focus: f32,
|
||||
transition: f32,
|
||||
},
|
||||
IrisBlur {
|
||||
pins: Vec<IrisPin>,
|
||||
},
|
||||
FieldBlur {
|
||||
pins: Vec<FieldPin>,
|
||||
},
|
||||
SpinBlur {
|
||||
pins: Vec<SpinPin>,
|
||||
},
|
||||
PathBlur {
|
||||
paths: Vec<BlurPath>,
|
||||
},
|
||||
|
||||
// ---- Other ----
|
||||
/// 5×5 kernel (row-major, centre = index 12), divided by `scale`, plus `offset` levels.
|
||||
Custom { kernel: Vec<f32>, scale: f32, offset: f32 },
|
||||
HsbHsl { input: HsbModel, output: HsbModel },
|
||||
Custom {
|
||||
kernel: Vec<f32>,
|
||||
scale: f32,
|
||||
offset: f32,
|
||||
},
|
||||
HsbHsl {
|
||||
input: HsbModel,
|
||||
output: HsbModel,
|
||||
},
|
||||
|
||||
// ---- Video ----
|
||||
/// Removes the even (or odd) lines and rebuilds them by interpolation (or duplication).
|
||||
DeInterlace { eliminate_even: bool, interpolate: bool },
|
||||
DeInterlace {
|
||||
eliminate_even: bool,
|
||||
interpolate: bool,
|
||||
},
|
||||
NtscColors,
|
||||
/// Filter Gallery: a stack of gallery effects applied in order.
|
||||
FilterGallery { effects: Vec<GalleryEffect> },
|
||||
FilterGallery {
|
||||
effects: Vec<GalleryEffect>,
|
||||
},
|
||||
}
|
||||
|
||||
impl FilterParams {
|
||||
|
|
@ -528,37 +699,92 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
|
|||
FilterParams::Mezzotint { kind, seed } => pixelate::mezzotint(src, out, ctx, *kind, *seed),
|
||||
FilterParams::Pointillize { cell_size, seed, background } => pixelate::pointillize(src, out, ctx, *cell_size, *seed, *background),
|
||||
FilterParams::Diffuse { mode, seed } => stylize2::diffuse(src, out, ctx, *mode, *seed),
|
||||
FilterParams::Extrude { kind, size, depth, level_based, solid_front, mask_incomplete, seed } => {
|
||||
stylize2::extrude(src, out, ctx, &stylize2::ExtrudeSpec { kind: *kind, size: *size, depth: *depth, level_based: *level_based, solid_front: *solid_front, mask_incomplete: *mask_incomplete, seed: *seed })
|
||||
}
|
||||
FilterParams::Extrude { kind, size, depth, level_based, solid_front, mask_incomplete, seed } => stylize2::extrude(
|
||||
src,
|
||||
out,
|
||||
ctx,
|
||||
&stylize2::ExtrudeSpec {
|
||||
kind: *kind,
|
||||
size: *size,
|
||||
depth: *depth,
|
||||
level_based: *level_based,
|
||||
solid_front: *solid_front,
|
||||
mask_incomplete: *mask_incomplete,
|
||||
seed: *seed,
|
||||
},
|
||||
),
|
||||
FilterParams::OilPaint { stylization, cleanliness, scale, bristle_detail, lighting, angle, shine } => oil::oil_paint(
|
||||
src,
|
||||
out,
|
||||
ctx,
|
||||
&oil::OilSpec { stylization: *stylization, cleanliness: *cleanliness, scale: *scale, bristle_detail: *bristle_detail, lighting: *lighting, angle: *angle, shine: *shine },
|
||||
&oil::OilSpec {
|
||||
stylization: *stylization,
|
||||
cleanliness: *cleanliness,
|
||||
scale: *scale,
|
||||
bristle_detail: *bristle_detail,
|
||||
lighting: *lighting,
|
||||
angle: *angle,
|
||||
shine: *shine,
|
||||
},
|
||||
),
|
||||
FilterParams::Tiles { count, max_offset, fill, foreground, background, seed } => stylize2::tiles(src, out, ctx, *count, *max_offset, *fill, (*foreground, *background), *seed),
|
||||
FilterParams::Tiles { count, max_offset, fill, foreground, background, seed } => {
|
||||
stylize2::tiles(src, out, ctx, *count, *max_offset, *fill, (*foreground, *background), *seed)
|
||||
}
|
||||
FilterParams::TraceContour { level, upper } => stylize2::trace_contour(src, out, ctx, *level, *upper),
|
||||
FilterParams::Wind { method, from_right, seed } => stylize2::wind(src, out, ctx, *method, *from_right, *seed),
|
||||
FilterParams::Displace { horizontal, vertical, stretch, undefined, map } => distort2::displace(src, out, ctx, (*horizontal, *vertical), *stretch, *undefined, map.as_deref()),
|
||||
FilterParams::Displace { horizontal, vertical, stretch, undefined, map } => {
|
||||
distort2::displace(src, out, ctx, (*horizontal, *vertical), *stretch, *undefined, map.as_deref())
|
||||
}
|
||||
FilterParams::Shear { points, undefined } => distort2::shear(src, out, ctx, points, *undefined),
|
||||
FilterParams::ZigZag { amount, ridges, style } => distort2::zigzag(src, out, ctx, *amount, *ridges, *style),
|
||||
FilterParams::Fibers { variance, strength, seed, foreground, background } => render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background)),
|
||||
FilterParams::Fibers { variance, strength, seed, foreground, background } => {
|
||||
render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background))
|
||||
}
|
||||
FilterParams::LensFlare { brightness, center_x, center_y, lens } => render::lens_flare(src, out, ctx, *brightness, (*center_x, *center_y), *lens),
|
||||
FilterParams::LightingEffects { lights, gloss, metallic, exposure, ambience, texture, height, white_is_high } => render::lighting(
|
||||
src,
|
||||
out,
|
||||
ctx,
|
||||
&render::LightingSpec { lights, gloss: *gloss, metallic: *metallic, exposure: *exposure, ambience: *ambience, texture: *texture, height: *height, white_is_high: *white_is_high },
|
||||
&render::LightingSpec {
|
||||
lights,
|
||||
gloss: *gloss,
|
||||
metallic: *metallic,
|
||||
exposure: *exposure,
|
||||
ambience: *ambience,
|
||||
texture: *texture,
|
||||
height: *height,
|
||||
white_is_high: *white_is_high,
|
||||
},
|
||||
),
|
||||
FilterParams::ReduceNoise { strength, preserve_details, reduce_color_noise, sharpen_details, remove_jpeg_artifact } => denoise::reduce_noise(
|
||||
src,
|
||||
out,
|
||||
ctx,
|
||||
&denoise::DenoiseSpec { strength: *strength, preserve: *preserve_details, color: *reduce_color_noise, sharpen: *sharpen_details, jpeg: *remove_jpeg_artifact },
|
||||
&denoise::DenoiseSpec {
|
||||
strength: *strength,
|
||||
preserve: *preserve_details,
|
||||
color: *reduce_color_noise,
|
||||
sharpen: *sharpen_details,
|
||||
jpeg: *remove_jpeg_artifact,
|
||||
},
|
||||
),
|
||||
FilterParams::SmartBlur { radius, threshold, quality, mode } => blur2::smart_blur(src, out, ctx, *radius, *threshold, *quality, *mode),
|
||||
FilterParams::LensBlur { radius, blades, curvature, rotation, depth, focal_distance, invert_depth, brightness, threshold, noise, distribution, monochromatic, seed, depth_map } => blur2::lens_blur(
|
||||
FilterParams::LensBlur {
|
||||
radius,
|
||||
blades,
|
||||
curvature,
|
||||
rotation,
|
||||
depth,
|
||||
focal_distance,
|
||||
invert_depth,
|
||||
brightness,
|
||||
threshold,
|
||||
noise,
|
||||
distribution,
|
||||
monochromatic,
|
||||
seed,
|
||||
depth_map,
|
||||
} => blur2::lens_blur(
|
||||
src,
|
||||
out,
|
||||
ctx,
|
||||
|
|
@ -580,7 +806,9 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
|
|||
},
|
||||
),
|
||||
FilterParams::ShapeBlur { radius, shape } => blur2::shape_blur(src, out, ctx, *radius, *shape),
|
||||
FilterParams::TiltShift { blur, center_x, center_y, angle, focus, transition } => gallery::tilt_shift(src, out, ctx, *blur, (*center_x, *center_y), *angle, *focus, *transition),
|
||||
FilterParams::TiltShift { blur, center_x, center_y, angle, focus, transition } => {
|
||||
gallery::tilt_shift(src, out, ctx, *blur, (*center_x, *center_y), *angle, *focus, *transition)
|
||||
}
|
||||
FilterParams::IrisBlur { pins } => gallery::iris(src, out, ctx, pins),
|
||||
FilterParams::FieldBlur { pins } => gallery::field(src, out, ctx, pins),
|
||||
FilterParams::SpinBlur { pins } => gallery::spin(src, out, ctx, pins),
|
||||
|
|
@ -640,7 +868,15 @@ fn auto_tile(params: &FilterParams) -> i32 {
|
|||
}
|
||||
|
||||
/// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence).
|
||||
pub fn apply_tiled(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rect, selection: Option<&Surface>, tile: i32, extent: Option<Rect>) -> Surface {
|
||||
pub fn apply_tiled(
|
||||
surface: &Surface,
|
||||
params: &FilterParams,
|
||||
area: Rect,
|
||||
bounds: Rect,
|
||||
selection: Option<&Surface>,
|
||||
tile: i32,
|
||||
extent: Option<Rect>,
|
||||
) -> Surface {
|
||||
let mut out = surface.clone();
|
||||
if area.is_empty() {
|
||||
return out;
|
||||
|
|
|
|||
|
|
@ -494,7 +494,8 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
|
|||
if !moved {
|
||||
return None;
|
||||
}
|
||||
let foot = Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&t.inflate(maxd)).intersect(&b);
|
||||
let foot =
|
||||
Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&t.inflate(maxd)).intersect(&b);
|
||||
if foot.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -665,7 +666,12 @@ impl ProxyImage {
|
|||
pub fn proxy_rect(&self, r: Rect) -> [usize; 4] {
|
||||
let s = self.scale;
|
||||
let f = |v: i32, o: i32| (f64::from(v - o) / s).max(0.0);
|
||||
[f(r.x0, self.bounds.x0).floor() as usize, f(r.y0, self.bounds.y0).floor() as usize, (f(r.x1, self.bounds.x0).ceil() as usize + 1).min(self.w), (f(r.y1, self.bounds.y0).ceil() as usize + 1).min(self.h)]
|
||||
[
|
||||
f(r.x0, self.bounds.x0).floor() as usize,
|
||||
f(r.y0, self.bounds.y0).floor() as usize,
|
||||
(f(r.x1, self.bounds.x0).ceil() as usize + 1).min(self.w),
|
||||
(f(r.y1, self.bounds.y0).ceil() as usize + 1).min(self.h),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,11 @@ impl RefineParams {
|
|||
}
|
||||
/// How far the refined mask can extend beyond the original.
|
||||
pub fn spread(&self) -> i32 {
|
||||
(if self.uses_image() { self.radius.ceil() as i32 } else { 0 }) + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + self.shift_px().max(0) + 1
|
||||
(if self.uses_image() { self.radius.ceil() as i32 } else { 0 })
|
||||
+ (3.0 * self.smooth_sigma()).ceil() as i32
|
||||
+ (3.0 * self.feather_sigma()).ceil() as i32
|
||||
+ self.shift_px().max(0)
|
||||
+ 1
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,7 +167,11 @@ pub fn guided_filter_color(guide: &RgbImage, p: &[f32], r: usize, eps: f32) -> V
|
|||
let (a0, a1, a2) = if det.abs() < 1e-30 {
|
||||
(0.0, 0.0, 0.0)
|
||||
} else {
|
||||
((i00 * cov[0] + i01 * cov[1] + i02 * cov[2]) / det, (i01 * cov[0] + i11 * cov[1] + i12 * cov[2]) / det, (i02 * cov[0] + i12 * cov[1] + i22 * cov[2]) / det)
|
||||
(
|
||||
(i00 * cov[0] + i01 * cov[1] + i02 * cov[2]) / det,
|
||||
(i01 * cov[0] + i11 * cov[1] + i12 * cov[2]) / det,
|
||||
(i02 * cov[0] + i12 * cov[1] + i22 * cov[2]) / det,
|
||||
)
|
||||
};
|
||||
av[0][i] = a0 as f32;
|
||||
av[1][i] = a1 as f32;
|
||||
|
|
@ -708,7 +716,13 @@ mod tests {
|
|||
s.fill_rect(Rect::new(0, 0, 10, 8), &[1.0, 0.0, 0.0, 1.0]);
|
||||
s.fill_rect(Rect::new(10, 0, 20, 8), &[0.5, 0.0, 0.5, 1.0]);
|
||||
s.fill_rect(Rect::new(11, 0, 20, 8), &[0.0, 0.0, 1.0, 1.0]);
|
||||
let mask: Vec<u8> = (0..20 * 8).map(|i| match i % 20 { x if x < 10 => 255, 10 => 128, _ => 0 }).collect();
|
||||
let mask: Vec<u8> = (0..20 * 8)
|
||||
.map(|i| match i % 20 {
|
||||
x if x < 10 => 255,
|
||||
10 => 128,
|
||||
_ => 0,
|
||||
})
|
||||
.collect();
|
||||
let a = Region { bbox: Rect::new(0, 0, 20, 8), mask };
|
||||
let d = decontaminate(&s, &a, 3.0, 100.0);
|
||||
let p = d.pixel(10, 4);
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
|
|||
gauss_blur_n(&mut efg, ww, wh, 3, tensor_sigma(spec.scale));
|
||||
// 4. Stroke direction = minor eigenvector (along edges).
|
||||
let tangent: Vec<(f32, f32)> = efg
|
||||
.chunks_exact(3)
|
||||
.as_chunks::<3>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let (e, f, g) = (t[0], t[1], t[2]);
|
||||
let l1 = 0.5 * (e + g + ((e - g) * (e - g) + 4.0 * f * f).sqrt());
|
||||
|
|
|
|||
|
|
@ -7,13 +7,7 @@ use crate::{Ctx, Preserve, UndefinedAreas};
|
|||
|
||||
/// Minimum or Maximum with the dialog's Preserve option: a square (`min_max`) or a disc
|
||||
/// (`min_max_round`).
|
||||
pub(crate) fn min_max_preserve(
|
||||
src: &Image,
|
||||
out: Rect,
|
||||
radius: f32,
|
||||
max: bool,
|
||||
preserve: Preserve,
|
||||
) -> Vec<f32> {
|
||||
pub(crate) fn min_max_preserve(src: &Image, out: Rect, radius: f32, max: bool, preserve: Preserve) -> Vec<f32> {
|
||||
match preserve {
|
||||
Preserve::Squareness => min_max(src, out, radius, max),
|
||||
Preserve::Roundness => min_max_round(src, out, radius, max),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ use crate::{Ctx, HsbModel};
|
|||
pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f32, offset: f32) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let cc = ncol(ctx, n);
|
||||
let k: Vec<(i32, i32, f32)> = (0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect();
|
||||
let k: Vec<(i32, i32, f32)> =
|
||||
(0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect();
|
||||
let s = if scale == 0.0 { 1.0 } else { scale };
|
||||
let off = offset / 255.0;
|
||||
let mut res = src.crop(out);
|
||||
|
|
|
|||
|
|
@ -221,7 +221,19 @@ mod tests {
|
|||
let a = Rect::new(0, 0, 11, 2);
|
||||
let mut sel = Surface::new(PixelFormat::GRAY8);
|
||||
sel.fill_rect(Rect::new(0, 0, 11, 1), &[1.0]);
|
||||
paint_gradient(&mut s, a, (0.5, 0.0), (10.5, 0.0), GradientShape::Linear, &[(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], false, 1.0, BlendMode::Normal, false, Some(&sel));
|
||||
paint_gradient(
|
||||
&mut s,
|
||||
a,
|
||||
(0.5, 0.0),
|
||||
(10.5, 0.0),
|
||||
GradientShape::Linear,
|
||||
&[(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])],
|
||||
false,
|
||||
1.0,
|
||||
BlendMode::Normal,
|
||||
false,
|
||||
Some(&sel),
|
||||
);
|
||||
assert!(s.pixel(0, 0)[0] < 0.01 && s.pixel(10, 0)[0] > 0.99);
|
||||
assert!((s.pixel(5, 0)[0] - 0.5).abs() < 0.01);
|
||||
assert_eq!(s.pixel(5, 1)[3], 0.0, "outside the selection");
|
||||
|
|
|
|||
|
|
@ -313,7 +313,13 @@ impl Placement {
|
|||
pub fn scaled(&self, k: f64) -> Placement {
|
||||
let s = Homography([k, 0.0, 0.0, 0.0, k, 0.0, 0.0, 0.0, 1.0]);
|
||||
let si = Homography([1.0 / k, 0.0, 0.0, 0.0, 1.0 / k, 0.0, 0.0, 0.0, 1.0]);
|
||||
Placement { projection: self.projection, focal: self.focal * k, center: [self.center[0] * k, self.center[1] * k], k1: self.k1, h: s.mul(&self.h).mul(&si) }
|
||||
Placement {
|
||||
projection: self.projection,
|
||||
focal: self.focal * k,
|
||||
center: [self.center[0] * k, self.center[1] * k],
|
||||
k1: self.k1,
|
||||
h: s.mul(&self.h).mul(&si),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shifted in the panorama.
|
||||
|
|
@ -1337,7 +1343,8 @@ mod tests {
|
|||
#[test]
|
||||
fn placement_round_trips_all_projections() {
|
||||
for projection in [Projection::Plane, Projection::Cylinder, Projection::Sphere] {
|
||||
let p = Placement { projection, focal: 400.0, center: [200.0, 150.0], k1: 0.05, h: Homography([0.98, -0.1, 30.0, 0.1, 0.98, -12.0, 0.0, 0.0, 1.0]) };
|
||||
let p =
|
||||
Placement { projection, focal: 400.0, center: [200.0, 150.0], k1: 0.05, h: Homography([0.98, -0.1, 30.0, 0.1, 0.98, -12.0, 0.0, 0.0, 1.0]) };
|
||||
for (x, y) in [(0.0, 0.0), (390.0, 20.0), (123.0, 280.0)] {
|
||||
let (u, v) = p.forward(x, y);
|
||||
let (bx, by) = p.inverse(u, v).unwrap();
|
||||
|
|
@ -1365,7 +1372,13 @@ mod tests {
|
|||
}
|
||||
}
|
||||
imgs.push(RoiImage { x0, y0: 0, w, h, ch: 1, px, alpha: vec![1.0; w * h] });
|
||||
pls.push(Placement { projection: Projection::Plane, focal: 200.0, center: [100.0, 50.0], k1: 0.0, h: Homography([1.0, 0.0, 100.0 + x0 as f64, 0.0, 1.0, 50.0, 0.0, 0.0, 1.0]) });
|
||||
pls.push(Placement {
|
||||
projection: Projection::Plane,
|
||||
focal: 200.0,
|
||||
center: [100.0, 50.0],
|
||||
k1: 0.0,
|
||||
h: Homography([1.0, 0.0, 100.0 + x0 as f64, 0.0, 1.0, 50.0, 0.0, 0.0, 1.0]),
|
||||
});
|
||||
let _ = k;
|
||||
}
|
||||
let ph = photometric(&imgs, Some((&pls, &[(w, h), (w, h)])), true, false);
|
||||
|
|
@ -1399,7 +1412,16 @@ mod tests {
|
|||
// Multi-band blend of two flat images at different levels joins smoothly.
|
||||
let mk = |x0: i32, v: f32| RoiImage { x0, y0: 0, w: 80, h, ch: 1, px: vec![v; 80 * h], alpha: vec![1.0; 80 * h] };
|
||||
let imgs = [mk(0, 0.3), mk(40, 0.7)];
|
||||
let wts: Vec<Vec<f32>> = (0..2).map(|k| (0..80 * h).map(|i| { let x = (i % 80) as i32 + imgs[k].x0; if (x < 60) == (k == 0) { 1.0 } else { 0.0 } }).collect()).collect();
|
||||
let wts: Vec<Vec<f32>> = (0..2)
|
||||
.map(|k| {
|
||||
(0..80 * h)
|
||||
.map(|i| {
|
||||
let x = (i % 80) as i32 + imgs[k].x0;
|
||||
if (x < 60) == (k == 0) { 1.0 } else { 0.0 }
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let (out, cov) = multiband(w, h, &imgs, &wts, 4);
|
||||
let row: Vec<f32> = (0..w).map(|x| out[20 * w + x]).collect();
|
||||
assert!((row[2] - 0.3).abs() < 0.02 && (row[w - 3] - 0.7).abs() < 0.02, "{row:?}");
|
||||
|
|
|
|||
|
|
@ -129,7 +129,8 @@ pub fn straighten(planes: &mut [Plane], mode: Straighten) {
|
|||
let groups = linked_corners(planes);
|
||||
let group_of = |p: usize, c: usize| groups.iter().position(|g| g.contains(&(p, c))).unwrap_or(0);
|
||||
let mut pos: Vec<[f64; 2]> = groups.iter().map(|g| planes[g[0].0].dst[g[0].1]).collect();
|
||||
let edges: Vec<(usize, usize)> = (0..planes.len()).flat_map(|p| (0..4).map(move |k| (p, k))).map(|(p, k)| (group_of(p, k), group_of(p, (k + 1) % 4))).collect();
|
||||
let edges: Vec<(usize, usize)> =
|
||||
(0..planes.len()).flat_map(|p| (0..4).map(move |k| (p, k))).map(|(p, k)| (group_of(p, k), group_of(p, (k + 1) % 4))).collect();
|
||||
for (a, b) in edges {
|
||||
let (pa, pb) = (pos[a], pos[b]);
|
||||
let (dx, dy) = ((pb[0] - pa[0]).abs(), (pb[1] - pa[1]).abs());
|
||||
|
|
@ -167,10 +168,17 @@ impl PerspectiveMap {
|
|||
let mut shared = [None; 4];
|
||||
for (k, s) in shared.iter_mut().enumerate() {
|
||||
let (a, b) = (p.src[k], p.src[(k + 1) % 4]);
|
||||
*s = planes.iter().enumerate().find(|(j, o)| *j != i && (0..4).any(|m| {
|
||||
*s = planes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(j, o)| {
|
||||
*j != i
|
||||
&& (0..4).any(|m| {
|
||||
let (c, d) = (o.src[m], o.src[(m + 1) % 4]);
|
||||
(near(a, c) && near(b, d)) || (near(a, d) && near(b, c))
|
||||
})).map(|(j, _)| j);
|
||||
})
|
||||
})
|
||||
.map(|(j, _)| j);
|
||||
}
|
||||
maps.push(PlaneMap { src, src_inv, dst, quad: p.src, shared });
|
||||
}
|
||||
|
|
@ -246,10 +254,12 @@ pub fn unit_corners() -> [[f64; 2]; 4] {
|
|||
/// A point of plane `p` at plane coordinates `(u, v)`, in source space.
|
||||
pub fn plane_point(p: &Plane, u: f64, v: f64, dst: bool) -> [f64; 2] {
|
||||
let q = if dst { &p.dst } else { &p.src };
|
||||
unit_to_quad(q).map(|h| {
|
||||
unit_to_quad(q)
|
||||
.map(|h| {
|
||||
let (x, y) = h.apply(u, v);
|
||||
[x, y]
|
||||
}).unwrap_or(q[0])
|
||||
})
|
||||
.unwrap_or(q[0])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -299,7 +309,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn straighten_levels_and_plumbs_edges() {
|
||||
let mut ps = vec![Plane { src: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], dst: [[0.0, 3.0], [100.0, -2.0], [96.0, 100.0], [4.0, 104.0]] }];
|
||||
let mut ps =
|
||||
vec![Plane { src: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], dst: [[0.0, 3.0], [100.0, -2.0], [96.0, 100.0], [4.0, 104.0]] }];
|
||||
straighten(&mut ps, Straighten::Vertical);
|
||||
assert_eq!(ps[0].dst[1][0], ps[0].dst[2][0]);
|
||||
assert_eq!(ps[0].dst[0][0], ps[0].dst[3][0]);
|
||||
|
|
|
|||
|
|
@ -72,7 +72,11 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32,
|
|||
let ink = if sub && !rgb { raw } else { 1.0 - raw };
|
||||
// Dot area tracks ink: πr² = ink·cell² until dots touch, then grow to cover the corners.
|
||||
let touch = std::f32::consts::FRAC_PI_4;
|
||||
let r = if ink <= touch { cell * (ink / std::f32::consts::PI).sqrt() } else { cell * (0.5 + (ink - touch) / (1.0 - touch) * (std::f32::consts::FRAC_1_SQRT_2 - 0.5)) };
|
||||
let r = if ink <= touch {
|
||||
cell * (ink / std::f32::consts::PI).sqrt()
|
||||
} else {
|
||||
cell * (0.5 + (ink - touch) / (1.0 - touch) * (std::f32::consts::FRAC_1_SQRT_2 - 0.5))
|
||||
};
|
||||
// Tiny dots cannot cover more than their own area.
|
||||
cov = cov.max((r - d + 0.5).clamp(0.0, 1.0).min(std::f32::consts::PI * r * r));
|
||||
}
|
||||
|
|
@ -116,7 +120,12 @@ pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see
|
|||
best.1
|
||||
};
|
||||
let mut acc = vec![0.0f32; gw * gh * (n + 1)];
|
||||
let win = Rect::new(b.x0 + (c0.0 as f32 * cell).floor() as i32, b.y0 + (c0.1 as f32 * cell).floor() as i32, b.x0 + ((c1.0 + 1) as f32 * cell).ceil() as i32, b.y0 + ((c1.1 + 1) as f32 * cell).ceil() as i32)
|
||||
let win = Rect::new(
|
||||
b.x0 + (c0.0 as f32 * cell).floor() as i32,
|
||||
b.y0 + (c0.1 as f32 * cell).floor() as i32,
|
||||
b.x0 + ((c1.0 + 1) as f32 * cell).ceil() as i32,
|
||||
b.y0 + ((c1.1 + 1) as f32 * cell).ceil() as i32,
|
||||
)
|
||||
.intersect(&src.rect)
|
||||
.intersect(&b);
|
||||
let mut owners = vec![(0i32, 0i32); out.width() as usize * out.height() as usize];
|
||||
|
|
|
|||
|
|
@ -272,10 +272,12 @@ mod tests {
|
|||
// Destination: flat reddish; source: bluish with a stripe texture.
|
||||
let dst: Vec<f32> = (0..w * h).flat_map(|_| [0.7, 0.3, 0.2]).collect();
|
||||
let tex = |x: usize| if (x / 4).is_multiple_of(2) { 0.05 } else { -0.05 };
|
||||
let src: Vec<f32> = (0..w * h).flat_map(|i| {
|
||||
let src: Vec<f32> = (0..w * h)
|
||||
.flat_map(|i| {
|
||||
let t = tex(i % w);
|
||||
[0.1 + t, 0.2 + t, 0.8 + t]
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
let out = seamless_clone(w, h, ch, &src, &dst, &mask);
|
||||
// Mean colour inside the mask matches the destination.
|
||||
let inside: Vec<usize> = (0..w * h).filter(|&i| mask[i]).collect();
|
||||
|
|
|
|||
|
|
@ -651,7 +651,14 @@ impl PuppetSolver {
|
|||
/// Deforms the mesh: `pin_dst` (one per pin given to [`Self::new`]), optional fixed pin
|
||||
/// rotations (degrees), `iterations` local/global rounds, warm-started from `init` when
|
||||
/// given. Returns the deformed vertices and the rigid ARAP energy after each round.
|
||||
pub fn solve(&self, pin_dst: &[[f64; 2]], rotations: &[Option<f64>], mode: PuppetMode, iterations: usize, init: Option<&[[f64; 2]]>) -> (Vec<[f64; 2]>, Vec<f64>) {
|
||||
pub fn solve(
|
||||
&self,
|
||||
pin_dst: &[[f64; 2]],
|
||||
rotations: &[Option<f64>],
|
||||
mode: PuppetMode,
|
||||
iterations: usize,
|
||||
init: Option<&[[f64; 2]]>,
|
||||
) -> (Vec<[f64; 2]>, Vec<f64>) {
|
||||
let nv = self.mesh.verts.len();
|
||||
let mut v: Vec<[f64; 2]> = match init {
|
||||
Some(i) if i.len() == nv => i.to_vec(),
|
||||
|
|
@ -667,7 +674,16 @@ impl PuppetSolver {
|
|||
.bound
|
||||
.iter()
|
||||
.zip(rotations)
|
||||
.filter_map(|(b, r)| r.map(|deg| ((0..3).fold([0.0, 0.0], |a, k| [a[0] + b.bary[k] * self.mesh.verts[b.verts[k]][0], a[1] + b.bary[k] * self.mesh.verts[b.verts[k]][1]]), deg.to_radians())))
|
||||
.filter_map(|(b, r)| {
|
||||
r.map(|deg| {
|
||||
(
|
||||
(0..3).fold([0.0, 0.0], |a, k| {
|
||||
[a[0] + b.bary[k] * self.mesh.verts[b.verts[k]][0], a[1] + b.bary[k] * self.mesh.verts[b.verts[k]][1]]
|
||||
}),
|
||||
deg.to_radians(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let r: Vec<[f64; 4]> = self
|
||||
.mesh
|
||||
|
|
@ -781,12 +797,14 @@ mod tests {
|
|||
let b = s.content_bounds();
|
||||
let m = build_mesh(&s, b, PuppetDensity::Normal, 2.0);
|
||||
assert!(m.tris.len() > 20);
|
||||
let inside = |p: [f64; 2]| m.tris.iter().any(|t| {
|
||||
let inside = |p: [f64; 2]| {
|
||||
m.tris.iter().any(|t| {
|
||||
let xs = t.map(|i| m.verts[i]);
|
||||
let (x0, x1) = (xs.iter().map(|v| v[0]).fold(f64::MAX, f64::min), xs.iter().map(|v| v[0]).fold(f64::MIN, f64::max));
|
||||
let (y0, y1) = (xs.iter().map(|v| v[1]).fold(f64::MAX, f64::min), xs.iter().map(|v| v[1]).fold(f64::MIN, f64::max));
|
||||
p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1
|
||||
});
|
||||
})
|
||||
};
|
||||
assert!(inside([28.0, 30.0]) && inside([60.0, 62.0]));
|
||||
assert!(!inside([60.0, 20.0]), "the empty corner of the L has no mesh");
|
||||
let more = build_mesh(&s, b, PuppetDensity::More, 2.0);
|
||||
|
|
@ -799,7 +817,13 @@ mod tests {
|
|||
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let s = blob(st);
|
||||
let b = s.content_bounds();
|
||||
for pins in [vec![], vec![PuppetPin { src: [28.0, 20.0], dst: [28.0, 20.0], rotate: None, depth: 0 }, PuppetPin { src: [60.0, 62.0], dst: [60.0, 62.0], rotate: None, depth: 0 }]] {
|
||||
for pins in [
|
||||
vec![],
|
||||
vec![
|
||||
PuppetPin { src: [28.0, 20.0], dst: [28.0, 20.0], rotate: None, depth: 0 },
|
||||
PuppetPin { src: [60.0, 62.0], dst: [60.0, 62.0], rotate: None, depth: 0 },
|
||||
],
|
||||
] {
|
||||
let w = PuppetWarp { pins, mode: PuppetMode::Normal, density: PuppetDensity::Normal, expansion: 2.0 };
|
||||
let out = puppet_warp(&s, b, &w, Interp::Bicubic);
|
||||
let worst = s.read_region(b).iter().zip(out.read_region(b)).map(|(a, c)| (a - c).abs()).fold(0.0f32, f32::max);
|
||||
|
|
@ -812,7 +836,12 @@ mod tests {
|
|||
fn a_single_pin_translates_rigidly() {
|
||||
let s = blob(SampleType::U8);
|
||||
let b = s.content_bounds();
|
||||
let w = PuppetWarp { pins: vec![PuppetPin { src: [28.0, 30.0], dst: [38.0, 25.0], rotate: None, depth: 0 }], mode: PuppetMode::Rigid, density: PuppetDensity::Normal, expansion: 2.0 };
|
||||
let w = PuppetWarp {
|
||||
pins: vec![PuppetPin { src: [28.0, 30.0], dst: [38.0, 25.0], rotate: None, depth: 0 }],
|
||||
mode: PuppetMode::Rigid,
|
||||
density: PuppetDensity::Normal,
|
||||
expansion: 2.0,
|
||||
};
|
||||
let (solver, v, _) = deform(&s, b, &w, ITERATIONS);
|
||||
for (d, x) in v.iter().zip(&solver.mesh.verts) {
|
||||
assert!((d[0] - x[0] - 10.0).abs() < 1e-3 && (d[1] - x[1] + 5.0).abs() < 1e-3, "{d:?} {x:?}");
|
||||
|
|
|
|||
|
|
@ -158,16 +158,7 @@ fn forced_colors(f: Forced) -> Vec<Rgb8> {
|
|||
match f {
|
||||
Forced::None => Vec::new(),
|
||||
Forced::BlackWhite => vec![[0, 0, 0], [255, 255, 255]],
|
||||
Forced::Primaries => vec![
|
||||
[0, 0, 0],
|
||||
[255, 255, 255],
|
||||
[255, 0, 0],
|
||||
[0, 255, 0],
|
||||
[0, 0, 255],
|
||||
[0, 255, 255],
|
||||
[255, 0, 255],
|
||||
[255, 255, 0],
|
||||
],
|
||||
Forced::Primaries => vec![[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 255, 255], [255, 0, 255], [255, 255, 0]],
|
||||
Forced::Web => web_palette(),
|
||||
}
|
||||
}
|
||||
|
|
@ -198,9 +189,7 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
|
|||
.map(|(i, b)| {
|
||||
let mut best = (0usize, -1.0f32);
|
||||
for a in 0..3 {
|
||||
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| {
|
||||
(lo.min(p.0[a]), hi.max(p.0[a]))
|
||||
});
|
||||
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
|
||||
if hi - lo > best.1 {
|
||||
best = (a, hi - lo);
|
||||
}
|
||||
|
|
@ -208,10 +197,7 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
|
|||
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
|
||||
(i, best.0, best.1 * w.sqrt())
|
||||
})
|
||||
.fold(
|
||||
(usize::MAX, 0, -1.0f32),
|
||||
|acc, x| if x.2 > acc.2 { x } else { acc },
|
||||
);
|
||||
.fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc });
|
||||
if bi == usize::MAX || score <= 0.0 {
|
||||
break;
|
||||
}
|
||||
|
|
@ -235,12 +221,7 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
|
|||
.iter()
|
||||
.map(|b| {
|
||||
let w: f64 = b.iter().map(|p| f64::from(p.1)).sum();
|
||||
std::array::from_fn(|a| {
|
||||
(b.iter()
|
||||
.map(|p| f64::from(p.0[a]) * f64::from(p.1))
|
||||
.sum::<f64>()
|
||||
/ w) as f32
|
||||
})
|
||||
std::array::from_fn(|a| (b.iter().map(|p| f64::from(p.0[a]) * f64::from(p.1)).sum::<f64>() / w) as f32)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -286,12 +267,7 @@ fn lab_to_rgb8(lab: [f32; 3]) -> Rgb8 {
|
|||
|
||||
/// Builds the palette for `px`. `colors` is the requested count (2..=256) for the computed
|
||||
/// kinds; forced colours are included first and count toward it.
|
||||
pub fn build_palette(
|
||||
px: &[[f32; 4]],
|
||||
kind: PaletteKind,
|
||||
colors: usize,
|
||||
forced: Forced,
|
||||
) -> Result<Vec<Rgb8>, String> {
|
||||
pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: Forced) -> Result<Vec<Rgb8>, String> {
|
||||
let colors = colors.clamp(2, 256);
|
||||
let mut pal = forced_colors(forced);
|
||||
let room = colors.saturating_sub(pal.len());
|
||||
|
|
@ -299,10 +275,7 @@ pub fn build_palette(
|
|||
let computed: Vec<Rgb8> = match kind {
|
||||
PaletteKind::Exact => {
|
||||
if hist.len() > 256 {
|
||||
return Err(format!(
|
||||
"the image has {} colours; Exact needs 256 or fewer",
|
||||
hist.len()
|
||||
));
|
||||
return Err(format!("the image has {} colours; Exact needs 256 or fewer", hist.len()));
|
||||
}
|
||||
hist.iter().map(|h| h.0).collect()
|
||||
}
|
||||
|
|
@ -314,24 +287,17 @@ pub fn build_palette(
|
|||
if hist.len() <= room {
|
||||
hist.iter().map(|h| h.0).collect()
|
||||
} else {
|
||||
let pts: Vec<([f32; 3], u32)> =
|
||||
hist.iter().map(|(c, n)| (c.map(f32::from), *n)).collect();
|
||||
let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (c.map(f32::from), *n)).collect();
|
||||
let mut centers = median_cut(&pts, room);
|
||||
kmeans(&pts, &mut centers, 2);
|
||||
centers
|
||||
.iter()
|
||||
.map(|c| c.map(|v| v.round().clamp(0.0, 255.0) as u8))
|
||||
.collect()
|
||||
centers.iter().map(|c| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect()
|
||||
}
|
||||
}
|
||||
PaletteKind::Perceptual | PaletteKind::Selective => {
|
||||
if hist.len() <= room {
|
||||
hist.iter().map(|h| h.0).collect()
|
||||
} else {
|
||||
let pts: Vec<([f32; 3], u32)> = hist
|
||||
.iter()
|
||||
.map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n))
|
||||
.collect();
|
||||
let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n)).collect();
|
||||
let mut centers = median_cut(&pts, room);
|
||||
kmeans(&pts, &mut centers, 4);
|
||||
let mut v: Vec<Rgb8> = centers.iter().map(|c| lab_to_rgb8(*c)).collect();
|
||||
|
|
@ -356,13 +322,7 @@ pub fn build_palette(
|
|||
pal.push(c);
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
kind,
|
||||
PaletteKind::Adaptive
|
||||
| PaletteKind::Perceptual
|
||||
| PaletteKind::Selective
|
||||
| PaletteKind::Uniform
|
||||
) {
|
||||
if matches!(kind, PaletteKind::Adaptive | PaletteKind::Perceptual | PaletteKind::Selective | PaletteKind::Uniform) {
|
||||
pal.truncate(colors.max(forced_colors(forced).len()));
|
||||
}
|
||||
if pal.is_empty() {
|
||||
|
|
@ -374,9 +334,7 @@ pub fn build_palette(
|
|||
fn nearest(pal: &[Rgb8], c: [f32; 3]) -> usize {
|
||||
let mut best = (f32::MAX, 0);
|
||||
for (i, e) in pal.iter().enumerate() {
|
||||
let d: f32 = (0..3)
|
||||
.map(|k| (c[k] * 255.0 - f32::from(e[k])).powi(2))
|
||||
.sum();
|
||||
let d: f32 = (0..3).map(|k| (c[k] * 255.0 - f32::from(e[k])).powi(2)).sum();
|
||||
if d < best.0 {
|
||||
best = (d, i);
|
||||
}
|
||||
|
|
@ -387,17 +345,14 @@ fn nearest(pal: &[Rgb8], c: [f32; 3]) -> usize {
|
|||
/// 8×8 Bayer threshold in -0.5..0.5.
|
||||
pub fn bayer8(x: usize, y: usize) -> f32 {
|
||||
const M: [u8; 64] = [
|
||||
0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38,
|
||||
60, 28, 52, 20, 62, 30, 54, 22, 3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57,
|
||||
25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21,
|
||||
0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22, 3, 35, 11, 43, 1, 33, 9, 41,
|
||||
51, 19, 59, 27, 49, 17, 57, 25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21,
|
||||
];
|
||||
(f32::from(M[(y % 8) * 8 + x % 8]) + 0.5) / 64.0 - 0.5
|
||||
}
|
||||
|
||||
fn hash01(x: usize, y: usize, seed: u32) -> f32 {
|
||||
let mut h = (x as u32).wrapping_mul(0x8da6_b343)
|
||||
^ (y as u32).wrapping_mul(0xd816_3841)
|
||||
^ seed.wrapping_mul(0xcb1a_b31f);
|
||||
let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
|
||||
h ^= h >> 13;
|
||||
h = h.wrapping_mul(0x5bd1_e995);
|
||||
h ^= h >> 15;
|
||||
|
|
@ -407,14 +362,7 @@ fn hash01(x: usize, y: usize, seed: u32) -> f32 {
|
|||
/// Maps every opaque pixel to its palette entry (in place) with the chosen dither; returns the
|
||||
/// index per pixel. Pixels with alpha < ½ become index `transparent` (and alpha 0) when given;
|
||||
/// otherwise alpha is kept. `amount` (0..=1) scales diffusion / pattern / noise strength.
|
||||
pub fn quantize(
|
||||
px: &mut [[f32; 4]],
|
||||
w: usize,
|
||||
pal: &[Rgb8],
|
||||
dither: Dither,
|
||||
amount: f32,
|
||||
transparent: Option<usize>,
|
||||
) -> Vec<u8> {
|
||||
pub fn quantize(px: &mut [[f32; 4]], w: usize, pal: &[Rgb8], dither: Dither, amount: f32, transparent: Option<usize>) -> Vec<u8> {
|
||||
let h = px.len().checked_div(w).unwrap_or(0);
|
||||
let mut idx = vec![0u8; px.len()];
|
||||
// Typical palette spacing sets the ordered/noise dither amplitude.
|
||||
|
|
@ -503,12 +451,7 @@ pub fn quantize(
|
|||
next[x + 2][q] += d[q] / 16.0;
|
||||
}
|
||||
}
|
||||
px[i] = [
|
||||
e[0],
|
||||
e[1],
|
||||
e[2],
|
||||
if transparent.is_some() { 1.0 } else { p[3] },
|
||||
];
|
||||
px[i] = [e[0], e[1], e[2], if transparent.is_some() { 1.0 } else { p[3] }];
|
||||
}
|
||||
}
|
||||
idx
|
||||
|
|
@ -568,16 +511,10 @@ fn spot(shape: HalftoneShape, u: f32, v: f32) -> f32 {
|
|||
pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
|
||||
let h = gray.len().checked_div(w).unwrap_or(0);
|
||||
match method {
|
||||
BitmapMethod::Threshold => gray
|
||||
.iter_mut()
|
||||
.for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }),
|
||||
BitmapMethod::Threshold => gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }),
|
||||
BitmapMethod::Pattern => {
|
||||
for (i, v) in gray.iter_mut().enumerate() {
|
||||
*v = if *v > bayer8(i % w, i / w) + 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
*v = if *v > bayer8(i % w, i / w) + 0.5 { 1.0 } else { 0.0 };
|
||||
}
|
||||
}
|
||||
BitmapMethod::Diffusion => {
|
||||
|
|
@ -608,15 +545,7 @@ pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
|
|||
let cell = cell.max(2.0);
|
||||
let (s, c) = angle.to_radians().sin_cos();
|
||||
// Rank the spot function over the cell so coverage is proportional to the tone.
|
||||
let mut table: Vec<f32> = (0..32 * 32)
|
||||
.map(|k| {
|
||||
spot(
|
||||
shape,
|
||||
((k % 32) as f32 + 0.5) / 16.0 - 1.0,
|
||||
((k / 32) as f32 + 0.5) / 16.0 - 1.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut table: Vec<f32> = (0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect();
|
||||
table.sort_by(f32::total_cmp);
|
||||
let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32;
|
||||
for (i, v) in gray.iter_mut().enumerate() {
|
||||
|
|
@ -625,11 +554,7 @@ pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
|
|||
let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0;
|
||||
let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0;
|
||||
// Ink (black) covers where the spot function exceeds the gray level.
|
||||
*v = if cdf(spot(shape, u, vv)) >= *v {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
*v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -640,16 +565,7 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
fn gradient(w: usize, h: usize) -> Vec<[f32; 4]> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
[
|
||||
(i % w) as f32 / (w - 1) as f32,
|
||||
(i / w) as f32 / (h - 1) as f32,
|
||||
0.3,
|
||||
1.0,
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
(0..w * h).map(|i| [(i % w) as f32 / (w - 1) as f32, (i / w) as f32 / (h - 1) as f32, 0.3, 1.0]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -666,28 +582,15 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn exact_palette_and_failure() {
|
||||
let px = vec![
|
||||
[1.0, 0.0, 0.0, 1.0],
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
[1.0, 0.0, 0.0, 1.0],
|
||||
];
|
||||
assert_eq!(
|
||||
build_palette(&px, PaletteKind::Exact, 256, Forced::None)
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
let px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 1.0]];
|
||||
assert_eq!(build_palette(&px, PaletteKind::Exact, 256, Forced::None).unwrap().len(), 2);
|
||||
assert!(build_palette(&gradient(64, 64), PaletteKind::Exact, 256, Forced::None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_and_perceptual_respect_count_and_forced() {
|
||||
let px = gradient(40, 40);
|
||||
for kind in [
|
||||
PaletteKind::Adaptive,
|
||||
PaletteKind::Perceptual,
|
||||
PaletteKind::Selective,
|
||||
] {
|
||||
for kind in [PaletteKind::Adaptive, PaletteKind::Perceptual, PaletteKind::Selective] {
|
||||
let pal = build_palette(&px, kind, 16, Forced::BlackWhite).unwrap();
|
||||
assert!(pal.len() <= 16 && pal.len() >= 8, "{kind:?} {}", pal.len());
|
||||
assert_eq!(&pal[..2], &[[0, 0, 0], [255, 255, 255]]);
|
||||
|
|
@ -700,11 +603,7 @@ mod tests {
|
|||
// computed entries; the median cut used to return no centres and
|
||||
// k-means indexed into the empty list.
|
||||
let px = vec![[1.0, 1.0, 1.0, 1.0]; 16];
|
||||
for kind in [
|
||||
PaletteKind::Adaptive,
|
||||
PaletteKind::Perceptual,
|
||||
PaletteKind::Selective,
|
||||
] {
|
||||
for kind in [PaletteKind::Adaptive, PaletteKind::Perceptual, PaletteKind::Selective] {
|
||||
let pal = build_palette(&px, kind, 2, Forced::BlackWhite).unwrap();
|
||||
assert_eq!(pal, vec![[0, 0, 0], [255, 255, 255]], "{kind:?}");
|
||||
let pal = build_palette(&gradient(8, 8), kind, 8, Forced::Primaries).unwrap();
|
||||
|
|
@ -715,12 +614,7 @@ mod tests {
|
|||
#[test]
|
||||
fn quantize_maps_to_palette_with_each_dither() {
|
||||
let pal = vec![[0, 0, 0], [255, 255, 255]];
|
||||
for d in [
|
||||
Dither::None,
|
||||
Dither::Diffusion,
|
||||
Dither::Pattern,
|
||||
Dither::Noise,
|
||||
] {
|
||||
for d in [Dither::None, Dither::Diffusion, Dither::Pattern, Dither::Noise] {
|
||||
let mut px: Vec<[f32; 4]> = (0..64)
|
||||
.map(|i| [i as f32 / 63.0; 4])
|
||||
.map(|mut p| {
|
||||
|
|
@ -748,11 +642,7 @@ mod tests {
|
|||
BitmapMethod::Threshold,
|
||||
BitmapMethod::Pattern,
|
||||
BitmapMethod::Diffusion,
|
||||
BitmapMethod::Halftone {
|
||||
cell: 6.0,
|
||||
angle: 45.0,
|
||||
shape: HalftoneShape::Round,
|
||||
},
|
||||
BitmapMethod::Halftone { cell: 6.0, angle: 45.0, shape: HalftoneShape::Round },
|
||||
] {
|
||||
let mut g = vec![0.25f32; 32 * 32];
|
||||
to_bitmap(&mut g, 32, m);
|
||||
|
|
|
|||
|
|
@ -487,7 +487,8 @@ fn flame_colour(t: f32, custom: Option<[f32; 4]>) -> [f32; 4] {
|
|||
[rgb[0], rgb[1], rgb[2], a * c[3]]
|
||||
}
|
||||
None => {
|
||||
const STOPS: [(f32, [f32; 3]); 5] = [(0.0, [1.0, 0.98, 0.85]), (0.2, [1.0, 0.86, 0.35]), (0.45, [1.0, 0.55, 0.08]), (0.75, [0.9, 0.25, 0.02]), (1.0, [0.6, 0.08, 0.0])];
|
||||
const STOPS: [(f32, [f32; 3]); 5] =
|
||||
[(0.0, [1.0, 0.98, 0.85]), (0.2, [1.0, 0.86, 0.35]), (0.45, [1.0, 0.55, 0.08]), (0.75, [0.9, 0.25, 0.02]), (1.0, [0.6, 0.08, 0.0])];
|
||||
let k = t.clamp(0.0, 1.0);
|
||||
let mut rgb = STOPS[4].1;
|
||||
for w in STOPS.windows(2) {
|
||||
|
|
@ -539,7 +540,11 @@ fn draw_flame(out: &mut Vec<Prim>, spine: &Spine, width: f32, spec: &FlameSpec,
|
|||
let o = sway(u);
|
||||
let c = flame_colour(u, spec.color);
|
||||
let r = half * (1.25 - u) + 2.0;
|
||||
out.push(Prim::ellipse((px - dy * o, py + dx * o), r, r * 1.2, dy.atan2(dx), [c[0], c[1], c[2], 0.16 * opacity], [c[0], c[1] * 0.6, c[2] * 0.3, 0.0]).with_soft(r.max(2.0)).additive());
|
||||
out.push(
|
||||
Prim::ellipse((px - dy * o, py + dx * o), r, r * 1.2, dy.atan2(dx), [c[0], c[1], c[2], 0.16 * opacity], [c[0], c[1] * 0.6, c[2] * 0.3, 0.0])
|
||||
.with_soft(r.max(2.0))
|
||||
.additive(),
|
||||
);
|
||||
}
|
||||
let line_r = (half / lines as f32 * 2.2).clamp(0.8, 10.0);
|
||||
for li in 0..lines {
|
||||
|
|
@ -572,7 +577,11 @@ fn draw_flame(out: &mut Vec<Prim>, spine: &Spine, width: f32, spec: &FlameSpec,
|
|||
if let Some((q, rq, uq)) = prev {
|
||||
// Fade in over the first tenth (a soft base) and out towards the line's tip.
|
||||
let f = |c: [f32; 4], uu: f32, t: f32| [c[0], c[1], c[2], c[3] * opacity * (1.0 - t.powi(3)) * ((uu - start) / 0.08).clamp(0.0, 1.0).sqrt()];
|
||||
out.push(Prim::capsule(q, p, rq, r, f(flame_colour(uq, spec.color), uq, fade_t), f(flame_colour(u, spec.color), u, fade_t)).with_soft(1.0 + lr * 0.6).lighten());
|
||||
out.push(
|
||||
Prim::capsule(q, p, rq, r, f(flame_colour(uq, spec.color), uq, fade_t), f(flame_colour(u, spec.color), u, fade_t))
|
||||
.with_soft(1.0 + lr * 0.6)
|
||||
.lighten(),
|
||||
);
|
||||
}
|
||||
prev = Some((p, r, u));
|
||||
}
|
||||
|
|
@ -586,7 +595,8 @@ pub fn flame(spec: &FlameSpec, paths: &[Vec<(f32, f32)>]) -> Vec<Prim> {
|
|||
let length = spec.length.clamp(1.0, 1000.0);
|
||||
let width = spec.width.clamp(1.0, 1000.0);
|
||||
let interval = spec.interval.clamp(1.0, 1000.0);
|
||||
let pick_len = |rng: &mut Rng, var: f32| if spec.randomize_length || var > 0.0 { length * rng.range(1.0 - var.max(0.4), 1.0 + var.max(0.4) * 0.5) } else { length };
|
||||
let pick_len =
|
||||
|rng: &mut Rng, var: f32| if spec.randomize_length || var > 0.0 { length * rng.range(1.0 - var.max(0.4), 1.0 + var.max(0.4) * 0.5) } else { length };
|
||||
for path in paths.iter().filter(|p| !p.is_empty()) {
|
||||
let spine = Spine::new(path.clone());
|
||||
let total = spine.len();
|
||||
|
|
@ -600,14 +610,29 @@ pub fn flame(spec: &FlameSpec, paths: &[Vec<(f32, f32)>]) -> Vec<Prim> {
|
|||
let c = FlameSpec { flame_shape: FlameShape::Oval, turbulent: spec.turbulent * 0.3, jag: spec.jag * 0.2, ..spec.clone() };
|
||||
draw_flame(&mut out, &candle, (width * 0.6).max(4.0), &c, &mut rng);
|
||||
let core = flame_colour(0.05, spec.color);
|
||||
out.push(Prim::ellipse((base.0, base.1 - h * 0.28), (width * 0.12).max(1.5), h * 0.22, 0.0, [core[0], core[1], core[2], 0.9 * spec.opacity.clamp(0.0, 100.0) / 100.0], [core[0], core[1], core[2], 0.0]).with_soft(2.0).additive());
|
||||
out.push(
|
||||
Prim::ellipse(
|
||||
(base.0, base.1 - h * 0.28),
|
||||
(width * 0.12).max(1.5),
|
||||
h * 0.22,
|
||||
0.0,
|
||||
[core[0], core[1], core[2], 0.9 * spec.opacity.clamp(0.0, 100.0) / 100.0],
|
||||
[core[0], core[1], core[2], 0.0],
|
||||
)
|
||||
.with_soft(2.0)
|
||||
.additive(),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
let closed = path.len() > 2 && {
|
||||
let (a, b) = (path[0], path[path.len() - 1]);
|
||||
(a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0
|
||||
};
|
||||
let count = if spec.adjust_interval_for_loops && closed { (total / interval).round().max(1.0) as usize } else { (total / interval).floor() as usize + 1 };
|
||||
let count = if spec.adjust_interval_for_loops && closed {
|
||||
(total / interval).round().max(1.0) as usize
|
||||
} else {
|
||||
(total / interval).floor() as usize + 1
|
||||
};
|
||||
let step = if spec.adjust_interval_for_loops && closed { total / count as f32 } else { interval };
|
||||
for k in 0..count {
|
||||
let s = k as f32 * step;
|
||||
|
|
@ -699,7 +724,22 @@ struct TreeKind {
|
|||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
const fn tk(name: &'static str, habit: Habit, levels: u32, children: u32, spread: f32, ratio: f32, trunk: f32, droop: f32, wiggle: f32, thick: f32, leaf: Leaf, leaf_col: [f32; 3], bark: [f32; 3], accent: Option<[f32; 3]>) -> TreeKind {
|
||||
const fn tk(
|
||||
name: &'static str,
|
||||
habit: Habit,
|
||||
levels: u32,
|
||||
children: u32,
|
||||
spread: f32,
|
||||
ratio: f32,
|
||||
trunk: f32,
|
||||
droop: f32,
|
||||
wiggle: f32,
|
||||
thick: f32,
|
||||
leaf: Leaf,
|
||||
leaf_col: [f32; 3],
|
||||
bark: [f32; 3],
|
||||
accent: Option<[f32; 3]>,
|
||||
) -> TreeKind {
|
||||
TreeKind { name, habit, levels, children, spread, ratio, trunk, droop, wiggle, thick, leaf, leaf_col, bark, accent }
|
||||
}
|
||||
|
||||
|
|
@ -1023,7 +1063,8 @@ pub fn tree(spec: &TreeSpec, canvas: Rect) -> Vec<Prim> {
|
|||
ang += 0.06 * d.sin().atan2(d.cos());
|
||||
let nq = (q.0 + ang.cos() * flen / segs as f32, q.1 + ang.sin() * flen / segs as f32);
|
||||
let k2 = t.light_at(nq);
|
||||
t.leaves.push((k2 - 0.1, Prim::capsule(q, nq, (r0 * 0.15).max(0.6), (r0 * 0.1).max(0.4), shade(t.leaf, k2 * 0.8), shade(t.leaf, k2 * 0.8))));
|
||||
t.leaves
|
||||
.push((k2 - 0.1, Prim::capsule(q, nq, (r0 * 0.15).max(0.6), (r0 * 0.1).max(0.4), shade(t.leaf, k2 * 0.8), shade(t.leaf, k2 * 0.8))));
|
||||
for j in 0..leaflets {
|
||||
let along = j as f32 / leaflets as f32;
|
||||
let at = (q.0 + (nq.0 - q.0) * along, q.1 + (nq.1 - q.1) * along);
|
||||
|
|
@ -1201,7 +1242,12 @@ pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec<Prim> {
|
|||
let thick = (unit * 0.04 + short * 0.00012 * spec.thickness.clamp(1.0, 100.0)).max(0.6);
|
||||
let spacing = unit * (4.0 - 3.2 * spec.arrangement.clamp(1.0, 100.0) / 100.0);
|
||||
let inset = margin + unit;
|
||||
let b = Border { x0: canvas.x0 as f32 + inset, y0: canvas.y0 as f32 + inset, w: (canvas.width() as f32 - 2.0 * inset).max(1.0), h: (canvas.height() as f32 - 2.0 * inset).max(1.0) };
|
||||
let b = Border {
|
||||
x0: canvas.x0 as f32 + inset,
|
||||
y0: canvas.y0 as f32 + inset,
|
||||
w: (canvas.width() as f32 - 2.0 * inset).max(1.0),
|
||||
h: (canvas.height() as f32 - 2.0 * inset).max(1.0),
|
||||
};
|
||||
let total = b.len();
|
||||
let count = (total / spacing).round().max(4.0) as usize;
|
||||
let step = total / count as f32;
|
||||
|
|
@ -1333,7 +1379,14 @@ pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec<Prim> {
|
|||
if style == "rope" {
|
||||
if k % 2 == 0 {
|
||||
let (_, t, _) = b.at(s);
|
||||
out.push(Prim::ellipse(q, unit * 0.45, unit * 0.2, t.1.atan2(t.0) + 0.7, shade([vine[0], vine[1], vine[2]], 1.1), shade([vine[0], vine[1], vine[2]], 0.65)));
|
||||
out.push(Prim::ellipse(
|
||||
q,
|
||||
unit * 0.45,
|
||||
unit * 0.2,
|
||||
t.1.atan2(t.0) + 0.7,
|
||||
shade([vine[0], vine[1], vine[2]], 1.1),
|
||||
shade([vine[0], vine[1], vine[2]], 0.65),
|
||||
));
|
||||
}
|
||||
} else if let Some(pp) = prev {
|
||||
out.push(Prim::capsule(pp, q, thick, thick, vine, vine));
|
||||
|
|
@ -1342,7 +1395,12 @@ pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec<Prim> {
|
|||
}
|
||||
if style == "doubleLine" {
|
||||
// A second rule inside each line and corner squares.
|
||||
let inner = Border { x0: b.x0 + unit * 0.6 + off, y0: b.y0 + unit * 0.6 + off, w: b.w - 2.0 * (unit * 0.6 + off), h: b.h - 2.0 * (unit * 0.6 + off) };
|
||||
let inner = Border {
|
||||
x0: b.x0 + unit * 0.6 + off,
|
||||
y0: b.y0 + unit * 0.6 + off,
|
||||
w: b.w - 2.0 * (unit * 0.6 + off),
|
||||
h: b.h - 2.0 * (unit * 0.6 + off),
|
||||
};
|
||||
for e in 0..4 {
|
||||
let (p, _, _) = inner.at([0.0, inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h][e]);
|
||||
let (q, _, _) = inner.at([inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h, inner.len() - 0.001][e]);
|
||||
|
|
@ -1458,7 +1516,14 @@ mod tests {
|
|||
assert!(!picture_frame(&FrameSpec { frame: k, ..Default::default() }, canvas).is_empty(), "frame {k}");
|
||||
}
|
||||
let path = default_flame_path(canvas);
|
||||
for t in [FlameType::OneFlameAlongPath, FlameType::MultipleFlamesAlongPath, FlameType::MultipleFlamesPathDirections, FlameType::MultipleFlamesVariousLength, FlameType::CandleLight, FlameType::MultipleFlamesOneDirection] {
|
||||
for t in [
|
||||
FlameType::OneFlameAlongPath,
|
||||
FlameType::MultipleFlamesAlongPath,
|
||||
FlameType::MultipleFlamesPathDirections,
|
||||
FlameType::MultipleFlamesVariousLength,
|
||||
FlameType::CandleLight,
|
||||
FlameType::MultipleFlamesOneDirection,
|
||||
] {
|
||||
assert!(!flame(&FlameSpec { flame_type: t, ..Default::default() }, std::slice::from_ref(&path)).is_empty(), "{t:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,7 +178,16 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
|
|||
/// With `protect_detail`, each result is clamped to the 3×3 neighbourhood's range, which stops
|
||||
/// halos and noise from being amplified into new extremes. Alpha is left unchanged.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn local_sharpen(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usize>, win: (usize, usize, usize, usize), amount: f32, protect_detail: bool) -> Vec<f32> {
|
||||
pub fn local_sharpen(
|
||||
data: &[f32],
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
alpha: Option<usize>,
|
||||
win: (usize, usize, usize, usize),
|
||||
amount: f32,
|
||||
protect_detail: bool,
|
||||
) -> Vec<f32> {
|
||||
let (x0, y0, x1, y1) = win;
|
||||
let ow = x1 - x0;
|
||||
let blur = local_blur(data, w, h, ch, alpha, win, 1.0);
|
||||
|
|
@ -289,7 +298,7 @@ mod tests {
|
|||
let (w, h, ch) = (8, 1, 4);
|
||||
let data: Vec<f32> = (0..w).flat_map(|x| if x < 4 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 1.0, 0.0, 0.0] }).collect();
|
||||
let b = local_blur(&data, w, h, ch, Some(3), (2, 0, 6, 1), 1.0);
|
||||
for p in b.chunks_exact(4) {
|
||||
for p in b.as_chunks::<4>().0 {
|
||||
if p[3] > 0.01 {
|
||||
assert!(p[0] > 0.99 && p[1] < 0.01, "{p:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ pub fn sharpness(img: &super::RgbImage, noise: f32) -> Vec<f32> {
|
|||
for yy in 0..h {
|
||||
for x in 0..w {
|
||||
let c = at(x, yy);
|
||||
let l = (2.0 * c - at(x.saturating_sub(1), yy) - at((x + 1).min(w - 1), yy)).abs() + (2.0 * c - at(x, yy.saturating_sub(1)) - at(x, (yy + 1).min(h - 1))).abs();
|
||||
let l = (2.0 * c - at(x.saturating_sub(1), yy) - at((x + 1).min(w - 1), yy)).abs()
|
||||
+ (2.0 * c - at(x, yy.saturating_sub(1)) - at(x, (yy + 1).min(h - 1))).abs();
|
||||
lap[yy * w + x] = l;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ impl Component {
|
|||
for (i, row) in c.iter_mut().enumerate() {
|
||||
row[i] += reg;
|
||||
}
|
||||
let det = c[0][0] * (c[1][1] * c[2][2] - c[1][2] * c[2][1]) - c[0][1] * (c[1][0] * c[2][2] - c[1][2] * c[2][0]) + c[0][2] * (c[1][0] * c[2][1] - c[1][1] * c[2][0]);
|
||||
let det = c[0][0] * (c[1][1] * c[2][2] - c[1][2] * c[2][1]) - c[0][1] * (c[1][0] * c[2][2] - c[1][2] * c[2][0])
|
||||
+ c[0][2] * (c[1][0] * c[2][1] - c[1][1] * c[2][0]);
|
||||
if det <= 1e-30 || weight <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -34,13 +35,7 @@ impl Component {
|
|||
[(c[1][0] * c[2][1] - c[1][1] * c[2][0]) / det, (c[0][1] * c[2][0] - c[0][0] * c[2][1]) / det, (c[0][0] * c[1][1] - c[0][1] * c[1][0]) / det],
|
||||
];
|
||||
let log_norm = weight.ln() - 0.5 * det.ln() - 1.5 * (2.0 * std::f64::consts::PI).ln();
|
||||
Some(Component {
|
||||
weight: weight as f32,
|
||||
mean: mean.map(|v| v as f32),
|
||||
cov: c.map(|r| r.map(|v| v as f32)),
|
||||
inv,
|
||||
log_norm,
|
||||
})
|
||||
Some(Component { weight: weight as f32, mean: mean.map(|v| v as f32), cov: c.map(|r| r.map(|v| v as f32)), inv, log_norm })
|
||||
}
|
||||
|
||||
/// `ln(weight · N(z; μ, Σ))`.
|
||||
|
|
@ -48,7 +43,9 @@ impl Component {
|
|||
pub fn log_weighted(&self, z: [f32; 3]) -> f64 {
|
||||
let d = [(z[0] - self.mean[0]) as f64, (z[1] - self.mean[1]) as f64, (z[2] - self.mean[2]) as f64];
|
||||
let m = &self.inv;
|
||||
let q = d[0] * (m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2]) + d[1] * (m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2]) + d[2] * (m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2]);
|
||||
let q = d[0] * (m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2])
|
||||
+ d[1] * (m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2])
|
||||
+ d[2] * (m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2]);
|
||||
self.log_norm - 0.5 * q
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
use photocraft_geom::Rect;
|
||||
|
||||
use super::gmm::{DEFAULT_REG, Gmm};
|
||||
use super::{HARD_BG, HARD_FG, FREE, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut, subsample};
|
||||
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut, subsample};
|
||||
|
||||
/// Definite background.
|
||||
pub const BG: u8 = 0;
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ pub mod gmm;
|
|||
pub mod grabcut;
|
||||
pub mod maxflow;
|
||||
pub mod quick;
|
||||
pub mod slic;
|
||||
pub mod sky;
|
||||
pub mod slic;
|
||||
pub mod subject;
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
use photocraft_geom::Rect;
|
||||
|
||||
use super::gmm::Gmm;
|
||||
use super::{HARD_FG, FREE, Region, RgbImage, Sampler, contrast_beta, grid_cut, keep_seeded, subsample};
|
||||
use super::{FREE, HARD_FG, Region, RgbImage, Sampler, contrast_beta, grid_cut, keep_seeded, subsample};
|
||||
|
||||
/// Covariance ridge for the (often tiny) stroke sample: ~6 levels of standard deviation.
|
||||
const QREG: f32 = 6e-4;
|
||||
|
|
|
|||
|
|
@ -319,11 +319,8 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti
|
|||
return nonempty(o.clone());
|
||||
}
|
||||
let p = prev_u8(o);
|
||||
let out: Vec<f32> = p
|
||||
.iter()
|
||||
.zip(&new.mask)
|
||||
.map(|(a, n)| if mode == SelectionMode::Add { *a.max(n) } else { a.saturating_sub(*n) } as f32 / 255.0)
|
||||
.collect();
|
||||
let out: Vec<f32> =
|
||||
p.iter().zip(&new.mask).map(|(a, n)| if mode == SelectionMode::Add { *a.max(n) } else { a.saturating_sub(*n) } as f32 / 255.0).collect();
|
||||
let mut s = o.clone();
|
||||
s.write_region(b, &out);
|
||||
s.prune();
|
||||
|
|
@ -550,14 +547,16 @@ pub fn polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec<f32>
|
|||
}
|
||||
}
|
||||
xs.sort_by(|a, b| a.total_cmp(b));
|
||||
for span in xs.chunks_exact(2) {
|
||||
for span in xs.as_chunks::<2>().0 {
|
||||
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
|
||||
if x1 <= x0 {
|
||||
continue;
|
||||
}
|
||||
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
|
||||
for cx in c0..c1 {
|
||||
let cov = if anti_alias { ((cx + 1) as f32).min(x1) - (cx as f32).max(x0) } else {
|
||||
let cov = if anti_alias {
|
||||
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
|
||||
} else {
|
||||
let c = cx as f32 + 0.5;
|
||||
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
|
||||
};
|
||||
|
|
@ -689,7 +688,13 @@ mod tests {
|
|||
let area = Rect::new(3, -2, 3 + w, -2 + h);
|
||||
let img = blobs(w, h);
|
||||
let f: Vec<[f32; 4]> = img.iter().map(|p| p.map(|v| v as f32 / 255.0)).collect();
|
||||
for (seed, tol, contiguous, aa) in [((10, 5), 10.0, true, true), ((10, 5), 10.0, true, false), ((20, 20), 40.0, false, true), ((4, -2), 0.0, true, true), ((30, 10), 255.0, true, true)] {
|
||||
for (seed, tol, contiguous, aa) in [
|
||||
((10, 5), 10.0, true, true),
|
||||
((10, 5), 10.0, true, false),
|
||||
((20, 20), 40.0, false, true),
|
||||
((4, -2), 0.0, true, true),
|
||||
((30, 10), 255.0, true, true),
|
||||
] {
|
||||
let reference = magic_wand(&f, area, seed, tol, contiguous, aa);
|
||||
let r = wand_region(&img, area, seed, tol, contiguous, aa).unwrap();
|
||||
for y in area.y0..area.y1 {
|
||||
|
|
|
|||
|
|
@ -35,37 +35,23 @@ fn stat(v: &mut [f32], s: Stat) -> f32 {
|
|||
Stat::Summation => v.iter().sum::<f32>(),
|
||||
Stat::Minimum => v.iter().copied().fold(f32::MAX, f32::min),
|
||||
Stat::Maximum => v.iter().copied().fold(f32::MIN, f32::max),
|
||||
Stat::Range => {
|
||||
v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min)
|
||||
}
|
||||
Stat::Range => v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min),
|
||||
Stat::Median => {
|
||||
v.sort_by(f32::total_cmp);
|
||||
let k = v.len();
|
||||
if k % 2 == 1 {
|
||||
v[k / 2]
|
||||
} else {
|
||||
(v[k / 2 - 1] + v[k / 2]) / 2.0
|
||||
}
|
||||
if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 }
|
||||
}
|
||||
Stat::Variance => m(2),
|
||||
Stat::StandardDeviation => m(2).sqrt(),
|
||||
Stat::Skewness => {
|
||||
let sd = m(2).sqrt();
|
||||
// Map −2..2 to 0..1 (0.5 = symmetric).
|
||||
if sd < 1e-6 {
|
||||
0.5
|
||||
} else {
|
||||
(m(3) / sd.powi(3) / 4.0 + 0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
if sd < 1e-6 { 0.5 } else { (m(3) / sd.powi(3) / 4.0 + 0.5).clamp(0.0, 1.0) }
|
||||
}
|
||||
Stat::Kurtosis => {
|
||||
let var = m(2);
|
||||
// 0..10 → 0..1.
|
||||
if var < 1e-12 {
|
||||
0.0
|
||||
} else {
|
||||
(m(4) / (var * var) / 10.0).clamp(0.0, 1.0)
|
||||
}
|
||||
if var < 1e-12 { 0.0 } else { (m(4) / (var * var) / 10.0).clamp(0.0, 1.0) }
|
||||
}
|
||||
Stat::Entropy => {
|
||||
if v.len() < 2 {
|
||||
|
|
@ -73,9 +59,7 @@ fn stat(v: &mut [f32], s: Stat) -> f32 {
|
|||
}
|
||||
let mut counts: std::collections::HashMap<u8, u32> = std::collections::HashMap::new();
|
||||
for x in v.iter() {
|
||||
*counts
|
||||
.entry((x.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
.or_default() += 1;
|
||||
*counts.entry((x.clamp(0.0, 1.0) * 255.0).round() as u8).or_default() += 1;
|
||||
}
|
||||
let h: f32 = counts
|
||||
.values()
|
||||
|
|
|
|||
|
|
@ -288,7 +288,8 @@ pub(crate) fn tiles(src: &Image, out: Rect, ctx: &Ctx, count: u32, max_offset: f
|
|||
let (cx, cy) = (gx + dx, gy + dy);
|
||||
let (ox, oy) = offset(cx, cy);
|
||||
let (u, v) = (fx - ox - cx as f32 * ts, fy - oy - cy as f32 * ts);
|
||||
if u >= 0.0 && v >= 0.0 && u < ts && v < ts && (cx as f32 * ts) < b.width() as f32 && (cy as f32 * ts) < b.height() as f32 && cx >= 0 && cy >= 0 {
|
||||
if u >= 0.0 && v >= 0.0 && u < ts && v < ts && (cx as f32 * ts) < b.width() as f32 && (cy as f32 * ts) < b.height() as f32 && cx >= 0 && cy >= 0
|
||||
{
|
||||
let order = (cy, cx);
|
||||
if hit.is_none_or(|(o, _, _)| order > o) {
|
||||
hit = Some((order, ox, oy));
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ fn blur_preserves_flat_colour_and_mass() {
|
|||
for p in [FilterParams::GaussianBlur { radius: 2.0 }, FilterParams::BoxBlur { radius: 3.0 }] {
|
||||
let out = run(&small, &p);
|
||||
let big = Rect::new(-10, -10, 40, 40);
|
||||
let sum: f32 = out.read_region(big).chunks_exact(4).map(|px| px[3]).sum();
|
||||
let sum: f32 = out.read_region(big).as_chunks::<4>().0.iter().map(|px| px[3]).sum();
|
||||
assert!((sum - 100.0).abs() < 0.01, "{}: {sum}", p.label());
|
||||
// No colour bleeding from transparent pixels.
|
||||
let edge = out.pixel(9, 15);
|
||||
|
|
@ -176,7 +176,7 @@ fn unsharp_threshold_and_contrast() {
|
|||
let sharp = run(&s, &FilterParams::UnsharpMask { amount: 200.0, radius: 2.0, threshold: 0.0 });
|
||||
let var = |surf: &Surface| {
|
||||
let v = surf.read_region(Rect::new(5, 5, 35, 25));
|
||||
let reds: Vec<f32> = v.chunks_exact(4).map(|p| p[0]).collect();
|
||||
let reds: Vec<f32> = v.as_chunks::<4>().0.iter().map(|p| p[0]).collect();
|
||||
let m = reds.iter().sum::<f32>() / reds.len() as f32;
|
||||
reds.iter().map(|r| (r - m).powi(2)).sum::<f32>()
|
||||
};
|
||||
|
|
@ -198,7 +198,7 @@ fn noise_is_deterministic_monochrome_and_centred() {
|
|||
assert_eq!(a, run(&s, &p));
|
||||
let v = a.read_region(R);
|
||||
let mut mean = 0.0;
|
||||
for px in v.chunks_exact(4) {
|
||||
for px in v.as_chunks::<4>().0 {
|
||||
assert_eq!(px[0], px[1]);
|
||||
assert_eq!(px[1], px[2]);
|
||||
assert!((px[0] - 0.5).abs() <= 0.25 + 1e-6);
|
||||
|
|
@ -240,35 +240,15 @@ fn maximum_roundness_grows_a_point_into_a_disc() {
|
|||
let mut s = flat(depth, R, [0.0, 0.0, 0.0, 1.0]);
|
||||
s.write_pixel(20, 15, &[1.0, 1.0, 1.0, 1.0]);
|
||||
for radius in [1.0f32, 2.0, 3.5, 6.0] {
|
||||
let round = run(
|
||||
&s,
|
||||
&FilterParams::Maximum {
|
||||
radius,
|
||||
preserve: Preserve::Roundness,
|
||||
},
|
||||
);
|
||||
let square = run(
|
||||
&s,
|
||||
&FilterParams::Maximum {
|
||||
radius,
|
||||
preserve: Preserve::Squareness,
|
||||
},
|
||||
);
|
||||
let round = run(&s, &FilterParams::Maximum { radius, preserve: Preserve::Roundness });
|
||||
let square = run(&s, &FilterParams::Maximum { radius, preserve: Preserve::Squareness });
|
||||
let rs = radius.round() as i32;
|
||||
for y in R.y0..R.y1 {
|
||||
for x in R.x0..R.x1 {
|
||||
let (dx, dy) = (x - 20, y - 15);
|
||||
let in_disc = (dx * dx + dy * dy) as f32 <= radius * radius;
|
||||
assert_eq!(
|
||||
round.pixel(x, y)[0] > 0.5,
|
||||
in_disc,
|
||||
"{depth:?} r {radius} at {dx},{dy}"
|
||||
);
|
||||
assert_eq!(
|
||||
square.pixel(x, y)[0] > 0.5,
|
||||
dx.abs() <= rs && dy.abs() <= rs,
|
||||
"{depth:?} r {radius} at {dx},{dy}"
|
||||
);
|
||||
assert_eq!(round.pixel(x, y)[0] > 0.5, in_disc, "{depth:?} r {radius} at {dx},{dy}");
|
||||
assert_eq!(square.pixel(x, y)[0] > 0.5, dx.abs() <= rs && dy.abs() <= rs, "{depth:?} r {radius} at {dx},{dy}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -276,13 +256,7 @@ fn maximum_roundness_grows_a_point_into_a_disc() {
|
|||
// Radius 1: the four neighbours, not the diagonals (a 3×3 square would take them).
|
||||
let mut s = flat(SampleType::U8, R, [0.0, 0.0, 0.0, 1.0]);
|
||||
s.write_pixel(20, 15, &[1.0, 1.0, 1.0, 1.0]);
|
||||
let r1 = run(
|
||||
&s,
|
||||
&FilterParams::Maximum {
|
||||
radius: 1.0,
|
||||
preserve: Preserve::Roundness,
|
||||
},
|
||||
);
|
||||
let r1 = run(&s, &FilterParams::Maximum { radius: 1.0, preserve: Preserve::Roundness });
|
||||
assert_eq!(r1.pixel(21, 15)[0], 1.0);
|
||||
assert_eq!(r1.pixel(21, 16)[0], 0.0);
|
||||
}
|
||||
|
|
@ -292,23 +266,14 @@ fn minimum_maximum_roundness_match_brute_force() {
|
|||
// Exhaustive disc search on a textured image, both operators, a few radii (incl. fractional).
|
||||
let s = pattern(SampleType::F32, R);
|
||||
let img = s.read_region(R);
|
||||
let at = |x: i32, y: i32, c: usize| -> Option<f32> {
|
||||
R.contains(x, y)
|
||||
.then(|| img[((y * 40 + x) * 4) as usize + c])
|
||||
};
|
||||
let at = |x: i32, y: i32, c: usize| -> Option<f32> { R.contains(x, y).then(|| img[((y * 40 + x) * 4) as usize + c]) };
|
||||
for radius in [1.0f32, 1.5, 2.9, 4.0] {
|
||||
let ri = radius.floor() as i32;
|
||||
for max in [false, true] {
|
||||
let p = if max {
|
||||
FilterParams::Maximum {
|
||||
radius,
|
||||
preserve: Preserve::Roundness,
|
||||
}
|
||||
FilterParams::Maximum { radius, preserve: Preserve::Roundness }
|
||||
} else {
|
||||
FilterParams::Minimum {
|
||||
radius,
|
||||
preserve: Preserve::Roundness,
|
||||
}
|
||||
FilterParams::Minimum { radius, preserve: Preserve::Roundness }
|
||||
};
|
||||
let got = run(&s, &p);
|
||||
for y in R.y0 + ri..R.y1 - ri {
|
||||
|
|
@ -324,10 +289,7 @@ fn minimum_maximum_roundness_match_brute_force() {
|
|||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
(got.pixel(x, y)[c] - want).abs() < 1e-6,
|
||||
"r {radius} max {max} at {x},{y} c{c}"
|
||||
);
|
||||
assert!((got.pixel(x, y)[c] - want).abs() < 1e-6, "r {radius} max {max} at {x},{y} c{c}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -342,16 +304,8 @@ fn running_extreme_windows() {
|
|||
for k in 1..=v.len() {
|
||||
for max in [false, true] {
|
||||
crate::other::running_extreme(&v, k, max, &mut out);
|
||||
let want: Vec<f32> = v
|
||||
.windows(k)
|
||||
.map(|w| {
|
||||
w.iter()
|
||||
.copied()
|
||||
.fold(if max { f32::MIN } else { f32::MAX }, |a, b| {
|
||||
if max { a.max(b) } else { a.min(b) }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let want: Vec<f32> =
|
||||
v.windows(k).map(|w| w.iter().copied().fold(if max { f32::MIN } else { f32::MAX }, |a, b| if max { a.max(b) } else { a.min(b) })).collect();
|
||||
assert_eq!(out, want, "k {k} max {max}");
|
||||
}
|
||||
}
|
||||
|
|
@ -360,22 +314,9 @@ fn running_extreme_windows() {
|
|||
#[test]
|
||||
fn minimum_maximum_old_params_default_to_squareness() {
|
||||
let p: FilterParams = serde_json::from_str(r#"{"filter":"minimum","radius":3}"#).unwrap();
|
||||
assert_eq!(
|
||||
p,
|
||||
FilterParams::Minimum {
|
||||
radius: 3.0,
|
||||
preserve: Preserve::Squareness
|
||||
}
|
||||
);
|
||||
let p: FilterParams =
|
||||
serde_json::from_str(r#"{"filter":"maximum","radius":2,"preserve":"roundness"}"#).unwrap();
|
||||
assert_eq!(
|
||||
p,
|
||||
FilterParams::Maximum {
|
||||
radius: 2.0,
|
||||
preserve: Preserve::Roundness
|
||||
}
|
||||
);
|
||||
assert_eq!(p, FilterParams::Minimum { radius: 3.0, preserve: Preserve::Squareness });
|
||||
let p: FilterParams = serde_json::from_str(r#"{"filter":"maximum","radius":2,"preserve":"roundness"}"#).unwrap();
|
||||
assert_eq!(p, FilterParams::Maximum { radius: 2.0, preserve: Preserve::Roundness });
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -475,7 +416,12 @@ fn other_colour_modes_work() {
|
|||
let mut s = Surface::new(f);
|
||||
let px: Vec<f32> = vec![0.4; f.channels() - 1].into_iter().chain([1.0]).collect();
|
||||
s.fill_rect(R, &px);
|
||||
for p in [FilterParams::GaussianBlur { radius: 1.0 }, FilterParams::Desaturate, FilterParams::Emboss { angle: 0.0, height: 1.0, amount: 100.0 }, FilterParams::Twirl { angle: 50.0 }] {
|
||||
for p in [
|
||||
FilterParams::GaussianBlur { radius: 1.0 },
|
||||
FilterParams::Desaturate,
|
||||
FilterParams::Emboss { angle: 0.0, height: 1.0, amount: 100.0 },
|
||||
FilterParams::Twirl { angle: 50.0 },
|
||||
] {
|
||||
let out = run(&s, &p);
|
||||
assert_eq!(out.format(), f);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,16 @@ fn new_filters() -> Vec<FilterParams> {
|
|||
FilterParams::ZigZag { amount: 50.0, ridges: 4.0, style: ZigZagStyle::PondRipples },
|
||||
FilterParams::Fibers { variance: 16.0, strength: 4.0, seed: 8, foreground: BLACK, background: WHITE },
|
||||
FilterParams::LensFlare { brightness: 100.0, center_x: 0.3, center_y: 0.3, lens: LensType::Zoom },
|
||||
FilterParams::LightingEffects { lights: vec![Light::default()], gloss: 0.0, metallic: 0.0, exposure: 0.0, ambience: 10.0, texture: TextureChannel::Luminance, height: 50.0, white_is_high: true },
|
||||
FilterParams::LightingEffects {
|
||||
lights: vec![Light::default()],
|
||||
gloss: 0.0,
|
||||
metallic: 0.0,
|
||||
exposure: 0.0,
|
||||
ambience: 10.0,
|
||||
texture: TextureChannel::Luminance,
|
||||
height: 50.0,
|
||||
white_is_high: true,
|
||||
},
|
||||
FilterParams::ReduceNoise { strength: 6.0, preserve_details: 60.0, reduce_color_noise: 45.0, sharpen_details: 25.0, remove_jpeg_artifact: true },
|
||||
FilterParams::SmartBlur { radius: 3.0, threshold: 40.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
|
||||
FilterParams::SmartBlur { radius: 3.0, threshold: 25.0, quality: BlurQuality::Low, mode: SmartBlurMode::OverlayEdge },
|
||||
|
|
@ -90,7 +99,11 @@ fn new_filters() -> Vec<FilterParams> {
|
|||
FilterParams::FieldBlur { pins: vec![FieldPin { x: 0.2, y: 0.2, blur: 0.0 }, FieldPin { x: 0.8, y: 0.8, blur: 6.0 }] },
|
||||
FilterParams::SpinBlur { pins: vec![SpinPin { blur_angle: 30.0, ..SpinPin::default() }] },
|
||||
FilterParams::PathBlur { paths: vec![BlurPath { points: vec![[0.1, 0.2], [0.9, 0.8]], speed: 8.0, taper: 30.0 }] },
|
||||
FilterParams::Custom { kernel: vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, -1.0, 5.0, -1.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], scale: 1.0, offset: 0.0 },
|
||||
FilterParams::Custom {
|
||||
kernel: vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, -1.0, 5.0, -1.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
scale: 1.0,
|
||||
offset: 0.0,
|
||||
},
|
||||
FilterParams::HsbHsl { input: HsbModel::Rgb, output: HsbModel::Hsl },
|
||||
FilterParams::DeInterlace { eliminate_even: false, interpolate: true },
|
||||
FilterParams::NtscColors,
|
||||
|
|
@ -136,8 +149,17 @@ fn new_filters_change_pixels_and_respect_empty_selection() {
|
|||
fn new_filters_agree_across_bit_depths() {
|
||||
// Threshold-type filters flip where quantized inputs straddle a threshold; compare them on average only.
|
||||
let thresholdy = |p: &FilterParams| {
|
||||
matches!(p, FilterParams::Facet | FilterParams::OilPaint { .. } | FilterParams::Mezzotint { .. } | FilterParams::TraceContour { .. } | FilterParams::SmartBlur { .. } | FilterParams::ColorHalftone { .. } | FilterParams::Diffuse { .. } | FilterParams::Wind { .. })
|
||||
|| matches!(p, FilterParams::Extrude { level_based: true, .. })
|
||||
matches!(
|
||||
p,
|
||||
FilterParams::Facet
|
||||
| FilterParams::OilPaint { .. }
|
||||
| FilterParams::Mezzotint { .. }
|
||||
| FilterParams::TraceContour { .. }
|
||||
| FilterParams::SmartBlur { .. }
|
||||
| FilterParams::ColorHalftone { .. }
|
||||
| FilterParams::Diffuse { .. }
|
||||
| FilterParams::Wind { .. }
|
||||
) || matches!(p, FilterParams::Extrude { level_based: true, .. })
|
||||
};
|
||||
for p in new_filters() {
|
||||
let outs: Vec<Vec<f32>> = [SampleType::U8, SampleType::U16, SampleType::F32]
|
||||
|
|
@ -186,7 +208,9 @@ fn new_params_serde_roundtrip() {
|
|||
let back: FilterParams = serde_json::from_str(&j).unwrap();
|
||||
// Caller-supplied maps are not serialized.
|
||||
let strip = |p: &FilterParams| match p.clone() {
|
||||
FilterParams::Displace { horizontal, vertical, stretch, undefined, .. } => FilterParams::Displace { horizontal, vertical, stretch, undefined, map: None },
|
||||
FilterParams::Displace { horizontal, vertical, stretch, undefined, .. } => {
|
||||
FilterParams::Displace { horizontal, vertical, stretch, undefined, map: None }
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
assert_eq!(back, strip(&p), "{j}");
|
||||
|
|
@ -279,7 +303,7 @@ fn mezzotint_is_binary_and_tracks_brightness() {
|
|||
let out = run(&s, &FilterParams::Mezzotint { kind: MezzotintType::FineDots, seed: 1 });
|
||||
let v = out.read_region(R);
|
||||
let mut mean = [0.0f32; 3];
|
||||
for px in v.chunks_exact(4) {
|
||||
for px in v.as_chunks::<4>().0 {
|
||||
for k in 0..3 {
|
||||
assert!(px[k] == 0.0 || px[k] == 1.0);
|
||||
mean[k] += px[k] / 1200.0;
|
||||
|
|
@ -294,10 +318,10 @@ fn halftone_covers_by_ink() {
|
|||
let w = run(&flat(SampleType::F32, R, WHITE), &FilterParams::ColorHalftone { max_radius: 4.0, angles });
|
||||
assert!(w.read_region(R).iter().all(|&v| (v - 1.0).abs() < 1e-6), "white paper stays white");
|
||||
let b = run(&flat(SampleType::F32, R, BLACK), &FilterParams::ColorHalftone { max_radius: 4.0, angles });
|
||||
let mean_b: f32 = b.read_region(R).chunks_exact(4).map(|p| p[0]).sum::<f32>() / 1200.0;
|
||||
let mean_b: f32 = b.read_region(R).as_chunks::<4>().0.iter().map(|p| p[0]).sum::<f32>() / 1200.0;
|
||||
assert!(mean_b < 0.05, "black is fully inked: {mean_b}");
|
||||
let g = run(&flat(SampleType::F32, R, [0.5, 0.5, 0.5, 1.0]), &FilterParams::ColorHalftone { max_radius: 4.0, angles });
|
||||
let mean_g: f32 = g.read_region(R).chunks_exact(4).map(|p| p[1]).sum::<f32>() / 1200.0;
|
||||
let mean_g: f32 = g.read_region(R).as_chunks::<4>().0.iter().map(|p| p[1]).sum::<f32>() / 1200.0;
|
||||
assert!((mean_g - 0.5).abs() < 0.12, "mid gray ≈ half coverage: {mean_g}");
|
||||
}
|
||||
|
||||
|
|
@ -306,8 +330,8 @@ fn pointillize_shows_canvas() {
|
|||
let s = flat(SampleType::F32, R, [0.0, 0.0, 1.0, 1.0]);
|
||||
let out = run(&s, &FilterParams::Pointillize { cell_size: 10.0, seed: 3, background: [1.0, 0.0, 0.0, 1.0] });
|
||||
let v = out.read_region(R);
|
||||
assert!(v.chunks_exact(4).any(|p| p[0] > 0.99 && p[2] < 0.01), "canvas visible between dots");
|
||||
assert!(v.chunks_exact(4).any(|p| p[2] > 0.9), "dots take the image colour");
|
||||
assert!(v.as_chunks::<4>().0.iter().any(|p| p[0] > 0.99 && p[2] < 0.01), "canvas visible between dots");
|
||||
assert!(v.as_chunks::<4>().0.iter().any(|p| p[2] > 0.9), "dots take the image colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -345,21 +369,36 @@ fn trace_contour_draws_line_at_level_crossing() {
|
|||
#[test]
|
||||
fn tiles_leave_gaps_with_fill() {
|
||||
let s = flat(SampleType::F32, R, [0.0, 0.0, 1.0, 1.0]);
|
||||
let out = run(&s, &FilterParams::Tiles { count: 3, max_offset: 40.0, fill: TileFill::Foreground, foreground: [0.0, 1.0, 0.0, 1.0], background: WHITE, seed: 2 });
|
||||
let out =
|
||||
run(&s, &FilterParams::Tiles { count: 3, max_offset: 40.0, fill: TileFill::Foreground, foreground: [0.0, 1.0, 0.0, 1.0], background: WHITE, seed: 2 });
|
||||
let v = out.read_region(R);
|
||||
assert!(v.chunks_exact(4).any(|p| p[1] > 0.99), "foreground gaps");
|
||||
assert!(v.chunks_exact(4).filter(|p| p[2] > 0.99).count() > 600, "mostly tiles");
|
||||
assert!(v.as_chunks::<4>().0.iter().any(|p| p[1] > 0.99), "foreground gaps");
|
||||
assert!(v.as_chunks::<4>().0.iter().filter(|p| p[2] > 0.99).count() > 600, "mostly tiles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrude_blocks_and_pyramids_shade_cells() {
|
||||
let s = pattern(SampleType::F32, R);
|
||||
let blocks = run(&s, &FilterParams::Extrude { kind: ExtrudeType::Blocks, size: 8.0, depth: 40.0, level_based: false, solid_front: true, mask_incomplete: false, seed: 1 });
|
||||
let blocks = run(
|
||||
&s,
|
||||
&FilterParams::Extrude { kind: ExtrudeType::Blocks, size: 8.0, depth: 40.0, level_based: false, solid_front: true, mask_incomplete: false, seed: 1 },
|
||||
);
|
||||
// A solid front face is uniform: find two equal neighbours somewhere in the middle.
|
||||
let same = (5..35).any(|x| blocks.pixel(x, 12) == blocks.pixel(x + 1, 12));
|
||||
assert!(same);
|
||||
let flat_s = flat(SampleType::F32, R, [0.5, 0.5, 0.5, 1.0]);
|
||||
let pyr = run(&flat_s, &FilterParams::Extrude { kind: ExtrudeType::Pyramids, size: 10.0, depth: 100.0, level_based: false, solid_front: false, mask_incomplete: false, seed: 1 });
|
||||
let pyr = run(
|
||||
&flat_s,
|
||||
&FilterParams::Extrude {
|
||||
kind: ExtrudeType::Pyramids,
|
||||
size: 10.0,
|
||||
depth: 100.0,
|
||||
level_based: false,
|
||||
solid_front: false,
|
||||
mask_incomplete: false,
|
||||
seed: 1,
|
||||
},
|
||||
);
|
||||
// Light from the top-left: the left face is brighter than the right face.
|
||||
assert!(pyr.pixel(11, 15)[0] > pyr.pixel(18, 15)[0], "{:?} {:?}", pyr.pixel(11, 15), pyr.pixel(18, 15));
|
||||
}
|
||||
|
|
@ -370,7 +409,7 @@ fn fibers_lie_between_colours_and_are_seeded() {
|
|||
let p = FilterParams::Fibers { variance: 16.0, strength: 4.0, seed: 1, foreground: [1.0, 0.0, 0.0, 1.0], background: [0.0, 0.0, 1.0, 1.0] };
|
||||
let a = run(&s, &p);
|
||||
assert_eq!(a, run(&s, &p));
|
||||
for px in a.read_region(R).chunks_exact(4) {
|
||||
for px in a.read_region(R).as_chunks::<4>().0 {
|
||||
assert!((px[0] + px[2] - 1.0).abs() < 1e-5 && px[1].abs() < 1e-6);
|
||||
}
|
||||
let b = run(&s, &FilterParams::Fibers { variance: 16.0, strength: 4.0, seed: 2, foreground: [1.0, 0.0, 0.0, 1.0], background: [0.0, 0.0, 1.0, 1.0] });
|
||||
|
|
@ -390,16 +429,40 @@ fn lens_flare_is_brightest_at_its_centre() {
|
|||
fn lighting_point_light_falls_off() {
|
||||
let s = flat(SampleType::F32, R, [0.8, 0.8, 0.8, 1.0]);
|
||||
let light = Light { kind: LightKind::Point, x: 0.1, y: 0.5, z: 0.2, radius: 1.0, intensity: 50.0, ..Light::default() };
|
||||
let out = run(&s, &FilterParams::LightingEffects { lights: vec![light], gloss: -100.0, metallic: 0.0, exposure: 0.0, ambience: 0.0, texture: TextureChannel::None, height: 0.0, white_is_high: true });
|
||||
let out = run(
|
||||
&s,
|
||||
&FilterParams::LightingEffects {
|
||||
lights: vec![light],
|
||||
gloss: -100.0,
|
||||
metallic: 0.0,
|
||||
exposure: 0.0,
|
||||
ambience: 0.0,
|
||||
texture: TextureChannel::None,
|
||||
height: 0.0,
|
||||
white_is_high: true,
|
||||
},
|
||||
);
|
||||
assert!(out.pixel(4, 15)[0] > out.pixel(36, 15)[0] + 0.1, "{:?} {:?}", out.pixel(4, 15), out.pixel(36, 15));
|
||||
// A spot light does not reach outside its cone.
|
||||
let spot = Light { kind: LightKind::Spot, x: 0.5, y: 0.5, z: 0.3, target_x: 0.5, target_y: 0.5, cone: 20.0, ..Light::default() };
|
||||
let out = run(&s, &FilterParams::LightingEffects { lights: vec![spot], gloss: 0.0, metallic: 0.0, exposure: 0.0, ambience: 0.0, texture: TextureChannel::None, height: 0.0, white_is_high: true });
|
||||
let out = run(
|
||||
&s,
|
||||
&FilterParams::LightingEffects {
|
||||
lights: vec![spot],
|
||||
gloss: 0.0,
|
||||
metallic: 0.0,
|
||||
exposure: 0.0,
|
||||
ambience: 0.0,
|
||||
texture: TextureChannel::None,
|
||||
height: 0.0,
|
||||
white_is_high: true,
|
||||
},
|
||||
);
|
||||
assert!(out.pixel(20, 15)[0] > 0.3 && out.pixel(1, 1)[0] < 1e-4, "{:?} {:?}", out.pixel(20, 15), out.pixel(1, 1));
|
||||
}
|
||||
|
||||
fn variance(s: &Surface, r: Rect, c: usize) -> f32 {
|
||||
let v: Vec<f32> = s.read_region(r).chunks_exact(4).map(|p| p[c]).collect();
|
||||
let v: Vec<f32> = s.read_region(r).as_chunks::<4>().0.iter().map(|p| p[c]).collect();
|
||||
let m = v.iter().sum::<f32>() / v.len() as f32;
|
||||
v.iter().map(|x| (x - m).powi(2)).sum::<f32>() / v.len() as f32
|
||||
}
|
||||
|
|
@ -413,7 +476,10 @@ fn noisy(st: SampleType) -> Surface {
|
|||
fn reduce_noise_lowers_noise() {
|
||||
let s = noisy(SampleType::F32);
|
||||
let inner = Rect::new(8, 8, 32, 22);
|
||||
let out = run(&s, &FilterParams::ReduceNoise { strength: 10.0, preserve_details: 0.0, reduce_color_noise: 100.0, sharpen_details: 0.0, remove_jpeg_artifact: false });
|
||||
let out = run(
|
||||
&s,
|
||||
&FilterParams::ReduceNoise { strength: 10.0, preserve_details: 0.0, reduce_color_noise: 100.0, sharpen_details: 0.0, remove_jpeg_artifact: false },
|
||||
);
|
||||
for c in 0..3 {
|
||||
assert!(variance(&out, inner, c) < variance(&s, inner, c) * 0.5, "channel {c}");
|
||||
}
|
||||
|
|
@ -459,7 +525,7 @@ fn shaped_blurs_spread_a_point_into_the_shape() {
|
|||
},
|
||||
);
|
||||
// A triangle iris pointing up: the bokeh reaches farther below the point than above it... and specular boost brightens.
|
||||
let sum: f32 = lb.read_region(R).chunks_exact(4).map(|p| p[0]).sum();
|
||||
let sum: f32 = lb.read_region(R).as_chunks::<4>().0.iter().map(|p| p[0]).sum();
|
||||
assert!(sum > 1.5, "highlight bloomed: {sum}");
|
||||
assert!(lb.pixel(20, 15 + 5)[0] != lb.pixel(20, 15 - 5)[0], "triangular, not circular");
|
||||
}
|
||||
|
|
@ -572,7 +638,10 @@ fn ntsc_limits_saturated_colours_only() {
|
|||
fn displace_shifts_by_map_and_shear_by_curve() {
|
||||
let s = pattern(SampleType::F32, R);
|
||||
let v = 0.5 + 3.0 / 64.0;
|
||||
let out = run(&s, &FilterParams::Displace { horizontal: 25.0, vertical: 0.0, stretch: false, undefined: UndefinedAreas::Wrap, map: Some(map_image([v, 0.5, 0.5, 1.0])) });
|
||||
let out = run(
|
||||
&s,
|
||||
&FilterParams::Displace { horizontal: 25.0, vertical: 0.0, stretch: false, undefined: UndefinedAreas::Wrap, map: Some(map_image([v, 0.5, 0.5, 1.0])) },
|
||||
);
|
||||
for x in 0..30 {
|
||||
assert_eq!(out.pixel(x, 7), s.pixel(x + 3, 7), "x={x}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fxutil::gauss_blur_n;
|
||||
use crate::photo_util::{par_map, par_rows};
|
||||
use crate::other2::{hsl_to_rgb, rgb_to_hsl};
|
||||
use crate::photo_util::{par_map, par_rows};
|
||||
|
||||
fn luma(c: [f32; 3]) -> f32 {
|
||||
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
|
||||
|
|
@ -73,16 +73,9 @@ pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHi
|
|||
return;
|
||||
}
|
||||
let ls = blurred_luma(px, w, h, p.shadow_radius);
|
||||
let lh = if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 {
|
||||
ls.clone()
|
||||
} else {
|
||||
blurred_luma(px, w, h, p.highlight_radius)
|
||||
};
|
||||
let lh = if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 { ls.clone() } else { blurred_luma(px, w, h, p.highlight_radius) };
|
||||
let (sa, ha) = (p.shadow_amount / 100.0, p.highlight_amount / 100.0);
|
||||
let (st, ht) = (
|
||||
(p.shadow_tone / 100.0).max(0.01),
|
||||
(p.highlight_tone / 100.0).max(0.01),
|
||||
);
|
||||
let (st, ht) = ((p.shadow_tone / 100.0).max(0.01), (p.highlight_tone / 100.0).max(0.01));
|
||||
let color = p.color / 100.0;
|
||||
let mid = p.midtone / 100.0;
|
||||
par_rows(px, w, 1, |y, pxrow| {
|
||||
|
|
@ -164,14 +157,7 @@ fn clip_stretch(px: &mut [[f32; 4]], black: f32, white: f32) {
|
|||
/// Image › Adjustments › Replace Color: Color Range-style selection (fuzziness in 0..=200 levels)
|
||||
/// around `color`, then a hue (±180°), saturation and lightness (±100) shift weighted by it.
|
||||
/// Returns the selection mask (what the dialog previews).
|
||||
pub fn replace_color(
|
||||
px: &mut [[f32; 4]],
|
||||
color: [f32; 3],
|
||||
fuzziness: f32,
|
||||
hue: f32,
|
||||
saturation: f32,
|
||||
lightness: f32,
|
||||
) -> Vec<f32> {
|
||||
pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: f32, saturation: f32, lightness: f32) -> Vec<f32> {
|
||||
let mask = crate::selection::color_range(px, color, fuzziness);
|
||||
for (q, &k) in px.iter_mut().zip(&mask) {
|
||||
if k <= 0.0 {
|
||||
|
|
@ -179,21 +165,9 @@ pub fn replace_color(
|
|||
}
|
||||
let c = [q[0], q[1], q[2]];
|
||||
let [hh, ss, ll] = rgb_to_hsl(c);
|
||||
let s = if saturation >= 0.0 {
|
||||
ss + (1.0 - ss) * saturation / 100.0
|
||||
} else {
|
||||
ss * (1.0 + saturation / 100.0)
|
||||
};
|
||||
let l = if lightness >= 0.0 {
|
||||
ll + (1.0 - ll) * lightness / 100.0
|
||||
} else {
|
||||
ll * (1.0 + lightness / 100.0)
|
||||
};
|
||||
let o = hsl_to_rgb([
|
||||
(hh + hue / 360.0).rem_euclid(1.0),
|
||||
s.clamp(0.0, 1.0),
|
||||
l.clamp(0.0, 1.0),
|
||||
]);
|
||||
let s = if saturation >= 0.0 { ss + (1.0 - ss) * saturation / 100.0 } else { ss * (1.0 + saturation / 100.0) };
|
||||
let l = if lightness >= 0.0 { ll + (1.0 - ll) * lightness / 100.0 } else { ll * (1.0 + lightness / 100.0) };
|
||||
let o = hsl_to_rgb([(hh + hue / 360.0).rem_euclid(1.0), s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)]);
|
||||
for i in 0..3 {
|
||||
q[i] = (c[i] + (o[i] - c[i]) * k).clamp(0.0, 1.0);
|
||||
}
|
||||
|
|
@ -229,8 +203,7 @@ pub fn lab_stats(px: &[[f32; 4]], mask: Option<&[f32]>) -> Option<LabStats> {
|
|||
return None;
|
||||
}
|
||||
let mean: [f32; 3] = std::array::from_fn(|k| (sum[k] / wsum) as f32);
|
||||
let std =
|
||||
std::array::from_fn(|k| ((sq[k] / wsum - (sum[k] / wsum).powi(2)).max(0.0).sqrt()) as f32);
|
||||
let std = std::array::from_fn(|k| ((sq[k] / wsum - (sum[k] / wsum).powi(2)).max(0.0).sqrt()) as f32);
|
||||
Some(LabStats { mean, std })
|
||||
}
|
||||
|
||||
|
|
@ -249,34 +222,20 @@ pub struct MatchColor {
|
|||
|
||||
impl Default for MatchColor {
|
||||
fn default() -> Self {
|
||||
MatchColor {
|
||||
luminance: 100.0,
|
||||
intensity: 100.0,
|
||||
fade: 0.0,
|
||||
neutralize: false,
|
||||
}
|
||||
MatchColor { luminance: 100.0, intensity: 100.0, fade: 0.0, neutralize: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Reinhard-style colour transfer in Lab: shift and scale each channel of `px` (statistics
|
||||
/// `target`) toward `source`'s statistics; with no source only Neutralize / intensity / luminance
|
||||
/// act on the image's own statistics.
|
||||
pub fn match_color(
|
||||
px: &mut [[f32; 4]],
|
||||
target: &LabStats,
|
||||
source: Option<&LabStats>,
|
||||
o: &MatchColor,
|
||||
) {
|
||||
pub fn match_color(px: &mut [[f32; 4]], target: &LabStats, source: Option<&LabStats>, o: &MatchColor) {
|
||||
let src = source.copied().unwrap_or(*target);
|
||||
let lum = o.luminance / 100.0;
|
||||
let inten = o.intensity / 100.0;
|
||||
let fade = (o.fade / 100.0).clamp(0.0, 1.0);
|
||||
let scale = |k: usize| {
|
||||
if target.std[k] > 1e-4 {
|
||||
src.std[k] / target.std[k]
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
if target.std[k] > 1e-4 { src.std[k] / target.std[k] } else { 1.0 }
|
||||
};
|
||||
let mut dst_mean = src.mean;
|
||||
if o.neutralize {
|
||||
|
|
@ -367,13 +326,7 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) {
|
|||
let mut base = logl.clone();
|
||||
gauss_blur_n(&mut base, w, h, 1, p.radius.max(1.0) / 2.0);
|
||||
let n = px.iter().filter(|q| q[3] > 0.0).count().max(1) as f32;
|
||||
let mean = base
|
||||
.iter()
|
||||
.zip(px.iter())
|
||||
.filter(|(_, q)| q[3] > 0.0)
|
||||
.map(|(b, _)| *b)
|
||||
.sum::<f32>()
|
||||
/ n;
|
||||
let mean = base.iter().zip(px.iter()).filter(|(_, q)| q[3] > 0.0).map(|(b, _)| *b).sum::<f32>() / n;
|
||||
let compress = 1.0 / (1.0 + p.strength.max(0.0));
|
||||
let detail = 1.0 + p.detail / 100.0;
|
||||
let exposure = 2f32.powf(p.exposure);
|
||||
|
|
@ -398,10 +351,7 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) {
|
|||
// Saturation then vibrance (less saturated colours move more), around the new luminance.
|
||||
let ll = luma(out);
|
||||
let sat_now = {
|
||||
let (mx, mn) = (
|
||||
out[0].max(out[1]).max(out[2]),
|
||||
out[0].min(out[1]).min(out[2]),
|
||||
);
|
||||
let (mx, mn) = (out[0].max(out[1]).max(out[2]), out[0].min(out[1]).min(out[2]));
|
||||
if mx > 0.0 { (mx - mn) / mx } else { 0.0 }
|
||||
};
|
||||
let s = 1.0 + p.saturation / 100.0 + p.vibrance / 100.0 * (1.0 - sat_now);
|
||||
|
|
@ -430,31 +380,13 @@ mod tests {
|
|||
fn shadows_lift_dark_pixels_more_than_bright() {
|
||||
let mut px = ramp(32, 8);
|
||||
let orig = px.clone();
|
||||
shadows_highlights(
|
||||
&mut px,
|
||||
32,
|
||||
8,
|
||||
&ShadowsHighlights {
|
||||
black_clip: 0.0,
|
||||
white_clip: 0.0,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let gain = |i: usize| {
|
||||
luma([px[i][0], px[i][1], px[i][2]]) - luma([orig[i][0], orig[i][1], orig[i][2]])
|
||||
};
|
||||
shadows_highlights(&mut px, 32, 8, &ShadowsHighlights { black_clip: 0.0, white_clip: 0.0, ..Default::default() });
|
||||
let gain = |i: usize| luma([px[i][0], px[i][1], px[i][2]]) - luma([orig[i][0], orig[i][1], orig[i][2]]);
|
||||
assert!(gain(3) > 0.02, "{}", gain(3));
|
||||
assert!(gain(3) > gain(30), "{} {}", gain(3), gain(30));
|
||||
// Zero amounts and no clipping: identity.
|
||||
let mut px2 = orig.clone();
|
||||
let id = ShadowsHighlights {
|
||||
shadow_amount: 0.0,
|
||||
highlight_amount: 0.0,
|
||||
color: 0.0,
|
||||
black_clip: 0.0,
|
||||
white_clip: 0.0,
|
||||
..Default::default()
|
||||
};
|
||||
let id = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 0.0, color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
|
||||
shadows_highlights(&mut px2, 32, 8, &id);
|
||||
assert_eq!(px2, orig);
|
||||
}
|
||||
|
|
@ -463,13 +395,7 @@ mod tests {
|
|||
fn highlights_darken_bright_pixels() {
|
||||
let mut px = ramp(32, 8);
|
||||
let orig = px.clone();
|
||||
let p = ShadowsHighlights {
|
||||
shadow_amount: 0.0,
|
||||
highlight_amount: 80.0,
|
||||
black_clip: 0.0,
|
||||
white_clip: 0.0,
|
||||
..Default::default()
|
||||
};
|
||||
let p = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 80.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
|
||||
shadows_highlights(&mut px, 32, 8, &p);
|
||||
assert!(px[30][0] < orig[30][0]);
|
||||
assert!((px[1][0] - orig[1][0]).abs() < 1e-6);
|
||||
|
|
@ -486,36 +412,19 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn match_color_moves_statistics_toward_source() {
|
||||
let src: Vec<[f32; 4]> = (0..64)
|
||||
.map(|i| [0.8, 0.4 + (i % 8) as f32 * 0.02, 0.2, 1.0])
|
||||
.collect();
|
||||
let mut tgt: Vec<[f32; 4]> = (0..64)
|
||||
.map(|i| [0.2, 0.3, 0.6 + (i % 8) as f32 * 0.03, 1.0])
|
||||
.collect();
|
||||
let src: Vec<[f32; 4]> = (0..64).map(|i| [0.8, 0.4 + (i % 8) as f32 * 0.02, 0.2, 1.0]).collect();
|
||||
let mut tgt: Vec<[f32; 4]> = (0..64).map(|i| [0.2, 0.3, 0.6 + (i % 8) as f32 * 0.03, 1.0]).collect();
|
||||
let s = lab_stats(&src, None).unwrap();
|
||||
let t = lab_stats(&tgt, None).unwrap();
|
||||
match_color(&mut tgt, &t, Some(&s), &MatchColor::default());
|
||||
let after = lab_stats(&tgt, None).unwrap();
|
||||
for k in 0..3 {
|
||||
assert!(
|
||||
(after.mean[k] - s.mean[k]).abs() < (t.mean[k] - s.mean[k]).abs() * 0.2 + 1.0,
|
||||
"{k}: {after:?} vs {s:?}"
|
||||
);
|
||||
assert!((after.mean[k] - s.mean[k]).abs() < (t.mean[k] - s.mean[k]).abs() * 0.2 + 1.0, "{k}: {after:?} vs {s:?}");
|
||||
}
|
||||
// Neutralize with no source: the mean colour goes gray.
|
||||
let mut tint: Vec<[f32; 4]> = (0..16)
|
||||
.map(|i| [0.7, 0.5, 0.3 + i as f32 * 0.01, 1.0])
|
||||
.collect();
|
||||
let mut tint: Vec<[f32; 4]> = (0..16).map(|i| [0.7, 0.5, 0.3 + i as f32 * 0.01, 1.0]).collect();
|
||||
let t = lab_stats(&tint, None).unwrap();
|
||||
match_color(
|
||||
&mut tint,
|
||||
&t,
|
||||
None,
|
||||
&MatchColor {
|
||||
neutralize: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
match_color(&mut tint, &t, None, &MatchColor { neutralize: true, ..Default::default() });
|
||||
let n = lab_stats(&tint, None).unwrap();
|
||||
assert!(n.mean[1].abs() < 2.0 && n.mean[2].abs() < 2.0, "{n:?}");
|
||||
}
|
||||
|
|
@ -523,38 +432,14 @@ mod tests {
|
|||
#[test]
|
||||
fn hdr_toning_compresses_range_and_keeps_bounds() {
|
||||
let mut px = ramp(40, 10);
|
||||
hdr_toning(
|
||||
&mut px,
|
||||
40,
|
||||
10,
|
||||
&HdrToning {
|
||||
strength: 2.0,
|
||||
detail: 0.0,
|
||||
saturation: 0.0,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
hdr_toning(&mut px, 40, 10, &HdrToning { strength: 2.0, detail: 0.0, saturation: 0.0, ..Default::default() });
|
||||
let l0 = luma([px[2][0], px[2][1], px[2][2]]);
|
||||
let l1 = luma([px[38][0], px[38][1], px[38][2]]);
|
||||
assert!(l1 > l0);
|
||||
assert!(
|
||||
px.iter()
|
||||
.all(|q| q[..3].iter().all(|v| (0.0..=1.0).contains(v)))
|
||||
);
|
||||
assert!(px.iter().all(|q| q[..3].iter().all(|v| (0.0..=1.0).contains(v))));
|
||||
// A toning curve that inverts luminance flips the order.
|
||||
let mut px = ramp(40, 10);
|
||||
hdr_toning(
|
||||
&mut px,
|
||||
40,
|
||||
10,
|
||||
&HdrToning {
|
||||
strength: 0.0,
|
||||
detail: 0.0,
|
||||
saturation: 0.0,
|
||||
curve: vec![(0.0, 1.0), (1.0, 0.0)],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
hdr_toning(&mut px, 40, 10, &HdrToning { strength: 0.0, detail: 0.0, saturation: 0.0, curve: vec![(0.0, 1.0), (1.0, 0.0)], ..Default::default() });
|
||||
assert!(luma([px[2][0], px[2][1], px[2][2]]) > luma([px[38][0], px[38][1], px[38][2]]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,12 +120,7 @@ impl Interp {
|
|||
fn catmull_rom(t: f64) -> [f64; 4] {
|
||||
let t2 = t * t;
|
||||
let t3 = t2 * t;
|
||||
[
|
||||
-0.5 * t3 + t2 - 0.5 * t,
|
||||
1.5 * t3 - 2.5 * t2 + 1.0,
|
||||
-1.5 * t3 + 2.0 * t2 + 0.5 * t,
|
||||
0.5 * t3 - 0.5 * t2,
|
||||
]
|
||||
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
|
||||
}
|
||||
|
||||
/// Warp `src` (its content inside `src_rect`) by `h` (source → destination document space).
|
||||
|
|
@ -160,7 +155,8 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
|
|||
}
|
||||
let Some(inv) = h.inverse() else { return out };
|
||||
// Destination bounds: the warped corners (plus a pixel for filter support).
|
||||
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)].map(|(x, y)| h.apply(x as f64, y as f64));
|
||||
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
|
||||
.map(|(x, y)| h.apply(x as f64, y as f64));
|
||||
if corners.iter().any(|c| !c.0.is_finite() || !c.1.is_finite()) {
|
||||
return out;
|
||||
}
|
||||
|
|
@ -314,7 +310,12 @@ mod tests {
|
|||
let mut s = rgba();
|
||||
s.fill_rect(Rect::new(0, 0, 40, 20), &[0.2, 0.4, 0.6, 1.0]);
|
||||
let r = s.content_bounds();
|
||||
let up = warp_surface(&s, r, &Homography::rect_to_quad([0.0, 0.0, 40.0, 20.0], [[0.0, 0.0], [80.0, 0.0], [80.0, 40.0], [0.0, 40.0]]).unwrap(), Interp::Bicubic);
|
||||
let up = warp_surface(
|
||||
&s,
|
||||
r,
|
||||
&Homography::rect_to_quad([0.0, 0.0, 40.0, 20.0], [[0.0, 0.0], [80.0, 0.0], [80.0, 40.0], [0.0, 40.0]]).unwrap(),
|
||||
Interp::Bicubic,
|
||||
);
|
||||
let b = up.content_bounds();
|
||||
assert!(b.width() >= 80 && b.width() <= 82 && b.height() >= 40 && b.height() <= 42, "{b:?}");
|
||||
// 90° rotation about (20, 10): a 40×20 box becomes 20×40.
|
||||
|
|
|
|||
|
|
@ -99,11 +99,7 @@ impl VpPlane {
|
|||
// Vanishing directions (or the edge directions when they're parallel in the image).
|
||||
let dir = |a: [f64; 2], b: [f64; 2], cc: [f64; 2], d: [f64; 2]| -> V3 {
|
||||
let v = meet(a, b, cc, d);
|
||||
if v[2].abs() < 1e-9 * (v[0].abs() + v[1].abs()).max(1.0) {
|
||||
norm([v[0], v[1], 0.0])
|
||||
} else {
|
||||
norm(ray([v[0] / v[2], v[1] / v[2]]))
|
||||
}
|
||||
if v[2].abs() < 1e-9 * (v[0].abs() + v[1].abs()).max(1.0) { norm([v[0], v[1], 0.0]) } else { norm(ray([v[0] / v[2], v[1] / v[2]])) }
|
||||
};
|
||||
let d1 = dir(q[0], q[1], q[3], q[2]);
|
||||
let d2 = dir(q[0], q[3], q[1], q[2]);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
use photocraft_color::ColorMode;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::Ctx;
|
||||
use crate::fxutil::{MAXC, rgba, set_rgba, xy};
|
||||
use crate::image::Image;
|
||||
use crate::Ctx;
|
||||
|
||||
/// De-Interlace: removes one field (odd = lines 1, 3, 5… counted from the
|
||||
/// bounds' top, i.e. even offsets) and rebuilds it from the neighbouring
|
||||
|
|
|
|||
|
|
@ -181,7 +181,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
|||
}
|
||||
let fw = foot.width() as usize;
|
||||
let at = |x: i32, y: i32, c: usize| -> f64 {
|
||||
if x < foot.x0 || y < foot.y0 || x >= foot.x1 || y >= foot.y1 { 0.0 } else { f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c]) }
|
||||
if x < foot.x0 || y < foot.y0 || x >= foot.x1 || y >= foot.y1 {
|
||||
0.0
|
||||
} else {
|
||||
f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c])
|
||||
}
|
||||
};
|
||||
let mut outp = vec![0.0f32; w * h * n];
|
||||
let mut any = false;
|
||||
|
|
@ -279,7 +283,7 @@ pub fn warp_mesh_gray(s: &Surface, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync),
|
|||
let b = w.content_bounds();
|
||||
if !b.is_empty() {
|
||||
let px = w.read_region(b);
|
||||
let flat: Vec<f32> = px.chunks_exact(2).map(|p| p[0] * p[1] + default * (1.0 - p[1])).collect();
|
||||
let flat: Vec<f32> = px.as_chunks::<2>().0.iter().map(|p| p[0] * p[1] + default * (1.0 - p[1])).collect();
|
||||
out.write_region(b, &flat);
|
||||
}
|
||||
out.prune();
|
||||
|
|
|
|||
|
|
@ -409,7 +409,10 @@ mod tests {
|
|||
let p = WideAngle {
|
||||
model: WideModel::Fisheye,
|
||||
focal_length: 8.0,
|
||||
constraints: vec![Constraint { a: [60.0, 70.0], b: [420.0, 70.0], orientation: Orientation::Horizontal }, Constraint { a: [80.0, 60.0], b: [80.0, 260.0], orientation: Orientation::Free }],
|
||||
constraints: vec![
|
||||
Constraint { a: [60.0, 70.0], b: [420.0, 70.0], orientation: Orientation::Horizontal },
|
||||
Constraint { a: [80.0, 60.0], b: [80.0, 260.0], orientation: Orientation::Free },
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let cam = Camera::new(&p, frame);
|
||||
|
|
@ -453,11 +456,30 @@ mod tile_tests {
|
|||
let frame = Rect::new(0, 0, 772, 517);
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(frame, &[0.2, 0.4, 0.6, 1.0]);
|
||||
for p in [WideAngle::default(), WideAngle { model: WideModel::Fisheye, focal_length: 12.0, constraints: vec![Constraint { a: [20.0, 30.0], b: [700.0, 35.0], orientation: Orientation::Horizontal }], ..Default::default() }] {
|
||||
for p in [
|
||||
WideAngle::default(),
|
||||
WideAngle {
|
||||
model: WideModel::Fisheye,
|
||||
focal_length: 12.0,
|
||||
constraints: vec![Constraint { a: [20.0, 30.0], b: [700.0, 35.0], orientation: Orientation::Horizontal }],
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
let out = apply(&s, frame, &p, Interp::Bilinear);
|
||||
let holes: Vec<(i32, i32)> = (80..440).flat_map(|y| (80..690).map(move |x| (x, y))).filter(|&(x, y)| out.rgba(x, y)[3] < 0.5).collect();
|
||||
let m = solve(&p, frame);
|
||||
assert!(holes.is_empty(), "{} holes, first {:?} last {:?}; mesh {}x{} v0 {:?} vlast {:?} bounds {:?}", holes.len(), holes.first(), holes.last(), m.nx, m.ny, m.verts[0], m.verts.last(), out.content_bounds());
|
||||
assert!(
|
||||
holes.is_empty(),
|
||||
"{} holes, first {:?} last {:?}; mesh {}x{} v0 {:?} vlast {:?} bounds {:?}",
|
||||
holes.len(),
|
||||
holes.first(),
|
||||
holes.last(),
|
||||
m.nx,
|
||||
m.ny,
|
||||
m.verts[0],
|
||||
m.verts.last(),
|
||||
out.content_bounds()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,7 @@ use tokio::sync::Mutex;
|
|||
|
||||
use crate::AutomationError;
|
||||
|
||||
type Conn = (
|
||||
BufReader<tokio::net::tcp::OwnedReadHalf>,
|
||||
tokio::net::tcp::OwnedWriteHalf,
|
||||
);
|
||||
type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWriteHalf);
|
||||
|
||||
pub struct BridgeClient {
|
||||
addr: String,
|
||||
|
|
@ -31,16 +28,9 @@ impl BridgeClient {
|
|||
let addr = addr.into();
|
||||
let host = addr.rsplit_once(':').map(|(h, _)| h).unwrap_or(&addr);
|
||||
if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
|
||||
return Err(AutomationError::BadRequest(format!(
|
||||
"bridge address must be loopback, got `{addr}`"
|
||||
)));
|
||||
return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`")));
|
||||
}
|
||||
Ok(BridgeClient {
|
||||
addr,
|
||||
conn: Mutex::new(None),
|
||||
next_id: AtomicU64::new(1),
|
||||
timeout: Duration::from_secs(60),
|
||||
})
|
||||
Ok(BridgeClient { addr, conn: Mutex::new(None), next_id: AtomicU64::new(1), timeout: Duration::from_secs(60) })
|
||||
}
|
||||
|
||||
pub fn with_timeout(mut self, t: Duration) -> Self {
|
||||
|
|
@ -61,12 +51,7 @@ impl BridgeClient {
|
|||
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|e| {
|
||||
AutomationError::Bridge(format!(
|
||||
"cannot connect to {} ({e}); start the app with `photocraft --control <port>`",
|
||||
self.addr
|
||||
))
|
||||
})?;
|
||||
.map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `photocraft --control <port>`", self.addr)))?;
|
||||
let (r, w) = s.into_split();
|
||||
*guard = Some((BufReader::new(r), w));
|
||||
}
|
||||
|
|
@ -84,10 +69,7 @@ impl BridgeClient {
|
|||
}
|
||||
Err(_) => {
|
||||
*guard = None;
|
||||
return Err(AutomationError::Bridge(format!(
|
||||
"`{method}` timed out after {:?}",
|
||||
self.timeout
|
||||
)));
|
||||
return Err(AutomationError::Bridge(format!("`{method}` timed out after {:?}", self.timeout)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -96,14 +78,8 @@ impl BridgeClient {
|
|||
}
|
||||
|
||||
/// Outer `Err` = transport failure (retryable); inner = app-level result.
|
||||
async fn exchange(
|
||||
conn: &mut Conn,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: &Value,
|
||||
) -> Result<Result<Value, AutomationError>, AutomationError> {
|
||||
let mut line = serde_json::to_string(&json!({"id": id, "method": method, "params": params}))
|
||||
.map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Result<Result<Value, AutomationError>, AutomationError> {
|
||||
let mut line = serde_json::to_string(&json!({"id": id, "method": method, "params": params})).map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
line.push('\n');
|
||||
let io = |e: std::io::Error| AutomationError::Bridge(e.to_string());
|
||||
conn.1.write_all(line.as_bytes()).await.map_err(io)?;
|
||||
|
|
@ -113,9 +89,7 @@ async fn exchange(
|
|||
buf.clear();
|
||||
let n = conn.0.read_line(&mut buf).await.map_err(io)?;
|
||||
if n == 0 {
|
||||
return Err(AutomationError::Bridge(
|
||||
"connection closed by the app".into(),
|
||||
));
|
||||
return Err(AutomationError::Bridge("connection closed by the app".into()));
|
||||
}
|
||||
let Ok(v) = serde_json::from_str::<Value>(buf.trim()) else {
|
||||
continue;
|
||||
|
|
@ -126,12 +100,7 @@ async fn exchange(
|
|||
return Ok(if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(AutomationError::App(
|
||||
v.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown error")
|
||||
.to_owned(),
|
||||
))
|
||||
Err(AutomationError::App(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_owned()))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,30 +19,17 @@ pub struct Opened {
|
|||
/// natively; everything else goes through `photocraft-io` (PSD, PNG, …).
|
||||
pub fn open(path: &Path) -> Result<Opened, AutomationError> {
|
||||
if path.is_dir() {
|
||||
return Ok(Opened {
|
||||
document: photocraft_format::load_path(path)?,
|
||||
warnings: Vec::new(),
|
||||
});
|
||||
return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new() });
|
||||
}
|
||||
let bytes =
|
||||
std::fs::read(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let bytes = std::fs::read(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let r = photocraft_io::import(&name, &bytes)?;
|
||||
Ok(Opened {
|
||||
document: r.document,
|
||||
warnings: r.warnings,
|
||||
})
|
||||
Ok(Opened { document: r.document, warnings: r.warnings })
|
||||
}
|
||||
|
||||
/// Previews for a `.pcraft` bundle.
|
||||
pub fn previews(doc: &Document) -> SaveOptions {
|
||||
SaveOptions {
|
||||
thumbnail: Some(photocraft_compose::thumbnail(doc, 256)),
|
||||
composite: Some(photocraft_compose::thumbnail(doc, 1024)),
|
||||
}
|
||||
SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)) }
|
||||
}
|
||||
|
||||
/// Save or export `doc` to `path`, choosing the format from the extension
|
||||
|
|
@ -57,16 +44,8 @@ pub fn save(
|
|||
) -> Result<Vec<String>, AutomationError> {
|
||||
let ext = format_override
|
||||
.map(|f| f.trim_start_matches('.').to_ascii_lowercase())
|
||||
.or_else(|| {
|
||||
path.extension()
|
||||
.map(|e| e.to_string_lossy().to_ascii_lowercase())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
AutomationError::BadRequest(format!(
|
||||
"cannot tell the format of `{}`; pass a format",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
.or_else(|| path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()))
|
||||
.ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{}`; pass a format", path.display())))?;
|
||||
if ext == photocraft_format::EXTENSION {
|
||||
let mut local = PcraftWriter::new();
|
||||
let w = writer.unwrap_or(&mut local);
|
||||
|
|
@ -77,26 +56,15 @@ pub fn save(
|
|||
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(parent).map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||
}
|
||||
std::fs::write(path, &r.bytes)
|
||||
.map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
std::fs::write(path, &r.bytes).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
Ok(r.warnings)
|
||||
}
|
||||
|
||||
/// Flattened document as PNG, scaled to fit `max_side` (0 = full size).
|
||||
pub fn render_png(doc: &Document, max_side: u32) -> Result<Vec<u8>, AutomationError> {
|
||||
let side = if max_side == 0 {
|
||||
doc.size.width.max(doc.size.height)
|
||||
} else {
|
||||
max_side
|
||||
};
|
||||
let side = if max_side == 0 { doc.size.width.max(doc.size.height) } else { max_side };
|
||||
let img = photocraft_compose::thumbnail(doc, side.max(1));
|
||||
let image = photocraft_codecs::Image::from_u8(
|
||||
img.width,
|
||||
img.height,
|
||||
photocraft_codecs::ChannelLayout::Rgba,
|
||||
img.pixels,
|
||||
)
|
||||
let image = photocraft_codecs::Image::from_u8(img.width, img.height, photocraft_codecs::ChannelLayout::Rgba, img.pixels)
|
||||
.map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default())
|
||||
.map_err(|e| AutomationError::Other(e.to_string()))
|
||||
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| AutomationError::Other(e.to_string()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,22 +27,15 @@ impl Headless {
|
|||
fn doc_index(&self, index: Option<usize>) -> Result<usize, AutomationError> {
|
||||
match index {
|
||||
Some(i) if i < self.session.documents().len() => Ok(i),
|
||||
Some(i) => Err(AutomationError::BadRequest(format!(
|
||||
"no document at index {i}"
|
||||
))),
|
||||
None => self
|
||||
.session
|
||||
.active_index()
|
||||
.ok_or_else(|| AutomationError::BadRequest("no document open".into())),
|
||||
Some(i) => Err(AutomationError::BadRequest(format!("no document at index {i}"))),
|
||||
None => self.session.active_index().ok_or_else(|| AutomationError::BadRequest("no document open".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a file and make it the active document.
|
||||
pub fn open(&mut self, path: &Path) -> Result<Value, AutomationError> {
|
||||
let o = files::open(path)?;
|
||||
let index = self
|
||||
.session
|
||||
.add_document(o.document, Some(path.to_string_lossy().into_owned()));
|
||||
let index = self.session.add_document(o.document, Some(path.to_string_lossy().into_owned()));
|
||||
let d = &self.session.documents()[index];
|
||||
Ok(json!({
|
||||
"index": index,
|
||||
|
|
@ -56,22 +49,14 @@ impl Headless {
|
|||
|
||||
/// Save (`.pcraft` or any export format by extension). With no path,
|
||||
/// saves to the document's own path.
|
||||
pub fn save(
|
||||
&mut self,
|
||||
index: Option<usize>,
|
||||
path: Option<&Path>,
|
||||
format: Option<&str>,
|
||||
opts: &ExportOptions,
|
||||
) -> Result<Value, AutomationError> {
|
||||
pub fn save(&mut self, index: Option<usize>, path: Option<&Path>, format: Option<&str>, opts: &ExportOptions) -> Result<Value, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
let st = &self.session.documents()[i];
|
||||
let target: PathBuf = match (path, &st.path) {
|
||||
(Some(p), _) => p.to_path_buf(),
|
||||
(None, Some(p)) => PathBuf::from(p),
|
||||
(None, None) => {
|
||||
return Err(AutomationError::BadRequest(
|
||||
"document has no path; pass `path`".into(),
|
||||
));
|
||||
return Err(AutomationError::BadRequest("document has no path; pass `path`".into()));
|
||||
}
|
||||
};
|
||||
let doc = st.doc.clone();
|
||||
|
|
@ -79,11 +64,7 @@ impl Headless {
|
|||
let warnings = files::save(&doc, &target, format, opts, Some(writer))?;
|
||||
let is_native = format
|
||||
.map(|f| f.trim_start_matches('.').eq_ignore_ascii_case("pcraft"))
|
||||
.unwrap_or_else(|| {
|
||||
target
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("pcraft"))
|
||||
});
|
||||
.unwrap_or_else(|| target.extension().is_some_and(|e| e.eq_ignore_ascii_case("pcraft")));
|
||||
if is_native {
|
||||
self.session.set_active(i);
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
|
|
@ -96,16 +77,10 @@ impl Headless {
|
|||
|
||||
pub fn inspect(&self, index: Option<usize>) -> Result<Value, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
Ok(photocraft_engine::inspect::document(
|
||||
&self.session.documents()[i],
|
||||
))
|
||||
Ok(photocraft_engine::inspect::document(&self.session.documents()[i]))
|
||||
}
|
||||
|
||||
pub fn render_png(
|
||||
&self,
|
||||
index: Option<usize>,
|
||||
max_side: u32,
|
||||
) -> Result<Vec<u8>, AutomationError> {
|
||||
pub fn render_png(&self, index: Option<usize>, max_side: u32) -> Result<Vec<u8>, AutomationError> {
|
||||
let i = self.doc_index(index)?;
|
||||
files::render_png(&self.session.documents()[i].doc, max_side)
|
||||
}
|
||||
|
|
@ -115,13 +90,7 @@ impl Headless {
|
|||
}
|
||||
|
||||
pub fn select(&mut self, index: usize) -> Result<Value, AutomationError> {
|
||||
if self.session.set_active(index) {
|
||||
Ok(self.session_list())
|
||||
} else {
|
||||
Err(AutomationError::BadRequest(format!(
|
||||
"no document at index {index}"
|
||||
)))
|
||||
}
|
||||
if self.session.set_active(index) { Ok(self.session_list()) } else { Err(AutomationError::BadRequest(format!("no document at index {index}"))) }
|
||||
}
|
||||
|
||||
pub fn close(&mut self, index: Option<usize>) -> Result<Value, AutomationError> {
|
||||
|
|
|
|||
|
|
@ -57,8 +57,7 @@ impl Headless {
|
|||
let p = if params.is_null() { json!({}) } else { params };
|
||||
match method {
|
||||
"engine.execute" => {
|
||||
let id =
|
||||
str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?;
|
||||
let id = str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?;
|
||||
self.command_run(id, p.get("params").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
"engine.commands" => {
|
||||
|
|
@ -70,11 +69,7 @@ impl Headless {
|
|||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| {
|
||||
let hay = format!(
|
||||
"{} {}",
|
||||
c["id"].as_str().unwrap_or(""),
|
||||
c["label"].as_str().unwrap_or("")
|
||||
);
|
||||
let hay = format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""));
|
||||
hay.to_lowercase().contains(&n)
|
||||
})
|
||||
.cloned()
|
||||
|
|
@ -102,8 +97,7 @@ impl Headless {
|
|||
let png = self.render_png(index_of(&p), max)?;
|
||||
match str_of(&p, "path") {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &png)
|
||||
.map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||
std::fs::write(path, &png).map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||
Ok(json!({"path": path, "bytes": png.len()}))
|
||||
}
|
||||
None => Ok(json!({
|
||||
|
|
@ -127,14 +121,8 @@ impl Headless {
|
|||
/// `{method, params?}` (any [`METHODS`] entry). Stops at the first error unless
|
||||
/// `stopOnError` is false; the reply lists every step's result.
|
||||
pub fn batch(&mut self, p: &Value) -> Result<Value, AutomationError> {
|
||||
let steps = p
|
||||
.get("steps")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| bad("batch needs `steps`"))?;
|
||||
let stop = p
|
||||
.get("stopOnError")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("batch needs `steps`"))?;
|
||||
let stop = p.get("stopOnError").and_then(Value::as_bool).unwrap_or(true);
|
||||
let mut results = Vec::with_capacity(steps.len());
|
||||
let mut failed = 0usize;
|
||||
for (i, s) in steps.iter().enumerate() {
|
||||
|
|
@ -142,11 +130,7 @@ impl Headless {
|
|||
let r = if let Some(c) = str_of(s, "command") {
|
||||
self.command_run(c, params)
|
||||
} else if let Some(m) = str_of(s, "method") {
|
||||
if m == "batch" {
|
||||
Err(bad("nested batch"))
|
||||
} else {
|
||||
self.handle(m, params)
|
||||
}
|
||||
if m == "batch" { Err(bad("nested batch")) } else { self.handle(m, params) }
|
||||
} else {
|
||||
Err(bad(format!("step {i} needs `command` or `method`")))
|
||||
};
|
||||
|
|
@ -202,16 +186,9 @@ pub fn serve_lines(h: &Mutex<Headless>, r: impl BufRead, mut w: impl Write) -> s
|
|||
|
||||
/// Serve on a loopback TCP address (one thread per connection, one shared session).
|
||||
/// Refuses non-loopback addresses.
|
||||
pub fn serve_tcp(
|
||||
addr: &str,
|
||||
h: Arc<Mutex<Headless>>,
|
||||
ready: impl FnOnce(std::net::SocketAddr),
|
||||
) -> Result<(), AutomationError> {
|
||||
let listener =
|
||||
TcpListener::bind(addr).map_err(|e| AutomationError::Io(format!("bind {addr}: {e}")))?;
|
||||
let local = listener
|
||||
.local_addr()
|
||||
.map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||
pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, ready: impl FnOnce(std::net::SocketAddr)) -> Result<(), AutomationError> {
|
||||
let listener = TcpListener::bind(addr).map_err(|e| AutomationError::Io(format!("bind {addr}: {e}")))?;
|
||||
let local = listener.local_addr().map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||
if !local.ip().is_loopback() {
|
||||
return Err(bad(format!("{addr} is not a loopback address")));
|
||||
}
|
||||
|
|
@ -252,22 +229,13 @@ mod tests {
|
|||
);
|
||||
let mut out = Vec::new();
|
||||
serve_lines(&h, input.as_bytes(), &mut out).unwrap();
|
||||
let replies: Vec<Value> = String::from_utf8(out)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str(l).unwrap())
|
||||
.collect();
|
||||
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 5);
|
||||
assert!(replies[0]["ok"].as_bool().unwrap());
|
||||
assert_eq!(replies[1]["id"], 2);
|
||||
assert!(replies[2]["result"].to_string().contains("Ink"));
|
||||
assert!(!replies[3]["ok"].as_bool().unwrap());
|
||||
assert!(
|
||||
replies[3]["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("unknown method")
|
||||
);
|
||||
assert!(replies[3]["error"].as_str().unwrap().contains("unknown method"));
|
||||
assert_eq!(replies[4]["id"], Value::Null);
|
||||
}
|
||||
|
||||
|
|
@ -275,8 +243,7 @@ mod tests {
|
|||
fn batch_stops_on_error_unless_told_not_to() {
|
||||
let h = session();
|
||||
let mut g = h.lock().unwrap();
|
||||
g.handle("doc.new", json!({"width": 16, "height": 16}))
|
||||
.unwrap();
|
||||
g.handle("doc.new", json!({"width": 16, "height": 16})).unwrap();
|
||||
let steps = json!([
|
||||
{"command": "layer.new.layer", "params": {"name": "A"}},
|
||||
{"command": "no.such.command"},
|
||||
|
|
@ -287,9 +254,7 @@ mod tests {
|
|||
assert_eq!(r["completed"], 1);
|
||||
assert_eq!(r["failed"], 1);
|
||||
assert_eq!(r["results"].as_array().unwrap().len(), 2);
|
||||
let r = g
|
||||
.handle("batch", json!({"steps": steps, "stopOnError": false}))
|
||||
.unwrap();
|
||||
let r = g.handle("batch", json!({"steps": steps, "stopOnError": false})).unwrap();
|
||||
assert_eq!(r["completed"], 3);
|
||||
let insp = &r["results"][3]["result"];
|
||||
assert!(insp.to_string().contains("\"B\""));
|
||||
|
|
@ -299,31 +264,15 @@ mod tests {
|
|||
fn render_to_file_and_base64() {
|
||||
let h = session();
|
||||
let mut g = h.lock().unwrap();
|
||||
g.handle(
|
||||
"doc.new",
|
||||
json!({"width": 40, "height": 20, "background": "black"}),
|
||||
)
|
||||
.unwrap();
|
||||
g.handle("doc.new", json!({"width": 40, "height": 20, "background": "black"})).unwrap();
|
||||
let b = g.handle("doc.render", json!({"maxSide": 10})).unwrap();
|
||||
let png = base64::engine::general_purpose::STANDARD
|
||||
.decode(b["base64"].as_str().unwrap())
|
||||
.unwrap();
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(b["base64"].as_str().unwrap()).unwrap();
|
||||
let img = photocraft_codecs::decode(&png).unwrap();
|
||||
assert_eq!(img.dimensions(), (10, 5));
|
||||
let path = std::env::temp_dir().join(format!("pc-rpc-{}.png", std::process::id()));
|
||||
let r = g
|
||||
.handle(
|
||||
"doc.render",
|
||||
json!({"path": path.to_string_lossy(), "maxSide": 0}),
|
||||
)
|
||||
.unwrap();
|
||||
let r = g.handle("doc.render", json!({"path": path.to_string_lossy(), "maxSide": 0})).unwrap();
|
||||
assert!(r["bytes"].as_u64().unwrap() > 0);
|
||||
assert_eq!(
|
||||
photocraft_codecs::decode(&std::fs::read(&path).unwrap())
|
||||
.unwrap()
|
||||
.dimensions(),
|
||||
(40, 20)
|
||||
);
|
||||
assert_eq!(photocraft_codecs::decode(&std::fs::read(&path).unwrap()).unwrap().dimensions(), (40, 20));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
|
|
@ -339,9 +288,7 @@ mod tests {
|
|||
let mut s = std::net::TcpStream::connect(addr).unwrap();
|
||||
writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(s.try_clone().unwrap())
|
||||
.read_line(&mut line)
|
||||
.unwrap();
|
||||
BufReader::new(s.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["id"], "a");
|
||||
assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch"));
|
||||
|
|
|
|||
|
|
@ -158,9 +158,7 @@ pub struct ControlParams {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn ok_json(v: &Value) -> CallToolResult {
|
||||
CallToolResult::success(vec![Content::text(
|
||||
serde_json::to_string_pretty(v).unwrap_or_default(),
|
||||
)])
|
||||
CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(v).unwrap_or_default())])
|
||||
}
|
||||
|
||||
fn fail(e: impl std::fmt::Display) -> CallToolResult {
|
||||
|
|
@ -192,28 +190,17 @@ impl PhotocraftMcp {
|
|||
}
|
||||
|
||||
pub fn bridge(addr: &str) -> Result<Self, AutomationError> {
|
||||
Ok(Self::with_backend(Backend::Bridge(Arc::new(
|
||||
BridgeClient::new(addr)?,
|
||||
))))
|
||||
Ok(Self::with_backend(Backend::Bridge(Arc::new(BridgeClient::new(addr)?))))
|
||||
}
|
||||
|
||||
pub fn with_backend(backend: Backend) -> Self {
|
||||
PhotocraftMcp {
|
||||
backend: Arc::new(backend),
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
PhotocraftMcp { backend: Arc::new(backend), tool_router: Self::tool_router() }
|
||||
}
|
||||
|
||||
/// Serve MCP over stdin/stdout until the client disconnects.
|
||||
pub async fn serve_stdio(self) -> Result<(), AutomationError> {
|
||||
let running = self
|
||||
.serve(rmcp::transport::stdio())
|
||||
.await
|
||||
.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?;
|
||||
running
|
||||
.waiting()
|
||||
.await
|
||||
.map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
let running = self.serve(rmcp::transport::stdio()).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?;
|
||||
running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -246,17 +233,9 @@ impl PhotocraftMcp {
|
|||
}
|
||||
}
|
||||
|
||||
async fn screenshot(
|
||||
&self,
|
||||
b: &BridgeClient,
|
||||
max_side: Option<u32>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("photocraft-mcp-shot-{}.png", std::process::id()));
|
||||
if let Err(e) = b
|
||||
.call("ui.screenshot", json!({"path": path.to_string_lossy()}))
|
||||
.await
|
||||
{
|
||||
async fn screenshot(&self, b: &BridgeClient, max_side: Option<u32>) -> Result<CallToolResult, McpError> {
|
||||
let path = std::env::temp_dir().join(format!("photocraft-mcp-shot-{}.png", std::process::id()));
|
||||
if let Err(e) = b.call("ui.screenshot", json!({"path": path.to_string_lossy()})).await {
|
||||
return Ok(fail(e));
|
||||
}
|
||||
let bytes = match std::fs::read(&path) {
|
||||
|
|
@ -268,10 +247,7 @@ impl PhotocraftMcp {
|
|||
Some(m) if m > 0 => downscale_png(&bytes, m).unwrap_or(bytes),
|
||||
_ => bytes,
|
||||
};
|
||||
Ok(png_result(
|
||||
&bytes,
|
||||
"screenshot of the live app window".into(),
|
||||
))
|
||||
Ok(png_result(&bytes, "screenshot of the live app window".into()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -282,10 +258,7 @@ fn downscale_png(png: &[u8], max_side: u32) -> Option<Vec<u8>> {
|
|||
return None;
|
||||
}
|
||||
let s = max_side as f32 / w.max(h) as f32;
|
||||
let (nw, nh) = (
|
||||
((w as f32 * s) as u32).max(1),
|
||||
((h as f32 * s) as u32).max(1),
|
||||
);
|
||||
let (nw, nh) = (((w as f32 * s) as u32).max(1), ((h as f32 * s) as u32).max(1));
|
||||
let src = img.to_rgba8();
|
||||
let mut out = vec![0u8; (nw * nh * 4) as usize];
|
||||
for y in 0..nh {
|
||||
|
|
@ -297,9 +270,7 @@ fn downscale_png(png: &[u8], max_side: u32) -> Option<Vec<u8>> {
|
|||
out[di..di + 4].copy_from_slice(&src[si..si + 4]);
|
||||
}
|
||||
}
|
||||
let small =
|
||||
photocraft_codecs::Image::from_u8(nw, nh, photocraft_codecs::ChannelLayout::Rgba, out)
|
||||
.ok()?;
|
||||
let small = photocraft_codecs::Image::from_u8(nw, nh, photocraft_codecs::ChannelLayout::Rgba, out).ok()?;
|
||||
photocraft_codecs::encode(&small, photocraft_codecs::Format::Png, &Default::default()).ok()
|
||||
}
|
||||
|
||||
|
|
@ -318,13 +289,8 @@ impl PhotocraftMcp {
|
|||
to_result(b.call("ui.inspect", json!({})).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Open an image or document file and make it active. Returns index, size and import warnings."
|
||||
)]
|
||||
async fn doc_open(
|
||||
&self,
|
||||
Parameters(p): Parameters<OpenParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
#[tool(description = "Open an image or document file and make it active. Returns index, size and import warnings.")]
|
||||
async fn doc_open(&self, Parameters(p): Parameters<OpenParams>) -> Result<CallToolResult, McpError> {
|
||||
let path = PathBuf::from(&p.path);
|
||||
if let Some(r) = self.headless_op(move |h| h.open(&path)).await {
|
||||
return to_result(r);
|
||||
|
|
@ -334,10 +300,7 @@ impl PhotocraftMcp {
|
|||
}
|
||||
|
||||
#[tool(description = "Create a new document (defaults: 1920x1080 RGB 8-bit white).")]
|
||||
async fn doc_new(
|
||||
&self,
|
||||
Parameters(p): Parameters<NewParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
async fn doc_new(&self, Parameters(p): Parameters<NewParams>) -> Result<CallToolResult, McpError> {
|
||||
let mut params = serde_json::Map::new();
|
||||
if let Some(v) = p.width {
|
||||
params.insert("width".into(), v.into());
|
||||
|
|
@ -357,61 +320,34 @@ impl PhotocraftMcp {
|
|||
if let Some(v) = p.name {
|
||||
params.insert("name".into(), v.into());
|
||||
}
|
||||
self.run_command("file.new".into(), Value::Object(params))
|
||||
.await
|
||||
self.run_command("file.new".into(), Value::Object(params)).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \
|
||||
(psd, png, jpg, tif, webp, exr, …) export. Returns warnings about anything the format cannot hold."
|
||||
)]
|
||||
async fn doc_save(
|
||||
&self,
|
||||
Parameters(p): Parameters<SaveParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
#[tool(description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \
|
||||
(psd, png, jpg, tif, webp, exr, …) export. Returns warnings about anything the format cannot hold.")]
|
||||
async fn doc_save(&self, Parameters(p): Parameters<SaveParams>) -> Result<CallToolResult, McpError> {
|
||||
self.save_impl(p).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Export the document to another format (same as doc_save with an explicit path/format)."
|
||||
)]
|
||||
async fn doc_export(
|
||||
&self,
|
||||
Parameters(p): Parameters<SaveParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
#[tool(description = "Export the document to another format (same as doc_save with an explicit path/format).")]
|
||||
async fn doc_export(&self, Parameters(p): Parameters<SaveParams>) -> Result<CallToolResult, McpError> {
|
||||
if p.path.is_none() {
|
||||
return Ok(fail("doc_export needs `path`"));
|
||||
}
|
||||
self.save_impl(p).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Document state as JSON: layer tree (top to bottom), history, selection, active layer."
|
||||
)]
|
||||
async fn doc_inspect(
|
||||
&self,
|
||||
Parameters(p): Parameters<DocIndex>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
#[tool(description = "Document state as JSON: layer tree (top to bottom), history, selection, active layer.")]
|
||||
async fn doc_inspect(&self, Parameters(p): Parameters<DocIndex>) -> Result<CallToolResult, McpError> {
|
||||
if let Some(r) = self.headless_op(move |h| h.inspect(p.index)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
let b = self.bridge_client().expect("bridge");
|
||||
to_result(
|
||||
b.call(
|
||||
"engine.execute",
|
||||
json!({"command": "document.inspect", "params": {}}),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
to_result(b.call("engine.execute", json!({"command": "document.inspect", "params": {}})).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Render the flattened document and return it as a PNG image (bridge mode: window screenshot)."
|
||||
)]
|
||||
async fn doc_render_preview(
|
||||
&self,
|
||||
Parameters(p): Parameters<PreviewParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
#[tool(description = "Render the flattened document and return it as a PNG image (bridge mode: window screenshot).")]
|
||||
async fn doc_render_preview(&self, Parameters(p): Parameters<PreviewParams>) -> Result<CallToolResult, McpError> {
|
||||
let max = p.max_side.unwrap_or(1024);
|
||||
if let Some(r) = self.headless_op(move |h| h.render_png(p.index, max)).await {
|
||||
return Ok(match r {
|
||||
|
|
@ -424,10 +360,7 @@ impl PhotocraftMcp {
|
|||
}
|
||||
|
||||
#[tool(description = "Make the document at `index` active.")]
|
||||
async fn doc_select(
|
||||
&self,
|
||||
Parameters(p): Parameters<SelectParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
async fn doc_select(&self, Parameters(p): Parameters<SelectParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.headless_op(move |h| h.select(p.index)).await {
|
||||
Some(r) => to_result(r),
|
||||
None => bridge_only_headless("doc_select"),
|
||||
|
|
@ -435,23 +368,15 @@ impl PhotocraftMcp {
|
|||
}
|
||||
|
||||
#[tool(description = "Close a document (default: the active one) without saving.")]
|
||||
async fn doc_close(
|
||||
&self,
|
||||
Parameters(p): Parameters<DocIndex>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
async fn doc_close(&self, Parameters(p): Parameters<DocIndex>) -> Result<CallToolResult, McpError> {
|
||||
match self.headless_op(move |h| h.close(p.index)).await {
|
||||
Some(r) => to_result(r),
|
||||
None => bridge_only_headless("doc_close"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now."
|
||||
)]
|
||||
async fn command_list(
|
||||
&self,
|
||||
Parameters(p): Parameters<ListParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
#[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now.")]
|
||||
async fn command_list(&self, Parameters(p): Parameters<ListParams>) -> Result<CallToolResult, McpError> {
|
||||
let all = if let Some(r) = self.headless_op(|h| Ok(h.command_list())).await {
|
||||
r
|
||||
} else {
|
||||
|
|
@ -467,52 +392,27 @@ impl PhotocraftMcp {
|
|||
let items: Vec<Value> = all
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
all.get("commands")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default())
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
let hay = format!(
|
||||
"{} {}",
|
||||
c.get("id").and_then(Value::as_str).unwrap_or(""),
|
||||
c.get("label").and_then(Value::as_str).unwrap_or("")
|
||||
)
|
||||
.to_lowercase();
|
||||
needle.as_ref().is_none_or(|n| hay.contains(n))
|
||||
&& (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true))
|
||||
let hay =
|
||||
format!("{} {}", c.get("id").and_then(Value::as_str).unwrap_or(""), c.get("label").and_then(Value::as_str).unwrap_or("")).to_lowercase();
|
||||
needle.as_ref().is_none_or(|n| hay.contains(n)) && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true))
|
||||
})
|
||||
.collect();
|
||||
Ok(ok_json(&Value::Array(items)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result."
|
||||
)]
|
||||
async fn command_run(
|
||||
&self,
|
||||
Parameters(p): Parameters<RunParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
self.run_command(p.id, p.params.unwrap_or_else(|| json!({})))
|
||||
.await
|
||||
#[tool(description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result.")]
|
||||
async fn command_run(&self, Parameters(p): Parameters<RunParams>) -> Result<CallToolResult, McpError> {
|
||||
self.run_command(p.id, p.params.unwrap_or_else(|| json!({}))).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \
|
||||
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false."
|
||||
)]
|
||||
async fn command_batch(
|
||||
&self,
|
||||
Parameters(p): Parameters<BatchParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
#[tool(description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \
|
||||
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false.")]
|
||||
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>) -> Result<CallToolResult, McpError> {
|
||||
let stop = p.stop_on_error.unwrap_or(true);
|
||||
let steps: Vec<Value> = p
|
||||
.steps
|
||||
.into_iter()
|
||||
.map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({}))}))
|
||||
.collect();
|
||||
let steps: Vec<Value> = p.steps.into_iter().map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({}))})).collect();
|
||||
let args = json!({"steps": steps, "stopOnError": stop});
|
||||
if let Some(r) = self.headless_op(move |h| h.batch(&args)).await {
|
||||
return to_result(r);
|
||||
|
|
@ -532,16 +432,12 @@ impl PhotocraftMcp {
|
|||
}
|
||||
}
|
||||
}
|
||||
Ok(ok_json(
|
||||
&json!({"completed": results.len() - failed, "failed": failed, "results": results}),
|
||||
))
|
||||
Ok(ok_json(&json!({"completed": results.len() - failed, "failed": failed, "results": results})))
|
||||
}
|
||||
|
||||
// ----- live-GUI tools (bridge mode) -----
|
||||
|
||||
#[tool(
|
||||
description = "Bridge mode: full UI state of the live app (tool, panels, views, dialogs, menu tree)."
|
||||
)]
|
||||
#[tool(description = "Bridge mode: full UI state of the live app (tool, panels, views, dialogs, menu tree).")]
|
||||
async fn ui_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => to_result(b.call("ui.inspect", json!({})).await),
|
||||
|
|
@ -550,23 +446,15 @@ impl PhotocraftMcp {
|
|||
}
|
||||
|
||||
#[tool(description = "Bridge mode: screenshot of the live app window as PNG.")]
|
||||
async fn ui_screenshot(
|
||||
&self,
|
||||
Parameters(p): Parameters<PreviewParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
async fn ui_screenshot(&self, Parameters(p): Parameters<PreviewParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => self.screenshot(b, p.max_side).await,
|
||||
None => bridge_only("ui_screenshot"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Bridge mode: send pointer events (document coordinates) to the active tool."
|
||||
)]
|
||||
async fn ui_pointer(
|
||||
&self,
|
||||
Parameters(p): Parameters<PointerParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
#[tool(description = "Bridge mode: send pointer events (document coordinates) to the active tool.")]
|
||||
async fn ui_pointer(&self, Parameters(p): Parameters<PointerParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => {
|
||||
let mut params = json!({"events": p.events});
|
||||
|
|
@ -580,10 +468,7 @@ impl PhotocraftMcp {
|
|||
}
|
||||
|
||||
#[tool(description = "Bridge mode: activate a menu item by id.")]
|
||||
async fn ui_menu_invoke(
|
||||
&self,
|
||||
Parameters(p): Parameters<MenuParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
async fn ui_menu_invoke(&self, Parameters(p): Parameters<MenuParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => to_result(b.call("ui.menu.invoke", json!({"id": p.id})).await),
|
||||
None => bridge_only("ui_menu_invoke"),
|
||||
|
|
@ -591,53 +476,34 @@ impl PhotocraftMcp {
|
|||
}
|
||||
|
||||
#[tool(description = "Bridge mode: change UI state (tool, panels, zoom, center, dark).")]
|
||||
async fn ui_set(
|
||||
&self,
|
||||
Parameters(p): Parameters<UiSetParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
async fn ui_set(&self, Parameters(p): Parameters<UiSetParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => to_result(b.call("ui.set", p.fields).await),
|
||||
None => bridge_only("ui_set"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Bridge mode: call any control-protocol method (docs/control-protocol.md) with raw params."
|
||||
)]
|
||||
async fn control_call(
|
||||
&self,
|
||||
Parameters(p): Parameters<ControlParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
#[tool(description = "Bridge mode: call any control-protocol method (docs/control-protocol.md) with raw params.")]
|
||||
async fn control_call(&self, Parameters(p): Parameters<ControlParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => to_result(
|
||||
b.call(&p.method, p.params.unwrap_or_else(|| json!({})))
|
||||
.await,
|
||||
),
|
||||
Some(b) => to_result(b.call(&p.method, p.params.unwrap_or_else(|| json!({}))).await),
|
||||
None => bridge_only("control_call"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bridge_only_headless(name: &str) -> Result<CallToolResult, McpError> {
|
||||
Ok(fail(format!(
|
||||
"`{name}` is only available in headless mode (in the GUI use the window/tab UI or control_call)"
|
||||
)))
|
||||
Ok(fail(format!("`{name}` is only available in headless mode (in the GUI use the window/tab UI or control_call)")))
|
||||
}
|
||||
|
||||
impl PhotocraftMcp {
|
||||
async fn run_command(&self, id: String, params: Value) -> Result<CallToolResult, McpError> {
|
||||
let (id2, params2) = (id.clone(), params.clone());
|
||||
if let Some(r) = self
|
||||
.headless_op(move |h| h.command_run(&id2, params2))
|
||||
.await
|
||||
{
|
||||
if let Some(r) = self.headless_op(move |h| h.command_run(&id2, params2)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
let b = self.bridge_client().expect("bridge");
|
||||
to_result(
|
||||
b.call("engine.execute", json!({"command": id, "params": params}))
|
||||
.await,
|
||||
)
|
||||
to_result(b.call("engine.execute", json!({"command": id, "params": params})).await)
|
||||
}
|
||||
|
||||
async fn save_impl(&self, p: SaveParams) -> Result<CallToolResult, McpError> {
|
||||
|
|
@ -667,10 +533,7 @@ impl PhotocraftMcp {
|
|||
impl ServerHandler for PhotocraftMcp {}
|
||||
|
||||
/// Used by the render helper in tests and the CLI.
|
||||
pub fn render_document_png(
|
||||
doc: &photocraft_doc::Document,
|
||||
max_side: u32,
|
||||
) -> Result<Vec<u8>, AutomationError> {
|
||||
pub fn render_document_png(doc: &photocraft_doc::Document, max_side: u32) -> Result<Vec<u8>, AutomationError> {
|
||||
files::render_png(doc, max_side)
|
||||
}
|
||||
|
||||
|
|
@ -681,15 +544,9 @@ mod tests {
|
|||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_panicking_command_does_not_wedge_the_session() {
|
||||
let mcp = PhotocraftMcp::headless();
|
||||
let r = mcp
|
||||
.headless_op(|_| -> Result<(), AutomationError> { panic!("boom") })
|
||||
.await
|
||||
.unwrap();
|
||||
let r = mcp.headless_op(|_| -> Result<(), AutomationError> { panic!("boom") }).await.unwrap();
|
||||
assert!(r.is_err());
|
||||
let r = mcp
|
||||
.headless_op(|h| h.command_run("file.new", json!({"width": 8, "height": 8})))
|
||||
.await
|
||||
.unwrap();
|
||||
let r = mcp.headless_op(|h| h.command_run("file.new", json!({"width": 8, "height": 8}))).await.unwrap();
|
||||
assert!(r.is_ok(), "session still usable after a panic: {r:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,12 +32,7 @@ async fn connect() -> Conn {
|
|||
}
|
||||
|
||||
fn text(r: &CallToolResult) -> String {
|
||||
r.content
|
||||
.iter()
|
||||
.filter_map(|c| c.as_text())
|
||||
.map(|t| t.text.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// Call a tool and parse its JSON result, failing the test on a tool error.
|
||||
|
|
@ -62,16 +57,8 @@ async fn inspect(c: &Conn) -> Value {
|
|||
/// Composite RGBA at (x, y), whatever envelope `command_run` wraps the result in.
|
||||
async fn pixel(c: &Conn, x: i32, y: i32) -> Vec<f64> {
|
||||
let v = run(c, "document.pixel", json!({"x": x, "y": y})).await;
|
||||
let arr = if v.is_array() {
|
||||
v
|
||||
} else {
|
||||
v.get("result").cloned().unwrap_or(v)
|
||||
};
|
||||
arr.as_array()
|
||||
.unwrap_or_else(|| panic!("pixel: {arr}"))
|
||||
.iter()
|
||||
.map(|x| x.as_f64().unwrap())
|
||||
.collect()
|
||||
let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) };
|
||||
arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect()
|
||||
}
|
||||
|
||||
fn top_layer(doc: &Value) -> &Value {
|
||||
|
|
@ -94,16 +81,9 @@ fn gradient_png(dir: &std::path::Path) -> String {
|
|||
px.extend_from_slice(&[(x * 4) as u8, (y * 5) as u8, 128]);
|
||||
}
|
||||
}
|
||||
let img =
|
||||
photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap();
|
||||
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap();
|
||||
let path = dir.join("gradient.png");
|
||||
std::fs::File::create(&path)
|
||||
.unwrap()
|
||||
.write_all(
|
||||
&photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::File::create(&path).unwrap().write_all(&photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
|
|
@ -118,32 +98,14 @@ async fn agent_completes_ten_scripted_tasks() {
|
|||
let c = connect().await;
|
||||
|
||||
// 1. Make a title card: type layer with a drop shadow, exported as PNG.
|
||||
tool(
|
||||
&c,
|
||||
"doc_new",
|
||||
json!({"width": 200, "height": 120, "background": "white"}),
|
||||
)
|
||||
.await;
|
||||
run(
|
||||
&c,
|
||||
"type.create",
|
||||
json!({"x": 20, "y": 70, "text": "Hello", "size": 36}),
|
||||
)
|
||||
.await;
|
||||
run(
|
||||
&c,
|
||||
"layer.layerStyle.dropShadow",
|
||||
json!({"distance": 4, "size": 3}),
|
||||
)
|
||||
.await;
|
||||
tool(&c, "doc_new", json!({"width": 200, "height": 120, "background": "white"})).await;
|
||||
run(&c, "type.create", json!({"x": 20, "y": 70, "text": "Hello", "size": 36})).await;
|
||||
run(&c, "layer.layerStyle.dropShadow", json!({"distance": 4, "size": 3})).await;
|
||||
let doc = inspect(&c).await;
|
||||
let t = top_layer(&doc);
|
||||
assert_eq!(t["kind"], "Type", "task 1: {t}");
|
||||
assert_eq!(t["text"]["text"], "Hello", "task 1: {t}");
|
||||
assert_eq!(
|
||||
t["effects"]["items"][0]["kind"], "Drop Shadow",
|
||||
"task 1: {t}"
|
||||
);
|
||||
assert_eq!(t["effects"]["items"][0]["kind"], "Drop Shadow", "task 1: {t}");
|
||||
let out = dir.join("card.png");
|
||||
tool(&c, "doc_export", json!({"path": out.to_string_lossy()})).await;
|
||||
let card = photocraft_codecs::decode(&std::fs::read(&out).unwrap()).unwrap();
|
||||
|
|
@ -153,132 +115,63 @@ async fn agent_completes_ten_scripted_tasks() {
|
|||
// 2. Colour grade a photo non-destructively: Curves brightens the midtones, Vibrance on top.
|
||||
tool(&c, "doc_open", json!({"path": png})).await;
|
||||
let before = pixel(&c, 16, 24).await;
|
||||
run(
|
||||
&c,
|
||||
"layer.newAdjustmentLayer.curves",
|
||||
json!({"points": [[0, 0], [128, 200], [255, 255]]}),
|
||||
)
|
||||
.await;
|
||||
run(
|
||||
&c,
|
||||
"layer.newAdjustmentLayer.vibrance",
|
||||
json!({"vibrance": 40, "saturation": 0}),
|
||||
)
|
||||
.await;
|
||||
run(&c, "layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [128, 200], [255, 255]]})).await;
|
||||
run(&c, "layer.newAdjustmentLayer.vibrance", json!({"vibrance": 40, "saturation": 0})).await;
|
||||
let after = pixel(&c, 16, 24).await;
|
||||
assert!(
|
||||
after[1] > before[1] + 0.05,
|
||||
"task 2: curves should brighten green {before:?} -> {after:?}"
|
||||
);
|
||||
assert!(after[1] > before[1] + 0.05, "task 2: curves should brighten green {before:?} -> {after:?}");
|
||||
let doc = inspect(&c).await;
|
||||
let kinds: Vec<&str> = doc["layers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|l| l["kind"].as_str())
|
||||
.collect();
|
||||
let kinds: Vec<&str> = doc["layers"].as_array().unwrap().iter().filter_map(|l| l["kind"].as_str()).collect();
|
||||
assert_eq!(kinds, ["Adjustment", "Adjustment", "Pixel"], "task 2");
|
||||
|
||||
// 3. Undo/redo: remove both adjustments, restore one.
|
||||
run(&c, "edit.undo", json!({})).await;
|
||||
run(&c, "edit.undo", json!({})).await;
|
||||
assert!(
|
||||
close(&pixel(&c, 16, 24).await, &before, 1e-6),
|
||||
"task 3: undo should restore the original"
|
||||
);
|
||||
assert!(close(&pixel(&c, 16, 24).await, &before, 1e-6), "task 3: undo should restore the original");
|
||||
run(&c, "edit.redo", json!({})).await;
|
||||
assert_eq!(
|
||||
inspect(&c).await["layers"].as_array().unwrap().len(),
|
||||
2,
|
||||
"task 3: redo restores Curves"
|
||||
);
|
||||
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 3: redo restores Curves");
|
||||
run(&c, "edit.undo", json!({})).await;
|
||||
|
||||
// 4. Editable blur: smart object + Gaussian Blur, retune the radius, then hide the filter.
|
||||
run(&c, "layer.smartObjects.convertToSmartObject", json!({})).await;
|
||||
run(&c, "filter.blur.gaussianBlur", json!({"radius": 2})).await;
|
||||
run(
|
||||
&c,
|
||||
"layer.smartFilter.setParams",
|
||||
json!({"index": 0, "params": {"radius": 6}}),
|
||||
)
|
||||
.await;
|
||||
run(&c, "layer.smartFilter.setParams", json!({"index": 0, "params": {"radius": 6}})).await;
|
||||
let t = inspect(&c).await;
|
||||
let sf = &top_layer(&t)["smartFilters"][0];
|
||||
assert_eq!(sf["command"], "filter.blur.gaussianBlur", "task 4: {t}");
|
||||
assert_eq!(sf["params"]["radius"].as_f64(), Some(6.0), "task 4: {sf}");
|
||||
run(
|
||||
&c,
|
||||
"layer.smartFilter.setVisible",
|
||||
json!({"index": 0, "visible": false}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
close(&pixel(&c, 16, 24).await, &before, 1.0 / 255.0),
|
||||
"task 4: hiding the filter restores the pixels"
|
||||
);
|
||||
run(&c, "layer.smartFilter.setVisible", json!({"index": 0, "visible": false})).await;
|
||||
assert!(close(&pixel(&c, 16, 24).await, &before, 1.0 / 255.0), "task 4: hiding the filter restores the pixels");
|
||||
|
||||
// 5. Selection → layer mask: an elliptical reveal on a red layer.
|
||||
run(&c, "layer.new.layer", json!({"name": "Red"})).await;
|
||||
run(&c, "select.all", json!({})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
|
||||
run(
|
||||
&c,
|
||||
"select.rect",
|
||||
json!({"x": 16, "y": 12, "width": 32, "height": 24, "ellipse": true}),
|
||||
)
|
||||
.await;
|
||||
run(&c, "select.rect", json!({"x": 16, "y": 12, "width": 32, "height": 24, "ellipse": true})).await;
|
||||
run(&c, "layer.layerMask.revealSelection", json!({})).await;
|
||||
assert_eq!(top_layer(&inspect(&c).await)["hasMask"], true, "task 5");
|
||||
let inside = pixel(&c, 32, 24).await;
|
||||
let outside = pixel(&c, 1, 1).await;
|
||||
assert!(
|
||||
inside[0] > 0.99 && inside[1] < 0.01,
|
||||
"task 5: centre is red {inside:?}"
|
||||
);
|
||||
assert!(
|
||||
outside[0] < 0.1,
|
||||
"task 5: corner shows the photo {outside:?}"
|
||||
);
|
||||
assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 5: centre is red {inside:?}");
|
||||
assert!(outside[0] < 0.1, "task 5: corner shows the photo {outside:?}");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
||||
// 6. Save a selection to a channel and load it back.
|
||||
run(
|
||||
&c,
|
||||
"select.rect",
|
||||
json!({"x": 4, "y": 6, "width": 20, "height": 10}),
|
||||
)
|
||||
.await;
|
||||
run(&c, "select.rect", json!({"x": 4, "y": 6, "width": 20, "height": 10})).await;
|
||||
run(&c, "select.saveSelection", json!({"name": "Keep"})).await;
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
assert_eq!(inspect(&c).await["hasSelection"], false, "task 6");
|
||||
run(&c, "select.loadSelection", json!({"channel": "Keep"})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(
|
||||
doc["selectionBounds"],
|
||||
json!([4, 6, 20, 10]),
|
||||
"task 6: {}",
|
||||
doc["selectionBounds"]
|
||||
);
|
||||
assert_eq!(doc["selectionBounds"], json!([4, 6, 20, 10]), "task 6: {}", doc["selectionBounds"]);
|
||||
assert_eq!(doc["channels"]["alpha"][0]["name"], "Keep", "task 6");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
|
||||
// 7. Lay out three swatches and align their left edges.
|
||||
tool(
|
||||
&c,
|
||||
"doc_new",
|
||||
json!({"width": 120, "height": 90, "background": "white"}),
|
||||
)
|
||||
.await;
|
||||
tool(&c, "doc_new", json!({"width": 120, "height": 90, "background": "white"})).await;
|
||||
for (i, x) in [10, 40, 70].into_iter().enumerate() {
|
||||
run(&c, "layer.new.layer", json!({"name": format!("S{i}")})).await;
|
||||
run(
|
||||
&c,
|
||||
"select.rect",
|
||||
json!({"x": x, "y": 10 + 25 * i as i32, "width": 20, "height": 15}),
|
||||
)
|
||||
.await;
|
||||
run(&c, "select.rect", json!({"x": x, "y": 10 + 25 * i as i32, "width": 20, "height": 15})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#3366cc"})).await;
|
||||
}
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
|
@ -297,35 +190,15 @@ async fn agent_completes_ten_scripted_tasks() {
|
|||
// 8. Export every layer to its own file.
|
||||
let layers_dir = dir.join("layers");
|
||||
std::fs::create_dir_all(&layers_dir).unwrap();
|
||||
run(
|
||||
&c,
|
||||
"file.export.layersToFiles",
|
||||
json!({"dir": layers_dir.to_string_lossy(), "format": "png", "prefix": "swatch"}),
|
||||
)
|
||||
.await;
|
||||
let n = std::fs::read_dir(&layers_dir)
|
||||
.unwrap()
|
||||
.filter(|e| {
|
||||
e.as_ref()
|
||||
.is_ok_and(|e| e.path().extension().is_some_and(|x| x == "png"))
|
||||
})
|
||||
.count();
|
||||
run(&c, "file.export.layersToFiles", json!({"dir": layers_dir.to_string_lossy(), "format": "png", "prefix": "swatch"})).await;
|
||||
let n = std::fs::read_dir(&layers_dir).unwrap().filter(|e| e.as_ref().is_ok_and(|e| e.path().extension().is_some_and(|x| x == "png"))).count();
|
||||
assert_eq!(n, 4, "task 8: three swatches plus the background");
|
||||
|
||||
// 9. Resize, then crop to a square.
|
||||
run(&c, "image.imageSize", json!({"width": 60, "height": 45})).await;
|
||||
run(
|
||||
&c,
|
||||
"image.crop",
|
||||
json!({"x": 5, "y": 0, "width": 45, "height": 45}),
|
||||
)
|
||||
.await;
|
||||
run(&c, "image.crop", json!({"x": 5, "y": 0, "width": 45, "height": 45})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(
|
||||
(doc["width"].as_u64(), doc["height"].as_u64()),
|
||||
(Some(45), Some(45)),
|
||||
"task 9"
|
||||
);
|
||||
assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(45), Some(45)), "task 9");
|
||||
|
||||
// 10. Prepare for print: convert to CMYK and save natively, then reopen.
|
||||
run(&c, "image.mode.cmyk", json!({"intent": "perceptual"})).await;
|
||||
|
|
@ -335,11 +208,7 @@ async fn agent_completes_ten_scripted_tasks() {
|
|||
tool(&c, "doc_close", json!({})).await;
|
||||
tool(&c, "doc_open", json!({"path": saved.to_string_lossy()})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(
|
||||
(doc["mode"].as_str(), doc["width"].as_u64()),
|
||||
(Some("Cmyk"), Some(45)),
|
||||
"task 10"
|
||||
);
|
||||
assert_eq!((doc["mode"].as_str(), doc["width"].as_u64()), (Some("Cmyk"), Some(45)), "task 10");
|
||||
assert_eq!(doc["layers"].as_array().unwrap().len(), 4, "task 10");
|
||||
|
||||
c.cancel().await.unwrap();
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@ async fn connect(server: PhotocraftMcp) -> RunningService<RoleClient, Client> {
|
|||
Client.serve(c).await.expect("client init")
|
||||
}
|
||||
|
||||
async fn call(
|
||||
client: &RunningService<RoleClient, Client>,
|
||||
name: &str,
|
||||
args: Value,
|
||||
) -> CallToolResult {
|
||||
async fn call(client: &RunningService<RoleClient, Client>, name: &str, args: Value) -> CallToolResult {
|
||||
let mut p = CallToolRequestParams::new(name.to_owned());
|
||||
if let Value::Object(m) = args {
|
||||
p = p.with_arguments(m);
|
||||
|
|
@ -41,12 +37,7 @@ async fn call(
|
|||
}
|
||||
|
||||
fn text(r: &CallToolResult) -> String {
|
||||
r.content
|
||||
.iter()
|
||||
.filter_map(|c| c.as_text())
|
||||
.map(|t| t.text.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
fn json_of(r: &CallToolResult) -> Value {
|
||||
|
|
@ -86,28 +77,15 @@ async fn lists_expected_tools() {
|
|||
"ui_set",
|
||||
"control_call",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(&n.to_string()),
|
||||
"missing tool {n}: {names:?}"
|
||||
);
|
||||
assert!(names.contains(&n.to_string()), "missing tool {n}: {names:?}");
|
||||
}
|
||||
for t in &tools {
|
||||
assert!(
|
||||
t.name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||
"tool name `{}`",
|
||||
t.name
|
||||
);
|
||||
assert!(t.name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), "tool name `{}`", t.name);
|
||||
assert!(t.description.as_ref().is_some_and(|d| !d.is_empty()));
|
||||
}
|
||||
let info = client.peer_info().expect("server info");
|
||||
assert_eq!(info.server_info.as_ref().unwrap().name, "photocraft");
|
||||
assert!(
|
||||
info.instructions
|
||||
.as_ref()
|
||||
.is_some_and(|i| i.contains("command_list"))
|
||||
);
|
||||
assert!(info.instructions.as_ref().is_some_and(|i| i.contains("command_list")));
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
|
|
@ -116,48 +94,23 @@ async fn headless_edit_render_save_roundtrip() {
|
|||
let dir = tmp("edit");
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
|
||||
let r = call(
|
||||
&client,
|
||||
"doc_new",
|
||||
json!({"width": 64, "height": 48, "background": "white", "name": "Agent"}),
|
||||
)
|
||||
.await;
|
||||
let r = call(&client, "doc_new", json!({"width": 64, "height": 48, "background": "white", "name": "Agent"})).await;
|
||||
assert_ne!(r.is_error, Some(true), "{}", text(&r));
|
||||
let r = call(
|
||||
&client,
|
||||
"command_run",
|
||||
json!({"id": "layer.new.layer", "params": {"name": "Ink"}}),
|
||||
)
|
||||
.await;
|
||||
let r = call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "Ink"}})).await;
|
||||
assert_ne!(r.is_error, Some(true), "{}", text(&r));
|
||||
let r = call(
|
||||
&client,
|
||||
"command_run",
|
||||
json!({"id": "paint.stroke", "params": {"points": [[5, 5, 1.0], [50, 40, 1.0]], "size": 6, "color": "#ff0000"}}),
|
||||
)
|
||||
.await;
|
||||
let r =
|
||||
call(&client, "command_run", json!({"id": "paint.stroke", "params": {"points": [[5, 5, 1.0], [50, 40, 1.0]], "size": 6, "color": "#ff0000"}})).await;
|
||||
assert_ne!(r.is_error, Some(true), "{}", text(&r));
|
||||
|
||||
let doc = json_of(&call(&client, "doc_inspect", json!({})).await);
|
||||
assert_eq!(doc["width"], 64);
|
||||
let names: Vec<&str> = doc["layers"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|l| l["name"].as_str())
|
||||
.collect();
|
||||
let names: Vec<&str> = doc["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Ink", "Background"]);
|
||||
|
||||
let r = call(&client, "doc_render_preview", json!({"max_side": 32})).await;
|
||||
let img = r
|
||||
.content
|
||||
.iter()
|
||||
.find_map(|c| c.as_image())
|
||||
.expect("image content");
|
||||
let img = r.content.iter().find_map(|c| c.as_image()).expect("image content");
|
||||
assert_eq!(img.mime_type, "image/png");
|
||||
let png = base64::engine::general_purpose::STANDARD
|
||||
.decode(&img.data)
|
||||
.unwrap();
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(&img.data).unwrap();
|
||||
let decoded = photocraft_codecs::decode(&png).unwrap();
|
||||
assert_eq!(decoded.dimensions(), (32, 24));
|
||||
|
||||
|
|
@ -165,23 +118,9 @@ async fn headless_edit_render_save_roundtrip() {
|
|||
let r = json_of(&call(&client, "doc_save", json!({"path": pc.to_string_lossy()})).await);
|
||||
assert_eq!(r["path"], pc.to_string_lossy().as_ref());
|
||||
let png_path = dir.join("agent.png");
|
||||
json_of(
|
||||
&call(
|
||||
&client,
|
||||
"doc_export",
|
||||
json!({"path": png_path.to_string_lossy()}),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
json_of(&call(&client, "doc_export", json!({"path": png_path.to_string_lossy()})).await);
|
||||
let jpg_path = dir.join("agent.jpg");
|
||||
json_of(
|
||||
&call(
|
||||
&client,
|
||||
"doc_export",
|
||||
json!({"path": jpg_path.to_string_lossy(), "quality": 70}),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
json_of(&call(&client, "doc_export", json!({"path": jpg_path.to_string_lossy(), "quality": 70})).await);
|
||||
assert!(photocraft_codecs::decode(&std::fs::read(&png_path).unwrap()).is_ok());
|
||||
assert!(std::fs::metadata(&jpg_path).unwrap().len() > 100);
|
||||
|
||||
|
|
@ -206,28 +145,10 @@ async fn command_list_filters() {
|
|||
assert!(n_all > 20);
|
||||
let blur = json_of(&call(&client, "command_list", json!({"filter": "blur"})).await);
|
||||
assert!(!blur.as_array().unwrap().is_empty() && blur.as_array().unwrap().len() < n_all);
|
||||
assert!(
|
||||
blur.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|c| c["params"].is_string())
|
||||
);
|
||||
assert!(blur.as_array().unwrap().iter().all(|c| c["params"].is_string()));
|
||||
let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await);
|
||||
assert!(
|
||||
enabled
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c["id"] == "file.new")
|
||||
);
|
||||
assert!(
|
||||
!enabled
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c["id"] == "layer.new.layer"),
|
||||
"needs a document"
|
||||
);
|
||||
assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new"));
|
||||
assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document");
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
|
|
@ -239,12 +160,7 @@ async fn errors_are_tool_errors_not_crashes() {
|
|||
assert!(text(&r).contains("unknown command"));
|
||||
let r = call(&client, "doc_inspect", json!({})).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
let r = call(
|
||||
&client,
|
||||
"doc_open",
|
||||
json!({"path": "/definitely/missing.png"}),
|
||||
)
|
||||
.await;
|
||||
let r = call(&client, "doc_open", json!({"path": "/definitely/missing.png"})).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
let r = call(&client, "ui_inspect", json!({})).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
|
|
@ -259,35 +175,13 @@ async fn errors_are_tool_errors_not_crashes() {
|
|||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_png_and_inspect() {
|
||||
let dir = tmp("open");
|
||||
let img = photocraft_codecs::Image::from_u8(
|
||||
8,
|
||||
4,
|
||||
photocraft_codecs::ChannelLayout::Rgb,
|
||||
vec![200; 96],
|
||||
)
|
||||
.unwrap();
|
||||
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![200; 96]).unwrap();
|
||||
let path = dir.join("in.png");
|
||||
std::fs::File::create(&path)
|
||||
.unwrap()
|
||||
.write_all(
|
||||
&photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::File::create(&path).unwrap().write_all(&photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
let o = json_of(&call(&client, "doc_open", json!({"path": path.to_string_lossy()})).await);
|
||||
assert_eq!(
|
||||
(o["width"].as_u64(), o["height"].as_u64()),
|
||||
(Some(8), Some(4))
|
||||
);
|
||||
let px = json_of(
|
||||
&call(
|
||||
&client,
|
||||
"command_run",
|
||||
json!({"id": "document.pixel", "params": {"x": 1, "y": 1}}),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
assert_eq!((o["width"].as_u64(), o["height"].as_u64()), (Some(8), Some(4)));
|
||||
let px = json_of(&call(&client, "command_run", json!({"id": "document.pixel", "params": {"x": 1, "y": 1}})).await);
|
||||
assert!(px.to_string().contains("0.78"), "{px}");
|
||||
client.cancel().await.unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
|
|
@ -321,31 +215,14 @@ async fn fake_app() -> (String, tokio::task::JoinHandle<Vec<Value>>) {
|
|||
}
|
||||
"ui.screenshot" => {
|
||||
let path = req["params"]["path"].as_str().unwrap().to_owned();
|
||||
let img = photocraft_codecs::Image::from_u8(
|
||||
40,
|
||||
20,
|
||||
photocraft_codecs::ChannelLayout::Rgba,
|
||||
vec![9; 3200],
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&path,
|
||||
photocraft_codecs::encode(
|
||||
&img,
|
||||
photocraft_codecs::Format::Png,
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let img = photocraft_codecs::Image::from_u8(40, 20, photocraft_codecs::ChannelLayout::Rgba, vec![9; 3200]).unwrap();
|
||||
std::fs::write(&path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
||||
json!({"id": id, "ok": true, "result": {"path": path}})
|
||||
}
|
||||
_ => json!({"id": id, "ok": false, "error": format!("unknown tool `{method}`")}),
|
||||
};
|
||||
// A stale line first, to check id matching.
|
||||
w.write_all(b"{\"id\":999999,\"ok\":true,\"result\":null}\n")
|
||||
.await
|
||||
.unwrap();
|
||||
w.write_all(b"{\"id\":999999,\"ok\":true,\"result\":null}\n").await.unwrap();
|
||||
w.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
|
||||
seen.push(req);
|
||||
}
|
||||
|
|
@ -361,30 +238,14 @@ async fn bridge_forwards_to_control_protocol() {
|
|||
|
||||
let ui = json_of(&call(&client, "ui_inspect", json!({})).await);
|
||||
assert_eq!(ui["tool"], "brush");
|
||||
let r = json_of(
|
||||
&call(
|
||||
&client,
|
||||
"command_run",
|
||||
json!({"id": "layer.new.layer", "params": {"name": "X"}}),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
let r = json_of(&call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "X"}})).await);
|
||||
assert_eq!(r["ran"], "layer.new.layer");
|
||||
let l = json_of(&call(&client, "command_list", json!({})).await);
|
||||
assert_eq!(l[0]["id"], "file.new");
|
||||
let shot = call(&client, "ui_screenshot", json!({"max_side": 20})).await;
|
||||
let img = shot
|
||||
.content
|
||||
.iter()
|
||||
.find_map(|c| c.as_image())
|
||||
.expect("image");
|
||||
let png = base64::engine::general_purpose::STANDARD
|
||||
.decode(&img.data)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
photocraft_codecs::decode(&png).unwrap().dimensions(),
|
||||
(20, 10)
|
||||
);
|
||||
let img = shot.content.iter().find_map(|c| c.as_image()).expect("image");
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(&img.data).unwrap();
|
||||
assert_eq!(photocraft_codecs::decode(&png).unwrap().dimensions(), (20, 10));
|
||||
let e = call(&client, "control_call", json!({"method": "bogus.method"})).await;
|
||||
assert_eq!(e.is_error, Some(true));
|
||||
assert!(text(&e).contains("unknown tool"));
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::Intent;
|
||||
use crate::curve::{Curve, srgb_trc};
|
||||
use crate::math::{self, Mat3};
|
||||
use crate::pipeline::Stage;
|
||||
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
|
||||
use crate::Intent;
|
||||
|
||||
/// Copyright tag text of every built-in profile.
|
||||
pub const COPYRIGHT: &str = "No copyright: CC0-1.0 public domain dedication. Generated by photocraft-cms.";
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ pub enum Curve {
|
|||
Gamma(f64),
|
||||
/// ICC `parametricCurveType` function `kind` (0–4) with parameters `[g, a, b, c, d, e, f]`
|
||||
/// (unused ones are 0).
|
||||
Parametric { kind: u16, p: [f64; 7] },
|
||||
Parametric {
|
||||
kind: u16,
|
||||
p: [f64; 7],
|
||||
},
|
||||
/// Samples at evenly spaced inputs over `[0, 1]`, normalised outputs.
|
||||
Table(Vec<f32>),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,11 +52,7 @@ impl LutFile {
|
|||
}
|
||||
}
|
||||
}
|
||||
LutFile {
|
||||
title: title.to_string(),
|
||||
size,
|
||||
data,
|
||||
}
|
||||
LutFile { title: title.to_string(), size, data }
|
||||
}
|
||||
|
||||
fn check(self) -> Result<Self, LutError> {
|
||||
|
|
@ -64,11 +60,7 @@ impl LutFile {
|
|||
return err(format!("unsupported LUT size {}", self.size));
|
||||
}
|
||||
if self.data.len() != self.size.pow(3) * 3 {
|
||||
return err(format!(
|
||||
"expected {} entries, found {}",
|
||||
self.size.pow(3),
|
||||
self.data.len() / 3
|
||||
));
|
||||
return err(format!("expected {} entries, found {}", self.size.pow(3), self.data.len() / 3));
|
||||
}
|
||||
if self.data.iter().any(|v| !v.is_finite()) {
|
||||
return err("non-finite LUT value");
|
||||
|
|
@ -105,26 +97,12 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
|
|||
let key = it.next().unwrap_or("");
|
||||
let nums = |it: std::str::SplitWhitespace<'_>| -> Result<[f32; 3], LutError> {
|
||||
let v: Vec<f32> = it.filter_map(|s| s.parse().ok()).collect();
|
||||
if v.len() == 3 {
|
||||
Ok([v[0], v[1], v[2]])
|
||||
} else {
|
||||
err(format!("bad line `{line}`"))
|
||||
}
|
||||
if v.len() == 3 { Ok([v[0], v[1], v[2]]) } else { err(format!("bad line `{line}`")) }
|
||||
};
|
||||
match key {
|
||||
"TITLE" => title = line[5..].trim().trim_matches('"').to_string(),
|
||||
"LUT_3D_SIZE" => {
|
||||
size3 = it
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.ok_or_else(|| LutError("bad LUT_3D_SIZE".into()))?
|
||||
}
|
||||
"LUT_1D_SIZE" => {
|
||||
size1 = it
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.ok_or_else(|| LutError("bad LUT_1D_SIZE".into()))?
|
||||
}
|
||||
"LUT_3D_SIZE" => size3 = it.next().and_then(|s| s.parse().ok()).ok_or_else(|| LutError("bad LUT_3D_SIZE".into()))?,
|
||||
"LUT_1D_SIZE" => size1 = it.next().and_then(|s| s.parse().ok()).ok_or_else(|| LutError("bad LUT_1D_SIZE".into()))?,
|
||||
"DOMAIN_MIN" => dmin = nums(it)?,
|
||||
"DOMAIN_MAX" => dmax = nums(it)?,
|
||||
"LUT_3D_INPUT_RANGE" | "LUT_1D_INPUT_RANGE" => {
|
||||
|
|
@ -134,13 +112,8 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
|
|||
dmax = [v[1]; 3];
|
||||
}
|
||||
}
|
||||
k if k
|
||||
.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '+' || c == '.') =>
|
||||
{
|
||||
let mut v = vec![
|
||||
k.parse::<f32>()
|
||||
.map_err(|_| LutError(format!("bad number in `{line}`")))?,
|
||||
];
|
||||
k if k.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '+' || c == '.') => {
|
||||
let mut v = vec![k.parse::<f32>().map_err(|_| LutError(format!("bad number in `{line}`")))?];
|
||||
v.extend(it.filter_map(|s| s.parse::<f32>().ok()));
|
||||
if v.len() != 3 {
|
||||
return err(format!("bad data line `{line}`"));
|
||||
|
|
@ -153,12 +126,7 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
|
|||
}
|
||||
let _ = (dmin, dmax); // Domain other than 0..1 only shifts input sampling; we assume 0..1 inputs.
|
||||
if size3 > 0 {
|
||||
return LutFile {
|
||||
title,
|
||||
size: size3,
|
||||
data: rows,
|
||||
}
|
||||
.check();
|
||||
return LutFile { title, size: size3, data: rows }.check();
|
||||
}
|
||||
if size1 >= 2 && rows.len() == size1 * 3 {
|
||||
let curve = |ch: usize, v: f32| {
|
||||
|
|
@ -167,9 +135,7 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
|
|||
let f = x - i as f32;
|
||||
rows[i * 3 + ch] * (1.0 - f) + rows[(i + 1) * 3 + ch] * f
|
||||
};
|
||||
let mut l = LutFile::from_fn(&title, 33, |c| {
|
||||
[curve(0, c[0]), curve(1, c[1]), curve(2, c[2])]
|
||||
});
|
||||
let mut l = LutFile::from_fn(&title, 33, |c| [curve(0, c[0]), curve(1, c[1]), curve(2, c[2])]);
|
||||
l.title = title;
|
||||
return l.check();
|
||||
}
|
||||
|
|
@ -185,10 +151,7 @@ pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
|
|||
if line.is_empty() || line.starts_with(|c: char| c.is_ascii_alphabetic()) {
|
||||
continue;
|
||||
}
|
||||
let v: Vec<f64> = line
|
||||
.split_whitespace()
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
let v: Vec<f64> = line.split_whitespace().filter_map(|s| s.parse().ok()).collect();
|
||||
if v.len() == 3 {
|
||||
rows.push([v[0], v[1], v[2]]);
|
||||
}
|
||||
|
|
@ -199,14 +162,7 @@ pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
|
|||
return err(format!("{} rows is not a cube", rows.len()));
|
||||
}
|
||||
let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
|
||||
let scale = if max <= 1.0 {
|
||||
1.0
|
||||
} else {
|
||||
[1023.0, 4095.0, 16383.0, 65535.0]
|
||||
.into_iter()
|
||||
.find(|&s| max <= s)
|
||||
.unwrap_or(max)
|
||||
};
|
||||
let scale = if max <= 1.0 { 1.0 } else { [1023.0, 4095.0, 16383.0, 65535.0].into_iter().find(|&s| max <= s).unwrap_or(max) };
|
||||
let mut data = vec![0.0f32; n * n * n * 3];
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let (r, g, b) = (i / (n * n), (i / n) % n, i % n);
|
||||
|
|
@ -215,12 +171,7 @@ pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
|
|||
data[at + k] = (row[k] / scale) as f32;
|
||||
}
|
||||
}
|
||||
LutFile {
|
||||
title: String::new(),
|
||||
size: n,
|
||||
data,
|
||||
}
|
||||
.check()
|
||||
LutFile { title: String::new(), size: n, data }.check()
|
||||
}
|
||||
|
||||
/// SpeedGrade `.look`: `<size>` and a hex `<data>` string of little-endian float32 RGB triplets
|
||||
|
|
@ -232,38 +183,14 @@ pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
|
|||
let end = text[start..].find(&format!("</{name}>"))? + start;
|
||||
Some(text[start..end].trim().trim_matches('"').trim().to_string())
|
||||
};
|
||||
let size: usize = tag("size")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.ok_or_else(|| LutError("no <size> in .look".into()))?;
|
||||
let hex: Vec<u8> = tag("data")
|
||||
.ok_or_else(|| LutError("no <data> in .look".into()))?
|
||||
.bytes()
|
||||
.filter(u8::is_ascii_hexdigit)
|
||||
.collect();
|
||||
let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no <size> in .look".into()))?;
|
||||
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect();
|
||||
let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8;
|
||||
let bytes: Vec<u8> = hex
|
||||
.chunks_exact(2)
|
||||
.map(|p| nib(p[0]) << 4 | nib(p[1]))
|
||||
.collect();
|
||||
let floats: Vec<f32> = bytes
|
||||
.chunks_exact(4)
|
||||
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
.collect();
|
||||
let bytes: Vec<u8> = hex.as_chunks::<2>().0.iter().map(|p| nib(p[0]) << 4 | nib(p[1])).collect();
|
||||
let floats: Vec<f32> = bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let n3 = size.pow(3);
|
||||
let data = if floats.len() == n3 * 4 {
|
||||
floats
|
||||
.chunks_exact(4)
|
||||
.flat_map(|c| [c[0], c[1], c[2]])
|
||||
.collect()
|
||||
} else {
|
||||
floats
|
||||
};
|
||||
LutFile {
|
||||
title: tag("title").unwrap_or_default(),
|
||||
size,
|
||||
data,
|
||||
}
|
||||
.check()
|
||||
let data = if floats.len() == n3 * 4 { floats.as_chunks::<4>().0.iter().flat_map(|c| [c[0], c[1], c[2]]).collect() } else { floats };
|
||||
LutFile { title: tag("title").unwrap_or_default(), size, data }.check()
|
||||
}
|
||||
|
||||
/// Writes a `.cube` file (what Photoshop embeds in a Color Lookup layer).
|
||||
|
|
@ -273,7 +200,7 @@ pub fn write_cube(l: &LutFile) -> String {
|
|||
s.push_str(&format!("TITLE \"{}\"\n", l.title.replace('"', "'")));
|
||||
}
|
||||
s.push_str(&format!("LUT_3D_SIZE {}\n", l.size));
|
||||
for c in l.data.chunks_exact(3) {
|
||||
for c in l.data.as_chunks::<3>().0 {
|
||||
s.push_str(&format!("{:.6} {:.6} {:.6}\n", c[0], c[1], c[2]));
|
||||
}
|
||||
s
|
||||
|
|
@ -347,10 +274,7 @@ mod tests {
|
|||
#[test]
|
||||
fn cube_roundtrip_and_identity() {
|
||||
let id = LutFile::identity(5);
|
||||
let text = write_cube(&LutFile {
|
||||
title: "Id".into(),
|
||||
..id.clone()
|
||||
});
|
||||
let text = write_cube(&LutFile { title: "Id".into(), ..id.clone() });
|
||||
let back = parse_cube(&text).unwrap();
|
||||
assert_eq!(back.size, 5);
|
||||
assert_eq!(back.title, "Id");
|
||||
|
|
@ -388,15 +312,8 @@ mod tests {
|
|||
#[test]
|
||||
fn look_hex_floats() {
|
||||
let id = LutFile::identity(2);
|
||||
let hex: String = id
|
||||
.data
|
||||
.iter()
|
||||
.flat_map(|v| v.to_le_bytes())
|
||||
.map(|b| format!("{b:02X}"))
|
||||
.collect();
|
||||
let xml = format!(
|
||||
"<?xml version=\"1.0\"?><look><LUT><size>\"2\"</size><data>\"{hex}\"</data></LUT></look>"
|
||||
);
|
||||
let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
|
||||
let xml = format!("<?xml version=\"1.0\"?><look><LUT><size>\"2\"</size><data>\"{hex}\"</data></LUT></look>");
|
||||
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,16 +27,11 @@ pub fn mul(a: &Mat3, b: &Mat3) -> Mat3 {
|
|||
}
|
||||
|
||||
pub fn apply(m: &Mat3, v: [f64; 3]) -> [f64; 3] {
|
||||
[
|
||||
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
||||
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
||||
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
|
||||
]
|
||||
[m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2]]
|
||||
}
|
||||
|
||||
pub fn det(m: &Mat3) -> f64 {
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
}
|
||||
|
||||
/// Inverse, or `None` when (nearly) singular.
|
||||
|
|
@ -47,21 +42,9 @@ pub fn invert(m: &Mat3) -> Option<Mat3> {
|
|||
}
|
||||
let inv = 1.0 / d;
|
||||
Some([
|
||||
[
|
||||
(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv,
|
||||
(m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv,
|
||||
(m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv,
|
||||
],
|
||||
[
|
||||
(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv,
|
||||
(m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv,
|
||||
(m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv,
|
||||
],
|
||||
[
|
||||
(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv,
|
||||
(m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv,
|
||||
(m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv,
|
||||
],
|
||||
[(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv],
|
||||
[(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv],
|
||||
[(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv],
|
||||
])
|
||||
}
|
||||
|
||||
|
|
@ -88,11 +71,7 @@ pub fn rgb_to_xyz_matrix(r: [f64; 2], g: [f64; 2], b: [f64; 2], white: [f64; 3])
|
|||
let (xr, xg, xb) = (xy_to_xyz(r), xy_to_xyz(g), xy_to_xyz(b));
|
||||
let m = [[xr[0], xg[0], xb[0]], [xr[1], xg[1], xb[1]], [xr[2], xg[2], xb[2]]];
|
||||
let s = apply(&invert(&m).expect("primaries are independent"), white);
|
||||
[
|
||||
[m[0][0] * s[0], m[0][1] * s[1], m[0][2] * s[2]],
|
||||
[m[1][0] * s[0], m[1][1] * s[1], m[1][2] * s[2]],
|
||||
[m[2][0] * s[0], m[2][1] * s[1], m[2][2] * s[2]],
|
||||
]
|
||||
[[m[0][0] * s[0], m[0][1] * s[1], m[0][2] * s[2]], [m[1][0] * s[0], m[1][1] * s[1], m[1][2] * s[2]], [m[2][0] * s[0], m[2][1] * s[1], m[2][2] * s[2]]]
|
||||
}
|
||||
|
||||
const EPS: f64 = 216.0 / 24389.0; // (6/29)^3
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ pub enum Stage {
|
|||
/// Per-channel inverse curves.
|
||||
InvCurves(Vec<Curve>),
|
||||
/// `out[r] = Σ m[r][c]·in[c] + offset[r]`, `rows × cols`.
|
||||
Matrix { rows: usize, cols: usize, m: Vec<f64>, offset: Vec<f64> },
|
||||
Matrix {
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
m: Vec<f64>,
|
||||
offset: Vec<f64>,
|
||||
},
|
||||
Clut(Clut),
|
||||
/// Float CIE Lab (L 0–100) → XYZ (D50, Y = 1).
|
||||
LabToXyz,
|
||||
|
|
@ -185,8 +190,7 @@ impl Pipeline {
|
|||
let noop = match &s {
|
||||
Stage::Curves(c) | Stage::InvCurves(c) => c.iter().all(Curve::is_identity),
|
||||
Stage::Matrix { rows: 3, cols: 3, m, offset } => {
|
||||
offset.iter().all(|o| o.abs() < 1e-12)
|
||||
&& m.iter().enumerate().all(|(i, v)| (v - if i % 4 == 0 { 1.0 } else { 0.0 }).abs() < 1e-12)
|
||||
offset.iter().all(|o| o.abs() < 1e-12) && m.iter().enumerate().all(|(i, v)| (v - if i % 4 == 0 { 1.0 } else { 0.0 }).abs() < 1e-12)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -525,7 +525,7 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> {
|
|||
return Err(CmsError::Invalid("curve too long".into()));
|
||||
}
|
||||
let s = r.slice(12, n * 2)?;
|
||||
let t = s.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect();
|
||||
let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect();
|
||||
Ok((Curve::Table(t), len))
|
||||
}
|
||||
}
|
||||
|
|
@ -585,8 +585,11 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
|
|||
let mut v = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let s = r.slice(*off, len * bps)?;
|
||||
let t: Vec<f32> =
|
||||
if wide { s.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect() } else { s.iter().map(|b| *b as f32 / 255.0).collect() };
|
||||
let t: Vec<f32> = if wide {
|
||||
s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect()
|
||||
} else {
|
||||
s.iter().map(|b| *b as f32 / 255.0).collect()
|
||||
};
|
||||
*off += len * bps;
|
||||
v.push(Curve::Table(t));
|
||||
}
|
||||
|
|
@ -599,8 +602,11 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
|
|||
}
|
||||
let total = nodes as usize * o;
|
||||
let s = r.slice(off, total * bps)?;
|
||||
let data: Vec<f32> =
|
||||
if wide { s.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect() } else { s.iter().map(|b| *b as f32 / 255.0).collect() };
|
||||
let data: Vec<f32> = if wide {
|
||||
s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect()
|
||||
} else {
|
||||
s.iter().map(|b| *b as f32 / 255.0).collect()
|
||||
};
|
||||
off += total * bps;
|
||||
let out_curves = read_tables(&mut off, o, n_out)?;
|
||||
let mut stages = Vec::new();
|
||||
|
|
@ -647,7 +653,7 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
|
|||
let total = nodes as usize * cout;
|
||||
let data: Vec<f32> = match prec {
|
||||
1 => r.slice(off + 20, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
|
||||
2 => r.slice(off + 20, total * 2)?.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
|
||||
2 => r.slice(off + 20, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
|
||||
_ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))),
|
||||
};
|
||||
Ok(Stage::Clut(Clut::new(grid, cout, data)))
|
||||
|
|
@ -724,7 +730,7 @@ fn parse_text(d: &[u8]) -> String {
|
|||
}
|
||||
let Some((off, len)) = best else { return String::new() };
|
||||
let Ok(s) = r.slice(off, len) else { return String::new() };
|
||||
let units: Vec<u16> = s.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
let units: Vec<u16> = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
String::from_utf16_lossy(&units).trim_end_matches('\0').trim().to_string()
|
||||
}
|
||||
_ => String::new(),
|
||||
|
|
|
|||
|
|
@ -22,12 +22,12 @@
|
|||
//! The model is plausible for coated offset, not a measured characterisation: use a real
|
||||
//! profile from your print provider for production print.
|
||||
|
||||
use crate::Intent;
|
||||
use crate::clut::Clut;
|
||||
use crate::curve::Curve;
|
||||
use crate::math;
|
||||
use crate::pipeline::Stage;
|
||||
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
|
||||
use crate::Intent;
|
||||
|
||||
pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
|
||||
|
||||
|
|
|
|||
|
|
@ -326,12 +326,7 @@ impl Transform {
|
|||
let s0 = s1 * c.grid[1];
|
||||
for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) {
|
||||
let (a, b, cc) = (g0[sp[0] as usize], g1[sp[1] as usize], g2[sp[2] as usize]);
|
||||
c.tetra_pub(
|
||||
(a.0 + b.0 + cc.0) as usize,
|
||||
[s0, s1, c.outputs],
|
||||
[a.1, b.1, cc.1],
|
||||
&mut mid,
|
||||
);
|
||||
c.tetra_pub((a.0 + b.0 + cc.0) as usize, [s0, s1, c.outputs], [a.1, b.1, cc.1], &mut mid);
|
||||
self.finish(&mid, &mut out);
|
||||
for k in 0..self.outputs {
|
||||
dp[k] = q(out[k]);
|
||||
|
|
@ -434,18 +429,18 @@ impl Transform {
|
|||
let run = |(s, d): (&[u8], &mut [u8])| match sample {
|
||||
SampleKind::U8 => self.run_u8(s, src_stride, d, dst_stride, extra),
|
||||
SampleKind::U16 => {
|
||||
let a: Vec<u16> = s.chunks_exact(2).map(|b| u16::from_ne_bytes([b[0], b[1]])).collect();
|
||||
let mut o: Vec<u16> = d.chunks_exact(2).map(|b| u16::from_ne_bytes([b[0], b[1]])).collect();
|
||||
let a: Vec<u16> = s.as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes([b[0], b[1]])).collect();
|
||||
let mut o: Vec<u16> = d.as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes([b[0], b[1]])).collect();
|
||||
self.run_u16(&a, src_stride, &mut o, dst_stride, extra);
|
||||
for (b, v) in d.chunks_exact_mut(2).zip(o) {
|
||||
for (b, v) in d.as_chunks_mut::<2>().0.iter_mut().zip(o) {
|
||||
b.copy_from_slice(&v.to_ne_bytes());
|
||||
}
|
||||
}
|
||||
SampleKind::F32 => {
|
||||
let a: Vec<f32> = s.chunks_exact(4).map(|b| f32::from_ne_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let mut o: Vec<f32> = d.chunks_exact(4).map(|b| f32::from_ne_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let a: Vec<f32> = s.as_chunks::<4>().0.iter().map(|b| f32::from_ne_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let mut o: Vec<f32> = d.as_chunks::<4>().0.iter().map(|b| f32::from_ne_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
self.run_f32(&a, src_stride, &mut o, dst_stride, extra);
|
||||
for (b, v) in d.chunks_exact_mut(4).zip(o) {
|
||||
for (b, v) in d.as_chunks_mut::<4>().0.iter_mut().zip(o) {
|
||||
b.copy_from_slice(&v.to_ne_bytes());
|
||||
}
|
||||
}
|
||||
|
|
@ -566,12 +561,7 @@ pub(crate) fn link_stages(src: &Profile, dst: &Profile, intent: Intent, bpc: boo
|
|||
if s_pcs == Pcs::Lab {
|
||||
stages.push(Stage::LabToXyz);
|
||||
}
|
||||
stages.push(Stage::Matrix {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
m: vec![scale[0], 0.0, 0.0, 0.0, scale[1], 0.0, 0.0, 0.0, scale[2]],
|
||||
offset: offset.to_vec(),
|
||||
});
|
||||
stages.push(Stage::Matrix { rows: 3, cols: 3, m: vec![scale[0], 0.0, 0.0, 0.0, scale[1], 0.0, 0.0, 0.0, scale[2]], offset: offset.to_vec() });
|
||||
if d_pcs == Pcs::Lab {
|
||||
stages.push(Stage::XyzToLab);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -353,7 +353,9 @@ fn gray_profiles() {
|
|||
#[test]
|
||||
fn hdr_floats_survive_matrix_transforms() {
|
||||
let lin = Builtin::LinearSrgb.profile();
|
||||
let t = Transform::with_options(lin, Builtin::Rec2020.profile(), TransformOptions { intent: Intent::RelativeColorimetric, bpc: false, precise_float: true }).unwrap();
|
||||
let t =
|
||||
Transform::with_options(lin, Builtin::Rec2020.profile(), TransformOptions { intent: Intent::RelativeColorimetric, bpc: false, precise_float: true })
|
||||
.unwrap();
|
||||
let back = Transform::new(Builtin::Rec2020.profile(), lin, Intent::RelativeColorimetric, false).unwrap();
|
||||
let src = [4.0f32, 2.0, 0.5];
|
||||
let mut mid = [0.0f32; 3];
|
||||
|
|
@ -410,7 +412,13 @@ fn system_profiles() {
|
|||
Err(e) => panic!("{}: {e}", path.display()),
|
||||
};
|
||||
seen += 1;
|
||||
if !matches!(p.class, photocraft_cms::ProfileClass::Input | photocraft_cms::ProfileClass::Display | photocraft_cms::ProfileClass::Output | photocraft_cms::ProfileClass::ColorSpace) {
|
||||
if !matches!(
|
||||
p.class,
|
||||
photocraft_cms::ProfileClass::Input
|
||||
| photocraft_cms::ProfileClass::Display
|
||||
| photocraft_cms::ProfileClass::Output
|
||||
| photocraft_cms::ProfileClass::ColorSpace
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
assert!(!p.description.is_empty(), "{}", path.display());
|
||||
|
|
@ -499,4 +507,3 @@ fn oracle_moxcms_system_cmyk() {
|
|||
let p99 = sorted[sorted.len() * 99 / 100];
|
||||
assert!(mean < 1.5 && p99 <= 6, "max {worst} p99 {p99} mean {mean}");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@
|
|||
use std::io::Cursor;
|
||||
|
||||
use exr::prelude::{
|
||||
AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, Layer, LayerAttributes,
|
||||
LineOrder, ReadChannels, ReadLayers, WritableImage, read,
|
||||
AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, Layer, LayerAttributes, LineOrder, ReadChannels, ReadLayers, WritableImage, read,
|
||||
};
|
||||
use half::f16;
|
||||
|
||||
|
|
@ -32,32 +31,18 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
let meta = exr::meta::MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?;
|
||||
for header in meta.headers.iter() {
|
||||
let size = header.layer_size;
|
||||
let (w, h) = (
|
||||
u32::try_from(size.0).unwrap_or(u32::MAX),
|
||||
u32::try_from(size.1).unwrap_or(u32::MAX),
|
||||
);
|
||||
let (w, h) = (u32::try_from(size.0).unwrap_or(u32::MAX), u32::try_from(size.1).unwrap_or(u32::MAX));
|
||||
let bpp = header.channels.list.len().max(1) as u64 * 4;
|
||||
limits.check_bytes(w, h, bpp)?;
|
||||
}
|
||||
|
||||
let image = read()
|
||||
.no_deep_data()
|
||||
.largest_resolution_level()
|
||||
.all_channels()
|
||||
.first_valid_layer()
|
||||
.all_attributes()
|
||||
.from_buffered(Cursor::new(bytes))
|
||||
.map_err(err)?;
|
||||
let image =
|
||||
read().no_deep_data().largest_resolution_level().all_channels().first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map_err(err)?;
|
||||
let layer = &image.layer_data;
|
||||
let (w, h) = (layer.size.0, layer.size.1);
|
||||
let channels = &layer.channel_data.list;
|
||||
let find = |n: &str| {
|
||||
channels
|
||||
.iter()
|
||||
.find(|c| base_name(c.name.to_string().as_str()) == n)
|
||||
};
|
||||
let (color, layout): (Vec<_>, ChannelLayout) =
|
||||
if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) {
|
||||
let find = |n: &str| channels.iter().find(|c| base_name(c.name.to_string().as_str()) == n);
|
||||
let (color, layout): (Vec<_>, ChannelLayout) = if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) {
|
||||
match find("A") {
|
||||
Some(a) => (vec![r, g, b, a], ChannelLayout::Rgba),
|
||||
None => (vec![r, g, b], ChannelLayout::Rgb),
|
||||
|
|
@ -70,19 +55,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
} else {
|
||||
return Err(CodecError::unsupported(F, "no R/G/B or Y channels"));
|
||||
};
|
||||
let all_f16 = color
|
||||
.iter()
|
||||
.all(|c| matches!(c.sample_data, FlatSamples::F16(_)));
|
||||
let sample = if all_f16 {
|
||||
SampleType::F16
|
||||
} else {
|
||||
SampleType::F32
|
||||
};
|
||||
let all_f16 = color.iter().all(|c| matches!(c.sample_data, FlatSamples::F16(_)));
|
||||
let sample = if all_f16 { SampleType::F16 } else { SampleType::F32 };
|
||||
let npx = w * h;
|
||||
if color.iter().any(|c| c.sample_data.len() != npx) {
|
||||
return Err(err(
|
||||
"channel sample count mismatch (subsampled channels are unsupported)",
|
||||
));
|
||||
return Err(err("channel sample count mismatch (subsampled channels are unsupported)"));
|
||||
}
|
||||
let nc = color.len();
|
||||
let (w32, h32) = (w as u32, h as u32);
|
||||
|
|
@ -146,18 +123,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
ExrCompression::Zip16 => Compression::ZIP16,
|
||||
ExrCompression::Piz => Compression::PIZ,
|
||||
};
|
||||
let encoding = Encoding {
|
||||
compression,
|
||||
blocks: Blocks::ScanLines,
|
||||
line_order: LineOrder::Increasing,
|
||||
};
|
||||
let encoding = Encoding { compression, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing };
|
||||
let channels = AnyChannels::sort(list.into_iter().collect());
|
||||
let layer = Layer::new((w, h), LayerAttributes::default(), encoding, channels);
|
||||
let image = exr::image::Image::from_layer(layer);
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
image
|
||||
.write()
|
||||
.to_buffered(&mut cursor)
|
||||
.map_err(|e| CodecError::encode(F, e))?;
|
||||
image.write().to_buffered(&mut cursor).map_err(|e| CodecError::encode(F, e))?;
|
||||
Ok(cursor.into_inner())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,16 +72,12 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
|
|||
m.exif = Some(seg[EXIF_HEADER.len()..].to_vec());
|
||||
}
|
||||
0xE1 if seg.starts_with(XMP_HEADER) && m.xmp.is_none() => {
|
||||
m.xmp = std::str::from_utf8(&seg[XMP_HEADER.len()..])
|
||||
.ok()
|
||||
.map(|s| s.trim_end_matches('\0').to_owned());
|
||||
m.xmp = std::str::from_utf8(&seg[XMP_HEADER.len()..]).ok().map(|s| s.trim_end_matches('\0').to_owned());
|
||||
}
|
||||
0xE2 if seg.len() >= 14 && seg.starts_with(ICC_HEADER) => {
|
||||
icc_parts.push((seg[12], seg[13], &seg[14..]));
|
||||
}
|
||||
0xC0..=0xCF
|
||||
if !matches!(marker, 0xC4 | 0xC8 | 0xCC) && seg.len() >= 6 && m.frame.is_none() =>
|
||||
{
|
||||
0xC0..=0xCF if !matches!(marker, 0xC4 | 0xC8 | 0xCC) && seg.len() >= 6 && m.frame.is_none() => {
|
||||
let h = u16::from_be_bytes([seg[1], seg[2]]) as u32;
|
||||
let w = u16::from_be_bytes([seg[3], seg[4]]) as u32;
|
||||
m.frame = Some((w, h, seg[5]));
|
||||
|
|
@ -96,11 +92,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
|
|||
if !icc_parts.is_empty() {
|
||||
icc_parts.sort_by_key(|p| p.0);
|
||||
let total = icc_parts[0].1 as usize;
|
||||
let seqs_ok = icc_parts.len() == total
|
||||
&& icc_parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(k, p)| p.0 as usize == k + 1 && p.1 as usize == total);
|
||||
let seqs_ok = icc_parts.len() == total && icc_parts.iter().enumerate().all(|(k, p)| p.0 as usize == k + 1 && p.1 as usize == total);
|
||||
if seqs_ok || icc_parts.len() == 1 {
|
||||
m.icc = Some(icc_parts.iter().flat_map(|p| p.2.iter().copied()).collect());
|
||||
}
|
||||
|
|
@ -117,10 +109,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
if let Some((w, h, nc)) = meta.frame {
|
||||
limits.check_bytes(w, h, u64::from(nc.max(1)))?;
|
||||
}
|
||||
let options = DecoderOptions::default()
|
||||
.set_strict_mode(false)
|
||||
.set_max_width(65535)
|
||||
.set_max_height(65535);
|
||||
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(65535).set_max_height(65535);
|
||||
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), options);
|
||||
dec.decode_headers().map_err(err)?;
|
||||
let (w, h) = dec.dimensions().ok_or_else(|| err("no dimensions"))?;
|
||||
|
|
@ -144,7 +133,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
// Adobe-style CMYK is stored inverted (255 = no ink).
|
||||
ColorSpace::CMYK => px.iter_mut().for_each(|v| *v = 255 - *v),
|
||||
ColorSpace::YCCK => {
|
||||
for p in px.chunks_exact_mut(4) {
|
||||
for p in px.as_chunks_mut::<4>().0 {
|
||||
let (y, cb, cr) = (p[0] as f32, p[1] as f32 - 128.0, p[2] as f32 - 128.0);
|
||||
let c = y + 1.402 * cr;
|
||||
let m = y - 0.344_136 * cb - 0.714_136 * cr;
|
||||
|
|
@ -159,12 +148,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
}
|
||||
let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?;
|
||||
img.icc = meta.icc;
|
||||
img.meta = Metadata {
|
||||
exif: meta.exif,
|
||||
xmp: meta.xmp,
|
||||
dpi: meta.dpi,
|
||||
text: Vec::new(),
|
||||
};
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, text: Vec::new() };
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
|
|
@ -174,10 +158,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
let (w16, h16) = match (u16::try_from(w), u16::try_from(h)) {
|
||||
(Ok(a), Ok(b)) => (a, b),
|
||||
_ => {
|
||||
return Err(CodecError::encode(
|
||||
F,
|
||||
"JPEG dimensions are limited to 65535",
|
||||
));
|
||||
return Err(CodecError::encode(F, "JPEG dimensions are limited to 65535"));
|
||||
}
|
||||
};
|
||||
let ct = match img.layout() {
|
||||
|
|
@ -202,10 +183,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
&& x >= 1.0
|
||||
&& y >= 1.0
|
||||
{
|
||||
enc.set_density(jpeg_encoder::Density::Inch {
|
||||
x: x.round().min(65535.0) as u16,
|
||||
y: y.round().min(65535.0) as u16,
|
||||
});
|
||||
enc.set_density(jpeg_encoder::Density::Inch { x: x.round().min(65535.0) as u16, y: y.round().min(65535.0) as u16 });
|
||||
}
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
let mut seg = EXIF_HEADER.to_vec();
|
||||
|
|
|
|||
|
|
@ -18,12 +18,7 @@ fn err(e: impl std::fmt::Display) -> CodecError {
|
|||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let mut decoder = png::Decoder::new_with_limits(
|
||||
Cursor::new(bytes),
|
||||
png::Limits {
|
||||
bytes: limits.alloc_usize(),
|
||||
},
|
||||
);
|
||||
let mut decoder = png::Decoder::new_with_limits(Cursor::new(bytes), png::Limits { bytes: limits.alloc_usize() });
|
||||
decoder.set_transformations(png::Transformations::EXPAND);
|
||||
{
|
||||
let info = decoder.read_header_info().map_err(map_png_err)?;
|
||||
|
|
@ -45,9 +40,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
png::BitDepth::Sixteen => SampleType::U16,
|
||||
d => return Err(err(format!("unexpected output bit depth {d:?}"))),
|
||||
};
|
||||
let size = reader
|
||||
.output_buffer_size()
|
||||
.ok_or_else(|| err("image too large"))?;
|
||||
let size = reader.output_buffer_size().ok_or_else(|| err("image too large"))?;
|
||||
let mut buf = vec![0u8; size];
|
||||
let out = reader.next_frame(&mut buf).map_err(map_png_err)?;
|
||||
buf.truncate(out.buffer_size());
|
||||
|
|
@ -55,7 +48,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
// tolerated: pixel data is already complete.
|
||||
let _ = reader.finish();
|
||||
if sample == SampleType::U16 {
|
||||
for c in buf.chunks_exact_mut(2) {
|
||||
for c in buf.as_chunks_mut::<2>().0 {
|
||||
let v = u16::from_be_bytes([c[0], c[1]]);
|
||||
c.copy_from_slice(&v.to_ne_bytes());
|
||||
}
|
||||
|
|
@ -63,19 +56,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
let info = reader.info();
|
||||
let mut img = Image::from_raw(out.width, out.height, layout, sample, buf)?;
|
||||
img.icc = info.icc_profile.as_ref().map(|c| c.to_vec());
|
||||
let mut meta = Metadata {
|
||||
exif: info.exif_metadata.as_ref().map(|c| c.to_vec()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut meta = Metadata { exif: info.exif_metadata.as_ref().map(|c| c.to_vec()), ..Default::default() };
|
||||
if let Some(d) = info.pixel_dims
|
||||
&& d.unit == png::Unit::Meter
|
||||
&& d.xppu > 0
|
||||
&& d.yppu > 0
|
||||
{
|
||||
meta.dpi = Some((
|
||||
d.xppu as f32 * METERS_PER_INCH,
|
||||
d.yppu as f32 * METERS_PER_INCH,
|
||||
));
|
||||
meta.dpi = Some((d.xppu as f32 * METERS_PER_INCH, d.yppu as f32 * METERS_PER_INCH));
|
||||
}
|
||||
for t in &info.uncompressed_latin1_text {
|
||||
meta.text.push((t.keyword.clone(), t.text.clone()));
|
||||
|
|
@ -100,18 +87,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
|
||||
fn map_png_err(e: png::DecodingError) -> CodecError {
|
||||
match e {
|
||||
png::DecodingError::LimitsExceeded => {
|
||||
CodecError::LimitExceeded("PNG decoder memory limit".into())
|
||||
}
|
||||
png::DecodingError::LimitsExceeded => CodecError::LimitExceeded("PNG decoder memory limit".into()),
|
||||
e => err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_latin1_keyword(k: &str) -> bool {
|
||||
!k.is_empty()
|
||||
&& k.len() <= 79
|
||||
&& k.chars()
|
||||
.all(|c| (' '..='~').contains(&c) || ('\u{a1}'..='\u{ff}').contains(&c))
|
||||
!k.is_empty() && k.len() <= 79 && k.chars().all(|c| (' '..='~').contains(&c) || ('\u{a1}'..='\u{ff}').contains(&c))
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
|
|
@ -132,7 +114,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
// Big-endian sample bytes.
|
||||
let mut data = img.data().to_vec();
|
||||
if depth == png::BitDepth::Sixteen {
|
||||
for c in data.chunks_exact_mut(2) {
|
||||
for c in data.as_chunks_mut::<2>().0 {
|
||||
let v = u16::from_ne_bytes([c[0], c[1]]);
|
||||
c.copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
|
|
@ -155,18 +137,14 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
&& x > 0.0
|
||||
&& y > 0.0
|
||||
{
|
||||
info.pixel_dims = Some(png::PixelDimensions {
|
||||
xppu: (x / METERS_PER_INCH).round() as u32,
|
||||
yppu: (y / METERS_PER_INCH).round() as u32,
|
||||
unit: png::Unit::Meter,
|
||||
});
|
||||
info.pixel_dims =
|
||||
Some(png::PixelDimensions { xppu: (x / METERS_PER_INCH).round() as u32, yppu: (y / METERS_PER_INCH).round() as u32, unit: png::Unit::Meter });
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut encoder =
|
||||
png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
|
||||
let mut encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
|
||||
encoder.set_compression(match opts.png_compression {
|
||||
PngCompression::None => png::Compression::NoCompression,
|
||||
PngCompression::Fast => png::Compression::Fast,
|
||||
|
|
@ -186,28 +164,18 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
res.map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
encoder
|
||||
.add_itxt_chunk(XMP_KEYWORD.into(), xmp.clone())
|
||||
.map_err(|e| CodecError::encode(F, e))?;
|
||||
encoder.add_itxt_chunk(XMP_KEYWORD.into(), xmp.clone()).map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
}
|
||||
let mut writer = encoder
|
||||
.write_header()
|
||||
.map_err(|e| CodecError::encode(F, e))?;
|
||||
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
|
||||
if opts.png_interlaced {
|
||||
let bpp = img.layout().channels() * img.sample_type().bytes();
|
||||
let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?;
|
||||
writer
|
||||
.write_chunk(png::chunk::IDAT, &idat)
|
||||
.map_err(|e| CodecError::encode(F, e))?;
|
||||
writer.write_chunk(png::chunk::IDAT, &idat).map_err(|e| CodecError::encode(F, e))?;
|
||||
} else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, img.layout().channels() * img.sample_type().bytes(), opts.png_compression) {
|
||||
writer
|
||||
.write_chunk(png::chunk::IDAT, &idat)
|
||||
.map_err(|e| CodecError::encode(F, e))?;
|
||||
writer.write_chunk(png::chunk::IDAT, &idat).map_err(|e| CodecError::encode(F, e))?;
|
||||
} else {
|
||||
writer
|
||||
.write_image_data(&data)
|
||||
.map_err(|e| CodecError::encode(F, e))?;
|
||||
writer.write_image_data(&data).map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
writer.finish().map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
|
|
@ -215,23 +183,9 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
}
|
||||
|
||||
/// Build the zlib stream for an Adam7-interlaced image (filter type 0).
|
||||
fn adam7_idat(
|
||||
data: &[u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
bpp: usize,
|
||||
level: PngCompression,
|
||||
) -> Result<Vec<u8>, CodecError> {
|
||||
fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression) -> Result<Vec<u8>, CodecError> {
|
||||
// (x0, y0, dx, dy) per pass.
|
||||
const PASSES: [(usize, usize, usize, usize); 7] = [
|
||||
(0, 0, 8, 8),
|
||||
(4, 0, 8, 8),
|
||||
(0, 4, 4, 8),
|
||||
(2, 0, 4, 4),
|
||||
(0, 2, 2, 4),
|
||||
(1, 0, 2, 2),
|
||||
(0, 1, 1, 2),
|
||||
];
|
||||
const PASSES: [(usize, usize, usize, usize); 7] = [(0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, 8), (2, 0, 4, 4), (0, 2, 2, 4), (1, 0, 2, 2), (0, 1, 1, 2)];
|
||||
let mut raw = Vec::with_capacity(data.len() + h * 7);
|
||||
for (x0, y0, dx, dy) in PASSES {
|
||||
if x0 >= w || y0 >= h {
|
||||
|
|
@ -357,7 +311,13 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o
|
|||
let paeth = |a: u8, b: u8, c: u8| {
|
||||
let p = i16::from(a) + i16::from(b) - i16::from(c);
|
||||
let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs());
|
||||
if pa <= pb && pa <= pc { a } else if pb <= pc { b } else { c }
|
||||
if pa <= pb && pa <= pc {
|
||||
a
|
||||
} else if pb <= pc {
|
||||
b
|
||||
} else {
|
||||
c
|
||||
}
|
||||
};
|
||||
let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::<u64>();
|
||||
let mut best = (cost(row), 0u8);
|
||||
|
|
|
|||
|
|
@ -27,10 +27,7 @@ impl<'a> Reader<'a> {
|
|||
while self.pos < self.b.len() {
|
||||
let c = self.b[self.pos];
|
||||
if c == b'#' {
|
||||
while self.pos < self.b.len()
|
||||
&& self.b[self.pos] != b'\n'
|
||||
&& self.b[self.pos] != b'\r'
|
||||
{
|
||||
while self.pos < self.b.len() && self.b[self.pos] != b'\n' && self.b[self.pos] != b'\r' {
|
||||
self.pos += 1;
|
||||
}
|
||||
} else if c.is_ascii_whitespace() {
|
||||
|
|
@ -44,10 +41,7 @@ impl<'a> Reader<'a> {
|
|||
fn token(&mut self) -> Result<&'a [u8], CodecError> {
|
||||
self.skip_ws_and_comments();
|
||||
let start = self.pos;
|
||||
while self.pos < self.b.len()
|
||||
&& !self.b[self.pos].is_ascii_whitespace()
|
||||
&& self.b[self.pos] != b'#'
|
||||
{
|
||||
while self.pos < self.b.len() && !self.b[self.pos].is_ascii_whitespace() && self.b[self.pos] != b'#' {
|
||||
self.pos += 1;
|
||||
}
|
||||
if start == self.pos {
|
||||
|
|
@ -58,10 +52,7 @@ impl<'a> Reader<'a> {
|
|||
|
||||
fn uint(&mut self) -> Result<u32, CodecError> {
|
||||
let t = self.token()?;
|
||||
std::str::from_utf8(t)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.ok_or_else(|| err("invalid integer in header"))
|
||||
std::str::from_utf8(t).ok().and_then(|s| s.parse::<u32>().ok()).ok_or_else(|| err("invalid integer in header"))
|
||||
}
|
||||
|
||||
/// Consume the single whitespace byte that separates header and raster.
|
||||
|
|
@ -89,45 +80,16 @@ fn sample_for_maxval(maxval: u32) -> Result<SampleType, CodecError> {
|
|||
}
|
||||
|
||||
/// Build an image from raw integer values in `[0, maxval]`.
|
||||
fn from_values(
|
||||
w: u32,
|
||||
h: u32,
|
||||
layout: ChannelLayout,
|
||||
maxval: u32,
|
||||
values: Vec<u32>,
|
||||
) -> Result<Image, CodecError> {
|
||||
fn from_values(w: u32, h: u32, layout: ChannelLayout, maxval: u32, values: Vec<u32>) -> Result<Image, CodecError> {
|
||||
let sample = sample_for_maxval(maxval)?;
|
||||
if values.iter().any(|&v| v > maxval) {
|
||||
return Err(err("sample exceeds maxval"));
|
||||
}
|
||||
match (sample, maxval) {
|
||||
(SampleType::U8, 255) => {
|
||||
Image::from_u8(w, h, layout, values.into_iter().map(|v| v as u8).collect())
|
||||
}
|
||||
(SampleType::U16, 65535) => Image::from_u16(
|
||||
w,
|
||||
h,
|
||||
layout,
|
||||
&values.into_iter().map(|v| v as u16).collect::<Vec<_>>(),
|
||||
),
|
||||
(SampleType::U8, m) => Image::from_u8(
|
||||
w,
|
||||
h,
|
||||
layout,
|
||||
values
|
||||
.into_iter()
|
||||
.map(|v| ((v * 255 + m / 2) / m) as u8)
|
||||
.collect(),
|
||||
),
|
||||
(_, m) => Image::from_u16(
|
||||
w,
|
||||
h,
|
||||
layout,
|
||||
&values
|
||||
.into_iter()
|
||||
.map(|v| ((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
(SampleType::U8, 255) => Image::from_u8(w, h, layout, values.into_iter().map(|v| v as u8).collect()),
|
||||
(SampleType::U16, 65535) => Image::from_u16(w, h, layout, &values.into_iter().map(|v| v as u16).collect::<Vec<_>>()),
|
||||
(SampleType::U8, m) => Image::from_u8(w, h, layout, values.into_iter().map(|v| ((v * 255 + m / 2) / m) as u8).collect()),
|
||||
(_, m) => Image::from_u16(w, h, layout, &values.into_iter().map(|v| ((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16).collect::<Vec<_>>()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,10 +103,7 @@ fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Vec<u32>, CodecErro
|
|||
if data.len() < n * 2 {
|
||||
return Err(err("truncated raster"));
|
||||
}
|
||||
Ok(data[..n * 2]
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_be_bytes([c[0], c[1]]) as u32)
|
||||
.collect())
|
||||
Ok(data[..n * 2].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).collect())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,11 +148,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
}
|
||||
b'2' | b'3' | b'5' | b'6' => {
|
||||
let (w, h, maxval) = (r.uint()?, r.uint()?, r.uint()?);
|
||||
let layout = if matches!(kind, b'2' | b'5') {
|
||||
ChannelLayout::Gray
|
||||
} else {
|
||||
ChannelLayout::Rgb
|
||||
};
|
||||
let layout = if matches!(kind, b'2' | b'5') { ChannelLayout::Gray } else { ChannelLayout::Rgb };
|
||||
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
|
||||
let n = w as usize * h as usize * layout.channels();
|
||||
let values = if matches!(kind, b'5' | b'6') {
|
||||
|
|
@ -210,11 +165,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
}
|
||||
b'7' => decode_pam(&mut r, limits),
|
||||
b'F' | b'f' => {
|
||||
let layout = if kind == b'F' {
|
||||
ChannelLayout::Rgb
|
||||
} else {
|
||||
ChannelLayout::Gray
|
||||
};
|
||||
let layout = if kind == b'F' { ChannelLayout::Rgb } else { ChannelLayout::Gray };
|
||||
let (w, h) = (r.uint()?, r.uint()?);
|
||||
let scale: f32 = std::str::from_utf8(r.token()?)
|
||||
.ok()
|
||||
|
|
@ -237,11 +188,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
for i in 0..row {
|
||||
let o = (y * row + i) * 4;
|
||||
let b4 = [data[o], data[o + 1], data[o + 2], data[o + 3]];
|
||||
out[dst + i] = if le {
|
||||
f32::from_le_bytes(b4)
|
||||
} else {
|
||||
f32::from_be_bytes(b4)
|
||||
};
|
||||
out[dst + i] = if le { f32::from_le_bytes(b4) } else { f32::from_be_bytes(b4) };
|
||||
}
|
||||
}
|
||||
Image::from_f32(w, h, layout, &out)
|
||||
|
|
@ -284,10 +231,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
|
|||
("CMYK", 4) => ChannelLayout::Cmyk,
|
||||
("CMYK_ALPHA", 5) => ChannelLayout::CmykA,
|
||||
(t, d) => {
|
||||
return Err(CodecError::unsupported(
|
||||
F,
|
||||
format!("PAM tuple type {t:?} with depth {d}"),
|
||||
));
|
||||
return Err(CodecError::unsupported(F, format!("PAM tuple type {t:?} with depth {d}")));
|
||||
}
|
||||
};
|
||||
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
|
||||
|
|
@ -296,11 +240,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
|
|||
from_values(w, h, layout, maxval, values)
|
||||
}
|
||||
|
||||
pub(crate) fn encode(
|
||||
src: &Image,
|
||||
plan: Plan,
|
||||
_opts: &EncodeOptions,
|
||||
) -> Result<Vec<u8>, CodecError> {
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, _opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.convert(plan.layout, plan.sample);
|
||||
let (w, h) = img.dimensions();
|
||||
let layout = img.layout();
|
||||
|
|
@ -324,12 +264,8 @@ pub(crate) fn encode(
|
|||
sample @ (SampleType::U8 | SampleType::U16) => {
|
||||
let maxval = if sample == SampleType::U8 { 255 } else { 65535 };
|
||||
match layout {
|
||||
ChannelLayout::Gray => {
|
||||
out.extend_from_slice(format!("P5\n{w} {h}\n{maxval}\n").as_bytes())
|
||||
}
|
||||
ChannelLayout::Rgb => {
|
||||
out.extend_from_slice(format!("P6\n{w} {h}\n{maxval}\n").as_bytes())
|
||||
}
|
||||
ChannelLayout::Gray => out.extend_from_slice(format!("P5\n{w} {h}\n{maxval}\n").as_bytes()),
|
||||
ChannelLayout::Rgb => out.extend_from_slice(format!("P6\n{w} {h}\n{maxval}\n").as_bytes()),
|
||||
l => {
|
||||
let t = match l {
|
||||
ChannelLayout::GrayA => "GRAYSCALE_ALPHA",
|
||||
|
|
@ -337,13 +273,7 @@ pub(crate) fn encode(
|
|||
ChannelLayout::Cmyk => "CMYK",
|
||||
_ => "CMYK_ALPHA",
|
||||
};
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"P7\nWIDTH {w}\nHEIGHT {h}\nDEPTH {}\nMAXVAL {maxval}\nTUPLTYPE {t}\nENDHDR\n",
|
||||
l.channels()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
out.extend_from_slice(format!("P7\nWIDTH {w}\nHEIGHT {h}\nDEPTH {}\nMAXVAL {maxval}\nTUPLTYPE {t}\nENDHDR\n", l.channels()).as_bytes());
|
||||
}
|
||||
}
|
||||
if sample == SampleType::U8 {
|
||||
|
|
|
|||
|
|
@ -79,19 +79,11 @@ fn camera_raw(b: &[u8]) -> bool {
|
|||
};
|
||||
let u16_at = |o: usize| {
|
||||
let s = b.get(o..o.checked_add(2)?)?;
|
||||
Some(if le {
|
||||
u16::from_le_bytes([s[0], s[1]])
|
||||
} else {
|
||||
u16::from_be_bytes([s[0], s[1]])
|
||||
})
|
||||
Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) })
|
||||
};
|
||||
let u32_at = |o: usize| {
|
||||
let s: [u8; 4] = b.get(o..o.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if le {
|
||||
u32::from_le_bytes(s)
|
||||
} else {
|
||||
u32::from_be_bytes(s)
|
||||
})
|
||||
Some(if le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) })
|
||||
};
|
||||
// CR2: "CR" and major version 2 right after the TIFF header.
|
||||
if b.get(8..11) == Some(b"CR\x02") {
|
||||
|
|
@ -120,9 +112,7 @@ fn camera_raw(b: &[u8]) -> bool {
|
|||
};
|
||||
match tag {
|
||||
TAG_DNG_VERSION => return true,
|
||||
TAG_PHOTOMETRIC
|
||||
if matches!(value(e), Some(PHOTOMETRIC_CFA | PHOTOMETRIC_LINEAR_RAW)) =>
|
||||
{
|
||||
TAG_PHOTOMETRIC if matches!(value(e), Some(PHOTOMETRIC_CFA | PHOTOMETRIC_LINEAR_RAW)) => {
|
||||
return true;
|
||||
}
|
||||
TAG_SUB_IFDS if count == 1 => queue.extend(value(e)),
|
||||
|
|
@ -136,8 +126,7 @@ fn camera_raw(b: &[u8]) -> bool {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(next) = u32_at(ifd.saturating_add(2 + 12 * usize::from(n))).filter(|&o| o != 0)
|
||||
{
|
||||
if let Some(next) = u32_at(ifd.saturating_add(2 + 12 * usize::from(n))).filter(|&o| o != 0) {
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
|
|
@ -146,10 +135,7 @@ fn camera_raw(b: &[u8]) -> bool {
|
|||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
if camera_raw(bytes) {
|
||||
return Err(CodecError::unsupported(
|
||||
F,
|
||||
"camera raw files (such as CR2, NEF, ARW or DNG) are not supported",
|
||||
));
|
||||
return Err(CodecError::unsupported(F, "camera raw files (such as CR2, NEF, ARW or DNG) are not supported"));
|
||||
}
|
||||
let tl = {
|
||||
let mut l = tiff::decoder::Limits::default();
|
||||
|
|
@ -157,9 +143,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
l.intermediate_buffer_size = limits.alloc_usize().min(1 << 30);
|
||||
l
|
||||
};
|
||||
let mut dec = Decoder::new(Cursor::new(bytes))
|
||||
.map_err(map_err)?
|
||||
.with_limits(tl);
|
||||
let mut dec = Decoder::new(Cursor::new(bytes)).map_err(map_err)?.with_limits(tl);
|
||||
let (w, h) = dec.dimensions().map_err(map_err)?;
|
||||
let ct = dec.colortype().map_err(map_err)?;
|
||||
let (layout, bits) = match ct {
|
||||
|
|
@ -170,27 +154,18 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
tiff::ColorType::CMYK(b) => (ChannelLayout::Cmyk, b),
|
||||
tiff::ColorType::CMYKA(b) => (ChannelLayout::CmykA, b),
|
||||
// The decoder reports gray+alpha as multiband (it ignores ExtraSamples).
|
||||
tiff::ColorType::Multiband {
|
||||
bit_depth,
|
||||
num_samples: 2,
|
||||
} => (ChannelLayout::GrayA, bit_depth),
|
||||
tiff::ColorType::Multiband { bit_depth, num_samples: 2 } => (ChannelLayout::GrayA, bit_depth),
|
||||
other => return Err(CodecError::unsupported(F, format!("colour type {other:?}"))),
|
||||
};
|
||||
let bytes_per_sample = u64::from(bits.max(8) / 8);
|
||||
limits.check_bytes(w, h, layout.channels() as u64 * bytes_per_sample.max(1))?;
|
||||
let white_is_zero = dec
|
||||
.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation)
|
||||
.ok()
|
||||
.flatten()
|
||||
== Some(PhotometricInterpretation::WhiteIsZero.to_u16());
|
||||
let white_is_zero = dec.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten() == Some(PhotometricInterpretation::WhiteIsZero.to_u16());
|
||||
|
||||
let result = dec.read_image().map_err(map_err)?;
|
||||
let n = w as usize * h as usize * layout.channels();
|
||||
let mut img = match result {
|
||||
DecodingResult::U8(v) if bits == 8 => Image::from_u8(w, h, layout, v)?,
|
||||
DecodingResult::U8(v) if bits < 8 && layout == ChannelLayout::Gray => {
|
||||
Image::from_u8(w, h, layout, unpack_bits(&v, w as usize, h as usize, bits)?)?
|
||||
}
|
||||
DecodingResult::U8(v) if bits < 8 && layout == ChannelLayout::Gray => Image::from_u8(w, h, layout, unpack_bits(&v, w as usize, h as usize, bits)?)?,
|
||||
DecodingResult::U16(v) => Image::from_u16(w, h, layout, &v)?,
|
||||
DecodingResult::U32(v) => {
|
||||
let v: Vec<u16> = v.iter().map(|&x| (x >> 16) as u16).collect();
|
||||
|
|
@ -203,10 +178,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
Image::from_f32(w, h, layout, &v)?
|
||||
}
|
||||
_ => {
|
||||
return Err(CodecError::unsupported(
|
||||
F,
|
||||
format!("sample format for {ct:?}"),
|
||||
));
|
||||
return Err(CodecError::unsupported(F, format!("sample format for {ct:?}")));
|
||||
}
|
||||
};
|
||||
if img.sample_count() != n {
|
||||
|
|
@ -225,11 +197,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
img = Image::from_normalized(w, h, layout, img.sample_type(), &v)?;
|
||||
}
|
||||
|
||||
img.icc = dec
|
||||
.find_tag(Tag::IccProfile)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(value_bytes);
|
||||
img.icc = dec.find_tag(Tag::IccProfile).ok().flatten().and_then(value_bytes);
|
||||
let mut meta = Metadata {
|
||||
xmp: dec
|
||||
.find_tag(Tag::Unknown(TAG_XMP))
|
||||
|
|
@ -240,21 +208,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
.map(|s| s.trim_end_matches('\0').to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let unit = dec
|
||||
.find_tag_unsigned::<u16>(Tag::ResolutionUnit)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(2);
|
||||
let xr = dec
|
||||
.find_tag(Tag::XResolution)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(rational_f64);
|
||||
let yr = dec
|
||||
.find_tag(Tag::YResolution)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(rational_f64);
|
||||
let unit = dec.find_tag_unsigned::<u16>(Tag::ResolutionUnit).ok().flatten().unwrap_or(2);
|
||||
let xr = dec.find_tag(Tag::XResolution).ok().flatten().and_then(rational_f64);
|
||||
let yr = dec.find_tag(Tag::YResolution).ok().flatten().and_then(rational_f64);
|
||||
if let (Some(x), Some(y)) = (xr, yr)
|
||||
&& x > 0.0
|
||||
&& y > 0.0
|
||||
|
|
@ -266,12 +222,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
};
|
||||
}
|
||||
for (tag, key) in TEXT_TAGS {
|
||||
if let Some(s) = dec
|
||||
.find_tag(*tag)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|v| v.into_string().ok())
|
||||
{
|
||||
if let Some(s) = dec.find_tag(*tag).ok().flatten().and_then(|v| v.into_string().ok()) {
|
||||
let s = s.trim_end_matches('\0').to_owned();
|
||||
if !s.is_empty() {
|
||||
meta.text.push(((*key).to_owned(), s));
|
||||
|
|
@ -367,46 +318,11 @@ macro_rules! custom_colortype {
|
|||
}
|
||||
|
||||
use PhotometricInterpretation as PI;
|
||||
custom_colortype!(
|
||||
GrayA8,
|
||||
u8,
|
||||
PI::BlackIsZero,
|
||||
&[8, 8],
|
||||
&[SampleFormat::Uint; 2],
|
||||
int
|
||||
);
|
||||
custom_colortype!(
|
||||
GrayA16,
|
||||
u16,
|
||||
PI::BlackIsZero,
|
||||
&[16, 16],
|
||||
&[SampleFormat::Uint; 2],
|
||||
int
|
||||
);
|
||||
custom_colortype!(
|
||||
GrayA32F,
|
||||
f32,
|
||||
PI::BlackIsZero,
|
||||
&[32, 32],
|
||||
&[SampleFormat::IEEEFP; 2],
|
||||
float
|
||||
);
|
||||
custom_colortype!(
|
||||
CmykA16,
|
||||
u16,
|
||||
PI::CMYK,
|
||||
&[16; 5],
|
||||
&[SampleFormat::Uint; 5],
|
||||
int
|
||||
);
|
||||
custom_colortype!(
|
||||
CmykA32F,
|
||||
f32,
|
||||
PI::CMYK,
|
||||
&[32; 5],
|
||||
&[SampleFormat::IEEEFP; 5],
|
||||
float
|
||||
);
|
||||
custom_colortype!(GrayA8, u8, PI::BlackIsZero, &[8, 8], &[SampleFormat::Uint; 2], int);
|
||||
custom_colortype!(GrayA16, u16, PI::BlackIsZero, &[16, 16], &[SampleFormat::Uint; 2], int);
|
||||
custom_colortype!(GrayA32F, f32, PI::BlackIsZero, &[32, 32], &[SampleFormat::IEEEFP; 2], float);
|
||||
custom_colortype!(CmykA16, u16, PI::CMYK, &[16; 5], &[SampleFormat::Uint; 5], int);
|
||||
custom_colortype!(CmykA32F, f32, PI::CMYK, &[32; 5], &[SampleFormat::IEEEFP; 5], float);
|
||||
|
||||
struct TagSet<'a> {
|
||||
icc: Option<&'a [u8]>,
|
||||
|
|
@ -416,13 +332,7 @@ struct TagSet<'a> {
|
|||
alpha: bool,
|
||||
}
|
||||
|
||||
fn write_one<C>(
|
||||
enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>>,
|
||||
w: u32,
|
||||
h: u32,
|
||||
data: &[C::Inner],
|
||||
tags: &TagSet<'_>,
|
||||
) -> tiff::TiffResult<()>
|
||||
fn write_one<C>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>>, w: u32, h: u32, data: &[C::Inner], tags: &TagSet<'_>) -> tiff::TiffResult<()>
|
||||
where
|
||||
C: TiffColorType,
|
||||
[C::Inner]: TiffValue,
|
||||
|
|
@ -430,14 +340,8 @@ where
|
|||
let mut im = enc.new_image::<C>(w, h)?;
|
||||
if let Some((x, y)) = tags.dpi {
|
||||
im.resolution_unit(ResolutionUnit::Inch);
|
||||
im.x_resolution(Rational {
|
||||
n: (x * 1000.0).round() as u32,
|
||||
d: 1000,
|
||||
});
|
||||
im.y_resolution(Rational {
|
||||
n: (y * 1000.0).round() as u32,
|
||||
d: 1000,
|
||||
});
|
||||
im.x_resolution(Rational { n: (x * 1000.0).round() as u32, d: 1000 });
|
||||
im.y_resolution(Rational { n: (y * 1000.0).round() as u32, d: 1000 });
|
||||
}
|
||||
let d = im.encoder();
|
||||
if tags.alpha {
|
||||
|
|
@ -462,16 +366,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
let compression = match opts.tiff_compression {
|
||||
TiffCompression::None => tiff::encoder::Compression::Uncompressed,
|
||||
TiffCompression::Lzw => tiff::encoder::Compression::Lzw,
|
||||
TiffCompression::Deflate => {
|
||||
tiff::encoder::Compression::Deflate(tiff::encoder::DeflateLevel::Balanced)
|
||||
}
|
||||
TiffCompression::Deflate => tiff::encoder::Compression::Deflate(tiff::encoder::DeflateLevel::Balanced),
|
||||
TiffCompression::PackBits => tiff::encoder::Compression::Packbits,
|
||||
};
|
||||
let predictor = if !img.sample_type().is_float()
|
||||
&& matches!(
|
||||
opts.tiff_compression,
|
||||
TiffCompression::Lzw | TiffCompression::Deflate
|
||||
) {
|
||||
let predictor = if !img.sample_type().is_float() && matches!(opts.tiff_compression, TiffCompression::Lzw | TiffCompression::Deflate) {
|
||||
tiff::encoder::Predictor::Horizontal
|
||||
} else {
|
||||
tiff::encoder::Predictor::None
|
||||
|
|
@ -480,43 +378,23 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
img.meta
|
||||
.text
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
TEXT_TAGS
|
||||
.iter()
|
||||
.find(|(_, key)| key.eq_ignore_ascii_case(k))
|
||||
.map(|(t, _)| (*t, v.as_str()))
|
||||
})
|
||||
.filter_map(|(k, v)| TEXT_TAGS.iter().find(|(_, key)| key.eq_ignore_ascii_case(k)).map(|(t, _)| (*t, v.as_str())))
|
||||
.filter(|(_, v)| v.is_ascii() && !v.contains('\0'))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let tags = TagSet {
|
||||
icc: if opts.embed_icc {
|
||||
img.icc.as_deref()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
xmp: if opts.embed_metadata {
|
||||
img.meta.xmp.as_deref().map(str::as_bytes)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
dpi: if opts.embed_metadata {
|
||||
img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
icc: if opts.embed_icc { img.icc.as_deref() } else { None },
|
||||
xmp: if opts.embed_metadata { img.meta.xmp.as_deref().map(str::as_bytes) } else { None },
|
||||
dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None },
|
||||
text,
|
||||
alpha: img.layout().has_alpha(),
|
||||
};
|
||||
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut enc = TiffEncoder::new(&mut cursor)
|
||||
.map_err(|e| CodecError::encode(F, e))?
|
||||
.with_compression(compression)
|
||||
.with_predictor(predictor);
|
||||
let mut enc = TiffEncoder::new(&mut cursor).map_err(|e| CodecError::encode(F, e))?.with_compression(compression).with_predictor(predictor);
|
||||
use ChannelLayout as L;
|
||||
use SampleType as S;
|
||||
let u16s = || img.to_u16_samples().unwrap_or_default();
|
||||
|
|
@ -525,9 +403,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
let res = match (img.layout(), img.sample_type()) {
|
||||
(L::Gray, S::U8) => write_one::<colortype::Gray8>(&mut enc, w, h, d8, &tags),
|
||||
(L::Gray, S::U16) => write_one::<colortype::Gray16>(&mut enc, w, h, &u16s(), &tags),
|
||||
(L::Gray, S::F32) => {
|
||||
write_one::<colortype::Gray32Float>(&mut enc, w, h, &f32s(), &tags)
|
||||
}
|
||||
(L::Gray, S::F32) => write_one::<colortype::Gray32Float>(&mut enc, w, h, &f32s(), &tags),
|
||||
(L::GrayA, S::U8) => write_one::<GrayA8>(&mut enc, w, h, d8, &tags),
|
||||
(L::GrayA, S::U16) => write_one::<GrayA16>(&mut enc, w, h, &u16s(), &tags),
|
||||
(L::GrayA, S::F32) => write_one::<GrayA32F>(&mut enc, w, h, &f32s(), &tags),
|
||||
|
|
@ -536,14 +412,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
(L::Rgb, S::F32) => write_one::<colortype::RGB32Float>(&mut enc, w, h, &f32s(), &tags),
|
||||
(L::Rgba, S::U8) => write_one::<colortype::RGBA8>(&mut enc, w, h, d8, &tags),
|
||||
(L::Rgba, S::U16) => write_one::<colortype::RGBA16>(&mut enc, w, h, &u16s(), &tags),
|
||||
(L::Rgba, S::F32) => {
|
||||
write_one::<colortype::RGBA32Float>(&mut enc, w, h, &f32s(), &tags)
|
||||
}
|
||||
(L::Rgba, S::F32) => write_one::<colortype::RGBA32Float>(&mut enc, w, h, &f32s(), &tags),
|
||||
(L::Cmyk, S::U8) => write_one::<colortype::CMYK8>(&mut enc, w, h, d8, &tags),
|
||||
(L::Cmyk, S::U16) => write_one::<colortype::CMYK16>(&mut enc, w, h, &u16s(), &tags),
|
||||
(L::Cmyk, S::F32) => {
|
||||
write_one::<colortype::CMYK32Float>(&mut enc, w, h, &f32s(), &tags)
|
||||
}
|
||||
(L::Cmyk, S::F32) => write_one::<colortype::CMYK32Float>(&mut enc, w, h, &f32s(), &tags),
|
||||
(L::CmykA, S::U8) => write_one::<colortype::CMYKA8>(&mut enc, w, h, d8, &tags),
|
||||
(L::CmykA, S::U16) => write_one::<CmykA16>(&mut enc, w, h, &u16s(), &tags),
|
||||
(L::CmykA, S::F32) => write_one::<CmykA32F>(&mut enc, w, h, &f32s(), &tags),
|
||||
|
|
|
|||
|
|
@ -33,8 +33,7 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError {
|
|||
}
|
||||
|
||||
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let fmt = image_format(f)
|
||||
.ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
|
||||
let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
|
||||
let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt);
|
||||
let mut il = image::Limits::default();
|
||||
il.max_image_width = Some(limits.max_width);
|
||||
|
|
@ -61,17 +60,14 @@ pub(crate) fn from_dynamic(f: Format, d: DynamicImage) -> Result<Image, CodecErr
|
|||
DynamicImage::ImageRgb8(b) => Image::from_u8(w, h, ChannelLayout::Rgb, b.into_raw())?,
|
||||
DynamicImage::ImageRgba8(b) => Image::from_u8(w, h, ChannelLayout::Rgba, b.into_raw())?,
|
||||
DynamicImage::ImageLuma16(b) => Image::from_u16(w, h, ChannelLayout::Gray, &b.into_raw())?,
|
||||
DynamicImage::ImageLumaA16(b) => {
|
||||
Image::from_u16(w, h, ChannelLayout::GrayA, &b.into_raw())?
|
||||
}
|
||||
DynamicImage::ImageLumaA16(b) => Image::from_u16(w, h, ChannelLayout::GrayA, &b.into_raw())?,
|
||||
DynamicImage::ImageRgb16(b) => Image::from_u16(w, h, ChannelLayout::Rgb, &b.into_raw())?,
|
||||
DynamicImage::ImageRgba16(b) => Image::from_u16(w, h, ChannelLayout::Rgba, &b.into_raw())?,
|
||||
DynamicImage::ImageRgb32F(b) => Image::from_f32(w, h, ChannelLayout::Rgb, &b.into_raw())?,
|
||||
DynamicImage::ImageRgba32F(b) => Image::from_f32(w, h, ChannelLayout::Rgba, &b.into_raw())?,
|
||||
other => {
|
||||
let b = other.into_rgba32f();
|
||||
Image::from_f32(w, h, ChannelLayout::Rgba, &b.into_raw())
|
||||
.map_err(|e| CodecError::malformed(f, e))?
|
||||
Image::from_f32(w, h, ChannelLayout::Rgba, &b.into_raw()).map_err(|e| CodecError::malformed(f, e))?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -80,49 +76,28 @@ fn to_dynamic(img: &Image) -> Result<DynamicImage, CodecError> {
|
|||
let (w, h) = img.dimensions();
|
||||
let bad = || CodecError::InvalidImage("buffer size mismatch".into());
|
||||
Ok(match (img.layout(), img.sample_type()) {
|
||||
(ChannelLayout::Gray, SampleType::U8) => DynamicImage::ImageLuma8(
|
||||
image::GrayImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?,
|
||||
),
|
||||
(ChannelLayout::GrayA, SampleType::U8) => DynamicImage::ImageLumaA8(
|
||||
image::GrayAlphaImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?,
|
||||
),
|
||||
(ChannelLayout::Rgb, SampleType::U8) => DynamicImage::ImageRgb8(
|
||||
image::RgbImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?,
|
||||
),
|
||||
(ChannelLayout::Rgba, SampleType::U8) => DynamicImage::ImageRgba8(
|
||||
image::RgbaImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?,
|
||||
),
|
||||
(ChannelLayout::Rgb, SampleType::F32) => DynamicImage::ImageRgb32F(
|
||||
image::Rgb32FImage::from_raw(w, h, img.to_f32_samples().unwrap_or_default())
|
||||
.ok_or_else(bad)?,
|
||||
),
|
||||
(ChannelLayout::Rgba, SampleType::F32) => DynamicImage::ImageRgba32F(
|
||||
image::Rgba32FImage::from_raw(w, h, img.to_f32_samples().unwrap_or_default())
|
||||
.ok_or_else(bad)?,
|
||||
),
|
||||
(ChannelLayout::Gray, SampleType::U8) => DynamicImage::ImageLuma8(image::GrayImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?),
|
||||
(ChannelLayout::GrayA, SampleType::U8) => DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?),
|
||||
(ChannelLayout::Rgb, SampleType::U8) => DynamicImage::ImageRgb8(image::RgbImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?),
|
||||
(ChannelLayout::Rgba, SampleType::U8) => DynamicImage::ImageRgba8(image::RgbaImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?),
|
||||
(ChannelLayout::Rgb, SampleType::F32) => {
|
||||
DynamicImage::ImageRgb32F(image::Rgb32FImage::from_raw(w, h, img.to_f32_samples().unwrap_or_default()).ok_or_else(bad)?)
|
||||
}
|
||||
(ChannelLayout::Rgba, SampleType::F32) => {
|
||||
DynamicImage::ImageRgba32F(image::Rgba32FImage::from_raw(w, h, img.to_f32_samples().unwrap_or_default()).ok_or_else(bad)?)
|
||||
}
|
||||
(l, s) => {
|
||||
return Err(CodecError::InvalidImage(format!(
|
||||
"no image-crate mapping for {l:?} {s:?}"
|
||||
)));
|
||||
return Err(CodecError::InvalidImage(format!("no image-crate mapping for {l:?} {s:?}")));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn encode(
|
||||
f: Format,
|
||||
src: &Image,
|
||||
plan: Plan,
|
||||
opts: &EncodeOptions,
|
||||
) -> Result<Vec<u8>, CodecError> {
|
||||
let fmt = image_format(f)
|
||||
.ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
|
||||
pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
|
||||
if let Some((mw, mh)) = f.max_dimensions()
|
||||
&& (src.width() > mw || src.height() > mh)
|
||||
{
|
||||
return Err(CodecError::encode(
|
||||
f,
|
||||
format!("dimensions exceed {mw}x{mh}"),
|
||||
));
|
||||
return Err(CodecError::encode(f, format!("dimensions exceed {mw}x{mh}")));
|
||||
}
|
||||
let img = src.convert(plan.layout, plan.sample);
|
||||
let dynimg = to_dynamic(&img)?;
|
||||
|
|
@ -130,20 +105,12 @@ pub(crate) fn encode(
|
|||
match f {
|
||||
#[cfg(feature = "avif")]
|
||||
Format::Avif => {
|
||||
let enc = image::codecs::avif::AvifEncoder::new_with_speed_quality(
|
||||
&mut cursor,
|
||||
8,
|
||||
opts.jpeg_quality.clamp(1, 100),
|
||||
);
|
||||
dynimg
|
||||
.write_with_encoder(enc)
|
||||
.map_err(|e| CodecError::encode(f, e))?;
|
||||
let enc = image::codecs::avif::AvifEncoder::new_with_speed_quality(&mut cursor, 8, opts.jpeg_quality.clamp(1, 100));
|
||||
dynimg.write_with_encoder(enc).map_err(|e| CodecError::encode(f, e))?;
|
||||
}
|
||||
_ => {
|
||||
let _ = opts;
|
||||
dynimg
|
||||
.write_to(&mut cursor, fmt)
|
||||
.map_err(|e| CodecError::encode(f, e))?;
|
||||
dynimg.write_to(&mut cursor, fmt).map_err(|e| CodecError::encode(f, e))?;
|
||||
}
|
||||
}
|
||||
Ok(cursor.into_inner())
|
||||
|
|
|
|||
|
|
@ -19,40 +19,23 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
let mut dec = image_webp::WebPDecoder::new(Cursor::new(bytes)).map_err(err)?;
|
||||
dec.set_memory_limit(limits.alloc_usize());
|
||||
let (w, h) = dec.dimensions();
|
||||
let layout = if dec.has_alpha() {
|
||||
ChannelLayout::Rgba
|
||||
} else {
|
||||
ChannelLayout::Rgb
|
||||
};
|
||||
let layout = if dec.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
||||
limits.check(w, h, layout, SampleType::U8)?;
|
||||
let size = dec
|
||||
.output_buffer_size()
|
||||
.ok_or_else(|| err("image too large"))?;
|
||||
let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?;
|
||||
let mut buf = vec![0u8; size];
|
||||
dec.read_image(&mut buf).map_err(err)?;
|
||||
let icc = dec.icc_profile().ok().flatten();
|
||||
let exif = dec.exif_metadata().ok().flatten();
|
||||
let xmp = dec
|
||||
.xmp_metadata()
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|b| String::from_utf8(b).ok());
|
||||
let xmp = dec.xmp_metadata().ok().flatten().and_then(|b| String::from_utf8(b).ok());
|
||||
let mut img = Image::from_u8(w, h, layout, buf)?;
|
||||
img.icc = icc;
|
||||
img.meta = Metadata {
|
||||
exif,
|
||||
xmp,
|
||||
..Default::default()
|
||||
};
|
||||
img.meta = Metadata { exif, xmp, ..Default::default() };
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
if !opts.webp_lossless {
|
||||
return Err(CodecError::unsupported(
|
||||
F,
|
||||
"lossy WebP encoding needs libwebp (C); only lossless is available",
|
||||
));
|
||||
return Err(CodecError::unsupported(F, "lossy WebP encoding needs libwebp (C); only lossless is available"));
|
||||
}
|
||||
let img = src.convert(plan.layout, plan.sample);
|
||||
let ct = match img.layout() {
|
||||
|
|
@ -77,7 +60,6 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
enc.set_xmp_metadata(xmp.as_bytes().to_vec());
|
||||
}
|
||||
}
|
||||
enc.encode(img.data(), img.width(), img.height(), ct)
|
||||
.map_err(|e| CodecError::encode(F, e))?;
|
||||
enc.encode(img.data(), img.width(), img.height(), ct).map_err(|e| CodecError::encode(F, e))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,21 +20,12 @@ pub enum CodecError {
|
|||
|
||||
impl CodecError {
|
||||
pub(crate) fn malformed(format: Format, e: impl std::fmt::Display) -> Self {
|
||||
CodecError::Malformed {
|
||||
format,
|
||||
message: e.to_string(),
|
||||
}
|
||||
CodecError::Malformed { format, message: e.to_string() }
|
||||
}
|
||||
pub(crate) fn encode(format: Format, e: impl std::fmt::Display) -> Self {
|
||||
CodecError::Encode {
|
||||
format,
|
||||
message: e.to_string(),
|
||||
}
|
||||
CodecError::Encode { format, message: e.to_string() }
|
||||
}
|
||||
pub(crate) fn unsupported(format: Format, reason: impl Into<String>) -> Self {
|
||||
CodecError::Unsupported {
|
||||
format,
|
||||
reason: reason.into(),
|
||||
}
|
||||
CodecError::Unsupported { format, reason: reason.into() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,10 +51,7 @@ pub enum FidelityWarning {
|
|||
impl FidelityWarning {
|
||||
/// `true` for warnings that make encoding fail outright.
|
||||
pub fn is_fatal(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
FidelityWarning::WriteUnsupported { .. } | FidelityWarning::DimensionsExceeded { .. }
|
||||
)
|
||||
matches!(self, FidelityWarning::WriteUnsupported { .. } | FidelityWarning::DimensionsExceeded { .. })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,12 +71,7 @@ impl fmt::Display for FidelityWarning {
|
|||
WriteUnsupported { format } => {
|
||||
write!(f, "{} cannot be written in this build", format.name())
|
||||
}
|
||||
DepthReduced { from, to } => write!(
|
||||
f,
|
||||
"{} will be reduced to {}",
|
||||
depth_name(*from),
|
||||
depth_name(*to)
|
||||
),
|
||||
DepthReduced { from, to } => write!(f, "{} will be reduced to {}", depth_name(*from), depth_name(*to)),
|
||||
RangeClipped => write!(f, "HDR values outside 0..1 will be clipped"),
|
||||
AlphaDiscarded => write!(f, "alpha will be discarded"),
|
||||
AlphaBinarized => write!(f, "alpha will be reduced to on/off transparency"),
|
||||
|
|
@ -94,14 +86,8 @@ impl fmt::Display for FidelityWarning {
|
|||
XmpDropped => write!(f, "XMP not supported; it will be dropped"),
|
||||
DpiDropped => write!(f, "resolution (DPI) not supported; it will be dropped"),
|
||||
TextDropped => write!(f, "text metadata not supported; it will be dropped"),
|
||||
DimensionsExceeded {
|
||||
max_width,
|
||||
max_height,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"image exceeds the format maximum of {max_width}x{max_height}"
|
||||
)
|
||||
DimensionsExceeded { max_width, max_height } => {
|
||||
write!(f, "image exceeds the format maximum of {max_width}x{max_height}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -125,10 +111,7 @@ fn pick_sample(src: SampleType, depths: &[SampleType]) -> SampleType {
|
|||
SampleType::F16 => &[SampleType::F32, SampleType::U16, SampleType::U8],
|
||||
SampleType::F32 => &[SampleType::F16, SampleType::U16, SampleType::U8],
|
||||
};
|
||||
pref.iter()
|
||||
.copied()
|
||||
.find(|s| depths.contains(s))
|
||||
.unwrap_or(depths[0])
|
||||
pref.iter().copied().find(|s| depths.contains(s)).unwrap_or(depths[0])
|
||||
}
|
||||
|
||||
fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> ChannelLayout {
|
||||
|
|
@ -141,11 +124,7 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch
|
|||
}
|
||||
// CMYK falls back to RGB; gray expands to RGB.
|
||||
if cand.is_cmyk() || cand.is_gray() {
|
||||
cand = if cand.has_alpha() {
|
||||
ChannelLayout::Rgba
|
||||
} else {
|
||||
ChannelLayout::Rgb
|
||||
};
|
||||
cand = if cand.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
||||
}
|
||||
if layouts.contains(&cand) {
|
||||
return cand;
|
||||
|
|
@ -165,14 +144,8 @@ pub(crate) fn plan(image: &Image, format: Format, _opts: &EncodeOptions) -> Plan
|
|||
match format {
|
||||
// PNM: integer data can use any layout (PAM); float goes to PFM
|
||||
// which only has gray and RGB.
|
||||
Format::Pnm if sample.is_float() => Plan {
|
||||
layout: pick_layout(layout, &[ChannelLayout::Gray, ChannelLayout::Rgb], false),
|
||||
sample: SampleType::F32,
|
||||
},
|
||||
_ => Plan {
|
||||
layout: pick_layout(layout, c.layouts, c.alpha),
|
||||
sample: pick_sample(sample, c.depths),
|
||||
},
|
||||
Format::Pnm if sample.is_float() => Plan { layout: pick_layout(layout, &[ChannelLayout::Gray, ChannelLayout::Rgb], false), sample: SampleType::F32 },
|
||||
_ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -182,11 +155,7 @@ pub fn fidelity_warnings(image: &Image, format: Format) -> Vec<FidelityWarning>
|
|||
}
|
||||
|
||||
/// Warnings for writing `image` as `format` with the given options.
|
||||
pub fn fidelity_warnings_with(
|
||||
image: &Image,
|
||||
format: Format,
|
||||
opts: &EncodeOptions,
|
||||
) -> Vec<FidelityWarning> {
|
||||
pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOptions) -> Vec<FidelityWarning> {
|
||||
use FidelityWarning as W;
|
||||
let c = caps(format);
|
||||
let mut w = Vec::new();
|
||||
|
|
@ -197,10 +166,7 @@ pub fn fidelity_warnings_with(
|
|||
if let Some((mw, mh)) = format.max_dimensions()
|
||||
&& (image.width() > mw || image.height() > mh)
|
||||
{
|
||||
w.push(W::DimensionsExceeded {
|
||||
max_width: mw,
|
||||
max_height: mh,
|
||||
});
|
||||
w.push(W::DimensionsExceeded { max_width: mw, max_height: mh });
|
||||
}
|
||||
let p = plan(image, format, opts);
|
||||
let (sl, ss) = (image.layout(), image.sample_type());
|
||||
|
|
@ -214,10 +180,7 @@ pub fn fidelity_warnings_with(
|
|||
_ => true,
|
||||
};
|
||||
if lossy_depth {
|
||||
w.push(W::DepthReduced {
|
||||
from: ss,
|
||||
to: p.sample,
|
||||
});
|
||||
w.push(W::DepthReduced { from: ss, to: p.sample });
|
||||
}
|
||||
if ss.is_float() && !p.sample.is_float() && image.has_out_of_range() {
|
||||
w.push(W::RangeClipped);
|
||||
|
|
|
|||
|
|
@ -175,82 +175,21 @@ pub fn caps(format: Format) -> FormatCaps {
|
|||
lossy: false,
|
||||
};
|
||||
match format {
|
||||
Format::Png => FormatCaps {
|
||||
depths: &[S::U8, S::U16],
|
||||
layouts: RGB_GRAY,
|
||||
icc: true,
|
||||
exif: true,
|
||||
xmp: true,
|
||||
dpi: true,
|
||||
text: true,
|
||||
animation: true,
|
||||
..base
|
||||
},
|
||||
Format::Jpeg => FormatCaps {
|
||||
layouts: &[L::Gray, L::Rgb, L::Cmyk],
|
||||
alpha: false,
|
||||
icc: true,
|
||||
exif: true,
|
||||
xmp: true,
|
||||
dpi: true,
|
||||
lossy: true,
|
||||
..base
|
||||
},
|
||||
Format::Tiff => FormatCaps {
|
||||
depths: &[S::U8, S::U16, S::F32],
|
||||
layouts: ALL_LAYOUTS,
|
||||
icc: true,
|
||||
xmp: true,
|
||||
dpi: true,
|
||||
text: true,
|
||||
..base
|
||||
},
|
||||
Format::WebP => FormatCaps {
|
||||
icc: true,
|
||||
exif: true,
|
||||
xmp: true,
|
||||
animation: true,
|
||||
..base
|
||||
},
|
||||
Format::Gif => FormatCaps {
|
||||
layouts: &[L::Rgba],
|
||||
animation: true,
|
||||
lossy: true,
|
||||
..base
|
||||
},
|
||||
Format::Png => {
|
||||
FormatCaps { depths: &[S::U8, S::U16], layouts: RGB_GRAY, icc: true, exif: true, xmp: true, dpi: true, text: true, animation: true, ..base }
|
||||
}
|
||||
Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base },
|
||||
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, icc: true, xmp: true, dpi: true, text: true, ..base },
|
||||
Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base },
|
||||
Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base },
|
||||
Format::Bmp => base,
|
||||
Format::Tga => FormatCaps {
|
||||
layouts: RGB_GRAY,
|
||||
..base
|
||||
},
|
||||
Format::Ico => FormatCaps {
|
||||
layouts: &[L::Rgba],
|
||||
..base
|
||||
},
|
||||
Format::Pnm => FormatCaps {
|
||||
depths: &[S::U8, S::U16, S::F32],
|
||||
layouts: ALL_LAYOUTS,
|
||||
..base
|
||||
},
|
||||
Format::Tga => FormatCaps { layouts: RGB_GRAY, ..base },
|
||||
Format::Ico => FormatCaps { layouts: &[L::Rgba], ..base },
|
||||
Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base },
|
||||
Format::Qoi => base,
|
||||
Format::OpenExr => FormatCaps {
|
||||
depths: &[S::F16, S::F32],
|
||||
layouts: RGB_GRAY,
|
||||
..base
|
||||
},
|
||||
Format::Hdr => FormatCaps {
|
||||
depths: &[S::F32],
|
||||
layouts: &[L::Rgb],
|
||||
alpha: false,
|
||||
lossy: true,
|
||||
..base
|
||||
},
|
||||
Format::Avif => FormatCaps {
|
||||
read: false,
|
||||
write: cfg!(feature = "avif"),
|
||||
lossy: true,
|
||||
..base
|
||||
},
|
||||
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
|
||||
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
|
||||
Format::Avif => FormatCaps { read: false, write: cfg!(feature = "avif"), lossy: true, ..base },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -264,11 +203,7 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
|||
if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
return Some(Format::Jpeg);
|
||||
}
|
||||
if b.starts_with(b"II*\0")
|
||||
|| b.starts_with(b"MM\0*")
|
||||
|| b.starts_with(b"II+\0")
|
||||
|| b.starts_with(b"MM\0+")
|
||||
{
|
||||
if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") || b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") {
|
||||
return Some(Format::Tiff);
|
||||
}
|
||||
if b.len() >= 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP" {
|
||||
|
|
@ -295,11 +230,7 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
|||
if b.len() >= 6 && b[0..4] == [0, 0, 1, 0] && (b[4] != 0 || b[5] != 0) {
|
||||
return Some(Format::Ico);
|
||||
}
|
||||
if b.len() >= 3
|
||||
&& b[0] == b'P'
|
||||
&& matches!(b[1], b'1'..=b'7' | b'F' | b'f')
|
||||
&& b[2].is_ascii_whitespace()
|
||||
{
|
||||
if b.len() >= 3 && b[0] == b'P' && matches!(b[1], b'1'..=b'7' | b'F' | b'f') && b[2].is_ascii_whitespace() {
|
||||
return Some(Format::Pnm);
|
||||
}
|
||||
if looks_like_tga(b) {
|
||||
|
|
@ -312,10 +243,7 @@ fn is_avif_ftyp(b: &[u8]) -> bool {
|
|||
let size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize;
|
||||
let end = size.clamp(12, b.len().min(64));
|
||||
let brands = &b[8..end];
|
||||
brands
|
||||
.chunks_exact(4)
|
||||
.enumerate()
|
||||
.any(|(i, c)| i != 1 && (c == b"avif" || c == b"avis"))
|
||||
brands.as_chunks::<4>().0.iter().enumerate().any(|(i, c)| i != 1 && (c == b"avif" || c == b"avis"))
|
||||
}
|
||||
|
||||
fn looks_like_tga(b: &[u8]) -> bool {
|
||||
|
|
@ -350,12 +278,6 @@ fn looks_like_tga(b: &[u8]) -> bool {
|
|||
/// Look up a format by file extension (case-insensitive, with or without a
|
||||
/// leading dot, or a full file name/path).
|
||||
pub fn from_extension(ext: &str) -> Option<Format> {
|
||||
let ext = ext
|
||||
.rsplit(['.', '/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(ext)
|
||||
.to_ascii_lowercase();
|
||||
Format::ALL
|
||||
.into_iter()
|
||||
.find(|f| f.extensions().contains(&ext.as_str()))
|
||||
let ext = ext.rsplit(['.', '/', '\\']).next().unwrap_or(ext).to_ascii_lowercase();
|
||||
Format::ALL.into_iter().find(|f| f.extensions().contains(&ext.as_str()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,14 +21,8 @@ pub enum ChannelLayout {
|
|||
}
|
||||
|
||||
impl ChannelLayout {
|
||||
pub const ALL: [ChannelLayout; 6] = [
|
||||
ChannelLayout::Gray,
|
||||
ChannelLayout::GrayA,
|
||||
ChannelLayout::Rgb,
|
||||
ChannelLayout::Rgba,
|
||||
ChannelLayout::Cmyk,
|
||||
ChannelLayout::CmykA,
|
||||
];
|
||||
pub const ALL: [ChannelLayout; 6] =
|
||||
[ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba, ChannelLayout::Cmyk, ChannelLayout::CmykA];
|
||||
|
||||
/// Total number of channels including alpha.
|
||||
pub const fn channels(self) -> usize {
|
||||
|
|
@ -47,10 +41,7 @@ impl ChannelLayout {
|
|||
}
|
||||
|
||||
pub const fn has_alpha(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA
|
||||
)
|
||||
matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA)
|
||||
}
|
||||
|
||||
pub const fn is_gray(self) -> bool {
|
||||
|
|
@ -85,9 +76,7 @@ impl ChannelLayout {
|
|||
|
||||
/// `true` if both layouts share a colour model (ignoring alpha).
|
||||
pub const fn same_model(self, other: Self) -> bool {
|
||||
(self.is_gray() && other.is_gray())
|
||||
|| (self.is_rgb() && other.is_rgb())
|
||||
|| (self.is_cmyk() && other.is_cmyk())
|
||||
(self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -102,12 +91,7 @@ pub enum SampleType {
|
|||
}
|
||||
|
||||
impl SampleType {
|
||||
pub const ALL: [SampleType; 4] = [
|
||||
SampleType::U8,
|
||||
SampleType::U16,
|
||||
SampleType::F16,
|
||||
SampleType::F32,
|
||||
];
|
||||
pub const ALL: [SampleType; 4] = [SampleType::U8, SampleType::U16, SampleType::F16, SampleType::F32];
|
||||
|
||||
pub const fn bytes(self) -> usize {
|
||||
match self {
|
||||
|
|
@ -156,10 +140,7 @@ pub struct Image {
|
|||
}
|
||||
|
||||
fn byte_len(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Option<usize> {
|
||||
(width as usize)
|
||||
.checked_mul(height as usize)?
|
||||
.checked_mul(layout.channels())?
|
||||
.checked_mul(sample.bytes())
|
||||
(width as usize).checked_mul(height as usize)?.checked_mul(layout.channels())?.checked_mul(sample.bytes())
|
||||
}
|
||||
|
||||
impl Image {
|
||||
|
|
@ -169,95 +150,40 @@ impl Image {
|
|||
/// If the byte size overflows `usize`.
|
||||
pub fn new(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Self {
|
||||
let len = byte_len(width, height, layout, sample).expect("image size overflows usize");
|
||||
Image {
|
||||
width,
|
||||
height,
|
||||
layout,
|
||||
sample,
|
||||
data: vec![0; len],
|
||||
icc: None,
|
||||
meta: Metadata::default(),
|
||||
}
|
||||
Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default() }
|
||||
}
|
||||
|
||||
/// Wrap raw interleaved, native-endian bytes.
|
||||
pub fn from_raw(
|
||||
width: u32,
|
||||
height: u32,
|
||||
layout: ChannelLayout,
|
||||
sample: SampleType,
|
||||
data: Vec<u8>,
|
||||
) -> Result<Self, CodecError> {
|
||||
let expected = byte_len(width, height, layout, sample)
|
||||
.ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?;
|
||||
pub fn from_raw(width: u32, height: u32, layout: ChannelLayout, sample: SampleType, data: Vec<u8>) -> Result<Self, CodecError> {
|
||||
let expected = byte_len(width, height, layout, sample).ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?;
|
||||
if data.len() != expected {
|
||||
return Err(CodecError::InvalidImage(format!(
|
||||
"buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}",
|
||||
data.len()
|
||||
)));
|
||||
return Err(CodecError::InvalidImage(format!("buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", data.len())));
|
||||
}
|
||||
Ok(Image {
|
||||
width,
|
||||
height,
|
||||
layout,
|
||||
sample,
|
||||
data,
|
||||
icc: None,
|
||||
meta: Metadata::default(),
|
||||
})
|
||||
Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default() })
|
||||
}
|
||||
|
||||
pub fn from_u8(
|
||||
width: u32,
|
||||
height: u32,
|
||||
layout: ChannelLayout,
|
||||
data: Vec<u8>,
|
||||
) -> Result<Self, CodecError> {
|
||||
pub fn from_u8(width: u32, height: u32, layout: ChannelLayout, data: Vec<u8>) -> Result<Self, CodecError> {
|
||||
Self::from_raw(width, height, layout, SampleType::U8, data)
|
||||
}
|
||||
|
||||
pub fn from_u16(
|
||||
width: u32,
|
||||
height: u32,
|
||||
layout: ChannelLayout,
|
||||
data: &[u16],
|
||||
) -> Result<Self, CodecError> {
|
||||
pub fn from_u16(width: u32, height: u32, layout: ChannelLayout, data: &[u16]) -> Result<Self, CodecError> {
|
||||
let bytes = data.iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
Self::from_raw(width, height, layout, SampleType::U16, bytes)
|
||||
}
|
||||
|
||||
pub fn from_f16(
|
||||
width: u32,
|
||||
height: u32,
|
||||
layout: ChannelLayout,
|
||||
data: &[f16],
|
||||
) -> Result<Self, CodecError> {
|
||||
let bytes = data
|
||||
.iter()
|
||||
.flat_map(|v| v.to_bits().to_ne_bytes())
|
||||
.collect();
|
||||
pub fn from_f16(width: u32, height: u32, layout: ChannelLayout, data: &[f16]) -> Result<Self, CodecError> {
|
||||
let bytes = data.iter().flat_map(|v| v.to_bits().to_ne_bytes()).collect();
|
||||
Self::from_raw(width, height, layout, SampleType::F16, bytes)
|
||||
}
|
||||
|
||||
pub fn from_f32(
|
||||
width: u32,
|
||||
height: u32,
|
||||
layout: ChannelLayout,
|
||||
data: &[f32],
|
||||
) -> Result<Self, CodecError> {
|
||||
pub fn from_f32(width: u32, height: u32, layout: ChannelLayout, data: &[f32]) -> Result<Self, CodecError> {
|
||||
let bytes = data.iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
Self::from_raw(width, height, layout, SampleType::F32, bytes)
|
||||
}
|
||||
|
||||
/// Build an image from normalized f32 samples (`[0,1]` for integer
|
||||
/// targets; values are clamped and rounded when quantizing).
|
||||
pub fn from_normalized(
|
||||
width: u32,
|
||||
height: u32,
|
||||
layout: ChannelLayout,
|
||||
sample: SampleType,
|
||||
values: &[f32],
|
||||
) -> Result<Self, CodecError> {
|
||||
pub fn from_normalized(width: u32, height: u32, layout: ChannelLayout, sample: SampleType, values: &[f32]) -> Result<Self, CodecError> {
|
||||
let data = quantize(values, sample);
|
||||
Self::from_raw(width, height, layout, sample, data)
|
||||
}
|
||||
|
|
@ -312,32 +238,17 @@ impl Image {
|
|||
|
||||
/// Copy samples out as `u16` (only when the sample type is `U16`).
|
||||
pub fn to_u16_samples(&self) -> Option<Vec<u16>> {
|
||||
(self.sample == SampleType::U16).then(|| {
|
||||
self.data
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_ne_bytes([c[0], c[1]]))
|
||||
.collect()
|
||||
})
|
||||
(self.sample == SampleType::U16).then(|| self.data.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes([c[0], c[1]])).collect())
|
||||
}
|
||||
|
||||
/// Copy samples out as `f16` (only when the sample type is `F16`).
|
||||
pub fn to_f16_samples(&self) -> Option<Vec<f16>> {
|
||||
(self.sample == SampleType::F16).then(|| {
|
||||
self.data
|
||||
.chunks_exact(2)
|
||||
.map(|c| f16::from_bits(u16::from_ne_bytes([c[0], c[1]])))
|
||||
.collect()
|
||||
})
|
||||
(self.sample == SampleType::F16).then(|| self.data.as_chunks::<2>().0.iter().map(|c| f16::from_bits(u16::from_ne_bytes([c[0], c[1]]))).collect())
|
||||
}
|
||||
|
||||
/// Copy samples out as `f32` (only when the sample type is `F32`).
|
||||
pub fn to_f32_samples(&self) -> Option<Vec<f32>> {
|
||||
(self.sample == SampleType::F32).then(|| {
|
||||
self.data
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]]))
|
||||
.collect()
|
||||
})
|
||||
(self.sample == SampleType::F32).then(|| self.data.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]])).collect())
|
||||
}
|
||||
|
||||
/// Sample `index` (in samples, not bytes) as a normalized f32.
|
||||
|
|
@ -414,11 +325,7 @@ impl Image {
|
|||
layout,
|
||||
sample,
|
||||
data,
|
||||
icc: if layout.same_model(self.layout) {
|
||||
self.icc.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
icc: if layout.same_model(self.layout) { self.icc.clone() } else { None },
|
||||
meta: self.meta.clone(),
|
||||
}
|
||||
}
|
||||
|
|
@ -430,9 +337,7 @@ impl Image {
|
|||
|
||||
/// Interleaved RGBA, 16 bits per sample.
|
||||
pub fn to_rgba16(&self) -> Vec<u16> {
|
||||
self.convert(ChannelLayout::Rgba, SampleType::U16)
|
||||
.to_u16_samples()
|
||||
.unwrap_or_default()
|
||||
self.convert(ChannelLayout::Rgba, SampleType::U16).to_u16_samples().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Interleaved RGBA as f32 (integer inputs normalized to `[0,1]`).
|
||||
|
|
@ -450,33 +355,17 @@ pub(crate) fn read_normalized(data: &[u8], sample: SampleType, i: usize) -> f32
|
|||
match sample {
|
||||
SampleType::U8 => data[i] as f32 / 255.0,
|
||||
SampleType::U16 => u16::from_ne_bytes([data[2 * i], data[2 * i + 1]]) as f32 / 65535.0,
|
||||
SampleType::F16 => {
|
||||
f16::from_bits(u16::from_ne_bytes([data[2 * i], data[2 * i + 1]])).to_f32()
|
||||
}
|
||||
SampleType::F32 => f32::from_ne_bytes([
|
||||
data[4 * i],
|
||||
data[4 * i + 1],
|
||||
data[4 * i + 2],
|
||||
data[4 * i + 3],
|
||||
]),
|
||||
SampleType::F16 => f16::from_bits(u16::from_ne_bytes([data[2 * i], data[2 * i + 1]])).to_f32(),
|
||||
SampleType::F32 => f32::from_ne_bytes([data[4 * i], data[4 * i + 1], data[4 * i + 2], data[4 * i + 3]]),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_normalized(data: &[u8], sample: SampleType) -> Vec<f32> {
|
||||
match sample {
|
||||
SampleType::U8 => data.iter().map(|&v| v as f32 / 255.0).collect(),
|
||||
SampleType::U16 => data
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_ne_bytes([c[0], c[1]]) as f32 / 65535.0)
|
||||
.collect(),
|
||||
SampleType::F16 => data
|
||||
.chunks_exact(2)
|
||||
.map(|c| f16::from_bits(u16::from_ne_bytes([c[0], c[1]])).to_f32())
|
||||
.collect(),
|
||||
SampleType::F32 => data
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]]))
|
||||
.collect(),
|
||||
SampleType::U16 => data.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
|
||||
SampleType::F16 => data.as_chunks::<2>().0.iter().map(|c| f16::from_bits(u16::from_ne_bytes([c[0], c[1]])).to_f32()).collect(),
|
||||
SampleType::F32 => data.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]])).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -487,18 +376,9 @@ fn clamp01(v: f32) -> f32 {
|
|||
|
||||
pub(crate) fn quantize(values: &[f32], sample: SampleType) -> Vec<u8> {
|
||||
match sample {
|
||||
SampleType::U8 => values
|
||||
.iter()
|
||||
.map(|&v| (clamp01(v) * 255.0).round() as u8)
|
||||
.collect(),
|
||||
SampleType::U16 => values
|
||||
.iter()
|
||||
.flat_map(|&v| ((clamp01(v) * 65535.0).round() as u16).to_ne_bytes())
|
||||
.collect(),
|
||||
SampleType::F16 => values
|
||||
.iter()
|
||||
.flat_map(|&v| f16::from_f32(v).to_bits().to_ne_bytes())
|
||||
.collect(),
|
||||
SampleType::U8 => values.iter().map(|&v| (clamp01(v) * 255.0).round() as u8).collect(),
|
||||
SampleType::U16 => values.iter().flat_map(|&v| ((clamp01(v) * 65535.0).round() as u16).to_ne_bytes()).collect(),
|
||||
SampleType::F16 => values.iter().flat_map(|&v| f16::from_f32(v).to_bits().to_ne_bytes()).collect(),
|
||||
SampleType::F32 => values.iter().flat_map(|&v| v.to_ne_bytes()).collect(),
|
||||
}
|
||||
}
|
||||
|
|
@ -507,11 +387,7 @@ pub(crate) fn quantize(values: &[f32], sample: SampleType) -> Vec<u8> {
|
|||
const LUMA: [f32; 3] = [0.2126, 0.7152, 0.0722];
|
||||
|
||||
fn cmyk_to_rgb(c: f32, m: f32, y: f32, k: f32) -> [f32; 3] {
|
||||
[
|
||||
(1.0 - c) * (1.0 - k),
|
||||
(1.0 - m) * (1.0 - k),
|
||||
(1.0 - y) * (1.0 - k),
|
||||
]
|
||||
[(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]
|
||||
}
|
||||
|
||||
fn rgb_to_cmyk(r: f32, g: f32, b: f32) -> [f32; 4] {
|
||||
|
|
@ -543,18 +419,14 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout
|
|||
let rgb = match from {
|
||||
ChannelLayout::Gray | ChannelLayout::GrayA => [color[0]; 3],
|
||||
ChannelLayout::Rgb | ChannelLayout::Rgba => [color[0], color[1], color[2]],
|
||||
ChannelLayout::Cmyk | ChannelLayout::CmykA => {
|
||||
cmyk_to_rgb(color[0], color[1], color[2], color[3])
|
||||
}
|
||||
ChannelLayout::Cmyk | ChannelLayout::CmykA => cmyk_to_rgb(color[0], color[1], color[2], color[3]),
|
||||
};
|
||||
match to {
|
||||
ChannelLayout::Gray | ChannelLayout::GrayA => {
|
||||
out.push(rgb[0] * LUMA[0] + rgb[1] * LUMA[1] + rgb[2] * LUMA[2]);
|
||||
}
|
||||
ChannelLayout::Rgb | ChannelLayout::Rgba => out.extend_from_slice(&rgb),
|
||||
ChannelLayout::Cmyk | ChannelLayout::CmykA => {
|
||||
out.extend_from_slice(&rgb_to_cmyk(rgb[0], rgb[1], rgb[2]))
|
||||
}
|
||||
ChannelLayout::Cmyk | ChannelLayout::CmykA => out.extend_from_slice(&rgb_to_cmyk(rgb[0], rgb[1], rgb[2])),
|
||||
}
|
||||
}
|
||||
if to.has_alpha() {
|
||||
|
|
@ -586,9 +458,7 @@ mod tests {
|
|||
fn u8_u16_roundtrip_exact() {
|
||||
let v: Vec<u8> = (0..=255).collect();
|
||||
let img = Image::from_u8(256, 1, ChannelLayout::Gray, v.clone()).unwrap();
|
||||
let back = img
|
||||
.convert(ChannelLayout::Gray, SampleType::U16)
|
||||
.convert(ChannelLayout::Gray, SampleType::U8);
|
||||
let back = img.convert(ChannelLayout::Gray, SampleType::U16).convert(ChannelLayout::Gray, SampleType::U8);
|
||||
assert_eq!(back.data(), &v[..]);
|
||||
}
|
||||
|
||||
|
|
@ -596,26 +466,15 @@ mod tests {
|
|||
fn u16_f32_roundtrip_exact() {
|
||||
let v: Vec<u16> = (0..=65535u32).step_by(7).map(|x| x as u16).collect();
|
||||
let img = Image::from_u16(v.len() as u32, 1, ChannelLayout::Gray, &v).unwrap();
|
||||
let back = img
|
||||
.convert(ChannelLayout::Gray, SampleType::F32)
|
||||
.convert(ChannelLayout::Gray, SampleType::U16);
|
||||
let back = img.convert(ChannelLayout::Gray, SampleType::F32).convert(ChannelLayout::Gray, SampleType::U16);
|
||||
assert_eq!(back.to_u16_samples().unwrap(), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icc_dropped_on_model_change() {
|
||||
let img =
|
||||
Image::new(2, 2, ChannelLayout::Cmyk, SampleType::U8).with_icc(Some(vec![1, 2, 3]));
|
||||
assert!(
|
||||
img.convert(ChannelLayout::Rgb, SampleType::U8)
|
||||
.icc
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
img.convert(ChannelLayout::CmykA, SampleType::U16)
|
||||
.icc
|
||||
.is_some()
|
||||
);
|
||||
let img = Image::new(2, 2, ChannelLayout::Cmyk, SampleType::U8).with_icc(Some(vec![1, 2, 3]));
|
||||
assert!(img.convert(ChannelLayout::Rgb, SampleType::U8).icc.is_none());
|
||||
assert!(img.convert(ChannelLayout::CmykA, SampleType::U16).icc.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -20,15 +20,13 @@ mod image;
|
|||
mod options;
|
||||
pub mod web;
|
||||
|
||||
pub use crate::codecs::png::encode_indexed as encode_png_indexed;
|
||||
pub use crate::error::CodecError;
|
||||
pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with};
|
||||
pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension};
|
||||
pub use crate::image::{ChannelLayout, Image, Metadata, SampleType};
|
||||
pub use crate::options::{
|
||||
DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression,
|
||||
};
|
||||
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use half::f16;
|
||||
pub use crate::codecs::png::encode_indexed as encode_png_indexed;
|
||||
|
||||
use crate::codecs::{exr, jpeg, png, pnm, tiff, via_image, webp};
|
||||
|
||||
|
|
@ -49,16 +47,9 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result<Image, CodecError> {
|
|||
}
|
||||
|
||||
/// Decode as a specific format.
|
||||
pub fn decode_as_with(
|
||||
format: Format,
|
||||
bytes: &[u8],
|
||||
opts: &DecodeOptions,
|
||||
) -> Result<Image, CodecError> {
|
||||
pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result<Image, CodecError> {
|
||||
if !caps(format).read {
|
||||
return Err(CodecError::unsupported(
|
||||
format,
|
||||
"decoding is not available for this format",
|
||||
));
|
||||
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
|
||||
}
|
||||
let l = &opts.limits;
|
||||
let img = match format {
|
||||
|
|
@ -68,13 +59,7 @@ pub fn decode_as_with(
|
|||
Format::WebP => webp::decode(bytes, l),
|
||||
Format::Pnm => pnm::decode(bytes, l),
|
||||
Format::OpenExr => exr::decode(bytes, l),
|
||||
Format::Gif
|
||||
| Format::Bmp
|
||||
| Format::Tga
|
||||
| Format::Ico
|
||||
| Format::Qoi
|
||||
| Format::Hdr
|
||||
| Format::Avif => via_image::decode(format, bytes, l),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
|
||||
}?;
|
||||
// Final guard for decoders whose header we could not pre-inspect.
|
||||
l.check(img.width(), img.height(), img.layout(), img.sample_type())?;
|
||||
|
|
@ -86,23 +71,15 @@ pub fn decode_as_with(
|
|||
/// format, oversize image) return an error.
|
||||
pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
if !caps(format).write {
|
||||
return Err(CodecError::unsupported(
|
||||
format,
|
||||
"encoding is not available in this build",
|
||||
));
|
||||
return Err(CodecError::unsupported(format, "encoding is not available in this build"));
|
||||
}
|
||||
if image.width() == 0 || image.height() == 0 {
|
||||
return Err(CodecError::InvalidImage(
|
||||
"cannot encode an empty image".into(),
|
||||
));
|
||||
return Err(CodecError::InvalidImage("cannot encode an empty image".into()));
|
||||
}
|
||||
if let Some((mw, mh)) = format.max_dimensions()
|
||||
&& (image.width() > mw || image.height() > mh)
|
||||
{
|
||||
return Err(CodecError::encode(
|
||||
format,
|
||||
format!("dimensions exceed {mw}x{mh}"),
|
||||
));
|
||||
return Err(CodecError::encode(format, format!("dimensions exceed {mw}x{mh}")));
|
||||
}
|
||||
let plan = fidelity::plan(image, format, opts);
|
||||
match format {
|
||||
|
|
@ -112,12 +89,6 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
|
|||
Format::WebP => webp::encode(image, plan, opts),
|
||||
Format::Pnm => pnm::encode(image, plan, opts),
|
||||
Format::OpenExr => exr::encode(image, plan, opts),
|
||||
Format::Gif
|
||||
| Format::Bmp
|
||||
| Format::Tga
|
||||
| Format::Ico
|
||||
| Format::Qoi
|
||||
| Format::Hdr
|
||||
| Format::Avif => via_image::encode(format, image, plan, opts),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,73 +18,39 @@ pub struct Limits {
|
|||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits {
|
||||
max_width: 1 << 18,
|
||||
max_height: 1 << 18,
|
||||
max_pixels: 1 << 28,
|
||||
max_alloc: 2 << 30,
|
||||
}
|
||||
Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 28, max_alloc: 2 << 30 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
/// Effectively unlimited (still bounded by `usize` overflow checks).
|
||||
pub fn none() -> Self {
|
||||
Limits {
|
||||
max_width: u32::MAX,
|
||||
max_height: u32::MAX,
|
||||
max_pixels: u64::MAX,
|
||||
max_alloc: u64::MAX,
|
||||
}
|
||||
Limits { max_width: u32::MAX, max_height: u32::MAX, max_pixels: u64::MAX, max_alloc: u64::MAX }
|
||||
}
|
||||
|
||||
/// Validate declared dimensions for an output buffer of the given layout
|
||||
/// and sample type.
|
||||
pub fn check(
|
||||
&self,
|
||||
width: u32,
|
||||
height: u32,
|
||||
layout: ChannelLayout,
|
||||
sample: SampleType,
|
||||
) -> Result<(), CodecError> {
|
||||
pub fn check(&self, width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Result<(), CodecError> {
|
||||
self.check_bytes(width, height, (layout.channels() * sample.bytes()) as u64)
|
||||
}
|
||||
|
||||
pub(crate) fn check_bytes(
|
||||
&self,
|
||||
width: u32,
|
||||
height: u32,
|
||||
bytes_per_pixel: u64,
|
||||
) -> Result<(), CodecError> {
|
||||
pub(crate) fn check_bytes(&self, width: u32, height: u32, bytes_per_pixel: u64) -> Result<(), CodecError> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err(CodecError::InvalidImage(format!(
|
||||
"zero-sized image {width}x{height}"
|
||||
)));
|
||||
return Err(CodecError::InvalidImage(format!("zero-sized image {width}x{height}")));
|
||||
}
|
||||
if width > self.max_width || height > self.max_height {
|
||||
return Err(CodecError::LimitExceeded(format!(
|
||||
"dimensions {width}x{height} exceed max {}x{}",
|
||||
self.max_width, self.max_height
|
||||
)));
|
||||
return Err(CodecError::LimitExceeded(format!("dimensions {width}x{height} exceed max {}x{}", self.max_width, self.max_height)));
|
||||
}
|
||||
let pixels = width as u64 * height as u64;
|
||||
if pixels > self.max_pixels {
|
||||
return Err(CodecError::LimitExceeded(format!(
|
||||
"{pixels} pixels exceed max {}",
|
||||
self.max_pixels
|
||||
)));
|
||||
return Err(CodecError::LimitExceeded(format!("{pixels} pixels exceed max {}", self.max_pixels)));
|
||||
}
|
||||
let bytes = pixels.saturating_mul(bytes_per_pixel);
|
||||
if bytes > self.max_alloc {
|
||||
return Err(CodecError::LimitExceeded(format!(
|
||||
"{bytes} bytes exceed max_alloc {}",
|
||||
self.max_alloc
|
||||
)));
|
||||
return Err(CodecError::LimitExceeded(format!("{bytes} bytes exceed max_alloc {}", self.max_alloc)));
|
||||
}
|
||||
if usize::try_from(bytes).is_err() {
|
||||
return Err(CodecError::LimitExceeded(format!(
|
||||
"{bytes} bytes do not fit in memory"
|
||||
)));
|
||||
return Err(CodecError::LimitExceeded(format!("{bytes} bytes do not fit in memory")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,14 @@ fn interlaced_rows(h: usize) -> Vec<usize> {
|
|||
|
||||
/// An indexed GIF89a: `palette` (1..=256 entries), one index per pixel, optional transparent
|
||||
/// index (Graphic Control Extension) and interlacing.
|
||||
pub fn encode_gif_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]], transparent: Option<u8>, interlaced: bool) -> Result<Vec<u8>, CodecError> {
|
||||
pub fn encode_gif_indexed(
|
||||
width: u32,
|
||||
height: u32,
|
||||
indices: &[u8],
|
||||
palette: &[[u8; 3]],
|
||||
transparent: Option<u8>,
|
||||
interlaced: bool,
|
||||
) -> Result<Vec<u8>, CodecError> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
if width == 0 || height == 0 || width > 65535 || height > 65535 || indices.len() != w * h {
|
||||
return Err(gif_err("GIF size must be 1..=65535 and match the index data"));
|
||||
|
|
@ -278,7 +285,17 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec<bool>), CodecError> {
|
|||
/// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and
|
||||
/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density and XMP.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn encode_jpeg_rgb8(width: u32, height: u32, rgb: &[u8], quality: u8, progressive: bool, optimized: bool, icc: Option<&[u8]>, dpi: Option<f32>, xmp: Option<&str>) -> Result<Vec<u8>, CodecError> {
|
||||
pub fn encode_jpeg_rgb8(
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgb: &[u8],
|
||||
quality: u8,
|
||||
progressive: bool,
|
||||
optimized: bool,
|
||||
icc: Option<&[u8]>,
|
||||
dpi: Option<f32>,
|
||||
xmp: Option<&str>,
|
||||
) -> Result<Vec<u8>, CodecError> {
|
||||
let e = |e: jpeg_encoder::EncodingError| CodecError::encode(Format::Jpeg, e);
|
||||
let (Ok(w16), Ok(h16)) = (u16::try_from(width), u16::try_from(height)) else {
|
||||
return Err(CodecError::encode(Format::Jpeg, "JPEG dimensions are limited to 65535"));
|
||||
|
|
@ -425,12 +442,7 @@ mod anim_tests {
|
|||
#[test]
|
||||
fn animated_gif_has_header_and_frames() {
|
||||
let frames: Vec<GifFrame> = (0..3)
|
||||
.map(|i| GifFrame {
|
||||
indices: vec![i as u8; 4],
|
||||
palette: vec![[0, 0, 0], [255, 0, 0], [0, 255, 0]],
|
||||
transparent: None,
|
||||
delay_cs: 4,
|
||||
})
|
||||
.map(|i| GifFrame { indices: vec![i as u8; 4], palette: vec![[0, 0, 0], [255, 0, 0], [0, 255, 0]], transparent: None, delay_cs: 4 })
|
||||
.collect();
|
||||
let g = encode_gif_animated(2, 2, &frames, true).unwrap();
|
||||
assert_eq!(&g[..6], b"GIF89a");
|
||||
|
|
|
|||
|
|
@ -25,10 +25,7 @@ fn tiff(ifds: &[Vec<(u16, u16, u32)>], header8: Option<[u8; 4]>) -> Vec<u8> {
|
|||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
}
|
||||
// Layout: IFDs back to back, then one shared 12-byte pixel strip.
|
||||
let sizes: Vec<u32> = ifds
|
||||
.iter()
|
||||
.map(|e| 2 + 12 * (e.len() as u32 + 1) + 4)
|
||||
.collect();
|
||||
let sizes: Vec<u32> = ifds.iter().map(|e| 2 + 12 * (e.len() as u32 + 1) + 4).collect();
|
||||
let strip = first + sizes.iter().sum::<u32>();
|
||||
let mut at = first;
|
||||
for (i, entries) in ifds.iter().enumerate() {
|
||||
|
|
@ -77,10 +74,7 @@ fn assert_camera_raw(bytes: &[u8]) {
|
|||
assert!(reason.contains("camera raw"), "unexpected reason: {reason}");
|
||||
}
|
||||
Err(e) => panic!("expected a camera raw error, got: {e}"),
|
||||
Ok(img) => panic!(
|
||||
"expected a camera raw error, decoded {:?}",
|
||||
img.dimensions()
|
||||
),
|
||||
Ok(img) => panic!("expected a camera raw error, decoded {:?}", img.dimensions()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,14 +30,7 @@ impl Rng {
|
|||
/// Gradient + noise image with values in [0, 1]. `noise` is the amplitude
|
||||
/// of uniform noise added to the gradient. Alpha is a separate gradient
|
||||
/// that includes fully transparent and fully opaque regions.
|
||||
pub fn synth(
|
||||
w: u32,
|
||||
h: u32,
|
||||
layout: ChannelLayout,
|
||||
sample: SampleType,
|
||||
seed: u64,
|
||||
noise: f32,
|
||||
) -> Image {
|
||||
pub fn synth(w: u32, h: u32, layout: ChannelLayout, sample: SampleType, seed: u64, noise: f32) -> Image {
|
||||
let mut rng = Rng::new(seed);
|
||||
let nc = layout.channels();
|
||||
let mut v = Vec::with_capacity((w * h) as usize * nc);
|
||||
|
|
@ -84,19 +77,11 @@ pub fn psnr(a: &Image, b: &Image) -> f64 {
|
|||
})
|
||||
.sum::<f64>()
|
||||
/ av.len() as f64;
|
||||
if mse == 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
10.0 * (1.0 / mse).log10()
|
||||
}
|
||||
if mse == 0.0 { f64::INFINITY } else { 10.0 * (1.0 / mse).log10() }
|
||||
}
|
||||
|
||||
pub fn max_abs_diff(a: &Image, b: &Image) -> f32 {
|
||||
a.to_normalized()
|
||||
.iter()
|
||||
.zip(b.to_normalized())
|
||||
.map(|(x, y)| (x - y).abs())
|
||||
.fold(0.0, f32::max)
|
||||
a.to_normalized().iter().zip(b.to_normalized()).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Formats that can be written in this build.
|
||||
|
|
@ -106,10 +91,7 @@ pub fn writable_formats() -> Vec<Format> {
|
|||
|
||||
/// Formats that can be written *and* read in this build.
|
||||
pub fn rw_formats() -> Vec<Format> {
|
||||
Format::ALL
|
||||
.into_iter()
|
||||
.filter(|f| caps(*f).write && caps(*f).read)
|
||||
.collect()
|
||||
Format::ALL.into_iter().filter(|f| caps(*f).write && caps(*f).read).collect()
|
||||
}
|
||||
|
||||
/// A minimal valid little-endian TIFF-structured EXIF blob (one IFD with
|
||||
|
|
|
|||
|
|
@ -20,12 +20,7 @@ fn every_enabled_format_is_symmetric_or_whitelisted() {
|
|||
continue;
|
||||
}
|
||||
let exc = ASYMMETRIC_EXCEPTIONS.iter().find(|(g, _)| *g == f);
|
||||
let (_, reason) = exc.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{f:?} is enabled but not symmetric (read={}, write={})",
|
||||
c.read, c.write
|
||||
)
|
||||
});
|
||||
let (_, reason) = exc.unwrap_or_else(|| panic!("{f:?} is enabled but not symmetric (read={}, write={})", c.read, c.write));
|
||||
assert!(reason.len() > 20, "{f:?} exception needs a real reason");
|
||||
}
|
||||
}
|
||||
|
|
@ -73,10 +68,7 @@ fn default_build_formats_all_symmetric() {
|
|||
fn avif_never_readable() {
|
||||
assert!(!caps(Format::Avif).read);
|
||||
assert_eq!(caps(Format::Avif).write, cfg!(feature = "avif"));
|
||||
assert!(matches!(
|
||||
decode_as(Format::Avif, b"\0\0\0\x1cftypavif"),
|
||||
Err(CodecError::Unsupported { .. })
|
||||
));
|
||||
assert!(matches!(decode_as(Format::Avif, b"\0\0\0\x1cftypavif"), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -84,16 +76,8 @@ fn caps_are_internally_consistent() {
|
|||
for f in Format::ALL {
|
||||
let c = caps(f);
|
||||
assert!(!c.depths.is_empty() && !c.layouts.is_empty(), "{f:?}");
|
||||
assert_eq!(
|
||||
c.alpha,
|
||||
c.layouts.iter().any(|l| l.has_alpha()),
|
||||
"{f:?} alpha flag vs layouts"
|
||||
);
|
||||
assert!(
|
||||
!f.extensions().is_empty()
|
||||
&& !f.name().is_empty()
|
||||
&& f.mime_type().starts_with("image/")
|
||||
);
|
||||
assert_eq!(c.alpha, c.layouts.iter().any(|l| l.has_alpha()), "{f:?} alpha flag vs layouts");
|
||||
assert!(!f.extensions().is_empty() && !f.name().is_empty() && f.mime_type().starts_with("image/"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,17 +142,7 @@ fn detect_magic_ico() {
|
|||
}
|
||||
#[test]
|
||||
fn detect_magic_pnm_family() {
|
||||
for m in [
|
||||
&b"P1\n"[..],
|
||||
b"P2 ",
|
||||
b"P3\t",
|
||||
b"P4\n",
|
||||
b"P5\r",
|
||||
b"P6\n",
|
||||
b"P7\n",
|
||||
b"PF\n",
|
||||
b"Pf\n",
|
||||
] {
|
||||
for m in [&b"P1\n"[..], b"P2 ", b"P3\t", b"P4\n", b"P5\r", b"P6\n", b"P7\n", b"PF\n", b"Pf\n"] {
|
||||
assert_eq!(detect(m), Some(Format::Pnm), "{:?}", std::str::from_utf8(m));
|
||||
}
|
||||
assert_eq!(detect(b"P8\n"), None);
|
||||
|
|
@ -180,10 +154,7 @@ fn detect_magic_qoi() {
|
|||
}
|
||||
#[test]
|
||||
fn detect_magic_exr() {
|
||||
assert_eq!(
|
||||
detect(&[0x76, 0x2F, 0x31, 0x01, 2, 0, 0, 0]),
|
||||
Some(Format::OpenExr)
|
||||
);
|
||||
assert_eq!(detect(&[0x76, 0x2F, 0x31, 0x01, 2, 0, 0, 0]), Some(Format::OpenExr));
|
||||
}
|
||||
#[test]
|
||||
fn detect_magic_hdr() {
|
||||
|
|
@ -192,24 +163,10 @@ fn detect_magic_hdr() {
|
|||
}
|
||||
#[test]
|
||||
fn detect_magic_avif() {
|
||||
assert_eq!(
|
||||
detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"),
|
||||
Some(Format::Avif)
|
||||
);
|
||||
assert_eq!(
|
||||
detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"),
|
||||
Some(Format::Avif)
|
||||
);
|
||||
assert_eq!(
|
||||
detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1avif"),
|
||||
Some(Format::Avif),
|
||||
"compatible brand"
|
||||
);
|
||||
assert_eq!(
|
||||
detect(b"\0\0\0\x18ftypisom\0\0\0\0isommp41"),
|
||||
None,
|
||||
"mp4 is not avif"
|
||||
);
|
||||
assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif));
|
||||
assert_eq!(detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), Some(Format::Avif));
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1avif"), Some(Format::Avif), "compatible brand");
|
||||
assert_eq!(detect(b"\0\0\0\x18ftypisom\0\0\0\0isommp41"), None, "mp4 is not avif");
|
||||
}
|
||||
#[test]
|
||||
fn detect_tga_heuristic() {
|
||||
|
|
@ -236,17 +193,12 @@ fn detect_negatives() {
|
|||
#[test]
|
||||
fn detect_random_bytes_rarely_matches_tga() {
|
||||
let mut rng = Rng::new(5);
|
||||
let hits = (0..2000)
|
||||
.filter(|_| detect(&rng.bytes(64)) == Some(Format::Tga))
|
||||
.count();
|
||||
let hits = (0..2000).filter(|_| detect(&rng.bytes(64)) == Some(Format::Tga)).count();
|
||||
assert!(hits < 10, "{hits} false TGA positives");
|
||||
}
|
||||
#[test]
|
||||
fn decode_unknown_is_error() {
|
||||
assert!(matches!(
|
||||
decode(b"garbage garbage"),
|
||||
Err(CodecError::UnknownFormat)
|
||||
));
|
||||
assert!(matches!(decode(b"garbage garbage"), Err(CodecError::UnknownFormat)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -277,10 +229,7 @@ fn extension_specific_cases() {
|
|||
}
|
||||
#[test]
|
||||
fn extensions_unique_across_formats() {
|
||||
let mut all: Vec<&str> = Format::ALL
|
||||
.iter()
|
||||
.flat_map(|f| f.extensions().iter().copied())
|
||||
.collect();
|
||||
let mut all: Vec<&str> = Format::ALL.iter().flat_map(|f| f.extensions().iter().copied()).collect();
|
||||
let n = all.len();
|
||||
all.sort();
|
||||
all.dedup();
|
||||
|
|
|
|||
|
|
@ -15,56 +15,30 @@ fn png_u8_rgb_is_clean() {
|
|||
}
|
||||
#[test]
|
||||
fn png_u16_rgba_is_clean() {
|
||||
assert!(
|
||||
warns(
|
||||
&test_image(ChannelLayout::Rgba, SampleType::U16),
|
||||
Format::Png
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(warns(&test_image(ChannelLayout::Rgba, SampleType::U16), Format::Png).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn tiff_is_clean_for_all_integer_and_f32_layouts() {
|
||||
for l in ChannelLayout::ALL {
|
||||
for s in [
|
||||
SampleType::U8,
|
||||
SampleType::U16,
|
||||
SampleType::F32,
|
||||
SampleType::F16,
|
||||
] {
|
||||
assert!(
|
||||
warns(&test_image(l, s), Format::Tiff).is_empty(),
|
||||
"{l:?} {s:?}"
|
||||
);
|
||||
for s in [SampleType::U8, SampleType::U16, SampleType::F32, SampleType::F16] {
|
||||
assert!(warns(&test_image(l, s), Format::Tiff).is_empty(), "{l:?} {s:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn sixteen_bit_to_jpeg_reduces() {
|
||||
let w = warns(
|
||||
&test_image(ChannelLayout::Rgb, SampleType::U16),
|
||||
Format::Jpeg,
|
||||
);
|
||||
assert!(w.contains(&W::DepthReduced {
|
||||
from: SampleType::U16,
|
||||
to: SampleType::U8
|
||||
}));
|
||||
let w = warns(&test_image(ChannelLayout::Rgb, SampleType::U16), Format::Jpeg);
|
||||
assert!(w.contains(&W::DepthReduced { from: SampleType::U16, to: SampleType::U8 }));
|
||||
assert!(w.contains(&W::LossyCompression));
|
||||
}
|
||||
#[test]
|
||||
fn sixteen_bit_message_text() {
|
||||
let w = W::DepthReduced {
|
||||
from: SampleType::U16,
|
||||
to: SampleType::U8,
|
||||
};
|
||||
let w = W::DepthReduced { from: SampleType::U16, to: SampleType::U8 };
|
||||
assert_eq!(w.to_string(), "16-bit will be reduced to 8-bit");
|
||||
}
|
||||
#[test]
|
||||
fn alpha_to_jpeg_discarded() {
|
||||
let w = warns(
|
||||
&test_image(ChannelLayout::Rgba, SampleType::U8),
|
||||
Format::Jpeg,
|
||||
);
|
||||
let w = warns(&test_image(ChannelLayout::Rgba, SampleType::U8), Format::Jpeg);
|
||||
assert!(w.contains(&W::AlphaDiscarded));
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -77,43 +51,24 @@ fn opaque_alpha_not_reported() {
|
|||
}
|
||||
#[test]
|
||||
fn alpha_to_hdr_discarded() {
|
||||
assert!(
|
||||
warns(
|
||||
&test_image(ChannelLayout::Rgba, SampleType::F32),
|
||||
Format::Hdr
|
||||
)
|
||||
.contains(&W::AlphaDiscarded)
|
||||
);
|
||||
assert!(warns(&test_image(ChannelLayout::Rgba, SampleType::F32), Format::Hdr).contains(&W::AlphaDiscarded));
|
||||
}
|
||||
#[test]
|
||||
fn cmyk_to_png_converted() {
|
||||
let w = warns(
|
||||
&test_image(ChannelLayout::Cmyk, SampleType::U8),
|
||||
Format::Png,
|
||||
);
|
||||
assert!(w.contains(&W::CmykConverted {
|
||||
to: ChannelLayout::Rgb
|
||||
}));
|
||||
let w = warns(&test_image(ChannelLayout::Cmyk, SampleType::U8), Format::Png);
|
||||
assert!(w.contains(&W::CmykConverted { to: ChannelLayout::Rgb }));
|
||||
}
|
||||
#[test]
|
||||
fn cmyka_to_webp_converted_keeps_alpha() {
|
||||
let w = warns(
|
||||
&test_image(ChannelLayout::CmykA, SampleType::U8),
|
||||
Format::WebP,
|
||||
);
|
||||
assert!(w.contains(&W::CmykConverted {
|
||||
to: ChannelLayout::Rgba
|
||||
}));
|
||||
let w = warns(&test_image(ChannelLayout::CmykA, SampleType::U8), Format::WebP);
|
||||
assert!(w.contains(&W::CmykConverted { to: ChannelLayout::Rgba }));
|
||||
assert!(!w.contains(&W::AlphaDiscarded));
|
||||
}
|
||||
#[test]
|
||||
fn cmyk_to_jpeg_and_tiff_not_converted() {
|
||||
for f in [Format::Jpeg, Format::Tiff, Format::Pnm] {
|
||||
let w = warns(&test_image(ChannelLayout::Cmyk, SampleType::U8), f);
|
||||
assert!(
|
||||
!w.iter().any(|w| matches!(w, W::CmykConverted { .. })),
|
||||
"{f:?}"
|
||||
);
|
||||
assert!(!w.iter().any(|w| matches!(w, W::CmykConverted { .. })), "{f:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -125,21 +80,13 @@ fn icc_dropped_for_bmp() {
|
|||
#[test]
|
||||
fn icc_warning_suppressed_when_not_embedding() {
|
||||
let img = test_image(ChannelLayout::Rgb, SampleType::U8).with_icc(Some(sample_icc(100)));
|
||||
let opts = EncodeOptions {
|
||||
embed_icc: false,
|
||||
..Default::default()
|
||||
};
|
||||
let opts = EncodeOptions { embed_icc: false, ..Default::default() };
|
||||
assert!(!fidelity_warnings_with(&img, Format::Bmp, &opts).contains(&W::IccDropped));
|
||||
}
|
||||
#[test]
|
||||
fn exif_xmp_dpi_text_dropped_for_qoi() {
|
||||
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
img.meta = Metadata {
|
||||
exif: Some(sample_exif()),
|
||||
xmp: Some("x".into()),
|
||||
dpi: Some((72.0, 72.0)),
|
||||
text: vec![("a".into(), "b".into())],
|
||||
};
|
||||
img.meta = Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())] };
|
||||
let w = warns(&img, Format::Qoi);
|
||||
for x in [W::ExifDropped, W::XmpDropped, W::DpiDropped, W::TextDropped] {
|
||||
assert!(w.contains(&x), "{x:?}");
|
||||
|
|
@ -148,12 +95,7 @@ fn exif_xmp_dpi_text_dropped_for_qoi() {
|
|||
#[test]
|
||||
fn webp_drops_dpi_and_text_only() {
|
||||
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
img.meta = Metadata {
|
||||
exif: Some(sample_exif()),
|
||||
xmp: Some("x".into()),
|
||||
dpi: Some((72.0, 72.0)),
|
||||
text: vec![("a".into(), "b".into())],
|
||||
};
|
||||
img.meta = Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())] };
|
||||
let w = warns(&img, Format::WebP);
|
||||
assert_eq!(w, vec![W::DpiDropped, W::TextDropped]);
|
||||
}
|
||||
|
|
@ -162,120 +104,72 @@ fn float_to_png_reduces_and_clips() {
|
|||
let vals: Vec<f32> = (0..12).map(|i| i as f32 * 0.3 - 0.5).collect();
|
||||
let img = Image::from_f32(2, 2, ChannelLayout::Rgb, &vals).unwrap();
|
||||
let w = warns(&img, Format::Png);
|
||||
assert!(w.contains(&W::DepthReduced {
|
||||
from: SampleType::F32,
|
||||
to: SampleType::U16
|
||||
}));
|
||||
assert!(w.contains(&W::DepthReduced { from: SampleType::F32, to: SampleType::U16 }));
|
||||
assert!(w.contains(&W::RangeClipped));
|
||||
}
|
||||
#[test]
|
||||
fn in_range_float_not_clipped() {
|
||||
let w = warns(
|
||||
&test_image(ChannelLayout::Rgb, SampleType::F32),
|
||||
Format::Png,
|
||||
);
|
||||
let w = warns(&test_image(ChannelLayout::Rgb, SampleType::F32), Format::Png);
|
||||
assert!(!w.contains(&W::RangeClipped));
|
||||
}
|
||||
#[test]
|
||||
fn float_to_exr_and_tiff_is_clean() {
|
||||
for f in [Format::OpenExr, Format::Tiff, Format::Pnm] {
|
||||
assert!(
|
||||
warns(&test_image(ChannelLayout::Rgb, SampleType::F32), f).is_empty(),
|
||||
"{f:?}"
|
||||
);
|
||||
assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), f).is_empty(), "{f:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn f16_to_exr_is_clean() {
|
||||
assert!(
|
||||
warns(
|
||||
&test_image(ChannelLayout::Rgba, SampleType::F16),
|
||||
Format::OpenExr
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(warns(&test_image(ChannelLayout::Rgba, SampleType::F16), Format::OpenExr).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn integer_to_exr_is_lossless() {
|
||||
for s in [SampleType::U8, SampleType::U16] {
|
||||
assert!(
|
||||
warns(&test_image(ChannelLayout::Rgb, s), Format::OpenExr).is_empty(),
|
||||
"{s:?}"
|
||||
);
|
||||
assert!(warns(&test_image(ChannelLayout::Rgb, s), Format::OpenExr).is_empty(), "{s:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn float_pnm_drops_alpha() {
|
||||
let w = warns(
|
||||
&test_image(ChannelLayout::Rgba, SampleType::F32),
|
||||
Format::Pnm,
|
||||
);
|
||||
let w = warns(&test_image(ChannelLayout::Rgba, SampleType::F32), Format::Pnm);
|
||||
assert!(w.contains(&W::AlphaDiscarded));
|
||||
}
|
||||
#[test]
|
||||
fn gif_quantizes_and_binarizes() {
|
||||
let w = warns(
|
||||
&test_image(ChannelLayout::Rgba, SampleType::U8),
|
||||
Format::Gif,
|
||||
);
|
||||
let w = warns(&test_image(ChannelLayout::Rgba, SampleType::U8), Format::Gif);
|
||||
assert!(w.contains(&W::PaletteQuantized));
|
||||
assert!(w.contains(&W::AlphaBinarized));
|
||||
assert!(!w.contains(&W::LossyCompression));
|
||||
}
|
||||
#[test]
|
||||
fn hdr_is_lossy() {
|
||||
assert!(
|
||||
warns(
|
||||
&test_image(ChannelLayout::Rgb, SampleType::F32),
|
||||
Format::Hdr
|
||||
)
|
||||
.contains(&W::LossyCompression)
|
||||
);
|
||||
assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), Format::Hdr).contains(&W::LossyCompression));
|
||||
}
|
||||
#[test]
|
||||
fn ico_oversize_is_fatal() {
|
||||
let img = Image::new(512, 16, ChannelLayout::Rgba, SampleType::U8);
|
||||
let w = warns(&img, Format::Ico);
|
||||
assert!(w.iter().any(|w| w.is_fatal()));
|
||||
assert!(w.contains(&W::DimensionsExceeded {
|
||||
max_width: 256,
|
||||
max_height: 256
|
||||
}));
|
||||
assert!(w.contains(&W::DimensionsExceeded { max_width: 256, max_height: 256 }));
|
||||
}
|
||||
#[test]
|
||||
fn avif_default_build_write_unsupported() {
|
||||
let w = warns(
|
||||
&test_image(ChannelLayout::Rgb, SampleType::U8),
|
||||
Format::Avif,
|
||||
);
|
||||
let w = warns(&test_image(ChannelLayout::Rgb, SampleType::U8), Format::Avif);
|
||||
if cfg!(feature = "avif") {
|
||||
assert!(w.contains(&W::LossyCompression));
|
||||
} else {
|
||||
assert_eq!(
|
||||
w,
|
||||
vec![W::WriteUnsupported {
|
||||
format: Format::Avif
|
||||
}]
|
||||
);
|
||||
assert_eq!(w, vec![W::WriteUnsupported { format: Format::Avif }]);
|
||||
assert!(w[0].is_fatal());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn every_warning_has_a_message() {
|
||||
let all = [
|
||||
W::WriteUnsupported {
|
||||
format: Format::Avif,
|
||||
},
|
||||
W::DepthReduced {
|
||||
from: SampleType::F32,
|
||||
to: SampleType::F16,
|
||||
},
|
||||
W::WriteUnsupported { format: Format::Avif },
|
||||
W::DepthReduced { from: SampleType::F32, to: SampleType::F16 },
|
||||
W::RangeClipped,
|
||||
W::AlphaDiscarded,
|
||||
W::AlphaBinarized,
|
||||
W::CmykConverted {
|
||||
to: ChannelLayout::Rgb,
|
||||
},
|
||||
W::CmykConverted { to: ChannelLayout::Rgb },
|
||||
W::ColorToGray,
|
||||
W::PaletteQuantized,
|
||||
W::LossyCompression,
|
||||
|
|
@ -284,10 +178,7 @@ fn every_warning_has_a_message() {
|
|||
W::XmpDropped,
|
||||
W::DpiDropped,
|
||||
W::TextDropped,
|
||||
W::DimensionsExceeded {
|
||||
max_width: 1,
|
||||
max_height: 1,
|
||||
},
|
||||
W::DimensionsExceeded { max_width: 1, max_height: 1 },
|
||||
];
|
||||
for w in all {
|
||||
assert!(w.to_string().len() > 5);
|
||||
|
|
@ -304,27 +195,12 @@ fn warnings_match_actual_roundtrip_behaviour() {
|
|||
let w = warns(&img, f);
|
||||
let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
let depth_reduced = w.iter().any(|w| matches!(w, W::DepthReduced { .. }));
|
||||
let lost_depth = back.sample_type() != s
|
||||
&& back.sample_type() != SampleType::F32
|
||||
&& s != SampleType::U8;
|
||||
assert_eq!(
|
||||
depth_reduced,
|
||||
lost_depth,
|
||||
"{f:?} {l:?} {s:?} {w:?} -> {:?}",
|
||||
back.sample_type()
|
||||
);
|
||||
let lost_depth = back.sample_type() != s && back.sample_type() != SampleType::F32 && s != SampleType::U8;
|
||||
assert_eq!(depth_reduced, lost_depth, "{f:?} {l:?} {s:?} {w:?} -> {:?}", back.sample_type());
|
||||
let alpha_lost = l.has_alpha() && !back.layout().has_alpha();
|
||||
assert_eq!(
|
||||
w.contains(&W::AlphaDiscarded),
|
||||
alpha_lost,
|
||||
"{f:?} {l:?} {s:?}"
|
||||
);
|
||||
assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}");
|
||||
let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk();
|
||||
assert_eq!(
|
||||
w.iter().any(|w| matches!(w, W::CmykConverted { .. })),
|
||||
cmyk_lost,
|
||||
"{f:?} {l:?} {s:?}"
|
||||
);
|
||||
assert_eq!(w.iter().any(|w| matches!(w, W::CmykConverted { .. })), cmyk_lost, "{f:?} {l:?} {s:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,29 +21,17 @@ fn encoded(f: Format) -> Vec<u8> {
|
|||
fn max_pixels_enforced_for_every_format() {
|
||||
for f in rw_formats() {
|
||||
let b = encoded(f);
|
||||
let l = Limits {
|
||||
max_pixels: 1000,
|
||||
..Limits::default()
|
||||
};
|
||||
let l = Limits { max_pixels: 1000, ..Limits::default() };
|
||||
assert!(is_limit(decode_with(&b, &opts(l))), "{f:?}");
|
||||
let l = Limits {
|
||||
max_pixels: 64 * 48,
|
||||
..Limits::default()
|
||||
};
|
||||
assert!(
|
||||
decode_with(&b, &opts(l)).is_ok(),
|
||||
"{f:?} exact limit should pass"
|
||||
);
|
||||
let l = Limits { max_pixels: 64 * 48, ..Limits::default() };
|
||||
assert!(decode_with(&b, &opts(l)).is_ok(), "{f:?} exact limit should pass");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_width_enforced_for_every_format() {
|
||||
for f in rw_formats() {
|
||||
let l = Limits {
|
||||
max_width: 63,
|
||||
..Limits::default()
|
||||
};
|
||||
let l = Limits { max_width: 63, ..Limits::default() };
|
||||
assert!(is_limit(decode_with(&encoded(f), &opts(l))), "{f:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -51,10 +39,7 @@ fn max_width_enforced_for_every_format() {
|
|||
#[test]
|
||||
fn max_height_enforced_for_every_format() {
|
||||
for f in rw_formats() {
|
||||
let l = Limits {
|
||||
max_height: 47,
|
||||
..Limits::default()
|
||||
};
|
||||
let l = Limits { max_height: 47, ..Limits::default() };
|
||||
assert!(is_limit(decode_with(&encoded(f), &opts(l))), "{f:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -62,10 +47,7 @@ fn max_height_enforced_for_every_format() {
|
|||
#[test]
|
||||
fn max_alloc_enforced_for_every_format() {
|
||||
for f in rw_formats() {
|
||||
let l = Limits {
|
||||
max_alloc: 64 * 48,
|
||||
..Limits::default()
|
||||
};
|
||||
let l = Limits { max_alloc: 64 * 48, ..Limits::default() };
|
||||
assert!(decode_with(&encoded(f), &opts(l)).is_err(), "{f:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -73,10 +55,7 @@ fn max_alloc_enforced_for_every_format() {
|
|||
#[test]
|
||||
fn limits_none_accepts() {
|
||||
for f in rw_formats() {
|
||||
assert!(
|
||||
decode_with(&encoded(f), &opts(Limits::none())).is_ok(),
|
||||
"{f:?}"
|
||||
);
|
||||
assert!(decode_with(&encoded(f), &opts(Limits::none())).is_ok(), "{f:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,11 +74,7 @@ fn crc32(d: &[u8]) -> u32 {
|
|||
for &b in d {
|
||||
c ^= b as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 {
|
||||
0xEDB8_8320 ^ (c >> 1)
|
||||
} else {
|
||||
c >> 1
|
||||
};
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
|
|
@ -113,10 +88,7 @@ fn png_bomb_header_rejected_before_alloc() {
|
|||
ihdr.extend_from_slice(&[16, 6, 0, 0, 0]);
|
||||
let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
b.extend(png_chunk(b"IHDR", &ihdr));
|
||||
b.extend(png_chunk(
|
||||
b"IDAT",
|
||||
&[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01],
|
||||
));
|
||||
b.extend(png_chunk(b"IDAT", &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]));
|
||||
b.extend(png_chunk(b"IEND", &[]));
|
||||
assert!(is_limit(decode(&b)));
|
||||
}
|
||||
|
|
@ -124,23 +96,15 @@ fn png_bomb_header_rejected_before_alloc() {
|
|||
#[test]
|
||||
fn pnm_bomb_header_rejected() {
|
||||
assert!(is_limit(decode(b"P6\n200000 200000\n255\n\0\0\0")));
|
||||
assert!(is_limit(decode(
|
||||
b"P7\nWIDTH 99999\nHEIGHT 99999\nDEPTH 4\nMAXVAL 65535\nTUPLTYPE RGB_ALPHA\nENDHDR\n"
|
||||
)));
|
||||
assert!(is_limit(decode(b"P7\nWIDTH 99999\nHEIGHT 99999\nDEPTH 4\nMAXVAL 65535\nTUPLTYPE RGB_ALPHA\nENDHDR\n")));
|
||||
assert!(is_limit(decode(b"PF\n100000 100000\n-1.0\n")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_bomb_header_rejected() {
|
||||
// SOI + SOF0 claiming 65535x65535x3 with no scan data.
|
||||
let b = [
|
||||
0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 1, 0x11, 0, 2, 0x11, 0,
|
||||
3, 0x11, 0, 0xFF, 0xD9,
|
||||
];
|
||||
let l = Limits {
|
||||
max_pixels: 1 << 24,
|
||||
..Limits::default()
|
||||
};
|
||||
let b = [0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 0xFF, 0xD9];
|
||||
let l = Limits { max_pixels: 1 << 24, ..Limits::default() };
|
||||
assert!(is_limit(decode_with(&b, &opts(l))));
|
||||
}
|
||||
|
||||
|
|
@ -148,14 +112,7 @@ fn jpeg_bomb_header_rejected() {
|
|||
fn tiff_bomb_header_rejected() {
|
||||
// Minimal little-endian TIFF claiming 1_000_000 x 1_000_000 8-bit gray.
|
||||
let mut b = b"II*\0\x08\0\0\0".to_vec();
|
||||
let entries: [(u16, u16, u32, u32); 6] = [
|
||||
(256, 4, 1, 1_000_000),
|
||||
(257, 4, 1, 1_000_000),
|
||||
(258, 3, 1, 8),
|
||||
(262, 3, 1, 1),
|
||||
(273, 4, 1, 0),
|
||||
(279, 4, 1, 0),
|
||||
];
|
||||
let entries: [(u16, u16, u32, u32); 6] = [(256, 4, 1, 1_000_000), (257, 4, 1, 1_000_000), (258, 3, 1, 8), (262, 3, 1, 1), (273, 4, 1, 0), (279, 4, 1, 0)];
|
||||
b.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
for (tag, ty, count, val) in entries {
|
||||
b.extend_from_slice(&tag.to_le_bytes());
|
||||
|
|
@ -170,25 +127,10 @@ fn tiff_bomb_header_rejected() {
|
|||
#[test]
|
||||
fn limits_check_api() {
|
||||
let l = Limits::default();
|
||||
assert!(
|
||||
l.check(100, 100, ChannelLayout::Rgba, SampleType::F32)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(matches!(
|
||||
l.check(0, 1, ChannelLayout::Gray, SampleType::U8),
|
||||
Err(CodecError::InvalidImage(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
l.check(u32::MAX, 1, ChannelLayout::Gray, SampleType::U8),
|
||||
Err(CodecError::LimitExceeded(_))
|
||||
));
|
||||
let l = Limits {
|
||||
max_alloc: 399,
|
||||
..Limits::default()
|
||||
};
|
||||
assert!(
|
||||
l.check(10, 10, ChannelLayout::Rgba, SampleType::U8)
|
||||
.is_err()
|
||||
);
|
||||
assert!(l.check(100, 100, ChannelLayout::Rgba, SampleType::F32).is_ok());
|
||||
assert!(matches!(l.check(0, 1, ChannelLayout::Gray, SampleType::U8), Err(CodecError::InvalidImage(_))));
|
||||
assert!(matches!(l.check(u32::MAX, 1, ChannelLayout::Gray, SampleType::U8), Err(CodecError::LimitExceeded(_))));
|
||||
let l = Limits { max_alloc: 399, ..Limits::default() };
|
||||
assert!(l.check(10, 10, ChannelLayout::Rgba, SampleType::U8).is_err());
|
||||
assert!(l.check(10, 10, ChannelLayout::Rgb, SampleType::U8).is_ok());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,24 +6,13 @@ use photocraft_codecs::*;
|
|||
use proptest::prelude::*;
|
||||
|
||||
fn tight() -> DecodeOptions {
|
||||
DecodeOptions {
|
||||
limits: Limits {
|
||||
max_width: 4096,
|
||||
max_height: 4096,
|
||||
max_pixels: 1 << 22,
|
||||
max_alloc: 64 << 20,
|
||||
},
|
||||
}
|
||||
DecodeOptions { limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 64 << 20 } }
|
||||
}
|
||||
|
||||
fn samples() -> Vec<(Format, Vec<u8>)> {
|
||||
let mut v = Vec::new();
|
||||
for f in rw_formats() {
|
||||
for (l, s) in [
|
||||
(ChannelLayout::Rgba, SampleType::U8),
|
||||
(ChannelLayout::Gray, SampleType::U16),
|
||||
(ChannelLayout::Rgb, SampleType::F32),
|
||||
] {
|
||||
for (l, s) in [(ChannelLayout::Rgba, SampleType::U8), (ChannelLayout::Gray, SampleType::U16), (ChannelLayout::Rgb, SampleType::F32)] {
|
||||
let mut img = synth(19, 13, l, s, 11, 0.3);
|
||||
img.icc = Some(sample_icc(300));
|
||||
img.meta.exif = Some(sample_exif());
|
||||
|
|
@ -33,31 +22,9 @@ fn samples() -> Vec<(Format, Vec<u8>)> {
|
|||
}
|
||||
// Extra variants: interlaced PNG, ASCII PNM, CMYK JPEG.
|
||||
let img = synth(19, 13, ChannelLayout::Rgb, SampleType::U8, 1, 0.3);
|
||||
v.push((
|
||||
Format::Png,
|
||||
encode(
|
||||
&img,
|
||||
Format::Png,
|
||||
&EncodeOptions {
|
||||
png_interlaced: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
));
|
||||
v.push((
|
||||
Format::Pnm,
|
||||
b"P3\n2 2\n255\n0 1 2 3 4 5 6 7 8 9 10 11\n".to_vec(),
|
||||
));
|
||||
v.push((
|
||||
Format::Jpeg,
|
||||
encode(
|
||||
&synth(19, 13, ChannelLayout::Cmyk, SampleType::U8, 1, 0.3),
|
||||
Format::Jpeg,
|
||||
&EncodeOptions::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
));
|
||||
v.push((Format::Png, encode(&img, Format::Png, &EncodeOptions { png_interlaced: true, ..Default::default() }).unwrap()));
|
||||
v.push((Format::Pnm, b"P3\n2 2\n255\n0 1 2 3 4 5 6 7 8 9 10 11\n".to_vec()));
|
||||
v.push((Format::Jpeg, encode(&synth(19, 13, ChannelLayout::Cmyk, SampleType::U8, 1, 0.3), Format::Jpeg, &EncodeOptions::default()).unwrap()));
|
||||
v
|
||||
}
|
||||
|
||||
|
|
@ -85,10 +52,7 @@ fn truncated_files_mostly_error() {
|
|||
// Chopping off the second half must not silently succeed for formats
|
||||
// with length-checked rasters.
|
||||
for (f, bytes) in samples() {
|
||||
if matches!(
|
||||
f,
|
||||
Format::Png | Format::Pnm | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp
|
||||
) {
|
||||
if matches!(f, Format::Png | Format::Pnm | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) {
|
||||
let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight());
|
||||
assert!(r.is_err(), "{f:?} decoded half a file");
|
||||
}
|
||||
|
|
@ -172,27 +136,15 @@ fn pnm_valid_variants() {
|
|||
assert_eq!(img.sample_type(), SampleType::U16);
|
||||
assert_eq!(img.to_u16_samples().unwrap(), vec![0, 65535]);
|
||||
let img = decode(b"P3\n1 1\n255\n1 2 3\n").unwrap();
|
||||
assert_eq!(
|
||||
(img.layout(), img.data()),
|
||||
(ChannelLayout::Rgb, &[1u8, 2, 3][..])
|
||||
);
|
||||
let img = decode(
|
||||
b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 2\nMAXVAL 255\nTUPLTYPE GRAYSCALE_ALPHA\nENDHDR\n\x10\x20",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(img.layout(), img.data()),
|
||||
(ChannelLayout::GrayA, &[0x10u8, 0x20][..])
|
||||
);
|
||||
assert_eq!((img.layout(), img.data()), (ChannelLayout::Rgb, &[1u8, 2, 3][..]));
|
||||
let img = decode(b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 2\nMAXVAL 255\nTUPLTYPE GRAYSCALE_ALPHA\nENDHDR\n\x10\x20").unwrap();
|
||||
assert_eq!((img.layout(), img.data()), (ChannelLayout::GrayA, &[0x10u8, 0x20][..]));
|
||||
let mut pfm = b"PF\n1 2\n1.0\n".to_vec(); // big-endian, bottom-up
|
||||
for v in [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0] {
|
||||
pfm.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let img = decode(&pfm).unwrap();
|
||||
assert_eq!(
|
||||
img.to_f32_samples().unwrap(),
|
||||
vec![4.0, 5.0, 6.0, 1.0, 2.0, 3.0]
|
||||
);
|
||||
assert_eq!(img.to_f32_samples().unwrap(), vec![4.0, 5.0, 6.0, 1.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -201,13 +153,8 @@ fn jpeg_marker_garbage() {
|
|||
&[0xFF, 0xD8, 0xFF],
|
||||
&[0xFF, 0xD8, 0xFF, 0xE0, 0x00],
|
||||
&[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x01],
|
||||
&[
|
||||
0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x10, b'I', b'C', b'C', b'_', b'P', b'R', b'O', b'F',
|
||||
b'I', b'L', b'E', 0, 1, 1,
|
||||
],
|
||||
&[
|
||||
0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x0B, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 0, 0, 0,
|
||||
],
|
||||
&[0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x10, b'I', b'C', b'C', b'_', b'P', b'R', b'O', b'F', b'I', b'L', b'E', 0, 1, 1],
|
||||
&[0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x0B, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 0, 0, 0],
|
||||
&[0xFF, 0xD8, 0xFF, 0xD9],
|
||||
];
|
||||
for c in cases {
|
||||
|
|
|
|||
|
|
@ -11,10 +11,7 @@ fn rich_image(layout: ChannelLayout) -> Image {
|
|||
exif: Some(sample_exif()),
|
||||
xmp: Some(SAMPLE_XMP.to_owned()),
|
||||
dpi: Some((300.0, 150.0)),
|
||||
text: vec![
|
||||
("Description".into(), "synthetic test image".into()),
|
||||
("Software".into(), "photocraft".into()),
|
||||
],
|
||||
text: vec![("Description".into(), "synthetic test image".into()), ("Software".into(), "photocraft".into())],
|
||||
};
|
||||
img
|
||||
}
|
||||
|
|
@ -24,11 +21,7 @@ fn check_format(format: Format) {
|
|||
if !(c.read && c.write) {
|
||||
return;
|
||||
}
|
||||
let layout = if c.layouts.contains(&ChannelLayout::Rgb) {
|
||||
ChannelLayout::Rgb
|
||||
} else {
|
||||
c.layouts[0]
|
||||
};
|
||||
let layout = if c.layouts.contains(&ChannelLayout::Rgb) { ChannelLayout::Rgb } else { c.layouts[0] };
|
||||
let img = rich_image(layout);
|
||||
let bytes = encode(&img, format, &EncodeOptions::default()).unwrap();
|
||||
let back = decode(&bytes).unwrap();
|
||||
|
|
@ -38,57 +31,32 @@ fn check_format(format: Format) {
|
|||
if c.icc {
|
||||
assert_eq!(back.icc, img.icc, "{format:?} icc bytes");
|
||||
} else {
|
||||
assert!(
|
||||
warnings.contains(&FidelityWarning::IccDropped),
|
||||
"{format:?}"
|
||||
);
|
||||
assert!(warnings.contains(&FidelityWarning::IccDropped), "{format:?}");
|
||||
}
|
||||
if c.exif {
|
||||
assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif");
|
||||
} else {
|
||||
assert!(
|
||||
warnings.contains(&FidelityWarning::ExifDropped),
|
||||
"{format:?}"
|
||||
);
|
||||
assert!(warnings.contains(&FidelityWarning::ExifDropped), "{format:?}");
|
||||
}
|
||||
if c.xmp {
|
||||
assert_eq!(back.meta.xmp, img.meta.xmp, "{format:?} xmp");
|
||||
} else {
|
||||
assert!(back.meta.xmp.is_none());
|
||||
assert!(
|
||||
warnings.contains(&FidelityWarning::XmpDropped),
|
||||
"{format:?}"
|
||||
);
|
||||
assert!(warnings.contains(&FidelityWarning::XmpDropped), "{format:?}");
|
||||
}
|
||||
if c.dpi {
|
||||
let (x, y) = back
|
||||
.meta
|
||||
.dpi
|
||||
.unwrap_or_else(|| panic!("{format:?} lost dpi"));
|
||||
assert!(
|
||||
(x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05,
|
||||
"{format:?} dpi {x},{y}"
|
||||
);
|
||||
let (x, y) = back.meta.dpi.unwrap_or_else(|| panic!("{format:?} lost dpi"));
|
||||
assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "{format:?} dpi {x},{y}");
|
||||
} else {
|
||||
assert!(
|
||||
warnings.contains(&FidelityWarning::DpiDropped),
|
||||
"{format:?}"
|
||||
);
|
||||
assert!(warnings.contains(&FidelityWarning::DpiDropped), "{format:?}");
|
||||
}
|
||||
if c.text {
|
||||
for kv in &img.meta.text {
|
||||
assert!(
|
||||
back.meta.text.contains(kv),
|
||||
"{format:?} lost text {kv:?}: {:?}",
|
||||
back.meta.text
|
||||
);
|
||||
assert!(back.meta.text.contains(kv), "{format:?} lost text {kv:?}: {:?}", back.meta.text);
|
||||
}
|
||||
} else {
|
||||
assert!(back.meta.text.is_empty());
|
||||
assert!(
|
||||
warnings.contains(&FidelityWarning::TextDropped),
|
||||
"{format:?}"
|
||||
);
|
||||
assert!(warnings.contains(&FidelityWarning::TextDropped), "{format:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,10 +135,7 @@ fn icc_on_cmyk_tiff_and_jpeg() {
|
|||
fn cmyk_icc_not_written_to_rgb_format() {
|
||||
let img = test_image(ChannelLayout::Cmyk, SampleType::U8).with_icc(Some(sample_icc(900)));
|
||||
let back = decode(&encode(&img, Format::Png, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
assert!(
|
||||
back.icc.is_none(),
|
||||
"a CMYK profile must not be attached to converted RGB data"
|
||||
);
|
||||
assert!(back.icc.is_none(), "a CMYK profile must not be attached to converted RGB data");
|
||||
assert!(fidelity_warnings(&img, Format::Png).contains(&FidelityWarning::IccDropped));
|
||||
}
|
||||
|
||||
|
|
@ -180,10 +145,7 @@ fn jpeg_multi_segment_icc() {
|
|||
for len in [65519, 65520, 150_000, 300_001] {
|
||||
let img = test_image(ChannelLayout::Rgb, SampleType::U8).with_icc(Some(sample_icc(len)));
|
||||
let bytes = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let app2 = bytes
|
||||
.windows(14)
|
||||
.filter(|w| w[0] == 0xFF && w[1] == 0xE2 && &w[4..14] == b"ICC_PROFIL")
|
||||
.count();
|
||||
let app2 = bytes.windows(14).filter(|w| w[0] == 0xFF && w[1] == 0xE2 && &w[4..14] == b"ICC_PROFIL").count();
|
||||
assert_eq!(app2, len.div_ceil(65519), "segments for {len}");
|
||||
assert_eq!(decode(&bytes).unwrap().icc.map(|v| v.len()), Some(len));
|
||||
// Oracle: the image crate reads the same profile.
|
||||
|
|
@ -196,34 +158,20 @@ fn jpeg_multi_segment_icc() {
|
|||
#[test]
|
||||
fn embed_flags_disable_metadata() {
|
||||
let img = rich_image(ChannelLayout::Rgb);
|
||||
let opts = EncodeOptions {
|
||||
embed_icc: false,
|
||||
embed_metadata: false,
|
||||
..Default::default()
|
||||
};
|
||||
let opts = EncodeOptions { embed_icc: false, embed_metadata: false, ..Default::default() };
|
||||
for f in [Format::Png, Format::Jpeg, Format::Tiff, Format::WebP] {
|
||||
let back = decode(&encode(&img, f, &opts).unwrap()).unwrap();
|
||||
assert!(back.icc.is_none(), "{f:?}");
|
||||
assert!(back.meta.exif.is_none() && back.meta.xmp.is_none(), "{f:?}");
|
||||
assert!(back.meta.text.is_empty(), "{f:?}");
|
||||
assert!(
|
||||
fidelity_warnings_with(&img, Format::Bmp, &opts)
|
||||
.iter()
|
||||
.all(|w| !matches!(
|
||||
w,
|
||||
FidelityWarning::IccDropped | FidelityWarning::ExifDropped
|
||||
))
|
||||
);
|
||||
assert!(fidelity_warnings_with(&img, Format::Bmp, &opts).iter().all(|w| !matches!(w, FidelityWarning::IccDropped | FidelityWarning::ExifDropped)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_unicode_text_uses_itxt() {
|
||||
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
img.meta.text = vec![
|
||||
("Title".into(), "Ünïcödé ☃ 雪".into()),
|
||||
("Author".into(), "latin1 ok é".into()),
|
||||
];
|
||||
img.meta.text = vec![("Title".into(), "Ünïcödé ☃ 雪".into()), ("Author".into(), "latin1 ok é".into())];
|
||||
let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
|
||||
assert!(bytes.windows(4).any(|w| w == b"iTXt"));
|
||||
assert!(bytes.windows(4).any(|w| w == b"tEXt"));
|
||||
|
|
|
|||
|
|
@ -10,15 +10,7 @@ use photocraft_codecs::*;
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn interlaced(img: &Image) -> Vec<u8> {
|
||||
encode(
|
||||
img,
|
||||
Format::Png,
|
||||
&EncodeOptions {
|
||||
png_interlaced: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
encode(img, Format::Png, &EncodeOptions { png_interlaced: true, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
fn check_adam7(w: u32, h: u32, layout: ChannelLayout, sample: SampleType) {
|
||||
|
|
@ -29,39 +21,18 @@ fn check_adam7(w: u32, h: u32, layout: ChannelLayout, sample: SampleType) {
|
|||
assert_eq!(b[28], 0);
|
||||
let da = decode(&a).unwrap();
|
||||
let db = decode(&b).unwrap();
|
||||
assert_eq!(
|
||||
da, db,
|
||||
"{w}x{h} {layout:?} {sample:?}: interlaced != progressive"
|
||||
);
|
||||
assert_eq!(da, db, "{w}x{h} {layout:?} {sample:?}: interlaced != progressive");
|
||||
assert_eq!(da.data(), img.data());
|
||||
// Oracle: the image crate agrees.
|
||||
let o = image::load_from_memory_with_format(&a, image::ImageFormat::Png).unwrap();
|
||||
let ours = da
|
||||
.convert(ChannelLayout::Rgba, SampleType::U16)
|
||||
.to_u16_samples()
|
||||
.unwrap();
|
||||
let ours = da.convert(ChannelLayout::Rgba, SampleType::U16).to_u16_samples().unwrap();
|
||||
assert_eq!(o.into_rgba16().into_raw(), ours);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adam7_small_sizes_all_layouts() {
|
||||
for (w, h) in [
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(5, 1),
|
||||
(1, 9),
|
||||
(7, 5),
|
||||
(8, 8),
|
||||
(9, 17),
|
||||
(33, 31),
|
||||
] {
|
||||
for l in [
|
||||
ChannelLayout::Gray,
|
||||
ChannelLayout::GrayA,
|
||||
ChannelLayout::Rgb,
|
||||
ChannelLayout::Rgba,
|
||||
] {
|
||||
for (w, h) in [(1, 1), (2, 2), (3, 3), (5, 1), (1, 9), (7, 5), (8, 8), (9, 17), (33, 31)] {
|
||||
for l in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
|
||||
check_adam7(w, h, l, SampleType::U8);
|
||||
}
|
||||
}
|
||||
|
|
@ -88,21 +59,8 @@ fn adam7_with_metadata() {
|
|||
#[test]
|
||||
fn adam7_compressions() {
|
||||
let img = test_image(ChannelLayout::Rgba, SampleType::U8);
|
||||
for c in [
|
||||
PngCompression::None,
|
||||
PngCompression::Fast,
|
||||
PngCompression::Best,
|
||||
] {
|
||||
let b = encode(
|
||||
&img,
|
||||
Format::Png,
|
||||
&EncodeOptions {
|
||||
png_interlaced: true,
|
||||
png_compression: c,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for c in [PngCompression::None, PngCompression::Fast, PngCompression::Best] {
|
||||
let b = encode(&img, Format::Png, &EncodeOptions { png_interlaced: true, png_compression: c, ..Default::default() }).unwrap();
|
||||
assert_eq!(decode(&b).unwrap().data(), img.data());
|
||||
}
|
||||
}
|
||||
|
|
@ -119,10 +77,7 @@ fn png_palette_and_low_bit_depth_expand() {
|
|||
w.write_image_data(&[0b00_01_10_11]).unwrap();
|
||||
}
|
||||
let img = decode(&out).unwrap();
|
||||
assert_eq!(
|
||||
(img.layout(), img.data()),
|
||||
(ChannelLayout::Gray, &[0u8, 85, 170, 255][..])
|
||||
);
|
||||
assert_eq!((img.layout(), img.data()), (ChannelLayout::Gray, &[0u8, 85, 170, 255][..]));
|
||||
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
|
|
@ -135,10 +90,7 @@ fn png_palette_and_low_bit_depth_expand() {
|
|||
w.write_image_data(&[0, 1]).unwrap();
|
||||
}
|
||||
let img = decode(&out).unwrap();
|
||||
assert_eq!(
|
||||
(img.layout(), img.data()),
|
||||
(ChannelLayout::Rgba, &[255u8, 0, 0, 128, 0, 0, 255, 255][..])
|
||||
);
|
||||
assert_eq!((img.layout(), img.data()), (ChannelLayout::Rgba, &[255u8, 0, 0, 128, 0, 0, 255, 255][..]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -156,19 +108,8 @@ fn jpeg_grayscale_decodes_as_gray() {
|
|||
#[test]
|
||||
fn jpeg_cmyk_roundtrip_with_adobe_marker() {
|
||||
let img = synth(40, 30, ChannelLayout::Cmyk, SampleType::U8, 3, 0.02);
|
||||
let bytes = encode(
|
||||
&img,
|
||||
Format::Jpeg,
|
||||
&EncodeOptions {
|
||||
jpeg_quality: 95,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
bytes.windows(5).any(|w| w == b"Adobe"),
|
||||
"APP14 Adobe marker"
|
||||
);
|
||||
let bytes = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 95, ..Default::default() }).unwrap();
|
||||
assert!(bytes.windows(5).any(|w| w == b"Adobe"), "APP14 Adobe marker");
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert_eq!(back.layout(), ChannelLayout::Cmyk);
|
||||
assert!(psnr(&img, &back) > 35.0, "{}", psnr(&img, &back));
|
||||
|
|
@ -180,8 +121,7 @@ fn jpeg_ycck_decodes_to_cmyk() {
|
|||
let mut bytes = Vec::new();
|
||||
let mut enc = jpeg_encoder::Encoder::new(&mut bytes, 95);
|
||||
enc.set_sampling_factor(jpeg_encoder::SamplingFactor::R_4_4_4);
|
||||
enc.encode(img.data(), 40, 32, jpeg_encoder::ColorType::CmykAsYcck)
|
||||
.unwrap();
|
||||
enc.encode(img.data(), 40, 32, jpeg_encoder::ColorType::CmykAsYcck).unwrap();
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert_eq!(back.layout(), ChannelLayout::Cmyk);
|
||||
assert!(psnr(&img, &back) > 32.0, "{}", psnr(&img, &back));
|
||||
|
|
@ -189,13 +129,7 @@ fn jpeg_ycck_decodes_to_cmyk() {
|
|||
|
||||
#[test]
|
||||
fn jpeg_cmyk_flat_colour_exact_ish() {
|
||||
let img = Image::from_u8(
|
||||
16,
|
||||
16,
|
||||
ChannelLayout::Cmyk,
|
||||
[10u8, 100, 200, 50].repeat(256),
|
||||
)
|
||||
.unwrap();
|
||||
let img = Image::from_u8(16, 16, ChannelLayout::Cmyk, [10u8, 100, 200, 50].repeat(256)).unwrap();
|
||||
let back = decode(&encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
for px in back.data().chunks(4) {
|
||||
for (a, b) in px.iter().zip([10u8, 100, 200, 50]) {
|
||||
|
|
@ -209,16 +143,8 @@ fn jpeg_rgb_matches_oracle_decoder() {
|
|||
let img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
let bytes = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let ours = decode(&bytes).unwrap();
|
||||
let theirs = image::load_from_memory_with_format(&bytes, image::ImageFormat::Jpeg)
|
||||
.unwrap()
|
||||
.into_rgb8();
|
||||
let max = ours
|
||||
.data()
|
||||
.iter()
|
||||
.zip(theirs.as_raw())
|
||||
.map(|(a, b)| (*a as i32 - *b as i32).abs())
|
||||
.max()
|
||||
.unwrap();
|
||||
let theirs = image::load_from_memory_with_format(&bytes, image::ImageFormat::Jpeg).unwrap().into_rgb8();
|
||||
let max = ours.data().iter().zip(theirs.as_raw()).map(|(a, b)| (*a as i32 - *b as i32).abs()).max().unwrap();
|
||||
assert!(max <= 1, "max diff {max}");
|
||||
}
|
||||
|
||||
|
|
@ -228,13 +154,7 @@ fn jpeg_progressive_from_other_encoder_decodes() {
|
|||
let mut bytes = Vec::new();
|
||||
let mut enc = jpeg_encoder::Encoder::new(&mut bytes, 90);
|
||||
enc.set_progressive(true);
|
||||
enc.encode(
|
||||
img.data(),
|
||||
img.width() as u16,
|
||||
img.height() as u16,
|
||||
jpeg_encoder::ColorType::Rgb,
|
||||
)
|
||||
.unwrap();
|
||||
enc.encode(img.data(), img.width() as u16, img.height() as u16, jpeg_encoder::ColorType::Rgb).unwrap();
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert!(psnr(&img, &back) > 25.0);
|
||||
}
|
||||
|
|
@ -245,19 +165,11 @@ fn jpeg_progressive_from_other_encoder_decodes() {
|
|||
|
||||
#[test]
|
||||
fn png_output_matches_oracle_all_layouts() {
|
||||
for l in [
|
||||
ChannelLayout::Gray,
|
||||
ChannelLayout::GrayA,
|
||||
ChannelLayout::Rgb,
|
||||
ChannelLayout::Rgba,
|
||||
] {
|
||||
for l in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
|
||||
for s in [SampleType::U8, SampleType::U16] {
|
||||
let img = test_image(l, s);
|
||||
let b = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
|
||||
let o = image::load_from_memory(&b)
|
||||
.unwrap()
|
||||
.into_rgba16()
|
||||
.into_raw();
|
||||
let o = image::load_from_memory(&b).unwrap().into_rgba16().into_raw();
|
||||
assert_eq!(o, img.to_rgba16(), "{l:?} {s:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -269,10 +181,7 @@ fn tiff_output_matches_oracle() {
|
|||
for s in [SampleType::U8, SampleType::U16] {
|
||||
let img = test_image(l, s);
|
||||
let b = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
let o = image::load_from_memory(&b)
|
||||
.unwrap()
|
||||
.into_rgba16()
|
||||
.into_raw();
|
||||
let o = image::load_from_memory(&b).unwrap().into_rgba16().into_raw();
|
||||
assert_eq!(o, img.to_rgba16(), "{l:?} {s:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -281,19 +190,8 @@ fn tiff_output_matches_oracle() {
|
|||
#[test]
|
||||
fn tiff_f32_output_matches_oracle() {
|
||||
let img = test_image(ChannelLayout::Rgba, SampleType::F32);
|
||||
let b = encode(
|
||||
&img,
|
||||
Format::Tiff,
|
||||
&EncodeOptions {
|
||||
tiff_compression: TiffCompression::None,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let o = image::load_from_memory(&b)
|
||||
.unwrap()
|
||||
.into_rgba32f()
|
||||
.into_raw();
|
||||
let b = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: TiffCompression::None, ..Default::default() }).unwrap();
|
||||
let o = image::load_from_memory(&b).unwrap().into_rgba32f().into_raw();
|
||||
assert_eq!(o, img.to_f32_samples().unwrap());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,39 +31,26 @@ fn check_roundtrip(format: Format, layout: ChannelLayout, sample: SampleType) {
|
|||
test_image(layout, sample)
|
||||
};
|
||||
let opts = EncodeOptions::default();
|
||||
let bytes = encode(&img, format, &opts)
|
||||
.unwrap_or_else(|e| panic!("{format:?} {layout:?} {sample:?}: {e}"));
|
||||
let bytes = encode(&img, format, &opts).unwrap_or_else(|e| panic!("{format:?} {layout:?} {sample:?}: {e}"));
|
||||
assert_eq!(detect(&bytes), Some(format), "detect after encode");
|
||||
if !c.read {
|
||||
return;
|
||||
}
|
||||
let back =
|
||||
decode(&bytes).unwrap_or_else(|e| panic!("{format:?} {layout:?} {sample:?} decode: {e}"));
|
||||
let back = decode(&bytes).unwrap_or_else(|e| panic!("{format:?} {layout:?} {sample:?} decode: {e}"));
|
||||
assert_eq!(back.dimensions(), img.dimensions());
|
||||
|
||||
// Native storage must be preserved when the format declares support.
|
||||
let float_pnm_special = format == Format::Pnm && sample.is_float();
|
||||
if c.layouts.contains(&layout) && c.depths.contains(&sample) && !float_pnm_special {
|
||||
assert_eq!(back.layout(), layout, "{format:?} must keep layout");
|
||||
assert_eq!(
|
||||
back.sample_type(),
|
||||
sample,
|
||||
"{format:?} must keep sample type"
|
||||
);
|
||||
assert_eq!(back.sample_type(), sample, "{format:?} must keep sample type");
|
||||
}
|
||||
if c.depths.contains(&sample) && !float_pnm_special {
|
||||
assert_eq!(
|
||||
back.sample_type(),
|
||||
sample,
|
||||
"{format:?} must keep supported depth"
|
||||
);
|
||||
assert_eq!(back.sample_type(), sample, "{format:?} must keep supported depth");
|
||||
}
|
||||
// Integer sources never lose precision in formats offering that depth.
|
||||
if sample == SampleType::U16 && c.depths.contains(&SampleType::U16) {
|
||||
assert!(matches!(
|
||||
back.sample_type(),
|
||||
SampleType::U16 | SampleType::F32
|
||||
));
|
||||
assert!(matches!(back.sample_type(), SampleType::U16 | SampleType::F32));
|
||||
}
|
||||
|
||||
let expected = img.convert(back.layout(), back.sample_type());
|
||||
|
|
@ -79,22 +66,12 @@ fn check_roundtrip(format: Format, layout: ChannelLayout, sample: SampleType) {
|
|||
}
|
||||
pe[ch - 1] = pb[ch - 1];
|
||||
}
|
||||
expected_cmp = Image::from_normalized(
|
||||
img.width(),
|
||||
img.height(),
|
||||
back.layout(),
|
||||
back.sample_type(),
|
||||
&e,
|
||||
)
|
||||
.unwrap();
|
||||
expected_cmp = Image::from_normalized(img.width(), img.height(), back.layout(), back.sample_type(), &e).unwrap();
|
||||
}
|
||||
|
||||
if c.lossy {
|
||||
let p = psnr(&expected_cmp, &back);
|
||||
assert!(
|
||||
p >= psnr_threshold(format),
|
||||
"{format:?} {layout:?} {sample:?}: PSNR {p:.2} dB"
|
||||
);
|
||||
assert!(p >= psnr_threshold(format), "{format:?} {layout:?} {sample:?}: PSNR {p:.2} dB");
|
||||
} else {
|
||||
assert_eq!(
|
||||
expected_cmp.data(),
|
||||
|
|
@ -196,21 +173,10 @@ fn hdr_format_keeps_bright_values_approximately() {
|
|||
|
||||
#[test]
|
||||
fn f16_exr_is_bit_exact() {
|
||||
let vals: Vec<f16> = (0..20 * 10 * 4)
|
||||
.map(|i| f16::from_f32(i as f32 * 0.013 - 1.0))
|
||||
.collect();
|
||||
let vals: Vec<f16> = (0..20 * 10 * 4).map(|i| f16::from_f32(i as f32 * 0.013 - 1.0)).collect();
|
||||
let img = Image::from_f16(20, 10, ChannelLayout::Rgba, &vals).unwrap();
|
||||
for comp in [
|
||||
ExrCompression::None,
|
||||
ExrCompression::Rle,
|
||||
ExrCompression::Zip1,
|
||||
ExrCompression::Zip16,
|
||||
ExrCompression::Piz,
|
||||
] {
|
||||
let opts = EncodeOptions {
|
||||
exr_compression: comp,
|
||||
..Default::default()
|
||||
};
|
||||
for comp in [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zip1, ExrCompression::Zip16, ExrCompression::Piz] {
|
||||
let opts = EncodeOptions { exr_compression: comp, ..Default::default() };
|
||||
let back = decode(&encode(&img, Format::OpenExr, &opts).unwrap()).unwrap();
|
||||
assert_eq!(back.to_f16_samples().unwrap(), vals, "{comp:?}");
|
||||
}
|
||||
|
|
@ -221,22 +187,10 @@ fn tiff_all_compressions_lossless() {
|
|||
for layout in ChannelLayout::ALL {
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let img = test_image(layout, sample);
|
||||
for comp in [
|
||||
TiffCompression::None,
|
||||
TiffCompression::Lzw,
|
||||
TiffCompression::Deflate,
|
||||
TiffCompression::PackBits,
|
||||
] {
|
||||
let opts = EncodeOptions {
|
||||
tiff_compression: comp,
|
||||
..Default::default()
|
||||
};
|
||||
for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
|
||||
let opts = EncodeOptions { tiff_compression: comp, ..Default::default() };
|
||||
let back = decode(&encode(&img, Format::Tiff, &opts).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
back,
|
||||
img.clone().with_meta(back.meta.clone()),
|
||||
"{layout:?} {sample:?} {comp:?}"
|
||||
);
|
||||
assert_eq!(back, img.clone().with_meta(back.meta.clone()), "{layout:?} {sample:?} {comp:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -245,16 +199,8 @@ fn tiff_all_compressions_lossless() {
|
|||
#[test]
|
||||
fn png_all_compressions_lossless() {
|
||||
let img = test_image(ChannelLayout::Rgba, SampleType::U16);
|
||||
for comp in [
|
||||
PngCompression::None,
|
||||
PngCompression::Fast,
|
||||
PngCompression::Default,
|
||||
PngCompression::Best,
|
||||
] {
|
||||
let opts = EncodeOptions {
|
||||
png_compression: comp,
|
||||
..Default::default()
|
||||
};
|
||||
for comp in [PngCompression::None, PngCompression::Fast, PngCompression::Default, PngCompression::Best] {
|
||||
let opts = EncodeOptions { png_compression: comp, ..Default::default() };
|
||||
let back = decode(&encode(&img, Format::Png, &opts).unwrap()).unwrap();
|
||||
assert_eq!(back.data(), img.data(), "{comp:?}");
|
||||
}
|
||||
|
|
@ -263,24 +209,8 @@ fn png_all_compressions_lossless() {
|
|||
#[test]
|
||||
fn jpeg_quality_affects_size_and_error() {
|
||||
let img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
let lo = encode(
|
||||
&img,
|
||||
Format::Jpeg,
|
||||
&EncodeOptions {
|
||||
jpeg_quality: 20,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let hi = encode(
|
||||
&img,
|
||||
Format::Jpeg,
|
||||
&EncodeOptions {
|
||||
jpeg_quality: 98,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let lo = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 20, ..Default::default() }).unwrap();
|
||||
let hi = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 98, ..Default::default() }).unwrap();
|
||||
assert!(lo.len() < hi.len());
|
||||
let plo = psnr(&img, &decode(&lo).unwrap());
|
||||
let phi = psnr(&img, &decode(&hi).unwrap());
|
||||
|
|
@ -290,48 +220,16 @@ fn jpeg_quality_affects_size_and_error() {
|
|||
#[test]
|
||||
fn jpeg_444_beats_420_on_chroma_detail() {
|
||||
let img = synth(64, 64, ChannelLayout::Rgb, SampleType::U8, 3, 0.5);
|
||||
let a = encode(
|
||||
&img,
|
||||
Format::Jpeg,
|
||||
&EncodeOptions {
|
||||
jpeg_chroma_subsampling: false,
|
||||
jpeg_quality: 95,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let b = encode(
|
||||
&img,
|
||||
Format::Jpeg,
|
||||
&EncodeOptions {
|
||||
jpeg_chroma_subsampling: true,
|
||||
jpeg_quality: 95,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let a = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_chroma_subsampling: false, jpeg_quality: 95, ..Default::default() }).unwrap();
|
||||
let b = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_chroma_subsampling: true, jpeg_quality: 95, ..Default::default() }).unwrap();
|
||||
assert!(psnr(&img, &decode(&a).unwrap()) > psnr(&img, &decode(&b).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_lossy_request_is_honest_error() {
|
||||
let img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
let e = encode(
|
||||
&img,
|
||||
Format::WebP,
|
||||
&EncodeOptions {
|
||||
webp_lossless: false,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
e,
|
||||
CodecError::Unsupported {
|
||||
format: Format::WebP,
|
||||
..
|
||||
}
|
||||
));
|
||||
let e = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, ..Default::default() }).unwrap_err();
|
||||
assert!(matches!(e, CodecError::Unsupported { format: Format::WebP, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -346,10 +244,7 @@ fn one_pixel_images_roundtrip() {
|
|||
#[test]
|
||||
fn ico_rejects_oversize() {
|
||||
let img = Image::new(300, 10, ChannelLayout::Rgba, SampleType::U8);
|
||||
assert!(matches!(
|
||||
encode(&img, Format::Ico, &EncodeOptions::default()),
|
||||
Err(CodecError::Encode { .. })
|
||||
));
|
||||
assert!(matches!(encode(&img, Format::Ico, &EncodeOptions::default()), Err(CodecError::Encode { .. })));
|
||||
let img = Image::new(256, 256, ChannelLayout::Rgba, SampleType::U8);
|
||||
assert!(encode(&img, Format::Ico, &EncodeOptions::default()).is_ok());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,16 +140,11 @@ impl BlendMode {
|
|||
}
|
||||
|
||||
pub fn from_psd_key(key: [u8; 4]) -> Option<BlendMode> {
|
||||
std::iter::once(BlendMode::PassThrough)
|
||||
.chain(Self::LAYER_MODES)
|
||||
.find(|m| m.psd_key() == key)
|
||||
std::iter::once(BlendMode::PassThrough).chain(Self::LAYER_MODES).find(|m| m.psd_key() == key)
|
||||
}
|
||||
|
||||
pub fn is_separable(self) -> bool {
|
||||
!matches!(
|
||||
self,
|
||||
BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity | BlendMode::DarkerColor | BlendMode::LighterColor
|
||||
)
|
||||
!matches!(self, BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity | BlendMode::DarkerColor | BlendMode::LighterColor)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -241,11 +236,7 @@ fn hard_light(cb: f32, cs: f32) -> f32 {
|
|||
/// Photoshop's Soft Light (differs from the W3C/PDF definition in the upper half).
|
||||
#[inline]
|
||||
fn soft_light_ps(cb: f32, cs: f32) -> f32 {
|
||||
if cs <= 0.5 {
|
||||
2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs)
|
||||
} else {
|
||||
2.0 * cb * (1.0 - cs) + cb.max(0.0).sqrt() * (2.0 * cs - 1.0)
|
||||
}
|
||||
if cs <= 0.5 { 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs) } else { 2.0 * cb * (1.0 - cs) + cb.max(0.0).sqrt() * (2.0 * cs - 1.0) }
|
||||
}
|
||||
|
||||
// ---- non-separable helpers (W3C Compositing Level 1) ----
|
||||
|
|
@ -305,10 +296,18 @@ pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
|
|||
BlendMode::Color => set_lum(cs, lum(cb)),
|
||||
BlendMode::Luminosity => set_lum(cb, lum(cs)),
|
||||
BlendMode::DarkerColor => {
|
||||
if lum(cs) < lum(cb) { cs } else { cb }
|
||||
if lum(cs) < lum(cb) {
|
||||
cs
|
||||
} else {
|
||||
cb
|
||||
}
|
||||
}
|
||||
BlendMode::LighterColor => {
|
||||
if lum(cs) > lum(cb) { cs } else { cb }
|
||||
if lum(cs) > lum(cb) {
|
||||
cs
|
||||
} else {
|
||||
cb
|
||||
}
|
||||
}
|
||||
m => [blend_channel(m, cb[0], cs[0]), blend_channel(m, cb[1], cs[1]), blend_channel(m, cb[2], cs[2])],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,16 @@ use photocraft_cms::{Builtin, Intent, Transform};
|
|||
|
||||
fn cmyk_to_srgb_transform() -> &'static Transform {
|
||||
static T: OnceLock<Transform> = OnceLock::new();
|
||||
T.get_or_init(|| Transform::new(Builtin::CoatedCmyk.profile(), Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).expect("built-in profiles link"))
|
||||
T.get_or_init(|| {
|
||||
Transform::new(Builtin::CoatedCmyk.profile(), Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).expect("built-in profiles link")
|
||||
})
|
||||
}
|
||||
|
||||
fn srgb_to_cmyk_transform() -> &'static Transform {
|
||||
static T: OnceLock<Transform> = OnceLock::new();
|
||||
T.get_or_init(|| Transform::new(Builtin::Srgb.profile(), Builtin::CoatedCmyk.profile(), Intent::RelativeColorimetric, true).expect("built-in profiles link"))
|
||||
T.get_or_init(|| {
|
||||
Transform::new(Builtin::Srgb.profile(), Builtin::CoatedCmyk.profile(), Intent::RelativeColorimetric, true).expect("built-in profiles link")
|
||||
})
|
||||
}
|
||||
|
||||
/// Colour-managed CMYK → sRGB (built-in coated CMYK profile, relative colorimetric + BPC).
|
||||
|
|
@ -76,11 +80,7 @@ pub fn lab_to_srgb(lab: [f32; 3]) -> [f32; 3] {
|
|||
let finv = |t: f32| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f32 / 29.0).powi(2) * (t - 4.0 / 29.0) };
|
||||
let xyz = [finv(fx) * D50[0], finv(fy) * D50[1], finv(fz) * D50[2]];
|
||||
// D50 XYZ → linear sRGB (Bradford-adapted matrix)
|
||||
let m = [
|
||||
[3.133_856, -1.616_867, -0.490_615],
|
||||
[-0.978_768, 1.916_142, 0.033_454],
|
||||
[0.071_945, -0.228_991, 1.405_243],
|
||||
];
|
||||
let m = [[3.133_856, -1.616_867, -0.490_615], [-0.978_768, 1.916_142, 0.033_454], [0.071_945, -0.228_991, 1.405_243]];
|
||||
let mut out = [0.0f32; 3];
|
||||
for i in 0..3 {
|
||||
let lin = m[i][0] * xyz[0] + m[i][1] * xyz[1] + m[i][2] * xyz[2];
|
||||
|
|
@ -92,11 +92,7 @@ pub fn lab_to_srgb(lab: [f32; 3]) -> [f32; 3] {
|
|||
/// Encoded sRGB → CIE L*a*b* (D50).
|
||||
pub fn srgb_to_lab(rgb: [f32; 3]) -> [f32; 3] {
|
||||
let lin = [srgb_to_linear(rgb[0]), srgb_to_linear(rgb[1]), srgb_to_linear(rgb[2])];
|
||||
let m = [
|
||||
[0.436_074, 0.385_064, 0.143_080],
|
||||
[0.222_504, 0.716_878, 0.060_618],
|
||||
[0.013_932, 0.097_104, 0.714_173],
|
||||
];
|
||||
let m = [[0.436_074, 0.385_064, 0.143_080], [0.222_504, 0.716_878, 0.060_618], [0.013_932, 0.097_104, 0.714_173]];
|
||||
let mut xyz = [0.0f32; 3];
|
||||
for i in 0..3 {
|
||||
xyz[i] = (m[i][0] * lin[0] + m[i][1] * lin[1] + m[i][2] * lin[2]) / D50[i];
|
||||
|
|
|
|||
|
|
@ -26,9 +26,7 @@ impl Transfer {
|
|||
/// Default transfer for a document colour mode.
|
||||
pub fn for_mode(mode: photocraft_color::ColorMode) -> Self {
|
||||
match mode {
|
||||
photocraft_color::ColorMode::Grayscale | photocraft_color::ColorMode::Duotone | photocraft_color::ColorMode::Bitmap => {
|
||||
Transfer::Gamma(1.732)
|
||||
}
|
||||
photocraft_color::ColorMode::Grayscale | photocraft_color::ColorMode::Duotone | photocraft_color::ColorMode::Bitmap => Transfer::Gamma(1.732),
|
||||
_ => Transfer::Srgb,
|
||||
}
|
||||
}
|
||||
|
|
@ -99,9 +97,8 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
|||
})
|
||||
}
|
||||
Adjustment::Levels { master, per_channel } => {
|
||||
let luts: [Vec<f32>; 3] = std::array::from_fn(|i| {
|
||||
(0..LUT_SIZE).map(|k| levels(&per_channel[i], levels(master, k as f32 / (LUT_SIZE - 1) as f32))).collect()
|
||||
});
|
||||
let luts: [Vec<f32>; 3] =
|
||||
std::array::from_fn(|i| (0..LUT_SIZE).map(|k| levels(&per_channel[i], levels(master, k as f32 / (LUT_SIZE - 1) as f32))).collect());
|
||||
map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i])))
|
||||
}
|
||||
Adjustment::Curves { master, per_channel } => {
|
||||
|
|
@ -145,11 +142,7 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
|||
}
|
||||
Adjustment::ChannelMixer { matrix, monochrome } => map_rgb(buf, |c| {
|
||||
let mix = |row: &[f32; 4]| (row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3]).clamp(0.0, 1.0);
|
||||
if *monochrome {
|
||||
[mix(&matrix[0]); 3]
|
||||
} else {
|
||||
[mix(&matrix[0]), mix(&matrix[1]), mix(&matrix[2])]
|
||||
}
|
||||
if *monochrome { [mix(&matrix[0]); 3] } else { [mix(&matrix[0]), mix(&matrix[1]), mix(&matrix[2])] }
|
||||
}),
|
||||
Adjustment::PhotoFilter { color, density, preserve_luminosity } => map_rgb(buf, |c| {
|
||||
let filtered: [f32; 3] = std::array::from_fn(|i| c[i] * (1.0 - density) + c[i] * color[i] * density);
|
||||
|
|
@ -337,11 +330,7 @@ pub fn modern_brightness(v: f32, brightness: f32) -> f32 {
|
|||
}
|
||||
let s = 1.375f32.powf(brightness / 50.0);
|
||||
// Exponent of the white-anchor term grows with |b|; shapes the roll-off toward (1,1).
|
||||
let p = if brightness >= 0.0 {
|
||||
(4.5 - 0.013 * brightness).max(2.0)
|
||||
} else {
|
||||
5.0 - 0.072 * brightness
|
||||
};
|
||||
let p = if brightness >= 0.0 { (4.5 - 0.013 * brightness).max(2.0) } else { 5.0 - 0.072 * brightness };
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
(s * v + (1.0 - s) * v.powf(p)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,4 +68,3 @@ pub fn content_bounds(s: &Surface) -> Rect {
|
|||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@
|
|||
//! use an exact Euclidean distance transform. Soft falloffs are Gaussian blurs.
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_doc::Pattern;
|
||||
use photocraft_doc::{
|
||||
Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Layer, Shadow,
|
||||
StrokePosition,
|
||||
};
|
||||
use photocraft_doc::Pattern;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::pattern::{Placement, Tile};
|
||||
|
|
@ -271,9 +271,8 @@ fn dist_inside_by(s: &Map, m: Metric) -> Vec<f32> {
|
|||
// Partly covered edge pixels (alpha below their neighbourhood's) are seeds starting at
|
||||
// their coverage, mirroring `dist_outside`: a 25 % edge column puts the edge 0.25 px in.
|
||||
let cov = local_coverage(s);
|
||||
let start = s
|
||||
.v
|
||||
.iter()
|
||||
let start =
|
||||
s.v.iter()
|
||||
.zip(&cov.v)
|
||||
.map(|(&a, &c)| {
|
||||
if a <= INSIDE_EPS {
|
||||
|
|
@ -372,10 +371,14 @@ pub fn ranged_lut(contour: &Contour, range: f32) -> Option<Vec<f32>> {
|
|||
return base;
|
||||
}
|
||||
let n = 4096;
|
||||
Some((0..n).map(|k| {
|
||||
Some(
|
||||
(0..n)
|
||||
.map(|k| {
|
||||
let v = (k as f32 / (n - 1) as f32 / range).min(1.0);
|
||||
base.as_ref().map_or(v, |l| lut_at(l, v))
|
||||
}).collect())
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_lut(m: Map, l: Option<Vec<f32>>) -> Map {
|
||||
|
|
@ -444,11 +447,7 @@ pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, o
|
|||
/// Samples colour and opacity stops at `t`.
|
||||
pub fn sample_gradient(g: &Gradient, t: f32) -> [f32; 4] {
|
||||
let color = sample_stops(&g.stops.iter().map(|(p, c)| (*p, rgb(c))).collect::<Vec<_>>(), t);
|
||||
let alpha = if g.opacity_stops.is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
sample_stops(&g.opacity_stops.iter().map(|(p, a)| (*p, [*a; 3])).collect::<Vec<_>>(), t)[0]
|
||||
};
|
||||
let alpha = if g.opacity_stops.is_empty() { 1.0 } else { sample_stops(&g.opacity_stops.iter().map(|(p, a)| (*p, [*a; 3])).collect::<Vec<_>>(), t)[0] };
|
||||
[color[0], color[1], color[2], alpha]
|
||||
}
|
||||
|
||||
|
|
@ -674,7 +673,8 @@ fn rows_par(src: &[f32], dst: &mut [f32], w: usize, f: impl Fn(&[f32], &mut [f32
|
|||
if w == 0 {
|
||||
return;
|
||||
}
|
||||
let threads = if cfg!(target_arch = "wasm32") || src.len() < 1 << 16 { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16) };
|
||||
let threads =
|
||||
if cfg!(target_arch = "wasm32") || src.len() < 1 << 16 { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16) };
|
||||
if threads == 1 {
|
||||
dst.chunks_mut(w).zip(src.chunks(w)).for_each(|(d, s)| f(s, d));
|
||||
return;
|
||||
|
|
@ -1166,11 +1166,7 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu
|
|||
let d = vdout.as_ref().unwrap_or(&dout);
|
||||
let mut share = vec![0.0f32; w * h];
|
||||
for (i, sh) in share.iter_mut().enumerate() {
|
||||
let k = if inside(shape.v[i]) {
|
||||
if vector_shape { 0.0 } else { 1.0 }
|
||||
} else {
|
||||
(out_w + 0.5 - d[i]).clamp(0.0, 1.0)
|
||||
};
|
||||
let k = if inside(shape.v[i]) { if vector_shape { 0.0 } else { 1.0 } } else { (out_w + 0.5 - d[i]).clamp(0.0, 1.0) };
|
||||
*sh = k * st.common.opacity * (1.0 - cover[i]);
|
||||
cover[i] += *sh;
|
||||
}
|
||||
|
|
@ -1223,11 +1219,7 @@ pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Bu
|
|||
for x in rect.x0..rect.x1 {
|
||||
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
|
||||
let a = before.px[i];
|
||||
let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 {
|
||||
work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize]
|
||||
} else {
|
||||
a
|
||||
};
|
||||
let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] } else { a };
|
||||
backdrop.px[i] = mix_premul(a, b, op);
|
||||
}
|
||||
}
|
||||
|
|
@ -1459,7 +1451,11 @@ mod tests {
|
|||
let (a, _) = bevel_maps(&shape, &plain, &l, &no_tex());
|
||||
// A contour reshapes the profile (here a ramp folded at its middle).
|
||||
let mut c = plain.clone();
|
||||
let fold = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 0.5, output: 1.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.0 }];
|
||||
let fold = vec![
|
||||
photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 },
|
||||
photocraft_doc::adjust::CurvePoint { input: 0.5, output: 1.0 },
|
||||
photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.0 },
|
||||
];
|
||||
c.contour = Some(photocraft_doc::BevelContour { contour: Contour::Custom { name: "fold".into(), points: fold }, range: 1.0, anti_alias: false });
|
||||
let (b, _) = bevel_maps(&shape, &c, &l, &no_tex());
|
||||
let diff: f32 = a[1].v.iter().zip(&b[1].v).map(|(x, y)| (x - y).abs()).sum();
|
||||
|
|
@ -1470,7 +1466,15 @@ mod tests {
|
|||
sf.fill_rect(Rect::new(0, 2, 4, 4), &[1.0, 1.0, 1.0, 1.0]);
|
||||
let pats = [Pattern::new("stripes", sf, 4, 4)];
|
||||
let mut t = plain.clone();
|
||||
t.texture = Some(photocraft_doc::BevelTexture { name: "stripes".into(), id: String::new(), scale: 1.0, depth: 1.0, invert: false, link: true, phase: (0.0, 0.0) });
|
||||
t.texture = Some(photocraft_doc::BevelTexture {
|
||||
name: "stripes".into(),
|
||||
id: String::new(),
|
||||
scale: 1.0,
|
||||
depth: 1.0,
|
||||
invert: false,
|
||||
link: true,
|
||||
phase: (0.0, 0.0),
|
||||
});
|
||||
let tex = TextureCtx { rect: Rect::new(0, 0, 40, 40), patterns: &pats, anchor: (0.0, 0.0) };
|
||||
let (m, _) = bevel_maps(&shape, &t, &l, &tex);
|
||||
let mid: f32 = (16..24).map(|y| m[0].v[y * 40 + 20] + m[1].v[y * 40 + 20]).sum();
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ pub mod psblend;
|
|||
pub mod shape_split;
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use psblend as blend;
|
||||
use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Rgba8Image, Surface};
|
||||
use psblend as blend;
|
||||
|
||||
/// Straight-alpha RGBA float buffer covering a rectangle.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
|
@ -48,7 +48,7 @@ impl Buffer {
|
|||
}
|
||||
pub fn to_rgba8(&self) -> Rgba8Image {
|
||||
let mut img = Rgba8Image::new(self.rect.width(), self.rect.height());
|
||||
for (o, p) in img.pixels.chunks_exact_mut(4).zip(&self.px) {
|
||||
for (o, p) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(&self.px) {
|
||||
for (dst, v) in o.iter_mut().zip(p) {
|
||||
*dst = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
|
|
@ -80,7 +80,14 @@ pub fn render(doc: &Document, rect: Rect) -> Buffer {
|
|||
|
||||
/// [`render`] with an explicit tile size (tests check tile independence).
|
||||
pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer {
|
||||
let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::for_mode(doc.mode), light: doc.global_light, patterns: &doc.patterns, mode: doc.mode, depth: doc.depth };
|
||||
let cx = Ctx {
|
||||
canvas: doc.bounds(),
|
||||
transfer: adjust::Transfer::for_mode(doc.mode),
|
||||
light: doc.global_light,
|
||||
patterns: &doc.patterns,
|
||||
mode: doc.mode,
|
||||
depth: doc.depth,
|
||||
};
|
||||
// Lab documents mix Normal blending in CIELAB, as Photoshop does (psblend::LAB_MIX).
|
||||
let lab = doc.mode == photocraft_color::ColorMode::Lab;
|
||||
if rect.width() as i32 <= tile && rect.height() as i32 <= tile {
|
||||
|
|
@ -134,7 +141,18 @@ pub fn flatten(doc: &Document) -> Buffer {
|
|||
/// Render an arbitrary subset: a single layer (e.g. for thumbnails), isolated.
|
||||
pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
||||
let mut buf = Buffer::transparent(rect);
|
||||
composite_stack(std::slice::from_ref(layer), &mut buf, &Ctx { canvas: rect, transfer: adjust::Transfer::Srgb, light: photocraft_doc::GlobalLight::default(), patterns: &[], mode: photocraft_color::ColorMode::Rgb, depth: photocraft_color::SampleType::F32 });
|
||||
composite_stack(
|
||||
std::slice::from_ref(layer),
|
||||
&mut buf,
|
||||
&Ctx {
|
||||
canvas: rect,
|
||||
transfer: adjust::Transfer::Srgb,
|
||||
light: photocraft_doc::GlobalLight::default(),
|
||||
patterns: &[],
|
||||
mode: photocraft_color::ColorMode::Rgb,
|
||||
depth: photocraft_color::SampleType::F32,
|
||||
},
|
||||
);
|
||||
buf
|
||||
}
|
||||
|
||||
|
|
@ -251,7 +269,13 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
|||
LayerContent::Group(g) if g.artboard.is_some() => g.artboard.as_ref().map_or(Rect::EMPTY, |a| a.rect),
|
||||
LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| {
|
||||
let b = layer_bounds(c, canvas);
|
||||
if b.is_empty() { acc } else if acc.is_empty() { b } else { acc.union(&b) }
|
||||
if b.is_empty() {
|
||||
acc
|
||||
} else if acc.is_empty() {
|
||||
b
|
||||
} else {
|
||||
acc.union(&b)
|
||||
}
|
||||
}),
|
||||
LayerContent::Fill(_) => canvas,
|
||||
_ => layer.surface().map_or(Rect::EMPTY, bounds::content_bounds),
|
||||
|
|
@ -320,7 +344,14 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buffer> {
|
|||
/// The alpha of `layer`'s own content over `rect` (masks applied, row-major): the shape its
|
||||
/// effect maps are built from (for the GPU compositor). Zero for adjustment layers.
|
||||
pub fn layer_shape(doc: &Document, layer: &Layer, rect: Rect) -> Vec<f32> {
|
||||
let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::for_mode(doc.mode), light: doc.global_light, patterns: &doc.patterns, mode: doc.mode, depth: doc.depth };
|
||||
let cx = Ctx {
|
||||
canvas: doc.bounds(),
|
||||
transfer: adjust::Transfer::for_mode(doc.mode),
|
||||
light: doc.global_light,
|
||||
patterns: &doc.patterns,
|
||||
mode: doc.mode,
|
||||
depth: doc.depth,
|
||||
};
|
||||
render_content(layer, rect, &cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; rect.width() as usize * rect.height() as usize])
|
||||
}
|
||||
|
||||
|
|
@ -482,12 +513,7 @@ pub fn blend_if_active(layer: &Layer, mode: photocraft_color::ColorMode) -> bool
|
|||
/// How much of a pixel shows through `layer`'s Blend If ranges, given the layer's own colour
|
||||
/// (`this`, `None` where the layer has no content of its own there) and the colour beneath it
|
||||
/// (`under`, `None` where nothing is beneath). Every range multiplies in.
|
||||
fn blend_if_weight(
|
||||
layer: &Layer,
|
||||
mode: photocraft_color::ColorMode,
|
||||
this: Option<[f32; 4]>,
|
||||
under: Option<[f32; 4]>,
|
||||
) -> f32 {
|
||||
fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Option<[f32; 4]>, under: Option<[f32; 4]>) -> f32 {
|
||||
use photocraft_color::ColorMode as M;
|
||||
let bi = &layer.blend_if;
|
||||
let mut k = 1.0;
|
||||
|
|
@ -521,11 +547,7 @@ fn blend_if_weight(
|
|||
fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) {
|
||||
// "This Layer" is the layer's own colour; adjustment layers (no content of their own) are
|
||||
// judged by their result.
|
||||
let own = if matches!(layer.content, LayerContent::Adjustment(_)) {
|
||||
None
|
||||
} else {
|
||||
render_content(layer, out.rect, cx)
|
||||
};
|
||||
let own = if matches!(layer.content, LayerContent::Adjustment(_)) { None } else { render_content(layer, out.rect, cx) };
|
||||
for (i, (p, b)) in out.px.iter_mut().zip(&before.px).enumerate() {
|
||||
let this = match &own {
|
||||
Some(o) => Some(o.px[i]).filter(|q| q[3] > 0.0),
|
||||
|
|
@ -538,8 +560,7 @@ fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) {
|
|||
}
|
||||
let a = b[3] + (p[3] - b[3]) * k;
|
||||
*p = if a > 0.0 {
|
||||
let c =
|
||||
|c: usize| ((b[c] * b[3] + (p[c] * p[3] - b[c] * b[3]) * k) / a).clamp(0.0, 1.0);
|
||||
let c = |c: usize| ((b[c] * b[3] + (p[c] * p[3] - b[c] * b[3]) * k) / a).clamp(0.0, 1.0);
|
||||
[c(0), c(1), c(2), a]
|
||||
} else {
|
||||
[0.0; 4]
|
||||
|
|
@ -674,12 +695,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
|||
let b = before.px[i];
|
||||
let a = adjusted.px[i];
|
||||
let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]);
|
||||
backdrop.px[i] = [
|
||||
b[0] + (blended[0] - b[0]) * k,
|
||||
b[1] + (blended[1] - b[1]) * k,
|
||||
b[2] + (blended[2] - b[2]) * k,
|
||||
b[3],
|
||||
];
|
||||
backdrop.px[i] = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]];
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
|
@ -859,7 +875,9 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
|||
let shape: Vec<f32> = if region.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
render_content(layer, region, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; region.width() as usize * region.height() as usize])
|
||||
render_content(layer, region, cx)
|
||||
.map(|b| b.px.iter().map(|p| p[3]).collect())
|
||||
.unwrap_or_else(|| vec![0.0; region.width() as usize * region.height() as usize])
|
||||
};
|
||||
let maps = effects::build_maps(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx));
|
||||
let bytes = maps.bytes();
|
||||
|
|
@ -929,7 +947,14 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
|||
let Some(content) = render_content(layer, big, cx) else { return };
|
||||
let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
||||
let maps = effect_maps(layer, cx);
|
||||
effects::composite_with_effects(layer, &content, &mut opaque, &maps, paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)), cx.patterns);
|
||||
effects::composite_with_effects(
|
||||
layer,
|
||||
&content,
|
||||
&mut opaque,
|
||||
&maps,
|
||||
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
|
||||
cx.patterns,
|
||||
);
|
||||
for (p, o) in base.px.iter_mut().zip(&opaque.px) {
|
||||
if p[3] > 0.0 {
|
||||
*p = [o[0], o[1], o[2], p[3]];
|
||||
|
|
|
|||
|
|
@ -76,7 +76,13 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
|||
&& v_out > 0.0
|
||||
{
|
||||
let b = m.surface.content_bounds();
|
||||
area = if area.is_empty() { b } else if b.is_empty() { area } else { area.union(&b) };
|
||||
area = if area.is_empty() {
|
||||
b
|
||||
} else if b.is_empty() {
|
||||
area
|
||||
} else {
|
||||
area.union(&b)
|
||||
};
|
||||
}
|
||||
let area = area.intersect(&canvas);
|
||||
let mut s = Surface::with_default(FORMAT, &[p_def * v_out]);
|
||||
|
|
|
|||
|
|
@ -114,12 +114,7 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity:
|
|||
}
|
||||
let kb = ab * (1.0 - as_);
|
||||
let inv = 1.0 / ao;
|
||||
return [
|
||||
(kb * backdrop[0] + as_ * source[0]) * inv,
|
||||
(kb * backdrop[1] + as_ * source[1]) * inv,
|
||||
(kb * backdrop[2] + as_ * source[2]) * inv,
|
||||
ao,
|
||||
];
|
||||
return [(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao];
|
||||
}
|
||||
let cb = [backdrop[0], backdrop[1], backdrop[2]];
|
||||
let cs = [source[0], source[1], source[2]];
|
||||
|
|
@ -191,7 +186,10 @@ mod tests {
|
|||
// 69 % blue (0, 51, 153) over grey 129: Photoshop shows (57, 80, 146).
|
||||
let r = composite_gamma(BlendMode::Normal, [129.0 / 255.0, 129.0 / 255.0, 129.0 / 255.0, 1.0], [0.0, 0.2, 0.6, 0.686], 1.0, TEXT_GAMMA);
|
||||
assert!((r[0] * 255.0 - 57.0).abs() < 2.0 && (r[1] * 255.0 - 80.0).abs() < 2.0 && (r[2] * 255.0 - 146.0).abs() < 2.0, "{r:?}");
|
||||
assert_eq!(composite_gamma(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8, 1.0), composite(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8));
|
||||
assert_eq!(
|
||||
composite_gamma(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8, 1.0),
|
||||
composite(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8)
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
|
@ -228,7 +226,8 @@ mod tests {
|
|||
}
|
||||
let (cb, cs) = ([0.2, 0.5, 0.9], [0.7, 0.1, 0.4]);
|
||||
assert_eq!(blend_rgb(m, cb, cs), generic::blend_rgb(m, cb, cs));
|
||||
let (a, b) = (composite(m, [0.2, 0.5, 0.9, 0.7], [0.7, 0.1, 0.4, 0.6], 0.8), generic::composite(m, [0.2, 0.5, 0.9, 0.7], [0.7, 0.1, 0.4, 0.6], 0.8));
|
||||
let (a, b) =
|
||||
(composite(m, [0.2, 0.5, 0.9, 0.7], [0.7, 0.1, 0.4, 0.6], 0.8), generic::composite(m, [0.2, 0.5, 0.9, 0.7], [0.7, 0.1, 0.4, 0.6], 0.8));
|
||||
for c in 0..4 {
|
||||
assert!((a[c] - b[c]).abs() < 1e-6, "{m:?} {a:?} {b:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,7 +260,13 @@ fn solid_and_gradient_fill_layers() {
|
|||
d.layers.push(Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgb(0.0, 0.0, 1.0)))));
|
||||
assert!(close4(px(&d, 5, 0), [0.0, 0.0, 1.0, 1.0]));
|
||||
|
||||
let g = Fill::Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, scale: 1.0, style: photocraft_doc::GradientStyle::Linear, reverse: false };
|
||||
let g = Fill::Gradient {
|
||||
stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)],
|
||||
angle: 0.0,
|
||||
scale: 1.0,
|
||||
style: photocraft_doc::GradientStyle::Linear,
|
||||
reverse: false,
|
||||
};
|
||||
let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &[]);
|
||||
// tile independence: a 1px render of the right edge equals the full render
|
||||
let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1), &[]);
|
||||
|
|
@ -353,7 +359,12 @@ fn fx_doc(effects: Vec<Effect>) -> Document {
|
|||
}
|
||||
|
||||
fn stroke(size: f32, position: StrokePosition) -> Effect {
|
||||
Effect::Stroke(StrokeFx { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), size, position, paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)) })
|
||||
Effect::Stroke(StrokeFx {
|
||||
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||||
size,
|
||||
position,
|
||||
paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -516,7 +527,9 @@ fn satin_and_bevel_stay_inside_shape() {
|
|||
anti_alias: true,
|
||||
invert: false,
|
||||
});
|
||||
let d = fx_doc(vec![satin, Effect::BevelEmboss(photocraft_doc::Bevel {
|
||||
let d = fx_doc(vec![
|
||||
satin,
|
||||
Effect::BevelEmboss(photocraft_doc::Bevel {
|
||||
enabled: true,
|
||||
style: photocraft_doc::BevelStyle::InnerBevel,
|
||||
technique: photocraft_doc::BevelTechnique::Smooth,
|
||||
|
|
@ -534,7 +547,8 @@ fn satin_and_bevel_stay_inside_shape() {
|
|||
shadow_color: Color::BLACK,
|
||||
contour: None,
|
||||
texture: None,
|
||||
})]);
|
||||
}),
|
||||
]);
|
||||
for (x, y) in [(5, 5), (35, 20), (20, 35)] {
|
||||
assert!(close4(px(&d, x, y), [1.0; 4]), "({x},{y}) {:?}", px(&d, x, y));
|
||||
}
|
||||
|
|
@ -558,7 +572,10 @@ fn clipped_layer_effects_are_clipped_to_base() {
|
|||
#[test]
|
||||
fn group_effects_apply_to_group_shape() {
|
||||
let mut d = doc_white(40, 40);
|
||||
let mut g = Layer::group("g", vec![solid_layer("a", Rect::new(10, 10, 20, 30), [1.0, 0.0, 0.0, 1.0]), solid_layer("b", Rect::new(20, 10, 30, 30), [1.0, 0.0, 0.0, 1.0])]);
|
||||
let mut g = Layer::group(
|
||||
"g",
|
||||
vec![solid_layer("a", Rect::new(10, 10, 20, 30), [1.0, 0.0, 0.0, 1.0]), solid_layer("b", Rect::new(20, 10, 30, 30), [1.0, 0.0, 0.0, 1.0])],
|
||||
);
|
||||
g.effects.items.push(stroke(2.0, StrokePosition::Outside));
|
||||
d.layers.push(g);
|
||||
assert!(close4(px(&d, 9, 20), [0.0, 0.0, 1.0, 1.0]));
|
||||
|
|
@ -787,28 +804,16 @@ const FULL: photocraft_doc::BlendRange = photocraft_doc::BlendRange::FULL;
|
|||
fn blend_if_this_layer_hides_by_the_layers_own_value() {
|
||||
// Mid-grey backdrop; the layer is black on the left, white on the right.
|
||||
let mut d = doc_white(8, 8);
|
||||
d.layers.push(solid_layer(
|
||||
"grey",
|
||||
Rect::new(0, 0, 8, 8),
|
||||
[0.5, 0.5, 0.5, 1.0],
|
||||
));
|
||||
d.layers.push(solid_layer("grey", Rect::new(0, 0, 8, 8), [0.5, 0.5, 0.5, 1.0]));
|
||||
let mut l = solid_layer("bw", Rect::new(0, 0, 4, 8), [0.0, 0.0, 0.0, 1.0]);
|
||||
l.surface_mut()
|
||||
.unwrap()
|
||||
.fill_rect(Rect::new(4, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]);
|
||||
// Gray › This Layer: black point at 50 hides the blacks.
|
||||
l.blend_if.set(0, [range([50, 50], [255, 255]), FULL]);
|
||||
d.layers.push(l);
|
||||
assert!(
|
||||
close4(px(&d, 1, 1), [0.5, 0.5, 0.5, 1.0]),
|
||||
"{:?}",
|
||||
px(&d, 1, 1)
|
||||
);
|
||||
assert!(close4(px(&d, 1, 1), [0.5, 0.5, 0.5, 1.0]), "{:?}", px(&d, 1, 1));
|
||||
assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0]));
|
||||
// White point at 200 hides the whites as well.
|
||||
d.layers[2]
|
||||
.blend_if
|
||||
.set(0, [range([50, 50], [200, 200]), FULL]);
|
||||
d.layers[2].blend_if.set(0, [range([50, 50], [200, 200]), FULL]);
|
||||
assert!(close4(px(&d, 6, 1), [0.5, 0.5, 0.5, 1.0]));
|
||||
// Back to the defaults: everything shows again.
|
||||
d.layers[2].blend_if.set(0, [FULL, FULL]);
|
||||
|
|
@ -820,11 +825,7 @@ fn blend_if_this_layer_hides_by_the_layers_own_value() {
|
|||
fn blend_if_underlying_layer_hides_by_the_backdrop_value() {
|
||||
// Backdrop black on the left, white on the right; a red layer over all of it.
|
||||
let mut d = doc_white(8, 8);
|
||||
d.layers.push(solid_layer(
|
||||
"black",
|
||||
Rect::new(0, 0, 4, 8),
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
));
|
||||
d.layers.push(solid_layer("black", Rect::new(0, 0, 4, 8), [0.0, 0.0, 0.0, 1.0]));
|
||||
let mut l = solid_layer("red", Rect::new(0, 0, 8, 8), [1.0, 0.0, 0.0, 1.0]);
|
||||
// Gray › Underlying Layer: white point at 128 = only over the darks (sky-replacement style).
|
||||
l.blend_if.set(0, [FULL, range([0, 0], [128, 128])]);
|
||||
|
|
@ -839,10 +840,7 @@ fn blend_if_split_point_fades_like_opacity() {
|
|||
// is exactly the layer at 50% opacity, also over a semi-transparent backdrop.
|
||||
let v = 100.0 / 255.0;
|
||||
let mut d = doc_white(4, 4);
|
||||
d.layers[0]
|
||||
.surface_mut()
|
||||
.unwrap()
|
||||
.fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 0.2, 1.0, 0.5]);
|
||||
d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 0.2, 1.0, 0.5]);
|
||||
let mut l = solid_layer("v", Rect::new(0, 0, 4, 4), [v, v, v, 1.0]);
|
||||
l.blend = BlendMode::Multiply;
|
||||
let mut half = l.clone();
|
||||
|
|
@ -852,10 +850,7 @@ fn blend_if_split_point_fades_like_opacity() {
|
|||
d.layers.push(l);
|
||||
d2.layers.push(half);
|
||||
let (a, b) = (px(&d, 1, 1), px(&d2, 1, 1));
|
||||
assert!(
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4),
|
||||
"{a:?} vs {b:?}"
|
||||
);
|
||||
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), "{a:?} vs {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -863,29 +858,21 @@ fn blend_if_per_channel_ranges() {
|
|||
// Blue › This Layer: hide pixels whose blue is above 100.
|
||||
let mut d = doc_white(8, 8);
|
||||
let mut l = solid_layer("c", Rect::new(0, 0, 4, 8), [1.0, 0.0, 0.0, 1.0]);
|
||||
l.surface_mut()
|
||||
.unwrap()
|
||||
.fill_rect(Rect::new(4, 0, 8, 8), &[0.0, 0.0, 1.0, 1.0]);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 8, 8), &[0.0, 0.0, 1.0, 1.0]);
|
||||
l.blend_if.set(3, [range([0, 0], [100, 100]), FULL]);
|
||||
d.layers.push(l);
|
||||
assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0]));
|
||||
assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0]));
|
||||
// Red › Underlying Layer: the backdrop's red (255) is above 254 → hidden everywhere.
|
||||
d.layers[1].blend_if = Default::default();
|
||||
d.layers[1]
|
||||
.blend_if
|
||||
.set(1, [FULL, range([0, 0], [254, 254])]);
|
||||
d.layers[1].blend_if.set(1, [FULL, range([0, 0], [254, 254])]);
|
||||
assert!(close4(px(&d, 1, 1), [1.0; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_if_on_adjustment_and_clipped_layers() {
|
||||
let mut d = doc_white(8, 8);
|
||||
d.layers.push(solid_layer(
|
||||
"dark",
|
||||
Rect::new(0, 0, 4, 8),
|
||||
[0.1, 0.1, 0.1, 1.0],
|
||||
));
|
||||
d.layers.push(solid_layer("dark", Rect::new(0, 0, 4, 8), [0.1, 0.1, 0.1, 1.0]));
|
||||
// Invert, but only where the backdrop is dark (Underlying white point at 128).
|
||||
let mut inv = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
|
||||
inv.blend_if.set(0, [FULL, range([0, 0], [128, 128])]);
|
||||
|
|
@ -895,9 +882,7 @@ fn blend_if_on_adjustment_and_clipped_layers() {
|
|||
// A clipped layer judges "underlying" by its clipping base.
|
||||
let mut d = doc_white(8, 8);
|
||||
let mut base = solid_layer("base", Rect::new(0, 0, 8, 8), [0.0, 0.0, 0.0, 1.0]);
|
||||
base.surface_mut()
|
||||
.unwrap()
|
||||
.fill_rect(Rect::new(4, 0, 8, 8), &[0.9, 0.9, 0.9, 1.0]);
|
||||
base.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 8, 8), &[0.9, 0.9, 0.9, 1.0]);
|
||||
let mut clip = solid_layer("clip", Rect::new(0, 0, 8, 8), [0.0, 1.0, 0.0, 1.0]);
|
||||
clip.clipped = true;
|
||||
clip.blend_if.set(0, [FULL, range([128, 128], [255, 255])]);
|
||||
|
|
@ -910,17 +895,9 @@ fn blend_if_on_adjustment_and_clipped_layers() {
|
|||
#[test]
|
||||
fn blend_if_modes() {
|
||||
// Grayscale documents: the single channel's entry (or the gray entry) applies.
|
||||
let mut d = Document::with_background(
|
||||
"g",
|
||||
Size::new(4, 4),
|
||||
ColorMode::Grayscale,
|
||||
SampleType::U8,
|
||||
Color::WHITE,
|
||||
);
|
||||
let mut d = Document::with_background("g", Size::new(4, 4), ColorMode::Grayscale, SampleType::U8, Color::WHITE);
|
||||
let mut l = Layer::raster("k", d.pixel_format());
|
||||
l.surface_mut()
|
||||
.unwrap()
|
||||
.fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 1.0]);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 1.0]);
|
||||
l.blend_if.set(1, [range([10, 10], [255, 255]), FULL]);
|
||||
d.layers.push(l);
|
||||
assert!(close4(px(&d, 1, 1), [1.0; 4]), "{:?}", px(&d, 1, 1));
|
||||
|
|
@ -984,10 +961,7 @@ fn modern_contrast_matches_photoshop() {
|
|||
|
||||
#[test]
|
||||
fn modern_brightness_matches_photoshop() {
|
||||
let cases: [(f32, [u8; 10]); 2] = [
|
||||
(50.0, [0, 22, 44, 88, 132, 171, 203, 228, 246, 255]),
|
||||
(-50.0, [0, 12, 23, 47, 70, 93, 118, 148, 186, 255]),
|
||||
];
|
||||
let cases: [(f32, [u8; 10]); 2] = [(50.0, [0, 22, 44, 88, 132, 171, 203, 228, 246, 255]), (-50.0, [0, 12, 23, 47, 70, 93, 118, 148, 186, 255])];
|
||||
let xs = [0usize, 16, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
for (b, out) in cases {
|
||||
for (i, &x) in xs.iter().enumerate() {
|
||||
|
|
|
|||
|
|
@ -27,18 +27,55 @@ impl Default for LevelsChannel {
|
|||
/// Photoshop adjustment layers (Layer → New Adjustment Layer).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Adjustment {
|
||||
BrightnessContrast { brightness: f32, contrast: f32, legacy: bool },
|
||||
BrightnessContrast {
|
||||
brightness: f32,
|
||||
contrast: f32,
|
||||
legacy: bool,
|
||||
},
|
||||
/// Composite channel first, then R, G, B.
|
||||
Levels { master: LevelsChannel, per_channel: [LevelsChannel; 3] },
|
||||
Levels {
|
||||
master: LevelsChannel,
|
||||
per_channel: [LevelsChannel; 3],
|
||||
},
|
||||
/// Master curve then R, G, B curves.
|
||||
Curves { master: Vec<CurvePoint>, per_channel: [Vec<CurvePoint>; 3] },
|
||||
Exposure { exposure: f32, offset: f32, gamma: f32 },
|
||||
Vibrance { vibrance: f32, saturation: f32 },
|
||||
HueSaturation { hue: f32, saturation: f32, lightness: f32, colorize: bool },
|
||||
ColorBalance { shadows: [f32; 3], midtones: [f32; 3], highlights: [f32; 3], preserve_luminosity: bool },
|
||||
BlackWhite { weights: [f32; 6], tint: Option<[f32; 3]> },
|
||||
PhotoFilter { color: [f32; 3], density: f32, preserve_luminosity: bool },
|
||||
ChannelMixer { matrix: [[f32; 4]; 3], monochrome: bool },
|
||||
Curves {
|
||||
master: Vec<CurvePoint>,
|
||||
per_channel: [Vec<CurvePoint>; 3],
|
||||
},
|
||||
Exposure {
|
||||
exposure: f32,
|
||||
offset: f32,
|
||||
gamma: f32,
|
||||
},
|
||||
Vibrance {
|
||||
vibrance: f32,
|
||||
saturation: f32,
|
||||
},
|
||||
HueSaturation {
|
||||
hue: f32,
|
||||
saturation: f32,
|
||||
lightness: f32,
|
||||
colorize: bool,
|
||||
},
|
||||
ColorBalance {
|
||||
shadows: [f32; 3],
|
||||
midtones: [f32; 3],
|
||||
highlights: [f32; 3],
|
||||
preserve_luminosity: bool,
|
||||
},
|
||||
BlackWhite {
|
||||
weights: [f32; 6],
|
||||
tint: Option<[f32; 3]>,
|
||||
},
|
||||
PhotoFilter {
|
||||
color: [f32; 3],
|
||||
density: f32,
|
||||
preserve_luminosity: bool,
|
||||
},
|
||||
ChannelMixer {
|
||||
matrix: [[f32; 4]; 3],
|
||||
monochrome: bool,
|
||||
},
|
||||
/// A 3D LUT: `size`³ RGB triplets, red varying fastest (`((b·size + g)·size + r)·3`), in 0..=1.
|
||||
/// `lut` is None for a lookup Photoshop stores as an ICC profile (identity here).
|
||||
ColorLookup {
|
||||
|
|
@ -53,14 +90,27 @@ pub enum Adjustment {
|
|||
dither: bool,
|
||||
},
|
||||
Invert,
|
||||
Posterize { levels: u32 },
|
||||
Threshold { level: f32 },
|
||||
GradientMap { stops: Vec<(f32, [f32; 3])>, reverse: bool },
|
||||
Posterize {
|
||||
levels: u32,
|
||||
},
|
||||
Threshold {
|
||||
level: f32,
|
||||
},
|
||||
GradientMap {
|
||||
stops: Vec<(f32, [f32; 3])>,
|
||||
reverse: bool,
|
||||
},
|
||||
/// Per range (reds, yellows, greens, cyans, blues, magentas, whites, neutrals, blacks) the
|
||||
/// cyan, magenta, yellow and black change in percent (-100..=100), as in Photoshop.
|
||||
SelectiveColor { relative: bool, adjustments: [[f32; 4]; 9] },
|
||||
SelectiveColor {
|
||||
relative: bool,
|
||||
adjustments: [[f32; 4]; 9],
|
||||
},
|
||||
/// A PSD adjustment we can't evaluate yet; preserved raw for round-trip.
|
||||
Unsupported { psd_key: String, raw: Vec<u8> },
|
||||
Unsupported {
|
||||
psd_key: String,
|
||||
raw: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Adjustment {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,15 @@ pub struct Note {
|
|||
impl Default for Note {
|
||||
fn default() -> Self {
|
||||
// Photoshop's default note colour (pale yellow).
|
||||
Self { author: String::new(), color: Color::rgb(1.0, 1.0, 0.51), text: String::new(), position: [0.0, 0.0], popup: [0.0, 0.0, 0.0, 0.0], open: false, modified: String::new() }
|
||||
Self {
|
||||
author: String::new(),
|
||||
color: Color::rgb(1.0, 1.0, 0.51),
|
||||
text: String::new(),
|
||||
position: [0.0, 0.0],
|
||||
popup: [0.0, 0.0, 0.0, 0.0],
|
||||
open: false,
|
||||
modified: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,10 +23,7 @@ impl Default for BlendRange {
|
|||
|
||||
impl BlendRange {
|
||||
/// Every value blends (the sliders at the ends).
|
||||
pub const FULL: BlendRange = BlendRange {
|
||||
black: [0, 0],
|
||||
white: [255, 255],
|
||||
};
|
||||
pub const FULL: BlendRange = BlendRange { black: [0, 0], white: [255, 255] };
|
||||
|
||||
/// Whether this range hides nothing.
|
||||
pub fn is_full(&self) -> bool {
|
||||
|
|
@ -35,10 +32,7 @@ impl BlendRange {
|
|||
|
||||
/// From PSD's byte order: black low, black high, white low, white high.
|
||||
pub fn from_bytes(b: [u8; 4]) -> Self {
|
||||
BlendRange {
|
||||
black: [b[0], b[1]],
|
||||
white: [b[2], b[3]],
|
||||
}
|
||||
BlendRange { black: [b[0], b[1]], white: [b[2], b[3]] }
|
||||
}
|
||||
|
||||
/// PSD's byte order (see [`BlendRange::from_bytes`]).
|
||||
|
|
@ -54,11 +48,7 @@ impl BlendRange {
|
|||
let ramp = |lo: u8, hi: u8, x: f32| -> f32 {
|
||||
// 0 at or below `lo`, 1 at or above `hi`.
|
||||
let (lo, hi) = (f32::from(lo), f32::from(hi));
|
||||
if hi <= lo {
|
||||
if x >= lo { 1.0 } else { 0.0 }
|
||||
} else {
|
||||
((x - lo) / (hi - lo)).clamp(0.0, 1.0)
|
||||
}
|
||||
if hi <= lo { if x >= lo { 1.0 } else { 0.0 } } else { ((x - lo) / (hi - lo)).clamp(0.0, 1.0) }
|
||||
};
|
||||
let lo = ramp(self.black[0], self.black[1], v);
|
||||
// Mirror for the white point: 1 at or below its low half, 0 above its high half.
|
||||
|
|
@ -93,11 +83,7 @@ impl BlendIf {
|
|||
self.ranges.resize(i + 1, [BlendRange::FULL; 2]);
|
||||
}
|
||||
self.ranges[i] = pair;
|
||||
while self
|
||||
.ranges
|
||||
.last()
|
||||
.is_some_and(|p| p.iter().all(BlendRange::is_full))
|
||||
{
|
||||
while self.ranges.last().is_some_and(|p| p.iter().all(BlendRange::is_full)) {
|
||||
self.ranges.pop();
|
||||
}
|
||||
}
|
||||
|
|
@ -118,10 +104,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn unsplit_points_are_hard_thresholds() {
|
||||
let r = BlendRange {
|
||||
black: [50, 50],
|
||||
white: [200, 200],
|
||||
};
|
||||
let r = BlendRange { black: [50, 50], white: [200, 200] };
|
||||
assert_eq!(r.weight(49.0), 0.0);
|
||||
assert_eq!(r.weight(49.9), 0.0);
|
||||
assert_eq!(r.weight(50.0), 1.0);
|
||||
|
|
@ -132,10 +115,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn split_points_ramp_linearly() {
|
||||
let r = BlendRange {
|
||||
black: [0, 100],
|
||||
white: [155, 255],
|
||||
};
|
||||
let r = BlendRange { black: [0, 100], white: [155, 255] };
|
||||
// A split black point fades from its low half (hidden) to its high half (shown).
|
||||
assert_eq!(r.weight(0.0), 0.0);
|
||||
assert!((r.weight(50.0) - 0.5).abs() < 1e-6);
|
||||
|
|
@ -148,10 +128,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn bytes_round_trip_in_psd_order() {
|
||||
let r = BlendRange {
|
||||
black: [10, 20],
|
||||
white: [230, 240],
|
||||
};
|
||||
let r = BlendRange { black: [10, 20], white: [230, 240] };
|
||||
assert_eq!(r.to_bytes(), [10, 20, 230, 240]);
|
||||
assert_eq!(BlendRange::from_bytes(r.to_bytes()), r);
|
||||
}
|
||||
|
|
@ -160,10 +137,7 @@ mod tests {
|
|||
fn set_trims_trailing_full_entries() {
|
||||
let mut b = BlendIf::default();
|
||||
assert!(b.is_default());
|
||||
let r = BlendRange {
|
||||
black: [30, 30],
|
||||
white: [255, 255],
|
||||
};
|
||||
let r = BlendRange { black: [30, 30], white: [255, 255] };
|
||||
b.set(2, [BlendRange::FULL, r]);
|
||||
assert_eq!(b.ranges.len(), 3);
|
||||
assert_eq!(b.get(2), [BlendRange::FULL, r]);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue